# 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 ```mermaid 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](../../database/models/map.md#shift-model) 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:** ```typescript enum ShiftStatus { OPEN // Accepting signups FULL // At capacity CANCELLED // Cancelled by admin } ``` ### ShiftSignup Model See [ShiftSignup Model Documentation](../../database/models/map.md#shiftsignup-model) 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:** ```typescript enum SignupSource { AUTHENTICATED // Logged-in user signup PUBLIC // Public signup (creates TEMP user) ADMIN // Admin created signup } ``` **Signup Status Enum:** ```typescript enum SignupStatus { CONFIRMED // Signup active CANCELLED // Volunteer cancelled NO_SHOW // Marked as no-show by admin } ``` **Related Models:** - [Shift](../../database/models/map.md#shift-model) — Parent shift - [User](../../database/models/user.md) — Volunteer account (TEMP role for public signups) - [Cut](../../database/models/map.md#cut-model) — Geographic territory assignment - [CanvassSession](../../database/models/canvass.md#canvasssession-model) — Linked to shift for canvassing ## API Endpoints See [Shifts Backend Module Documentation](../../backend/modules/map/shifts.md) 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) ```typescript // 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) ```typescript // 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) ```typescript // 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) ```typescript // api/src/services/email.service.ts async sendShiftConfirmation( to: string, shift: Shift, userName: string ): Promise { 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) ```typescript // 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) ```typescript // 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) ```typescript // 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: ```typescript 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, }, }); }); ``` 2. **Verify count matches reality**: ```sql -- 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); ``` 3. **Recalculate counts**: ```typescript // 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**: ```bash # 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 ``` 2. **Test email service**: ```bash # 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"}' ``` 3. **Check MailHog in dev**: ```bash # Access MailHog UI open http://localhost:8025 # View email queue in BullMQ docker compose exec redis redis-cli KEYS "bull:email-queue:*" ``` 4. **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**: ```typescript 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) } ``` 2. **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: ```sql CREATE INDEX idx_shifts_upcoming ON "Shift" (date, "isPublic", status) WHERE date >= CURRENT_DATE; ``` **Efficient Public Query:** ```typescript // 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: ```typescript // 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`: ```typescript 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 } }, }); }); ``` ## Related Documentation **Backend Modules:** - [Shifts Backend Module](../../backend/modules/map/shifts.md) — API implementation - [Email Service](../../backend/modules/services/email.md) — Confirmation emails **Frontend Pages:** - [ShiftsPage](../../frontend/pages/admin/shifts-page.md) — Admin CRUD interface - [Public ShiftsPage](../../frontend/pages/public/shifts-page.md) — Public signup - [VolunteerShiftsPage](../../frontend/pages/volunteer/shifts-page.md) — Volunteer assignments **Database:** - [Shift Model](../../database/models/map.md#shift-model) — Shift schema - [ShiftSignup Model](../../database/models/map.md#shiftsignup-model) — Signup records - [User Model](../../database/models/user.md) — TEMP user accounts **Features:** - [Cuts](./cuts.md) — Territory assignment for shifts - [Canvassing](./canvassing.md) — Shift-based canvassing sessions - [Users](../auth/users.md) — TEMP user management