Case Study
ParkYourVehicle
A production-grade parking discovery and booking platform with a customer storefront, a Captain operator dashboard, Redis-distributed slot locking, Razorpay payments, real-time Socket.IO updates, OneSignal push notifications, and a Dockerized AWS EC2 CI/CD deployment pipeline.
Production AWS Deployment
ParkYourVehicle is deployed on 2× AWS EC2 instances behind an Application Load Balancer. GitHub Actions builds and pushes Docker images to AWS ECR on every `prod` branch push, then deploys via AWS SSM `RunShellScript` commands to both instances with container health checks before declaring success.
Core Feature Overview
🗺️
Geo-Based Discovery
MongoDB 2dsphere GeoJSON index powers proximity-based parking space queries. Drivers search for nearby open spots filtered by vehicle type, availability, and pricing slab.
Complete Project Structure
root
park-your-vehicle-prod/
backend/
config/
db.js— MongoDB Atlas connection
redis.js— Redis client with reconnect handling
razorpay.js— Razorpay instance initialization
exotel.js— Exotel call masking config
timings.js— HOLD_WINDOW_MS, PAYMENT_WINDOW_MS constants
envValidation.js— Joi-based env var validation on startup
controllers/
booking.js— Core booking lifecycle (hold → confirm → complete)
auth.js— User + Captain auth, JWT + Firebase
captain.js— Space listing, slot management, earnings
captain.controller.js— Captain profile and operator CRUD
dashboard.js— Admin analytics and KPI dashboard
coupon.controller.js— Promo code validation and application
banner.controller.js— Home screen banner management
bbps.controller.js— BBPS bill payment integration
models/
ParkingSpace.js— GeoJSON location, capacity, pricing slabs, overrides
Booking.js— Booking with refund schema, pass types, vehicle info
BookedSlot.js— Time-slotted availability tracking per space
Reservation.js— Redis-lock companion for pending holds
User.js— Customer profile with device tokens, wallet
Rating.js— Driver → Space and Space → Driver ratings
PaymentRecord.js— Razorpay transaction metadata
PayoutSettlement.js— Captain earnings and payout ledger
AuditLog.js— Immutable action audit trail
services/
lockService.js— Redis NX lock + Lua-script safe release
inventoryService.js— getCapacity() — real-time spot computation
availabilityService.js— getAvailability() — single source of truth for slots
NotificationService.js— OneSignal + Firebase push dispatch
modules/
bookingchat/— In-app driver ↔ captain chat system
worker/— Background job workers (slot expiry, cleanup)
cronjob.js— Scheduled notification + booking expiry jobs
index.js— Express + Socket.IO server entrypoint
Dockerfile— Multi-stage Node.js production image
frontend/— Customer React+Vite storefront
captain/— Operator React+Vite dashboard
docker-compose.yml— Local dev orchestrator
docker-compose.prod.yml— Production EC2 compose config
.github/workflows/
deploy.yml— ECR push + AWS SSM deployment
deploy-dev.yml— SSH VPS staging deployment
pr-check.yml— Pull request lint and type check
Booking Lifecycle Flow
1
Geo Proximity Search
The driver sends coordinates to the parking discovery endpoint. MongoDB's `$near` operator with a `2dsphere` index queries `ParkingSpace` documents within the requested radius, sorted by distance, filtered by vehicle type availability and `isOnline: true`.
2
Slot Hold with Redis Lock
On booking initiation, `withLock()` acquires a Redis NX lock with `PX` expiry (milliseconds). If acquired, `getAvailability()` validates the time window against `BookedSlot` records. The slot is marked `held` in the `Reservation` model for the `HOLD_WINDOW_MS` payment window.
3
Pricing Calculation
`calculatePrice()` resolves active pricing — checking `pricingOverrides` for date-range or day-of-week matches first, falling back to base pricing. Hourly pricing uses slab bands: each slab defines `fromHour`, `toHour`, and either a `fixed` or `increment` type rate.
4
Razorpay Order Creation
The server creates a Razorpay order with the computed amount. The `Reservation` is linked to the Razorpay `orderId` for webhook correlation.
5
Payment Webhook Verification
Razorpay POSTs to the backend webhook endpoint. The server verifies `HMAC-SHA256(razorpayOrderId|razorpayPaymentId)` against the received signature. On success, the `Booking` is confirmed, the `BookedSlot` is permanently written, and `availableSpots` is decremented on `ParkingSpace`.
6
Push Notification Dispatch
`NotificationService` dispatches OneSignal and Firebase push notifications to the driver's registered device tokens. The space captain receives a new booking alert via Socket.IO to their connected captain session.
Redis Distributed Slot Locking
javascript
// backend/services/lockService.js
import { getRedisClient } from '../config/redis.js';
export const acquireLock = async (key, ttlMs = 10000) => {
const client = getRedisClient();
if (!client) {
// Graceful fallback if Redis is unavailable — rely on MongoDB optimistic locking
return { acquired: true, release: async () => {} };
}
const value = Date.now().toString();
// NX = only set if key does NOT exist; PX = TTL in milliseconds
const result = await client.set(key, value, 'NX', 'PX', ttlMs);
if (result === 'OK') {
return {
acquired: true,
release: async () => {
// Lua script: only delete if the stored value matches ours
// Prevents another request from releasing a lock it didn't acquire
const luaScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await client.eval(luaScript, 1, key, value);
}
};
}
return { acquired: false, release: async () => {} };
};
export const withLock = async (key, ttlMs, operation) => {
let attempts = 0;
const maxAttempts = 3;
const backoffMs = 500;
while (attempts < maxAttempts) {
const lock = await acquireLock(key, ttlMs);
if (lock.acquired) {
try {
return await operation(); // Execute booking logic inside lock
} finally {
await lock.release(); // Always release, even on error
}
}
attempts++;
if (attempts < maxAttempts)
await new Promise(r => setTimeout(r, backoffMs)); // Exponential backoff
}
throw new Error('Server busy, please try again (Failed to acquire lock)');
};Dynamic Pricing Slab Engine
javascript
// backend/controllers/booking.js
const calculatePrice = ({ parking, vehicleType, durationType, startTime, endTime }) => {
const basePricing = parking.pricing || {};
let activePricing = { ...basePricing };
const overrides = parking.pricingOverrides || [];
const bookingStart = new Date(startTime);
const startDay = bookingStart.getDay(); // 0 = Sunday
// Check for date-range or day-of-week pricing overrides
for (const override of overrides) {
let matched = false;
if (override.type === 'day_of_week' && override.daysOfWeek?.includes(startDay)) matched = true;
if (override.type === 'date_range' && override.startDateTime && override.endDateTime) {
const from = new Date(override.startDateTime);
const to = new Date(override.endDateTime);
if (bookingStart >= from && bookingStart <= to) matched = true;
}
if (matched && override.pricing) {
activePricing = {
hourly: override.pricing.hourly ?? basePricing.hourly,
day: override.pricing.day ?? basePricing.day,
pass: override.pricing.pass ?? basePricing.pass,
};
break;
}
}
const hours = Math.max(1, Math.ceil((new Date(endTime) - new Date(startTime)) / 3_600_000));
if (durationType === 'hourly') {
const slabs = activePricing?.hourly?.[vehicleType]?.slabs;
if (slabs?.length > 0) {
let total = 0;
for (const slab of slabs) {
if (hours > slab.fromHour) {
const applicable = Math.min(hours, slab.toHour) - slab.fromHour;
total += slab.type === 'fixed' ? slab.price : applicable * slab.price;
}
}
if (total > 0) return total;
}
}
// Falls through to daily / pass pricing...
};ParkingSpace Model — Geo & Pricing Schema
javascript
// backend/models/ParkingSpace.js
const parkingSpaceSchema = new mongoose.Schema({
owner: { type: mongoose.Schema.Types.ObjectId, ref: 'ParkFinderSecondUser', required: true },
title: { type: String, required: true },
isOnline: { type: Boolean, default: false },
isDeleted: { type: Boolean, default: false },
// GeoJSON Point for $near queries
location: {
type: { type: String, enum: ['Point'], required: true },
coordinates: { type: [Number], required: true }, // [longitude, latitude]
},
address: { street: String, city: String, state: String, zipCode: String },
totalSpots: { type: Number, required: true, default: 1 },
availableSpots: { type: Number, required: true, default: 1 },
vehicleSpots: {
twoWheeler: { type: Number, default: 0 },
fourWheeler: { type: Number, default: 0 },
heavyWheeler: { type: Number, default: 0 },
},
// Pricing overrides support date ranges and day-of-week targeting
pricingOverrides: [{
type: { type: String, enum: ['date_range', 'day_of_week'] },
startDateTime: Date,
endDateTime: Date,
daysOfWeek: [Number], // 0=Sun … 6=Sat
pricing: { hourly: mongoose.Schema.Types.Mixed, day: mongoose.Schema.Types.Mixed },
}],
});
// 2dsphere index enables MongoDB geospatial $near queries
parkingSpaceSchema.index({ location: '2dsphere' });AWS CI/CD Pipeline — SSM Deploy
yaml
# .github/workflows/deploy.yml
- name: Build & push Docker image to ECR
env:
ECR_REPOSITORY: parkyourvehicle-backend
IMAGE_TAG: ${{ github.sha }} # Immutable per-commit tag
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG ./backend
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
- name: Deploy to EC2 via AWS SSM
env:
INSTANCE_IDS: ${{ vars.EC2_INSTANCE_IDS }} # Comma-separated instance list
APP_DIR: ${{ vars.PROD_APP_DIR || '/home/ubuntu/apps' }}
run: |
# Reconstruct .env from AWS SSM Parameter Store on the EC2 (split across
# two parameters to stay under the 4KB Standard-tier limit)
aws ssm get-parameter --name "/parkfinder/prod/env-main" \
--with-decryption --query "Parameter.Value" --output text > backend/.env
# Pull exact commit image and restart containers
export BACKEND_IMAGE="$ACCOUNT.dkr.ecr.$REGION.amazonaws.com/$ECR_REPOSITORY:$IMAGE_TAG"
docker pull "$BACKEND_IMAGE"
docker compose -f docker-compose.prod.yml down || true
docker compose -f docker-compose.prod.yml up -d
# Wait for health checks before declaring success
for i in $(seq 1 24); do
BACKEND_STATUS=$(docker inspect -f '{{.State.Health.Status}}' parkfinder_backend_prod)
FRONTEND_STATUS=$(docker inspect -f '{{.State.Health.Status}}' parkfinder_frontend_prod)
[ "$BACKEND_STATUS" = "healthy" ] && [ "$FRONTEND_STATUS" = "healthy" ] && break
[ "$i" = "24" ] && exit 1
sleep 5
doneDeployment Architecture
The production setup uses two separate deploy workflows: `deploy.yml` (AWS ECR + SSM for production EC2s) and `deploy-dev.yml` (direct SSH to a single VPS for staging). Environments are isolated by branch — pushes to `prod` trigger the AWS pipeline, while pushes to `main` trigger the VPS deployment.