38 KiB

Shifts Module

Overview

The Shifts module manages volunteer shift scheduling with public signup capabilities. It provides comprehensive CRUD operations for shift management, volunteer signup tracking, and automatic status updates based on capacity. The module includes three separate routers for admin management, authenticated volunteer portal access, and public signup flows.

Key Features:

  • Full shift CRUD with pagination, search, and filtering
  • Automatic status management (OPEN → FULL based on capacity)
  • Cut association for canvassing shifts (optional)
  • Three signup sources: admin-added, authenticated user, public
  • Temporary user creation for public signups (auto-expires after shift date)
  • Email confirmation system with readable passwords for new users
  • Capacity tracking (currentVolunteers / maxVolunteers)
  • Cancellation system with capacity recalculation
  • Email all volunteers functionality
  • Rate limiting on signup endpoints (5/min per IP)
  • Prometheus metrics tracking (cm_shift_signups_total)

File Paths

File Purpose
api/src/modules/map/shifts/shifts.routes.ts 3 routers: admin, volunteer, public (242 lines)
api/src/modules/map/shifts/shifts.service.ts Shift business logic with signup flows (754 lines)
api/src/modules/map/shifts/shifts.schemas.ts Zod validation schemas (55 lines)

Database Models

model Shift {
  id                String      @id @default(cuid())
  title             String
  description       String?     @db.Text
  date              DateTime    @db.Date
  startTime         String      // HH:MM format
  endTime           String      // HH:MM format
  location          String?
  maxVolunteers     Int
  currentVolunteers Int         @default(0)
  status            ShiftStatus @default(OPEN)
  isPublic          Boolean     @default(false)
  cutId             String?
  cut               Cut?        @relation(fields: [cutId], references: [id], onDelete: SetNull)
  createdBy         String?
  createdAt         DateTime    @default(now())
  updatedAt         DateTime    @updatedAt

  signups           ShiftSignup[]
  canvassVisits     CanvassVisit[]
  canvassSessions   CanvassSession[]

  @@index([cutId])
  @@map("shifts")
}

enum ShiftStatus {
  OPEN       // Accepting signups
  FULL       // Max capacity reached
  CANCELLED  // Shift cancelled
}

model ShiftSignup {
  id           String       @id @default(cuid())
  shiftId      String
  shift        Shift        @relation(fields: [shiftId], references: [id], onDelete: Cascade)
  shiftTitle   String?
  userId       String?
  user         User?        @relation(fields: [userId], references: [id], onDelete: SetNull)
  userEmail    String
  userName     String?
  userPhone    String?
  signupDate   DateTime     @default(now())
  status       SignupStatus @default(CONFIRMED)
  signupSource SignupSource @default(AUTHENTICATED)

  @@unique([shiftId, userEmail])
  @@index([shiftId])
  @@map("shift_signups")
}

enum SignupStatus {
  CONFIRMED  // Active signup
  CANCELLED  // Cancelled (can be re-activated)
}

enum SignupSource {
  AUTHENTICATED  // Logged-in user signup
  PUBLIC         // Anonymous public signup
  ADMIN          // Added by admin
}

Key Relationships:

  • Shift → ShiftSignup: One-to-many (cascade delete)
  • Shift → Cut: Optional many-to-one (cut assignment for canvassing, set null on delete)
  • Shift → CanvassSession/CanvassVisit: One-to-many (canvassing data linked to shifts)
  • ShiftSignup → User: Optional many-to-one (set null on user delete, preserves signup record)

Unique Constraints:

  • [shiftId, userEmail] — One signup per email per shift (allows re-activation of cancelled signups)

API Endpoints

Admin Endpoints (Authentication Required)

Method Path Auth Description
GET /api/map/shifts MAP_ADMIN List paginated shifts
GET /api/map/shifts/stats MAP_ADMIN Shift statistics
GET /api/map/shifts/:id MAP_ADMIN Get shift with signups
POST /api/map/shifts MAP_ADMIN Create shift
PUT /api/map/shifts/:id MAP_ADMIN Update shift
DELETE /api/map/shifts/:id MAP_ADMIN Delete shift
POST /api/map/shifts/:id/signups MAP_ADMIN Add volunteer signup
DELETE /api/map/shifts/:id/signups/:signupId MAP_ADMIN Remove signup
POST /api/map/shifts/:id/email-details MAP_ADMIN Email all volunteers

Admin Roles: SUPER_ADMIN, MAP_ADMIN

Volunteer Endpoints (Authentication Required)

Method Path Auth Description
GET /api/map/shifts/volunteer/upcoming Any logged-in Upcoming shifts with signup status
GET /api/map/shifts/volunteer/my-signups Any logged-in Own confirmed signups
POST /api/map/shifts/volunteer/:id/signup Any logged-in Sign up for shift
DELETE /api/map/shifts/volunteer/:id/signup Any logged-in Cancel own signup

