24 KiB

Volunteer Shift Management

Overview

The shifts system enables campaigns to organize volunteer activities with time-based scheduling, capacity management, and cut assignment. It supports public shift signup with automatic TEMP user creation for unauthenticated volunteers.

Key Capabilities:

  • Shift Scheduling: Date, start/end times (HH:MM format), location
  • Capacity Management: Max volunteers, auto-status updates (OPEN → FULL)
  • Cut Assignment: Link shifts to geographic cuts for territory-based organizing
  • Public Signup: Unauthenticated users can signup (creates TEMP user)
  • Email Confirmations: Auto-send confirmation emails on signup
  • Signup Tracking: Source tracking (AUTHENTICATED, PUBLIC, ADMIN)
  • Status Lifecycle: OPEN, FULL, CANCELLED workflow
  • Bulk Operations: Email all volunteers, export signups CSV

Use Cases:

  • Canvassing shift scheduling
  • Phone bank volunteer coordination
  • Event volunteer management
  • Door-knocking territory assignment
  • Get-out-the-vote (GOTV) shifts
  • Public volunteer recruitment
  • Volunteer confirmation emails

Architecture

graph TD
    A[Admin] -->|Creates Shift| B[ShiftsPage]
    B -->|POST /api/map/shifts| C[Shifts Service]
    C -->|Validate| D[Shift Model]
    D -->|Linked To| E[Cut Model]

    F[Public User] -->|Browse Shifts| G[Public ShiftsPage]
    G -->|GET /api/public/map/shifts| C
    C -->|Filter upcoming=true| D

    F -->|Signup| H[Signup Modal]
    H -->|POST /api/public/map/shifts/:id/signup| C
    C -->|Check Capacity| D
    C -->|Create TEMP User| I[User Service]
    C -->|Create Signup| J[ShiftSignup Model]
    C -->|Send Email| K[Email Service]

    L[Volunteer] -->|View Assignments| M[VolunteerShiftsPage]
    M -->|GET /api/map/canvass/volunteer/assignments| N[Canvass Service]
    N -->|Filter by userId| J
    N -->|Include Cut| E

    D -->|1:N| J
    D -->|N:1| E

    style D fill:#e1f5ff
    style J fill:#e1f5ff
    style E fill:#e1f5ff
    style I fill:#e8f5e9

Flow Description:

  1. Admin creates shift → Validates date/time, assigns cut (optional), saves to database
  2. Public user browses → Query upcoming shifts (isPublic=true, date >=today), display cards
  3. Public signup → Check capacity, create TEMP user if unauthenticated, create signup record, send confirmation email
  4. Volunteer views assignments → Query signups for current user, include shift + cut details
  5. Shift capacity check → Auto-update status to FULL when currentVolunteers >= maxVolunteers

Database Models

Shift Model

See Shift Model Documentation for full schema.

Key Fields:

  • title: Shift name (e.g., "Saturday Canvassing - Downtown")
  • description: Free-text shift details
  • date: Shift date (Date type, not DateTime)
  • startTime: Start time in HH:MM format (24-hour)
  • endTime: End time in HH:MM format (24-hour)
  • location: Meeting point address/description
  • maxVolunteers: Maximum volunteer capacity
  • currentVolunteers: Current signup count (auto-updated)
  • status: OPEN | FULL | CANCELLED
  • isPublic: Show on public shifts page
  • cutId: Optional foreign key to Cut (territory assignment)
  • createdBy: User ID who created shift

Status Enum:

enum ShiftStatus {
  OPEN       // Accepting signups
  FULL       // At capacity
  CANCELLED  // Cancelled by admin
}

ShiftSignup Model

See ShiftSignup Model Documentation for full schema.

Key Fields:

  • shiftId: Foreign key to Shift
  • userId: Foreign key to User (optional for TEMP users)
  • userEmail: Email address (required, used for confirmations)
  • userName: Display name
  • userPhone: Phone number (optional)
  • status: CONFIRMED | CANCELLED | NO_SHOW
  • signupDate: When signup occurred
  • signupSource: AUTHENTICATED | PUBLIC | ADMIN
  • notes: Admin notes about signup

Signup Source Enum:

enum SignupSource {
  AUTHENTICATED  // Logged-in user signup
  PUBLIC         // Public signup (creates TEMP user)
  ADMIN          // Admin created signup
}

Signup Status Enum:

enum SignupStatus {
  CONFIRMED  // Signup active
  CANCELLED  // Volunteer cancelled
  NO_SHOW    // Marked as no-show by admin
}

Related Models:

  • Shift — Parent shift
  • User — Volunteer account (TEMP role for public signups)
  • Cut — Geographic territory assignment
  • CanvassSession — Linked to shift for canvassing

