Skip to content

Notification

Purpose

A row in the user-side inbox — one typed event per row, addressed to a specific User. Covers all in-app events: booking lifecycle (created / cancelled / confirmed), refund flow, session reminders, wallet top-up confirmations, broadcasts from coaches, pass low-sessions / expiry warnings, and billing events (renewal success / failure / cancellation). The “Notifications” tab in the client app is a feed over this table.

Identity & key fields

  • Primary key: id (uuid, default gen_random_uuid()).
  • userId (uuid, NOT NULL, FK → users.users.id, on-delete cascade).
  • type (enum notification_type) with values: BOOKING_CREATED, BOOKING_CANCELLED, BOOKING_CONFIRMED, REFUND_AUTO_APPROVED, REFUND_REQUESTED, REFUND_APPROVED, REFUND_REJECTED, SESSION_REMINDER, WALLET_TOP_UP, BROADCAST_MESSAGE, PASS_LOW_SESSIONS, PASS_EXPIRY_REMINDER, BILLING_RENEWAL_SUCCESS, BILLING_RENEWAL_FAILED, BILLING_CANCELLED.
  • title, body (text, NOT NULL).
  • data (nullable jsonb) — payload for deep links (bookingId, sessionId, etc.).
  • readAt (nullable timestamp; NULL = unread).
  • createdAt (timestamp, NOT NULL).

readAt IS NULL is the unread state — there is no explicit boolean. data jsonb carries the deep-link payload (e.g. { bookingId, sessionId } or { messageId }) — the shape depends on type and is not validated by the database.

Invariants

  • userId ON DELETE CASCADE — notifications follow the user (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/notifications.schema.ts).
  • userId, type, title, body NOT NULL (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/notifications.schema.ts).

Business invariants:

  • Write-once shape — type, title, body, data are set at create time and never updated. Only readAt is mutable (UPDATE to current timestamp on mark-as-read).
  • Every domain event that wants to reach a user goes through NotificationFeatureService.notify({userId, type, title, body, data}) — that is the central API. Direct INSERTs into the table are not the pattern.
  • data payload shape depends on type — a BROADCAST_MESSAGE carries { messageId }; a BOOKING_CREATED carries { bookingId, sessionId }; etc. The shape is not validated at the DB layer.
  • Push delivery (via Device token) is best-effort and decoupled — a notification row exists even if the push fails to reach the device.

Lifecycle

No explicit status enum — readAt carries the read/unread state.

  • Create: via NotificationFeatureService.notify({...}) — the central producer API called from every event producer (booking flows, refund flows, pass cron jobs, billing events, broadcast fan-out).
  • Mark as read: NotificationHistoryService.markAsRead(userId, notificationId) sets readAt = now(). No further transitions.
  • Delete: cascades when the User is deleted. There is no self-delete or expiration today.

Mutation sources (verified against code)

SourceWhat it does
NotificationFeatureService.notifyCentral producer — INSERT the row, then dispatch push via Device token (best-effort, no transactional coupling)
NotificationHistoryService.markAsReadUPDATE readAt = now() for one notification belonging to the calling user
Each event producer (booking, refund, pass scheduler, billing, broadcast fan-out)Calls notify — never INSERTs directly

Files: libs/features/notifications/src/lib/services/notification-feature.service.ts, notification-history.service.ts.

Relationships

  • User (ENT-021) — userIdusers.users.id, on-delete cascade. N:1.
  • Message recipient (ENT-025) — referenced 0..N (a notification may have been created from a broadcast message; that recipient row points back at this notification with on-delete set null).

API surfaces

SurfaceExposedNotes
clientyes — /me/notifications, /me/notifications/unread-count, /me/notifications/{id}/read, /me/notifications/read-all (NotificationDto, UnreadCountDto)Swagger UI
businessno — direct user notifications are not browsable from the business panel; broadcasts are managed via MessageSwagger UI
super-adminno

Known gotchas / open questions

  • The notification_type enum is shared across booking, refund, session, wallet, broadcast, pass, and billing concerns — adding a new event type requires a Drizzle migration on the enum.
  • data is free-form JSONB — its shape depends on type and is not validated at insert. A wrong shape silently breaks the deep-link in the client app.
  • Push delivery is best-effort and unobserved here — there is no column tracking whether the push reached the device. If you need delivery proof, look elsewhere (or add it, see Recommendations).
  • High-volume table — broadcasts to a large customer base produce one row per recipient. Without retention this grows fast.

Recommendations

Forward-looking improvements suggested while filling this doc — not currently in place.

  • Typed data schemas per type — discriminated union in TS + a runtime validator at notify time. Today a typo in the producer silently breaks the deep-link.
  • Push-delivery status column (or sibling table) — capture SENT / FAILED / PENDING per device per notification so failed deliveries are observable and retriable.
  • Retention / archival policycron to delete or archive notifications older than N days (perhaps differentiating read vs unread).
  • Partial index (user_id, read_at) WHERE read_at IS NULL — the “unread count” query is hot; this index speeds it up substantially.
  • Soft-archive on User delete instead of CASCADE — preserves notification history for compliance / disputes (especially for broadcast events the company sent).