Public Endpoints (No Authentication)

Method Path Auth Description
GET /api/map/shifts/public None List public upcoming shifts
POST /api/map/shifts/public/:id/signup None Public signup (creates temp user if needed)

Admin Endpoint Details

GET /api/map/shifts

List shifts with pagination, search, and filtering.

Query Parameters:

Parameter Type Required Default Description
page number No 1 Page number
limit number No 20 Results per page (max 100)
search string No - Search title or location
status ShiftStatus No - Filter by status
upcoming boolean No - Filter to shifts with date >= today
sortBy enum No date Sort field: date, createdAt, title
sortOrder enum No desc Sort direction: asc, desc

Example Request:

curl -H "Authorization: Bearer <token>" \
  "http://api.cmlite.org/api/map/shifts?upcoming=true&status=OPEN&page=1&limit=10"

Response (200 OK):

{
  "shifts": [
    {
      "id": "clx1234567890",
      "title": "Door Knocking — Ward 5",
      "description": "Canvassing residential areas in Ward 5. Meet at campaign office.",
      "date": "2026-02-15T00:00:00.000Z",
      "startTime": "10:00",
      "endTime": "14:00",
      "location": "123 Main St (Campaign Office)",
      "maxVolunteers": 15,
      "currentVolunteers": 8,
      "status": "OPEN",
      "isPublic": true,
      "cutId": "clx0987654321",
      "cut": {
        "id": "clx0987654321",
        "name": "Ward 5 Residential"
      },
      "createdBy": "clx1111111111",
      "createdAt": "2026-02-01T12:00:00.000Z",
      "updatedAt": "2026-02-11T14:30:00.000Z",
      "_count": {
        "signups": 8
      }
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 10,
    "total": 23,
    "totalPages": 3
  }
}

GET /api/map/shifts/stats

Get shift statistics.

Example Request:

curl -H "Authorization: Bearer <token>" \
  "http://api.cmlite.org/api/map/shifts/stats"

Response (200 OK):

{
  "total": 45,
  "open": 12,
  "full": 3,
  "cancelled": 2,
  "upcoming": 15,
  "totalSignups": 287
}

GET /api/map/shifts/:id

Get single shift with signups list.

Path Parameters:

  • id (string): Shift ID

Example Request:

curl -H "Authorization: Bearer <token>" \
  "http://api.cmlite.org/api/map/shifts/clx1234567890"

Response (200 OK):

{
  "id": "clx1234567890",
  "title": "Door Knocking — Ward 5",
  "description": "Canvassing residential areas in Ward 5",
  "date": "2026-02-15T00:00:00.000Z",
  "startTime": "10:00",
  "endTime": "14:00",
  "location": "123 Main St",
  "maxVolunteers": 15,
  "currentVolunteers": 8,
  "status": "OPEN",
  "isPublic": true,
  "cutId": "clx0987654321",
  "cut": {
    "id": "clx0987654321",
    "name": "Ward 5 Residential"
  },
  "signups": [
    {
      "id": "clx2222222222",
      "shiftId": "clx1234567890",
      "shiftTitle": "Door Knocking — Ward 5",
      "userId": "clx3333333333",
      "user": {
        "id": "clx3333333333",
        "email": "volunteer@example.com",
        "name": "Jane Volunteer",
        "phone": "+1234567890"
      },
      "userEmail": "volunteer@example.com",
      "userName": "Jane Volunteer",
      "userPhone": "+1234567890",
      "signupDate": "2026-02-05T10:30:00.000Z",
      "status": "CONFIRMED",
      "signupSource": "PUBLIC"
    }
  ],
  "_count": {
    "signups": 8
  }
}

Error Responses:

  • 404 Not Found: Shift not found

POST /api/map/shifts

Create new shift.

Request Body:

{
  "title": "Door Knocking — Ward 5",
  "description": "Canvassing residential areas in Ward 5. Meet at campaign office.",
  "date": "2026-02-15",
  "startTime": "10:00",
  "endTime": "14:00",
  "location": "123 Main St (Campaign Office)",
  "maxVolunteers": 15,
  "isPublic": true,
  "cutId": "clx0987654321"
}

Response (201 Created):

Returns created shift object (same format as GET).

Validation:

  • date must be YYYY-MM-DD format
  • startTime, endTime must be HH:MM format
  • maxVolunteers must be >= 1
  • cutId is optional (for non-canvassing shifts)

PUT /api/map/shifts/:id

Update shift. Auto-updates status if capacity changes.

Request Body (Partial):

{
  "maxVolunteers": 20,
  "status": "OPEN"
}

Response (200 OK):

Returns updated shift object.

Auto-Status Logic:

// When maxVolunteers is updated:
if (currentVolunteers >= newMaxVolunteers && status === OPEN) {
  status = FULL;
} else if (currentVolunteers < newMaxVolunteers && status === FULL) {
  status = OPEN;
}

DELETE /api/map/shifts/:id

Delete shift. Cascade deletes all signups.

Response (204 No Content):

No response body.


POST /api/map/shifts/:id/signups

Admin add volunteer signup.

Request Body:

{
  "userEmail": "volunteer@example.com",
  "userName": "Jane Volunteer"
}

Response (201 Created):

{
  "id": "clx2222222222",
  "shiftId": "clx1234567890",
  "shiftTitle": "Door Knocking — Ward 5",
  "userId": "clx3333333333",
  "userEmail": "volunteer@example.com",
  "userName": "Jane Volunteer",
  "userPhone": null,
  "signupDate": "2026-02-11T15:00:00.000Z",
  "status": "CONFIRMED",
  "signupSource": "ADMIN"
}

Behavior:

  • Looks up user by email (if exists, links via userId)
  • If signup was previously cancelled, re-activates it
  • Increments currentVolunteers
  • Auto-updates shift status to FULL if capacity reached
  • Transaction ensures atomicity

Error Responses:

  • 400 Bad Request: Shift is full
  • 404 Not Found: Shift not found
  • 409 Conflict: Volunteer already signed up

DELETE /api/map/shifts/:id/signups/:signupId

Admin remove volunteer signup.

Path Parameters:

  • id (string): Shift ID
  • signupId (string): Signup ID

Response (204 No Content):

No response body.

Behavior:

  • Updates signup status to CANCELLED (does not delete record)
  • Decrements currentVolunteers
  • Auto-updates shift status to OPEN
  • Transaction ensures atomicity

Error Responses:

  • 400 Bad Request: Signup already cancelled
  • 404 Not Found: Signup not found

POST /api/map/shifts/:id/email-details

Email shift details to all confirmed volunteers.

Example Request:

curl -X POST \
  -H "Authorization: Bearer <token>" \
  "http://api.cmlite.org/api/map/shifts/clx1234567890/email-details"

Response (200 OK):

{
  "sent": 8,
  "failed": 0
}

Email Template:

Uses shift-details.html and shift-details.txt templates with variables:

  • USER_NAME — Volunteer name
  • SHIFT_TITLE — Shift title
  • SHIFT_DATE — Formatted date
  • SHIFT_START_TIME — Start time
  • SHIFT_END_TIME — End time
  • SHIFT_LOCATION — Location
  • SHIFT_DESCRIPTION — Description
  • CURRENT_VOLUNTEERS — Current signup count
  • MAX_VOLUNTEERS — Max capacity
  • SHIFT_STATUS — Status
  • ORGANIZATION_NAME — Site settings org name

Volunteer Endpoint Details

GET /api/map/shifts/volunteer/upcoming

Get upcoming public shifts with signup status for current user.

Example Request:

curl -H "Authorization: Bearer <token>" \
  "http://api.cmlite.org/api/map/shifts/volunteer/upcoming"

Response (200 OK):

[
  {
    "id": "clx1234567890",
    "title": "Door Knocking — Ward 5",
    "description": "Canvassing residential areas",
    "date": "2026-02-15T00:00:00.000Z",
    "startTime": "10:00",
    "endTime": "14:00",
    "location": "123 Main St",
    "maxVolunteers": 15,
    "currentVolunteers": 8,
    "status": "OPEN",
    "isSignedUp": true
  },
  {
    "id": "clx9876543210",
    "title": "Phone Banking",
    "description": "Call voters for GOTV",
    "date": "2026-02-16T00:00:00.000Z",
    "startTime": "18:00",
    "endTime": "20:00",
    "location": "Virtual (Zoom)",
    "maxVolunteers": 25,
    "currentVolunteers": 12,
    "status": "OPEN",
    "isSignedUp": false
  }
]

Filtering:

  • Only public shifts (isPublic: true)
  • Only non-cancelled shifts
  • Only shifts with date >= today
  • Sorted by date ASC, then startTime ASC

GET /api/map/shifts/volunteer/my-signups

Get current user's confirmed signups for upcoming shifts.

Example Request:

curl -H "Authorization: Bearer <token>" \
  "http://api.cmlite.org/api/map/shifts/volunteer/my-signups"

Response (200 OK):

[
  {
    "id": "clx2222222222",
    "shiftId": "clx1234567890",
    "shiftTitle": "Door Knocking — Ward 5",
    "userId": "clx3333333333",
    "userEmail": "volunteer@example.com",
    "userName": "Jane Volunteer",
    "userPhone": "+1234567890",
    "signupDate": "2026-02-05T10:30:00.000Z",
    "status": "CONFIRMED",
    "signupSource": "PUBLIC",
    "shift": {
      "id": "clx1234567890",
      "title": "Door Knocking — Ward 5",
      "description": "Canvassing residential areas",
      "date": "2026-02-15T00:00:00.000Z",
      "startTime": "10:00",
      "endTime": "14:00",
      "location": "123 Main St",
      "maxVolunteers": 15,
      "currentVolunteers": 8,
      "status": "OPEN"
    }
  }
]

Filtering:

  • Signups by current user's email
  • Only confirmed signups
  • Only shifts with date >= today
  • Only non-cancelled shifts
  • Sorted by shift date ASC

POST /api/map/shifts/volunteer/:id/signup

Authenticated user signs up for shift.

Path Parameters:

  • id (string): Shift ID

Rate Limiting:

5 requests/min per IP (shiftSignupRateLimit middleware)

Example Request:

curl -X POST \
  -H "Authorization: Bearer <token>" \
  "http://api.cmlite.org/api/map/shifts/volunteer/clx1234567890/signup"

Response (201 Created):

{
  "id": "clx2222222222",
  "shiftId": "clx1234567890",
  "shiftTitle": "Door Knocking — Ward 5",
  "userId": "clx3333333333",
  "userEmail": "volunteer@example.com",
  "userName": "Jane Volunteer",
  "userPhone": "+1234567890",
  "signupDate": "2026-02-11T15:00:00.000Z",
  "status": "CONFIRMED",
  "signupSource": "AUTHENTICATED"
}

Validation:

  • Shift must be public (isPublic: true)
  • Shift must not be cancelled
  • Shift must not have passed (date >= today)
  • Shift must not be full
  • User must not already be signed up

Behavior:

  • If previously cancelled signup exists, re-activates it
  • Sends confirmation email (no temp password)
  • Increments currentVolunteers
  • Auto-updates shift status to FULL if capacity reached
  • Records Prometheus metric cm_shift_signups_total

Error Responses:

  • 400 Bad Request: Shift is full, cancelled, or past
  • 403 Forbidden: Shift is not public
  • 404 Not Found: Shift not found
  • 409 Conflict: Already signed up
  • 429 Too Many Requests: Rate limit exceeded

DELETE /api/map/shifts/volunteer/:id/signup

Cancel own signup.

Path Parameters:

  • id (string): Shift ID

Example Request:

curl -X DELETE \
  -H "Authorization: Bearer <token>" \
  "http://api.cmlite.org/api/map/shifts/volunteer/clx1234567890/signup"

Response (204 No Content):

No response body.

Behavior:

  • Updates signup status to CANCELLED
  • Decrements currentVolunteers
  • Auto-updates shift status to OPEN

Error Responses:

  • 400 Bad Request: Already cancelled
  • 404 Not Found: Signup not found

Public Endpoint Details

GET /api/map/shifts/public

List public upcoming shifts (no auth required).

Example Request:

curl http://api.cmlite.org/api/map/shifts/public

Response (200 OK):

[
  {
    "id": "clx1234567890",
    "title": "Door Knocking — Ward 5",
    "description": "Canvassing residential areas in Ward 5",
    "date": "2026-02-15T00:00:00.000Z",
    "startTime": "10:00",
    "endTime": "14:00",
    "location": "123 Main St",
    "maxVolunteers": 15,
    "currentVolunteers": 8,
    "status": "OPEN"
  }
]

Filtering:

  • Only public shifts (isPublic: true)
  • Only non-cancelled shifts
  • Only shifts with date >= today
  • Sorted by date ASC, then startTime ASC

POST /api/map/shifts/public/:id/signup

Public signup with temporary user creation.

Path Parameters:

  • id (string): Shift ID

Rate Limiting:

5 requests/min per IP (shiftSignupRateLimit middleware)

Request Body:

{
  "email": "newvolunteer@example.com",
  "name": "John Doe",
  "phone": "+1234567890"
}

Response (201 Created):

{
  "signup": {
    "id": "clx2222222222",
    "shiftId": "clx1234567890",
    "shiftTitle": "Door Knocking — Ward 5",
    "userId": "clx4444444444",
    "userEmail": "newvolunteer@example.com",
    "userName": "John Doe",
    "userPhone": "+1234567890",
    "signupDate": "2026-02-11T15:00:00.000Z",
    "status": "CONFIRMED",
    "signupSource": "PUBLIC"
  },
  "isNewUser": true
}

Validation:

  • Shift must be public
  • Shift must be OPEN status
  • Shift date must not have passed
  • Shift must not be full
  • Email must not already be signed up

Behavior — New User:

If email does not exist in database:

  1. Generate readable password:

    const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];
    const nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];
    
    function generateReadablePassword(): string {
      const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
      const noun = nouns[Math.floor(Math.random() * nouns.length)];
      const num = Math.floor(Math.random() * 90) + 10;
      return `${adj}${noun}${num}`;  // e.g., "BlueEagle42"
    }
    
  2. Create TEMP user:

    const hashedPassword = await bcrypt.hash(tempPassword, 12);
    const shiftDate = new Date(shift.date);
    shiftDate.setDate(shiftDate.getDate() + 1);  // Expires day after shift
    
    const user = await prisma.user.create({
      data: {
        email: data.email,
        password: hashedPassword,
        name: data.name,
        phone: data.phone,
        role: 'TEMP',
        createdVia: 'PUBLIC_SHIFT_SIGNUP',
        expiresAt: shiftDate,
      },
    });
    
  3. Send confirmation email with temp password:

    const vars = {
      USER_NAME: data.name,
      USER_EMAIL: data.email,
      SHIFT_TITLE: shift.title,
      SHIFT_DATE: '...',
      SHIFT_TIME: '...',
      SHIFT_LOCATION: shift.location || 'TBD',
      IS_NEW_USER: 'true',
      TEMP_PASSWORD: tempPassword,  // Only included for new users
      LOGIN_URL: `${siteUrl}/login`,
      ORGANIZATION_NAME: orgName,
    };
    

