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