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, defaultgen_random_uuid()). userId(uuid, NOT NULL, FK →users.users.id, on-delete cascade).type(enumnotification_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
userIdON DELETE CASCADE — notifications follow the user (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/notifications.schema.ts).userId,type,title,bodyNOT NULL (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/notifications.schema.ts).
Business invariants:
- Write-once shape —
type,title,body,dataare set at create time and never updated. OnlyreadAtis 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. datapayload shape depends ontype— aBROADCAST_MESSAGEcarries{ messageId }; aBOOKING_CREATEDcarries{ 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)setsreadAt = now(). No further transitions. - Delete: cascades when the User is deleted. There is no self-delete or expiration today.
Mutation sources (verified against code)
| Source | What it does |
|---|---|
NotificationFeatureService.notify | Central producer — INSERT the row, then dispatch push via Device token (best-effort, no transactional coupling) |
NotificationHistoryService.markAsRead | UPDATE 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) —
userId→users.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
| Surface | Exposed | Notes |
|---|---|---|
| client | yes — /me/notifications, /me/notifications/unread-count, /me/notifications/{id}/read, /me/notifications/read-all (NotificationDto, UnreadCountDto) | Swagger UI |
| business | no — direct user notifications are not browsable from the business panel; broadcasts are managed via Message | Swagger UI |
| super-admin | no | — |
Known gotchas / open questions
- The
notification_typeenum is shared across booking, refund, session, wallet, broadcast, pass, and billing concerns — adding a new event type requires a Drizzle migration on the enum. datais free-form JSONB — its shape depends ontypeand 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
dataschemas pertype— discriminated union in TS + a runtime validator atnotifytime. Today a typo in the producer silently breaks the deep-link. - Push-delivery status column (or sibling table) — capture
SENT/FAILED/PENDINGper device per notification so failed deliveries are observable and retriable. - Retention / archival policy —
cronto 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).