Behavior — Existing User:

If email exists in database:

  • Links signup to existing user via userId
  • No password generated or sent
  • Sets signupSource to AUTHENTICATED

Behavior — Re-activation:

If cancelled signup exists:

  • Re-activates existing signup record (status → CONFIRMED)
  • Does not create duplicate signup

Transaction:

  • Signup creation + currentVolunteers increment + status update are atomic

Error Responses:

  • 400 Bad Request: Shift full, not open, or past
  • 403 Forbidden: Shift not public
  • 404 Not Found: Shift not found
  • 409 Conflict: Already signed up
  • 429 Too Many Requests: Rate limit exceeded

Service Functions

shiftsService.findAll(filters)

List shifts with pagination, search, and filtering.

Usage:

import { shiftsService } from './shifts.service';

const result = await shiftsService.findAll({
  page: 1,
  limit: 20,
  search: 'ward 5',
  status: ShiftStatus.OPEN,
  upcoming: true,
  sortBy: 'date',
  sortOrder: 'asc',
});

console.log(result.shifts.length);  // Array of shifts
console.log(result.pagination);     // { page, limit, total, totalPages }

Search Behavior:

if (search) {
  where.OR = [
    { title: { contains: search, mode: 'insensitive' } },
    { location: { contains: search, mode: 'insensitive' } },
  ];
}