API Endpoints

See Shifts Backend Module Documentation for full API reference.

Admin Endpoints:

Method Endpoint Auth Description
GET /api/map/shifts MAP_ADMIN List shifts with pagination, search, filters
GET /api/map/shifts/stats MAP_ADMIN Get shift statistics (total, upcoming, by status)
GET /api/map/shifts/:id MAP_ADMIN Get shift details with signups
POST /api/map/shifts MAP_ADMIN Create new shift
PATCH /api/map/shifts/:id MAP_ADMIN Update shift
DELETE /api/map/shifts/:id MAP_ADMIN Delete shift (cascade signups)
POST /api/map/shifts/:id/signups MAP_ADMIN Manually add signup
PATCH /api/map/shifts/:id/signups/:signupId MAP_ADMIN Update signup (change status, notes)
DELETE /api/map/shifts/:id/signups/:signupId MAP_ADMIN Delete signup
POST /api/map/shifts/:id/email-volunteers MAP_ADMIN Send email to all shift volunteers

Public Endpoints:

Method Endpoint Auth Description
GET /api/public/map/shifts None List upcoming public shifts (isPublic=true, date >=today)
GET /api/public/map/shifts/:id None Get public shift details
POST /api/public/map/shifts/:id/signup None Public signup (creates TEMP user if unauthenticated)

Volunteer Endpoints:

Method Endpoint Auth Description
GET /api/map/canvass/volunteer/assignments Any logged-in user Get shifts user signed up for
DELETE /api/map/shifts/:id/signups/cancel Any logged-in user Cancel own signup

Configuration

Environment Variables

Variable Type Default Description
EMAIL_TEST_MODE boolean false Send confirmation emails to MailHog (dev)
SMTP_HOST string - SMTP server for confirmation emails
SMTP_PORT number 587 SMTP port
SMTP_USER string - SMTP username
SMTP_PASSWORD string - SMTP password

Email Templates

Shift Confirmation Email:

Subject: Shift Confirmation - {{shift.title}}

Body:

Hi {{userName}},

You're confirmed for:
{{shift.title}}

Date: {{shift.date}}
Time: {{shift.startTime}} - {{shift.endTime}}
Location: {{shift.location}}

