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:
datemust beYYYY-MM-DDformatstartTime,endTimemust beHH:MMformatmaxVolunteersmust be >= 1cutIdis 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
FULLif capacity reached - Transaction ensures atomicity
Error Responses:
400 Bad Request: Shift is full404 Not Found: Shift not found409 Conflict: Volunteer already signed up
DELETE /api/map/shifts/:id/signups/:signupId
Admin remove volunteer signup.
Path Parameters:
id(string): Shift IDsignupId(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 cancelled404 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 nameSHIFT_TITLE— Shift titleSHIFT_DATE— Formatted dateSHIFT_START_TIME— Start timeSHIFT_END_TIME— End timeSHIFT_LOCATION— LocationSHIFT_DESCRIPTION— DescriptionCURRENT_VOLUNTEERS— Current signup countMAX_VOLUNTEERS— Max capacitySHIFT_STATUS— StatusORGANIZATION_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
FULLif capacity reached - Records Prometheus metric
cm_shift_signups_total
Error Responses:
400 Bad Request: Shift is full, cancelled, or past403 Forbidden: Shift is not public404 Not Found: Shift not found409 Conflict: Already signed up429 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 cancelled404 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:
-
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" } -
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, }, }); -
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
signupSourcetoAUTHENTICATED
Behavior — Re-activation:
If cancelled signup exists:
- Re-activates existing signup record (status → CONFIRMED)
- Does not create duplicate signup
Transaction:
- Signup creation +
currentVolunteersincrement + status update are atomic
Error Responses:
400 Bad Request: Shift full, not open, or past403 Forbidden: Shift not public404 Not Found: Shift not found409 Conflict: Already signed up429 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 fullAppError(404)if shift not foundAppError(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 pastAppError(403)if not publicAppError(404)if shift not foundAppError(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_NAMESHIFT_TITLESHIFT_DATESHIFT_START_TIMESHIFT_END_TIMESHIFT_LOCATIONSHIFT_DESCRIPTIONCURRENT_VOLUNTEERSMAX_VOLUNTEERSSHIFT_STATUSORGANIZATION_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
isPublicflag) - 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_signupsin 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:
-
MailHog mode enabled:
EMAIL_TEST_MODE=true # Emails go to MailHog, not SMTP -
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 -
Template missing:
# Check template exists ls api/src/templates/shift-signup-confirmation.html ls api/src/templates/shift-signup-confirmation.txt -
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=falsefor 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:
expiresAtis 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);
},
Related Documentation
- Canvass Module - Volunteer canvassing system (uses
Shift.cutId) - Cuts Module - Polygon management (cut association)
- Users Module - User CRUD (temp user management)
- Auth Module - JWT authentication (temp user login)
- Settings Module - Organization name for emails
- Email Service - Email sending (confirmation emails)
- Frontend: ShiftsPage - Admin shift management UI
- Frontend: Volunteer Portal - Volunteer shift signup UI
- Frontend: Public Shifts Page - Public shift listings
- API Reference: Shifts - Complete endpoint reference
- User Guide: Volunteer Manager - Managing volunteers
- Troubleshooting: Email Issues - Email debugging guide