shiftsService.findById(id)

Get single shift with signups list.

Usage:

const shift = await shiftsService.findById('clx1234567890');
console.log(shift.signups.length);  // Confirmed signups only
console.log(shift.cut?.name);       // Cut name if associated

Throws:

  • AppError(404) if shift not found

shiftsService.create(data, userId)

Create shift.

Usage:

const shift = await shiftsService.create({
  title: 'Door Knocking — Ward 5',
  description: 'Canvassing residential areas',
  date: '2026-02-15',
  startTime: '10:00',
  endTime: '14:00',
  location: '123 Main St',
  maxVolunteers: 15,
  isPublic: true,
  cutId: 'clx0987654321',
}, req.user.id);

shiftsService.update(id, data)

Update shift with auto-status management.

Usage:

const shift = await shiftsService.update('clx1234567890', {
  maxVolunteers: 20,
});

// If currentVolunteers was 15 and maxVolunteers was 15:
// - Old status: FULL
// - New status: OPEN (because 15 < 20)

Auto-Status Logic:

if (data.maxVolunteers !== undefined) {
  if (existing.currentVolunteers >= data.maxVolunteers && existing.status === ShiftStatus.OPEN) {
    updateData.status = ShiftStatus.FULL;
  } else if (existing.currentVolunteers < data.maxVolunteers && existing.status === ShiftStatus.FULL) {
    updateData.status = ShiftStatus.OPEN;
  }
}

