From 9fd09fa9049311639408b43c22228e0e27b8f0ae Mon Sep 17 00:00:00 2001 From: theosiemensrhodes Date: Wed, 11 Mar 2026 22:55:07 -0700 Subject: [PATCH 1/9] fix: jobService seperator not accepted; test issues --- src/server/services/jobService.ts | 5 ++- src/test/integration/job-service.test.ts | 41 ++++++++++++------------ 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/server/services/jobService.ts b/src/server/services/jobService.ts index 3d3dde7e..de3199ac 100644 --- a/src/server/services/jobService.ts +++ b/src/server/services/jobService.ts @@ -78,7 +78,6 @@ export class JobService implements IJobService { } async start(): Promise { - if (this.env.NODE_ENV === "test") return; if (sharedBossState.isStarted) return; sharedBossState.startPromise ??= this.bootstrap().catch((error) => { @@ -190,7 +189,7 @@ export class JobService implements IJobService { queueName, cron, (data ?? null) as object | null, - scheduleOptions as any, + scheduleOptions, ); this.trackRecurringQueue(jobName, queueName); return null; @@ -331,7 +330,7 @@ export class JobService implements IJobService { if (!normalized) { throw new Error("correlationId must be a non-empty string."); } - return `${jobName}:${normalized}`; + return `${jobName}/${normalized}`; } private async ensureWorkerRegistered( diff --git a/src/test/integration/job-service.test.ts b/src/test/integration/job-service.test.ts index 78c2493f..5ed5b534 100644 --- a/src/test/integration/job-service.test.ts +++ b/src/test/integration/job-service.test.ts @@ -273,17 +273,13 @@ describe("JobService", () => { expect(result).toBeNull(); }); - it("rejects correlationId due to colon separator in queue name (known bug)", async () => { - // getQueueName() produces "jobs.integration-test:tenant-1" but pg-boss - // only allows alphanumeric, underscores, hyphens, periods, and slashes. - // The ":" separator is invalid. This test documents the current behavior. - await expect( - jobService.run( - "jobs.integration-test", - { message: "cron-corr" }, - { cron: "0 * * * *", correlationId: "tenant-1" }, - ), - ).rejects.toThrow(/Name can only contain/); + it("schedules cron job with correlationId", async () => { + const result = await jobService.run( + "jobs.integration-test", + { message: "cron-corr" }, + { cron: "0 * * * *", correlationId: "tenant-1" }, + ); + expect(result).toBeNull(); }); it("rejects cron with runAt", async () => { @@ -320,10 +316,13 @@ describe("JobService", () => { ).resolves.toBeUndefined(); }); - it("unschedules by correlationId (no-ops when schedule does not exist)", async () => { - // unschedule with correlationId resolves even if the schedule was never - // created — pg-boss.unschedule does not validate queue name format the - // same way as getQueue/createQueue. + it("unschedules by correlationId", async () => { + await jobService.run( + "jobs.integration-test", + { message: "corr-unsched" }, + { cron: "0 * * * *", correlationId: "tenant-2" }, + ); + await expect( jobService.unschedule("jobs.integration-test", { correlationId: "tenant-2", @@ -332,9 +331,9 @@ describe("JobService", () => { }); it("throws for unknown job name", async () => { - await expect( - jobService.unschedule("jobs.nonexistent"), - ).rejects.toThrow("Unknown job name"); + await expect(jobService.unschedule("jobs.nonexistent")).rejects.toThrow( + "Unknown job name", + ); }); }); @@ -366,9 +365,9 @@ describe("JobService", () => { describe("error handling", () => { it("throws for unknown job name on run()", async () => { - await expect( - jobService.run("jobs.does-not-exist"), - ).rejects.toThrow("Unknown job name"); + await expect(jobService.run("jobs.does-not-exist")).rejects.toThrow( + "Unknown job name", + ); }); it("throws for unknown job name on unschedule()", async () => { From 3f46826555266fa6d21d3163f5aef8ea61ba6120 Mon Sep 17 00:00:00 2001 From: theosiemensrhodes Date: Fri, 20 Mar 2026 00:39:46 -0700 Subject: [PATCH 2/9] feat: notifications with 2 example notifs, coverage requested and shift cancelled --- NOTIFICATION_SYSTEM_PLAN.md | 507 ++++ src/components/app-navbar.tsx | 7 +- .../notifications/notification-inbox.tsx | 110 + .../notifications/notification-item.tsx | 60 + .../pages/notifications-settings-content.tsx | 136 +- src/models/api/notification.ts | 31 + src/server/api/di-container.ts | 20 + src/server/api/root.ts | 2 + src/server/api/routers/notification-router.ts | 68 + .../db/migrations/0010_lethal_stature.sql | 37 + .../db/migrations/meta/0010_snapshot.json | 2692 +++++++++++++++++ src/server/db/migrations/meta/_journal.json | 7 + src/server/db/schema/index.ts | 1 + src/server/db/schema/notification.ts | 100 + .../emails/templates/coverage-available.tsx | 44 + .../emails/templates/coverage-requested.tsx | 44 + .../emails/templates/shift-cancelled.tsx | 46 + .../definitions/process-notification.job.ts | 29 + src/server/jobs/registry.ts | 2 + src/server/notifications/registry.ts | 102 + src/server/notifications/types.ts | 43 + src/server/services/entity/coverageService.ts | 38 +- src/server/services/entity/shiftService.ts | 21 +- .../services/notificationEventService.ts | 192 ++ src/server/services/notificationService.ts | 418 +++ src/server/services/preferenceService.ts | 173 ++ .../integration/preference-service.test.ts | 340 +++ src/test/mocks/mock-notification-services.ts | 73 + src/test/test-container.ts | 16 + 29 files changed, 5354 insertions(+), 5 deletions(-) create mode 100644 NOTIFICATION_SYSTEM_PLAN.md create mode 100644 src/components/notifications/notification-inbox.tsx create mode 100644 src/components/notifications/notification-item.tsx create mode 100644 src/models/api/notification.ts create mode 100644 src/server/api/routers/notification-router.ts create mode 100644 src/server/db/migrations/0010_lethal_stature.sql create mode 100644 src/server/db/migrations/meta/0010_snapshot.json create mode 100644 src/server/db/schema/notification.ts create mode 100644 src/server/emails/templates/coverage-available.tsx create mode 100644 src/server/emails/templates/coverage-requested.tsx create mode 100644 src/server/emails/templates/shift-cancelled.tsx create mode 100644 src/server/jobs/definitions/process-notification.job.ts create mode 100644 src/server/notifications/registry.ts create mode 100644 src/server/notifications/types.ts create mode 100644 src/server/services/notificationEventService.ts create mode 100644 src/server/services/notificationService.ts create mode 100644 src/server/services/preferenceService.ts create mode 100644 src/test/integration/preference-service.test.ts create mode 100644 src/test/mocks/mock-notification-services.ts diff --git a/NOTIFICATION_SYSTEM_PLAN.md b/NOTIFICATION_SYSTEM_PLAN.md new file mode 100644 index 00000000..72deab5e --- /dev/null +++ b/NOTIFICATION_SYSTEM_PLAN.md @@ -0,0 +1,507 @@ +# Notification & Event System Implementation Plan + +## Context + +The codebase currently has a pgBoss-based job system (`jobService`) with one registered job, an email service, and a placeholder "Notifications coming soon..." settings page. We need a unified notification/event system that: + +1. Provides a stable API for triggering notifications from domain events (shift cancellations, coverage requests, etc.) +2. **Persists every notification** to a database table so they can be displayed and filtered on the frontend +3. Delivers notifications via channels (email now, push later) respecting user preferences +4. Uses event-level jobs (one pgBoss job per event, not per recipient) that resolve recipients + preferences at execution time + +## Service Architecture (3 services) + +The system is split across three services with clear responsibilities: + +``` +Entity Services (shiftService, coverageService, ...) + │ + │ call typed methods like notifyShiftCancelled(...) + ▼ +┌─────────────────────────────┐ +│ NotificationEventService │ ← Event surface: typed domain methods, owns copywriting, +│ (the public API for │ audience selection, and context mapping +│ triggering notifications) │ +└──────────────┬──────────────┘ + │ calls notify({ type, audience, context, ... }) + ▼ +┌─────────────────────────────┐ +│ NotificationService │ ← Core engine: job dispatch, audience resolution, +│ (generic notification │ preference checking, persistence, email delivery, +│ dispatch + queries) │ frontend queries (list, unread count, mark read) +└─────────────────────────────┘ + │ reads preferences from + ▼ +┌─────────────────────────────┐ +│ PreferenceService │ ← Preferences: CRUD for user overrides, +│ (notification preferences) │ effective preference resolution (registry defaults +│ │ + user overrides), used by tRPC router + engine +└─────────────────────────────┘ +``` + +**Why this split?** +- **NotificationEventService** — Entity services call `this.notificationEventService.notifyShiftCancelled({ shiftId, cancelReason })` with just domain data. All copywriting (title, body), audience selection, source mapping, and idempotency key generation live here, not scattered across entity services. Adding a new notification = adding one method here + a registry entry. +- **NotificationService** — Generic engine. Doesn't know about shifts or coverage. Takes `{ type, audience, context }` and handles job dispatch, audience resolution, preference checking, persistence, and email delivery. Also serves frontend queries (list, unread count, mark read). +- **PreferenceService** — Owns the `notification_preference` table. Provides CRUD for user overrides and `getEffectivePreferences()` that merges registry defaults with user overrides. Used by both the notification engine (at delivery time) and the tRPC router (for the settings UI). + +--- + +## Step 1: Database Schema + +**New file: `src/server/db/schema/notification.ts`** + +Two tables: + +### `notification` table (persisted events for frontend + audit) +| Column | Type | Notes | +|--------|------|-------| +| `id` | uuid PK | `defaultRandom()` | +| `userId` | uuid FK → user | recipient, `onDelete: cascade` | +| `type` | text | notification type key e.g. `"shift.cancelled"` | +| `title` | text | rendered title | +| `body` | text | rendered body | +| `linkUrl` | text? | deep link e.g. `/shifts/{id}` | +| `sourceType` | text? | `"shift"`, `"coverageRequest"`, etc. | +| `sourceId` | uuid? | ID of source entity | +| `actorId` | uuid? FK → user | who triggered it, `onDelete: set null` | +| `read` | boolean | default `false` | +| `readAt` | timestamp? | when marked read | +| `emailSent` | boolean | default `false`, tracks if email was dispatched | +| `createdAt` | timestamp | `defaultNow()` | +| `idempotencyKey` | text? | unique, prevents duplicate notifications | + +Indexes: `(userId, createdAt)`, `(userId, read)`, `(type)`, `(sourceType, sourceId)` + +### `notification_preference` table (override-only) +| Column | Type | Notes | +|--------|------|-------| +| `id` | uuid PK | `defaultRandom()` | +| `userId` | uuid FK → user | `onDelete: cascade` | +| `type` | text | notification type key | +| `channel` | enum(`email`, `in_app`, `push`) | channel being overridden | +| `enabled` | boolean | override value | +| `updatedAt` | timestamp | `defaultNow()` | + +Unique constraint on `(userId, type, channel)`. Index on `(userId)`. + +**Modify: `src/server/db/schema/index.ts`** — add `export * from "./notification"` + +**Generate migration** via `drizzle-kit generate` + +--- + +## Step 2: Notification Type Registry + +**New file: `src/server/notifications/types.ts`** + +Core type definitions: +- `NotificationChannel = "email" | "in_app" | "push"` +- `Audience` union type: `{ kind: "user", userId }` | `{ kind: "users", userIds }` | `{ kind: "role", role }` | `{ kind: "shift", shiftId }` | `{ kind: "class", classId }` +- `NotificationTypeDefinition` — defines per-type: `key`, `channelDefaults`, `title(ctx)`, `body(ctx)`, `linkUrl?(ctx)`, `sourceType?`, `sourceId?(ctx)`, `emailTemplate?(ctx)` + +**New file: `src/server/notifications/registry.ts`** + +A plain object mapping type keys to their definitions. Start with two types: + +- **`shift.cancelled`** — defaults: `{ email: true, in_app: true }`. Title/body reference class name, date, reason. Source: shift. +- **`coverage.requested`** — defaults: `{ email: true, in_app: true }`. Title/body reference volunteer name, class, date. Source: coverageRequest. + +Adding new notification types = adding an entry here + a method on `NotificationEventService`. + +--- + +## Step 3: PreferenceService + +**New file: `src/server/services/preferenceService.ts`** + +Public interface (`IPreferenceService`): + +``` +// For settings UI (tRPC router) +getEffectivePreferences(userId): Promise + // merges registry defaults + user overrides for all types/channels + +setPreference({ userId, type, channel, enabled }): Promise +clearPreference({ userId, type, channel }): Promise + +// For notification engine (bulk resolution at delivery time) +getPreferencesForRecipients({ type, userIds }): Promise>> + // SELECT * FROM notification_preference WHERE userId IN (...) AND type = ? + // overlay onto registry defaults, return effective per-user/per-channel map +``` + +**DI dependencies**: `{ db }` + +### Preference resolution logic +1. Load all override rows for the given userIds + type +2. For each user, for each channel defined in the registry type: check override → fall back to registry default +3. Return a `Map>` for the engine to consume + +### `EffectivePreference` shape (for settings UI) +```ts +{ type: string; channel: NotificationChannel; enabled: boolean; isOverride: boolean } +``` +`isOverride: true` means the user has explicitly set this, `false` means it's the registry default. The UI can use this to show a "reset to default" action. + +--- + +## Step 4: NotificationService (core engine) + +**New file: `src/server/services/notificationService.ts`** + +Public interface (`INotificationService`): + +``` +// Dispatch — enqueues a pgBoss job +notify({ type, audience, context, actorId?, deliverAt?, idempotencyKey? }): Promise + +// Cancel a scheduled notification +cancel(idempotencyKey): Promise + +// Frontend queries +getNotifications({ userId, type?, read?, limit?, cursor? }): Promise<{ items, nextCursor }> +getUnreadCount(userId): Promise +markAsRead(notificationId, userId): Promise +markAllAsRead(userId): Promise + +// Internal — called by job handler only +_processNotification({ type, audience, context, actorId?, idempotencyKey? }): Promise +``` + +**DI dependencies**: `{ db, jobService, emailService, preferenceService }` + +### `notify()` implementation +- Serializes `{ type, audience, context, actorId, idempotencyKey }` as job payload +- Calls `jobService.run("jobs.process-notification", payload, { startAfter: deliverAt, singletonKey: idempotencyKey })` + +### `_processNotification()` implementation (called by job handler) +1. Look up type definition from registry +2. Resolve audience → list of `{ userId, email }`: + - `kind: "user"` → single lookup + - `kind: "users"` → `WHERE id IN (...)` + - `kind: "role"` → `WHERE role = ? AND status = 'active'` + - `kind: "shift"` → join `shiftAttendance` or `volunteerToSchedule` via schedule + - `kind: "class"` → join `volunteerToSchedule` + `instructorToSchedule` for all schedules under that courseId +3. Call `preferenceService.getPreferencesForRecipients({ type, userIds })` — single bulk query +4. For each recipient, check effective preferences per channel: + - **in_app channel**: batch `INSERT INTO notification` for all enabled recipients + - **email channel**: render email template (or fallback to title/body), call `emailService.send()` per enabled recipient +5. Respect `idempotencyKey` — skip if notification row with that key already exists + +### `getNotifications()` implementation +- Keyset pagination on `createdAt` (cursor = ISO timestamp) +- Filter by `type`, `read` status +- Always scoped to `userId` (security enforced at service level) + +--- + +## Step 5: NotificationEventService (event surface) + +**New file: `src/server/services/notificationEventService.ts`** + +This is the **only service that entity services interact with**. It provides typed, domain-specific methods that encapsulate all notification details (copywriting, audience, source mapping). Entity services pass only domain data — they never construct notification titles, bodies, or audience shapes. + +Public interface (`INotificationEventService`): + +```ts +notifyShiftCancelled(params: { + shiftId: string; + className: string; + shiftDate: string; + cancelReason: string; + cancelledByUserId: string; + cancelledByName: string; +}): Promise; + +notifyCoverageRequested(params: { + coverageRequestId: string; + shiftId: string; + classId: string; + className: string; + shiftDate: string; + requestingVolunteerUserId: string; + requestingVolunteerName: string; + reason: string; +}): Promise; + +// Future methods added here as new notification types are needed: +// notifyShiftReminder(...) +// notifyCoverageFilled(...) +// notifyVolunteerDeactivated(...) +``` + +**DI dependencies**: `{ notificationService }` + +### Implementation pattern (each method follows the same shape) + +```ts +async notifyShiftCancelled(params) { + await this.notificationService.notify({ + type: "shift.cancelled", + audience: { kind: "shift", shiftId: params.shiftId }, + context: { + shiftId: params.shiftId, + className: params.className, + shiftDate: params.shiftDate, + cancelReason: params.cancelReason, + cancelledByName: params.cancelledByName, + }, + actorId: params.cancelledByUserId, + idempotencyKey: `shift-cancelled-${params.shiftId}`, + }); +} +``` + +Each method is responsible for: +- Mapping domain params → the correct notification `type` key +- Choosing the right `audience` shape +- Assembling the `context` object that the registry's title/body functions need +- Setting the `actorId` and `idempotencyKey` + +--- + +## Step 6: pgBoss Job Definition + +**New file: `src/server/jobs/definitions/process-notification.job.ts`** + +``` +name: "jobs.process-notification" +retryOpts: { retryLimit: 3, retryDelay: 30, retryBackoff: true } +handler: calls cradle.notificationService._processNotification(payload) +``` + +Payload type: `{ type, audience, context, actorId?, idempotencyKey? }` + +**Modify: `src/server/jobs/registry.ts`** — add `processNotificationJob` to `allJobs` + +--- + +## Step 7: DI Container Registration + +**Modify: `src/server/api/di-container.ts`** + +Add to `NeuronCradle`: +```ts +preferenceService: IPreferenceService; +notificationService: INotificationService; +notificationEventService: INotificationEventService; +``` + +Register: +```ts +preferenceService: asClass(PreferenceService).scoped(), +notificationService: asClass(NotificationService).scoped(), +notificationEventService: asClass(NotificationEventService).scoped(), +``` + +All scoped — job handler creates a container scope per job execution. + +--- + +## Step 8: tRPC Router + +**New file: `src/models/api/notification.ts`** — Zod input schemas for list, preferences + +**New file: `src/server/api/routers/notification-router.ts`** + +Endpoints: +- `notification.list` — query, paginated list of user's notifications (→ `notificationService.getNotifications`) +- `notification.unreadCount` — query, returns number (→ `notificationService.getUnreadCount`) +- `notification.markAsRead` — mutation, single notification (→ `notificationService.markAsRead`) +- `notification.markAllAsRead` — mutation (→ `notificationService.markAllAsRead`) +- `notification.preferences` — query, returns effective preferences (→ `preferenceService.getEffectivePreferences`) +- `notification.setPreference` — mutation (→ `preferenceService.setPreference`) + +All endpoints use `authorizedProcedure()` (no special permission needed — every user can manage their own notifications). User ID always from `currentSessionService.requireUser().id`. + +**Modify: `src/server/api/root.ts`** — add `notification: notificationRouter` + +--- + +## Step 9: Entity Service Integration + +### `src/server/services/entity/shiftService.ts` +- Add `notificationEventService: INotificationEventService` to constructor deps +- After successful `cancelShift()` (after the update query succeeds), call: + ```ts + await this.notificationEventService.notifyShiftCancelled({ + shiftId, + className: course.name, + shiftDate: format(shiftRow.startAt), + cancelReason, + cancelledByUserId: currentUserId, + cancelledByName: currentUser.name, + }); + ``` +- Need to fetch course name in the cancel method (add to the existing select or do a follow-up query) + +### `src/server/services/entity/coverageService.ts` +- Add `notificationEventService` to constructor deps +- After `requestCoverage()` succeeds, call: + ```ts + await this.notificationEventService.notifyCoverageRequested({ + coverageRequestId: newRequest.id, + shiftId, + classId: shift.courseId, + className: course.name, + shiftDate: format(shift.startAt), + requestingVolunteerUserId: currentUserId, + requestingVolunteerName: currentUser.name, + reason: input.details, + }); + ``` + +--- + +## Step 10: Email Templates + +**New file: `src/server/emails/templates/shift-cancelled.tsx`** — React Email template following existing pattern (`EmailLayout` wrapper, `renderEmail()` export) + +**New file: `src/server/emails/templates/coverage-requested.tsx`** — same pattern + +These get wired into the registry's `emailTemplate` function for each type. + +--- + +## Step 11: Unread Tracking Strategy + +Modern platforms (GitHub, Notion, Linear) use **per-notification read state** rather than a global "last seen" timestamp. This gives users fine-grained control (mark individual items read/unread) while still supporting bulk "mark all as read". + +### How it works + +- Each `notification` row has a `read` boolean (default `false`) and `readAt` timestamp +- **Viewing the dropdown does NOT auto-mark as read** — this matches GitHub/Notion behavior where opening the inbox shows notifications but you explicitly interact to mark them +- **Clicking a notification** marks that single notification as read (sets `read = true`, `readAt = now()`) and navigates to the `linkUrl` +- **"Mark all as read" button** bulk-updates: `UPDATE notification SET read = true, readAt = now() WHERE userId = ? AND read = false` +- **Unread count** query: `SELECT count(*) FROM notification WHERE userId = ? AND read = false` — cached on the frontend with polling + +### Why not a "last seen" timestamp approach? +A `lastSeenAt` timestamp (GitHub's older model) is simpler but breaks when a user wants to keep specific notifications unread as reminders. Per-notification state is the standard now and only costs one boolean column we already have. + +--- + +## Step 12: Frontend + +### Notification inbox dropdown + +**New file: `src/components/notifications/notification-inbox.tsx`** + +Placement: In the `SidebarHeader` of `src/components/app-navbar.tsx`, to the left of the existing sidebar toggle button (in the `ml-auto` right-aligned section). + +Structure: +``` + + +
+ {/* lucide-react Inbox icon */} + {unreadCount > 0 && ( + {/* red dot */} + )} +
+
+ +
+ Notifications + +
+ + {notifications.map(n => ( + { markAsRead(n.id); navigate(n.linkUrl); }} + /> + ))} + +
+
+``` + +Key behaviors: +- **Red dot indicator** (not a count badge) — small `size-2 rounded-full bg-red-500` circle, visible when `unreadCount > 0`. Matches modern minimal style. +- **Polling for unread count**: `trpc.notification.unreadCount.useQuery()` with `refetchInterval: 30_000` (30s) +- **Notification list in dropdown**: `trpc.notification.list.useQuery({ limit: 20 })` — fetched when dropdown opens +- **Unread items styled differently**: unread notifications get a left blue border or subtle background tint to distinguish from read ones +- **Click behavior**: marks as read + navigates to `linkUrl` +- **Empty state**: "You're all caught up" message when no notifications +- **Invalidation**: After `markAsRead` or `markAllAsRead` mutations, invalidate both `unreadCount` and `list` queries + +**New file: `src/components/notifications/notification-item.tsx`** + +Single notification row component: +- Actor avatar (small, if actorId present) + title + relative timestamp ("2h ago") +- Body text truncated to 2 lines +- Unread indicator (blue dot or bold title) +- Hover state for interactivity + +### Modify: `src/components/app-navbar.tsx` +- Import and render `` in the `SidebarHeader` right-aligned `div`, before the sidebar toggle button +- When sidebar is collapsed to icon mode, the inbox icon should still be visible (use `group-data-[state=collapsed]` classes) + +### Notification settings +**Modify: `src/components/settings/pages/notifications-settings-content.tsx`** +- Replace "coming soon" with a real preferences UI +- Table/grid: rows = notification types, columns = channels +- Toggle switches calling `trpc.notification.setPreference.useMutation()` +- Data from `trpc.notification.preferences.useQuery()` +- Show "reset to default" option when `isOverride` is true + +--- + +## File Summary + +### New files (13) +| File | Purpose | +|------|---------| +| `src/server/db/schema/notification.ts` | Drizzle schema for notification + notification_preference | +| `src/server/notifications/types.ts` | Type definitions (channels, audiences, type definition shape) | +| `src/server/notifications/registry.ts` | Central registry of notification types with defaults | +| `src/server/services/preferenceService.ts` | Preference CRUD + effective preference resolution | +| `src/server/services/notificationService.ts` | Core engine: dispatch, audience resolution, persistence, queries | +| `src/server/services/notificationEventService.ts` | Event surface: typed domain methods for triggering notifications | +| `src/server/jobs/definitions/process-notification.job.ts` | pgBoss job definition | +| `src/server/api/routers/notification-router.ts` | tRPC router for frontend | +| `src/models/api/notification.ts` | Zod input schemas | +| `src/server/emails/templates/shift-cancelled.tsx` | Email template | +| `src/server/emails/templates/coverage-requested.tsx` | Email template | +| `src/components/notifications/notification-inbox.tsx` | Inbox dropdown with red dot indicator | +| `src/components/notifications/notification-item.tsx` | Single notification row component | + +### Modified files (8) +| File | Change | +|------|--------| +| `src/server/db/schema/index.ts` | Export notification schema | +| `src/server/jobs/registry.ts` | Register process-notification job | +| `src/server/api/di-container.ts` | Add 3 new services to cradle + registration | +| `src/server/api/root.ts` | Add notification router | +| `src/server/services/entity/shiftService.ts` | Call `notificationEventService.notifyShiftCancelled(...)` | +| `src/server/services/entity/coverageService.ts` | Call `notificationEventService.notifyCoverageRequested(...)` | +| `src/components/app-navbar.tsx` | Add NotificationInbox to sidebar header | +| `src/components/settings/pages/notifications-settings-content.tsx` | Replace placeholder | + +--- + +## Implementation Order + +1. DB schema + migration +2. Notification types + registry +3. PreferenceService +4. NotificationService (core engine) +5. NotificationEventService (event surface) +6. DI container registration (all 3 services) +7. pgBoss job definition + registry update +8. tRPC router + input schemas + root router update +9. Entity service integration (shiftService, coverageService) +10. Email templates +11. Frontend: notification inbox dropdown + notification item components +12. Frontend: wire inbox into app-navbar sidebar header +13. Frontend: notification settings preferences page + +--- + +## Verification + +1. **Unit tests**: PreferenceService — test effective preference resolution (defaults, overrides, clearing). NotificationService — mock db/jobService/emailService/preferenceService, test audience resolution, persistence, idempotency. NotificationEventService — mock notificationService, verify correct type/audience/context mapping per method. +2. **Integration tests**: Full flow — cancel a shift → notificationEventService called → pgBoss job fires → notification rows inserted + email sent. Use test container with MockEmailService. +3. **tRPC tests**: List/pagination, mark read, preference CRUD. +4. **Manual**: Cancel a shift in the UI → verify notification appears in DB → verify email received → verify notification shows in frontend inbox. diff --git a/src/components/app-navbar.tsx b/src/components/app-navbar.tsx index 04cc638f..00edc1a7 100644 --- a/src/components/app-navbar.tsx +++ b/src/components/app-navbar.tsx @@ -33,6 +33,7 @@ import LogIcon from "@public/assets/icons/nav/log.svg"; import MemberIcon from "@public/assets/icons/nav/member.svg"; import ScheduleIcon from "@public/assets/icons/nav/schedule.svg"; import Logo from "@public/assets/logo.svg"; +import { NotificationInbox } from "./notifications/notification-inbox"; import { SettingsDropdown } from "./settings/settings-dropdown"; export const navbarItems = [ @@ -151,7 +152,8 @@ export function AppNavbar() { -
+
+ + { + void utils.notification.unreadCount.invalidate(); + void utils.notification.list.invalidate(); + }, + }); + + const markAllAsRead = clientApi.notification.markAllAsRead.useMutation({ + onSuccess: () => { + void utils.notification.unreadCount.invalidate(); + void utils.notification.list.invalidate(); + }, + }); + + const handleNotificationClick = (notificationId: string, linkUrl?: string | null) => { + markAsRead.mutate({ notificationId }); + if (linkUrl) { + router.push(linkUrl as any); + } + setOpen(false); + }; + + return ( + + + + + +
+

Notifications

+ {unreadCount > 0 && ( + + )} +
+ + {isLoading ? ( +
+

Loading...

+
+ ) : !data?.items.length ? ( +
+

+ You're all caught up +

+
+ ) : ( +
+ {data.items.map((n) => ( + handleNotificationClick(n.id, n.linkUrl)} + /> + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/notifications/notification-item.tsx b/src/components/notifications/notification-item.tsx new file mode 100644 index 00000000..ed0f337d --- /dev/null +++ b/src/components/notifications/notification-item.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { NotificationDB } from "@/server/db/schema/notification"; + +function timeAgo(date: Date): string { + const now = new Date(); + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return date.toLocaleDateString(); +} + +interface NotificationItemProps { + notification: NotificationDB; + onClick: () => void; +} + +export function NotificationItem({ + notification, + onClick, +}: NotificationItemProps) { + return ( + + ); +} diff --git a/src/components/settings/pages/notifications-settings-content.tsx b/src/components/settings/pages/notifications-settings-content.tsx index 4c40bff1..ecd58cb2 100644 --- a/src/components/settings/pages/notifications-settings-content.tsx +++ b/src/components/settings/pages/notifications-settings-content.tsx @@ -1,3 +1,137 @@ +"use client"; + +import { clientApi } from "@/trpc/client"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/primitives/button"; +import { Spinner } from "@/components/ui/spinner"; +import { Bell } from "lucide-react"; +import type { NotificationChannel } from "@/server/notifications/types"; + +const channelLabels: Record = { + email: "Email", + in_app: "In-App", + push: "Push", +}; + +const typeLabels: Record = { + "shift.cancelled": "Shift Cancelled", + "coverage.requested": "Coverage Requested", + "coverage.available": "Coverage Opportunity", +}; + export function NotificationsSettingsContent() { - return <>Notifications coming soon...; + const utils = clientApi.useUtils(); + + const { data: preferences, isLoading } = + clientApi.notification.preferences.useQuery(); + + const setPreference = clientApi.notification.setPreference.useMutation({ + onSuccess: () => { + void utils.notification.preferences.invalidate(); + }, + }); + + const clearPreference = clientApi.notification.clearPreference.useMutation({ + onSuccess: () => { + void utils.notification.preferences.invalidate(); + }, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + // Group preferences by type + const grouped = new Map< + string, + { type: string; channel: NotificationChannel; enabled: boolean; isOverride: boolean }[] + >(); + for (const pref of preferences ?? []) { + const existing = grouped.get(pref.type) ?? []; + existing.push(pref); + grouped.set(pref.type, existing); + } + + return ( + + +
+ + Notification Preferences +
+ + Choose which notifications you receive and how they are delivered. + +
+ +
+ {Array.from(grouped.entries()).map(([type, channels]) => ( +
+

+ {typeLabels[type] ?? type} +

+
+ {channels.map((pref) => ( +
+
+ + {pref.isOverride && ( + + )} +
+ + setPreference.mutate({ + type: pref.type, + channel: pref.channel, + enabled: checked, + }) + } + disabled={ + setPreference.isPending || clearPreference.isPending + } + /> +
+ ))} +
+
+ ))} +
+
+
+ ); } diff --git a/src/models/api/notification.ts b/src/models/api/notification.ts new file mode 100644 index 00000000..d89d5e2d --- /dev/null +++ b/src/models/api/notification.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const ListNotificationsInput = z.object({ + type: z.string().optional(), + read: z.boolean().optional(), + limit: z.number().int().min(1).max(100).default(20), + cursor: z.string().datetime().optional(), +}); +export type ListNotificationsInput = z.infer; + +export const MarkAsReadInput = z.object({ + notificationId: z.uuid(), +}); +export type MarkAsReadInput = z.infer; + +export const SetNotificationPreferenceInput = z.object({ + type: z.string(), + channel: z.enum(["email", "in_app", "push"]), + enabled: z.boolean(), +}); +export type SetNotificationPreferenceInput = z.infer< + typeof SetNotificationPreferenceInput +>; + +export const ClearNotificationPreferenceInput = z.object({ + type: z.string(), + channel: z.enum(["email", "in_app", "push"]), +}); +export type ClearNotificationPreferenceInput = z.infer< + typeof ClearNotificationPreferenceInput +>; diff --git a/src/server/api/di-container.ts b/src/server/api/di-container.ts index da83c82f..af20886d 100644 --- a/src/server/api/di-container.ts +++ b/src/server/api/di-container.ts @@ -35,6 +35,18 @@ import { type ICurrentSessionService, } from "../services/currentSessionService"; import { JobService, type IJobService } from "../services/jobService"; +import { + PreferenceService, + type IPreferenceService, +} from "../services/preferenceService"; +import { + NotificationService, + type INotificationService, +} from "../services/notificationService"; +import { + NotificationEventService, + type INotificationEventService, +} from "../services/notificationEventService"; export type NeuronCradle = { env: typeof env; @@ -59,6 +71,9 @@ export type NeuronCradle = { shiftService: IShiftService; coverageService: ICoverageService; jobService: IJobService; + preferenceService: IPreferenceService; + notificationService: INotificationService; + notificationEventService: INotificationEventService; }; export type NeuronContainer = AwilixContainer; @@ -100,6 +115,11 @@ const registerServices = (container: NeuronContainer) => { termService: asClass(TermService).scoped(), coverageService: asClass(CoverageService).scoped(), jobService: asClass(JobService).singleton(), + preferenceService: asClass(PreferenceService).scoped(), + notificationService: + asClass(NotificationService).scoped(), + notificationEventService: + asClass(NotificationEventService).scoped(), // cacheService: asClass(CacheService).scoped(), }); }; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index aba78349..caf28e41 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -8,6 +8,7 @@ import { termRouter } from "@/server/api/routers/term-router"; import { userRouter } from "@/server/api/routers/user-router"; import { volunteerRouter } from "@/server/api/routers/volunteer-router"; import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc"; +import { notificationRouter } from "./routers/notification-router"; import { storageRouter } from "./routers/storage-router"; /** @@ -24,6 +25,7 @@ export const appRouter = createTRPCRouter({ user: userRouter, term: termRouter, profile: profileRouter, + notification: notificationRouter, storage: storageRouter, }); diff --git a/src/server/api/routers/notification-router.ts b/src/server/api/routers/notification-router.ts new file mode 100644 index 00000000..d404e2c0 --- /dev/null +++ b/src/server/api/routers/notification-router.ts @@ -0,0 +1,68 @@ +import { + ClearNotificationPreferenceInput, + ListNotificationsInput, + MarkAsReadInput, + SetNotificationPreferenceInput, +} from "@/models/api/notification"; +import { authorizedProcedure } from "@/server/api/procedures"; +import { createTRPCRouter } from "@/server/api/trpc"; + +export const notificationRouter = createTRPCRouter({ + list: authorizedProcedure() + .input(ListNotificationsInput) + .query(async ({ input, ctx }) => { + const userId = ctx.currentSessionService.requireUser().id; + return ctx.notificationService.getNotifications({ + userId, + type: input.type, + read: input.read, + limit: input.limit, + cursor: input.cursor, + }); + }), + + unreadCount: authorizedProcedure().query(async ({ ctx }) => { + const userId = ctx.currentSessionService.requireUser().id; + return ctx.notificationService.getUnreadCount(userId); + }), + + markAsRead: authorizedProcedure() + .input(MarkAsReadInput) + .mutation(async ({ input, ctx }) => { + const userId = ctx.currentSessionService.requireUser().id; + await ctx.notificationService.markAsRead(input.notificationId, userId); + }), + + markAllAsRead: authorizedProcedure().mutation(async ({ ctx }) => { + const userId = ctx.currentSessionService.requireUser().id; + await ctx.notificationService.markAllAsRead(userId); + }), + + preferences: authorizedProcedure().query(async ({ ctx }) => { + const userId = ctx.currentSessionService.requireUser().id; + return ctx.preferenceService.getEffectivePreferences(userId); + }), + + setPreference: authorizedProcedure() + .input(SetNotificationPreferenceInput) + .mutation(async ({ input, ctx }) => { + const userId = ctx.currentSessionService.requireUser().id; + await ctx.preferenceService.setPreference({ + userId, + type: input.type, + channel: input.channel, + enabled: input.enabled, + }); + }), + + clearPreference: authorizedProcedure() + .input(ClearNotificationPreferenceInput) + .mutation(async ({ input, ctx }) => { + const userId = ctx.currentSessionService.requireUser().id; + await ctx.preferenceService.clearPreference({ + userId, + type: input.type, + channel: input.channel, + }); + }), +}); diff --git a/src/server/db/migrations/0010_lethal_stature.sql b/src/server/db/migrations/0010_lethal_stature.sql new file mode 100644 index 00000000..e0629199 --- /dev/null +++ b/src/server/db/migrations/0010_lethal_stature.sql @@ -0,0 +1,37 @@ +CREATE TYPE "public"."notification_channel" AS ENUM('email', 'in_app', 'push');--> statement-breakpoint +CREATE TABLE "notification" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "type" text NOT NULL, + "title" text NOT NULL, + "body" text NOT NULL, + "link_url" text, + "source_type" text, + "source_id" uuid, + "actor_id" uuid, + "read" boolean DEFAULT false NOT NULL, + "read_at" timestamp with time zone, + "email_sent" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "idempotency_key" text, + CONSTRAINT "notification_idempotency_key_unique" UNIQUE("idempotency_key") +); +--> statement-breakpoint +CREATE TABLE "notification_preference" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "type" text NOT NULL, + "channel" "notification_channel" NOT NULL, + "enabled" boolean NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notification" ADD CONSTRAINT "notification_actor_id_user_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notification_preference" ADD CONSTRAINT "notification_preference_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_notification_user_created" ON "notification" USING btree ("user_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_notification_user_read" ON "notification" USING btree ("user_id","read");--> statement-breakpoint +CREATE INDEX "idx_notification_type" ON "notification" USING btree ("type");--> statement-breakpoint +CREATE INDEX "idx_notification_source" ON "notification" USING btree ("source_type","source_id");--> statement-breakpoint +CREATE UNIQUE INDEX "uq_notification_pref_user_type_channel" ON "notification_preference" USING btree ("user_id","type","channel");--> statement-breakpoint +CREATE INDEX "idx_notification_pref_user" ON "notification_preference" USING btree ("user_id"); \ No newline at end of file diff --git a/src/server/db/migrations/meta/0010_snapshot.json b/src/server/db/migrations/meta/0010_snapshot.json new file mode 100644 index 00000000..0706cfdc --- /dev/null +++ b/src/server/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,2692 @@ +{ + "id": "f790cb06-6dfc-4305-b743-957276334967", + "prevId": "5de7367c-71f7-4d40-9ee8-87a526c11ab9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_index": { + "name": "account_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_provider_id_account_id_index": { + "name": "account_provider_id_account_id_index", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appInvitation": { + "name": "appInvitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "domain_whitelist": { + "name": "domain_whitelist", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "appInvitation_inviter_id_index": { + "name": "appInvitation_inviter_id_index", + "columns": [ + { + "expression": "inviter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "appInvitation_email_index": { + "name": "appInvitation_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "appInvitation_status_index": { + "name": "appInvitation_status_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "appInvitation_inviter_id_user_id_fk": { + "name": "appInvitation_inviter_id_user_id_fk", + "tableFrom": "appInvitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_user_id_index": { + "name": "session_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_index": { + "name": "session_token_index", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_index": { + "name": "verification_identifier_index", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_identifier_value_index": { + "name": "verification_identifier_value_index", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blackout": { + "name": "blackout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "term_id": { + "name": "term_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "starts_on": { + "name": "starts_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "ends_on": { + "name": "ends_on", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blackout_term_id_starts_on_ends_on_index": { + "name": "blackout_term_id_starts_on_ends_on_index", + "columns": [ + { + "expression": "term_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_on", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_on", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blackout_schedule_id_starts_on_ends_on_index": { + "name": "blackout_schedule_id_starts_on_ends_on_index", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_on", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_on", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blackout_term_id_term_id_fk": { + "name": "blackout_term_id_term_id_fk", + "tableFrom": "blackout", + "tableTo": "term", + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blackout_schedule_id_schedule_id_fk": { + "name": "blackout_schedule_id_schedule_id_fk", + "tableFrom": "blackout", + "tableTo": "schedule", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_blackout_owner_xor": { + "name": "chk_blackout_owner_xor", + "value": "( \"blackout\".\"term_id\" IS NOT NULL ) <> ( \"blackout\".\"schedule_id\" IS NOT NULL )" + }, + "chk_blackout_range_valid": { + "name": "chk_blackout_range_valid", + "value": "\"blackout\".\"ends_on\" >= \"blackout\".\"starts_on\"" + } + }, + "isRLSEnabled": false + }, + "public.course": { + "name": "course", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "term_id": { + "name": "term_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_type": { + "name": "location_type", + "type": "location_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subcategory": { + "name": "subcategory", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lower_level": { + "name": "lower_level", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "upper_level": { + "name": "upper_level", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "course_term_id_index": { + "name": "course_term_id_index", + "columns": [ + { + "expression": "term_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "course_name_index": { + "name": "course_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "course_term_id_term_id_fk": { + "name": "course_term_id_term_id_fk", + "tableFrom": "course", + "tableTo": "term", + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_lower_level_bounds": { + "name": "chk_lower_level_bounds", + "value": "\"course\".\"lower_level\" IS NULL OR (\"course\".\"lower_level\" >= 1 AND \"course\".\"lower_level\" <= 4)" + }, + "chk_upper_level_bounds": { + "name": "chk_upper_level_bounds", + "value": "\"course\".\"upper_level\" IS NULL OR (\"course\".\"upper_level\" >= 1 AND \"course\".\"upper_level\" <= 4)" + }, + "chk_levels_both_or_neither": { + "name": "chk_levels_both_or_neither", + "value": "(\"course\".\"lower_level\" IS NULL) = (\"course\".\"upper_level\" IS NULL)" + }, + "chk_lower_lte_upper": { + "name": "chk_lower_lte_upper", + "value": "\"course\".\"lower_level\" IS NULL OR \"course\".\"lower_level\" <= \"course\".\"upper_level\"" + } + }, + "isRLSEnabled": false + }, + "public.term": { + "name": "term", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "term_name": { + "name": "term_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "term_term_name_index": { + "name": "term_term_name_index", + "columns": [ + { + "expression": "term_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_term_date_ok": { + "name": "chk_term_date_ok", + "value": "\"term\".\"end_date\" >= \"term\".\"start_date\"" + } + }, + "isRLSEnabled": false + }, + "public.log": { + "name": "log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signoff": { + "name": "signoff", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "volunteer_user_id": { + "name": "volunteer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_logs_volunteer": { + "name": "idx_logs_volunteer", + "columns": [ + { + "expression": "volunteer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_logs_course": { + "name": "idx_logs_course", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_logs_created_at": { + "name": "idx_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_logs_page": { + "name": "idx_logs_page", + "columns": [ + { + "expression": "page", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "log_volunteer_user_id_volunteer_user_id_fk": { + "name": "log_volunteer_user_id_volunteer_user_id_fk", + "tableFrom": "log", + "tableTo": "volunteer", + "columnsFrom": [ + "volunteer_user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "log_course_id_course_id_fk": { + "name": "log_course_id_course_id_fk", + "tableFrom": "log", + "tableTo": "course", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instructor_to_schedule": { + "name": "instructor_to_schedule", + "schema": "", + "columns": { + "instructor_user_id": { + "name": "instructor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "instructor_to_schedule_instructor_user_id_index": { + "name": "instructor_to_schedule_instructor_user_id_index", + "columns": [ + { + "expression": "instructor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instructor_to_schedule_schedule_id_index": { + "name": "instructor_to_schedule_schedule_id_index", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "instructor_to_schedule_instructor_user_id_user_id_fk": { + "name": "instructor_to_schedule_instructor_user_id_user_id_fk", + "tableFrom": "instructor_to_schedule", + "tableTo": "user", + "columnsFrom": [ + "instructor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "instructor_to_schedule_schedule_id_schedule_id_fk": { + "name": "instructor_to_schedule_schedule_id_schedule_id_fk", + "tableFrom": "instructor_to_schedule", + "tableTo": "schedule", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pk_instructor_schedule": { + "name": "pk_instructor_schedule", + "columns": [ + "instructor_user_id", + "schedule_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedule": { + "name": "schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "effective_start": { + "name": "effective_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "effective_end": { + "name": "effective_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "rrule": { + "name": "rrule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferred_volunteer_count": { + "name": "preferred_volunteer_count", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedule_course_id_index": { + "name": "schedule_course_id_index", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedule_course_id_course_id_fk": { + "name": "schedule_course_id_course_id_fk", + "tableFrom": "schedule", + "tableTo": "course", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_schedule_duration_positive": { + "name": "chk_schedule_duration_positive", + "value": "\"schedule\".\"duration_minutes\" > 0" + }, + "chk_schedule_effective_range_valid": { + "name": "chk_schedule_effective_range_valid", + "value": "\"schedule\".\"effective_end\" IS NULL\n OR \"schedule\".\"effective_start\" IS NULL\n OR \"schedule\".\"effective_end\" >= \"schedule\".\"effective_start\"" + } + }, + "isRLSEnabled": false + }, + "public.volunteer_to_schedule": { + "name": "volunteer_to_schedule", + "schema": "", + "columns": { + "volunteer_user_id": { + "name": "volunteer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "volunteer_to_schedule_volunteer_user_id_index": { + "name": "volunteer_to_schedule_volunteer_user_id_index", + "columns": [ + { + "expression": "volunteer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "volunteer_to_schedule_schedule_id_index": { + "name": "volunteer_to_schedule_schedule_id_index", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volunteer_to_schedule_volunteer_user_id_volunteer_user_id_fk": { + "name": "volunteer_to_schedule_volunteer_user_id_volunteer_user_id_fk", + "tableFrom": "volunteer_to_schedule", + "tableTo": "volunteer", + "columnsFrom": [ + "volunteer_user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_to_schedule_schedule_id_schedule_id_fk": { + "name": "volunteer_to_schedule_schedule_id_schedule_id_fk", + "tableFrom": "volunteer_to_schedule", + "tableTo": "schedule", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pk_volunteer_schedule": { + "name": "pk_volunteer_schedule", + "columns": [ + "volunteer_user_id", + "schedule_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.coverage_request": { + "name": "coverage_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shift_id": { + "name": "shift_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "coverage_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comments": { + "name": "comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "coverage_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "requesting_volunteer_user_id": { + "name": "requesting_volunteer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "covered_by_volunteer_user_id": { + "name": "covered_by_volunteer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "coverage_request_shift_id_requesting_volunteer_user_id_index": { + "name": "coverage_request_shift_id_requesting_volunteer_user_id_index", + "columns": [ + { + "expression": "shift_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requesting_volunteer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "not \"coverage_request\".\"status\" = 'withdrawn'::coverage_status", + "concurrently": false, + "method": "btree", + "with": {} + }, + "coverage_request_shift_id_status_index": { + "name": "coverage_request_shift_id_status_index", + "columns": [ + { + "expression": "shift_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "coverage_request_covered_by_volunteer_user_id_index": { + "name": "coverage_request_covered_by_volunteer_user_id_index", + "columns": [ + { + "expression": "covered_by_volunteer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "coverage_request_requesting_volunteer_user_id_index": { + "name": "coverage_request_requesting_volunteer_user_id_index", + "columns": [ + { + "expression": "requesting_volunteer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "coverage_request_shift_id_shift_id_fk": { + "name": "coverage_request_shift_id_shift_id_fk", + "tableFrom": "coverage_request", + "tableTo": "shift", + "columnsFrom": [ + "shift_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "coverage_request_requesting_volunteer_user_id_volunteer_user_id_fk": { + "name": "coverage_request_requesting_volunteer_user_id_volunteer_user_id_fk", + "tableFrom": "coverage_request", + "tableTo": "volunteer", + "columnsFrom": [ + "requesting_volunteer_user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "coverage_request_covered_by_volunteer_user_id_volunteer_user_id_fk": { + "name": "coverage_request_covered_by_volunteer_user_id_volunteer_user_id_fk", + "tableFrom": "coverage_request", + "tableTo": "volunteer", + "columnsFrom": [ + "covered_by_volunteer_user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shift": { + "name": "shift", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "class_id": { + "name": "class_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancelled_by_user_id": { + "name": "cancelled_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "shift_class_id_index": { + "name": "shift_class_id_index", + "columns": [ + { + "expression": "class_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shift_schedule_id_index": { + "name": "shift_schedule_id_index", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_shift_date": { + "name": "idx_shift_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "class_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "not \"shift\".\"canceled\"", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_shift_start": { + "name": "idx_shift_start", + "columns": [ + { + "expression": "start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "not \"shift\".\"canceled\"", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_shift_slot": { + "name": "idx_shift_slot", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shift_class_id_course_id_fk": { + "name": "shift_class_id_course_id_fk", + "tableFrom": "shift", + "tableTo": "course", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shift_schedule_id_schedule_id_fk": { + "name": "shift_schedule_id_schedule_id_fk", + "tableFrom": "shift", + "tableTo": "schedule", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shift_cancelled_by_user_id_user_id_fk": { + "name": "shift_cancelled_by_user_id_user_id_fk", + "tableFrom": "shift", + "tableTo": "user", + "columnsFrom": [ + "cancelled_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_shift_time": { + "name": "chk_shift_time", + "value": "\"shift\".\"end_at\" > \"shift\".\"start_at\"" + } + }, + "isRLSEnabled": false + }, + "public.shift_attendance": { + "name": "shift_attendance", + "schema": "", + "columns": { + "shift_id": { + "name": "shift_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "attendance_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "checked_in_at": { + "name": "checked_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "minutes_worked": { + "name": "minutes_worked", + "type": "smallint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "shift_attendance_user_id_index": { + "name": "shift_attendance_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shift_attendance_shift_id_shift_id_fk": { + "name": "shift_attendance_shift_id_shift_id_fk", + "tableFrom": "shift_attendance", + "tableTo": "shift", + "columnsFrom": [ + "shift_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shift_attendance_user_id_volunteer_user_id_fk": { + "name": "shift_attendance_user_id_volunteer_user_id_fk", + "tableFrom": "shift_attendance", + "tableTo": "volunteer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pk_shift_attendance": { + "name": "pk_shift_attendance", + "columns": [ + "shift_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.course_preference": { + "name": "course_preference", + "schema": "", + "columns": { + "volunteer_user_id": { + "name": "volunteer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "course_preference_volunteer_user_id_volunteer_user_id_fk": { + "name": "course_preference_volunteer_user_id_volunteer_user_id_fk", + "tableFrom": "course_preference", + "tableTo": "volunteer", + "columnsFrom": [ + "volunteer_user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "course_preference_course_id_course_id_fk": { + "name": "course_preference_course_id_course_id_fk", + "tableFrom": "course_preference", + "tableTo": "course", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pk_course_preferences": { + "name": "pk_course_preferences", + "columns": [ + "volunteer_user_id", + "course_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unverified'" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_user_email": { + "name": "idx_user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_role": { + "name": "idx_user_role", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_status": { + "name": "idx_user_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_created_at": { + "name": "idx_user_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer": { + "name": "volunteer", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "preferred_name": { + "name": "preferred_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "province": { + "name": "province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "availability": { + "name": "availability", + "type": "bit(336)", + "primaryKey": false, + "notNull": true, + "default": "'000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'" + }, + "preferred_time_commitment_hours": { + "name": "preferred_time_commitment_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_volunteer_city": { + "name": "idx_volunteer_city", + "columns": [ + { + "expression": "city", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_volunteer_province": { + "name": "idx_volunteer_province", + "columns": [ + { + "expression": "province", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volunteer_user_id_user_id_fk": { + "name": "volunteer_user_id_user_id_fk", + "tableFrom": "volunteer", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email_sent": { + "name": "email_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_notification_user_created": { + "name": "idx_notification_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_user_read": { + "name": "idx_notification_user_read", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_type": { + "name": "idx_notification_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_source": { + "name": "idx_notification_source", + "columns": [ + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_actor_id_user_id_fk": { + "name": "notification_actor_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_idempotency_key_unique": { + "name": "notification_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preference": { + "name": "notification_preference", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "notification_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_notification_pref_user_type_channel": { + "name": "uq_notification_pref_user_type_channel", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_pref_user": { + "name": "idx_notification_pref_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preference_user_id_user_id_fk": { + "name": "notification_preference_user_id_user_id_fk", + "tableFrom": "notification_preference", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.location_type": { + "name": "location_type", + "schema": "public", + "values": [ + "InPerson", + "MeetingLink" + ] + }, + "public.attendance_status": { + "name": "attendance_status", + "schema": "public", + "values": [ + "present", + "absent", + "excused", + "late" + ] + }, + "public.coverage_category": { + "name": "coverage_category", + "schema": "public", + "values": [ + "emergency", + "health", + "conflict", + "transportation", + "other" + ] + }, + "public.coverage_status": { + "name": "coverage_status", + "schema": "public", + "values": [ + "open", + "withdrawn", + "resolved" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "instructor", + "volunteer" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "unverified", + "rejected", + "active", + "inactive" + ] + }, + "public.notification_channel": { + "name": "notification_channel", + "schema": "public", + "values": [ + "email", + "in_app", + "push" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.vw_instructor_user": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unverified'" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "definition": "select \"id\", \"name\", \"email\", \"email_verified\", \"image\", \"created_at\", \"updated_at\", \"role\", \"status\", \"last_name\" from \"user\" where \"user\".\"role\" = 'instructor'", + "name": "vw_instructor_user", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_volunteer_user": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unverified'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "preferred_name": { + "name": "preferred_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "province": { + "name": "province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "availability": { + "name": "availability", + "type": "bit(336)", + "primaryKey": false, + "notNull": true, + "default": "'000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'" + }, + "preferred_time_commitment_hours": { + "name": "preferred_time_commitment_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"user\".\"id\", \"user\".\"name\", \"user\".\"last_name\", \"user\".\"email\", \"user\".\"status\", \"user\".\"created_at\", \"user\".\"updated_at\", \"user\".\"email_verified\", \"user\".\"image\", \"user\".\"role\", \"volunteer\".\"preferred_name\", \"volunteer\".\"bio\", \"volunteer\".\"pronouns\", \"volunteer\".\"phone_number\", \"volunteer\".\"city\", \"volunteer\".\"province\", \"volunteer\".\"availability\", \"volunteer\".\"preferred_time_commitment_hours\" from \"user\" inner join \"volunteer\" on \"volunteer\".\"user_id\" = \"user\".\"id\" where \"user\".\"role\" = 'volunteer'", + "name": "vw_volunteer_user", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/server/db/migrations/meta/_journal.json b/src/server/db/migrations/meta/_journal.json index e7cf2d46..c1dc3c54 100644 --- a/src/server/db/migrations/meta/_journal.json +++ b/src/server/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1773033743776, "tag": "0009_add_location_type", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1773985794131, + "tag": "0010_lethal_stature", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/server/db/schema/index.ts b/src/server/db/schema/index.ts index f8470011..354bf31a 100644 --- a/src/server/db/schema/index.ts +++ b/src/server/db/schema/index.ts @@ -8,3 +8,4 @@ export * from "./log"; export * from "./schedule"; export * from "./shift"; export * from "./user"; +export * from "./notification"; diff --git a/src/server/db/schema/notification.ts b/src/server/db/schema/notification.ts new file mode 100644 index 00000000..e857cc2e --- /dev/null +++ b/src/server/db/schema/notification.ts @@ -0,0 +1,100 @@ +import { relations } from "drizzle-orm"; +import { + boolean, + index, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { user } from "./user"; + +export const notificationChannel = pgEnum("notification_channel", [ + "email", + "in_app", + "push", +]); + +export const notification = pgTable( + "notification", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + type: text("type").notNull(), + title: text("title").notNull(), + body: text("body").notNull(), + linkUrl: text("link_url"), + sourceType: text("source_type"), + sourceId: uuid("source_id"), + actorId: uuid("actor_id").references(() => user.id, { + onDelete: "set null", + }), + read: boolean("read").notNull().default(false), + readAt: timestamp("read_at", { withTimezone: true }), + emailSent: boolean("email_sent").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + idempotencyKey: text("idempotency_key").unique(), + }, + (table) => [ + index("idx_notification_user_created").on(table.userId, table.createdAt), + index("idx_notification_user_read").on(table.userId, table.read), + index("idx_notification_type").on(table.type), + index("idx_notification_source").on(table.sourceType, table.sourceId), + ], +); +export type NotificationDB = typeof notification.$inferSelect; + +export const notificationRelations = relations(notification, ({ one }) => ({ + user: one(user, { + fields: [notification.userId], + references: [user.id], + relationName: "notificationRecipient", + }), + actor: one(user, { + fields: [notification.actorId], + references: [user.id], + relationName: "notificationActor", + }), +})); + +export const notificationPreference = pgTable( + "notification_preference", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + type: text("type").notNull(), + channel: notificationChannel("channel").notNull(), + enabled: boolean("enabled").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("uq_notification_pref_user_type_channel").on( + table.userId, + table.type, + table.channel, + ), + index("idx_notification_pref_user").on(table.userId), + ], +); +export type NotificationPreferenceDB = + typeof notificationPreference.$inferSelect; + +export const notificationPreferenceRelations = relations( + notificationPreference, + ({ one }) => ({ + user: one(user, { + fields: [notificationPreference.userId], + references: [user.id], + }), + }), +); diff --git a/src/server/emails/templates/coverage-available.tsx b/src/server/emails/templates/coverage-available.tsx new file mode 100644 index 00000000..dc206b69 --- /dev/null +++ b/src/server/emails/templates/coverage-available.tsx @@ -0,0 +1,44 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface CoverageAvailableEmailProps { + className: string; + shiftDate: string; + coverageRequestId: string; +} + +export function CoverageAvailableEmail({ + className, + shiftDate, + coverageRequestId, +}: CoverageAvailableEmailProps) { + return ( + + + Coverage Opportunity + + + A shift for {className} on{" "} + {shiftDate} needs coverage. + +
+ +
+
+ ); +} + +export default CoverageAvailableEmail; + +export function renderCoverageAvailable(props: CoverageAvailableEmailProps) { + return renderEmail(); +} diff --git a/src/server/emails/templates/coverage-requested.tsx b/src/server/emails/templates/coverage-requested.tsx new file mode 100644 index 00000000..883a6e81 --- /dev/null +++ b/src/server/emails/templates/coverage-requested.tsx @@ -0,0 +1,44 @@ +import { Heading, Section, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface CoverageRequestedEmailProps { + className: string; + shiftDate: string; + requestingVolunteerName: string; + reason: string; +} + +export function CoverageRequestedEmail({ + className, + shiftDate, + requestingVolunteerName, + reason, +}: CoverageRequestedEmailProps) { + return ( + + + Coverage Needed + + + {requestingVolunteerName}{" "} + is requesting coverage for{" "} + {className} on{" "} + {shiftDate}. + +
+ Reason + {reason} +
+
+ ); +} + +export default CoverageRequestedEmail; + +export function renderCoverageRequested(props: CoverageRequestedEmailProps) { + return renderEmail(); +} diff --git a/src/server/emails/templates/shift-cancelled.tsx b/src/server/emails/templates/shift-cancelled.tsx new file mode 100644 index 00000000..bc4645fd --- /dev/null +++ b/src/server/emails/templates/shift-cancelled.tsx @@ -0,0 +1,46 @@ +import { Heading, Section, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface ShiftCancelledEmailProps { + className: string; + shiftDate: string; + cancelReason: string; + cancelledByName: string; +} + +export function ShiftCancelledEmail({ + className, + shiftDate, + cancelReason, + cancelledByName, +}: ShiftCancelledEmailProps) { + return ( + + + Shift Cancelled + + + A shift for{" "} + {className} on{" "} + {shiftDate} has been + cancelled by {cancelledByName}. + +
+ Reason + + {cancelReason} + +
+
+ ); +} + +export default ShiftCancelledEmail; + +export function renderShiftCancelled(props: ShiftCancelledEmailProps) { + return renderEmail(); +} diff --git a/src/server/jobs/definitions/process-notification.job.ts b/src/server/jobs/definitions/process-notification.job.ts new file mode 100644 index 00000000..1f1cbceb --- /dev/null +++ b/src/server/jobs/definitions/process-notification.job.ts @@ -0,0 +1,29 @@ +import type { Audience } from "@/server/notifications/types"; +import type { RegisteredJob } from "../types"; + +export type ProcessNotificationPayload = { + type: string; + audience: Audience; + context: Record; + actorId?: string; + idempotencyKey?: string; +}; + +export const processNotificationJob: RegisteredJob = + { + name: "jobs.process-notification", + retryOpts: { + retryLimit: 3, + retryDelay: 30, + retryBackoff: true, + }, + handler: async (payload, { cradle }) => { + await cradle.notificationService.processNotification({ + type: payload.type, + audience: payload.audience, + context: payload.context, + actorId: payload.actorId, + idempotencyKey: payload.idempotencyKey, + }); + }, + }; diff --git a/src/server/jobs/registry.ts b/src/server/jobs/registry.ts index 06c6a6b0..c06e493e 100644 --- a/src/server/jobs/registry.ts +++ b/src/server/jobs/registry.ts @@ -1,8 +1,10 @@ import { cleanupOrphanedImagesJob } from "./definitions/cleanup-orphaned-images.job"; +import { processNotificationJob } from "./definitions/process-notification.job"; import type { RegisteredJob } from "./types"; const allJobs = [ cleanupOrphanedImagesJob, + processNotificationJob, ] as const satisfies readonly RegisteredJob[]; type AnyKnownJob = (typeof allJobs)[number]; diff --git a/src/server/notifications/registry.ts b/src/server/notifications/registry.ts new file mode 100644 index 00000000..ea4c4be2 --- /dev/null +++ b/src/server/notifications/registry.ts @@ -0,0 +1,102 @@ +import type { NotificationTypeDefinition } from "./types"; +import { renderShiftCancelled } from "@/server/emails/templates/shift-cancelled"; +import { renderCoverageRequested } from "@/server/emails/templates/coverage-requested"; +import { renderCoverageAvailable } from "@/server/emails/templates/coverage-available"; + +export interface ShiftCancelledContext { + shiftId: string; + className: string; + shiftDate: string; + cancelReason: string; + cancelledByName: string; +} + +export interface CoverageRequestedContext { + coverageRequestId: string; + shiftId: string; + className: string; + shiftDate: string; + requestingVolunteerName: string; + reason: string; +} + +export interface CoverageAvailableContext { + coverageRequestId: string; + shiftId: string; + className: string; + shiftDate: string; +} + +export const notificationTypes = { + "shift.cancelled": { + key: "shift.cancelled", + channelDefaults: { email: true, in_app: true }, + title: (ctx) => `Shift Cancelled: ${ctx.className}`, + body: (ctx) => + `The shift on ${ctx.shiftDate} for ${ctx.className} has been cancelled. Reason: ${ctx.cancelReason}`, + linkUrl: (ctx) => `/schedule?shiftId=${ctx.shiftId}`, + sourceType: "shift", + sourceId: (ctx) => ctx.shiftId, + renderEmail: (ctx) => + renderShiftCancelled({ + className: ctx.className, + shiftDate: ctx.shiftDate, + cancelReason: ctx.cancelReason, + cancelledByName: ctx.cancelledByName, + }), + } satisfies NotificationTypeDefinition, + + "coverage.requested": { + key: "coverage.requested", + channelDefaults: { email: true, in_app: true }, + title: (ctx) => `Coverage Needed: ${ctx.className}`, + body: (ctx) => + `${ctx.requestingVolunteerName} is requesting coverage for ${ctx.className} on ${ctx.shiftDate}.`, + linkUrl: (ctx) => `/coverage?requestId=${ctx.coverageRequestId}`, + sourceType: "coverageRequest", + sourceId: (ctx) => ctx.coverageRequestId, + renderEmail: (ctx) => + renderCoverageRequested({ + className: ctx.className, + shiftDate: ctx.shiftDate, + requestingVolunteerName: ctx.requestingVolunteerName, + reason: ctx.reason, + }), + } satisfies NotificationTypeDefinition, + + "coverage.available": { + key: "coverage.available", + channelDefaults: { email: true, in_app: true }, + title: (ctx) => `Coverage Opportunity: ${ctx.className}`, + body: (ctx) => + `A shift for ${ctx.className} on ${ctx.shiftDate} needs coverage.`, + linkUrl: (ctx) => `/coverage?requestId=${ctx.coverageRequestId}`, + sourceType: "coverageRequest", + sourceId: (ctx) => ctx.coverageRequestId, + renderEmail: (ctx) => + renderCoverageAvailable({ + className: ctx.className, + shiftDate: ctx.shiftDate, + coverageRequestId: ctx.coverageRequestId, + }), + } satisfies NotificationTypeDefinition, +} as const; + +export type NotificationType = keyof typeof notificationTypes; + +export const getNotificationTypeDefinition = ( + type: string, +): NotificationTypeDefinition | undefined => { + if (!(type in notificationTypes)) return undefined; + // Each registry entry is a NotificationTypeDefinition, which + // is structurally compatible when the context arg is Record. + // The cast is safe because _processNotification always passes the context + // that the NotificationEventService assembled for this specific type. + return notificationTypes[type as NotificationType] as unknown as + | NotificationTypeDefinition + | undefined; +}; + +export const allNotificationTypes = Object.keys( + notificationTypes, +) as NotificationType[]; diff --git a/src/server/notifications/types.ts b/src/server/notifications/types.ts new file mode 100644 index 00000000..17d3ea34 --- /dev/null +++ b/src/server/notifications/types.ts @@ -0,0 +1,43 @@ +import type { Role } from "@/models/interfaces"; + +export type NotificationChannel = "email" | "in_app" | "push"; + +export type ChannelDefaults = Partial>; + +export type Audience = + | { kind: "user"; userId: string } + | { kind: "users"; userIds: string[] } + | { kind: "role"; role: Role } + | { kind: "shift"; shiftId: string } + | { kind: "class"; classId: string }; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface NotificationTypeDefinition< + TContext extends {} = Record, +> { + key: string; + channelDefaults: ChannelDefaults; + title: (ctx: TContext) => string; + body: (ctx: TContext) => string; + linkUrl?: (ctx: TContext) => string; + sourceType?: string; + sourceId?: (ctx: TContext) => string; + renderEmail?: (ctx: TContext) => Promise<{ html: string; text: string }>; +} + +export interface NotifyParams { + type: string; + audience: Audience | Audience[]; + context: Record; + actorId?: string; + deliverAt?: Date; + idempotencyKey?: string; + excludeUserIds?: string[]; +} + +export interface EffectivePreference { + type: string; + channel: NotificationChannel; + enabled: boolean; + isOverride: boolean; +} diff --git a/src/server/services/entity/coverageService.ts b/src/server/services/entity/coverageService.ts index f79df4cb..436eb5cc 100644 --- a/src/server/services/entity/coverageService.ts +++ b/src/server/services/entity/coverageService.ts @@ -46,6 +46,7 @@ import { import type { IVolunteerService } from "./volunteerService"; import type { IShiftService } from "./shiftService"; import type { ICurrentSessionService } from "../currentSessionService"; +import type { INotificationEventService } from "../notificationEventService"; export interface ICoverageService { getCoverageRequestsForShift(shiftId: string): Promise; @@ -81,22 +82,26 @@ export class CoverageService implements ICoverageService { private readonly currentSessionService: ICurrentSessionService; private readonly volunteerService: IVolunteerService; private readonly shiftService: IShiftService; + private readonly notificationEventService: INotificationEventService; constructor({ db, currentSessionService, volunteerService, shiftService, + notificationEventService, }: { db: Drizzle; currentSessionService: ICurrentSessionService; volunteerService: IVolunteerService; shiftService: IShiftService; + notificationEventService: INotificationEventService; }) { this.db = db; this.currentSessionService = currentSessionService; this.volunteerService = volunteerService; this.shiftService = shiftService; + this.notificationEventService = notificationEventService; } async getCoverageRequestsForShift( @@ -405,7 +410,38 @@ export class CoverageService implements ICoverageService { }) .returning({ id: coverageRequest.id }); - return created!.id; + const coverageRequestId = created!.id; + + // Fetch shift + course info for notification + const [shiftInfo] = await this.db + .select({ + courseId: shift.courseId, + startAt: shift.startAt, + endAt: shift.endAt, + courseName: course.name, + }) + .from(shift) + .innerJoin(course, eq(course.id, shift.courseId)) + .where(eq(shift.id, requestData.shiftId)) + .limit(1); + + if (shiftInfo) { + const currentUser = this.currentSessionService.getUser(); + void this.notificationEventService.notifyCoverageRequested({ + coverageRequestId, + shiftId: requestData.shiftId, + classId: shiftInfo.courseId, + className: shiftInfo.courseName, + shiftDate: shiftInfo.startAt.toLocaleDateString(), + shiftStartAt: shiftInfo.startAt, + shiftEndAt: shiftInfo.endAt, + requestingVolunteerUserId, + requestingVolunteerName: currentUser?.name ?? "A volunteer", + reason: requestData.details, + }); + } + + return coverageRequestId; } async cancelCoverageRequest( diff --git a/src/server/services/entity/shiftService.ts b/src/server/services/entity/shiftService.ts index 2583772d..ef3e6d41 100644 --- a/src/server/services/entity/shiftService.ts +++ b/src/server/services/entity/shiftService.ts @@ -48,6 +48,7 @@ import { volunteerUserView, } from "@/server/db/schema/user"; import { NeuronError, NeuronErrorCodes } from "@/server/errors/neuron-error"; +import type { INotificationEventService } from "@/server/services/notificationEventService"; import { term } from "@/server/db/schema/course"; import { and, eq, gte, inArray, lte, or, sql, type SQL } from "drizzle-orm"; @@ -104,16 +105,20 @@ function sortCoverageRequestsByStatus( export class ShiftService implements IShiftService { private readonly db: Drizzle; private readonly currentSessionService: ICurrentSessionService; + private readonly notificationEventService: INotificationEventService; constructor({ db, currentSessionService, + notificationEventService, }: { db: Drizzle; currentSessionService: ICurrentSessionService; + notificationEventService: INotificationEventService; }) { this.db = db; this.currentSessionService = currentSessionService; + this.notificationEventService = notificationEventService; } private async getViewer( @@ -720,8 +725,10 @@ export class ShiftService implements IShiftService { id: shift.id, startAt: shift.startAt, canceled: shift.canceled, + courseName: course.name, }) .from(shift) + .innerJoin(course, eq(course.id, shift.courseId)) .where(eq(shift.id, shiftId)) .limit(1); @@ -743,11 +750,13 @@ export class ShiftService implements IShiftService { return; } + const currentUserId = this.currentSessionService.getUserId(); + const [updated] = await this.db .update(shift) .set({ canceled: true, - cancelledByUserId: this.currentSessionService.getUserId() ?? null, + cancelledByUserId: currentUserId ?? null, canceledAt: new Date(), cancelReason, }) @@ -760,6 +769,16 @@ export class ShiftService implements IShiftService { NeuronErrorCodes.BAD_REQUEST, ); } + + const currentUser = this.currentSessionService.getUser(); + void this.notificationEventService.notifyShiftCancelled({ + shiftId, + className: shiftRow.courseName, + shiftDate: shiftRow.startAt.toLocaleDateString(), + cancelReason, + cancelledByUserId: currentUserId ?? "system", + cancelledByName: currentUser?.name ?? "System", + }); } async assertValidShift(volunteerId: string, shiftId: string): Promise { diff --git a/src/server/services/notificationEventService.ts b/src/server/services/notificationEventService.ts new file mode 100644 index 00000000..322400c1 --- /dev/null +++ b/src/server/services/notificationEventService.ts @@ -0,0 +1,192 @@ +import type { Drizzle } from "@/server/db"; +import type { INotificationService } from "@/server/services/notificationService"; +import { + schedule, + volunteerToSchedule, + instructorToSchedule, +} from "@/server/db/schema/schedule"; +import { shift } from "@/server/db/schema/shift"; +import { and, eq, inArray, lt, gt, notInArray } from "drizzle-orm"; + +interface ShiftCancelledParams { + shiftId: string; + className: string; + shiftDate: string; + cancelReason: string; + cancelledByUserId: string; + cancelledByName: string; +} + +interface CoverageRequestedParams { + coverageRequestId: string; + shiftId: string; + classId: string; + className: string; + shiftDate: string; + shiftStartAt: Date; + shiftEndAt: Date; + requestingVolunteerUserId: string; + requestingVolunteerName: string; + reason: string; +} + +export interface INotificationEventService { + notifyShiftCancelled(params: ShiftCancelledParams): Promise; + notifyCoverageRequested(params: CoverageRequestedParams): Promise; +} + +export class NotificationEventService implements INotificationEventService { + private readonly notificationService: INotificationService; + private readonly db: Drizzle; + + constructor({ + notificationService, + db, + }: { + notificationService: INotificationService; + db: Drizzle; + }) { + this.notificationService = notificationService; + this.db = db; + } + + async notifyShiftCancelled(params: ShiftCancelledParams): Promise { + await this.notificationService.notify({ + type: "shift.cancelled", + audience: [ + { kind: "shift", shiftId: params.shiftId }, + { kind: "role", role: "admin" }, + ], + context: { + shiftId: params.shiftId, + className: params.className, + shiftDate: params.shiftDate, + cancelReason: params.cancelReason, + cancelledByName: params.cancelledByName, + }, + actorId: params.cancelledByUserId, + idempotencyKey: `shift-cancelled-${params.shiftId}`, + }); + } + + async notifyCoverageRequested( + params: CoverageRequestedParams, + ): Promise { + // 1. Notify admins + class instructors (with reason) + const instructorIds = await this.getClassInstructorUserIds(params.classId); + + await this.notificationService.notify({ + type: "coverage.requested", + audience: [ + { kind: "role", role: "admin" }, + ...(instructorIds.length > 0 + ? [{ kind: "users" as const, userIds: instructorIds }] + : []), + ], + context: { + coverageRequestId: params.coverageRequestId, + shiftId: params.shiftId, + className: params.className, + shiftDate: params.shiftDate, + requestingVolunteerName: params.requestingVolunteerName, + reason: params.reason, + }, + actorId: params.requestingVolunteerUserId, + excludeUserIds: [params.requestingVolunteerUserId], + idempotencyKey: `coverage-requested-${params.coverageRequestId}`, + }); + + // 2. Notify eligible volunteers (without reason) + const eligibleIds = await this.getEligibleVolunteerUserIds( + params.classId, + params.shiftStartAt, + params.shiftEndAt, + params.requestingVolunteerUserId, + ); + + if (eligibleIds.length > 0) { + await this.notificationService.notify({ + type: "coverage.available", + audience: { kind: "users", userIds: eligibleIds }, + context: { + coverageRequestId: params.coverageRequestId, + shiftId: params.shiftId, + className: params.className, + shiftDate: params.shiftDate, + }, + actorId: params.requestingVolunteerUserId, + idempotencyKey: `coverage-available-${params.coverageRequestId}`, + }); + } + } + + /** + * Get instructor user IDs for all schedules of a class. + */ + private async getClassInstructorUserIds(classId: string): Promise { + const schedules = await this.db + .select({ id: schedule.id }) + .from(schedule) + .where(eq(schedule.courseId, classId)); + + const scheduleIds = schedules.map((s) => s.id); + if (scheduleIds.length === 0) return []; + + const instructors = await this.db + .select({ userId: instructorToSchedule.instructorUserId }) + .from(instructorToSchedule) + .where(inArray(instructorToSchedule.scheduleId, scheduleIds)); + + return [...new Set(instructors.map((i) => i.userId))]; + } + + /** + * Get volunteer user IDs for a class who do NOT have a conflicting shift + * overlapping with the given time window. + */ + private async getEligibleVolunteerUserIds( + classId: string, + shiftStartAt: Date, + shiftEndAt: Date, + excludeUserId: string, + ): Promise { + // Get all schedules for the class + const schedules = await this.db + .select({ id: schedule.id }) + .from(schedule) + .where(eq(schedule.courseId, classId)); + + const scheduleIds = schedules.map((s) => s.id); + if (scheduleIds.length === 0) return []; + + // Get all volunteers for the class + const volunteers = await this.db + .select({ userId: volunteerToSchedule.volunteerUserId }) + .from(volunteerToSchedule) + .where(inArray(volunteerToSchedule.scheduleId, scheduleIds)); + + const allVolunteerIds = [ + ...new Set(volunteers.map((v) => v.userId)), + ].filter((id) => id !== excludeUserId); + + if (allVolunteerIds.length === 0) return []; + + // Find volunteers with a conflicting shift (overlapping time window) + // Two shifts overlap when: shift.startAt < shiftEndAt AND shift.endAt > shiftStartAt + const conflicting = await this.db + .selectDistinct({ userId: volunteerToSchedule.volunteerUserId }) + .from(volunteerToSchedule) + .innerJoin(shift, eq(shift.scheduleId, volunteerToSchedule.scheduleId)) + .where( + and( + inArray(volunteerToSchedule.volunteerUserId, allVolunteerIds), + lt(shift.startAt, shiftEndAt), + gt(shift.endAt, shiftStartAt), + eq(shift.canceled, false), + ), + ); + + const conflictingIds = new Set(conflicting.map((c) => c.userId)); + return allVolunteerIds.filter((id) => !conflictingIds.has(id)); + } +} diff --git a/src/server/services/notificationService.ts b/src/server/services/notificationService.ts new file mode 100644 index 00000000..82fe3d58 --- /dev/null +++ b/src/server/services/notificationService.ts @@ -0,0 +1,418 @@ +import type { Drizzle } from "@/server/db"; +import { + notification, + type NotificationDB, +} from "@/server/db/schema/notification"; +import { user } from "@/server/db/schema/user"; +import { + schedule, + volunteerToSchedule, + instructorToSchedule, +} from "@/server/db/schema/schedule"; +import { shift } from "@/server/db/schema/shift"; +import { getNotificationTypeDefinition } from "@/server/notifications/registry"; +import type { + Audience, + NotificationChannel, + NotifyParams, +} from "@/server/notifications/types"; +import type { IJobService } from "@/server/services/jobService"; +import type { IEmailService } from "@/server/services/emailService"; +import type { IPreferenceService } from "@/server/services/preferenceService"; +import { and, count, desc, eq, inArray, lt, sql } from "drizzle-orm"; +import type { RunnableJobName } from "@/server/jobs/registry"; + +interface ResolvedRecipient { + userId: string; + email: string; +} + +export interface INotificationService { + notify(params: NotifyParams): Promise; + cancel(idempotencyKey: string): Promise; + + getNotifications(params: { + userId: string; + type?: string; + read?: boolean; + limit?: number; + cursor?: string; + }): Promise<{ items: NotificationDB[]; nextCursor: string | null }>; + + getUnreadCount(userId: string): Promise; + markAsRead(notificationId: string, userId: string): Promise; + markAllAsRead(userId: string): Promise; + + processNotification(params: { + type: string; + audience: Audience | Audience[]; + context: Record; + actorId?: string; + idempotencyKey?: string; + excludeUserIds?: string[]; + }): Promise; +} + +export class NotificationService implements INotificationService { + private readonly db: Drizzle; + private readonly jobService: IJobService; + private readonly emailService: IEmailService; + private readonly preferenceService: IPreferenceService; + + constructor({ + db, + jobService, + emailService, + preferenceService, + }: { + db: Drizzle; + jobService: IJobService; + emailService: IEmailService; + preferenceService: IPreferenceService; + }) { + this.db = db; + this.jobService = jobService; + this.emailService = emailService; + this.preferenceService = preferenceService; + } + + async notify(params: NotifyParams): Promise { + const { + type, + audience, + context, + actorId, + deliverAt, + idempotencyKey, + excludeUserIds, + } = params; + + const payload = { + type, + audience, + context, + actorId, + idempotencyKey, + excludeUserIds, + }; + + return this.jobService.run( + "jobs.process-notification" as RunnableJobName, + payload as any, + { + ...(deliverAt && { startAfter: deliverAt }), + ...(idempotencyKey && { singletonKey: idempotencyKey }), + }, + ); + } + + async cancel(idempotencyKey: string): Promise { + await this.jobService.unschedule( + "jobs.process-notification" as RunnableJobName, + { correlationId: idempotencyKey }, + ); + } + + async getNotifications({ + userId, + type, + read, + limit = 20, + cursor, + }: { + userId: string; + type?: string; + read?: boolean; + limit?: number; + cursor?: string; + }): Promise<{ items: NotificationDB[]; nextCursor: string | null }> { + const conditions = [eq(notification.userId, userId)]; + + if (type !== undefined) { + conditions.push(eq(notification.type, type)); + } + if (read !== undefined) { + conditions.push(eq(notification.read, read)); + } + if (cursor) { + conditions.push(lt(notification.createdAt, new Date(cursor))); + } + + const items = await this.db + .select() + .from(notification) + .where(and(...conditions)) + .orderBy(desc(notification.createdAt)) + .limit(limit + 1); + + const hasMore = items.length > limit; + if (hasMore) items.pop(); + + const nextCursor = + hasMore && items.length > 0 + ? items[items.length - 1]!.createdAt.toISOString() + : null; + + return { items, nextCursor }; + } + + async getUnreadCount(userId: string): Promise { + const [result] = await this.db + .select({ count: count() }) + .from(notification) + .where( + and(eq(notification.userId, userId), eq(notification.read, false)), + ); + return result?.count ?? 0; + } + + async markAsRead(notificationId: string, userId: string): Promise { + await this.db + .update(notification) + .set({ read: true, readAt: new Date() }) + .where( + and( + eq(notification.id, notificationId), + eq(notification.userId, userId), + ), + ); + } + + async markAllAsRead(userId: string): Promise { + await this.db + .update(notification) + .set({ read: true, readAt: new Date() }) + .where( + and(eq(notification.userId, userId), eq(notification.read, false)), + ); + } + + async processNotification({ + type, + audience, + context, + actorId, + idempotencyKey, + excludeUserIds, + }: { + type: string; + audience: Audience | Audience[]; + context: Record; + actorId?: string; + idempotencyKey?: string; + excludeUserIds?: string[]; + }): Promise { + // Check idempotency + if (idempotencyKey) { + const [existing] = await this.db + .select({ id: notification.id }) + .from(notification) + .where(eq(notification.idempotencyKey, idempotencyKey)) + .limit(1); + + if (existing) return; + } + + const typeDef = getNotificationTypeDefinition(type); + if (!typeDef) { + console.warn(`[notification] Unknown notification type: ${type}`); + return; + } + + // Resolve audience(s) to recipients + const audiences = Array.isArray(audience) ? audience : [audience]; + const allRecipients: ResolvedRecipient[] = []; + for (const aud of audiences) { + const resolved = await this.resolveAudience(aud); + allRecipients.push(...resolved); + } + const deduplicated = deduplicateRecipients(allRecipients); + + // Apply exclusions + const excludeSet = new Set(excludeUserIds ?? []); + const recipients = + excludeSet.size > 0 + ? deduplicated.filter((r) => !excludeSet.has(r.userId)) + : deduplicated; + if (recipients.length === 0) return; + + // Resolve preferences in bulk + const userIds = recipients.map((r) => r.userId); + const preferences = + await this.preferenceService.getPreferencesForRecipients({ + type, + userIds, + }); + + // Render notification content + const title = typeDef.title(context); + const body = typeDef.body(context); + const linkUrl = typeDef.linkUrl?.(context); + const sourceType = typeDef.sourceType; + const sourceId = typeDef.sourceId?.(context); + + // Process in_app channel: batch insert notifications + const inAppRecipients = recipients.filter((r) => { + const channelPrefs = preferences.get(r.userId); + return channelPrefs?.get("in_app") !== false; + }); + + if (inAppRecipients.length > 0) { + // Use the same idempotency key base but append userId for per-user uniqueness + const notificationRows = inAppRecipients.map((r) => ({ + userId: r.userId, + type, + title, + body, + linkUrl, + sourceType, + sourceId, + actorId, + idempotencyKey: idempotencyKey + ? `${idempotencyKey}:${r.userId}` + : undefined, + })); + + await this.db + .insert(notification) + .values(notificationRows) + .onConflictDoNothing({ target: notification.idempotencyKey }); + } + + // Process email channel + const emailRecipients = recipients.filter((r) => { + const channelPrefs = preferences.get(r.userId); + return channelPrefs?.get("email") === true; + }); + + if (emailRecipients.length > 0) { + // Render HTML email once for all recipients (content is identical) + let html: string | undefined; + let emailText: string | undefined; + + if (typeDef.renderEmail) { + try { + const rendered = await typeDef.renderEmail(context); + html = rendered.html; + emailText = rendered.text; + } catch (error) { + console.error( + `[notification] Failed to render email template for ${type}:`, + error, + ); + } + } + + for (const recipient of emailRecipients) { + try { + if (html) { + await this.emailService.send( + recipient.email, + title, + emailText ?? body, + html, + ); + } else { + await this.emailService.send(recipient.email, title, body); + } + } catch (error) { + console.error( + `[notification] Failed to send email to ${recipient.email} for ${type}:`, + error, + ); + } + } + } + } + + private async resolveAudience( + audience: Audience, + ): Promise { + console.log("processing notif"); + switch (audience.kind) { + case "user": { + const [result] = await this.db + .select({ userId: user.id, email: user.email }) + .from(user) + .where(eq(user.id, audience.userId)); + return result ? [result] : []; + } + + case "users": { + if (audience.userIds.length === 0) return []; + return this.db + .select({ userId: user.id, email: user.email }) + .from(user) + .where(inArray(user.id, audience.userIds)); + } + + case "role": { + return this.db + .select({ userId: user.id, email: user.email }) + .from(user) + .where(and(eq(user.role, audience.role), eq(user.status, "active"))); + } + + case "shift": { + // Get volunteers assigned to the shift's schedule + const shiftRow = await this.db + .select({ scheduleId: shift.scheduleId }) + .from(shift) + .where(eq(shift.id, audience.shiftId)) + .then((rows) => rows[0]); + + if (!shiftRow) return []; + + const volunteers = await this.db + .select({ userId: user.id, email: user.email }) + .from(volunteerToSchedule) + .innerJoin(user, eq(user.id, volunteerToSchedule.volunteerUserId)) + .where(eq(volunteerToSchedule.scheduleId, shiftRow.scheduleId)); + + const instructors = await this.db + .select({ userId: user.id, email: user.email }) + .from(instructorToSchedule) + .innerJoin(user, eq(user.id, instructorToSchedule.instructorUserId)) + .where(eq(instructorToSchedule.scheduleId, shiftRow.scheduleId)); + + return deduplicateRecipients([...volunteers, ...instructors]); + } + + case "class": { + // Get all volunteers and instructors across all schedules for a class + const schedules = await this.db + .select({ id: schedule.id }) + .from(schedule) + .where(eq(schedule.courseId, audience.classId)); + + const scheduleIds = schedules.map((s) => s.id); + if (scheduleIds.length === 0) return []; + + const volunteers = await this.db + .select({ userId: user.id, email: user.email }) + .from(volunteerToSchedule) + .innerJoin(user, eq(user.id, volunteerToSchedule.volunteerUserId)) + .where(inArray(volunteerToSchedule.scheduleId, scheduleIds)); + + const instructors = await this.db + .select({ userId: user.id, email: user.email }) + .from(instructorToSchedule) + .innerJoin(user, eq(user.id, instructorToSchedule.instructorUserId)) + .where(inArray(instructorToSchedule.scheduleId, scheduleIds)); + + return deduplicateRecipients([...volunteers, ...instructors]); + } + } + } +} + +function deduplicateRecipients( + recipients: ResolvedRecipient[], +): ResolvedRecipient[] { + const seen = new Set(); + return recipients.filter((r) => { + if (seen.has(r.userId)) return false; + seen.add(r.userId); + return true; + }); +} diff --git a/src/server/services/preferenceService.ts b/src/server/services/preferenceService.ts new file mode 100644 index 00000000..06b8122e --- /dev/null +++ b/src/server/services/preferenceService.ts @@ -0,0 +1,173 @@ +import type { Drizzle } from "@/server/db"; +import { + notificationPreference, + type NotificationPreferenceDB, +} from "@/server/db/schema/notification"; +import { + allNotificationTypes, + notificationTypes, + type NotificationType, +} from "@/server/notifications/registry"; +import type { + EffectivePreference, + NotificationChannel, +} from "@/server/notifications/types"; +import { and, eq, inArray } from "drizzle-orm"; + +export interface IPreferenceService { + getEffectivePreferences(userId: string): Promise; + + setPreference(params: { + userId: string; + type: string; + channel: NotificationChannel; + enabled: boolean; + }): Promise; + + clearPreference(params: { + userId: string; + type: string; + channel: NotificationChannel; + }): Promise; + + getPreferencesForRecipients(params: { + type: string; + userIds: string[]; + }): Promise>>; +} + +export class PreferenceService implements IPreferenceService { + private readonly db: Drizzle; + + constructor({ db }: { db: Drizzle }) { + this.db = db; + } + + async getEffectivePreferences( + userId: string, + ): Promise { + const overrides = await this.db + .select() + .from(notificationPreference) + .where(eq(notificationPreference.userId, userId)); + + const overrideMap = new Map(); + for (const row of overrides) { + overrideMap.set(`${row.type}:${row.channel}`, row); + } + + const result: EffectivePreference[] = []; + + for (const typeKey of allNotificationTypes) { + const typeDef = notificationTypes[typeKey]; + const channels = Object.entries(typeDef.channelDefaults) as [ + NotificationChannel, + boolean, + ][]; + + for (const [channel, defaultEnabled] of channels) { + const override = overrideMap.get(`${typeKey}:${channel}`); + result.push({ + type: typeKey, + channel, + enabled: override ? override.enabled : defaultEnabled, + isOverride: !!override, + }); + } + } + + return result; + } + + async setPreference({ + userId, + type, + channel, + enabled, + }: { + userId: string; + type: string; + channel: NotificationChannel; + enabled: boolean; + }): Promise { + await this.db + .insert(notificationPreference) + .values({ userId, type, channel, enabled }) + .onConflictDoUpdate({ + target: [ + notificationPreference.userId, + notificationPreference.type, + notificationPreference.channel, + ], + set: { enabled, updatedAt: new Date() }, + }); + } + + async clearPreference({ + userId, + type, + channel, + }: { + userId: string; + type: string; + channel: NotificationChannel; + }): Promise { + await this.db + .delete(notificationPreference) + .where( + and( + eq(notificationPreference.userId, userId), + eq(notificationPreference.type, type), + eq(notificationPreference.channel, channel), + ), + ); + } + + async getPreferencesForRecipients({ + type, + userIds, + }: { + type: string; + userIds: string[]; + }): Promise>> { + const typeDef = notificationTypes[type as NotificationType]; + const defaults = typeDef?.channelDefaults ?? {}; + + const result = new Map>(); + + // Initialize with defaults for all users + for (const userId of userIds) { + const channelMap = new Map(); + for (const [channel, enabled] of Object.entries(defaults)) { + channelMap.set(channel as NotificationChannel, enabled); + } + result.set(userId, channelMap); + } + + if (userIds.length === 0) return result; + + // Load overrides in bulk + const overrides = await this.db + .select() + .from(notificationPreference) + .where( + and( + inArray(notificationPreference.userId, userIds), + eq(notificationPreference.type, type), + ), + ); + + // Apply overrides + for (const override of overrides) { + const channelMap = result.get(override.userId); + if (channelMap) { + channelMap.set( + override.channel as NotificationChannel, + override.enabled, + ); + } + } + + return result; + } +} diff --git a/src/test/integration/preference-service.test.ts b/src/test/integration/preference-service.test.ts new file mode 100644 index 00000000..17669e25 --- /dev/null +++ b/src/test/integration/preference-service.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { randomUUID } from "crypto"; +import { asClass } from "awilix"; +import { user, notificationPreference } from "@/server/db/schema"; +import { inArray } from "drizzle-orm"; +import { + PreferenceService, + type IPreferenceService, +} from "@/server/services/preferenceService"; +import { + createTestScope, + type ITestServiceScope, +} from "../helpers/test-service-scope"; + +describe("PreferenceService", () => { + let scope: ITestServiceScope; + let preferenceService: IPreferenceService; + let userId: string; + const createdUserIds: string[] = []; + + beforeEach(async () => { + scope = createTestScope(); + scope.mockSession.setAsAdmin(); + // Override the mock with the real PreferenceService for integration testing + scope.container.register({ + preferenceService: + asClass(PreferenceService).scoped(), + }); + preferenceService = + scope.resolve("preferenceService"); + + userId = randomUUID(); + await scope.db.insert(user).values({ + id: userId, + name: "Test", + lastName: "User", + email: `pref-test-${Date.now()}-${randomUUID()}@test.com`, + role: "volunteer", + status: "active", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + createdUserIds.push(userId); + }); + + afterEach(async () => { + if (createdUserIds.length > 0) { + await scope.db + .delete(notificationPreference) + .where(inArray(notificationPreference.userId, createdUserIds)); + await scope.db + .delete(user) + .where(inArray(user.id, createdUserIds)); + createdUserIds.length = 0; + } + scope.dispose(); + }); + + describe("getEffectivePreferences", () => { + it("should return registry defaults when no overrides exist", async () => { + const prefs = await preferenceService.getEffectivePreferences(userId); + + expect(prefs.length).toBeGreaterThan(0); + + // shift.cancelled has defaults: email: true, in_app: true + const shiftEmail = prefs.find( + (p) => p.type === "shift.cancelled" && p.channel === "email", + ); + expect(shiftEmail).toBeDefined(); + expect(shiftEmail!.enabled).toBe(true); + expect(shiftEmail!.isOverride).toBe(false); + + const shiftInApp = prefs.find( + (p) => p.type === "shift.cancelled" && p.channel === "in_app", + ); + expect(shiftInApp).toBeDefined(); + expect(shiftInApp!.enabled).toBe(true); + expect(shiftInApp!.isOverride).toBe(false); + }); + + it("should apply user override over registry default", async () => { + await preferenceService.setPreference({ + userId, + type: "shift.cancelled", + channel: "email", + enabled: false, + }); + + const prefs = await preferenceService.getEffectivePreferences(userId); + + const shiftEmail = prefs.find( + (p) => p.type === "shift.cancelled" && p.channel === "email", + ); + expect(shiftEmail!.enabled).toBe(false); + expect(shiftEmail!.isOverride).toBe(true); + + // in_app should still be the default + const shiftInApp = prefs.find( + (p) => p.type === "shift.cancelled" && p.channel === "in_app", + ); + expect(shiftInApp!.enabled).toBe(true); + expect(shiftInApp!.isOverride).toBe(false); + }); + + it("should include preferences for all registered notification types", async () => { + const prefs = await preferenceService.getEffectivePreferences(userId); + + const types = new Set(prefs.map((p) => p.type)); + expect(types.has("shift.cancelled")).toBe(true); + expect(types.has("coverage.requested")).toBe(true); + }); + }); + + describe("setPreference", () => { + it("should create a new preference override", async () => { + await preferenceService.setPreference({ + userId, + type: "shift.cancelled", + channel: "email", + enabled: false, + }); + + const prefs = await preferenceService.getEffectivePreferences(userId); + const pref = prefs.find( + (p) => p.type === "shift.cancelled" && p.channel === "email", + ); + expect(pref!.enabled).toBe(false); + expect(pref!.isOverride).toBe(true); + }); + + it("should update an existing preference override (upsert)", async () => { + await preferenceService.setPreference({ + userId, + type: "shift.cancelled", + channel: "email", + enabled: false, + }); + + await preferenceService.setPreference({ + userId, + type: "shift.cancelled", + channel: "email", + enabled: true, + }); + + const prefs = await preferenceService.getEffectivePreferences(userId); + const pref = prefs.find( + (p) => p.type === "shift.cancelled" && p.channel === "email", + ); + expect(pref!.enabled).toBe(true); + expect(pref!.isOverride).toBe(true); + }); + + it("should set preferences independently per channel", async () => { + await preferenceService.setPreference({ + userId, + type: "shift.cancelled", + channel: "email", + enabled: false, + }); + await preferenceService.setPreference({ + userId, + type: "shift.cancelled", + channel: "in_app", + enabled: false, + }); + + const prefs = await preferenceService.getEffectivePreferences(userId); + const emailPref = prefs.find( + (p) => p.type === "shift.cancelled" && p.channel === "email", + ); + const inAppPref = prefs.find( + (p) => p.type === "shift.cancelled" && p.channel === "in_app", + ); + + expect(emailPref!.enabled).toBe(false); + expect(inAppPref!.enabled).toBe(false); + }); + }); + + describe("clearPreference", () => { + it("should remove an override and revert to registry default", async () => { + // Set override to disabled + await preferenceService.setPreference({ + userId, + type: "shift.cancelled", + channel: "email", + enabled: false, + }); + + // Verify it's overridden + let prefs = await preferenceService.getEffectivePreferences(userId); + let pref = prefs.find( + (p) => p.type === "shift.cancelled" && p.channel === "email", + ); + expect(pref!.enabled).toBe(false); + expect(pref!.isOverride).toBe(true); + + // Clear the override + await preferenceService.clearPreference({ + userId, + type: "shift.cancelled", + channel: "email", + }); + + // Should revert to registry default (true) + prefs = await preferenceService.getEffectivePreferences(userId); + pref = prefs.find( + (p) => p.type === "shift.cancelled" && p.channel === "email", + ); + expect(pref!.enabled).toBe(true); + expect(pref!.isOverride).toBe(false); + }); + + it("should be a no-op when no override exists", async () => { + // Should not throw + await preferenceService.clearPreference({ + userId, + type: "shift.cancelled", + channel: "email", + }); + + const prefs = await preferenceService.getEffectivePreferences(userId); + const pref = prefs.find( + (p) => p.type === "shift.cancelled" && p.channel === "email", + ); + expect(pref!.enabled).toBe(true); + expect(pref!.isOverride).toBe(false); + }); + }); + + describe("getPreferencesForRecipients", () => { + let user2Id: string; + + beforeEach(async () => { + user2Id = randomUUID(); + await scope.db.insert(user).values({ + id: user2Id, + name: "Test", + lastName: "User2", + email: `pref-test2-${Date.now()}-${randomUUID()}@test.com`, + role: "volunteer", + status: "active", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + createdUserIds.push(user2Id); + }); + + it("should return registry defaults for all users when no overrides exist", async () => { + const result = await preferenceService.getPreferencesForRecipients({ + type: "shift.cancelled", + userIds: [userId, user2Id], + }); + + expect(result.size).toBe(2); + + for (const uid of [userId, user2Id]) { + const channelMap = result.get(uid); + expect(channelMap).toBeDefined(); + expect(channelMap!.get("email")).toBe(true); + expect(channelMap!.get("in_app")).toBe(true); + } + }); + + it("should apply overrides per-user while others keep defaults", async () => { + await preferenceService.setPreference({ + userId, + type: "shift.cancelled", + channel: "email", + enabled: false, + }); + + const result = await preferenceService.getPreferencesForRecipients({ + type: "shift.cancelled", + userIds: [userId, user2Id], + }); + + // user1 has email disabled + expect(result.get(userId)!.get("email")).toBe(false); + expect(result.get(userId)!.get("in_app")).toBe(true); + + // user2 keeps defaults + expect(result.get(user2Id)!.get("email")).toBe(true); + expect(result.get(user2Id)!.get("in_app")).toBe(true); + }); + + it("should handle empty userIds array", async () => { + const result = await preferenceService.getPreferencesForRecipients({ + type: "shift.cancelled", + userIds: [], + }); + + expect(result.size).toBe(0); + }); + + it("should only load overrides for the requested type", async () => { + // Set override for shift.cancelled + await preferenceService.setPreference({ + userId, + type: "shift.cancelled", + channel: "email", + enabled: false, + }); + + // Query for coverage.requested — the shift.cancelled override should not apply + const result = await preferenceService.getPreferencesForRecipients({ + type: "coverage.requested", + userIds: [userId], + }); + + expect(result.get(userId)!.get("email")).toBe(true); + }); + + it("should handle multiple overrides for same user", async () => { + await preferenceService.setPreference({ + userId, + type: "shift.cancelled", + channel: "email", + enabled: false, + }); + await preferenceService.setPreference({ + userId, + type: "shift.cancelled", + channel: "in_app", + enabled: false, + }); + + const result = await preferenceService.getPreferencesForRecipients({ + type: "shift.cancelled", + userIds: [userId], + }); + + expect(result.get(userId)!.get("email")).toBe(false); + expect(result.get(userId)!.get("in_app")).toBe(false); + }); + }); +}); diff --git a/src/test/mocks/mock-notification-services.ts b/src/test/mocks/mock-notification-services.ts new file mode 100644 index 00000000..4dbe1a17 --- /dev/null +++ b/src/test/mocks/mock-notification-services.ts @@ -0,0 +1,73 @@ +import type { INotificationEventService } from "@/server/services/notificationEventService"; +import type { INotificationService } from "@/server/services/notificationService"; +import type { IPreferenceService } from "@/server/services/preferenceService"; +import type { NotificationDB } from "@/server/db/schema/notification"; +import type { + EffectivePreference, + NotificationChannel, + NotifyParams, +} from "@/server/notifications/types"; + +export class MockNotificationService implements INotificationService { + public calls: NotifyParams[] = []; + + async notify(params: NotifyParams): Promise { + this.calls.push(params); + return "mock-notification-job-id"; + } + + async cancel(): Promise {} + + async getNotifications(): Promise<{ + items: NotificationDB[]; + nextCursor: string | null; + }> { + return { items: [], nextCursor: null }; + } + + async getUnreadCount(): Promise { + return 0; + } + + async markAsRead(): Promise {} + async markAllAsRead(): Promise {} + + async processNotification(): Promise {} + + clear() { + this.calls = []; + } +} + +export class MockPreferenceService implements IPreferenceService { + async getEffectivePreferences(): Promise { + return []; + } + + async setPreference(): Promise {} + async clearPreference(): Promise {} + + async getPreferencesForRecipients(): Promise< + Map> + > { + return new Map(); + } +} + +export class MockNotificationEventService + implements INotificationEventService +{ + public calls: { method: string; params: unknown }[] = []; + + async notifyShiftCancelled(params: unknown): Promise { + this.calls.push({ method: "notifyShiftCancelled", params }); + } + + async notifyCoverageRequested(params: unknown): Promise { + this.calls.push({ method: "notifyCoverageRequested", params }); + } + + clear() { + this.calls = []; + } +} diff --git a/src/test/test-container.ts b/src/test/test-container.ts index 081a9963..c544b680 100644 --- a/src/test/test-container.ts +++ b/src/test/test-container.ts @@ -17,6 +17,14 @@ import { MockEmailService } from "./mocks/mock-email-service"; import { MockImageService } from "./mocks/mock-image-service"; import { MockCurrentSessionService } from "./mocks/mock-current-session-service"; import { MockJobService } from "./mocks/mock-job-service"; +import { + MockNotificationService, + MockPreferenceService, + MockNotificationEventService, +} from "./mocks/mock-notification-services"; +import type { INotificationService } from "@/server/services/notificationService"; +import type { IPreferenceService } from "@/server/services/preferenceService"; +import type { INotificationEventService } from "@/server/services/notificationEventService"; // Services that use real implementations with test DB import { @@ -97,6 +105,14 @@ export function createTestContainer( volunteerService: asClass(VolunteerService).singleton(), termService: asClass(TermService).scoped(), coverageService: asClass(CoverageService).scoped(), + + // Notification services (mocked) + notificationService: + asClass(MockNotificationService).singleton(), + preferenceService: + asClass(MockPreferenceService).singleton(), + notificationEventService: + asClass(MockNotificationEventService).singleton(), }); return container; From 1ecbf3d34ef6acd54e0a810a0a37655e7292e4c0 Mon Sep 17 00:00:00 2001 From: theosiemensrhodes Date: Fri, 20 Mar 2026 17:24:15 -0700 Subject: [PATCH 3/9] feat: better dropdown content --- .../notifications/notification-inbox.tsx | 259 +++++++++++++++--- .../notifications/notification-item.tsx | 85 ++++-- .../schedule/cancel-shift-button.tsx | 18 +- .../schedule/shift-details-aside.tsx | 1 - src/components/ui/badge.tsx | 2 +- src/models/api/notification.ts | 8 + src/server/api/routers/notification-router.ts | 28 ++ src/server/db/migrations/meta/_journal.json | 7 + src/server/db/schema/notification.ts | 3 + src/server/services/notificationService.ts | 92 ++++++- src/styles/globals.css | 3 - src/test/mocks/mock-notification-services.ts | 4 + 12 files changed, 434 insertions(+), 76 deletions(-) diff --git a/src/components/notifications/notification-inbox.tsx b/src/components/notifications/notification-inbox.tsx index b747c01d..3bd292f0 100644 --- a/src/components/notifications/notification-inbox.tsx +++ b/src/components/notifications/notification-inbox.tsx @@ -1,30 +1,74 @@ "use client"; -import { Inbox } from "lucide-react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; +import { + Archive, + Check, + Inbox, + ListFilter, + MoreHorizontal, +} from "lucide-react"; +import { Button as UIButton } from "@/components/ui/button"; +import { Button } from "@/components/primitives/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; import { clientApi } from "@/trpc/client"; import { NotificationItem } from "./notification-item"; -import { useState } from "react"; + +type Filter = "all" | "unread" | "archived"; + +function getQueryParams(filter: Filter) { + switch (filter) { + case "all": + return { archived: false } as const; + case "unread": + return { read: false, archived: false } as const; + case "archived": + return { archived: true } as const; + } +} + +const filterLabels: Record = { + all: "Unread & read", + unread: "Unread", + archived: "Archived", +}; + +const emptyMessages: Record = { + all: "You're all caught up", + unread: "No unread notifications", + archived: "No archived notifications", +}; export function NotificationInbox() { const router = useRouter(); const [open, setOpen] = useState(false); + const [filter, setFilter] = useState("all"); const utils = clientApi.useUtils(); - const { data: unreadCount = 0 } = - clientApi.notification.unreadCount.useQuery(undefined, { + const { data: unreadCount = 0 } = clientApi.notification.unreadCount.useQuery( + undefined, + { refetchInterval: 30_000, - }); + }, + ); const { data, isLoading } = clientApi.notification.list.useQuery( - { limit: 20 }, + { limit: 20, ...getQueryParams(filter) }, { enabled: open }, ); @@ -35,6 +79,13 @@ export function NotificationInbox() { }, }); + const markAsUnread = clientApi.notification.markAsUnread.useMutation({ + onSuccess: () => { + void utils.notification.unreadCount.invalidate(); + void utils.notification.list.invalidate(); + }, + }); + const markAllAsRead = clientApi.notification.markAllAsRead.useMutation({ onSuccess: () => { void utils.notification.unreadCount.invalidate(); @@ -42,7 +93,31 @@ export function NotificationInbox() { }, }); - const handleNotificationClick = (notificationId: string, linkUrl?: string | null) => { + const archive = clientApi.notification.archive.useMutation({ + onSuccess: () => { + void utils.notification.unreadCount.invalidate(); + void utils.notification.list.invalidate(); + }, + }); + + const unarchive = clientApi.notification.unarchive.useMutation({ + onSuccess: () => { + void utils.notification.unreadCount.invalidate(); + void utils.notification.list.invalidate(); + }, + }); + + const archiveAll = clientApi.notification.archiveAll.useMutation({ + onSuccess: () => { + void utils.notification.unreadCount.invalidate(); + void utils.notification.list.invalidate(); + }, + }); + + const handleNotificationClick = ( + notificationId: string, + linkUrl?: string | null, + ) => { markAsRead.mutate({ notificationId }); if (linkUrl) { router.push(linkUrl as any); @@ -50,56 +125,172 @@ export function NotificationInbox() { setOpen(false); }; + const handleArchive = (notificationId: string) => { + archive.mutate({ notificationId }); + }; + + const handleUnarchive = (notificationId: string) => { + unarchive.mutate({ notificationId }); + }; + + const handleToggleRead = (notificationId: string) => { + const item = items.find((n) => n.id === notificationId); + if (item?.read) { + markAsUnread.mutate({ notificationId }); + } else { + markAsRead.mutate({ notificationId }); + } + }; + + const items = data?.items ?? []; + + // Group into time buckets + const now = new Date(); + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + + const sevenDaysAgo = new Date(startOfToday); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const groups: { label: string; items: typeof items }[] = []; + const todayItems = items.filter((n) => new Date(n.createdAt) >= startOfToday); + const weekItems = items.filter((n) => { + const d = new Date(n.createdAt); + return d < startOfToday && d >= sevenDaysAgo; + }); + const monthItems = items.filter((n) => { + const d = new Date(n.createdAt); + return d < sevenDaysAgo && d >= startOfMonth; + }); + const olderItems = items.filter((n) => new Date(n.createdAt) < startOfMonth); + + if (todayItems.length > 0) groups.push({ label: "Today", items: todayItems }); + if (weekItems.length > 0) + groups.push({ label: "This Week", items: weekItems }); + if (monthItems.length > 0) + groups.push({ label: "This Month", items: monthItems }); + if (olderItems.length > 0) groups.push({ label: "Older", items: olderItems }); + + const isArchivedView = filter === "archived"; + const hasUnread = items.some((n) => !n.read); + const hasItems = items.length > 0; + return ( - + e.preventDefault()} > -
-

Notifications

- {unreadCount > 0 && ( - - )} + {/* Header */} +
+

Notifications

+
+ {/* Filter dropdown */} + + + + + + Filter + + {(["all", "unread", "archived"] as const).map((f) => ( + setFilter(f)} + > + {filterLabels[f]} + + ))} + + + + {/* More actions dropdown */} + + + + + + markAllAsRead.mutate()} + > + + Mark all as read + + archiveAll.mutate()} + > + + Archive all + + + +
+ + {/* Notification list */} {isLoading ? (

Loading...

- ) : !data?.items.length ? ( + ) : items.length === 0 ? (

- You're all caught up + {emptyMessages[filter]}

) : ( -
- {data.items.map((n) => ( - handleNotificationClick(n.id, n.linkUrl)} - /> +
+ {groups.map((group) => ( +
+ {groups.length > 1 && ( +

+ {group.label} +

+ )} +
+ {group.items.map((n) => ( + handleNotificationClick(n.id, n.linkUrl)} + onArchive={handleArchive} + onUnarchive={handleUnarchive} + onToggleRead={handleToggleRead} + isArchivedView={isArchivedView} + /> + ))} +
+
))}
)} diff --git a/src/components/notifications/notification-item.tsx b/src/components/notifications/notification-item.tsx index ed0f337d..60b8ce26 100644 --- a/src/components/notifications/notification-item.tsx +++ b/src/components/notifications/notification-item.tsx @@ -1,8 +1,11 @@ "use client"; +import { Archive, ArchiveRestore, MailOpen, Mail } from "lucide-react"; import type { NotificationDB } from "@/server/db/schema/notification"; +import { Button as UIButton } from "@/components/ui/button"; +import { Button } from "@/components/primitives/button"; -function timeAgo(date: Date): string { +export function timeAgo(date: Date): string { const now = new Date(); const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); @@ -19,42 +22,92 @@ function timeAgo(date: Date): string { interface NotificationItemProps { notification: NotificationDB; onClick: () => void; + onArchive: (notificationId: string) => void; + onUnarchive: (notificationId: string) => void; + onToggleRead: (notificationId: string) => void; + isArchivedView?: boolean; } export function NotificationItem({ notification, onClick, + onArchive, + onUnarchive, + onToggleRead, + isArchivedView = false, }: NotificationItemProps) { + const isUnread = !notification.read; + return ( - + + {/* Hover actions — sit above the overlay */} +
+ {isArchivedView ? ( + + ) : ( + <> + + + + )} +
+
); } diff --git a/src/components/schedule/cancel-shift-button.tsx b/src/components/schedule/cancel-shift-button.tsx index be2d15fc..ec7d509f 100644 --- a/src/components/schedule/cancel-shift-button.tsx +++ b/src/components/schedule/cancel-shift-button.tsx @@ -15,26 +15,10 @@ export function CancelShiftButton({ shift: SingleShift; className?: string; }) { - if ( - shift.status === ShiftStatus.inprogress || - shift.status === ShiftStatus.finished - ) { + if (shift.status !== ShiftStatus.scheduled) { return null; } - if (shift.status === ShiftStatus.cancelled) { - return ( - - {ShiftStatus.getName(shift.status)} - - ); - } - return (
- - className={cn( - "min-w-45 max-w-80 shrink justify-between gap-2", + "min-w-45 max-w-80 h-9 shrink justify-between gap-2", className, )} > diff --git a/src/components/notifications/notification-inbox.tsx b/src/components/notifications/notification-inbox.tsx index 3bd292f0..a47e355c 100644 --- a/src/components/notifications/notification-inbox.tsx +++ b/src/components/notifications/notification-inbox.tsx @@ -9,7 +9,6 @@ import { ListFilter, MoreHorizontal, } from "lucide-react"; -import { Button as UIButton } from "@/components/ui/button"; import { Button } from "@/components/primitives/button"; import { Popover, @@ -180,17 +179,21 @@ export function NotificationInbox() { return ( - - + e.preventDefault()} diff --git a/src/components/page-layout.tsx b/src/components/page-layout.tsx index 10b8453e..e5fbdaec 100644 --- a/src/components/page-layout.tsx +++ b/src/components/page-layout.tsx @@ -7,6 +7,7 @@ import { TypographyPageTitle } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; import CaretLeftIcon from "@public/assets/icons/caret-left.svg"; import { useRouter } from "next/navigation"; +import { NotificationInbox } from "./notifications/notification-inbox"; import { SidebarTrigger } from "./ui/sidebar"; type PageLayoutContextValue = { @@ -217,6 +218,10 @@ function PageLayoutHeaderContent({ )} {children} + +
+ +
); } diff --git a/src/components/ui/filter-bar.tsx b/src/components/ui/filter-bar.tsx index 2b405f60..f8d4b868 100644 --- a/src/components/ui/filter-bar.tsx +++ b/src/components/ui/filter-bar.tsx @@ -286,7 +286,7 @@ function FilterBarRow({ return (
{children}
diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 1747f53e..aa3963a2 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -551,6 +551,7 @@ function SidebarMenuButton({
); } diff --git a/src/server/emails/templates/coverage-filled-personal.tsx b/src/server/emails/templates/coverage-filled-personal.tsx new file mode 100644 index 00000000..93e9abe7 --- /dev/null +++ b/src/server/emails/templates/coverage-filled-personal.tsx @@ -0,0 +1,44 @@ +import { Heading, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface CoverageFilledPersonalEmailProps { + className: string; + shiftDate: string; + coveredByVolunteerName: string; +} + +export function CoverageFilledPersonalEmail({ + className, + shiftDate, + coveredByVolunteerName, +}: CoverageFilledPersonalEmailProps) { + return ( + + + Your Coverage Request Was Filled + + + Good news!{" "} + {coveredByVolunteerName}{" "} + has picked up your shift for{" "} + {className} on{" "} + {shiftDate}. You no longer + need to attend this shift. + + + ); +} + +export default CoverageFilledPersonalEmail; + +export function renderCoverageFilledPersonal( + props: CoverageFilledPersonalEmailProps, +) { + return renderEmail(); +} diff --git a/src/server/emails/templates/coverage-filled.tsx b/src/server/emails/templates/coverage-filled.tsx new file mode 100644 index 00000000..bc555a17 --- /dev/null +++ b/src/server/emails/templates/coverage-filled.tsx @@ -0,0 +1,42 @@ +import { Heading, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface CoverageFilledEmailProps { + className: string; + shiftDate: string; + coveredByVolunteerName: string; + requestingVolunteerName: string; +} + +export function CoverageFilledEmail({ + className, + shiftDate, + coveredByVolunteerName, + requestingVolunteerName, +}: CoverageFilledEmailProps) { + return ( + + + Coverage Filled + + + {coveredByVolunteerName}{" "} + has picked up the shift for{" "} + {className} on{" "} + {shiftDate} (originally + requested by{" "} + {requestingVolunteerName}). + + + ); +} + +export default CoverageFilledEmail; + +export function renderCoverageFilled(props: CoverageFilledEmailProps) { + return renderEmail(); +} diff --git a/src/server/emails/templates/shift-no-checkin.tsx b/src/server/emails/templates/shift-no-checkin.tsx new file mode 100644 index 00000000..d0896e47 --- /dev/null +++ b/src/server/emails/templates/shift-no-checkin.tsx @@ -0,0 +1,50 @@ +import { Heading, Section, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface ShiftNoCheckinEmailProps { + className: string; + shiftDate: string; + volunteerNames: string; + volunteerCount: number; +} + +export function ShiftNoCheckinEmail({ + className, + shiftDate, + volunteerNames, + volunteerCount, +}: ShiftNoCheckinEmailProps) { + return ( + + + Missed Check-in + + + {volunteerCount} volunteer{volunteerCount !== 1 ? "s" : ""} did not + check in for{" "} + {className} on{" "} + {shiftDate}. + +
+ + Missing Volunteers + + + {volunteerNames} + +
+
+ ); +} + +export default ShiftNoCheckinEmail; + +export function renderShiftNoCheckin(props: ShiftNoCheckinEmailProps) { + return renderEmail(); +} diff --git a/src/server/emails/templates/shift-reminder.tsx b/src/server/emails/templates/shift-reminder.tsx new file mode 100644 index 00000000..476b45e3 --- /dev/null +++ b/src/server/emails/templates/shift-reminder.tsx @@ -0,0 +1,48 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface ShiftReminderEmailProps { + className: string; + shiftDate: string; + shiftTime: string; + shiftId: string; +} + +export function ShiftReminderEmail({ + className, + shiftDate, + shiftTime, + shiftId, +}: ShiftReminderEmailProps) { + return ( + + + Shift Reminder + + + Your shift for{" "} + {className} starts at{" "} + {shiftTime} on{" "} + {shiftDate}. + +
+ +
+
+ ); +} + +export default ShiftReminderEmail; + +export function renderShiftReminder(props: ShiftReminderEmailProps) { + return renderEmail(); +} diff --git a/src/server/jobs/definitions/check-shift-notifications.job.ts b/src/server/jobs/definitions/check-shift-notifications.job.ts new file mode 100644 index 00000000..2fa56f69 --- /dev/null +++ b/src/server/jobs/definitions/check-shift-notifications.job.ts @@ -0,0 +1,158 @@ +import { eq, and, inArray } from "drizzle-orm"; +import type { Drizzle } from "@/server/db"; +import { shift, coverageRequest, shiftAttendance } from "@/server/db/schema"; +import { course } from "@/server/db/schema/course"; +import { volunteerToSchedule } from "@/server/db/schema/schedule"; +import { user } from "@/server/db/schema/user"; +import { CoverageStatus } from "@/models/api/coverage"; +import type { RegisteredJob } from "../types"; + +export type CheckShiftNotificationsPayload = { + shiftId: string; + checkType: "reminder" | "no-checkin"; +}; + +export const checkShiftNotificationsJob: RegisteredJob = + { + name: "jobs.check-shift-notifications", + retryOpts: { + retryLimit: 2, + retryDelay: 60, + retryBackoff: true, + }, + handler: async (payload, { cradle }) => { + const { shiftId, checkType } = payload; + const { db, notificationEventService } = cradle; + + // Fetch shift details + const [shiftRow] = await db + .select({ + id: shift.id, + startAt: shift.startAt, + endAt: shift.endAt, + canceled: shift.canceled, + scheduleId: shift.scheduleId, + courseId: shift.courseId, + className: course.name, + }) + .from(shift) + .innerJoin(course, eq(course.id, shift.courseId)) + .where(eq(shift.id, shiftId)); + + if (!shiftRow || shiftRow.canceled) return; + + // Get effective volunteer roster (accounting for coverage swaps) + const effectiveVolunteers = await getEffectiveVolunteers( + db, + shiftRow.scheduleId, + shiftId, + ); + + if (effectiveVolunteers.length === 0) return; + + if (checkType === "reminder") { + await notificationEventService.notifyShiftReminder({ + shiftId, + className: shiftRow.className, + shiftDate: shiftRow.startAt.toLocaleDateString(), + shiftTime: shiftRow.startAt.toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }), + volunteerUserIds: effectiveVolunteers.map((v) => v.userId), + }); + } else { + // no-checkin: find volunteers who have no attendance record + const attendanceRecords = await db + .select({ userId: shiftAttendance.userId }) + .from(shiftAttendance) + .where(eq(shiftAttendance.shiftId, shiftId)); + + const checkedInUserIds = new Set( + attendanceRecords.map((a) => a.userId), + ); + const missingVolunteers = effectiveVolunteers.filter( + (v) => !checkedInUserIds.has(v.userId), + ); + + if (missingVolunteers.length === 0) return; + + await notificationEventService.notifyShiftNoCheckin({ + shiftId, + className: shiftRow.className, + shiftDate: shiftRow.startAt.toLocaleDateString(), + volunteerNames: missingVolunteers.map((v) => v.name).join(", "), + volunteerCount: missingVolunteers.length, + }); + } + }, + }; + +/** + * Get the effective volunteer roster for a shift, accounting for coverage swaps. + * Original volunteers with resolved coverage are replaced by covering volunteers. + */ +async function getEffectiveVolunteers( + db: Drizzle, + scheduleId: string, + shiftId: string, +) { + // Get all original volunteers for the schedule + const originalVolunteers = await db + .select({ + userId: volunteerToSchedule.volunteerUserId, + name: user.name, + lastName: user.lastName, + }) + .from(volunteerToSchedule) + .innerJoin(user, eq(user.id, volunteerToSchedule.volunteerUserId)) + .where(eq(volunteerToSchedule.scheduleId, scheduleId)); + + // Get resolved coverage requests for this shift + const resolvedCoverage = await db + .select({ + requestingVolunteerUserId: coverageRequest.requestingVolunteerUserId, + coveredByVolunteerUserId: coverageRequest.coveredByVolunteerUserId, + }) + .from(coverageRequest) + .where( + and( + eq(coverageRequest.shiftId, shiftId), + eq(coverageRequest.status, CoverageStatus.resolved), + ), + ); + + // Build sets for quick lookup + const replacedUserIds = new Set( + resolvedCoverage.map((c) => c.requestingVolunteerUserId), + ); + const coveringUserIds = resolvedCoverage + .map((c) => c.coveredByVolunteerUserId) + .filter((id): id is string => id != null); + + // Start with original volunteers minus those replaced by coverage + const effectiveVolunteers = originalVolunteers + .filter((v) => !replacedUserIds.has(v.userId)) + .map((v) => ({ userId: v.userId, name: `${v.name} ${v.lastName}` })); + + // Add covering volunteers + if (coveringUserIds.length > 0) { + const coveringUsers = await db + .select({ + userId: user.id, + name: user.name, + lastName: user.lastName, + }) + .from(user) + .where(inArray(user.id, coveringUserIds)); + + for (const u of coveringUsers) { + effectiveVolunteers.push({ + userId: u.userId, + name: `${u.name} ${u.lastName}`, + }); + } + } + + return effectiveVolunteers; +} diff --git a/src/server/jobs/registry.ts b/src/server/jobs/registry.ts index c06e493e..38a749b5 100644 --- a/src/server/jobs/registry.ts +++ b/src/server/jobs/registry.ts @@ -1,10 +1,12 @@ import { cleanupOrphanedImagesJob } from "./definitions/cleanup-orphaned-images.job"; import { processNotificationJob } from "./definitions/process-notification.job"; +import { checkShiftNotificationsJob } from "./definitions/check-shift-notifications.job"; import type { RegisteredJob } from "./types"; const allJobs = [ cleanupOrphanedImagesJob, processNotificationJob, + checkShiftNotificationsJob, ] as const satisfies readonly RegisteredJob[]; type AnyKnownJob = (typeof allJobs)[number]; diff --git a/src/server/notifications/registry.ts b/src/server/notifications/registry.ts index ea4c4be2..2200da3b 100644 --- a/src/server/notifications/registry.ts +++ b/src/server/notifications/registry.ts @@ -2,6 +2,10 @@ import type { NotificationTypeDefinition } from "./types"; import { renderShiftCancelled } from "@/server/emails/templates/shift-cancelled"; import { renderCoverageRequested } from "@/server/emails/templates/coverage-requested"; import { renderCoverageAvailable } from "@/server/emails/templates/coverage-available"; +import { renderShiftReminder } from "@/server/emails/templates/shift-reminder"; +import { renderShiftNoCheckin } from "@/server/emails/templates/shift-no-checkin"; +import { renderCoverageFilled } from "@/server/emails/templates/coverage-filled"; +import { renderCoverageFilledPersonal } from "@/server/emails/templates/coverage-filled-personal"; export interface ShiftCancelledContext { shiftId: string; @@ -27,6 +31,38 @@ export interface CoverageAvailableContext { shiftDate: string; } +export interface ShiftReminderContext { + shiftId: string; + className: string; + shiftDate: string; + shiftTime: string; +} + +export interface ShiftNoCheckinContext { + shiftId: string; + className: string; + shiftDate: string; + volunteerNames: string; + volunteerCount: number; +} + +export interface CoverageFilledContext { + coverageRequestId: string; + shiftId: string; + className: string; + shiftDate: string; + coveredByVolunteerName: string; + requestingVolunteerName: string; +} + +export interface CoverageFilledPersonalContext { + coverageRequestId: string; + shiftId: string; + className: string; + shiftDate: string; + coveredByVolunteerName: string; +} + export const notificationTypes = { "shift.cancelled": { key: "shift.cancelled", @@ -80,6 +116,77 @@ export const notificationTypes = { coverageRequestId: ctx.coverageRequestId, }), } satisfies NotificationTypeDefinition, + + "shift.reminder": { + key: "shift.reminder", + channelDefaults: { email: true, in_app: true }, + title: (ctx) => `Shift Reminder: ${ctx.className}`, + body: (ctx) => + `Your shift for ${ctx.className} starts at ${ctx.shiftTime} on ${ctx.shiftDate}.`, + linkUrl: (ctx) => `/schedule?shiftId=${ctx.shiftId}`, + sourceType: "shift", + sourceId: (ctx) => ctx.shiftId, + renderEmail: (ctx) => + renderShiftReminder({ + className: ctx.className, + shiftDate: ctx.shiftDate, + shiftTime: ctx.shiftTime, + shiftId: ctx.shiftId, + }), + } satisfies NotificationTypeDefinition, + + "shift.no-checkin": { + key: "shift.no-checkin", + channelDefaults: { email: true, in_app: true }, + title: (ctx) => `Missed Check-in: ${ctx.className}`, + body: (ctx) => + `${ctx.volunteerCount} volunteer${ctx.volunteerCount !== 1 ? "s" : ""} did not check in for ${ctx.className} on ${ctx.shiftDate}: ${ctx.volunteerNames}`, + linkUrl: (ctx) => `/schedule?shiftId=${ctx.shiftId}`, + sourceType: "shift", + sourceId: (ctx) => ctx.shiftId, + renderEmail: (ctx) => + renderShiftNoCheckin({ + className: ctx.className, + shiftDate: ctx.shiftDate, + volunteerNames: ctx.volunteerNames, + volunteerCount: ctx.volunteerCount, + }), + } satisfies NotificationTypeDefinition, + + "coverage.filled": { + key: "coverage.filled", + channelDefaults: { email: true, in_app: true }, + title: (ctx) => `Coverage Filled: ${ctx.className}`, + body: (ctx) => + `${ctx.coveredByVolunteerName} has picked up the shift for ${ctx.className} on ${ctx.shiftDate} (originally requested by ${ctx.requestingVolunteerName}).`, + linkUrl: (ctx) => `/coverage?requestId=${ctx.coverageRequestId}`, + sourceType: "coverageRequest", + sourceId: (ctx) => ctx.coverageRequestId, + renderEmail: (ctx) => + renderCoverageFilled({ + className: ctx.className, + shiftDate: ctx.shiftDate, + coveredByVolunteerName: ctx.coveredByVolunteerName, + requestingVolunteerName: ctx.requestingVolunteerName, + }), + } satisfies NotificationTypeDefinition, + + "coverage.filled-personal": { + key: "coverage.filled-personal", + channelDefaults: { email: true, in_app: true }, + title: (ctx) => `Your Coverage Request Was Filled: ${ctx.className}`, + body: (ctx) => + `Good news! ${ctx.coveredByVolunteerName} has picked up your shift for ${ctx.className} on ${ctx.shiftDate}. You no longer need to attend this shift.`, + linkUrl: (ctx) => `/coverage?requestId=${ctx.coverageRequestId}`, + sourceType: "coverageRequest", + sourceId: (ctx) => ctx.coverageRequestId, + renderEmail: (ctx) => + renderCoverageFilledPersonal({ + className: ctx.className, + shiftDate: ctx.shiftDate, + coveredByVolunteerName: ctx.coveredByVolunteerName, + }), + } satisfies NotificationTypeDefinition, } as const; export type NotificationType = keyof typeof notificationTypes; diff --git a/src/server/services/entity/coverageService.ts b/src/server/services/entity/coverageService.ts index 436eb5cc..b0f8b354 100644 --- a/src/server/services/entity/coverageService.ts +++ b/src/server/services/entity/coverageService.ts @@ -26,7 +26,7 @@ import { type CoverageRequestDB, } from "@/server/db/schema"; import { volunteerToSchedule } from "@/server/db/schema/schedule"; -import { instructorUserView } from "@/server/db/schema/user"; +import { instructorUserView, user } from "@/server/db/schema/user"; import { getViewColumns } from "@/server/db/extensions/get-view-columns"; import { NeuronError, NeuronErrorCodes } from "@/server/errors/neuron-error"; import { toMap, uniqueDefined } from "@/utils/arrayUtils"; @@ -511,9 +511,13 @@ export class CoverageService implements ICoverageService { status: coverageRequest.status, shiftStartAt: shift.startAt, shiftId: shift.id, + courseId: shift.courseId, + courseName: course.name, + requestingVolunteerUserId: coverageRequest.requestingVolunteerUserId, }) .from(coverageRequest) .innerJoin(shift, eq(coverageRequest.shiftId, shift.id)) + .innerJoin(course, eq(course.id, shift.courseId)) .where(eq(coverageRequest.id, coverageRequestId)); if (!request) { @@ -568,6 +572,27 @@ export class CoverageService implements ICoverageService { NeuronErrorCodes.BAD_REQUEST, ); } + + // Notify admins, instructors, and requesting volunteer + const [requestingUser] = await this.db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, request.requestingVolunteerUserId)) + .limit(1); + + const currentUser = this.currentSessionService.getUser(); + + void this.notificationEventService.notifyCoverageFilled({ + coverageRequestId, + shiftId: request.shiftId, + classId: request.courseId, + className: request.courseName, + shiftDate: request.shiftStartAt.toLocaleDateString(), + coveredByVolunteerUserId, + coveredByVolunteerName: currentUser?.name ?? "A volunteer", + requestingVolunteerUserId: request.requestingVolunteerUserId, + requestingVolunteerName: requestingUser?.name ?? "A volunteer", + }); } async unassignCoverage( diff --git a/src/server/services/entity/shiftService.ts b/src/server/services/entity/shiftService.ts index ef3e6d41..22f9cae0 100644 --- a/src/server/services/entity/shiftService.ts +++ b/src/server/services/entity/shiftService.ts @@ -49,6 +49,7 @@ import { } from "@/server/db/schema/user"; import { NeuronError, NeuronErrorCodes } from "@/server/errors/neuron-error"; import type { INotificationEventService } from "@/server/services/notificationEventService"; +import type { IJobService } from "@/server/services/jobService"; import { term } from "@/server/db/schema/course"; import { and, eq, gte, inArray, lte, or, sql, type SQL } from "drizzle-orm"; @@ -106,19 +107,23 @@ export class ShiftService implements IShiftService { private readonly db: Drizzle; private readonly currentSessionService: ICurrentSessionService; private readonly notificationEventService: INotificationEventService; + private readonly jobService: IJobService; constructor({ db, currentSessionService, notificationEventService, + jobService, }: { db: Drizzle; currentSessionService: ICurrentSessionService; notificationEventService: INotificationEventService; + jobService: IJobService; }) { this.db = db; this.currentSessionService = currentSessionService; this.notificationEventService = notificationEventService; + this.jobService = jobService; } private async getViewer( @@ -688,18 +693,42 @@ export class ShiftService implements IShiftService { ); } + const startAt = new Date(input.startAt); + const endAt = new Date(input.endAt); + const [row] = await transaction .insert(shift) .values({ courseId: scheduleRow.courseId, scheduleId: input.scheduleId, date: input.date, - startAt: new Date(input.startAt), - endAt: new Date(input.endAt), + startAt, + endAt, }) .returning({ id: shift.id }); - return row!.id; + const shiftId = row!.id; + + // Schedule shift reminder (1 hour before start) + const reminderAt = new Date(startAt.getTime() - 60 * 60 * 1000); + if (reminderAt > new Date()) { + void this.jobService.run( + "jobs.check-shift-notifications", + { shiftId, checkType: "reminder" }, + { startAfter: reminderAt }, + ); + } + + // Schedule no-checkin check (at shift end) + if (endAt > new Date()) { + void this.jobService.run( + "jobs.check-shift-notifications", + { shiftId, checkType: "no-checkin" }, + { startAfter: endAt }, + ); + } + + return shiftId; } async deleteShift(input: ShiftIdInput): Promise { diff --git a/src/server/services/notificationEventService.ts b/src/server/services/notificationEventService.ts index 322400c1..3c488489 100644 --- a/src/server/services/notificationEventService.ts +++ b/src/server/services/notificationEventService.ts @@ -30,9 +30,40 @@ interface CoverageRequestedParams { reason: string; } +interface ShiftReminderParams { + shiftId: string; + className: string; + shiftDate: string; + shiftTime: string; + volunteerUserIds: string[]; +} + +interface ShiftNoCheckinParams { + shiftId: string; + className: string; + shiftDate: string; + volunteerNames: string; + volunteerCount: number; +} + +interface CoverageFilledParams { + coverageRequestId: string; + shiftId: string; + classId: string; + className: string; + shiftDate: string; + coveredByVolunteerUserId: string; + coveredByVolunteerName: string; + requestingVolunteerUserId: string; + requestingVolunteerName: string; +} + export interface INotificationEventService { notifyShiftCancelled(params: ShiftCancelledParams): Promise; notifyCoverageRequested(params: CoverageRequestedParams): Promise; + notifyShiftReminder(params: ShiftReminderParams): Promise; + notifyShiftNoCheckin(params: ShiftNoCheckinParams): Promise; + notifyCoverageFilled(params: CoverageFilledParams): Promise; } export class NotificationEventService implements INotificationEventService { @@ -120,6 +151,83 @@ export class NotificationEventService implements INotificationEventService { } } + async notifyShiftReminder(params: ShiftReminderParams): Promise { + if (params.volunteerUserIds.length === 0) return; + + await this.notificationService.notify({ + type: "shift.reminder", + audience: { kind: "users", userIds: params.volunteerUserIds }, + context: { + shiftId: params.shiftId, + className: params.className, + shiftDate: params.shiftDate, + shiftTime: params.shiftTime, + }, + idempotencyKey: `shift-reminder-${params.shiftId}`, + }); + } + + async notifyShiftNoCheckin(params: ShiftNoCheckinParams): Promise { + if (params.volunteerCount === 0) return; + + await this.notificationService.notify({ + type: "shift.no-checkin", + audience: { kind: "role", role: "admin" }, + context: { + shiftId: params.shiftId, + className: params.className, + shiftDate: params.shiftDate, + volunteerNames: params.volunteerNames, + volunteerCount: params.volunteerCount, + }, + idempotencyKey: `shift-no-checkin-${params.shiftId}`, + }); + } + + async notifyCoverageFilled(params: CoverageFilledParams): Promise { + // 1. Notify admins + class instructors + const instructorIds = await this.getClassInstructorUserIds(params.classId); + + await this.notificationService.notify({ + type: "coverage.filled", + audience: [ + { kind: "role", role: "admin" }, + ...(instructorIds.length > 0 + ? [{ kind: "users" as const, userIds: instructorIds }] + : []), + ], + context: { + coverageRequestId: params.coverageRequestId, + shiftId: params.shiftId, + className: params.className, + shiftDate: params.shiftDate, + coveredByVolunteerName: params.coveredByVolunteerName, + requestingVolunteerName: params.requestingVolunteerName, + }, + actorId: params.coveredByVolunteerUserId, + excludeUserIds: [ + params.coveredByVolunteerUserId, + params.requestingVolunteerUserId, + ], + idempotencyKey: `coverage-filled-${params.coverageRequestId}`, + }); + + // 2. Notify requesting volunteer with personal message + await this.notificationService.notify({ + type: "coverage.filled-personal", + audience: { kind: "user", userId: params.requestingVolunteerUserId }, + context: { + coverageRequestId: params.coverageRequestId, + shiftId: params.shiftId, + className: params.className, + shiftDate: params.shiftDate, + coveredByVolunteerName: params.coveredByVolunteerName, + }, + actorId: params.coveredByVolunteerUserId, + idempotencyKey: `coverage-filled-personal-${params.coverageRequestId}`, + }); + } + /** * Get instructor user IDs for all schedules of a class. */ diff --git a/src/test/mocks/mock-notification-services.ts b/src/test/mocks/mock-notification-services.ts index 9e0e5cc3..7b4eae14 100644 --- a/src/test/mocks/mock-notification-services.ts +++ b/src/test/mocks/mock-notification-services.ts @@ -71,6 +71,18 @@ export class MockNotificationEventService this.calls.push({ method: "notifyCoverageRequested", params }); } + async notifyShiftReminder(params: unknown): Promise { + this.calls.push({ method: "notifyShiftReminder", params }); + } + + async notifyShiftNoCheckin(params: unknown): Promise { + this.calls.push({ method: "notifyShiftNoCheckin", params }); + } + + async notifyCoverageFilled(params: unknown): Promise { + this.calls.push({ method: "notifyCoverageFilled", params }); + } + clear() { this.calls = []; } From b10ea3464d16fca151fcde03ef5e64e83e9053f2 Mon Sep 17 00:00:00 2001 From: theosiemensrhodes Date: Fri, 20 Mar 2026 23:03:18 -0700 Subject: [PATCH 6/9] feat: notifications section overhaul; add reliable emails and emails for volunteer status change --- .../pages/notifications-settings-content.tsx | 117 +++++++----------- .../extensions/app-invite/server-plugin.ts | 10 +- src/lib/auth/index.ts | 19 +-- src/server/api/routers/notification-router.ts | 8 +- .../emails/templates/volunteer-approved.tsx | 41 ++++++ .../templates/volunteer-deactivated.tsx | 35 ++++++ .../templates/volunteer-reactivated.tsx | 43 +++++++ src/server/jobs/definitions/send-email.job.ts | 25 ++++ src/server/jobs/registry.ts | 2 + src/server/notifications/registry.ts | 41 ++++++ src/server/notifications/types.ts | 5 + src/server/services/entity/userService.ts | 57 ++++++++- src/server/services/notificationService.ts | 28 +---- src/server/services/preferenceService.ts | 21 +++- .../integration/preference-service.test.ts | 22 ++-- 15 files changed, 348 insertions(+), 126 deletions(-) create mode 100644 src/server/emails/templates/volunteer-approved.tsx create mode 100644 src/server/emails/templates/volunteer-deactivated.tsx create mode 100644 src/server/emails/templates/volunteer-reactivated.tsx create mode 100644 src/server/jobs/definitions/send-email.job.ts diff --git a/src/components/settings/pages/notifications-settings-content.tsx b/src/components/settings/pages/notifications-settings-content.tsx index ecd58cb2..513fbde2 100644 --- a/src/components/settings/pages/notifications-settings-content.tsx +++ b/src/components/settings/pages/notifications-settings-content.tsx @@ -9,23 +9,9 @@ import { CardTitle, } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/ui/label"; import { Button } from "@/components/primitives/button"; import { Spinner } from "@/components/ui/spinner"; import { Bell } from "lucide-react"; -import type { NotificationChannel } from "@/server/notifications/types"; - -const channelLabels: Record = { - email: "Email", - in_app: "In-App", - push: "Push", -}; - -const typeLabels: Record = { - "shift.cancelled": "Shift Cancelled", - "coverage.requested": "Coverage Requested", - "coverage.available": "Coverage Opportunity", -}; export function NotificationsSettingsContent() { const utils = clientApi.useUtils(); @@ -53,81 +39,66 @@ export function NotificationsSettingsContent() { ); } - // Group preferences by type - const grouped = new Map< - string, - { type: string; channel: NotificationChannel; enabled: boolean; isOverride: boolean }[] - >(); - for (const pref of preferences ?? []) { - const existing = grouped.get(pref.type) ?? []; - existing.push(pref); - grouped.set(pref.type, existing); - } + // Group by type, only show email channel preferences + const emailPrefs = (preferences ?? []).filter( + (pref) => pref.channel === "email", + ); return (
- Notification Preferences + Email notifications
- Choose which notifications you receive and how they are delivered. + Manage the emails you get about activity in Neuron. You'll always + receive in-app notifications.
-
- {Array.from(grouped.entries()).map(([type, channels]) => ( -
-

- {typeLabels[type] ?? type} -

-
- {channels.map((pref) => ( -
-
- - {pref.isOverride && ( - - )} -
- - setPreference.mutate({ +
+ {emailPrefs.map((pref) => ( +
+
+
+

{pref.label}

+ {pref.isOverride && ( +
- ))} + > + Reset + + )} +
+

+ {pref.description} +

+ + setPreference.mutate({ + type: pref.type, + channel: pref.channel, + enabled: checked, + }) + } + disabled={ + setPreference.isPending || clearPreference.isPending + } + />
))}
diff --git a/src/lib/auth/extensions/app-invite/server-plugin.ts b/src/lib/auth/extensions/app-invite/server-plugin.ts index 9367ea26..40181cb2 100644 --- a/src/lib/auth/extensions/app-invite/server-plugin.ts +++ b/src/lib/auth/extensions/app-invite/server-plugin.ts @@ -19,7 +19,7 @@ export const appInvitePlugin = appInvite({ } const scope = createRequestScope(); - const { emailService } = scope.cradle; + const { jobService } = scope.cradle; const origin = request ? new URL(request.url).origin : env.NEURON_BASE_URL; const inviteUrl = new URL( @@ -32,12 +32,12 @@ export const appInvitePlugin = appInvite({ inviterName: invitation.inviter.name, inviterEmail: invitation.inviter.email, }); - await emailService.send( - invitation.email, - "You've been invited to Neuron", + await jobService.run("jobs.send-email", { + to: invitation.email, + subject: "You've been invited to Neuron", text, html, - ); + }); }, canCreateInvitation: (ctx): boolean => { const inviter = ctx.context.session?.user as User; diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 4d5882da..8959127a 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -76,29 +76,34 @@ export const auth = betterAuth({ requireEmailVerification: true, sendResetPassword: async ({ user, url }) => { const scope = createRequestScope(); - const { emailService } = scope.cradle; + const { jobService } = scope.cradle; const { html, text } = await renderForgotPassword({ url, userName: user.name, }); - await emailService.send(user.email, "Reset your password", text, html); + await jobService.run("jobs.send-email", { + to: user.email, + subject: "Reset your password", + text, + html, + }); }, }, emailVerification: { sendOnSignUp: true, sendVerificationEmail: async ({ user, url }) => { const scope = createRequestScope(); - const { emailService } = scope.cradle; + const { jobService } = scope.cradle; const { html, text } = await renderVerifyEmail({ url, userName: user.name, }); - await emailService.send( - user.email, - "Verify your email address", + await jobService.run("jobs.send-email", { + to: user.email, + subject: "Verify your email address", text, html, - ); + }); }, }, plugins: [nextCookies(), appInvitePlugin], diff --git a/src/server/api/routers/notification-router.ts b/src/server/api/routers/notification-router.ts index 60c47d69..bcd6fcc7 100644 --- a/src/server/api/routers/notification-router.ts +++ b/src/server/api/routers/notification-router.ts @@ -5,6 +5,7 @@ import { MarkAsReadInput, SetNotificationPreferenceInput, } from "@/models/api/notification"; +import type { Role } from "@/models/interfaces"; import { authorizedProcedure } from "@/server/api/procedures"; import { createTRPCRouter } from "@/server/api/trpc"; @@ -67,8 +68,11 @@ export const notificationRouter = createTRPCRouter({ }), preferences: authorizedProcedure().query(async ({ ctx }) => { - const userId = ctx.currentSessionService.requireUser().id; - return ctx.preferenceService.getEffectivePreferences(userId); + const user = ctx.currentSessionService.requireUser(); + return ctx.preferenceService.getEffectivePreferences( + user.id, + user.role as Role, + ); }), setPreference: authorizedProcedure() diff --git a/src/server/emails/templates/volunteer-approved.tsx b/src/server/emails/templates/volunteer-approved.tsx new file mode 100644 index 00000000..f8e73117 --- /dev/null +++ b/src/server/emails/templates/volunteer-approved.tsx @@ -0,0 +1,41 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface VolunteerApprovedEmailProps { + volunteerName: string; +} + +export function VolunteerApprovedEmail({ + volunteerName, +}: VolunteerApprovedEmailProps) { + return ( + + + Welcome to BC BWP + + + Hi {volunteerName}, your + volunteer account has been approved! You can now log in and start signing + up for shifts. + +
+ +
+
+ ); +} + +export default VolunteerApprovedEmail; + +export function renderVolunteerApproved(props: VolunteerApprovedEmailProps) { + return renderEmail(); +} diff --git a/src/server/emails/templates/volunteer-deactivated.tsx b/src/server/emails/templates/volunteer-deactivated.tsx new file mode 100644 index 00000000..761fd89b --- /dev/null +++ b/src/server/emails/templates/volunteer-deactivated.tsx @@ -0,0 +1,35 @@ +import { Heading, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface VolunteerDeactivatedEmailProps { + volunteerName: string; +} + +export function VolunteerDeactivatedEmail({ + volunteerName, +}: VolunteerDeactivatedEmailProps) { + return ( + + + Your Account Has Been Deactivated + + + Hi {volunteerName}, your + volunteer account has been deactivated. If you have any questions, please + reach out to an administrator. + + + ); +} + +export default VolunteerDeactivatedEmail; + +export function renderVolunteerDeactivated( + props: VolunteerDeactivatedEmailProps, +) { + return renderEmail(); +} diff --git a/src/server/emails/templates/volunteer-reactivated.tsx b/src/server/emails/templates/volunteer-reactivated.tsx new file mode 100644 index 00000000..8bbea8d1 --- /dev/null +++ b/src/server/emails/templates/volunteer-reactivated.tsx @@ -0,0 +1,43 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import { EmailLayout } from "./components/email-layout"; +import { renderEmail } from "../render"; + +interface VolunteerReactivatedEmailProps { + volunteerName: string; +} + +export function VolunteerReactivatedEmail({ + volunteerName, +}: VolunteerReactivatedEmailProps) { + return ( + + + Your Account Has Been Reactivated + + + Hi {volunteerName}, your + volunteer account has been reactivated. You can log in and start signing + up for shifts again. + +
+ +
+
+ ); +} + +export default VolunteerReactivatedEmail; + +export function renderVolunteerReactivated( + props: VolunteerReactivatedEmailProps, +) { + return renderEmail(); +} diff --git a/src/server/jobs/definitions/send-email.job.ts b/src/server/jobs/definitions/send-email.job.ts new file mode 100644 index 00000000..111ed6c4 --- /dev/null +++ b/src/server/jobs/definitions/send-email.job.ts @@ -0,0 +1,25 @@ +import type { RegisteredJob } from "../types"; + +export interface SendEmailPayload { + to: string; + subject: string; + text: string; + html?: string; +} + +export const sendEmailJob: RegisteredJob = { + name: "jobs.send-email", + retryOpts: { + retryLimit: 3, + retryDelay: 30, + retryBackoff: true, + }, + handler: async (payload, { cradle }) => { + const { emailService } = cradle; + if (payload.html) { + await emailService.send(payload.to, payload.subject, payload.text, payload.html); + } else { + await emailService.send(payload.to, payload.subject, payload.text); + } + }, +}; diff --git a/src/server/jobs/registry.ts b/src/server/jobs/registry.ts index 38a749b5..e2e27e4b 100644 --- a/src/server/jobs/registry.ts +++ b/src/server/jobs/registry.ts @@ -1,12 +1,14 @@ import { cleanupOrphanedImagesJob } from "./definitions/cleanup-orphaned-images.job"; import { processNotificationJob } from "./definitions/process-notification.job"; import { checkShiftNotificationsJob } from "./definitions/check-shift-notifications.job"; +import { sendEmailJob } from "./definitions/send-email.job"; import type { RegisteredJob } from "./types"; const allJobs = [ cleanupOrphanedImagesJob, processNotificationJob, checkShiftNotificationsJob, + sendEmailJob, ] as const satisfies readonly RegisteredJob[]; type AnyKnownJob = (typeof allJobs)[number]; diff --git a/src/server/notifications/registry.ts b/src/server/notifications/registry.ts index 2200da3b..294d14f2 100644 --- a/src/server/notifications/registry.ts +++ b/src/server/notifications/registry.ts @@ -66,6 +66,15 @@ export interface CoverageFilledPersonalContext { export const notificationTypes = { "shift.cancelled": { key: "shift.cancelled", + label: "Shift cancellations", + description: { + admin: "Get notified when any shift is cancelled across the program", + instructor: + "Get notified when a shift you're instructing is cancelled", + volunteer: + "Get notified when a shift you're assigned to is cancelled", + }, + applicableRoles: ["admin", "volunteer", "instructor"], channelDefaults: { email: true, in_app: true }, title: (ctx) => `Shift Cancelled: ${ctx.className}`, body: (ctx) => @@ -84,6 +93,14 @@ export const notificationTypes = { "coverage.requested": { key: "coverage.requested", + label: "Coverage requests", + description: { + admin: + "Get notified when any volunteer requests coverage for a shift", + instructor: + "Get notified when a volunteer requests coverage for one of your classes", + }, + applicableRoles: ["admin", "instructor"], channelDefaults: { email: true, in_app: true }, title: (ctx) => `Coverage Needed: ${ctx.className}`, body: (ctx) => @@ -102,6 +119,10 @@ export const notificationTypes = { "coverage.available": { key: "coverage.available", + label: "Coverage opportunities", + description: + "Get notified when a shift you're eligible for needs coverage", + applicableRoles: ["volunteer"], channelDefaults: { email: true, in_app: true }, title: (ctx) => `Coverage Opportunity: ${ctx.className}`, body: (ctx) => @@ -119,6 +140,10 @@ export const notificationTypes = { "shift.reminder": { key: "shift.reminder", + label: "Shift reminders", + description: + "Get a reminder 1 hour before your upcoming shifts", + applicableRoles: ["volunteer"], channelDefaults: { email: true, in_app: true }, title: (ctx) => `Shift Reminder: ${ctx.className}`, body: (ctx) => @@ -137,6 +162,10 @@ export const notificationTypes = { "shift.no-checkin": { key: "shift.no-checkin", + label: "Missed check-ins", + description: + "Get notified when volunteers don't check in for their scheduled shift", + applicableRoles: ["admin"], channelDefaults: { email: true, in_app: true }, title: (ctx) => `Missed Check-in: ${ctx.className}`, body: (ctx) => @@ -155,6 +184,14 @@ export const notificationTypes = { "coverage.filled": { key: "coverage.filled", + label: "Coverage updates", + description: { + admin: + "Get notified when a volunteer picks up an open coverage request", + instructor: + "Get notified when coverage is filled for one of your classes", + }, + applicableRoles: ["admin", "instructor"], channelDefaults: { email: true, in_app: true }, title: (ctx) => `Coverage Filled: ${ctx.className}`, body: (ctx) => @@ -173,6 +210,10 @@ export const notificationTypes = { "coverage.filled-personal": { key: "coverage.filled-personal", + label: "Your coverage requests", + description: + "Get notified when another volunteer picks up a shift you requested coverage for", + applicableRoles: ["volunteer"], channelDefaults: { email: true, in_app: true }, title: (ctx) => `Your Coverage Request Was Filled: ${ctx.className}`, body: (ctx) => diff --git a/src/server/notifications/types.ts b/src/server/notifications/types.ts index 17d3ea34..a0ce4fbe 100644 --- a/src/server/notifications/types.ts +++ b/src/server/notifications/types.ts @@ -16,6 +16,9 @@ export interface NotificationTypeDefinition< TContext extends {} = Record, > { key: string; + label: string; + description: string | Partial>; + applicableRoles: Role[]; channelDefaults: ChannelDefaults; title: (ctx: TContext) => string; body: (ctx: TContext) => string; @@ -37,6 +40,8 @@ export interface NotifyParams { export interface EffectivePreference { type: string; + label: string; + description: string; channel: NotificationChannel; enabled: boolean; isOverride: boolean; diff --git a/src/server/services/entity/userService.ts b/src/server/services/entity/userService.ts index 5f7626b1..28df1e71 100644 --- a/src/server/services/entity/userService.ts +++ b/src/server/services/entity/userService.ts @@ -8,6 +8,10 @@ import type { ListResponse } from "@/models/list-response"; import { buildUser, type User } from "@/models/user"; import { type Drizzle } from "@/server/db"; import { NeuronError, NeuronErrorCodes } from "@/server/errors/neuron-error"; +import type { IJobService } from "@/server/services/jobService"; +import { renderVolunteerApproved } from "@/server/emails/templates/volunteer-approved"; +import { renderVolunteerReactivated } from "@/server/emails/templates/volunteer-reactivated"; +import { renderVolunteerDeactivated } from "@/server/emails/templates/volunteer-deactivated"; import { buildSimilarityExpression, getPagination } from "@/utils/searchUtils"; import { and, desc, eq, getTableColumns, gt, inArray, sql } from "drizzle-orm"; import { user } from "../../db/schema/user"; @@ -38,9 +42,11 @@ export interface IUserService { export class UserService implements IUserService { private readonly db: Drizzle; + private readonly jobService: IJobService; - constructor({ db }: { db: Drizzle }) { + constructor({ db, jobService }: { db: Drizzle; jobService: IJobService }) { this.db = db; + this.jobService = jobService; } async getUsersForRequest( @@ -146,10 +152,40 @@ export class UserService implements IUserService { // any -> active async verifyVolunteer(id: string): Promise { + const [existing] = await this.db + .select({ status: user.status, name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, id)); + + if (!existing) { + throw new NeuronError( + `Could not find user with id ${id}`, + NeuronErrorCodes.NOT_FOUND, + ); + } + await this.db .update(user) .set({ status: UserStatus.active }) .where(eq(user.id, id)); + + const isFirstApproval = existing.status === UserStatus.unverified; + const render = isFirstApproval + ? renderVolunteerApproved + : renderVolunteerReactivated; + const subject = isFirstApproval + ? "Welcome to BC BWP" + : "Your Account Has Been Reactivated"; + + void render({ volunteerName: existing.name }).then(({ html, text }) => + this.jobService.run("jobs.send-email", { + to: existing.email, + subject, + text, + html, + }), + ); + return id; } @@ -177,13 +213,12 @@ export class UserService implements IUserService { // active -> inactive async deactivateUser(id: string): Promise { - const currentStatus = await this.db - .select() + const [existing] = await this.db + .select({ status: user.status, name: user.name, email: user.email }) .from(user) - .where(eq(user.id, id)) - .then(([user]) => user?.status); + .where(eq(user.id, id)); - if (currentStatus !== UserStatus.active) { + if (existing?.status !== UserStatus.active) { throw new NeuronError( `Volunteer with id ${id} is not active`, NeuronErrorCodes.BAD_REQUEST, @@ -195,6 +230,16 @@ export class UserService implements IUserService { .set({ status: UserStatus.inactive }) .where(eq(user.id, id)); + void renderVolunteerDeactivated({ volunteerName: existing.name }).then( + ({ html, text }) => + this.jobService.run("jobs.send-email", { + to: existing.email, + subject: "Your Account Has Been Deactivated", + text, + html, + }), + ); + return id; } diff --git a/src/server/services/notificationService.ts b/src/server/services/notificationService.ts index 02baa9e0..1b2ccd42 100644 --- a/src/server/services/notificationService.ts +++ b/src/server/services/notificationService.ts @@ -17,7 +17,6 @@ import type { NotifyParams, } from "@/server/notifications/types"; import type { IJobService } from "@/server/services/jobService"; -import type { IEmailService } from "@/server/services/emailService"; import type { IPreferenceService } from "@/server/services/preferenceService"; import { NeuronError, NeuronErrorCodes } from "@/server/errors/neuron-error"; import { and, count, desc, eq, inArray, lt, sql } from "drizzle-orm"; @@ -62,23 +61,19 @@ export interface INotificationService { export class NotificationService implements INotificationService { private readonly db: Drizzle; private readonly jobService: IJobService; - private readonly emailService: IEmailService; private readonly preferenceService: IPreferenceService; constructor({ db, jobService, - emailService, preferenceService, }: { db: Drizzle; jobService: IJobService; - emailService: IEmailService; preferenceService: IPreferenceService; }) { this.db = db; this.jobService = jobService; - this.emailService = emailService; this.preferenceService = preferenceService; } @@ -388,23 +383,12 @@ export class NotificationService implements INotificationService { } for (const recipient of emailRecipients) { - try { - if (html) { - await this.emailService.send( - recipient.email, - title, - emailText ?? body, - html, - ); - } else { - await this.emailService.send(recipient.email, title, body); - } - } catch (error) { - console.error( - `[notification] Failed to send email to ${recipient.email} for ${type}:`, - error, - ); - } + void this.jobService.run("jobs.send-email", { + to: recipient.email, + subject: title, + text: emailText ?? body, + ...(html ? { html } : {}), + }); } } } diff --git a/src/server/services/preferenceService.ts b/src/server/services/preferenceService.ts index 06b8122e..7167235a 100644 --- a/src/server/services/preferenceService.ts +++ b/src/server/services/preferenceService.ts @@ -8,6 +8,7 @@ import { notificationTypes, type NotificationType, } from "@/server/notifications/registry"; +import type { Role } from "@/models/interfaces"; import type { EffectivePreference, NotificationChannel, @@ -15,7 +16,10 @@ import type { import { and, eq, inArray } from "drizzle-orm"; export interface IPreferenceService { - getEffectivePreferences(userId: string): Promise; + getEffectivePreferences( + userId: string, + role: Role, + ): Promise; setPreference(params: { userId: string; @@ -45,6 +49,7 @@ export class PreferenceService implements IPreferenceService { async getEffectivePreferences( userId: string, + role: Role, ): Promise { const overrides = await this.db .select() @@ -60,15 +65,29 @@ export class PreferenceService implements IPreferenceService { for (const typeKey of allNotificationTypes) { const typeDef = notificationTypes[typeKey]; + + // Only include notification types applicable to this role + if (!(typeDef.applicableRoles as readonly string[]).includes(role)) + continue; + const channels = Object.entries(typeDef.channelDefaults) as [ NotificationChannel, boolean, ][]; + const description = + typeof typeDef.description === "string" + ? typeDef.description + : ((typeDef.description as Record)[role] ?? + Object.values(typeDef.description)[0] ?? + ""); + for (const [channel, defaultEnabled] of channels) { const override = overrideMap.get(`${typeKey}:${channel}`); result.push({ type: typeKey, + label: typeDef.label, + description, channel, enabled: override ? override.enabled : defaultEnabled, isOverride: !!override, diff --git a/src/test/integration/preference-service.test.ts b/src/test/integration/preference-service.test.ts index 17669e25..3c7eafc7 100644 --- a/src/test/integration/preference-service.test.ts +++ b/src/test/integration/preference-service.test.ts @@ -59,7 +59,7 @@ describe("PreferenceService", () => { describe("getEffectivePreferences", () => { it("should return registry defaults when no overrides exist", async () => { - const prefs = await preferenceService.getEffectivePreferences(userId); + const prefs = await preferenceService.getEffectivePreferences(userId, "volunteer"); expect(prefs.length).toBeGreaterThan(0); @@ -87,7 +87,7 @@ describe("PreferenceService", () => { enabled: false, }); - const prefs = await preferenceService.getEffectivePreferences(userId); + const prefs = await preferenceService.getEffectivePreferences(userId, "volunteer"); const shiftEmail = prefs.find( (p) => p.type === "shift.cancelled" && p.channel === "email", @@ -104,11 +104,13 @@ describe("PreferenceService", () => { }); it("should include preferences for all registered notification types", async () => { - const prefs = await preferenceService.getEffectivePreferences(userId); + const prefs = await preferenceService.getEffectivePreferences(userId, "volunteer"); const types = new Set(prefs.map((p) => p.type)); expect(types.has("shift.cancelled")).toBe(true); - expect(types.has("coverage.requested")).toBe(true); + expect(types.has("coverage.available")).toBe(true); + // coverage.requested is admin/instructor only, not shown for volunteers + expect(types.has("coverage.requested")).toBe(false); }); }); @@ -121,7 +123,7 @@ describe("PreferenceService", () => { enabled: false, }); - const prefs = await preferenceService.getEffectivePreferences(userId); + const prefs = await preferenceService.getEffectivePreferences(userId, "volunteer"); const pref = prefs.find( (p) => p.type === "shift.cancelled" && p.channel === "email", ); @@ -144,7 +146,7 @@ describe("PreferenceService", () => { enabled: true, }); - const prefs = await preferenceService.getEffectivePreferences(userId); + const prefs = await preferenceService.getEffectivePreferences(userId, "volunteer"); const pref = prefs.find( (p) => p.type === "shift.cancelled" && p.channel === "email", ); @@ -166,7 +168,7 @@ describe("PreferenceService", () => { enabled: false, }); - const prefs = await preferenceService.getEffectivePreferences(userId); + const prefs = await preferenceService.getEffectivePreferences(userId, "volunteer"); const emailPref = prefs.find( (p) => p.type === "shift.cancelled" && p.channel === "email", ); @@ -190,7 +192,7 @@ describe("PreferenceService", () => { }); // Verify it's overridden - let prefs = await preferenceService.getEffectivePreferences(userId); + let prefs = await preferenceService.getEffectivePreferences(userId, "volunteer"); let pref = prefs.find( (p) => p.type === "shift.cancelled" && p.channel === "email", ); @@ -205,7 +207,7 @@ describe("PreferenceService", () => { }); // Should revert to registry default (true) - prefs = await preferenceService.getEffectivePreferences(userId); + prefs = await preferenceService.getEffectivePreferences(userId, "volunteer"); pref = prefs.find( (p) => p.type === "shift.cancelled" && p.channel === "email", ); @@ -221,7 +223,7 @@ describe("PreferenceService", () => { channel: "email", }); - const prefs = await preferenceService.getEffectivePreferences(userId); + const prefs = await preferenceService.getEffectivePreferences(userId, "volunteer"); const pref = prefs.find( (p) => p.type === "shift.cancelled" && p.channel === "email", ); From b42713f7f0ef532659db1bb900f631320eba1def Mon Sep 17 00:00:00 2001 From: theosiemensrhodes Date: Fri, 20 Mar 2026 23:10:12 -0700 Subject: [PATCH 7/9] fix: consolidate migrations --- ...e.sql => 0010_add_notification_system.sql} | 3 + .../db/migrations/0011_curly_psynapse.sql | 3 - .../db/migrations/meta/0010_snapshot.json | 36 +- .../db/migrations/meta/0011_snapshot.json | 2726 ----------------- src/server/db/migrations/meta/_journal.json | 11 +- 5 files changed, 40 insertions(+), 2739 deletions(-) rename src/server/db/migrations/{0010_lethal_stature.sql => 0010_add_notification_system.sql} (91%) delete mode 100644 src/server/db/migrations/0011_curly_psynapse.sql delete mode 100644 src/server/db/migrations/meta/0011_snapshot.json diff --git a/src/server/db/migrations/0010_lethal_stature.sql b/src/server/db/migrations/0010_add_notification_system.sql similarity index 91% rename from src/server/db/migrations/0010_lethal_stature.sql rename to src/server/db/migrations/0010_add_notification_system.sql index e0629199..a55c2e5f 100644 --- a/src/server/db/migrations/0010_lethal_stature.sql +++ b/src/server/db/migrations/0010_add_notification_system.sql @@ -11,6 +11,8 @@ CREATE TABLE "notification" ( "actor_id" uuid, "read" boolean DEFAULT false NOT NULL, "read_at" timestamp with time zone, + "archived" boolean DEFAULT false NOT NULL, + "archived_at" timestamp with time zone, "email_sent" boolean DEFAULT false NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "idempotency_key" text, @@ -33,5 +35,6 @@ CREATE INDEX "idx_notification_user_created" ON "notification" USING btree ("use CREATE INDEX "idx_notification_user_read" ON "notification" USING btree ("user_id","read");--> statement-breakpoint CREATE INDEX "idx_notification_type" ON "notification" USING btree ("type");--> statement-breakpoint CREATE INDEX "idx_notification_source" ON "notification" USING btree ("source_type","source_id");--> statement-breakpoint +CREATE INDEX "idx_notification_user_archived" ON "notification" USING btree ("user_id","archived");--> statement-breakpoint CREATE UNIQUE INDEX "uq_notification_pref_user_type_channel" ON "notification_preference" USING btree ("user_id","type","channel");--> statement-breakpoint CREATE INDEX "idx_notification_pref_user" ON "notification_preference" USING btree ("user_id"); \ No newline at end of file diff --git a/src/server/db/migrations/0011_curly_psynapse.sql b/src/server/db/migrations/0011_curly_psynapse.sql deleted file mode 100644 index d71be8f1..00000000 --- a/src/server/db/migrations/0011_curly_psynapse.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "notification" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;--> statement-breakpoint -ALTER TABLE "notification" ADD COLUMN "archived_at" timestamp with time zone;--> statement-breakpoint -CREATE INDEX "idx_notification_user_archived" ON "notification" USING btree ("user_id","archived"); \ No newline at end of file diff --git a/src/server/db/migrations/meta/0010_snapshot.json b/src/server/db/migrations/meta/0010_snapshot.json index 0706cfdc..55656e9a 100644 --- a/src/server/db/migrations/meta/0010_snapshot.json +++ b/src/server/db/migrations/meta/0010_snapshot.json @@ -1,5 +1,5 @@ { - "id": "f790cb06-6dfc-4305-b743-957276334967", + "id": "97fec7ce-4c2d-46f2-9bd1-76ff70f62293", "prevId": "5de7367c-71f7-4d40-9ee8-87a526c11ab9", "version": "7", "dialect": "postgresql", @@ -2156,6 +2156,19 @@ "primaryKey": false, "notNull": false }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, "email_sent": { "name": "email_sent", "type": "boolean", @@ -2255,6 +2268,27 @@ "concurrently": false, "method": "btree", "with": {} + }, + "idx_notification_user_archived": { + "name": "idx_notification_user_archived", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { diff --git a/src/server/db/migrations/meta/0011_snapshot.json b/src/server/db/migrations/meta/0011_snapshot.json deleted file mode 100644 index a7ce32e2..00000000 --- a/src/server/db/migrations/meta/0011_snapshot.json +++ /dev/null @@ -1,2726 +0,0 @@ -{ - "id": "761e8c57-bd95-4d01-b17c-978b22e0b996", - "prevId": "f790cb06-6dfc-4305-b743-957276334967", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "account_user_id_index": { - "name": "account_user_id_index", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "account_provider_id_account_id_index": { - "name": "account_provider_id_account_id_index", - "columns": [ - { - "expression": "provider_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "account_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.appInvitation": { - "name": "appInvitation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "inviter_id": { - "name": "inviter_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "domain_whitelist": { - "name": "domain_whitelist", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "appInvitation_inviter_id_index": { - "name": "appInvitation_inviter_id_index", - "columns": [ - { - "expression": "inviter_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "appInvitation_email_index": { - "name": "appInvitation_email_index", - "columns": [ - { - "expression": "email", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "appInvitation_status_index": { - "name": "appInvitation_status_index", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "appInvitation_inviter_id_user_id_fk": { - "name": "appInvitation_inviter_id_user_id_fk", - "tableFrom": "appInvitation", - "tableTo": "user", - "columnsFrom": [ - "inviter_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "session_user_id_index": { - "name": "session_user_id_index", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "session_token_index": { - "name": "session_token_index", - "columns": [ - { - "expression": "token", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": [ - "token" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "verification_identifier_index": { - "name": "verification_identifier_index", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "verification_identifier_value_index": { - "name": "verification_identifier_value_index", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "value", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.blackout": { - "name": "blackout", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "term_id": { - "name": "term_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "schedule_id": { - "name": "schedule_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "starts_on": { - "name": "starts_on", - "type": "date", - "primaryKey": false, - "notNull": true - }, - "ends_on": { - "name": "ends_on", - "type": "date", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "blackout_term_id_starts_on_ends_on_index": { - "name": "blackout_term_id_starts_on_ends_on_index", - "columns": [ - { - "expression": "term_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "starts_on", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "ends_on", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "blackout_schedule_id_starts_on_ends_on_index": { - "name": "blackout_schedule_id_starts_on_ends_on_index", - "columns": [ - { - "expression": "schedule_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "starts_on", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "ends_on", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "blackout_term_id_term_id_fk": { - "name": "blackout_term_id_term_id_fk", - "tableFrom": "blackout", - "tableTo": "term", - "columnsFrom": [ - "term_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "blackout_schedule_id_schedule_id_fk": { - "name": "blackout_schedule_id_schedule_id_fk", - "tableFrom": "blackout", - "tableTo": "schedule", - "columnsFrom": [ - "schedule_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "chk_blackout_owner_xor": { - "name": "chk_blackout_owner_xor", - "value": "( \"blackout\".\"term_id\" IS NOT NULL ) <> ( \"blackout\".\"schedule_id\" IS NOT NULL )" - }, - "chk_blackout_range_valid": { - "name": "chk_blackout_range_valid", - "value": "\"blackout\".\"ends_on\" >= \"blackout\".\"starts_on\"" - } - }, - "isRLSEnabled": false - }, - "public.course": { - "name": "course", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "term_id": { - "name": "term_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "published": { - "name": "published", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "location": { - "name": "location", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "location_type": { - "name": "location_type", - "type": "location_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "category": { - "name": "category", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "subcategory": { - "name": "subcategory", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "lower_level": { - "name": "lower_level", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "upper_level": { - "name": "upper_level", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "course_term_id_index": { - "name": "course_term_id_index", - "columns": [ - { - "expression": "term_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "course_name_index": { - "name": "course_name_index", - "columns": [ - { - "expression": "name", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "course_term_id_term_id_fk": { - "name": "course_term_id_term_id_fk", - "tableFrom": "course", - "tableTo": "term", - "columnsFrom": [ - "term_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "chk_lower_level_bounds": { - "name": "chk_lower_level_bounds", - "value": "\"course\".\"lower_level\" IS NULL OR (\"course\".\"lower_level\" >= 1 AND \"course\".\"lower_level\" <= 4)" - }, - "chk_upper_level_bounds": { - "name": "chk_upper_level_bounds", - "value": "\"course\".\"upper_level\" IS NULL OR (\"course\".\"upper_level\" >= 1 AND \"course\".\"upper_level\" <= 4)" - }, - "chk_levels_both_or_neither": { - "name": "chk_levels_both_or_neither", - "value": "(\"course\".\"lower_level\" IS NULL) = (\"course\".\"upper_level\" IS NULL)" - }, - "chk_lower_lte_upper": { - "name": "chk_lower_lte_upper", - "value": "\"course\".\"lower_level\" IS NULL OR \"course\".\"lower_level\" <= \"course\".\"upper_level\"" - } - }, - "isRLSEnabled": false - }, - "public.term": { - "name": "term", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "term_name": { - "name": "term_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "start_date": { - "name": "start_date", - "type": "date", - "primaryKey": false, - "notNull": true - }, - "end_date": { - "name": "end_date", - "type": "date", - "primaryKey": false, - "notNull": true - }, - "published": { - "name": "published", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - } - }, - "indexes": { - "term_term_name_index": { - "name": "term_term_name_index", - "columns": [ - { - "expression": "term_name", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "chk_term_date_ok": { - "name": "chk_term_date_ok", - "value": "\"term\".\"end_date\" >= \"term\".\"start_date\"" - } - }, - "isRLSEnabled": false - }, - "public.log": { - "name": "log", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "page": { - "name": "page", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "signoff": { - "name": "signoff", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "volunteer_user_id": { - "name": "volunteer_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "course_id": { - "name": "course_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_logs_volunteer": { - "name": "idx_logs_volunteer", - "columns": [ - { - "expression": "volunteer_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_logs_course": { - "name": "idx_logs_course", - "columns": [ - { - "expression": "course_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_logs_created_at": { - "name": "idx_logs_created_at", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_logs_page": { - "name": "idx_logs_page", - "columns": [ - { - "expression": "page", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "log_volunteer_user_id_volunteer_user_id_fk": { - "name": "log_volunteer_user_id_volunteer_user_id_fk", - "tableFrom": "log", - "tableTo": "volunteer", - "columnsFrom": [ - "volunteer_user_id" - ], - "columnsTo": [ - "user_id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "log_course_id_course_id_fk": { - "name": "log_course_id_course_id_fk", - "tableFrom": "log", - "tableTo": "course", - "columnsFrom": [ - "course_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.instructor_to_schedule": { - "name": "instructor_to_schedule", - "schema": "", - "columns": { - "instructor_user_id": { - "name": "instructor_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "schedule_id": { - "name": "schedule_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "instructor_to_schedule_instructor_user_id_index": { - "name": "instructor_to_schedule_instructor_user_id_index", - "columns": [ - { - "expression": "instructor_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "instructor_to_schedule_schedule_id_index": { - "name": "instructor_to_schedule_schedule_id_index", - "columns": [ - { - "expression": "schedule_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "instructor_to_schedule_instructor_user_id_user_id_fk": { - "name": "instructor_to_schedule_instructor_user_id_user_id_fk", - "tableFrom": "instructor_to_schedule", - "tableTo": "user", - "columnsFrom": [ - "instructor_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "instructor_to_schedule_schedule_id_schedule_id_fk": { - "name": "instructor_to_schedule_schedule_id_schedule_id_fk", - "tableFrom": "instructor_to_schedule", - "tableTo": "schedule", - "columnsFrom": [ - "schedule_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "pk_instructor_schedule": { - "name": "pk_instructor_schedule", - "columns": [ - "instructor_user_id", - "schedule_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.schedule": { - "name": "schedule", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "course_id": { - "name": "course_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "duration_minutes": { - "name": "duration_minutes", - "type": "smallint", - "primaryKey": false, - "notNull": true - }, - "effective_start": { - "name": "effective_start", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "effective_end": { - "name": "effective_end", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "rrule": { - "name": "rrule", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "preferred_volunteer_count": { - "name": "preferred_volunteer_count", - "type": "smallint", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "schedule_course_id_index": { - "name": "schedule_course_id_index", - "columns": [ - { - "expression": "course_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "schedule_course_id_course_id_fk": { - "name": "schedule_course_id_course_id_fk", - "tableFrom": "schedule", - "tableTo": "course", - "columnsFrom": [ - "course_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "chk_schedule_duration_positive": { - "name": "chk_schedule_duration_positive", - "value": "\"schedule\".\"duration_minutes\" > 0" - }, - "chk_schedule_effective_range_valid": { - "name": "chk_schedule_effective_range_valid", - "value": "\"schedule\".\"effective_end\" IS NULL\n OR \"schedule\".\"effective_start\" IS NULL\n OR \"schedule\".\"effective_end\" >= \"schedule\".\"effective_start\"" - } - }, - "isRLSEnabled": false - }, - "public.volunteer_to_schedule": { - "name": "volunteer_to_schedule", - "schema": "", - "columns": { - "volunteer_user_id": { - "name": "volunteer_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "schedule_id": { - "name": "schedule_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "volunteer_to_schedule_volunteer_user_id_index": { - "name": "volunteer_to_schedule_volunteer_user_id_index", - "columns": [ - { - "expression": "volunteer_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "volunteer_to_schedule_schedule_id_index": { - "name": "volunteer_to_schedule_schedule_id_index", - "columns": [ - { - "expression": "schedule_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "volunteer_to_schedule_volunteer_user_id_volunteer_user_id_fk": { - "name": "volunteer_to_schedule_volunteer_user_id_volunteer_user_id_fk", - "tableFrom": "volunteer_to_schedule", - "tableTo": "volunteer", - "columnsFrom": [ - "volunteer_user_id" - ], - "columnsTo": [ - "user_id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "volunteer_to_schedule_schedule_id_schedule_id_fk": { - "name": "volunteer_to_schedule_schedule_id_schedule_id_fk", - "tableFrom": "volunteer_to_schedule", - "tableTo": "schedule", - "columnsFrom": [ - "schedule_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "pk_volunteer_schedule": { - "name": "pk_volunteer_schedule", - "columns": [ - "volunteer_user_id", - "schedule_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.coverage_request": { - "name": "coverage_request", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "shift_id": { - "name": "shift_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "category": { - "name": "category", - "type": "coverage_category", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "details": { - "name": "details", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "comments": { - "name": "comments", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "requested_at": { - "name": "requested_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "status": { - "name": "status", - "type": "coverage_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'open'" - }, - "requesting_volunteer_user_id": { - "name": "requesting_volunteer_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "covered_by_volunteer_user_id": { - "name": "covered_by_volunteer_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "coverage_request_shift_id_requesting_volunteer_user_id_index": { - "name": "coverage_request_shift_id_requesting_volunteer_user_id_index", - "columns": [ - { - "expression": "shift_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "requesting_volunteer_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "not \"coverage_request\".\"status\" = 'withdrawn'::coverage_status", - "concurrently": false, - "method": "btree", - "with": {} - }, - "coverage_request_shift_id_status_index": { - "name": "coverage_request_shift_id_status_index", - "columns": [ - { - "expression": "shift_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "coverage_request_covered_by_volunteer_user_id_index": { - "name": "coverage_request_covered_by_volunteer_user_id_index", - "columns": [ - { - "expression": "covered_by_volunteer_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "coverage_request_requesting_volunteer_user_id_index": { - "name": "coverage_request_requesting_volunteer_user_id_index", - "columns": [ - { - "expression": "requesting_volunteer_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "coverage_request_shift_id_shift_id_fk": { - "name": "coverage_request_shift_id_shift_id_fk", - "tableFrom": "coverage_request", - "tableTo": "shift", - "columnsFrom": [ - "shift_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "coverage_request_requesting_volunteer_user_id_volunteer_user_id_fk": { - "name": "coverage_request_requesting_volunteer_user_id_volunteer_user_id_fk", - "tableFrom": "coverage_request", - "tableTo": "volunteer", - "columnsFrom": [ - "requesting_volunteer_user_id" - ], - "columnsTo": [ - "user_id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "coverage_request_covered_by_volunteer_user_id_volunteer_user_id_fk": { - "name": "coverage_request_covered_by_volunteer_user_id_volunteer_user_id_fk", - "tableFrom": "coverage_request", - "tableTo": "volunteer", - "columnsFrom": [ - "covered_by_volunteer_user_id" - ], - "columnsTo": [ - "user_id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.shift": { - "name": "shift", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "class_id": { - "name": "class_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "schedule_id": { - "name": "schedule_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "start_at": { - "name": "start_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "end_at": { - "name": "end_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "date": { - "name": "date", - "type": "date", - "primaryKey": false, - "notNull": true - }, - "canceled": { - "name": "canceled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "cancel_reason": { - "name": "cancel_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cancelled_by_user_id": { - "name": "cancelled_by_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "canceled_at": { - "name": "canceled_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "shift_class_id_index": { - "name": "shift_class_id_index", - "columns": [ - { - "expression": "class_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "shift_schedule_id_index": { - "name": "shift_schedule_id_index", - "columns": [ - { - "expression": "schedule_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_shift_date": { - "name": "idx_shift_date", - "columns": [ - { - "expression": "date", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "class_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "not \"shift\".\"canceled\"", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_shift_start": { - "name": "idx_shift_start", - "columns": [ - { - "expression": "start_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "not \"shift\".\"canceled\"", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_shift_slot": { - "name": "idx_shift_slot", - "columns": [ - { - "expression": "schedule_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "shift_class_id_course_id_fk": { - "name": "shift_class_id_course_id_fk", - "tableFrom": "shift", - "tableTo": "course", - "columnsFrom": [ - "class_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "shift_schedule_id_schedule_id_fk": { - "name": "shift_schedule_id_schedule_id_fk", - "tableFrom": "shift", - "tableTo": "schedule", - "columnsFrom": [ - "schedule_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "shift_cancelled_by_user_id_user_id_fk": { - "name": "shift_cancelled_by_user_id_user_id_fk", - "tableFrom": "shift", - "tableTo": "user", - "columnsFrom": [ - "cancelled_by_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "chk_shift_time": { - "name": "chk_shift_time", - "value": "\"shift\".\"end_at\" > \"shift\".\"start_at\"" - } - }, - "isRLSEnabled": false - }, - "public.shift_attendance": { - "name": "shift_attendance", - "schema": "", - "columns": { - "shift_id": { - "name": "shift_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "attendance_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "checked_in_at": { - "name": "checked_in_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "minutes_worked": { - "name": "minutes_worked", - "type": "smallint", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "shift_attendance_user_id_index": { - "name": "shift_attendance_user_id_index", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "shift_attendance_shift_id_shift_id_fk": { - "name": "shift_attendance_shift_id_shift_id_fk", - "tableFrom": "shift_attendance", - "tableTo": "shift", - "columnsFrom": [ - "shift_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "shift_attendance_user_id_volunteer_user_id_fk": { - "name": "shift_attendance_user_id_volunteer_user_id_fk", - "tableFrom": "shift_attendance", - "tableTo": "volunteer", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "user_id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "pk_shift_attendance": { - "name": "pk_shift_attendance", - "columns": [ - "shift_id", - "user_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.course_preference": { - "name": "course_preference", - "schema": "", - "columns": { - "volunteer_user_id": { - "name": "volunteer_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "course_id": { - "name": "course_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "course_preference_volunteer_user_id_volunteer_user_id_fk": { - "name": "course_preference_volunteer_user_id_volunteer_user_id_fk", - "tableFrom": "course_preference", - "tableTo": "volunteer", - "columnsFrom": [ - "volunteer_user_id" - ], - "columnsTo": [ - "user_id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "course_preference_course_id_course_id_fk": { - "name": "course_preference_course_id_course_id_fk", - "tableFrom": "course_preference", - "tableTo": "course", - "columnsFrom": [ - "course_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "pk_course_preferences": { - "name": "pk_course_preferences", - "columns": [ - "volunteer_user_id", - "course_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "role": { - "name": "role", - "type": "role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'unverified'" - }, - "last_name": { - "name": "last_name", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "idx_user_email": { - "name": "idx_user_email", - "columns": [ - { - "expression": "email", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_user_role": { - "name": "idx_user_role", - "columns": [ - { - "expression": "role", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_user_status": { - "name": "idx_user_status", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_user_created_at": { - "name": "idx_user_created_at", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.volunteer": { - "name": "volunteer", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": true, - "notNull": true - }, - "preferred_name": { - "name": "preferred_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "bio": { - "name": "bio", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "pronouns": { - "name": "pronouns", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "phone_number": { - "name": "phone_number", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "city": { - "name": "city", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "province": { - "name": "province", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "availability": { - "name": "availability", - "type": "bit(336)", - "primaryKey": false, - "notNull": true, - "default": "'000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'" - }, - "preferred_time_commitment_hours": { - "name": "preferred_time_commitment_hours", - "type": "integer", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "idx_volunteer_city": { - "name": "idx_volunteer_city", - "columns": [ - { - "expression": "city", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_volunteer_province": { - "name": "idx_volunteer_province", - "columns": [ - { - "expression": "province", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "volunteer_user_id_user_id_fk": { - "name": "volunteer_user_id_user_id_fk", - "tableFrom": "volunteer", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.notification": { - "name": "notification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "body": { - "name": "body", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "link_url": { - "name": "link_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_type": { - "name": "source_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_id": { - "name": "source_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "actor_id": { - "name": "actor_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "read": { - "name": "read", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "read_at": { - "name": "read_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "archived": { - "name": "archived", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "archived_at": { - "name": "archived_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "email_sent": { - "name": "email_sent", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "idempotency_key": { - "name": "idempotency_key", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "idx_notification_user_created": { - "name": "idx_notification_user_created", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_notification_user_read": { - "name": "idx_notification_user_read", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "read", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_notification_type": { - "name": "idx_notification_type", - "columns": [ - { - "expression": "type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_notification_source": { - "name": "idx_notification_source", - "columns": [ - { - "expression": "source_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_notification_user_archived": { - "name": "idx_notification_user_archived", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "archived", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "notification_user_id_user_id_fk": { - "name": "notification_user_id_user_id_fk", - "tableFrom": "notification", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "notification_actor_id_user_id_fk": { - "name": "notification_actor_id_user_id_fk", - "tableFrom": "notification", - "tableTo": "user", - "columnsFrom": [ - "actor_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "notification_idempotency_key_unique": { - "name": "notification_idempotency_key_unique", - "nullsNotDistinct": false, - "columns": [ - "idempotency_key" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.notification_preference": { - "name": "notification_preference", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "channel": { - "name": "channel", - "type": "notification_channel", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "enabled": { - "name": "enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "uq_notification_pref_user_type_channel": { - "name": "uq_notification_pref_user_type_channel", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "channel", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_notification_pref_user": { - "name": "idx_notification_pref_user", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "notification_preference_user_id_user_id_fk": { - "name": "notification_preference_user_id_user_id_fk", - "tableFrom": "notification_preference", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.location_type": { - "name": "location_type", - "schema": "public", - "values": [ - "InPerson", - "MeetingLink" - ] - }, - "public.attendance_status": { - "name": "attendance_status", - "schema": "public", - "values": [ - "present", - "absent", - "excused", - "late" - ] - }, - "public.coverage_category": { - "name": "coverage_category", - "schema": "public", - "values": [ - "emergency", - "health", - "conflict", - "transportation", - "other" - ] - }, - "public.coverage_status": { - "name": "coverage_status", - "schema": "public", - "values": [ - "open", - "withdrawn", - "resolved" - ] - }, - "public.role": { - "name": "role", - "schema": "public", - "values": [ - "admin", - "instructor", - "volunteer" - ] - }, - "public.status": { - "name": "status", - "schema": "public", - "values": [ - "unverified", - "rejected", - "active", - "inactive" - ] - }, - "public.notification_channel": { - "name": "notification_channel", - "schema": "public", - "values": [ - "email", - "in_app", - "push" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": { - "public.vw_instructor_user": { - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "role": { - "name": "role", - "type": "role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'unverified'" - }, - "last_name": { - "name": "last_name", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "definition": "select \"id\", \"name\", \"email\", \"email_verified\", \"image\", \"created_at\", \"updated_at\", \"role\", \"status\", \"last_name\" from \"user\" where \"user\".\"role\" = 'instructor'", - "name": "vw_instructor_user", - "schema": "public", - "isExisting": false, - "materialized": false - }, - "public.vw_volunteer_user": { - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "last_name": { - "name": "last_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'unverified'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "role": { - "name": "role", - "type": "role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "preferred_name": { - "name": "preferred_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "bio": { - "name": "bio", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "pronouns": { - "name": "pronouns", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "phone_number": { - "name": "phone_number", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "city": { - "name": "city", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "province": { - "name": "province", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "availability": { - "name": "availability", - "type": "bit(336)", - "primaryKey": false, - "notNull": true, - "default": "'000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'" - }, - "preferred_time_commitment_hours": { - "name": "preferred_time_commitment_hours", - "type": "integer", - "primaryKey": false, - "notNull": false - } - }, - "definition": "select \"user\".\"id\", \"user\".\"name\", \"user\".\"last_name\", \"user\".\"email\", \"user\".\"status\", \"user\".\"created_at\", \"user\".\"updated_at\", \"user\".\"email_verified\", \"user\".\"image\", \"user\".\"role\", \"volunteer\".\"preferred_name\", \"volunteer\".\"bio\", \"volunteer\".\"pronouns\", \"volunteer\".\"phone_number\", \"volunteer\".\"city\", \"volunteer\".\"province\", \"volunteer\".\"availability\", \"volunteer\".\"preferred_time_commitment_hours\" from \"user\" inner join \"volunteer\" on \"volunteer\".\"user_id\" = \"user\".\"id\" where \"user\".\"role\" = 'volunteer'", - "name": "vw_volunteer_user", - "schema": "public", - "isExisting": false, - "materialized": false - } - }, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/src/server/db/migrations/meta/_journal.json b/src/server/db/migrations/meta/_journal.json index b4a303f7..33ad99e5 100644 --- a/src/server/db/migrations/meta/_journal.json +++ b/src/server/db/migrations/meta/_journal.json @@ -75,15 +75,8 @@ { "idx": 10, "version": "7", - "when": 1773985794131, - "tag": "0010_lethal_stature", - "breakpoints": true - }, - { - "idx": 11, - "version": "7", - "when": 1773992441500, - "tag": "0011_curly_psynapse", + "when": 1774073381780, + "tag": "0010_add_notification_system", "breakpoints": true } ] From 2a8a042452d8f726753072bf95ecf0b93b5af931 Mon Sep 17 00:00:00 2001 From: theosiemensrhodes Date: Sat, 21 Mar 2026 10:55:35 -0700 Subject: [PATCH 8/9] fix: greptile fixes --- src/lib/constants.ts | 19 +++++++++ .../check-shift-notifications.job.ts | 10 ++--- .../definitions/process-notification.job.ts | 2 + src/server/services/entity/coverageService.ts | 5 ++- src/server/services/entity/shiftService.ts | 3 +- src/server/services/notificationService.ts | 40 +++++++++++++------ 6 files changed, 58 insertions(+), 21 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 28880f75..a1e91b78 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1 +1,20 @@ export const NEURON_TIMEZONE = "America/Vancouver" as const; // All of our users are in Vancouver, no need to over complicate + +/** e.g. "March 21, 2026" */ +export function formatDate(date: Date): string { + return date.toLocaleDateString("en-CA", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: NEURON_TIMEZONE, + }); +} + +/** e.g. "2:30 p.m." */ +export function formatTime(date: Date): string { + return date.toLocaleTimeString("en-CA", { + hour: "numeric", + minute: "2-digit", + timeZone: NEURON_TIMEZONE, + }); +} diff --git a/src/server/jobs/definitions/check-shift-notifications.job.ts b/src/server/jobs/definitions/check-shift-notifications.job.ts index 2fa56f69..31d642d9 100644 --- a/src/server/jobs/definitions/check-shift-notifications.job.ts +++ b/src/server/jobs/definitions/check-shift-notifications.job.ts @@ -5,6 +5,7 @@ import { course } from "@/server/db/schema/course"; import { volunteerToSchedule } from "@/server/db/schema/schedule"; import { user } from "@/server/db/schema/user"; import { CoverageStatus } from "@/models/api/coverage"; +import { formatDate, formatTime } from "@/lib/constants"; import type { RegisteredJob } from "../types"; export type CheckShiftNotificationsPayload = { @@ -54,11 +55,8 @@ export const checkShiftNotificationsJob: RegisteredJob v.userId), }); } else { @@ -80,7 +78,7 @@ export const checkShiftNotificationsJob: RegisteredJob v.name).join(", "), volunteerCount: missingVolunteers.length, }); diff --git a/src/server/jobs/definitions/process-notification.job.ts b/src/server/jobs/definitions/process-notification.job.ts index 1f1cbceb..369edae0 100644 --- a/src/server/jobs/definitions/process-notification.job.ts +++ b/src/server/jobs/definitions/process-notification.job.ts @@ -7,6 +7,7 @@ export type ProcessNotificationPayload = { context: Record; actorId?: string; idempotencyKey?: string; + excludeUserIds?: string[]; }; export const processNotificationJob: RegisteredJob = @@ -24,6 +25,7 @@ export const processNotificationJob: RegisteredJob = context: payload.context, actorId: payload.actorId, idempotencyKey: payload.idempotencyKey, + excludeUserIds: payload.excludeUserIds, }); }, }; diff --git a/src/server/services/entity/coverageService.ts b/src/server/services/entity/coverageService.ts index b0f8b354..ba09689a 100644 --- a/src/server/services/entity/coverageService.ts +++ b/src/server/services/entity/coverageService.ts @@ -12,6 +12,7 @@ import { type ListCoverageRequestWithReason, } from "@/models/coverage"; import { hasPermission } from "@/lib/auth/extensions/permissions"; +import { formatDate } from "@/lib/constants"; import { Role } from "@/models/interfaces"; import type { EmbeddedShift } from "@/models/shift"; import { buildUser } from "@/models/user"; @@ -432,7 +433,7 @@ export class CoverageService implements ICoverageService { shiftId: requestData.shiftId, classId: shiftInfo.courseId, className: shiftInfo.courseName, - shiftDate: shiftInfo.startAt.toLocaleDateString(), + shiftDate: formatDate(shiftInfo.startAt), shiftStartAt: shiftInfo.startAt, shiftEndAt: shiftInfo.endAt, requestingVolunteerUserId, @@ -587,7 +588,7 @@ export class CoverageService implements ICoverageService { shiftId: request.shiftId, classId: request.courseId, className: request.courseName, - shiftDate: request.shiftStartAt.toLocaleDateString(), + shiftDate: formatDate(request.shiftStartAt), coveredByVolunteerUserId, coveredByVolunteerName: currentUser?.name ?? "A volunteer", requestingVolunteerUserId: request.requestingVolunteerUserId, diff --git a/src/server/services/entity/shiftService.ts b/src/server/services/entity/shiftService.ts index 22f9cae0..798eddaa 100644 --- a/src/server/services/entity/shiftService.ts +++ b/src/server/services/entity/shiftService.ts @@ -1,4 +1,5 @@ import { hasPermission } from "@/lib/auth/extensions/permissions"; +import { formatDate } from "@/lib/constants"; import type { ICurrentSessionService } from "@/server/services/currentSessionService"; import { CoverageStatus } from "@/models/api/coverage"; import type { @@ -803,7 +804,7 @@ export class ShiftService implements IShiftService { void this.notificationEventService.notifyShiftCancelled({ shiftId, className: shiftRow.courseName, - shiftDate: shiftRow.startAt.toLocaleDateString(), + shiftDate: formatDate(shiftRow.startAt), cancelReason, cancelledByUserId: currentUserId ?? "system", cancelledByName: currentUser?.name ?? "System", diff --git a/src/server/services/notificationService.ts b/src/server/services/notificationService.ts index 1b2ccd42..d9a6fc03 100644 --- a/src/server/services/notificationService.ts +++ b/src/server/services/notificationService.ts @@ -189,7 +189,10 @@ export class NotificationService implements INotificationService { ) .returning({ id: notification.id }); if (rows.length === 0) { - throw new NeuronError("Notification not found", NeuronErrorCodes.NOT_FOUND); + throw new NeuronError( + "Notification not found", + NeuronErrorCodes.NOT_FOUND, + ); } } @@ -205,7 +208,10 @@ export class NotificationService implements INotificationService { ) .returning({ id: notification.id }); if (rows.length === 0) { - throw new NeuronError("Notification not found", NeuronErrorCodes.NOT_FOUND); + throw new NeuronError( + "Notification not found", + NeuronErrorCodes.NOT_FOUND, + ); } } @@ -225,7 +231,12 @@ export class NotificationService implements INotificationService { async archive(notificationId: string, userId: string): Promise { const rows = await this.db .update(notification) - .set({ archived: true, archivedAt: new Date(), read: true, readAt: new Date() }) + .set({ + archived: true, + archivedAt: new Date(), + read: true, + readAt: new Date(), + }) .where( and( eq(notification.id, notificationId), @@ -234,7 +245,10 @@ export class NotificationService implements INotificationService { ) .returning({ id: notification.id }); if (rows.length === 0) { - throw new NeuronError("Notification not found", NeuronErrorCodes.NOT_FOUND); + throw new NeuronError( + "Notification not found", + NeuronErrorCodes.NOT_FOUND, + ); } } @@ -250,7 +264,10 @@ export class NotificationService implements INotificationService { ) .returning({ id: notification.id }); if (rows.length === 0) { - throw new NeuronError("Notification not found", NeuronErrorCodes.NOT_FOUND); + throw new NeuronError( + "Notification not found", + NeuronErrorCodes.NOT_FOUND, + ); } } @@ -259,10 +276,7 @@ export class NotificationService implements INotificationService { .update(notification) .set({ archived: true, archivedAt: new Date() }) .where( - and( - eq(notification.userId, userId), - eq(notification.archived, false), - ), + and(eq(notification.userId, userId), eq(notification.archived, false)), ); } @@ -281,12 +295,15 @@ export class NotificationService implements INotificationService { idempotencyKey?: string; excludeUserIds?: string[]; }): Promise { - // Check idempotency + // Check idempotency — per-user keys are stored as `${baseKey}:${userId}`, + // so we check for any row whose key starts with the base key. if (idempotencyKey) { const [existing] = await this.db .select({ id: notification.id }) .from(notification) - .where(eq(notification.idempotencyKey, idempotencyKey)) + .where( + sql`${notification.idempotencyKey} like ${idempotencyKey + ":%"}`, + ) .limit(1); if (existing) return; @@ -396,7 +413,6 @@ export class NotificationService implements INotificationService { private async resolveAudience( audience: Audience, ): Promise { - console.log("processing notif"); switch (audience.kind) { case "user": { const [result] = await this.db From a8a07939b151f22245bb6eb1e512e4f333589893 Mon Sep 17 00:00:00 2001 From: theosiemensrhodes Date: Sat, 21 Mar 2026 11:30:11 -0700 Subject: [PATCH 9/9] fix: greptile issues --- .../notifications/notification-inbox.tsx | 2 +- .../db/migrations/0010_add_notification_system.sql | 3 +-- src/server/db/migrations/meta/0010_snapshot.json | 9 +-------- src/server/db/migrations/meta/_journal.json | 2 +- src/server/db/schema/notification.ts | 1 - src/server/services/entity/coverageService.ts | 14 ++++++++++---- src/server/services/entity/shiftService.ts | 6 ++++-- src/server/services/notificationEventService.ts | 2 +- 8 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/components/notifications/notification-inbox.tsx b/src/components/notifications/notification-inbox.tsx index a47e355c..4c97ee76 100644 --- a/src/components/notifications/notification-inbox.tsx +++ b/src/components/notifications/notification-inbox.tsx @@ -119,7 +119,7 @@ export function NotificationInbox() { ) => { markAsRead.mutate({ notificationId }); if (linkUrl) { - router.push(linkUrl as any); + router.push(linkUrl); } setOpen(false); }; diff --git a/src/server/db/migrations/0010_add_notification_system.sql b/src/server/db/migrations/0010_add_notification_system.sql index a55c2e5f..960052a2 100644 --- a/src/server/db/migrations/0010_add_notification_system.sql +++ b/src/server/db/migrations/0010_add_notification_system.sql @@ -13,7 +13,6 @@ CREATE TABLE "notification" ( "read_at" timestamp with time zone, "archived" boolean DEFAULT false NOT NULL, "archived_at" timestamp with time zone, - "email_sent" boolean DEFAULT false NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "idempotency_key" text, CONSTRAINT "notification_idempotency_key_unique" UNIQUE("idempotency_key") @@ -37,4 +36,4 @@ CREATE INDEX "idx_notification_type" ON "notification" USING btree ("type");--> CREATE INDEX "idx_notification_source" ON "notification" USING btree ("source_type","source_id");--> statement-breakpoint CREATE INDEX "idx_notification_user_archived" ON "notification" USING btree ("user_id","archived");--> statement-breakpoint CREATE UNIQUE INDEX "uq_notification_pref_user_type_channel" ON "notification_preference" USING btree ("user_id","type","channel");--> statement-breakpoint -CREATE INDEX "idx_notification_pref_user" ON "notification_preference" USING btree ("user_id"); \ No newline at end of file +CREATE INDEX "idx_notification_pref_user" ON "notification_preference" USING btree ("user_id"); diff --git a/src/server/db/migrations/meta/0010_snapshot.json b/src/server/db/migrations/meta/0010_snapshot.json index 55656e9a..b0e94307 100644 --- a/src/server/db/migrations/meta/0010_snapshot.json +++ b/src/server/db/migrations/meta/0010_snapshot.json @@ -1,5 +1,5 @@ { - "id": "97fec7ce-4c2d-46f2-9bd1-76ff70f62293", + "id": "912223b0-c5dd-4889-9f94-bf066af68068", "prevId": "5de7367c-71f7-4d40-9ee8-87a526c11ab9", "version": "7", "dialect": "postgresql", @@ -2169,13 +2169,6 @@ "primaryKey": false, "notNull": false }, - "email_sent": { - "name": "email_sent", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, "created_at": { "name": "created_at", "type": "timestamp with time zone", diff --git a/src/server/db/migrations/meta/_journal.json b/src/server/db/migrations/meta/_journal.json index 33ad99e5..c0094c07 100644 --- a/src/server/db/migrations/meta/_journal.json +++ b/src/server/db/migrations/meta/_journal.json @@ -75,7 +75,7 @@ { "idx": 10, "version": "7", - "when": 1774073381780, + "when": 1774117341138, "tag": "0010_add_notification_system", "breakpoints": true } diff --git a/src/server/db/schema/notification.ts b/src/server/db/schema/notification.ts index e0d4ff79..893cc6fc 100644 --- a/src/server/db/schema/notification.ts +++ b/src/server/db/schema/notification.ts @@ -37,7 +37,6 @@ export const notification = pgTable( readAt: timestamp("read_at", { withTimezone: true }), archived: boolean("archived").notNull().default(false), archivedAt: timestamp("archived_at", { withTimezone: true }), - emailSent: boolean("email_sent").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), diff --git a/src/server/services/entity/coverageService.ts b/src/server/services/entity/coverageService.ts index ba09689a..ed15d451 100644 --- a/src/server/services/entity/coverageService.ts +++ b/src/server/services/entity/coverageService.ts @@ -437,7 +437,9 @@ export class CoverageService implements ICoverageService { shiftStartAt: shiftInfo.startAt, shiftEndAt: shiftInfo.endAt, requestingVolunteerUserId, - requestingVolunteerName: currentUser?.name ?? "A volunteer", + requestingVolunteerName: currentUser + ? `${currentUser.name} ${currentUser.lastName}` + : "A volunteer", reason: requestData.details, }); } @@ -576,7 +578,7 @@ export class CoverageService implements ICoverageService { // Notify admins, instructors, and requesting volunteer const [requestingUser] = await this.db - .select({ name: user.name }) + .select({ name: user.name, lastName: user.lastName }) .from(user) .where(eq(user.id, request.requestingVolunteerUserId)) .limit(1); @@ -590,9 +592,13 @@ export class CoverageService implements ICoverageService { className: request.courseName, shiftDate: formatDate(request.shiftStartAt), coveredByVolunteerUserId, - coveredByVolunteerName: currentUser?.name ?? "A volunteer", + coveredByVolunteerName: currentUser + ? `${currentUser.name} ${currentUser.lastName}` + : "A volunteer", requestingVolunteerUserId: request.requestingVolunteerUserId, - requestingVolunteerName: requestingUser?.name ?? "A volunteer", + requestingVolunteerName: requestingUser + ? `${requestingUser.name} ${requestingUser.lastName}` + : "A volunteer", }); } diff --git a/src/server/services/entity/shiftService.ts b/src/server/services/entity/shiftService.ts index 798eddaa..442fd822 100644 --- a/src/server/services/entity/shiftService.ts +++ b/src/server/services/entity/shiftService.ts @@ -806,8 +806,10 @@ export class ShiftService implements IShiftService { className: shiftRow.courseName, shiftDate: formatDate(shiftRow.startAt), cancelReason, - cancelledByUserId: currentUserId ?? "system", - cancelledByName: currentUser?.name ?? "System", + cancelledByUserId: currentUserId, + cancelledByName: currentUser + ? `${currentUser.name} ${currentUser.lastName}` + : "System", }); } diff --git a/src/server/services/notificationEventService.ts b/src/server/services/notificationEventService.ts index 3c488489..da6b137c 100644 --- a/src/server/services/notificationEventService.ts +++ b/src/server/services/notificationEventService.ts @@ -13,7 +13,7 @@ interface ShiftCancelledParams { className: string; shiftDate: string; cancelReason: string; - cancelledByUserId: string; + cancelledByUserId?: string; cancelledByName: string; }