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:
- Admin creates shift → Validates date/time, assigns cut (optional), saves to database
- Public user browses → Query upcoming shifts (isPublic=true, date >=today), display cards
- Public signup → Check capacity, create TEMP user if unauthenticated, create signup record, send confirmation email
- Volunteer views assignments → Query signups for current user, include shift + cut details
- 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 detailsdate: 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/descriptionmaxVolunteers: Maximum volunteer capacitycurrentVolunteers: Current signup count (auto-updated)status: OPEN | FULL | CANCELLEDisPublic: Show on public shifts pagecutId: 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 ShiftuserId: Foreign key to User (optional for TEMP users)userEmail: Email address (required, used for confirmations)userName: Display nameuserPhone: Phone number (optional)status: CONFIRMED | CANCELLED | NO_SHOWsignupDate: When signup occurredsignupSource: AUTHENTICATED | PUBLIC | ADMINnotes: 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:
- Check capacity (reject if FULL)
- Create TEMP user if email not in database
- Create signup with source=ADMIN
- Send confirmation email
- 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:
- Check capacity (reject if FULL)
- Create TEMP user with email (if not exists)
- Create shift signup with source=PUBLIC
- Send confirmation email
- Update shift.currentVolunteers count
- 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:
- Update signup status to CANCELLED
- Decrement shift.currentVolunteers count
- Update shift status to OPEN if was FULL
- 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:
- 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,
},
});
});
- 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);
- 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:
- 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
- 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"}'
- 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:*"
- 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:
- 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)
}
- 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 } },
});
});
Related Documentation
Backend Modules:
- Shifts Backend Module — API implementation
- Email Service — Confirmation emails
Frontend Pages:
- ShiftsPage — Admin CRUD interface
- Public ShiftsPage — Public signup
- VolunteerShiftsPage — Volunteer assignments
Database:
- Shift Model — Shift schema
- ShiftSignup Model — Signup records
- User Model — TEMP user accounts
Features:
- Cuts — Territory assignment for shifts
- Canvassing — Shift-based canvassing sessions
- Users — TEMP user management