shiftsService.addSignup(shiftId, data)

Admin add volunteer signup.

Usage:

const signup = await shiftsService.addSignup('clx1234567890', {
  userEmail: 'volunteer@example.com',
  userName: 'Jane Volunteer',
});

console.log(signup.signupSource);  // 'ADMIN'

Behavior:

  • Looks up user by email
  • Re-activates cancelled signup if exists
  • Atomic transaction (signup + capacity + status)

Throws:

  • AppError(400) if shift full
  • AppError(404) if shift not found
  • AppError(409) if already signed up

shiftsService.publicSignup(shiftId, data)

Public signup with temp user creation.

Usage:

const result = await shiftsService.publicSignup('clx1234567890', {
  email: 'newuser@example.com',
  name: 'John Doe',
  phone: '+1234567890',
});

if (result.isNewUser) {
  console.log('Created TEMP user with readable password');
  console.log('Confirmation email sent with credentials');
}

Temp User Expiry:

const shiftDate = new Date(shift.date);
shiftDate.setDate(shiftDate.getDate() + 1);  // Expires day after shift

Email Template Variables:

const vars: Record<string, string> = {
  USER_NAME: data.name,
  USER_EMAIL: data.email,
  SHIFT_TITLE: shift.title,
  SHIFT_DATE: dateStr,
  SHIFT_TIME: `${shift.startTime}${shift.endTime}`,
  SHIFT_LOCATION: shift.location || 'TBD',
  IS_NEW_USER: isNewUser ? 'true' : '',  // Conditional content in template
  TEMP_PASSWORD: tempPassword || '',     // Only included for new users
  LOGIN_URL: `${baseUrl}/login`,
  ORGANIZATION_NAME: orgName,
};