{{#if shift.cut}}
Territory: {{shift.cut.name}}
{{/if}}

{{#if shift.description}}
Details:
{{shift.description}}
{{/if}}

To cancel your signup, reply to this email.

Thank you!

Admin Email All Volunteers:

Subject: Configurable by admin

Body: Configurable by admin (supports {{name}}, {{email}}, {{phone}} placeholders)

Admin Workflow

Creating a Shift

Step 1: Navigate to Shifts Page

Navigate to Map → Shifts in the admin sidebar.

![ShiftsPage Screenshot Placeholder]

Step 2: Click "Add Shift"

Click + Add Shift button in the top-right corner.

Step 3: Fill Shift Form

Complete shift details:

  • Title: "Saturday Canvassing - Ward 5"
  • Description: "Door-knocking downtown, meet at campaign office"
  • Date: Select date from calendar
  • Start Time: "09:00" (24-hour format)
  • End Time: "12:00"
  • Location: "123 Campaign Office, Main St"
  • Max Volunteers: 20
  • Cut: Select from dropdown (optional)
  • Is Public: Toggle to show on public shifts page

Step 4: Save Shift

Click Create to save shift. Status is automatically set to OPEN.

Managing Signups

Step 1: View Shift

Click Signups button on a shift row to open signups drawer.

Step 2: View Signup List

Drawer displays:

  • Volunteer Name: From signup or user account
  • Email: Contact email
  • Phone: Contact phone (if provided)
  • Signup Date: When volunteer signed up
  • Source: AUTHENTICATED | PUBLIC | ADMIN
  • Status: CONFIRMED | CANCELLED | NO_SHOW

Step 3: Manually Add Signup (Admin)

Click Add Signup button in drawer:

  • Email: Required (validates format)
  • Name: Required
  • Phone: Optional
  • Notes: Admin notes

System will:

  1. Check capacity (reject if FULL)
  2. Create TEMP user if email not in database
  3. Create signup with source=ADMIN
  4. Send confirmation email
  5. Update shift.currentVolunteers count

Step 4: Mark No-Show

Click Mark No-Show on signup row to update status. Useful for tracking volunteer reliability.

Step 5: Delete Signup

Click Delete to remove signup. Decrements shift.currentVolunteers count.

Emailing All Volunteers

Step 1: Click "Email All"

On shift row, click Email All button.

Step 2: Compose Email

Modal opens with:

  • Subject: Pre-filled with shift title
  • Message: Rich text editor with placeholders
  • Placeholders: {{name}}, {{email}}, {{phone}}, {{shift.title}}, {{shift.date}}, {{shift.startTime}}, {{shift.endTime}}

Step 3: Preview

Click Preview to see sample email with placeholders replaced.

Step 4: Send

Click Send Email to queue emails to all CONFIRMED volunteers. Uses BullMQ email queue for async processing.

Updating Shift Status

Step 1: Edit Shift

Click Edit on shift row.

Step 2: Change Status

Update status dropdown:

  • OPEN: Accepting signups
  • FULL: At capacity (auto-set when currentVolunteers >= maxVolunteers)
  • CANCELLED: Cancelled by admin

Step 3: Save

Click Update. If status changed to CANCELLED, optionally send cancellation email to all volunteers.

Public Workflow

Public users can browse and signup for shifts without authentication.

Step 1: Navigate to Public Shifts Page

Visit /shifts (public route, no auth required).

Step 2: Browse Shifts

View upcoming shifts as cards:

  • Shift Title: Large heading
  • Date/Time: Formatted date + time range
  • Location: Meeting point
  • Volunteers: "5 / 20 spots filled" progress bar
  • Cut: Territory name (if assigned)
  • Status Badge: OPEN (green), FULL (red), CANCELLED (gray)

Step 3: Filter Shifts

Use filters:

  • Date: Show only shifts on specific date
  • Status: OPEN only (hide FULL/CANCELLED)

Step 4: Click Signup

Click Signup button on shift card. Modal opens.

Step 5: Fill Signup Form

Complete form:

  • Name: Required
  • Email: Required (validates format)
  • Phone: Optional

Step 6: Submit

Click Sign Up. System will:

  1. Check capacity (reject if FULL)
  2. Create TEMP user with email (if not exists)
  3. Create shift signup with source=PUBLIC
  4. Send confirmation email
  5. Update shift.currentVolunteers count
  6. Auto-update status to FULL if at capacity

Step 7: Receive Confirmation

Check email for confirmation with shift details.

Volunteer Workflow

Authenticated volunteers can view assigned shifts and cancel signups.

Step 1: Login

Login at /login with volunteer account.

Step 2: Navigate to Assignments

Navigate to Volunteer → My Assignments.

Step 3: View Assigned Shifts

Table displays:

  • Shift Title: Linked to shift details
  • Date/Time: Formatted
  • Location: Meeting point
  • Cut: Territory name (if assigned)
  • Status: Signup status

Step 4: View Shift Details

Click shift title to view:

  • Description: Full shift details
  • Volunteers: List of other volunteers (names only, privacy protected)
  • Map: If cut assigned, show cut polygon on map

Step 5: Cancel Signup

Click Cancel Signup button. Confirmation modal appears.

Step 6: Confirm Cancellation

Click Confirm. System will:

  1. Update signup status to CANCELLED
  2. Decrement shift.currentVolunteers count
  3. Update shift status to OPEN if was FULL
  4. Send cancellation confirmation email

Code Examples

Shift Service Create (Backend)

// api/src/modules/map/shifts/shifts.service.ts
async create(data: CreateShiftInput, userId: string) {
  const shift = await prisma.shift.create({
    data: {
      title: data.title,
      description: data.description,
      date: new Date(data.date),
      startTime: data.startTime,
      endTime: data.endTime,
      location: data.location,
      maxVolunteers: data.maxVolunteers,
      isPublic: data.isPublic,
      cutId: data.cutId,
      createdBy: userId,
    },
  });

  return shift;
}

Public Signup (Backend)

// api/src/modules/map/shifts/shifts.service.ts
import bcrypt from 'bcryptjs';

async publicSignup(shiftId: string, data: PublicSignupInput) {
  const shift = await prisma.shift.findUnique({ where: { id: shiftId } });

  if (!shift) {
    throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
  }

  // Check capacity
  if (shift.currentVolunteers >= shift.maxVolunteers) {
    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
  }

  // Find or create TEMP user
  let user = await prisma.user.findUnique({ where: { email: data.email } });

  if (!user) {
    const password = generateReadablePassword(); // e.g., "BlueEagle42"
    const hashedPassword = await bcrypt.hash(password, 10);

    user = await prisma.user.create({
      data: {
        email: data.email,
        name: data.name,
        phone: data.phone,
        password: hashedPassword,
        role: 'TEMP',
      },
    });

    logger.info('Created TEMP user for shift signup', {
      email: data.email,
      shiftId,
    });
  }

  // Create signup
  const signup = await prisma.shiftSignup.create({
    data: {
      shiftId,
      userId: user.id,
      userEmail: user.email,
      userName: user.name ?? data.name,
      userPhone: user.phone ?? data.phone,
      signupSource: SignupSource.PUBLIC,
      status: SignupStatus.CONFIRMED,
    },
  });

  // Increment volunteer count
  await prisma.shift.update({
    where: { id: shiftId },
    data: {
      currentVolunteers: { increment: 1 },
      status: shift.currentVolunteers + 1 >= shift.maxVolunteers
        ? ShiftStatus.FULL
        : shift.status,
    },
  });

  // Send confirmation email
  await emailService.sendShiftConfirmation(user.email, shift, user.name ?? data.name);

  recordShiftSignup('public');

  return signup;
}

Generate Readable Password (Backend)

// api/src/modules/map/shifts/shifts.service.ts
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}`;
}

// Example output: "BoldWolf72", "SwiftStar45"

Shift Confirmation Email (Backend)

// api/src/services/email.service.ts
async sendShiftConfirmation(
  to: string,
  shift: Shift,
  userName: string
): Promise<void> {
  const subject = `Shift Confirmation - ${shift.title}`;

  const body = `
Hi ${userName},

You're confirmed for:
${shift.title}

Date: ${dayjs(shift.date).format('MMMM D, YYYY')}
Time: ${shift.startTime} - ${shift.endTime}
Location: ${shift.location}

${shift.description ? `\nDetails:\n${shift.description}\n` : ''}

To cancel your signup, reply to this email.

Thank you!
`;

  await this.sendEmail({ to, subject, text: body });
}

Public Shifts List (Frontend)

// admin/src/pages/public/ShiftsPage.tsx
const fetchShifts = async () => {
  try {
    const { data } = await axios.get('/api/public/map/shifts', {
      params: {
        upcoming: true, // Only show future shifts
      },
    });

    setShifts(data.shifts);
  } catch (error) {
    message.error('Failed to load shifts');
  }
};

useEffect(() => {
  fetchShifts();
}, []);

Signup Modal (Frontend)

// admin/src/pages/public/ShiftsPage.tsx
const handleSignup = async (values: any) => {
  try {
    await axios.post(`/api/public/map/shifts/${selectedShift.id}/signup`, {
      name: values.name,
      email: values.email,
      phone: values.phone,
    });

    message.success('Signup successful! Check your email for confirmation.');
    setSignupModalOpen(false);
    signupForm.resetFields();
    fetchShifts(); // Refresh to update volunteer count
  } catch (error: any) {
    if (error.response?.data?.code === 'SHIFT_FULL') {
      message.error('This shift is now full. Please choose another shift.');
    } else {
      message.error('Signup failed. Please try again.');
    }
  }
};

Volunteer Assignments (Frontend)

// admin/src/pages/volunteer/VolunteerShiftsPage.tsx
const fetchAssignments = async () => {
  try {
    const { data } = await api.get('/map/canvass/volunteer/assignments');

    setAssignments(data);
  } catch (error) {
    message.error('Failed to load assignments');
  }
};

Troubleshooting

Issue: Shift Status Not Auto-Updating to FULL

Symptoms:

  • Shift accepts signups beyond maxVolunteers
  • Status remains OPEN even when at capacity
  • currentVolunteers count incorrect

Causes:

  • currentVolunteers not incremented on signup
  • Signup deletion not decrementing count
  • Race condition on concurrent signups

Solutions:

  1. Use database transaction for capacity check + signup creation:
await prisma.$transaction(async (tx) => {
  const shift = await tx.shift.findUnique({
    where: { id: shiftId },
    select: { currentVolunteers: true, maxVolunteers: true },
  });

  if (shift.currentVolunteers >= shift.maxVolunteers) {
    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
  }

  await tx.shiftSignup.create({ data: signupData });

  await tx.shift.update({
    where: { id: shiftId },
    data: {
      currentVolunteers: { increment: 1 },
      status: shift.currentVolunteers + 1 >= shift.maxVolunteers
        ? ShiftStatus.FULL
        : shift.status,
    },
  });
});
  1. Verify count matches reality:
-- Check if currentVolunteers matches actual signup count
SELECT s.id, s.title, s.currentVolunteers,
  COUNT(ss.id) as actual_signups
FROM "Shift" s
LEFT JOIN "ShiftSignup" ss ON s.id = ss."shiftId"
  AND ss.status = 'CONFIRMED'
GROUP BY s.id
HAVING s."currentVolunteers" != COUNT(ss.id);
  1. Recalculate counts:
// Admin utility to fix counts
async function recalculateShiftCounts() {
  const shifts = await prisma.shift.findMany();

  for (const shift of shifts) {
    const count = await prisma.shiftSignup.count({
      where: {
        shiftId: shift.id,
        status: SignupStatus.CONFIRMED,
      },
    });

    await prisma.shift.update({
      where: { id: shift.id },
      data: {
        currentVolunteers: count,
        status: count >= shift.maxVolunteers ? ShiftStatus.FULL : ShiftStatus.OPEN,
      },
    });
  }
}

Issue: Confirmation Emails Not Sending

Symptoms:

  • Users signup successfully but no email received
  • MailHog shows no emails in dev
  • SMTP errors in API logs

Causes:

  • EMAIL_TEST_MODE not set in dev
  • SMTP credentials invalid
  • Email service not configured
  • Email in spam folder

Solutions:

  1. Check email service config:
# Verify SMTP settings in .env
grep "SMTP_\|EMAIL_TEST_MODE" .env

# In development, use MailHog
EMAIL_TEST_MODE=true

# In production, configure SMTP
EMAIL_TEST_MODE=false
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
  1. Test email service:
# Send test email via API
curl -X POST http://localhost:4000/api/test-email \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"to":"test@example.com","subject":"Test","text":"Test email"}'
  1. Check MailHog in dev:
# Access MailHog UI
open http://localhost:8025

# View email queue in BullMQ
docker compose exec redis redis-cli KEYS "bull:email-queue:*"
  1. Check spam folder (production):

Add SPF/DKIM/DMARC records to domain to improve deliverability.

Issue: TEMP User Password Security

Symptoms:

  • TEMP users can't login with generated password
  • Password doesn't meet complexity requirements
  • Account locked after signup

Causes:

  • Generated password doesn't meet 12-char minimum
  • Password missing uppercase/lowercase/digit
  • Password not sent to user (they can't login)

Solutions:

  1. Ensure generated password meets policy:
function generateReadablePassword(): string {
  // Must meet: 12+ chars, uppercase, lowercase, digit
  const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; // Uppercase
  const noun = nouns[Math.floor(Math.random() * nouns.length)]; // Uppercase
  const num = Math.floor(Math.random() * 90) + 10; // 2 digits
  const lower = 'abc'; // Lowercase

  return `${adj}${noun}${num}${lower}`; // E.g., "BoldWolf72abc" (14 chars)
}
  1. Send password to user (security risk, consider alternative):

Include password in confirmation email (only for TEMP users, one-time):

Your temporary account has been created.

Email: {{email}}
Password: {{password}}

Please change your password after logging in.

Better Alternative: Use passwordless login link:

Click here to confirm your shift and access your account:
https://app.cmlite.org/confirm-shift/{{signupToken}}

Performance Considerations

Shift Query Optimization

Index Upcoming Shifts:

Create composite index for common query:

CREATE INDEX idx_shifts_upcoming ON "Shift" (date, "isPublic", status)
WHERE date >= CURRENT_DATE;

Efficient Public Query:

// Only query future public shifts
const shifts = await prisma.shift.findMany({
  where: {
    isPublic: true,
    date: { gte: new Date() },
    status: { not: ShiftStatus.CANCELLED },
  },
  orderBy: { date: 'asc' },
  include: {
    cut: { select: { id: true, name: true } },
    _count: {
      select: { signups: { where: { status: SignupStatus.CONFIRMED } } },
    },
  },
});

Email Queue Performance

Batch Email Sending:

Use BullMQ queue to avoid blocking API requests:

// Add email jobs to queue
for (const volunteer of volunteers) {
  await emailQueue.add('send-email', {
    to: volunteer.email,
    subject: 'Shift Update',
    text: message,
  });
}

// Worker processes jobs asynchronously
emailQueue.process('send-email', async (job) => {
  await emailService.sendEmail(job.data);
});

Concurrent Signup Handling

Prevent Race Conditions:

Use database transactions with SELECT FOR UPDATE:

await prisma.$transaction(async (tx) => {
  const shift = await tx.shift.findUnique({
    where: { id: shiftId },
    // Lock row to prevent concurrent updates
  });

  if (shift.currentVolunteers >= shift.maxVolunteers) {
    throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
  }

  // Create signup and update count atomically
  await tx.shiftSignup.create({ data: signupData });
  await tx.shift.update({
    where: { id: shiftId },
    data: { currentVolunteers: { increment: 1 } },
  });
});

Backend Modules:

Frontend Pages:

Database:

Features:

  • Cuts — Territory assignment for shifts
  • Canvassing — Shift-based canvassing sessions
  • Users — TEMP user management