@12-apps/notifications 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ADOPTING.md +316 -0
- package/README.md +153 -0
- package/package.json +94 -0
- package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +218 -0
- package/prisma/notifications.prisma +141 -0
- package/scripts/sync-notifications-schema.mjs +60 -0
- package/src/errors.ts +21 -0
- package/src/generators.ts +44 -0
- package/src/hono/index.ts +121 -0
- package/src/index.ts +73 -0
- package/src/messages.ts +156 -0
- package/src/phone.ts +57 -0
- package/src/preferences-core.ts +89 -0
- package/src/react/api.ts +111 -0
- package/src/react/bell-button.tsx +78 -0
- package/src/react/bell-icon.tsx +33 -0
- package/src/react/create-web-notifications.tsx +127 -0
- package/src/react/hooks.ts +74 -0
- package/src/react/inbox-state.ts +216 -0
- package/src/react/index.ts +61 -0
- package/src/react/panel.tsx +181 -0
- package/src/react/preferences-screen.tsx +242 -0
- package/src/react/relative-time.ts +18 -0
- package/src/react/row.tsx +98 -0
- package/src/react/transport.ts +72 -0
- package/src/react/web-push-client.ts +113 -0
- package/src/react/web-push-setup.tsx +167 -0
- package/src/server/by-permission.ts +255 -0
- package/src/server/context.ts +269 -0
- package/src/server/create-api-notifications.ts +215 -0
- package/src/server/db.ts +252 -0
- package/src/server/dispatch.ts +298 -0
- package/src/server/inbox.ts +155 -0
- package/src/server/index.ts +115 -0
- package/src/server/preferences.ts +103 -0
- package/src/server/push-subscriptions.ts +121 -0
- package/src/server/router.ts +275 -0
- package/src/server/routes.ts +218 -0
- package/src/server/transports/drivers.ts +148 -0
- package/src/server/transports/email.ts +141 -0
- package/src/server/transports/registry.ts +106 -0
- package/src/server/transports/sms.ts +120 -0
- package/src/server/transports/web-push.ts +168 -0
- package/src/server/transports/whatsapp.ts +183 -0
- package/src/types.ts +158 -0
- package/src/web-push/index.ts +70 -0
- package/src/wire.ts +62 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
-- @12-apps/notifications (12-15): the four generic notification tables, owned
|
|
2
|
+
-- by the package and copied into a host's migrations folder by its
|
|
3
|
+
-- plugin-migration sync. Runs identically on PostgreSQL + PGlite.
|
|
4
|
+
--
|
|
5
|
+
-- NO foreign keys into host tables (the payments-backend doctrine): `user_id`
|
|
6
|
+
-- and `client_id` are by-value scalars, and the host's own migration may add FK
|
|
7
|
+
-- constraints (recommended: both ON DELETE CASCADE). The relation INTERNAL to
|
|
8
|
+
-- the partial — notification_deliveries -> notifications — IS constrained, with
|
|
9
|
+
-- a cascade, so a purged notification can never leave orphan delivery rows.
|
|
10
|
+
--
|
|
11
|
+
-- `channel` and `status` carry CHECKs: those are the LIBRARY's own closed sets,
|
|
12
|
+
-- and a row outside them is a row no transport can carry. `category` does NOT,
|
|
13
|
+
-- for the reason `@12-apps/rbac` gives for `role`: the category set is host
|
|
14
|
+
-- vocabulary (`categories` on the server config), so a closed set here would be
|
|
15
|
+
-- wrong for every host but the first. A host that wants its own CHECK adds one.
|
|
16
|
+
--
|
|
17
|
+
-- The `status` set is QUEUED | SENDING | SENT | FAILED | DEAD. `SENDING` is the
|
|
18
|
+
-- dispatcher's CLAIM and `DEAD` is terminal (see `src/server/dispatch.ts`), and
|
|
19
|
+
-- both are newer than the first adopters' hand-made tables — which is why the
|
|
20
|
+
-- status CHECK below is DROPPED and re-added rather than guarded by a
|
|
21
|
+
-- `pg_constraint` lookup like the others. An existence guard would find the
|
|
22
|
+
-- three-value constraint an early adopter already has, skip, and leave the claim
|
|
23
|
+
-- rejected at runtime by a CHECK that predates it.
|
|
24
|
+
--
|
|
25
|
+
-- ============================ REPLAY SAFETY ================================
|
|
26
|
+
-- Every statement is guarded, because the first adopters ALREADY HAVE these
|
|
27
|
+
-- tables: future-pay created them by hand before the package existed, so this
|
|
28
|
+
-- migration must be a no-op there and correct on an empty database.
|
|
29
|
+
--
|
|
30
|
+
-- The guards are per COLUMN, not per table. `CREATE TABLE IF NOT EXISTS` alone
|
|
31
|
+
-- is the trap: it skips the whole table, so a host whose table predates a
|
|
32
|
+
-- column silently never gets that column and the failure surfaces later as a
|
|
33
|
+
-- missing-column error in production. So each table is followed by one
|
|
34
|
+
-- `ADD COLUMN IF NOT EXISTS` per column, and every NOT NULL column carries a
|
|
35
|
+
-- DEFAULT — a NOT NULL column with no default cannot be added to a table that
|
|
36
|
+
-- already holds rows.
|
|
37
|
+
--
|
|
38
|
+
-- CHECK constraints have no `IF NOT EXISTS` form, so they are guarded by a
|
|
39
|
+
-- `pg_constraint` lookup instead (plpgsql, which PGlite has) — EXCEPT the
|
|
40
|
+
-- delivery status CHECK, which must CONVERGE rather than be skipped and so is
|
|
41
|
+
-- `DROP CONSTRAINT IF EXISTS` + `ADD`. Same for the sweep's index, which moved
|
|
42
|
+
-- key: `DROP INDEX IF EXISTS` + `CREATE INDEX IF NOT EXISTS`. Both are
|
|
43
|
+
-- idempotent, which is the property replay safety actually needs — "guarded" was
|
|
44
|
+
-- only ever the usual way to get it, and it is the wrong way when the definition
|
|
45
|
+
-- itself has changed under an existing adopter.
|
|
46
|
+
|
|
47
|
+
-- ---------------------------------------------------------------------------
|
|
48
|
+
-- notifications — the always-on inbox. One row per emit, written before any
|
|
49
|
+
-- transport is consulted.
|
|
50
|
+
-- ---------------------------------------------------------------------------
|
|
51
|
+
CREATE TABLE IF NOT EXISTS "notifications" (
|
|
52
|
+
"id" TEXT NOT NULL,
|
|
53
|
+
"user_id" TEXT NOT NULL,
|
|
54
|
+
"client_id" TEXT,
|
|
55
|
+
"type" TEXT NOT NULL,
|
|
56
|
+
"category" TEXT NOT NULL,
|
|
57
|
+
"title" TEXT NOT NULL,
|
|
58
|
+
"body" TEXT NOT NULL,
|
|
59
|
+
"link" TEXT,
|
|
60
|
+
"data" JSONB NOT NULL DEFAULT '{}',
|
|
61
|
+
"read_at" TIMESTAMP(3),
|
|
62
|
+
"deleted_at" TIMESTAMP(3),
|
|
63
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
64
|
+
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
65
|
+
|
|
66
|
+
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "user_id" TEXT NOT NULL DEFAULT '';
|
|
70
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "client_id" TEXT;
|
|
71
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "type" TEXT NOT NULL DEFAULT '';
|
|
72
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "category" TEXT NOT NULL DEFAULT 'system';
|
|
73
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "title" TEXT NOT NULL DEFAULT '';
|
|
74
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "body" TEXT NOT NULL DEFAULT '';
|
|
75
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "link" TEXT;
|
|
76
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "data" JSONB NOT NULL DEFAULT '{}';
|
|
77
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "read_at" TIMESTAMP(3);
|
|
78
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP(3);
|
|
79
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
80
|
+
ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
81
|
+
|
|
82
|
+
-- Inbox list (owner, not-deleted, newest first) + the unread badge count.
|
|
83
|
+
CREATE INDEX IF NOT EXISTS "notifications_user_id_deleted_at_created_at_idx"
|
|
84
|
+
ON "notifications"("user_id", "deleted_at", "created_at");
|
|
85
|
+
CREATE INDEX IF NOT EXISTS "notifications_user_id_deleted_at_read_at_idx"
|
|
86
|
+
ON "notifications"("user_id", "deleted_at", "read_at");
|
|
87
|
+
|
|
88
|
+
-- ---------------------------------------------------------------------------
|
|
89
|
+
-- notification_deliveries — one row per channel the router fanned out to.
|
|
90
|
+
-- ---------------------------------------------------------------------------
|
|
91
|
+
CREATE TABLE IF NOT EXISTS "notification_deliveries" (
|
|
92
|
+
"id" TEXT NOT NULL,
|
|
93
|
+
"notification_id" TEXT NOT NULL,
|
|
94
|
+
"channel" TEXT NOT NULL,
|
|
95
|
+
"status" TEXT NOT NULL DEFAULT 'QUEUED',
|
|
96
|
+
"error" TEXT,
|
|
97
|
+
"attempts" INTEGER NOT NULL DEFAULT 0,
|
|
98
|
+
"sent_at" TIMESTAMP(3),
|
|
99
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
100
|
+
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
101
|
+
|
|
102
|
+
CONSTRAINT "notification_deliveries_pkey" PRIMARY KEY ("id")
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "notification_id" TEXT NOT NULL DEFAULT '';
|
|
106
|
+
ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "channel" TEXT NOT NULL DEFAULT 'EMAIL';
|
|
107
|
+
ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'QUEUED';
|
|
108
|
+
ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "error" TEXT;
|
|
109
|
+
-- The retry ceiling's counter (12-15). An adopter whose table predates it gets
|
|
110
|
+
-- it at 0, which reads as "never claimed" — the correct starting point for a row
|
|
111
|
+
-- that has, in the new lifecycle's terms, spent no attempts.
|
|
112
|
+
ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "attempts" INTEGER NOT NULL DEFAULT 0;
|
|
113
|
+
ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "sent_at" TIMESTAMP(3);
|
|
114
|
+
ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
115
|
+
ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
116
|
+
|
|
117
|
+
-- The library's own closed sets. A delivery outside them is a delivery no
|
|
118
|
+
-- transport can carry, so the schema refuses it rather than the router.
|
|
119
|
+
DO $$
|
|
120
|
+
BEGIN
|
|
121
|
+
IF NOT EXISTS (
|
|
122
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'notification_deliveries_channel_check'
|
|
123
|
+
) THEN
|
|
124
|
+
ALTER TABLE "notification_deliveries"
|
|
125
|
+
ADD CONSTRAINT "notification_deliveries_channel_check"
|
|
126
|
+
CHECK ("channel" IN ('EMAIL', 'SMS', 'WHATSAPP', 'WEB_PUSH'));
|
|
127
|
+
END IF;
|
|
128
|
+
END $$;
|
|
129
|
+
|
|
130
|
+
-- The status set, DROP-and-re-ADD rather than guarded (see the header): the
|
|
131
|
+
-- values widened in 12-15, so an adopter already holding a constraint under this
|
|
132
|
+
-- name holds the OLD three, and a guard would keep it — rejecting the claim's
|
|
133
|
+
-- own UPDATE. Idempotent, and re-running it converges rather than accumulating.
|
|
134
|
+
ALTER TABLE "notification_deliveries"
|
|
135
|
+
DROP CONSTRAINT IF EXISTS "notification_deliveries_status_check";
|
|
136
|
+
ALTER TABLE "notification_deliveries"
|
|
137
|
+
ADD CONSTRAINT "notification_deliveries_status_check"
|
|
138
|
+
CHECK ("status" IN ('QUEUED', 'SENDING', 'SENT', 'FAILED', 'DEAD'));
|
|
139
|
+
|
|
140
|
+
-- Idempotent fan-out: re-dispatching a notification can never duplicate a
|
|
141
|
+
-- channel's delivery row. This is what makes transport sends retry-safe.
|
|
142
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "notification_deliveries_notification_id_channel_key"
|
|
143
|
+
ON "notification_deliveries"("notification_id", "channel");
|
|
144
|
+
-- Serves the retry sweep, which selects `status IN (…) AND updated_at < cutoff`.
|
|
145
|
+
-- On `updated_at`, not `created_at`: the sweep asks "has this row moved lately",
|
|
146
|
+
-- and `created_at` cannot answer that — a row re-queued a second ago still
|
|
147
|
+
-- carries a `created_at` from days back, so it reads as stale again immediately.
|
|
148
|
+
-- The `(status, created_at)` index this replaces served the earlier, wrong
|
|
149
|
+
-- predicate and is dropped rather than left behind to cost every write.
|
|
150
|
+
DROP INDEX IF EXISTS "notification_deliveries_status_created_at_idx";
|
|
151
|
+
CREATE INDEX IF NOT EXISTS "notification_deliveries_status_updated_at_idx"
|
|
152
|
+
ON "notification_deliveries"("status", "updated_at");
|
|
153
|
+
|
|
154
|
+
DO $$
|
|
155
|
+
BEGIN
|
|
156
|
+
IF NOT EXISTS (
|
|
157
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'notification_deliveries_notification_id_fkey'
|
|
158
|
+
) THEN
|
|
159
|
+
ALTER TABLE "notification_deliveries"
|
|
160
|
+
ADD CONSTRAINT "notification_deliveries_notification_id_fkey"
|
|
161
|
+
FOREIGN KEY ("notification_id") REFERENCES "notifications"("id")
|
|
162
|
+
ON DELETE CASCADE ON UPDATE CASCADE;
|
|
163
|
+
END IF;
|
|
164
|
+
END $$;
|
|
165
|
+
|
|
166
|
+
-- ---------------------------------------------------------------------------
|
|
167
|
+
-- notification_preferences — EXPLICIT choices only; no row means the defaults.
|
|
168
|
+
-- ---------------------------------------------------------------------------
|
|
169
|
+
CREATE TABLE IF NOT EXISTS "notification_preferences" (
|
|
170
|
+
"id" TEXT NOT NULL,
|
|
171
|
+
"user_id" TEXT NOT NULL,
|
|
172
|
+
"category" TEXT NOT NULL,
|
|
173
|
+
"channels" JSONB NOT NULL DEFAULT '{}',
|
|
174
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
175
|
+
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
176
|
+
|
|
177
|
+
CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id")
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "user_id" TEXT NOT NULL DEFAULT '';
|
|
181
|
+
ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "category" TEXT NOT NULL DEFAULT 'system';
|
|
182
|
+
ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "channels" JSONB NOT NULL DEFAULT '{}';
|
|
183
|
+
ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
184
|
+
ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
185
|
+
|
|
186
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "notification_preferences_user_id_category_key"
|
|
187
|
+
ON "notification_preferences"("user_id", "category");
|
|
188
|
+
|
|
189
|
+
-- ---------------------------------------------------------------------------
|
|
190
|
+
-- push_subscriptions — the Web Push destination, one row per browser.
|
|
191
|
+
-- ---------------------------------------------------------------------------
|
|
192
|
+
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
|
|
193
|
+
"id" TEXT NOT NULL,
|
|
194
|
+
"user_id" TEXT NOT NULL,
|
|
195
|
+
"endpoint" TEXT NOT NULL,
|
|
196
|
+
"p256dh" TEXT NOT NULL,
|
|
197
|
+
"auth" TEXT NOT NULL,
|
|
198
|
+
"user_agent" TEXT,
|
|
199
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
200
|
+
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
201
|
+
|
|
202
|
+
CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id")
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "user_id" TEXT NOT NULL DEFAULT '';
|
|
206
|
+
ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "endpoint" TEXT NOT NULL DEFAULT '';
|
|
207
|
+
ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "p256dh" TEXT NOT NULL DEFAULT '';
|
|
208
|
+
ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "auth" TEXT NOT NULL DEFAULT '';
|
|
209
|
+
ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "user_agent" TEXT;
|
|
210
|
+
ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
211
|
+
ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
212
|
+
|
|
213
|
+
-- The push-service endpoint is globally unique per subscription — registering
|
|
214
|
+
-- the same browser again upserts rather than duplicates.
|
|
215
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "push_subscriptions_endpoint_key"
|
|
216
|
+
ON "push_subscriptions"("endpoint");
|
|
217
|
+
CREATE INDEX IF NOT EXISTS "push_subscriptions_user_id_idx"
|
|
218
|
+
ON "push_subscriptions"("user_id");
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @12-apps/notifications (12-15) — the four models the notification system
|
|
2
|
+
// owns, COPIED into a host's schema folder by
|
|
3
|
+
// `pnpm --filter @12-apps/notifications prisma:sync` (never symlinked: Prisma
|
|
4
|
+
// lstats a migration directory, so a symlinked migration is silently skipped
|
|
5
|
+
// and a green deploy applies no schema).
|
|
6
|
+
//
|
|
7
|
+
// Deliberately NO foreign keys into host tables (the payments-backend
|
|
8
|
+
// doctrine): `user_id` and `client_id` are by-value scalars, and the host's own
|
|
9
|
+
// migration may add FK constraints (recommended: both ON DELETE CASCADE, so a
|
|
10
|
+
// deleted account takes its inbox with it). The relation INTERNAL to the
|
|
11
|
+
// partial — notification_deliveries -> notifications — IS constrained, with a
|
|
12
|
+
// cascade, so a purged notification can never leave orphan delivery rows.
|
|
13
|
+
//
|
|
14
|
+
// Do not edit a synced copy by hand: the next sync reverts it and
|
|
15
|
+
// `prisma:sync:check` goes red in CI.
|
|
16
|
+
|
|
17
|
+
// One inbox entry — the always-on channel. Written for EVERY emit, before any
|
|
18
|
+
// transport is consulted, which is what makes "the user was told" true even
|
|
19
|
+
// when every provider is down. `type` is an open dot-namespaced set validated
|
|
20
|
+
// by the generator registry at the emit site (deliberately no CHECK, so a new
|
|
21
|
+
// notification type ships without a migration). `category` has NO CHECK either,
|
|
22
|
+
// and for a stronger reason: the category set is HOST vocabulary (`categories`
|
|
23
|
+
// on the server config), so a closed set in the schema would be wrong for every
|
|
24
|
+
// adopter but the first. A host that wants its own taxonomy enforced adds the
|
|
25
|
+
// CHECK in a migration of its own — future-pay does. Only `channel` and `status`
|
|
26
|
+
// on the delivery row are closed here, because those two are the LIBRARY's.
|
|
27
|
+
model Notification {
|
|
28
|
+
id String @id @default(uuid())
|
|
29
|
+
userId String @map("user_id")
|
|
30
|
+
clientId String? @map("client_id")
|
|
31
|
+
type String
|
|
32
|
+
category String
|
|
33
|
+
title String
|
|
34
|
+
body String
|
|
35
|
+
link String?
|
|
36
|
+
data Json @default("{}")
|
|
37
|
+
readAt DateTime? @map("read_at")
|
|
38
|
+
deletedAt DateTime? @map("deleted_at")
|
|
39
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
40
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
41
|
+
|
|
42
|
+
deliveries NotificationDelivery[]
|
|
43
|
+
|
|
44
|
+
// The inbox list (owner, not-deleted, newest first) and the unread-count
|
|
45
|
+
// badge (owner, not-deleted, unread) — both single-index scans.
|
|
46
|
+
@@index([userId, deletedAt, createdAt])
|
|
47
|
+
@@index([userId, deletedAt, readAt])
|
|
48
|
+
@@map("notifications")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Per-channel delivery tracking for one Notification: one row per transport
|
|
52
|
+
// channel the router fanned out to, carrying the channel lifecycle and the
|
|
53
|
+
// provider error when a send fails. `channel` is EMAIL | SMS | WHATSAPP |
|
|
54
|
+
// WEB_PUSH (CHECK in the migration).
|
|
55
|
+
//
|
|
56
|
+
// The lifecycle is QUEUED -> SENDING -> SENT | FAILED | DEAD (String + DB
|
|
57
|
+
// CHECK), and the two states beyond the obvious three each remove a way to send
|
|
58
|
+
// somebody a duplicate paid message:
|
|
59
|
+
//
|
|
60
|
+
// - SENDING is the CLAIM. A dispatcher moves the row out of QUEUED with one
|
|
61
|
+
// conditional UPDATE and sends only if it moved exactly one row, so two
|
|
62
|
+
// dispatchers racing the same delivery make exactly one provider call.
|
|
63
|
+
// `attempts` is incremented by that same claim.
|
|
64
|
+
// - DEAD is TERMINAL, reached at the `attempts` ceiling. Without it a
|
|
65
|
+
// permanently invalid destination is a billed provider call on every sweep
|
|
66
|
+
// for the life of the row.
|
|
67
|
+
//
|
|
68
|
+
// `@@unique([notificationId, channel])` makes fan-out idempotent — re-dispatching
|
|
69
|
+
// a notification can never enqueue a duplicate delivery. Failures are isolated
|
|
70
|
+
// per row: one channel failing never blocks the inbox record or the others.
|
|
71
|
+
model NotificationDelivery {
|
|
72
|
+
id String @id @default(uuid())
|
|
73
|
+
notificationId String @map("notification_id")
|
|
74
|
+
channel String
|
|
75
|
+
status String @default("QUEUED")
|
|
76
|
+
error String?
|
|
77
|
+
/// Claims spent on this row. The retry ceiling counts these, not failures, so
|
|
78
|
+
/// a dispatcher that dies mid-send still spends one and cannot loop forever.
|
|
79
|
+
attempts Int @default(0)
|
|
80
|
+
sentAt DateTime? @map("sent_at")
|
|
81
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
82
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
83
|
+
|
|
84
|
+
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
|
85
|
+
|
|
86
|
+
@@unique([notificationId, channel])
|
|
87
|
+
// Serves the retry sweep: `status IN (…) AND updated_at < cutoff`. On
|
|
88
|
+
// `updatedAt`, because "stale" is a question about the last write and
|
|
89
|
+
// `createdAt` cannot answer it — a row re-queued a second ago has an ancient
|
|
90
|
+
// `created_at` and would read as stale again immediately.
|
|
91
|
+
@@index([status, updatedAt])
|
|
92
|
+
@@map("notification_deliveries")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A user's channel choices for ONE notification category: which of the
|
|
96
|
+
// transport channels may carry notifications of that category to them. One row
|
|
97
|
+
// per (user, category); NO row = the code-level defaults apply (email + web
|
|
98
|
+
// push on, the paid per-message channels off), so the table only ever stores
|
|
99
|
+
// EXPLICIT choices. The inbox is NOT a channel here — it is always on and not
|
|
100
|
+
// preference-gated.
|
|
101
|
+
//
|
|
102
|
+
// `channels` is a JSON map { "EMAIL": true, "SMS": false, … } rather than one
|
|
103
|
+
// boolean column per channel: the channel set is open-ended, and a new
|
|
104
|
+
// transport must not need a schema change — the open/closed rule the whole
|
|
105
|
+
// pipeline is built on. A stored row missing a channel key falls back to that
|
|
106
|
+
// channel's default, which is what makes the arrival of a channel a no-op for
|
|
107
|
+
// every existing row.
|
|
108
|
+
model NotificationPreference {
|
|
109
|
+
id String @id @default(uuid())
|
|
110
|
+
userId String @map("user_id")
|
|
111
|
+
category String
|
|
112
|
+
channels Json @default("{}")
|
|
113
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
114
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
115
|
+
|
|
116
|
+
@@unique([userId, category])
|
|
117
|
+
@@map("notification_preferences")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One browser push subscription registered by a user's device — the Web Push
|
|
121
|
+
// transport's destination, exactly as `PushManager.subscribe()` returns it: the
|
|
122
|
+
// push-service `endpoint` (globally unique per subscription, hence @unique —
|
|
123
|
+
// re-registering the same browser upserts rather than duplicates) plus the
|
|
124
|
+
// `p256dh`/`auth` client keys the RFC 8291 payload encryption needs. A user may
|
|
125
|
+
// hold many rows (one per browser/device); the transport sends to all of them.
|
|
126
|
+
// A subscription the push service reports GONE (404/410) is pruned by the
|
|
127
|
+
// transport, so the table self-heals as browsers expire subscriptions.
|
|
128
|
+
model PushSubscription {
|
|
129
|
+
id String @id @default(uuid())
|
|
130
|
+
userId String @map("user_id")
|
|
131
|
+
endpoint String @unique
|
|
132
|
+
p256dh String
|
|
133
|
+
auth String
|
|
134
|
+
// Free-form browser/device hint ("Chrome · Linux") for a device list.
|
|
135
|
+
userAgent String? @map("user_agent")
|
|
136
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
137
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
138
|
+
|
|
139
|
+
@@index([userId])
|
|
140
|
+
@@map("push_subscriptions")
|
|
141
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* global console, process */
|
|
3
|
+
/**
|
|
4
|
+
* Copy this package's Prisma model PARTIAL into the host's schema folder.
|
|
5
|
+
*
|
|
6
|
+
* node scripts/sync-notifications-schema.mjs [--check] [<host-schema-dir>]
|
|
7
|
+
*
|
|
8
|
+
* The partial is COPIED, never symlinked (the
|
|
9
|
+
* prisma-partials-are-copied-never-symlinked doctrine): `turbo prune` copies
|
|
10
|
+
* only what the dependency graph reaches, so a committed symlink dangles the
|
|
11
|
+
* moment the owning package is not a declared workspace dependency — and a
|
|
12
|
+
* SYMLINKED MIGRATION is silently skipped by Prisma (`readdir` +
|
|
13
|
+
* `isDirectory()` is false for a link), so a green deploy applies no schema.
|
|
14
|
+
*
|
|
15
|
+
* Only the schema partial. MIGRATIONS ARE NOT HANDLED HERE — the host discovers
|
|
16
|
+
* and copies them structurally, by looking for a `prisma/migrations` directory
|
|
17
|
+
* inside every installed `@12-apps/*` package (see
|
|
18
|
+
* packages/prisma/scripts/sync-prisma-plugins.mjs in this repo).
|
|
19
|
+
*
|
|
20
|
+
* The host package that owns the schema folder MUST also declare this package
|
|
21
|
+
* as a dependency, so the source of the copy is present in every build context.
|
|
22
|
+
*
|
|
23
|
+
* Default host path follows the future-pay layout
|
|
24
|
+
* (`packages/prisma/prisma/schema/`); another repo passes its own schema folder
|
|
25
|
+
* as the positional argument, or sets NOTIFICATIONS_HOST_SCHEMA_DIR.
|
|
26
|
+
*/
|
|
27
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const LABEL = '[notifications-schema]';
|
|
32
|
+
const RESYNC = 'pnpm --filter @12-apps/notifications prisma:sync';
|
|
33
|
+
|
|
34
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const SOURCE = join(HERE, '../prisma/notifications.prisma');
|
|
36
|
+
|
|
37
|
+
const args = process.argv.slice(2).filter((arg) => arg !== '--check');
|
|
38
|
+
const check = process.argv.includes('--check');
|
|
39
|
+
const hostSchemaDir =
|
|
40
|
+
args[0] ??
|
|
41
|
+
process.env.NOTIFICATIONS_HOST_SCHEMA_DIR ??
|
|
42
|
+
join(HERE, '../../prisma/prisma/schema');
|
|
43
|
+
const TARGET = join(hostSchemaDir, 'notifications.prisma');
|
|
44
|
+
|
|
45
|
+
const source = readFileSync(SOURCE, 'utf8');
|
|
46
|
+
const target = existsSync(TARGET) ? readFileSync(TARGET, 'utf8') : null;
|
|
47
|
+
|
|
48
|
+
if (source === target) {
|
|
49
|
+
console.log(`${LABEL} in sync.`);
|
|
50
|
+
} else if (check) {
|
|
51
|
+
console.error(
|
|
52
|
+
`${LABEL} DRIFT: ${TARGET} does not match the @12-apps/notifications partial. ` +
|
|
53
|
+
`Run "${RESYNC}" and commit the result.`,
|
|
54
|
+
);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
} else {
|
|
57
|
+
mkdirSync(hostSchemaDir, { recursive: true });
|
|
58
|
+
copyFileSync(SOURCE, TARGET);
|
|
59
|
+
console.log(`${LABEL} copied ${SOURCE} -> ${TARGET}.`);
|
|
60
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Thrown by `notify` when no generator is registered for the event type. */
|
|
2
|
+
export class UnknownNotificationTypeError extends Error {
|
|
3
|
+
readonly type: string;
|
|
4
|
+
constructor(type: string) {
|
|
5
|
+
super(`No notification generator registered for type "${type}".`);
|
|
6
|
+
this.name = 'UnknownNotificationTypeError';
|
|
7
|
+
this.type = type;
|
|
8
|
+
Object.setPrototypeOf(this, UnknownNotificationTypeError.prototype);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Thrown by `notify` when the recipient has no contact record in the host. */
|
|
13
|
+
export class UnknownNotificationRecipientError extends Error {
|
|
14
|
+
readonly userId: string;
|
|
15
|
+
constructor(userId: string) {
|
|
16
|
+
super(`notify(): unknown recipient user "${userId}".`);
|
|
17
|
+
this.name = 'UnknownNotificationRecipientError';
|
|
18
|
+
this.userId = userId;
|
|
19
|
+
Object.setPrototypeOf(this, UnknownNotificationRecipientError.prototype);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { UnknownNotificationTypeError } from './errors';
|
|
2
|
+
import type { NotificationGenerator } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generator registry: one {@link NotificationGenerator} per event `type`.
|
|
6
|
+
*
|
|
7
|
+
* INSTANCE state, not a module-level Map. future-pay's original was
|
|
8
|
+
* process-wide, which is what a package of loose functions forces; a factory
|
|
9
|
+
* config does not need it, and one registry per mount is what makes a test (or
|
|
10
|
+
* a second mount) able to hold its own set without clearing anyone else's.
|
|
11
|
+
* Domain modules still register from the OUTSIDE — that is the open/closed
|
|
12
|
+
* seam the whole pipeline is built on — either through the server config's
|
|
13
|
+
* `generators` array or through `registerGenerator` for a late arrival.
|
|
14
|
+
*/
|
|
15
|
+
export interface NotificationGeneratorRegistry {
|
|
16
|
+
/** Register (last-wins, so a re-import is idempotent). */
|
|
17
|
+
register<TPayload>(generator: NotificationGenerator<TPayload>): void;
|
|
18
|
+
/** Resolve for `type`, throwing {@link UnknownNotificationTypeError}. */
|
|
19
|
+
resolve(type: string): NotificationGenerator<never>;
|
|
20
|
+
/** Whether a generator is registered for `type` (emit-site guard). */
|
|
21
|
+
has(type: string): boolean;
|
|
22
|
+
/** Every registered type, for diagnostics. */
|
|
23
|
+
types(): string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createGeneratorRegistry(
|
|
27
|
+
initial: readonly NotificationGenerator<never>[] = [],
|
|
28
|
+
): NotificationGeneratorRegistry {
|
|
29
|
+
const generators = new Map<string, NotificationGenerator<never>>();
|
|
30
|
+
const registry: NotificationGeneratorRegistry = {
|
|
31
|
+
register(generator) {
|
|
32
|
+
generators.set(generator.type, generator as NotificationGenerator<never>);
|
|
33
|
+
},
|
|
34
|
+
resolve(type) {
|
|
35
|
+
const generator = generators.get(type);
|
|
36
|
+
if (!generator) throw new UnknownNotificationTypeError(type);
|
|
37
|
+
return generator;
|
|
38
|
+
},
|
|
39
|
+
has: (type) => generators.has(type),
|
|
40
|
+
types: () => [...generators.keys()],
|
|
41
|
+
};
|
|
42
|
+
for (const generator of initial) registry.register(generator);
|
|
43
|
+
return registry;
|
|
44
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { Context } from 'hono';
|
|
3
|
+
|
|
4
|
+
import { messagesOf } from '../messages';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createApiNotifications,
|
|
8
|
+
type ApiNotifications,
|
|
9
|
+
type NotificationsServerConfig,
|
|
10
|
+
} from '../server/create-api-notifications';
|
|
11
|
+
import type { NotificationsActor } from '../server/context';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `@12-apps/notifications/hono` — the account notification endpoints as a
|
|
15
|
+
* mountable router.
|
|
16
|
+
*
|
|
17
|
+
* The framework-neutral descriptors in `/server` are the contract; this is the
|
|
18
|
+
* adapter for the framework we happen to use, behind its own subpath with
|
|
19
|
+
* `hono` as an OPTIONAL peer (the report-builder precedent — a host on Express,
|
|
20
|
+
* or one that only wants the React surface, never resolves Hono).
|
|
21
|
+
*
|
|
22
|
+
* A host writes:
|
|
23
|
+
*
|
|
24
|
+
* const notifications = notificationsRouter({ …config, resolveActor });
|
|
25
|
+
* app.route('/api/account', notifications.router);
|
|
26
|
+
*
|
|
27
|
+
* and keeps what is genuinely its own: who the caller is. Everything after
|
|
28
|
+
* that — parsing, status codes, the envelope, the pt-BR copy — is the
|
|
29
|
+
* package's.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the caller. Returning `null` means unauthenticated, which answers 401
|
|
34
|
+
* before any handler runs.
|
|
35
|
+
*
|
|
36
|
+
* Note the 401 is self-guarded HERE rather than assumed from middleware: these
|
|
37
|
+
* paths sit under an API prefix that a host's page middleware typically does not
|
|
38
|
+
* match, and an unauthenticated inbox read that fell through would answer
|
|
39
|
+
* somebody else's rows or none at all — both worse than a 401.
|
|
40
|
+
*/
|
|
41
|
+
export type ResolveNotificationsActor = (
|
|
42
|
+
c: Context,
|
|
43
|
+
) => Promise<NotificationsActor | null> | NotificationsActor | null;
|
|
44
|
+
|
|
45
|
+
export interface NotificationsHonoConfig extends NotificationsServerConfig {
|
|
46
|
+
resolveActor: ResolveNotificationsActor;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface NotificationsHono extends ApiNotifications {
|
|
50
|
+
router: Hono;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reads the JSON body, tolerating an absent or malformed one — and only when
|
|
55
|
+
* the caller SAID it was JSON.
|
|
56
|
+
*
|
|
57
|
+
* The content-type check is a CSRF speed bump, not a defence (see ADOPTING rule
|
|
58
|
+
* 13, which names the actual one). `text/plain`, `multipart/form-data` and
|
|
59
|
+
* `application/x-www-form-urlencoded` are the three types a cross-site `fetch`
|
|
60
|
+
* or a plain `<form>` can send with NO preflight, so parsing a body regardless
|
|
61
|
+
* of its type is what lets such a request reach these handlers at all. Refusing
|
|
62
|
+
* them means a cross-site write has to earn a preflight first, which the browser
|
|
63
|
+
* will then refuse on its own. The price is nil: every client of this surface,
|
|
64
|
+
* the packaged one included, sends `application/json`.
|
|
65
|
+
*/
|
|
66
|
+
function saysJson(c: Context): boolean {
|
|
67
|
+
const type = c.req.header('content-type');
|
|
68
|
+
if (!type) return false;
|
|
69
|
+
const mime = (type.split(';')[0] ?? '').trim().toLowerCase();
|
|
70
|
+
return mime === 'application/json' || mime.endsWith('+json');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readBody(c: Context): Promise<unknown> {
|
|
74
|
+
if (c.req.method === 'GET') return undefined;
|
|
75
|
+
if (!saysJson(c)) return undefined;
|
|
76
|
+
try {
|
|
77
|
+
return await c.req.json();
|
|
78
|
+
} catch {
|
|
79
|
+
// A malformed body is the caller's error; the handler's own validation
|
|
80
|
+
// reports it far better than a parse failure would.
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function notificationsRouter(config: NotificationsHonoConfig): NotificationsHono {
|
|
86
|
+
const api = createApiNotifications(config);
|
|
87
|
+
const messages = messagesOf(config);
|
|
88
|
+
const router = new Hono();
|
|
89
|
+
|
|
90
|
+
// Mounted IN DESCRIPTOR ORDER, which any adapter must preserve. Hono resolves
|
|
91
|
+
// by registration order, so a host route shaped `/notifications/:id` under the
|
|
92
|
+
// same prefix must be registered AFTER this router or it captures
|
|
93
|
+
// `/notifications/unread-count`.
|
|
94
|
+
for (const route of api.routes) {
|
|
95
|
+
const handler = async (c: Context): Promise<Response> => {
|
|
96
|
+
const actor = await config.resolveActor(c);
|
|
97
|
+
if (!actor) return c.json({ error: messages.unauthenticated }, 401);
|
|
98
|
+
|
|
99
|
+
const response = await route.handle({
|
|
100
|
+
actor,
|
|
101
|
+
params: c.req.param() as Record<string, string | undefined>,
|
|
102
|
+
query: c.req.query() as Record<string, string | undefined>,
|
|
103
|
+
body: await readBody(c),
|
|
104
|
+
headers: { 'user-agent': c.req.header('user-agent') },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// A handler that chose NO body means exactly that (204).
|
|
108
|
+
if (response.body === undefined) return c.body(null, response.status as 204);
|
|
109
|
+
// The status travels with the body the handler chose; the adapter never
|
|
110
|
+
// reinterprets either.
|
|
111
|
+
return c.json(response.body as Record<string, unknown>, response.status as 200);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (route.method === 'GET') router.get(route.path, handler);
|
|
115
|
+
else if (route.method === 'POST') router.post(route.path, handler);
|
|
116
|
+
else if (route.method === 'PUT') router.put(route.path, handler);
|
|
117
|
+
else router.delete(route.path, handler);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { ...api, router };
|
|
121
|
+
}
|