Metrics:

Records cm_shift_signups_total Prometheus counter.

Throws:

  • AppError(400) if shift full, not open, or past
  • AppError(403) if not public
  • AppError(404) if shift not found
  • AppError(409) if duplicate signup

shiftsService.removeSignup(signupId)

Cancel signup (admin).

Usage:

await shiftsService.removeSignup('clx2222222222');

// Signup status → CANCELLED
// currentVolunteers decremented
// Shift status → OPEN

Atomic Transaction:

await prisma.$transaction([
  prisma.shiftSignup.update({
    where: { id: signupId },
    data: { status: SignupStatus.CANCELLED },
  }),
  prisma.shift.update({
    where: { id: signup.shiftId },
    data: {
      currentVolunteers: { decrement: 1 },
      status: ShiftStatus.OPEN,
    },
  }),
]);

shiftsService.emailShiftDetails(shiftId)

Email shift details to all confirmed volunteers.

Usage:

const result = await shiftsService.emailShiftDetails('clx1234567890');
console.log(`Sent: ${result.sent}, Failed: ${result.failed}`);

Email Template:

Uses shift-details.html and shift-details.txt with variables:

  • USER_NAME
  • SHIFT_TITLE
  • SHIFT_DATE
  • SHIFT_START_TIME
  • SHIFT_END_TIME
  • SHIFT_LOCATION
  • SHIFT_DESCRIPTION
  • CURRENT_VOLUNTEERS
  • MAX_VOLUNTEERS
  • SHIFT_STATUS
  • ORGANIZATION_NAME

Error Handling:

Individual email failures are logged but do not stop batch processing.


Validation Schemas

Create Shift Schema

export const createShiftSchema = z.object({
  title: z.string().min(1, 'Title is required'),
  description: z.string().optional(),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
  startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
  endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
  location: z.string().optional(),
  maxVolunteers: z.number().int().min(1, 'Must have at least 1 volunteer spot'),
  isPublic: z.boolean().optional().default(false),
  cutId: z.string().optional(),
});

Example Valid Input:

{
  "title": "Door Knocking — Ward 5",
  "description": "Canvassing residential areas",
  "date": "2026-02-15",
  "startTime": "10:00",
  "endTime": "14:00",
  "location": "123 Main St",
  "maxVolunteers": 15,
  "isPublic": true,
  "cutId": "clx0987654321"
}

Update Shift Schema

export const updateShiftSchema = z.object({
  title: z.string().min(1).optional(),
  description: z.string().nullable().optional(),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
  startTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
  endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
  location: z.string().nullable().optional(),
  maxVolunteers: z.number().int().min(1).optional(),
  isPublic: z.boolean().optional(),
  status: z.nativeEnum(ShiftStatus).optional(),
  cutId: z.string().nullable().optional(),
});

Partial Updates:

All fields optional. Only provided fields are updated.


Public Signup Schema

export const publicSignupSchema = z.object({
  email: z.string().email('Valid email is required'),
  name: z.string().min(1, 'Name is required'),
  phone: z.string().optional(),
});

Example Valid Input:

{
  "email": "volunteer@example.com",
  "name": "Jane Volunteer",
  "phone": "+1234567890"
}

Code Examples

Admin: Create Shift with Cut Association

import { api } from '@/lib/api';
import { message } from 'antd';

