Summary
Introduce a notificationService that serves as the public interface for all notification scheduling and delivery logic. It should abstract pgBoss job creation/execution and support email now, with a design that can extend to browser push / phone later.
Notifications should be scheduled primarily as event jobs (e.g., one job per class reminder) and resolve recipients + preferences at execution time in bulk, to avoid job fan-out and to ensure preference changes apply immediately.
Depends On
Goals
- Provide a stable, general-purpose API for scheduling/triggering notifications without leaking pgBoss details.
- Support scheduled and recurring notifications.
- Implement a default-safe preferences system:
- Global defaults per notification type (registry)
- User-specific overrides stored in a table
- No migrations/bulk updates required when adding new notification types
Public API (NotificationService)
Expose a small, stable interface:
-
notify({ type, audience, context, deliverAt, recurrence, idempotencyKey })
- Used for immediate or scheduled notifications.
- The service decides the job strategy (event job vs per-user) internally.
-
cancel({ idempotencyKey })
-
reschedule({ idempotencyKey, deliverAt })
-
Preferences (for UI + enforcement):
setPreference({ userId, type, channel, enabled })
clearPreferenceOverride({ userId, type, channel })
getEffectivePreferences({ userId }) (for ui)
getAllEffectivePreferences({ type, userIds })
getEffectivePreference({ type, userId })
Audience Shapes (initial)
{ kind: "user", userId }
{ kind: "users", userIds: string[] }
{ kind: "class", classId }
Default strategy: prefer event jobs for shared domain objects (e.g., one job per class reminder), and resolve recipients + preferences at execution time.
Job Strategy & Execution Model
Event Jobs (recommended default)
Example: class created -> schedule check-in reminder 15 minutes before start.
-
On scheduling:
- enqueue ONE job keyed by the domain object (e.g.,
classId + type + deliverAt)
- persist/calculate an
idempotencyKey for cancellation/rescheduling
-
On execution:
- load the domain object (e.g., class) and validate it still applies (not canceled, still in time window, etc.)
- resolve recipients (volunteers/instructors/etc.)
- resolve preferences in bulk (single query per type/channel set)
- send via enabled channels
- write audit log / mark sent (idempotency)
Per-User Jobs (only when needed)
Reserved for truly individualized reminders where the audience is inherently per-user and job count is bounded.
Notification Type Registry (Defaults)
Define a central registry of notification types and their default channel behavior.
Example registry entry:
type: "available_shifts"
- defaults:
This registry is the source of truth for:
- which channels exist for each type
- default enablement
- template selection / rendering hooks
Notification Preferences System
Drizzle Table: notification_preferences
Store only overrides. If no row exists, fall back to the registry default.
Columns:
userId (FK)
type (notification type key; matches registry)
channel (e.g. email, push)
enabled (boolean)
Constraints & indexes:
- Unique constraint / PK on
(userId, type, channel)
- Indexes for common lookup patterns:
(userId, type) or (userId, type, channel) (point lookup)
- optionally
(type, userId) for fan-out resolution by type
Preference Resolution Logic
When deciding whether to deliver via a channel:
- check for override row
(userId, type, channel)
- if missing, use registry default
(type, channel)
- effective result determines send/no-send
Implementation note:
- For multi-recipient notifications, resolve overrides in bulk:
WHERE userId IN (...) AND type = ?
- overlay onto defaults in memory
pgBoss Integration
- Use pgBoss for scheduling + retries.
- Jobs should include:
type
audience (or a domain reference like classId)
context
idempotencyKey
deliverAt / recurrence metadata
Error handling:
- configure retry policies (attempts/backoff) suitable for email delivery
Summary
Introduce a
notificationServicethat serves as the public interface for all notification scheduling and delivery logic. It should abstract pgBoss job creation/execution and support email now, with a design that can extend to browser push / phone later.Notifications should be scheduled primarily as event jobs (e.g., one job per class reminder) and resolve recipients + preferences at execution time in bulk, to avoid job fan-out and to ensure preference changes apply immediately.
Depends On
Goals
Public API (NotificationService)
Expose a small, stable interface:
notify({ type, audience, context, deliverAt, recurrence, idempotencyKey })cancel({ idempotencyKey })reschedule({ idempotencyKey, deliverAt })Preferences (for UI + enforcement):
setPreference({ userId, type, channel, enabled })clearPreferenceOverride({ userId, type, channel })getEffectivePreferences({ userId })(for ui)getAllEffectivePreferences({ type, userIds })getEffectivePreference({ type, userId })Audience Shapes (initial)
{ kind: "user", userId }{ kind: "users", userIds: string[] }{ kind: "class", classId }Job Strategy & Execution Model
Event Jobs (recommended default)
Example: class created -> schedule check-in reminder 15 minutes before start.
On scheduling:
classId + type + deliverAt)idempotencyKeyfor cancellation/reschedulingOn execution:
Per-User Jobs (only when needed)
Reserved for truly individualized reminders where the audience is inherently per-user and job count is bounded.
Notification Type Registry (Defaults)
Define a central registry of notification types and their default channel behavior.
Example registry entry:
type: "available_shifts"This registry is the source of truth for:
Notification Preferences System
Drizzle Table:
notification_preferencesStore only overrides. If no row exists, fall back to the registry default.
Columns:
userId(FK)type(notification type key; matches registry)channel(e.g.email,push)enabled(boolean)Constraints & indexes:
(userId, type, channel)(userId, type)or(userId, type, channel)(point lookup)(type, userId)for fan-out resolution by typePreference Resolution Logic
When deciding whether to deliver via a channel:
(userId, type, channel)(type, channel)Implementation note:
WHERE userId IN (...) AND type = ?pgBoss Integration
typeaudience(or a domain reference likeclassId)contextidempotencyKeydeliverAt/recurrencemetadataError handling: