1657 lines
38 KiB
Markdown
1657 lines
38 KiB
Markdown
# 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
|
|
|
|
```prisma
|
|
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:**
|
|
|
|
```bash
|
|
curl -H "Authorization: Bearer <token>" \
|
|
"http://api.cmlite.org/api/map/shifts?upcoming=true&status=OPEN&page=1&limit=10"
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
|
|
```json
|
|
{
|
|
"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:**
|
|
|
|
```bash
|
|
curl -H "Authorization: Bearer <token>" \
|
|
"http://api.cmlite.org/api/map/shifts/stats"
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
|
|
```json
|
|
{
|
|
"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:**
|
|
|
|
```bash
|
|
curl -H "Authorization: Bearer <token>" \
|
|
"http://api.cmlite.org/api/map/shifts/clx1234567890"
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
|
|
```json
|
|
{
|
|
"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:**
|
|
|
|
```json
|
|
{
|
|
"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:**
|
|
|
|
- `date` must be `YYYY-MM-DD` format
|
|
- `startTime`, `endTime` must be `HH:MM` format
|
|
- `maxVolunteers` must be >= 1
|
|
- `cutId` is optional (for non-canvassing shifts)
|
|
|
|
---
|
|
|
|
### PUT /api/map/shifts/:id
|
|
|
|
Update shift. Auto-updates status if capacity changes.
|
|
|
|
**Request Body (Partial):**
|
|
|
|
```json
|
|
{
|
|
"maxVolunteers": 20,
|
|
"status": "OPEN"
|
|
}
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
|
|
Returns updated shift object.
|
|
|
|
**Auto-Status Logic:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```json
|
|
{
|
|
"userEmail": "volunteer@example.com",
|
|
"userName": "Jane Volunteer"
|
|
}
|
|
```
|
|
|
|
**Response (201 Created):**
|
|
|
|
```json
|
|
{
|
|
"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 `FULL` if capacity reached
|
|
- Transaction ensures atomicity
|
|
|
|
**Error Responses:**
|
|
|
|
- `400 Bad Request`: Shift is full
|
|
- `404 Not Found`: Shift not found
|
|
- `409 Conflict`: Volunteer already signed up
|
|
|
|
---
|
|
|
|
### DELETE /api/map/shifts/:id/signups/:signupId
|
|
|
|
Admin remove volunteer signup.
|
|
|
|
**Path Parameters:**
|
|
|
|
- `id` (string): Shift ID
|
|
- `signupId` (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 cancelled
|
|
- `404 Not Found`: Signup not found
|
|
|
|
---
|
|
|
|
### POST /api/map/shifts/:id/email-details
|
|
|
|
Email shift details to all confirmed volunteers.
|
|
|
|
**Example Request:**
|
|
|
|
```bash
|
|
curl -X POST \
|
|
-H "Authorization: Bearer <token>" \
|
|
"http://api.cmlite.org/api/map/shifts/clx1234567890/email-details"
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
|
|
```json
|
|
{
|
|
"sent": 8,
|
|
"failed": 0
|
|
}
|
|
```
|
|
|
|
**Email Template:**
|
|
|
|
Uses `shift-details.html` and `shift-details.txt` templates with variables:
|
|
|
|
- `USER_NAME` — Volunteer name
|
|
- `SHIFT_TITLE` — Shift title
|
|
- `SHIFT_DATE` — Formatted date
|
|
- `SHIFT_START_TIME` — Start time
|
|
- `SHIFT_END_TIME` — End time
|
|
- `SHIFT_LOCATION` — Location
|
|
- `SHIFT_DESCRIPTION` — Description
|
|
- `CURRENT_VOLUNTEERS` — Current signup count
|
|
- `MAX_VOLUNTEERS` — Max capacity
|
|
- `SHIFT_STATUS` — Status
|
|
- `ORGANIZATION_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:**
|
|
|
|
```bash
|
|
curl -H "Authorization: Bearer <token>" \
|
|
"http://api.cmlite.org/api/map/shifts/volunteer/upcoming"
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
|
|
```json
|
|
[
|
|
{
|
|
"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:**
|
|
|
|
```bash
|
|
curl -H "Authorization: Bearer <token>" \
|
|
"http://api.cmlite.org/api/map/shifts/volunteer/my-signups"
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
|
|
```json
|
|
[
|
|
{
|
|
"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:**
|
|
|
|
```bash
|
|
curl -X POST \
|
|
-H "Authorization: Bearer <token>" \
|
|
"http://api.cmlite.org/api/map/shifts/volunteer/clx1234567890/signup"
|
|
```
|
|
|
|
**Response (201 Created):**
|
|
|
|
```json
|
|
{
|
|
"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 `FULL` if capacity reached
|
|
- Records Prometheus metric `cm_shift_signups_total`
|
|
|
|
**Error Responses:**
|
|
|
|
- `400 Bad Request`: Shift is full, cancelled, or past
|
|
- `403 Forbidden`: Shift is not public
|
|
- `404 Not Found`: Shift not found
|
|
- `409 Conflict`: Already signed up
|
|
- `429 Too Many Requests`: Rate limit exceeded
|
|
|
|
---
|
|
|
|
### DELETE /api/map/shifts/volunteer/:id/signup
|
|
|
|
Cancel own signup.
|
|
|
|
**Path Parameters:**
|
|
|
|
- `id` (string): Shift ID
|
|
|
|
**Example Request:**
|
|
|
|
```bash
|
|
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 cancelled
|
|
- `404 Not Found`: Signup not found
|
|
|
|
---
|
|
|
|
## Public Endpoint Details
|
|
|
|
### GET /api/map/shifts/public
|
|
|
|
List public upcoming shifts (no auth required).
|
|
|
|
**Example Request:**
|
|
|
|
```bash
|
|
curl http://api.cmlite.org/api/map/shifts/public
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
|
|
```json
|
|
[
|
|
{
|
|
"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:**
|
|
|
|
```json
|
|
{
|
|
"email": "newvolunteer@example.com",
|
|
"name": "John Doe",
|
|
"phone": "+1234567890"
|
|
}
|
|
```
|
|
|
|
**Response (201 Created):**
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
1. **Generate readable password:**
|
|
```typescript
|
|
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"
|
|
}
|
|
```
|
|
|
|
2. **Create TEMP user:**
|
|
```typescript
|
|
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,
|
|
},
|
|
});
|
|
```
|
|
|
|
3. **Send confirmation email with temp password:**
|
|
```typescript
|
|
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 `signupSource` to `AUTHENTICATED`
|
|
|
|
**Behavior — Re-activation:**
|
|
|
|
If cancelled signup exists:
|
|
|
|
- Re-activates existing signup record (status → CONFIRMED)
|
|
- Does not create duplicate signup
|
|
|
|
**Transaction:**
|
|
|
|
- Signup creation + `currentVolunteers` increment + status update are atomic
|
|
|
|
**Error Responses:**
|
|
|
|
- `400 Bad Request`: Shift full, not open, or past
|
|
- `403 Forbidden`: Shift not public
|
|
- `404 Not Found`: Shift not found
|
|
- `409 Conflict`: Already signed up
|
|
- `429 Too Many Requests`: Rate limit exceeded
|
|
|
|
---
|
|
|
|
## Service Functions
|
|
|
|
### shiftsService.findAll(filters)
|
|
|
|
List shifts with pagination, search, and filtering.
|
|
|
|
**Usage:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
if (search) {
|
|
where.OR = [
|
|
{ title: { contains: search, mode: 'insensitive' } },
|
|
{ location: { contains: search, mode: 'insensitive' } },
|
|
];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### shiftsService.findById(id)
|
|
|
|
Get single shift with signups list.
|
|
|
|
**Usage:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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 full
|
|
- `AppError(404)` if shift not found
|
|
- `AppError(409)` if already signed up
|
|
|
|
---
|
|
|
|
### shiftsService.publicSignup(shiftId, data)
|
|
|
|
Public signup with temp user creation.
|
|
|
|
**Usage:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
const shiftDate = new Date(shift.date);
|
|
shiftDate.setDate(shiftDate.getDate() + 1); // Expires day after shift
|
|
```
|
|
|
|
**Email Template Variables:**
|
|
|
|
```typescript
|
|
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 past
|
|
- `AppError(403)` if not public
|
|
- `AppError(404)` if shift not found
|
|
- `AppError(409)` if duplicate signup
|
|
|
|
---
|
|
|
|
### shiftsService.removeSignup(signupId)
|
|
|
|
Cancel signup (admin).
|
|
|
|
**Usage:**
|
|
|
|
```typescript
|
|
await shiftsService.removeSignup('clx2222222222');
|
|
|
|
// Signup status → CANCELLED
|
|
// currentVolunteers decremented
|
|
// Shift status → OPEN
|
|
```
|
|
|
|
**Atomic Transaction:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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_NAME`
|
|
- `SHIFT_TITLE`
|
|
- `SHIFT_DATE`
|
|
- `SHIFT_START_TIME`
|
|
- `SHIFT_END_TIME`
|
|
- `SHIFT_LOCATION`
|
|
- `SHIFT_DESCRIPTION`
|
|
- `CURRENT_VOLUNTEERS`
|
|
- `MAX_VOLUNTEERS`
|
|
- `SHIFT_STATUS`
|
|
- `ORGANIZATION_NAME`
|
|
|
|
**Error Handling:**
|
|
|
|
Individual email failures are logged but do not stop batch processing.
|
|
|
|
---
|
|
|
|
## Validation Schemas
|
|
|
|
### Create Shift Schema
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```json
|
|
{
|
|
"email": "volunteer@example.com",
|
|
"name": "Jane Volunteer",
|
|
"phone": "+1234567890"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Code Examples
|
|
|
|
### Admin: Create Shift with Cut Association
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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 `isPublic` flag)
|
|
- **Status badges** (OPEN=green, FULL=orange, CANCELLED=red)
|
|
|
|
**State Management:**
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```sql
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
// 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`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```sql
|
|
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_signups` in psql
|
|
- Add application-level locking if race conditions persist:
|
|
```typescript
|
|
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:
|
|
|
|
```bash
|
|
docker compose logs -f api | grep "shift signup confirmation"
|
|
```
|
|
|
|
**Common Causes:**
|
|
|
|
1. **MailHog mode enabled:**
|
|
```env
|
|
EMAIL_TEST_MODE=true # Emails go to MailHog, not SMTP
|
|
```
|
|
|
|
2. **SMTP misconfiguration:**
|
|
```env
|
|
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
|
|
```
|
|
|
|
3. **Template missing:**
|
|
```bash
|
|
# Check template exists
|
|
ls api/src/templates/shift-signup-confirmation.html
|
|
ls api/src/templates/shift-signup-confirmation.txt
|
|
```
|
|
|
|
4. **Email service crash:**
|
|
```typescript
|
|
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=false` for 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:
|
|
|
|
```sql
|
|
SELECT id, email, role, "expiresAt", "createdAt"
|
|
FROM users
|
|
WHERE role = 'TEMP' AND "expiresAt" < NOW() AND status = 'ACTIVE';
|
|
```
|
|
|
|
**Expected Behavior:**
|
|
|
|
- `expiresAt` is set to shift date + 1 day
|
|
- Expired users should be marked EXPIRED by auth middleware
|
|
|
|
**Solution:**
|
|
|
|
Run cleanup script or add cron job:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
export const shiftSignupRateLimit = createRateLimiter({
|
|
windowMs: 60 * 1000, // 1 minute
|
|
max: 5, // 5 signups per minute
|
|
});
|
|
```
|
|
|
|
**Solution:**
|
|
|
|
Increase limit if legitimate use case:
|
|
|
|
```typescript
|
|
max: 10, // Allow 10 signups per minute
|
|
```
|
|
|
|
**Alternative:**
|
|
|
|
Whitelist admin IPs:
|
|
|
|
```typescript
|
|
skip: (req) => {
|
|
const ip = req.ip || req.connection.remoteAddress;
|
|
return ['127.0.0.1', '::1', ADMIN_IP].includes(ip);
|
|
},
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [Canvass Module](/v2/backend/modules/canvass.md) - Volunteer canvassing system (uses `Shift.cutId`)
|
|
- [Cuts Module](/v2/backend/modules/cuts.md) - Polygon management (cut association)
|
|
- [Users Module](/v2/backend/modules/users.md) - User CRUD (temp user management)
|
|
- [Auth Module](/v2/backend/modules/auth.md) - JWT authentication (temp user login)
|
|
- [Settings Module](/v2/backend/modules/settings.md) - Organization name for emails
|
|
- [Email Service](/v2/backend/services/email.md) - Email sending (confirmation emails)
|
|
- [Frontend: ShiftsPage](/v2/frontend/pages/admin/shifts-page.md) - Admin shift management UI
|
|
- [Frontend: Volunteer Portal](/v2/frontend/pages/volunteer/shifts-page.md) - Volunteer shift signup UI
|
|
- [Frontend: Public Shifts Page](/v2/frontend/pages/public/shifts-page.md) - Public shift listings
|
|
- [API Reference: Shifts](/v2/api-reference/shifts.md) - Complete endpoint reference
|
|
- [User Guide: Volunteer Manager](/v2/user-guides/volunteer-manager-guide.md) - Managing volunteers
|
|
- [Troubleshooting: Email Issues](/v2/troubleshooting/email-issues.md) - Email debugging guide
|