const createShift = async () => {
  try {
    const { data } = await api.post('/api/map/shifts', {
      title: 'Door Knocking — Ward 5',
      description: 'Canvassing residential areas in Ward 5. Meet at campaign office.',
      date: '2026-02-15',
      startTime: '10:00',
      endTime: '14:00',
      location: '123 Main St (Campaign Office)',
      maxVolunteers: 15,
      isPublic: true,
      cutId: 'clx0987654321',  // Associate with cut
    });

    message.success(`Shift created: ${data.title}`);
    return data;
  } catch (error) {
    message.error('Failed to create shift');
    throw error;
  }
};

Volunteer: Sign Up for Shift

import { api } from '@/lib/api';
import { message } from 'antd';

const signUpForShift = async (shiftId: string) => {
  try {
    const { data } = await api.post(`/api/map/shifts/volunteer/${shiftId}/signup`);

    message.success('Signed up successfully! Check your email for confirmation.');
    return data;
  } catch (error: any) {
    if (error.response?.data?.code === 'SHIFT_FULL') {
      message.error('This shift is full');
    } else if (error.response?.data?.code === 'DUPLICATE_SIGNUP') {
      message.warning('You are already signed up for this shift');
    } else {
      message.error('Failed to sign up');
    }
    throw error;
  }
};

Public: Sign Up Without Account

import axios from 'axios';

const publicSignup = async (shiftId: string, formData: { email: string; name: string; phone?: string }) => {
  try {
    const { data } = await axios.post(
      `/api/map/shifts/public/${shiftId}/signup`,
      formData
    );

    if (data.isNewUser) {
      alert(`Account created! Check your email for your temporary password.`);
    } else {
      alert('Signed up successfully!');
    }

    return data;
  } catch (error: any) {
    if (error.response?.status === 429) {
      alert('Too many signups. Please try again in a minute.');
    } else if (error.response?.data?.code === 'SHIFT_FULL') {
      alert('This shift is full');
    } else {
      alert('Failed to sign up');
    }
    throw error;
  }
};

Admin: Email All Volunteers

import { api } from '@/lib/api';
import { message } from 'antd';

const emailAllVolunteers = async (shiftId: string) => {
  try {
    const { data } = await api.post(`/api/map/shifts/${shiftId}/email-details`);

    message.success(`Sent ${data.sent} emails successfully. ${data.failed} failed.`);
    return data;
  } catch (error) {
    message.error('Failed to send emails');
    throw error;
  }
};

Frontend Integration

The ShiftsPage component (admin/src/pages/ShiftsPage.tsx) provides:

  • Paginated shifts table with search and status filter
  • Cut association dropdown (optional, for canvassing shifts)
  • Capacity badges (8/15 with OPEN/FULL status)
  • Create shift modal with date/time pickers
  • Edit shift modal (pre-populated form)
  • Delete confirmation modal
  • Signups drawer (shows volunteers, email all button, remove signup)
  • Public/private toggle (controls isPublic flag)
  • Status badges (OPEN=green, FULL=orange, CANCELLED=red)

State Management:

