947 lines
24 KiB
Markdown
947 lines
24 KiB
Markdown
# 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<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)
|
|
|
|
```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
|