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
    done

Deployment 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.