const [shifts, setShifts] = useState<Shift[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [filters, setFilters] = useState({ search: '', status: null, upcoming: true });
const [signupsDrawerOpen, setSignupsDrawerOpen] = useState(false);
const [selectedShift, setSelectedShift] = useState<Shift | null>(null);

Volunteer Portal:

The VolunteerShiftsPage component (admin/src/pages/volunteer/VolunteerShiftsPage.tsx) provides:

  • Upcoming shifts cards with shift details
  • Signup status badges ("Signed Up" vs "Join Now")
  • Capacity indicators (8/15 volunteers)
  • Signup confirmation modal
  • Cancel signup functionality
  • My signups tab (shows user's confirmed signups)

Public Page:

The ShiftsPage component (admin/src/pages/public/ShiftsPage.tsx) provides:

  • Public shift cards with date/time/location
  • Signup modal (collects email, name, phone)
  • Capacity indicators
  • Status badges
  • Responsive grid layout

Performance Considerations

Capacity Tracking

The currentVolunteers field is denormalized for performance:

// Instead of counting signups on every query:
const count = await prisma.shiftSignup.count({ where: { shiftId, status: 'CONFIRMED' } });

// We maintain a counter:
data: {
  currentVolunteers: { increment: 1 },  // On signup
  currentVolunteers: { decrement: 1 },  // On cancel
}

Pros:

  • No joins or aggregations needed for shift listings
  • Instant capacity checks
  • Fast status filtering

Cons:

  • Must maintain consistency in transactions
  • Risk of drift if transactions fail

Consistency Checks:

Run periodic reconciliation:

UPDATE shifts
SET "currentVolunteers" = (
  SELECT COUNT(*) FROM shift_signups
  WHERE "shiftId" = shifts.id AND status = 'CONFIRMED'
)
WHERE "currentVolunteers" != (
  SELECT COUNT(*) FROM shift_signups
  WHERE "shiftId" = shifts.id AND status = 'CONFIRMED'
);

Unique Constraint Performance

The [shiftId, userEmail] unique constraint enables fast duplicate checks:

const existing = await prisma.shiftSignup.findUnique({
  where: { shiftId_userEmail: { shiftId, userEmail: data.email } },
});

Index Usage:

  • PostgreSQL uses the unique index for lookups
  • O(log n) lookup time
  • No full table scan

Rate Limiting

The shiftSignupRateLimit middleware protects against signup spam:

// From api/src/middleware/rate-limit.ts
export const shiftSignupRateLimit = createRateLimiter({
  windowMs: 60 * 1000,  // 1 minute
  max: 5,               // 5 signups per minute
  message: 'Too many signup requests. Please try again later.',
});

Why 5/min?

  • Allows legitimate users to sign up for multiple shifts quickly
  • Prevents automated abuse
  • Balances UX with security

Troubleshooting

Shift Status Not Updating Automatically

Problem:

Shift status stays FULL even after volunteer cancels.

Diagnosis:

Check transaction logic in removeSignup:

await prisma.$transaction([
  prisma.shiftSignup.update({ /* ... */ }),
  prisma.shift.update({
    where: { id: signup.shiftId },
    data: {
      currentVolunteers: { decrement: 1 },
      status: ShiftStatus.OPEN,  // Always set to OPEN on cancel
    },
  }),
]);

Solution:

Status is always set to OPEN on cancel. If shift should remain FULL (e.g., still at capacity), check if another transaction occurred simultaneously.


Duplicate Signups

Problem:

User signed up twice for same shift.

Diagnosis:

Check unique constraint enforcement:

SELECT * FROM shift_signups
WHERE "shiftId" = 'clx1234567890' AND "userEmail" = 'volunteer@example.com';

Possible Causes:

  • Constraint disabled
  • Race condition (two requests hit database before first commit)
  • Manual database insertion bypassing constraint

Solution:

  • Verify constraint exists: \d shift_signups in psql
  • Add application-level locking if race conditions persist:
    await prisma.$transaction([
      prisma.shiftSignup.findUnique({ /* check */ }),
      prisma.shiftSignup.create({ /* create */ }),
    ], { isolationLevel: 'Serializable' });
    

Confirmation Emails Not Sending

Problem:

Volunteers sign up but don't receive confirmation emails.

Diagnosis:

Check email service logs:

docker compose logs -f api | grep "shift signup confirmation"

Common Causes:

  1. MailHog mode enabled:

    EMAIL_TEST_MODE=true  # Emails go to MailHog, not SMTP
    
  2. SMTP misconfiguration:

    SMTP_HOST=smtp.gmail.com
    SMTP_PORT=587
    SMTP_USER=your-email@gmail.com
    SMTP_PASSWORD=your-app-password  # Must be app password, not account password
    
  3. Template missing:

    # Check template exists
    ls api/src/templates/shift-signup-confirmation.html
    ls api/src/templates/shift-signup-confirmation.txt
    
  4. Email service crash:

    try {
      await emailService.sendEmail({ /* ... */ });
    } catch (err) {
      logger.error('Failed to send shift signup confirmation email:', err);
      // Signup succeeds even if email fails
    }
    

Solution:

  • Set EMAIL_TEST_MODE=false for production
  • Verify SMTP credentials
  • Ensure templates exist
  • Check email logs for detailed errors

Temp Users Not Expiring

Problem:

TEMP users created via public signup still active long after shift.

Diagnosis:

Check expiresAt value:

SELECT id, email, role, "expiresAt", "createdAt"
FROM users
WHERE role = 'TEMP' AND "expiresAt" < NOW() AND status = 'ACTIVE';

Expected Behavior:

  • expiresAt is set to shift date + 1 day
  • Expired users should be marked EXPIRED by auth middleware

Solution:

Run cleanup script or add cron job:

// Expire temp users
await prisma.user.updateMany({
  where: {
    role: UserRole.TEMP,
    expiresAt: { lte: new Date() },
    status: { not: UserStatus.EXPIRED },
  },
  data: { status: UserStatus.EXPIRED },
});

Rate Limit Too Strict

Problem:

Users get rate-limited when legitimately signing up for multiple shifts.

Diagnosis:

Check rate limit config:

export const shiftSignupRateLimit = createRateLimiter({
  windowMs: 60 * 1000,  // 1 minute
  max: 5,               // 5 signups per minute
});

Solution:

Increase limit if legitimate use case:

max: 10,  // Allow 10 signups per minute

Alternative:

Whitelist admin IPs:

skip: (req) => {
  const ip = req.ip || req.connection.remoteAddress;
  return ['127.0.0.1', '::1', ADMIN_IP].includes(ip);
},