@marktiderman/genesis-notifications 0.0.0-stage → 0.1.1

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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -2
  3. package/dist/blocks.d.ts +10 -0
  4. package/dist/blocks.js +35 -0
  5. package/dist/blocks.js.map +1 -0
  6. package/dist/chunk-2BG336II.js +124 -0
  7. package/dist/chunk-2BG336II.js.map +1 -0
  8. package/dist/chunk-7PHA3WVG.js +51 -0
  9. package/dist/chunk-7PHA3WVG.js.map +1 -0
  10. package/dist/chunk-EWZB35V6.js +113 -0
  11. package/dist/chunk-EWZB35V6.js.map +1 -0
  12. package/dist/chunk-UXNWI7OS.js +118 -0
  13. package/dist/chunk-UXNWI7OS.js.map +1 -0
  14. package/dist/email-render-CID_pXDy.d.ts +53 -0
  15. package/dist/email.d.ts +46 -0
  16. package/dist/email.js +64 -0
  17. package/dist/email.js.map +1 -0
  18. package/dist/index.d.ts +26 -0
  19. package/dist/index.js +151 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/native.d.ts +92 -0
  22. package/dist/native.js +170 -0
  23. package/dist/native.js.map +1 -0
  24. package/dist/react.d.ts +98 -0
  25. package/dist/react.js +4 -0
  26. package/dist/react.js.map +1 -0
  27. package/dist/runner.d.ts +174 -0
  28. package/dist/runner.js +3 -0
  29. package/dist/runner.js.map +1 -0
  30. package/dist/server.d.ts +658 -0
  31. package/dist/server.js +995 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/supabase.d.ts +118 -0
  34. package/dist/supabase.js +3 -0
  35. package/dist/supabase.js.map +1 -0
  36. package/dist/types-BilguCPW.d.ts +332 -0
  37. package/dist/web.d.ts +79 -0
  38. package/dist/web.js +233 -0
  39. package/dist/web.js.map +1 -0
  40. package/functions/genesis-notifications/index.ts +60 -0
  41. package/functions/genesis-notifications/notifications.ts +18 -0
  42. package/migrations/0001_genesis_notifications.sql +2057 -0
  43. package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
  44. package/package.json +191 -3
  45. package/src/blocks.ts +42 -0
  46. package/src/core/index.ts +43 -0
  47. package/src/core/registry.ts +354 -0
  48. package/src/core/types.ts +90 -0
  49. package/src/core/validate.ts +73 -0
  50. package/src/email/index.tsx +142 -0
  51. package/src/index.ts +6 -0
  52. package/src/native/index.tsx +283 -0
  53. package/src/react/index.tsx +226 -0
  54. package/src/runner/index.ts +299 -0
  55. package/src/server/crypto.ts +44 -0
  56. package/src/server/dns.ts +106 -0
  57. package/src/server/email-render.ts +90 -0
  58. package/src/server/errors.ts +61 -0
  59. package/src/server/expo.ts +133 -0
  60. package/src/server/handler.ts +127 -0
  61. package/src/server/index.ts +57 -0
  62. package/src/server/notify.ts +100 -0
  63. package/src/server/rate.ts +47 -0
  64. package/src/server/resend.ts +143 -0
  65. package/src/server/runner.ts +524 -0
  66. package/src/server/secret-links.ts +53 -0
  67. package/src/server/store.ts +150 -0
  68. package/src/server/unsubscribe.ts +78 -0
  69. package/src/server/webhook.ts +55 -0
  70. package/src/supabase/index.ts +210 -0
  71. package/src/web/index.tsx +428 -0
@@ -0,0 +1,2057 @@
1
+ -- Genesis notifications: one notifier for every domain. A domain (chat,
2
+ -- forms, sites, support) decides who is told and what; this decides how and
3
+ -- when: the channel (the in-app feed, Expo push, email), quiet hours,
4
+ -- preferences by category and channel (with tenant defaults; critical types
5
+ -- always on), delay-then-skip-if-read for email, dedupe, retries, delivery
6
+ -- receipts and pruning dead tokens. It never reads a domain's tables.
7
+ --
8
+ -- The TypeScript half is @marktiderman/genesis-notifications: `notify()` (a
9
+ -- wrapper over genesis_notify), the runner that sends, the Expo and Resend
10
+ -- providers, the feed hooks and screens.
11
+ --
12
+ -- Use: copy this file into your app's `supabase/migrations/` under a new
13
+ -- timestamped name, and its pgTAP test into `supabase/tests/database/`. Never
14
+ -- edit it once applied; change it with a new migration.
15
+ --
16
+ -- Push ownership moves here from @marktiderman/genesis-supabase's migration
17
+ -- 0005 (genesis_push). This migration works with or without 0005:
18
+ -- - With 0005 applied, it adopts push_devices, notification_preferences and
19
+ -- push_receipts as they are and only adds to them (a `channels` column on
20
+ -- notification_preferences, a `delivery_id` on push_receipts). No row is
21
+ -- touched. The sender-side functions of 0005 (genesis_push_targets, …)
22
+ -- and the genesis-push-send function stay but are superseded by the runner
23
+ -- here; stop calling them once this runs.
24
+ -- - Without 0005, it creates those three tables and their policies exactly
25
+ -- as 0005 does, so the push block works.
26
+ -- Either way it (re)defines the device-side functions the phone calls, with
27
+ -- two security rules 0005 didn't have: genesis_push_register moves a token
28
+ -- another login holds only when the caller registers it with the same
29
+ -- install id (the same phone), and records the move in push_device_moves
30
+ -- (knowing a token never takes it); genesis_push_forget still works signed
31
+ -- out with the token as proof, deletes only that exact token's row, and
32
+ -- answers the same whether or not there was one.
33
+ --
34
+ -- Tables (public, row level security on):
35
+ -- notification_types mirrored from code (defineNotifications), never edited by hand.
36
+ -- notifications the in-app feed, and the outbox: one row per recipient,
37
+ -- unique on (recipient, type, tenant, idempotency_key). A person reads only
38
+ -- their own rows.
39
+ -- notification_deliveries one row per notification and channel (push, email): status,
40
+ -- attempts, send_after, the provider's id, an error code. Claimed
41
+ -- with SKIP LOCKED. Admins read the log columns of their tenant's.
42
+ -- notification_tenant_defaults a tenant's default for a category and channel.
43
+ -- notification_senders a tenant's email identity (from name, reply-to, own domain).
44
+ -- notification_suppressions addresses not to email (bounces, complaints, unsubscribes of
45
+ -- people with no login), by SHA-256 of the address only.
46
+ --
47
+ -- Who can do what (granted and revoked by name, never in bulk):
48
+ -- authenticated read their own feed; genesis_notifications_mark / _mark_all (seen, read,
49
+ -- archived); genesis_notifications_set_preference; and, gated by
50
+ -- private.genesis_notifications_admin, the admin functions (tenant defaults,
51
+ -- sender identity, send a test to yourself, lift a suppression).
52
+ -- service_role genesis_notify, genesis_notifications_cancel, the runner's claim/settle
53
+ -- functions, receipts, webhooks, unsubscribe, erase, prune and the type mirror.
54
+ --
55
+ -- Admins: private.genesis_notifications_admin(user uuid, tenant uuid) says who may see a
56
+ -- tenant's delivery log (tenant null: the whole app, and the suppression list). It answers
57
+ -- false until you replace it in a migration of your own, after this one.
58
+ --
59
+ -- Secrets: `data` never holds one (genesis_notify refuses keys such as `token` or a link
60
+ -- with `?token=`). A secret link is built at send time from a reference. A token that
61
+ -- exists only hashed in your tables (an invite) is passed as `p_secret`: it is encrypted
62
+ -- onto the email's delivery row with a key kept in Vault, handed to the runner only when
63
+ -- it claims the email, and cleared once the email is sent, skipped or failed.
64
+ --
65
+ -- Realtime: a new notification (and a read or archive) sends a broadcast with ids only on
66
+ -- the private channel `user:<id>`; the app refetches. No Postgres Changes.
67
+ --
68
+ -- Erasure: genesis_notifications_erase(user, email, ref) deletes a person's notifications
69
+ -- (with their deliveries), devices, receipts and preferences, logins or not. Deleting a
70
+ -- login also cascades. Delivery logs are kept as long as genesis_notifications_prune says.
71
+ --
72
+ -- Verify: the pgTAP test beside this file (`tests/0001_genesis_notifications.test.sql`).
73
+
74
+ create schema if not exists private;
75
+ create extension if not exists pgcrypto with schema extensions;
76
+
77
+ -- 0005's tables, created only where 0005 never ran ---------------------------
78
+
79
+ do $adopt$
80
+ begin
81
+ if to_regclass('public.push_devices') is not null then
82
+ return;
83
+ end if;
84
+
85
+ create table public.push_devices (
86
+ id uuid primary key default gen_random_uuid(),
87
+ user_id uuid not null references auth.users (id) on delete cascade,
88
+ token text not null unique
89
+ constraint push_devices_token_format check (token ~ '^Expo(nent)?PushToken\[[A-Za-z0-9_-]{1,200}\]$'),
90
+ platform text not null
91
+ constraint push_devices_platform check (platform in ('ios', 'android')),
92
+ app_version text
93
+ constraint push_devices_app_version_length check (char_length(app_version) <= 64),
94
+ created_at timestamptz not null default now(),
95
+ last_seen_at timestamptz not null default now()
96
+ );
97
+ create index push_devices_user_id on public.push_devices (user_id, last_seen_at desc);
98
+ comment on table public.push_devices is
99
+ 'Expo push tokens of each login''s devices. The owner reads and deletes their own; written through genesis_push_register. Genesis notifications.';
100
+ alter table public.push_devices enable row level security;
101
+ create policy push_devices_owner_read on public.push_devices
102
+ for select to authenticated using (user_id = (select auth.uid()));
103
+ create policy push_devices_owner_delete on public.push_devices
104
+ for delete to authenticated using (user_id = (select auth.uid()));
105
+ revoke all on table public.push_devices from public, anon, authenticated;
106
+ grant select, delete on table public.push_devices to authenticated;
107
+ grant select, insert, update, delete on table public.push_devices to service_role;
108
+
109
+ create table public.notification_preferences (
110
+ user_id uuid primary key default auth.uid() references auth.users (id) on delete cascade,
111
+ categories jsonb not null default '{}'::jsonb
112
+ constraint notification_preferences_categories_shape check (
113
+ jsonb_typeof(categories) = 'object'
114
+ and not jsonb_path_exists(categories, '$.* ? (@.type() != "boolean")')
115
+ and pg_column_size(categories) <= 8192
116
+ ),
117
+ quiet_hours_enabled boolean not null default false,
118
+ quiet_hours_start time not null default '22:00',
119
+ quiet_hours_end time not null default '07:00',
120
+ time_zone text
121
+ constraint notification_preferences_time_zone_length check (char_length(time_zone) between 1 and 64),
122
+ hide_previews boolean not null default false,
123
+ updated_at timestamptz not null default now(),
124
+ constraint notification_preferences_quiet_hours_zone check (not quiet_hours_enabled or time_zone is not null)
125
+ );
126
+ comment on table public.notification_preferences is
127
+ 'Per-person notification choices, quiet hours and lock-screen privacy. The owner reads and writes their own row. Genesis notifications.';
128
+
129
+ execute $f$
130
+ create function private.genesis_notification_preferences_check()
131
+ returns trigger
132
+ language plpgsql
133
+ set search_path = ''
134
+ as $$
135
+ begin
136
+ if new.time_zone is not null and not exists (select 1 from pg_catalog.pg_timezone_names z where z.name = new.time_zone) then
137
+ raise exception 'notification_preferences: unknown time zone' using errcode = '22023';
138
+ end if;
139
+ if exists (select 1 from pg_catalog.jsonb_object_keys(new.categories) k where k !~ '^[a-z0-9][a-z0-9._-]{0,63}$') then
140
+ raise exception 'notification_preferences: a category id must be lower case letters, digits, ".", "_" or "-"' using errcode = '22023';
141
+ end if;
142
+ new.updated_at := now();
143
+ return new;
144
+ end;
145
+ $$
146
+ $f$;
147
+ create trigger genesis_notification_preferences_check
148
+ before insert or update on public.notification_preferences
149
+ for each row execute function private.genesis_notification_preferences_check();
150
+ revoke execute on function private.genesis_notification_preferences_check() from public, anon, authenticated;
151
+
152
+ alter table public.notification_preferences enable row level security;
153
+ create policy notification_preferences_owner_read on public.notification_preferences
154
+ for select to authenticated using (user_id = (select auth.uid()));
155
+ create policy notification_preferences_owner_insert on public.notification_preferences
156
+ for insert to authenticated with check (user_id = (select auth.uid()));
157
+ create policy notification_preferences_owner_update on public.notification_preferences
158
+ for update to authenticated using (user_id = (select auth.uid())) with check (user_id = (select auth.uid()));
159
+ create policy notification_preferences_owner_delete on public.notification_preferences
160
+ for delete to authenticated using (user_id = (select auth.uid()));
161
+ revoke all on table public.notification_preferences from public, anon, authenticated;
162
+ grant select, insert, update, delete on table public.notification_preferences to authenticated;
163
+ grant select, insert, update, delete on table public.notification_preferences to service_role;
164
+
165
+ create table public.push_receipts (
166
+ id bigint generated always as identity primary key,
167
+ user_id uuid not null references auth.users (id) on delete cascade,
168
+ device_id uuid references public.push_devices (id) on delete set null,
169
+ category text not null
170
+ constraint push_receipts_category_format check (category ~ '^[a-z0-9][a-z0-9._-]{0,63}$'),
171
+ ticket_id text
172
+ constraint push_receipts_ticket_id_length check (char_length(ticket_id) <= 128),
173
+ status text not null
174
+ constraint push_receipts_status check (status in ('sent', 'delivered', 'failed', 'unknown')),
175
+ error text
176
+ constraint push_receipts_error_format check (error ~ '^[A-Za-z_]{1,64}$'),
177
+ created_at timestamptz not null default now(),
178
+ check_after timestamptz,
179
+ checked_at timestamptz,
180
+ attempts integer not null default 0,
181
+ constraint push_receipts_sent_has_ticket check (status <> 'sent' or ticket_id is not null)
182
+ );
183
+ create index push_receipts_due on public.push_receipts (check_after) where status = 'sent';
184
+ create index push_receipts_user_id on public.push_receipts (user_id, created_at desc);
185
+ create index push_receipts_created_at on public.push_receipts (created_at);
186
+ comment on table public.push_receipts is
187
+ 'What was handed to Expo''s push service and what became of it (no content). The owner reads their own. Genesis notifications.';
188
+ alter table public.push_receipts enable row level security;
189
+ create policy push_receipts_owner_read on public.push_receipts
190
+ for select to authenticated using (user_id = (select auth.uid()));
191
+ revoke all on table public.push_receipts from public, anon, authenticated;
192
+ grant select on table public.push_receipts to authenticated;
193
+ grant select, insert, update, delete on table public.push_receipts to service_role;
194
+
195
+ end;
196
+ $adopt$;
197
+
198
+ -- Preferences by category and channel ----------------------------------------
199
+
200
+ -- Category id -> channel -> on. A pair not listed has its default (the tenant's, then the type's).
201
+ alter table public.notification_preferences
202
+ add column channels jsonb not null default '{}'::jsonb
203
+ constraint notification_preferences_channels_shape check (
204
+ jsonb_typeof(channels) = 'object'
205
+ and not jsonb_path_exists(channels, '$.* ? (@.type() != "object")')
206
+ and not jsonb_path_exists(channels, '$.*.* ? (@.type() != "boolean")')
207
+ and pg_column_size(channels) <= 16384
208
+ );
209
+
210
+ -- Tenant id -> category id -> channel -> on: a choice for one tenant only (an unsubscribe
211
+ -- from one tenant's mail; the nil uuid for mail sent for no tenant). It wins over `channels` for that tenant; a choice made in
212
+ -- Settings (genesis_notifications_set_preference) clears it for that category and channel.
213
+ alter table public.notification_preferences
214
+ add column tenant_channels jsonb not null default '{}'::jsonb
215
+ constraint notification_preferences_tenant_channels_shape check (
216
+ jsonb_typeof(tenant_channels) = 'object'
217
+ and not jsonb_path_exists(tenant_channels, '$.* ? (@.type() != "object")')
218
+ and not jsonb_path_exists(tenant_channels, '$.*.* ? (@.type() != "object")')
219
+ and not jsonb_path_exists(tenant_channels, '$.*.*.* ? (@.type() != "boolean")')
220
+ and pg_column_size(tenant_channels) <= 16384
221
+ );
222
+
223
+ create function private.genesis_notifications_channels_check()
224
+ returns trigger
225
+ language plpgsql
226
+ set search_path = ''
227
+ as $$
228
+ begin
229
+ if exists (
230
+ select 1
231
+ from pg_catalog.jsonb_each(new.channels) c, pg_catalog.jsonb_object_keys(c.value) ch
232
+ where c.key !~ '^[a-z0-9][a-z0-9._-]{0,63}$' or ch not in ('in_app', 'push', 'email')
233
+ ) then
234
+ raise exception 'notification_preferences: channels are { category: { in_app | push | email: boolean } }' using errcode = '22023';
235
+ end if;
236
+ if exists (
237
+ select 1
238
+ from pg_catalog.jsonb_each(new.tenant_channels) t, pg_catalog.jsonb_each(t.value) c, pg_catalog.jsonb_object_keys(c.value) ch
239
+ where t.key !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
240
+ or c.key !~ '^[a-z0-9][a-z0-9._-]{0,63}$' or ch not in ('in_app', 'push', 'email')
241
+ ) then
242
+ raise exception 'notification_preferences: tenant_channels are { tenant id: { category: { in_app | push | email: boolean } } }' using errcode = '22023';
243
+ end if;
244
+ return new;
245
+ end;
246
+ $$;
247
+
248
+ create trigger genesis_notifications_channels_check
249
+ before insert or update of channels, tenant_channels on public.notification_preferences
250
+ for each row execute function private.genesis_notifications_channels_check();
251
+
252
+ -- Types, mirrored from code -------------------------------------------------
253
+
254
+ create table public.notification_types (
255
+ key text primary key
256
+ constraint notification_types_key_format check (key ~ '^[a-z0-9][a-z0-9._-]{0,63}$'),
257
+ category text not null
258
+ constraint notification_types_category_format check (category ~ '^[a-z0-9][a-z0-9._-]{0,63}$'),
259
+ -- Channel -> on by default. A channel not listed is never used for this type.
260
+ channels jsonb not null
261
+ constraint notification_types_channels_shape check (
262
+ jsonb_typeof(channels) = 'object'
263
+ and channels <> '{}'::jsonb
264
+ and not jsonb_path_exists(channels, '$.* ? (@.type() != "boolean")')
265
+ and not jsonb_path_exists(channels, '$.keyvalue() ? (@.key != "in_app" && @.key != "push" && @.key != "email")')
266
+ ),
267
+ critical boolean not null default false,
268
+ transactional boolean not null default false,
269
+ email_delay_seconds integer not null default 0
270
+ constraint notification_types_email_delay check (email_delay_seconds between 0 and 604800),
271
+ secret_link boolean not null default false,
272
+ push_privacy text not null default 'default'
273
+ constraint notification_types_push_privacy check (push_privacy in ('default', 'generic')),
274
+ ios_category text
275
+ constraint notification_types_ios_category check (ios_category ~ '^[A-Za-z0-9_]{1,64}$'),
276
+ retired_at timestamptz,
277
+ updated_at timestamptz not null default now()
278
+ );
279
+
280
+ comment on table public.notification_types is
281
+ 'Notification types, mirrored from the app''s defineNotifications by genesis_notifications_sync_types; never edited by hand, never deleted (retired_at). Genesis notifications.';
282
+
283
+ alter table public.notification_types enable row level security;
284
+ create policy notification_types_read on public.notification_types
285
+ for select to authenticated using (true);
286
+ revoke all on table public.notification_types from public, anon, authenticated;
287
+ grant select on table public.notification_types to authenticated;
288
+ grant select, insert, update, delete on table public.notification_types to service_role;
289
+
290
+ -- The admin's own test notification (Admin -> Send a test).
291
+ insert into public.notification_types (key, category, channels, critical, transactional)
292
+ values ('genesis.test', 'genesis', '{"in_app": true, "push": true, "email": true}', true, true);
293
+
294
+ -- Devices: a token moves between logins only from its own device ------------
295
+
296
+ -- Which install of the app holds a token: a random id the phone keeps across
297
+ -- sign-ins. A token moves to another login only when that login registers it
298
+ -- with the same install id (the same phone).
299
+ alter table public.push_devices
300
+ add column installation_id uuid,
301
+ -- Registered before install ids existed (a row 0005 left): the first register that brings an
302
+ -- install id may adopt it, once. Only rows already here when this migration runs; a row made
303
+ -- since (the three-argument register included) never is, so knowing its token never takes it.
304
+ add column adoptable boolean not null default false;
305
+ update public.push_devices set adoptable = true where installation_id is null;
306
+
307
+ -- Every time a token moved from one login to another. Read by the service role only.
308
+ create table public.push_device_moves (
309
+ id bigint generated always as identity primary key,
310
+ -- SHA-256 of the token: the move is traceable without keeping the credential twice.
311
+ token_hash text not null,
312
+ from_user_id uuid references auth.users (id) on delete set null,
313
+ to_user_id uuid references auth.users (id) on delete set null,
314
+ installation_id uuid,
315
+ moved_at timestamptz not null default now()
316
+ );
317
+ create index push_device_moves_moved_at on public.push_device_moves (moved_at);
318
+
319
+ comment on table public.push_device_moves is
320
+ 'Audit of push tokens that moved between logins on one device (same install id). Service role only. Genesis notifications.';
321
+
322
+ alter table public.push_device_moves enable row level security;
323
+ revoke all on table public.push_device_moves from public, anon, authenticated;
324
+ grant select, delete on table public.push_device_moves to service_role;
325
+
326
+ -- Keeps this device's token against the caller and returns its device id.
327
+ -- A token another login holds moves to the caller only when the caller
328
+ -- registers it with the install id stored with it (the same phone, where the
329
+ -- other login didn't sign out cleanly); the move is recorded in
330
+ -- push_device_moves. Otherwise it is refused (42501) and nothing changes:
331
+ -- knowing a token never takes it. One exception, so an upgrade from 0005
332
+ -- strands no phone: a row 0005 left with no install id (only those, never
333
+ -- one registered since, through the three-argument form or otherwise) is
334
+ -- adopted by the first register that brings one, and that move is recorded too. The caller's ten most recently seen devices
335
+ -- are kept.
336
+ create function public.genesis_push_register(p_token text, p_platform text, p_app_version text, p_installation_id uuid)
337
+ returns uuid
338
+ language plpgsql
339
+ security definer
340
+ set search_path = ''
341
+ as $$
342
+ declare
343
+ me uuid := (select auth.uid());
344
+ holder public.push_devices;
345
+ device uuid;
346
+ begin
347
+ if me is null then
348
+ raise exception 'genesis_push_register: sign in first' using errcode = '42501';
349
+ end if;
350
+ if p_token is null or p_platform is null then
351
+ raise exception 'genesis_push_register: malformed token, platform or app version' using errcode = '23514';
352
+ end if;
353
+ select * into holder from public.push_devices d where d.token = p_token for update;
354
+ if found and holder.user_id <> me then
355
+ if p_installation_id is null
356
+ or (holder.installation_id is null and not holder.adoptable)
357
+ or (holder.installation_id is not null and holder.installation_id <> p_installation_id) then
358
+ raise exception 'genesis_push_register: this token is registered to another sign-in' using errcode = '42501';
359
+ end if;
360
+ delete from public.push_devices d where d.id = holder.id;
361
+ insert into public.push_device_moves (token_hash, from_user_id, to_user_id, installation_id)
362
+ values (encode(sha256(convert_to(p_token, 'UTF8')), 'hex'), holder.user_id, me, p_installation_id);
363
+ end if;
364
+ insert into public.push_devices as d (user_id, token, platform, app_version, installation_id)
365
+ values (me, p_token, p_platform, p_app_version, p_installation_id)
366
+ on conflict (token) do update
367
+ set platform = excluded.platform, app_version = excluded.app_version, last_seen_at = now(),
368
+ installation_id = coalesce(excluded.installation_id, d.installation_id),
369
+ adoptable = d.adoptable and coalesce(excluded.installation_id, d.installation_id) is null
370
+ where d.user_id = me
371
+ returning d.id into device;
372
+ if device is null then
373
+ raise exception 'genesis_push_register: the token changed hands; try again' using errcode = '40001';
374
+ end if;
375
+ delete from public.push_devices d
376
+ where d.user_id = me
377
+ and d.id not in (
378
+ select k.id from public.push_devices k where k.user_id = me
379
+ order by k.id = device desc, k.last_seen_at desc, k.id limit 10
380
+ );
381
+ return device;
382
+ exception
383
+ when check_violation or not_null_violation then
384
+ -- Raised afresh so the error (and the server log) never carries the failing row, token and all.
385
+ raise exception 'genesis_push_register: malformed token, platform or app version' using errcode = '23514';
386
+ end;
387
+ $$;
388
+
389
+ -- The three-argument form (0005's, and genesis-native's supabasePushBackend):
390
+ -- now without an install id, so it never moves a token another login holds.
391
+ create or replace function public.genesis_push_register(p_token text, p_platform text, p_app_version text default null)
392
+ returns uuid
393
+ language sql
394
+ security definer
395
+ set search_path = ''
396
+ as $$
397
+ select public.genesis_push_register(p_token, p_platform, p_app_version, null::uuid);
398
+ $$;
399
+
400
+ -- Forgets a token, whoever holds it: holding the token is the proof, so a
401
+ -- phone already signed out still stops getting the last person's pushes. It
402
+ -- deletes only the row with exactly that token, and answers true whether or
403
+ -- not there was one, so it tells a caller nothing about anyone's tokens.
404
+ create or replace function public.genesis_push_forget(p_token text)
405
+ returns boolean
406
+ language plpgsql
407
+ security definer
408
+ set search_path = ''
409
+ as $$
410
+ begin
411
+ if p_token is not null and p_token ~ '^Expo(nent)?PushToken\[[A-Za-z0-9_-]{1,200}\]$' then
412
+ delete from public.push_devices d where d.token = p_token;
413
+ end if;
414
+ return true;
415
+ end;
416
+ $$;
417
+
418
+ -- Senders, tenant defaults, suppressions -------------------------------------
419
+
420
+ -- A tenant's email identity. Without a verified domain of its own, mail goes
421
+ -- from the app's shared domain with the tenant's name as the from name and
422
+ -- its reply-to; with one, from that domain.
423
+ create table public.notification_senders (
424
+ tenant_id uuid primary key,
425
+ from_name text
426
+ constraint notification_senders_from_name check (from_name ~ '^[^"<>\r\n]{1,100}$'),
427
+ reply_to text
428
+ constraint notification_senders_reply_to check (reply_to ~ '^[^\s@<>,;:"()\[\]\\]+@[^\s@<>,;:"()\[\]\\]+\.[^\s@<>,;:"()\[\]\\]+$' and char_length(reply_to) <= 254),
429
+ -- The tenant's own sending domain (mail.example.com), used once verified.
430
+ domain text
431
+ constraint notification_senders_domain check (domain ~ '^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$'),
432
+ domain_status text not null default 'none'
433
+ constraint notification_senders_domain_status check (domain_status in ('none', 'pending', 'verified', 'failed')),
434
+ -- The ownership challenge for `domain`, made afresh whenever the domain changes: the tenant
435
+ -- proves the domain is theirs with a TXT record `_genesis-notifications.<domain>` holding
436
+ -- `genesis-notifications-verify=<token>` (checkSenderDomain reads it).
437
+ domain_token text
438
+ constraint notification_senders_domain_token check (domain_token ~ '^[0-9a-f]{32}$'),
439
+ -- What the last check found: { spf, dkim, dmarc, ownership: boolean }.
440
+ dns jsonb,
441
+ checked_at timestamptz,
442
+ -- The tenant's default brand: { name, logoUrl, color, url }.
443
+ brand jsonb
444
+ constraint notification_senders_brand check (brand is null or (jsonb_typeof(brand) = 'object' and pg_column_size(brand) <= 4096)),
445
+ updated_at timestamptz not null default now()
446
+ );
447
+
448
+ comment on table public.notification_senders is
449
+ 'Each tenant''s email identity: from name, reply-to, an own sending domain once verified, and its brand. Genesis notifications.';
450
+
451
+ -- A domain is verified for one tenant at most: another tenant can't send as it.
452
+ create unique index notification_senders_verified_domain on public.notification_senders (domain) where domain_status = 'verified';
453
+
454
+ -- A tenant's default for one category and channel, before the person chooses.
455
+ create table public.notification_tenant_defaults (
456
+ tenant_id uuid not null,
457
+ category text not null
458
+ constraint notification_tenant_defaults_category check (category ~ '^[a-z0-9][a-z0-9._-]{0,63}$'),
459
+ channel text not null
460
+ constraint notification_tenant_defaults_channel check (channel in ('in_app', 'push', 'email')),
461
+ enabled boolean not null,
462
+ updated_at timestamptz not null default now(),
463
+ primary key (tenant_id, category, channel)
464
+ );
465
+
466
+ comment on table public.notification_tenant_defaults is
467
+ 'A tenant''s default for a category and channel; a person''s own choice wins, a critical type ignores both. Genesis notifications.';
468
+
469
+ -- Addresses not to email, by SHA-256 of the lower-cased address. category '*'
470
+ -- is every category: a hard bounce stops all mail, a complaint all but
471
+ -- transactional mail; an unsubscribe (of someone with no login) one category
472
+ -- of one tenant (tenant_id null: the mail sent for no tenant). A bounce, a
473
+ -- complaint or a manual entry has tenant_id null and holds for every tenant.
474
+ create table public.notification_suppressions (
475
+ email_hash text not null
476
+ constraint notification_suppressions_hash check (email_hash ~ '^[0-9a-f]{64}$'),
477
+ category text not null default '*'
478
+ constraint notification_suppressions_category check (category = '*' or category ~ '^[a-z0-9][a-z0-9._-]{0,63}$'),
479
+ tenant_id uuid,
480
+ reason text not null
481
+ constraint notification_suppressions_reason check (reason in ('bounce', 'complaint', 'unsubscribe', 'manual')),
482
+ -- For the admin list: "s***@example.com".
483
+ email_masked text
484
+ constraint notification_suppressions_masked check (char_length(email_masked) <= 254),
485
+ created_at timestamptz not null default now(),
486
+ constraint notification_suppressions_once unique nulls not distinct (email_hash, category, tenant_id)
487
+ );
488
+
489
+ comment on table public.notification_suppressions is
490
+ 'Addresses not to email (bounce, complaint, unsubscribe), by hash only. Platform admins read it. Genesis notifications.';
491
+
492
+ -- The feed, which is also the outbox --------------------------------------
493
+
494
+ create table public.notifications (
495
+ id uuid primary key default gen_random_uuid(),
496
+ type text not null references public.notification_types (key),
497
+ category text not null,
498
+ tenant_id uuid,
499
+ -- A login, or (for someone with none) an email address; never neither.
500
+ user_id uuid references auth.users (id) on delete cascade,
501
+ email text
502
+ constraint notifications_email check (char_length(email) <= 254),
503
+ recipient_name text
504
+ constraint notifications_recipient_name check (char_length(recipient_name) <= 200),
505
+ -- The domain's id for a recipient with no login ("people:42"), so erasing them finds the rows.
506
+ person_ref text
507
+ constraint notifications_person_ref check (char_length(person_ref) <= 200),
508
+ recipient_key text not null generated always as (coalesce(user_id::text, 'email:' || lower(email))) stored,
509
+ idempotency_key text not null
510
+ constraint notifications_idempotency_key check (char_length(idempotency_key) between 1 and 200),
511
+ -- References and display values; never a secret (genesis_notify checks).
512
+ data jsonb not null default '{}'::jsonb
513
+ constraint notifications_data check (jsonb_typeof(data) = 'object' and pg_column_size(data) <= 16384),
514
+ thread_key text
515
+ constraint notifications_thread_key check (char_length(thread_key) <= 200),
516
+ collapse_key text
517
+ constraint notifications_collapse_key check (char_length(collapse_key) <= 200),
518
+ badge integer
519
+ constraint notifications_badge check (badge between 0 and 99999),
520
+ brand jsonb
521
+ constraint notifications_brand check (brand is null or (jsonb_typeof(brand) = 'object' and pg_column_size(brand) <= 4096)),
522
+ -- Whether it shows in the feed (the in_app channel was on for this person).
523
+ in_feed boolean not null default false,
524
+ created_at timestamptz not null default now(),
525
+ seen_at timestamptz,
526
+ read_at timestamptz,
527
+ archived_at timestamptz,
528
+ constraint notifications_recipient check (user_id is not null or email is not null),
529
+ constraint notifications_feed_needs_login check (not in_feed or user_id is not null),
530
+ -- An idempotency key is a domain's, per type and tenant: tenant A's "release:42" never finds tenant B's.
531
+ constraint notifications_once unique nulls not distinct (recipient_key, type, tenant_id, idempotency_key)
532
+ );
533
+ create index notifications_feed on public.notifications (user_id, created_at desc) where in_feed and archived_at is null;
534
+ create index notifications_unread on public.notifications (user_id) where in_feed and read_at is null and archived_at is null;
535
+ create index notifications_email on public.notifications (lower(email)) where email is not null;
536
+ create index notifications_person_ref on public.notifications (person_ref) where person_ref is not null;
537
+ create index notifications_collapse on public.notifications (recipient_key, collapse_key) where collapse_key is not null;
538
+ create index notifications_created_at on public.notifications (created_at);
539
+
540
+ comment on table public.notifications is
541
+ 'The in-app feed and the outbox: one row per recipient, unique per type, tenant and idempotency key. A person reads only their own. Genesis notifications.';
542
+
543
+ create table public.notification_deliveries (
544
+ id uuid primary key default gen_random_uuid(),
545
+ notification_id uuid not null references public.notifications (id) on delete cascade,
546
+ -- Copied from the notification, so an admin's log reads no feed row.
547
+ tenant_id uuid,
548
+ type text not null,
549
+ channel text not null
550
+ constraint notification_deliveries_channel check (channel in ('push', 'email')),
551
+ status text not null default 'pending'
552
+ constraint notification_deliveries_status check (status in ('pending', 'sending', 'sent', 'delivered', 'failed', 'skipped', 'cancelled')),
553
+ attempts integer not null default 0,
554
+ deferrals integer not null default 0,
555
+ send_after timestamptz not null default now(),
556
+ lease_until timestamptz,
557
+ -- The claim this row is leased to; settling with another one is stale.
558
+ claim_id uuid,
559
+ -- Cancelled ('cancelled') or collapsed ('collapsed') while it was being sent: unless that
560
+ -- attempt went out, it ends cancelled with this reason, never retried.
561
+ cancel_requested text
562
+ constraint notification_deliveries_cancel_requested check (cancel_requested in ('cancelled', 'collapsed')),
563
+ -- Resend's email id, or the push ticket ids live in push_receipts.
564
+ provider_id text
565
+ constraint notification_deliveries_provider_id check (char_length(provider_id) <= 200),
566
+ -- An error code or a short safe reason: never content or an address.
567
+ error text
568
+ constraint notification_deliveries_error check (char_length(error) <= 500),
569
+ skip_reason text
570
+ constraint notification_deliveries_skip_reason check (skip_reason ~ '^[a-z_]{1,64}$'),
571
+ recipient_masked text
572
+ constraint notification_deliveries_recipient_masked check (char_length(recipient_masked) <= 254),
573
+ -- The email as first rendered ({ from, to, reply_to, subject, html, text, headers }), so every
574
+ -- retry sends the same bytes. Cleared by genesis_notifications_prune after a day.
575
+ rendered jsonb,
576
+ rendered_hash text
577
+ constraint notification_deliveries_rendered_hash check (rendered_hash ~ '^[0-9a-f]{64}$'),
578
+ -- A one-time secret for the email (pgp_sym_encrypt with the Vault key), cleared once it settles.
579
+ secret_ciphertext bytea,
580
+ created_at timestamptz not null default now(),
581
+ updated_at timestamptz not null default now(),
582
+ sent_at timestamptz,
583
+ constraint notification_deliveries_once unique (notification_id, channel)
584
+ );
585
+ create index notification_deliveries_due on public.notification_deliveries (channel, send_after) where status in ('pending', 'sending');
586
+ create index notification_deliveries_provider on public.notification_deliveries (provider_id) where provider_id is not null;
587
+ create index notification_deliveries_log on public.notification_deliveries (tenant_id, created_at desc);
588
+ create index notification_deliveries_created_at on public.notification_deliveries (created_at);
589
+
590
+ comment on table public.notification_deliveries is
591
+ 'One send job per notification and channel: status, attempts, send_after, the provider''s id, an error code. Admins read the log columns. Genesis notifications.';
592
+
593
+ alter table public.push_receipts
594
+ add column delivery_id uuid references public.notification_deliveries (id) on delete set null;
595
+ create index push_receipts_delivery_id on public.push_receipts (delivery_id) where delivery_id is not null;
596
+
597
+ -- Admins ---------------------------------------------------------------------
598
+
599
+ -- Who may read a tenant's delivery log and manage its defaults and sender
600
+ -- (p_tenant null: the whole app, and the suppression list). False for
601
+ -- everyone until the app replaces it in a migration of its own, after this:
602
+ -- create or replace function private.genesis_notifications_admin(p_user uuid, p_tenant uuid) returns boolean
603
+ -- language sql stable security definer set search_path = '' as $$ … $$;
604
+ -- Row level security calls it as the signed-in person, so `authenticated` may
605
+ -- execute it (the private schema isn't exposed through the API); never grant
606
+ -- it to anon or public.
607
+ create function private.genesis_notifications_admin(p_user uuid, p_tenant uuid)
608
+ returns boolean
609
+ language sql
610
+ stable
611
+ security definer
612
+ set search_path = ''
613
+ as $$
614
+ select false;
615
+ $$;
616
+
617
+ -- Helpers --------------------------------------------------------------------
618
+
619
+ create function private.genesis_notifications_email_hash(p_email text)
620
+ returns text
621
+ language sql
622
+ immutable
623
+ set search_path = ''
624
+ as $$
625
+ select encode(sha256(convert_to(lower(btrim(p_email)), 'UTF8')), 'hex');
626
+ $$;
627
+
628
+ -- "s***@example.com": enough for an admin to recognise, not to harvest.
629
+ create function private.genesis_notifications_mask_email(p_email text)
630
+ returns text
631
+ language sql
632
+ immutable
633
+ set search_path = ''
634
+ as $$
635
+ select case
636
+ when p_email is null or position('@' in p_email) < 2 then null
637
+ else left(p_email, 1) || '***' || substr(p_email, position('@' in p_email))
638
+ end;
639
+ $$;
640
+
641
+ -- Refuses data with a key named like a secret, or a link (absolute or relative) that carries
642
+ -- one in its query or fragment. A backstop, not a guarantee: it catches the usual names
643
+ -- (any key or parameter ending in token, code, secret, key, otp or password), so a display
644
+ -- value named that way (country_code) is refused too; name it otherwise (country).
645
+ create function private.genesis_notifications_assert_no_secrets(p_data jsonb)
646
+ returns void
647
+ language plpgsql
648
+ immutable
649
+ set search_path = ''
650
+ as $$
651
+ declare
652
+ bad text;
653
+ begin
654
+ with recursive walk(path, value) as (
655
+ select '', p_data
656
+ union all
657
+ select w.path || coalesce('.' || e.key, '[]'), e.value
658
+ from walk w
659
+ cross join lateral (
660
+ select o.key, o.value from pg_catalog.jsonb_each(case when jsonb_typeof(w.value) = 'object' then w.value else '{}'::jsonb end) o
661
+ union all
662
+ select null, a.value from pg_catalog.jsonb_array_elements(case when jsonb_typeof(w.value) = 'array' then w.value else '[]'::jsonb end) a
663
+ ) e
664
+ )
665
+ -- Each key as words (inviteToken -> invite_token), so camelCase is caught as well as snake_case.
666
+ select substr(w.path, 2) into bad
667
+ from walk w
668
+ where lower(regexp_replace(w.path, '([a-z0-9])([A-Z])', '\1_\2', 'g'))
669
+ ~ '((token|code|secret|key|otp|password)|(^|[._-])(signature|sig|magic_?link|invite_?link|reset_?link))$'
670
+ or (jsonb_typeof(w.value) = 'string'
671
+ and (w.value #>> '{}') ~* '^([a-z][a-z0-9+.-]*:|/|\.\.?/|(?=[?#]))\S*[?&#;][^=&#;\s]*(token|code|secret|key|otp|password|signature|sig)=')
672
+ limit 1;
673
+ if bad is not null then
674
+ raise exception 'genesis_notify: data.% looks like a secret; store a reference and build the link at send time', bad using errcode = '22023';
675
+ end if;
676
+ end;
677
+ $$;
678
+
679
+ -- The Vault key that encrypts one-time secrets on send jobs, made once.
680
+ do $key$
681
+ begin
682
+ if to_regclass('vault.secrets') is not null
683
+ and not exists (select 1 from vault.secrets where name = 'genesis_notifications_job_key') then
684
+ perform vault.create_secret(encode(extensions.gen_random_bytes(32), 'hex'), 'genesis_notifications_job_key',
685
+ 'Encrypts one-time secrets on notification send jobs (genesis-notifications). Rotate only with no email pending.');
686
+ end if;
687
+ end;
688
+ $key$;
689
+
690
+ create function private.genesis_notifications_job_key()
691
+ returns text
692
+ language plpgsql
693
+ stable
694
+ security definer
695
+ set search_path = ''
696
+ as $$
697
+ declare
698
+ k text;
699
+ begin
700
+ if to_regclass('vault.decrypted_secrets') is null then
701
+ raise exception 'genesis_notifications: Vault is not installed, so a secret can''t travel on a send job' using errcode = '55000';
702
+ end if;
703
+ execute 'select decrypted_secret from vault.decrypted_secrets where name = $1' into k using 'genesis_notifications_job_key';
704
+ if k is null then
705
+ raise exception 'genesis_notifications: no genesis_notifications_job_key in Vault' using errcode = '55000';
706
+ end if;
707
+ return k;
708
+ end;
709
+ $$;
710
+
711
+ -- Ids only, on the person's private channel; best effort (the app also refetches on focus).
712
+ create function private.genesis_notifications_broadcast(p_user uuid, p_event text, p_payload jsonb)
713
+ returns void
714
+ language plpgsql
715
+ security definer
716
+ set search_path = ''
717
+ as $$
718
+ begin
719
+ if p_user is null or to_regprocedure('realtime.send(jsonb, text, text, boolean)') is null then
720
+ return;
721
+ end if;
722
+ begin
723
+ execute 'select realtime.send($1, $2, $3, true)' using p_payload, p_event, 'user:' || p_user::text;
724
+ exception
725
+ when others then
726
+ null;
727
+ end;
728
+ end;
729
+ $$;
730
+
731
+ -- The first instant at which the clock in `p_zone` reads `p_local` or later,
732
+ -- coming from the day before: the first of a repeated (fall-back) time, the
733
+ -- moment of the change for a skipped (spring-forward) one. (0005's
734
+ -- genesis_push_local_instant, so this stands without it.)
735
+ create function private.genesis_notifications_local_instant(p_local timestamp, p_zone text)
736
+ returns timestamptz
737
+ language plpgsql
738
+ stable
739
+ set search_path = ''
740
+ as $$
741
+ declare
742
+ guess timestamptz := p_local at time zone p_zone;
743
+ t timestamptz;
744
+ found_at timestamptz := null;
745
+ begin
746
+ if ((guess - interval '3 hours') at time zone p_zone) - (guess - interval '3 hours') at time zone 'UTC'
747
+ = ((guess + interval '3 hours') at time zone p_zone) - (guess + interval '3 hours') at time zone 'UTC' then
748
+ return guess;
749
+ end if;
750
+ t := guess + interval '1 hour';
751
+ while t > guess - interval '3 hours' loop
752
+ if (t at time zone p_zone) >= p_local and ((t - interval '1 minute') at time zone p_zone) < p_local then
753
+ found_at := t;
754
+ end if;
755
+ t := t - interval '1 minute';
756
+ end loop;
757
+ return coalesce(found_at, guess);
758
+ end;
759
+ $$;
760
+
761
+ -- When the person's quiet hours end, if `p_at` is inside them; null otherwise.
762
+ -- Each night's window runs from the first moment the local clock reads the
763
+ -- start to the first moment after it that it reads the end (past midnight when
764
+ -- the start is later). A start equal to the end is no quiet hours; a zone the
765
+ -- server no longer knows never silences anyone.
766
+ create function private.genesis_notifications_quiet_until(p_user uuid, p_at timestamptz)
767
+ returns timestamptz
768
+ language plpgsql
769
+ stable
770
+ security definer
771
+ set search_path = ''
772
+ as $$
773
+ declare
774
+ pref public.notification_preferences;
775
+ local_now timestamp;
776
+ day date;
777
+ window_start timestamptz;
778
+ window_end timestamptz;
779
+ begin
780
+ select * into pref from public.notification_preferences n where n.user_id = p_user;
781
+ if not found or not pref.quiet_hours_enabled or pref.quiet_hours_start = pref.quiet_hours_end then
782
+ return null;
783
+ end if;
784
+ begin
785
+ local_now := p_at at time zone pref.time_zone;
786
+ exception
787
+ when invalid_parameter_value then
788
+ return null;
789
+ end;
790
+ foreach day in array array[local_now::date, local_now::date - 1] loop
791
+ window_start := private.genesis_notifications_local_instant(day + pref.quiet_hours_start, pref.time_zone);
792
+ if window_start <= p_at then
793
+ window_end := private.genesis_notifications_local_instant(
794
+ day + pref.quiet_hours_end + case when pref.quiet_hours_end < pref.quiet_hours_start then interval '1 day' else interval '0' end,
795
+ pref.time_zone
796
+ );
797
+ return case when p_at < window_end then window_end end;
798
+ end if;
799
+ end loop;
800
+ return null;
801
+ end;
802
+ $$;
803
+
804
+ -- Is `p_channel` on for this type, person and tenant? A channel the type
805
+ -- doesn't offer: no. A critical type, or the email of a transactional one: as
806
+ -- the type defaults it. Otherwise the person's choice for this tenant (an
807
+ -- unsubscribe from its mail), then the
808
+ -- person's choice for the category and channel, then (for push and email) a
809
+ -- category the person turned off entirely (0005's `categories`), then the
810
+ -- tenant's default, then the type's.
811
+ create function private.genesis_notifications_channel_on(p_user uuid, p_tenant uuid, p_type text, p_channel text)
812
+ returns boolean
813
+ language plpgsql
814
+ stable
815
+ security definer
816
+ set search_path = ''
817
+ as $$
818
+ declare
819
+ t public.notification_types;
820
+ base boolean;
821
+ chosen boolean;
822
+ pref public.notification_preferences;
823
+ begin
824
+ select * into t from public.notification_types where key = p_type;
825
+ if not found then
826
+ return false;
827
+ end if;
828
+ base := (t.channels ->> p_channel)::boolean;
829
+ if base is null then
830
+ return false;
831
+ end if;
832
+ if t.critical then
833
+ return base;
834
+ end if;
835
+ -- Transactional mail skips unsubscribe: no choice or default turns its email off.
836
+ if t.transactional and p_channel = 'email' then
837
+ return base;
838
+ end if;
839
+ if p_user is not null then
840
+ select * into pref from public.notification_preferences n where n.user_id = p_user;
841
+ if found then
842
+ -- Mail sent for no tenant is under the nil uuid.
843
+ chosen := (pref.tenant_channels -> coalesce(p_tenant, '00000000-0000-0000-0000-000000000000'::uuid)::text -> t.category ->> p_channel)::boolean;
844
+ if chosen is not null then
845
+ return chosen;
846
+ end if;
847
+ chosen := (pref.channels -> t.category ->> p_channel)::boolean;
848
+ if chosen is not null then
849
+ return chosen;
850
+ end if;
851
+ if p_channel <> 'in_app' and (pref.categories ->> t.category)::boolean is false then
852
+ return false;
853
+ end if;
854
+ end if;
855
+ end if;
856
+ if p_tenant is not null then
857
+ select d.enabled into chosen from public.notification_tenant_defaults d
858
+ where d.tenant_id = p_tenant and d.category = t.category and d.channel = p_channel;
859
+ if chosen is not null then
860
+ return chosen;
861
+ end if;
862
+ end if;
863
+ return base;
864
+ end;
865
+ $$;
866
+
867
+ -- notify ---------------------------------------------------------------------
868
+
869
+ -- What a domain calls (through notify() in TypeScript, or from its own SQL):
870
+ -- one notification per recipient, and a send job per channel that is on.
871
+ -- p_recipients: [{ "user_id": uuid, "badge"? } | { "email", "name"?, "ref"? }], at most 1000;
872
+ -- a recipient's own badge (their unread count) wins over p_badge.
873
+ -- The same idempotency key again for a recipient, type and tenant finds the
874
+ -- first one (created false) and sends nothing more; another type or tenant
875
+ -- with the same key is a notification of its own. A login that no longer exists is
876
+ -- skipped (no row returned). A collapse key replaces the recipient's older
877
+ -- unsent push and email with that key, and archives the older feed row.
878
+ create function public.genesis_notify(
879
+ p_type text,
880
+ p_recipients jsonb,
881
+ p_data jsonb default '{}'::jsonb,
882
+ p_idempotency_key text default null,
883
+ p_thread_key text default null,
884
+ p_collapse_key text default null,
885
+ p_badge integer default null,
886
+ p_tenant_id uuid default null,
887
+ p_brand jsonb default null,
888
+ p_secret text default null
889
+ )
890
+ returns table (notification_id uuid, recipient_key text, created boolean)
891
+ language plpgsql
892
+ security definer
893
+ set search_path = ''
894
+ as $$
895
+ declare
896
+ t public.notification_types;
897
+ r jsonb;
898
+ uid uuid;
899
+ addr text;
900
+ n_id uuid;
901
+ n_key text;
902
+ feed boolean;
903
+ push_on boolean;
904
+ email_on boolean;
905
+ cipher bytea;
906
+ data_ jsonb := coalesce(p_data, '{}'::jsonb);
907
+ begin
908
+ select * into t from public.notification_types where key = p_type and retired_at is null;
909
+ if not found then
910
+ raise exception 'genesis_notify: unknown or retired type %', p_type using errcode = '22023';
911
+ end if;
912
+ if p_idempotency_key is null or char_length(p_idempotency_key) not between 1 and 200 then
913
+ raise exception 'genesis_notify: an idempotency key (1 to 200 characters) is required' using errcode = '22023';
914
+ end if;
915
+ if jsonb_typeof(data_) <> 'object' then
916
+ raise exception 'genesis_notify: data must be an object' using errcode = '22023';
917
+ end if;
918
+ perform private.genesis_notifications_assert_no_secrets(data_);
919
+ if p_secret is not null and not t.secret_link then
920
+ raise exception 'genesis_notify: type % carries no secret link', p_type using errcode = '22023';
921
+ end if;
922
+ if p_secret is not null and (t.channels ->> 'email') is null then
923
+ raise exception 'genesis_notify: a secret travels only by email' using errcode = '22023';
924
+ end if;
925
+ if p_secret is not null and jsonb_array_length(coalesce(p_recipients, '[]'::jsonb)) <> 1 then
926
+ -- A one-time secret is one person's: never copied onto anyone else's email.
927
+ raise exception 'genesis_notify: a secret goes to exactly one recipient' using errcode = '22023';
928
+ end if;
929
+ if jsonb_typeof(p_recipients) is distinct from 'array' or jsonb_array_length(p_recipients) not between 1 and 1000 then
930
+ raise exception 'genesis_notify: recipients is an array of 1 to 1000' using errcode = '22023';
931
+ end if;
932
+ if p_secret is not null then
933
+ cipher := extensions.pgp_sym_encrypt(p_secret, private.genesis_notifications_job_key());
934
+ end if;
935
+
936
+ for r in select value from jsonb_array_elements(p_recipients) loop
937
+ uid := null;
938
+ addr := null;
939
+ if jsonb_typeof(r) = 'string' or r ? 'user_id' then
940
+ begin
941
+ uid := (case when jsonb_typeof(r) = 'string' then r #>> '{}' else r ->> 'user_id' end)::uuid;
942
+ exception
943
+ when invalid_text_representation then
944
+ -- Raised afresh so the error never echoes the input.
945
+ raise exception 'genesis_notify: a recipient is not a user id' using errcode = '22023';
946
+ end;
947
+ elsif r ? 'email' then
948
+ addr := btrim(r ->> 'email');
949
+ if addr !~ '^[^\s@<>,;:"()\[\]\\]+@[^\s@<>,;:"()\[\]\\]+\.[^\s@<>,;:"()\[\]\\]+$' or char_length(addr) > 254 then
950
+ raise exception 'genesis_notify: a recipient has no valid email address' using errcode = '22023';
951
+ end if;
952
+ else
953
+ raise exception 'genesis_notify: a recipient is { user_id } or { email }' using errcode = '22023';
954
+ end if;
955
+ if uid is not null and not exists (select 1 from auth.users u where u.id = uid) then
956
+ continue;
957
+ end if;
958
+
959
+ feed := uid is not null and private.genesis_notifications_channel_on(uid, p_tenant_id, p_type, 'in_app');
960
+ push_on := uid is not null and private.genesis_notifications_channel_on(uid, p_tenant_id, p_type, 'push');
961
+ email_on := private.genesis_notifications_channel_on(uid, p_tenant_id, p_type, 'email');
962
+
963
+ insert into public.notifications as n (type, category, tenant_id, user_id, email, recipient_name, person_ref, idempotency_key, data, thread_key, collapse_key, badge, brand, in_feed)
964
+ values (p_type, t.category, p_tenant_id, uid, addr, left(r ->> 'name', 200), left(r ->> 'ref', 200), p_idempotency_key, data_, p_thread_key, p_collapse_key,
965
+ case when jsonb_typeof(r -> 'badge') = 'number' then (r ->> 'badge')::numeric::integer else p_badge end, p_brand, feed)
966
+ on conflict on constraint notifications_once do nothing
967
+ returning n.id, n.recipient_key into n_id, n_key;
968
+
969
+ if n_id is null then
970
+ select n.id, n.recipient_key into n_id, n_key from public.notifications n
971
+ where n.recipient_key = coalesce(uid::text, 'email:' || lower(addr)) and n.idempotency_key = p_idempotency_key
972
+ and n.type = p_type and n.tenant_id is not distinct from p_tenant_id;
973
+ notification_id := n_id;
974
+ recipient_key := n_key;
975
+ created := false;
976
+ return next;
977
+ continue;
978
+ end if;
979
+
980
+ if p_collapse_key is not null then
981
+ update public.notification_deliveries d
982
+ set status = 'cancelled', skip_reason = 'collapsed', secret_ciphertext = null, updated_at = now()
983
+ from public.notifications o
984
+ where d.notification_id = o.id and o.recipient_key = n_key and o.collapse_key = p_collapse_key and o.id <> n_id
985
+ and o.tenant_id is not distinct from p_tenant_id
986
+ and d.status = 'pending';
987
+ -- One being sent right now ends collapsed unless that attempt goes out.
988
+ update public.notification_deliveries d
989
+ set cancel_requested = coalesce(d.cancel_requested, 'collapsed'), updated_at = now()
990
+ from public.notifications o
991
+ where d.notification_id = o.id and o.recipient_key = n_key and o.collapse_key = p_collapse_key and o.id <> n_id
992
+ and o.tenant_id is not distinct from p_tenant_id
993
+ and d.status = 'sending';
994
+ update public.notifications o
995
+ set archived_at = now()
996
+ where o.recipient_key = n_key and o.collapse_key = p_collapse_key and o.id <> n_id and o.archived_at is null and o.read_at is null
997
+ and o.tenant_id is not distinct from p_tenant_id;
998
+ end if;
999
+
1000
+ if push_on then
1001
+ insert into public.notification_deliveries (notification_id, tenant_id, type, channel)
1002
+ values (n_id, p_tenant_id, p_type, 'push');
1003
+ end if;
1004
+ if email_on then
1005
+ insert into public.notification_deliveries (notification_id, tenant_id, type, channel, send_after, secret_ciphertext, recipient_masked)
1006
+ values (
1007
+ n_id, p_tenant_id, p_type, 'email',
1008
+ -- Delay, then skip if read: only worth it when the person also has it in the app.
1009
+ now() + case when feed and not t.transactional then make_interval(secs => t.email_delay_seconds) else interval '0' end,
1010
+ cipher,
1011
+ private.genesis_notifications_mask_email(coalesce(addr, (select u.email from auth.users u where u.id = uid)))
1012
+ );
1013
+ end if;
1014
+ if feed then
1015
+ perform private.genesis_notifications_broadcast(uid, 'notification', jsonb_build_object('id', n_id, 'kind', 'created'));
1016
+ end if;
1017
+
1018
+ notification_id := n_id;
1019
+ recipient_key := n_key;
1020
+ created := true;
1021
+ return next;
1022
+ end loop;
1023
+ end;
1024
+ $$;
1025
+
1026
+ -- The runner -----------------------------------------------------------------
1027
+
1028
+ -- Hands out up to p_limit due send jobs on one channel, each leased for
1029
+ -- p_lease_seconds to a new claim id (settle with it). Due: pending and past
1030
+ -- send_after, or sending with a lease that ran out (`redelivery`). Before
1031
+ -- handing one out it decides again, at send time:
1032
+ -- - the person turned the channel off since: skipped (preference);
1033
+ -- - email, delayed and read in the app meanwhile: skipped (read_in_app);
1034
+ -- - email to a suppressed address: skipped (suppressed);
1035
+ -- - push in quiet hours (not transactional): back to pending until they end, no attempt used;
1036
+ -- - push with no devices: skipped (no_devices).
1037
+ -- A job whose lease ran out p_max_attempts times (the runner died with it each
1038
+ -- time) fails as lease_expired instead of coming back for ever; one cancelled
1039
+ -- while it was being sent ends cancelled instead of coming back. The secret,
1040
+ -- when there is one, is decrypted for the runner here and nowhere else; a
1041
+ -- secret that won't decrypt fails its own job (secret_unreadable) and the rest
1042
+ -- are handed out. No Vault key at all is the app's to fix, so that raises.
1043
+ create function public.genesis_notifications_claim(p_channel text, p_limit integer default 25, p_lease_seconds integer default 300, p_max_attempts integer default 8)
1044
+ returns table (
1045
+ id uuid,
1046
+ claim_id uuid,
1047
+ notification_id uuid,
1048
+ channel text,
1049
+ attempt integer,
1050
+ deferrals integer,
1051
+ redelivery boolean,
1052
+ type text,
1053
+ category text,
1054
+ tenant_id uuid,
1055
+ user_id uuid,
1056
+ email text,
1057
+ recipient_name text,
1058
+ data jsonb,
1059
+ thread_key text,
1060
+ collapse_key text,
1061
+ badge integer,
1062
+ brand jsonb,
1063
+ transactional boolean,
1064
+ critical boolean,
1065
+ push_privacy text,
1066
+ ios_category text,
1067
+ hide_previews boolean,
1068
+ devices jsonb,
1069
+ sender jsonb,
1070
+ rendered jsonb,
1071
+ rendered_hash text,
1072
+ secret text
1073
+ )
1074
+ language plpgsql
1075
+ security definer
1076
+ set search_path = ''
1077
+ as $$
1078
+ declare
1079
+ d record;
1080
+ claim uuid := gen_random_uuid();
1081
+ quiet timestamptz;
1082
+ addr text;
1083
+ devs jsonb;
1084
+ key_ text;
1085
+ job_key text;
1086
+ plain text;
1087
+ unreadable boolean;
1088
+ begin
1089
+ if p_channel not in ('push', 'email') then
1090
+ raise exception 'genesis_notifications_claim: channel is push or email' using errcode = '22023';
1091
+ end if;
1092
+ for d in
1093
+ select q.*, n.user_id as n_user, n.email as n_email, n.recipient_name as n_name, n.data as n_data, n.thread_key as n_thread,
1094
+ n.collapse_key as n_collapse, n.badge as n_badge, n.brand as n_brand, n.read_at as n_read, n.category as n_category,
1095
+ n.in_feed as n_feed, t.transactional as t_transactional, t.critical as t_critical, t.push_privacy as t_privacy, t.ios_category as t_ios,
1096
+ t.email_delay_seconds as t_delay
1097
+ from public.notification_deliveries q
1098
+ join public.notifications n on n.id = q.notification_id
1099
+ join public.notification_types t on t.key = q.type
1100
+ where q.id in (
1101
+ select c.id from public.notification_deliveries c
1102
+ where c.channel = p_channel
1103
+ and ((c.status = 'pending' and c.send_after <= now()) or (c.status = 'sending' and c.lease_until < now()))
1104
+ order by c.send_after
1105
+ limit greatest(1, least(coalesce(p_limit, 25), 500))
1106
+ for update skip locked
1107
+ )
1108
+ loop
1109
+ if d.status = 'sending' and d.cancel_requested is not null then
1110
+ update public.notification_deliveries x set status = 'cancelled', skip_reason = d.cancel_requested, secret_ciphertext = null, lease_until = null, claim_id = null, updated_at = now() where x.id = d.id;
1111
+ continue;
1112
+ end if;
1113
+ if d.status = 'sending' and d.attempts >= greatest(1, coalesce(p_max_attempts, 8)) then
1114
+ update public.notification_deliveries x set status = 'failed', error = 'lease_expired', secret_ciphertext = null, lease_until = null, claim_id = null, updated_at = now() where x.id = d.id;
1115
+ continue;
1116
+ end if;
1117
+ -- The person may have changed their mind since it was queued.
1118
+ if not private.genesis_notifications_channel_on(d.n_user, d.tenant_id, d.type, p_channel) then
1119
+ update public.notification_deliveries x set status = 'skipped', skip_reason = 'preference', secret_ciphertext = null, lease_until = null, updated_at = now() where x.id = d.id;
1120
+ continue;
1121
+ end if;
1122
+
1123
+ if p_channel = 'email' then
1124
+ if d.t_delay > 0 and d.n_feed and not d.t_transactional and d.n_read is not null then
1125
+ update public.notification_deliveries x set status = 'skipped', skip_reason = 'read_in_app', secret_ciphertext = null, lease_until = null, updated_at = now() where x.id = d.id;
1126
+ continue;
1127
+ end if;
1128
+ addr := coalesce(d.n_email, (select u.email from auth.users u where u.id = d.n_user));
1129
+ if addr is null then
1130
+ update public.notification_deliveries x set status = 'skipped', skip_reason = 'no_address', secret_ciphertext = null, lease_until = null, updated_at = now() where x.id = d.id;
1131
+ continue;
1132
+ end if;
1133
+ key_ := private.genesis_notifications_email_hash(addr);
1134
+ if exists (
1135
+ select 1 from public.notification_suppressions s
1136
+ where s.email_hash = key_
1137
+ and (s.reason = 'bounce' or (not d.t_transactional and (s.category = '*' or s.category = d.n_category)
1138
+ -- An unsubscribe holds for its own tenant (null: mail sent for none); others for all.
1139
+ and case when s.reason = 'unsubscribe' then s.tenant_id is not distinct from d.tenant_id
1140
+ else s.tenant_id is null or s.tenant_id = d.tenant_id end))
1141
+ ) then
1142
+ update public.notification_deliveries x set status = 'skipped', skip_reason = 'suppressed', secret_ciphertext = null, lease_until = null, updated_at = now() where x.id = d.id;
1143
+ continue;
1144
+ end if;
1145
+ devs := null;
1146
+ else
1147
+ addr := null;
1148
+ if not d.t_transactional then
1149
+ quiet := private.genesis_notifications_quiet_until(d.n_user, now());
1150
+ if quiet is not null then
1151
+ update public.notification_deliveries x set status = 'pending', send_after = quiet, lease_until = null, updated_at = now() where x.id = d.id;
1152
+ continue;
1153
+ end if;
1154
+ end if;
1155
+ select coalesce(jsonb_agg(jsonb_build_object('id', v.id, 'token', v.token, 'platform', v.platform) order by v.last_seen_at desc), '[]'::jsonb)
1156
+ into devs
1157
+ from public.push_devices v where v.user_id = d.n_user;
1158
+ if jsonb_array_length(devs) = 0 then
1159
+ update public.notification_deliveries x set status = 'skipped', skip_reason = 'no_devices', lease_until = null, updated_at = now() where x.id = d.id;
1160
+ continue;
1161
+ end if;
1162
+ end if;
1163
+
1164
+ plain := null;
1165
+ if d.secret_ciphertext is not null then
1166
+ job_key := coalesce(job_key, private.genesis_notifications_job_key());
1167
+ unreadable := false;
1168
+ begin
1169
+ plain := extensions.pgp_sym_decrypt(d.secret_ciphertext, job_key);
1170
+ exception
1171
+ when others then
1172
+ unreadable := true;
1173
+ end;
1174
+ if unreadable then
1175
+ update public.notification_deliveries x set status = 'failed', error = 'secret_unreadable', secret_ciphertext = null, lease_until = null, claim_id = null, updated_at = now() where x.id = d.id;
1176
+ continue;
1177
+ end if;
1178
+ end if;
1179
+
1180
+ update public.notification_deliveries x
1181
+ set status = 'sending', attempts = x.attempts + 1, lease_until = now() + make_interval(secs => greatest(30, least(coalesce(p_lease_seconds, 300), 3600))),
1182
+ claim_id = claim, updated_at = now()
1183
+ where x.id = d.id;
1184
+
1185
+ id := d.id;
1186
+ claim_id := claim;
1187
+ notification_id := d.notification_id;
1188
+ channel := p_channel;
1189
+ attempt := d.attempts + 1;
1190
+ deferrals := d.deferrals;
1191
+ redelivery := d.status = 'sending';
1192
+ type := d.type;
1193
+ category := d.n_category;
1194
+ tenant_id := d.tenant_id;
1195
+ user_id := d.n_user;
1196
+ email := addr;
1197
+ recipient_name := d.n_name;
1198
+ data := d.n_data;
1199
+ thread_key := d.n_thread;
1200
+ collapse_key := d.n_collapse;
1201
+ badge := d.n_badge;
1202
+ transactional := d.t_transactional;
1203
+ critical := d.t_critical;
1204
+ push_privacy := d.t_privacy;
1205
+ ios_category := d.t_ios;
1206
+ hide_previews := coalesce((select p.hide_previews from public.notification_preferences p where p.user_id = d.n_user), false);
1207
+ devices := devs;
1208
+ select case when s.tenant_id is null then null else jsonb_build_object(
1209
+ 'from_name', s.from_name, 'reply_to', s.reply_to, 'domain', s.domain, 'domain_verified', s.domain_status = 'verified')
1210
+ end,
1211
+ coalesce(d.n_brand, s.brand)
1212
+ into sender, brand
1213
+ from (select 1) one left join public.notification_senders s on s.tenant_id = d.tenant_id;
1214
+ rendered := d.rendered;
1215
+ rendered_hash := d.rendered_hash;
1216
+ secret := plain;
1217
+ return next;
1218
+ end loop;
1219
+ end;
1220
+ $$;
1221
+
1222
+ -- Keeps the email as first rendered, so every retry sends the same bytes:
1223
+ -- the first call stores it, later ones get the stored copy back.
1224
+ create function public.genesis_notifications_store_rendered(p_id uuid, p_claim uuid, p_rendered jsonb, p_hash text)
1225
+ returns table (rendered jsonb, rendered_hash text)
1226
+ language plpgsql
1227
+ security definer
1228
+ set search_path = ''
1229
+ as $$
1230
+ begin
1231
+ update public.notification_deliveries d
1232
+ set rendered = p_rendered, rendered_hash = p_hash, updated_at = now()
1233
+ where d.id = p_id and d.claim_id = p_claim and d.status = 'sending' and d.rendered is null;
1234
+ return query
1235
+ select d.rendered, d.rendered_hash from public.notification_deliveries d where d.id = p_id and d.claim_id = p_claim;
1236
+ end;
1237
+ $$;
1238
+
1239
+ -- Records what became of a claimed job (the runner's outcome):
1240
+ -- { "status": "sent" | "skipped" | "filtered" | "failed" | "retry", "error", "reason",
1241
+ -- "retry_at", "counted", "provider_id" }. False when the claim was no longer held
1242
+ -- (stale). A settled job's secret is cleared; a retry keeps it for the next try.
1243
+ -- A retry of a job cancelled while it was being sent ends cancelled instead.
1244
+ create function public.genesis_notifications_settle(p_id uuid, p_claim uuid, p_outcome jsonb)
1245
+ returns boolean
1246
+ language plpgsql
1247
+ security definer
1248
+ set search_path = ''
1249
+ as $$
1250
+ declare
1251
+ st text := p_outcome ->> 'status';
1252
+ counted boolean := coalesce((p_outcome ->> 'counted')::boolean, true);
1253
+ begin
1254
+ if st not in ('sent', 'skipped', 'filtered', 'failed', 'retry') then
1255
+ raise exception 'genesis_notifications_settle: unknown status' using errcode = '22023';
1256
+ end if;
1257
+ update public.notification_deliveries d
1258
+ set status = case st when 'sent' then 'sent' when 'retry' then (case when d.cancel_requested is not null then 'cancelled' else 'pending' end) when 'failed' then 'failed' else 'skipped' end,
1259
+ send_after = case when st = 'retry' and d.cancel_requested is null then coalesce((p_outcome ->> 'retry_at')::timestamptz, now() + interval '5 minutes') else d.send_after end,
1260
+ attempts = case when st = 'retry' and not counted then greatest(0, d.attempts - 1) else d.attempts end,
1261
+ deferrals = case when st = 'retry' and not counted then d.deferrals + 1 else d.deferrals end,
1262
+ error = case when st in ('failed', 'retry') then left(p_outcome ->> 'error', 500) else null end,
1263
+ skip_reason = case when st = 'skipped' then coalesce(substring(p_outcome ->> 'reason' from '^[a-z_]{1,64}$'), 'not_sent') when st = 'filtered' then 'filtered'
1264
+ when st = 'retry' and d.cancel_requested is not null then d.cancel_requested end,
1265
+ provider_id = coalesce(left(p_outcome ->> 'provider_id', 200), d.provider_id),
1266
+ sent_at = case when st = 'sent' then now() else d.sent_at end,
1267
+ secret_ciphertext = case when st = 'retry' and d.cancel_requested is null then d.secret_ciphertext end,
1268
+ lease_until = null,
1269
+ claim_id = null,
1270
+ updated_at = now()
1271
+ where d.id = p_id and d.claim_id = p_claim and d.status = 'sending';
1272
+ return found;
1273
+ end;
1274
+ $$;
1275
+
1276
+ -- Keeps the provider's id for a claimed email the moment the send returns,
1277
+ -- before it settles, so a webhook that comes first still finds the job.
1278
+ create function public.genesis_notifications_record_provider_id(p_id uuid, p_claim uuid, p_provider_id text)
1279
+ returns boolean
1280
+ language plpgsql
1281
+ security definer
1282
+ set search_path = ''
1283
+ as $$
1284
+ begin
1285
+ if p_provider_id is null or char_length(p_provider_id) not between 1 and 200 then
1286
+ raise exception 'genesis_notifications_record_provider_id: a provider id of 1 to 200 characters' using errcode = '22023';
1287
+ end if;
1288
+ update public.notification_deliveries d
1289
+ set provider_id = p_provider_id, updated_at = now()
1290
+ where d.id = p_id and d.claim_id = p_claim and d.status = 'sending';
1291
+ return found;
1292
+ end;
1293
+ $$;
1294
+
1295
+ -- Records Expo's tickets for one push job:
1296
+ -- [{ "device_id", "ticket_id", "status": "sent" | "failed", "error" }]. A device Expo
1297
+ -- called DeviceNotRegistered is forgotten at once. Returns the receipts kept.
1298
+ create function public.genesis_notifications_push_tickets(p_delivery uuid, p_tickets jsonb)
1299
+ returns integer
1300
+ language plpgsql
1301
+ security definer
1302
+ set search_path = ''
1303
+ as $$
1304
+ declare
1305
+ kept integer;
1306
+ n public.notifications;
1307
+ begin
1308
+ if jsonb_typeof(p_tickets) is distinct from 'array' or jsonb_array_length(p_tickets) > 100 then
1309
+ raise exception 'genesis_notifications_push_tickets: expected an array of at most 100 tickets' using errcode = '22023';
1310
+ end if;
1311
+ select o.* into n from public.notifications o join public.notification_deliveries d on d.notification_id = o.id where d.id = p_delivery;
1312
+ if not found then
1313
+ return 0;
1314
+ end if;
1315
+ insert into public.push_receipts (user_id, device_id, category, ticket_id, status, error, check_after, delivery_id)
1316
+ select n.user_id,
1317
+ (select v.id from public.push_devices v where v.id = t.device_id),
1318
+ n.category,
1319
+ t.ticket_id,
1320
+ t.status,
1321
+ substring(t.error from '^[A-Za-z_]{1,64}$'),
1322
+ case when t.status = 'sent' then now() + interval '15 minutes' end,
1323
+ p_delivery
1324
+ from jsonb_to_recordset(p_tickets) as t (device_id uuid, ticket_id text, status text, error text)
1325
+ where t.status in ('sent', 'failed') and (t.status <> 'sent' or t.ticket_id is not null);
1326
+ get diagnostics kept = row_count;
1327
+ delete from public.push_devices v
1328
+ using jsonb_to_recordset(p_tickets) as t (device_id uuid, error text)
1329
+ where v.id = t.device_id and t.error = 'DeviceNotRegistered' and v.user_id = n.user_id;
1330
+ return kept;
1331
+ end;
1332
+ $$;
1333
+
1334
+ -- Hands out up to p_limit tickets whose receipts are due (about 15 minutes
1335
+ -- after sending; Expo keeps them a day), each leased for 30 minutes. Tickets
1336
+ -- with no receipt after a day become 'unknown'.
1337
+ create function public.genesis_notifications_receipts_claim(p_limit integer default 1000)
1338
+ returns table (id bigint, ticket_id text)
1339
+ language plpgsql
1340
+ security definer
1341
+ set search_path = ''
1342
+ as $$
1343
+ begin
1344
+ update public.push_receipts r
1345
+ set status = 'unknown', check_after = null, checked_at = now()
1346
+ where r.status = 'sent' and r.created_at < now() - interval '1 day';
1347
+ return query
1348
+ update public.push_receipts r
1349
+ set attempts = r.attempts + 1, check_after = now() + interval '30 minutes'
1350
+ where r.id in (
1351
+ select q.id from public.push_receipts q
1352
+ where q.status = 'sent' and q.check_after <= now()
1353
+ order by q.check_after
1354
+ limit greatest(1, least(coalesce(p_limit, 1000), 1000))
1355
+ for update skip locked
1356
+ )
1357
+ returning r.id, r.ticket_id;
1358
+ end;
1359
+ $$;
1360
+
1361
+ -- Records receipts: [{ "id", "status": "delivered" | "failed", "error" }]. A
1362
+ -- DeviceNotRegistered receipt forgets its device (unless registered again since
1363
+ -- the push); a delivered one marks its job delivered. Returns the receipts settled.
1364
+ create function public.genesis_notifications_receipts_settle(p_results jsonb)
1365
+ returns integer
1366
+ language plpgsql
1367
+ security definer
1368
+ set search_path = ''
1369
+ as $$
1370
+ declare
1371
+ settled integer;
1372
+ dead jsonb;
1373
+ delivered uuid[];
1374
+ begin
1375
+ if jsonb_typeof(p_results) is distinct from 'array' or jsonb_array_length(p_results) > 1000 then
1376
+ raise exception 'genesis_notifications_receipts_settle: expected an array of at most 1000 results' using errcode = '22023';
1377
+ end if;
1378
+ with results as (
1379
+ select x.id, x.status, x.error
1380
+ from jsonb_to_recordset(p_results) as x (id bigint, status text, error text)
1381
+ where x.status in ('delivered', 'failed')
1382
+ ), updated as (
1383
+ update public.push_receipts r
1384
+ set status = s.status, error = case when s.status = 'failed' then substring(s.error from '^[A-Za-z_]{1,64}$') end, check_after = null, checked_at = now()
1385
+ from results s
1386
+ where r.id = s.id and r.status = 'sent'
1387
+ returning r.device_id, r.error, r.created_at, r.status, r.delivery_id
1388
+ )
1389
+ select count(*),
1390
+ jsonb_agg(jsonb_build_object('device_id', u.device_id, 'sent_at', u.created_at)) filter (where u.error = 'DeviceNotRegistered' and u.device_id is not null),
1391
+ array_agg(distinct u.delivery_id) filter (where u.status = 'delivered' and u.delivery_id is not null)
1392
+ into settled, dead, delivered
1393
+ from updated u;
1394
+ if dead is not null then
1395
+ delete from public.push_devices v
1396
+ using jsonb_to_recordset(dead) as x (device_id uuid, sent_at timestamptz)
1397
+ where v.id = x.device_id and v.last_seen_at <= x.sent_at;
1398
+ end if;
1399
+ if delivered is not null then
1400
+ update public.notification_deliveries d set status = 'delivered', updated_at = now()
1401
+ where d.id = any (delivered) and d.status = 'sent';
1402
+ end if;
1403
+ return settled;
1404
+ end;
1405
+ $$;
1406
+
1407
+ -- Email events from the provider's webhook (Resend): 'delivered', 'bounced'
1408
+ -- (p_permanent: a hard bounce suppresses the address for all mail),
1409
+ -- 'complained' (suppresses all but transactional mail). Returns whether the
1410
+ -- email was one of ours. An event that comes before the job settles (its
1411
+ -- provider id is kept as soon as the send returns) settles it: the runner's
1412
+ -- own settle then finds the claim gone and counts it stale.
1413
+ create function public.genesis_notifications_email_event(p_provider_id text, p_event text, p_permanent boolean default true)
1414
+ returns boolean
1415
+ language plpgsql
1416
+ security definer
1417
+ set search_path = ''
1418
+ as $$
1419
+ declare
1420
+ d public.notification_deliveries;
1421
+ addr text;
1422
+ begin
1423
+ select * into d from public.notification_deliveries x where x.provider_id = p_provider_id and x.channel = 'email' limit 1;
1424
+ if not found then
1425
+ return false;
1426
+ end if;
1427
+ select coalesce(n.email, (select u.email from auth.users u where u.id = n.user_id)) into addr
1428
+ from public.notifications n where n.id = d.notification_id;
1429
+ if p_event = 'delivered' then
1430
+ update public.notification_deliveries x
1431
+ set status = 'delivered', sent_at = coalesce(x.sent_at, now()), secret_ciphertext = null, lease_until = null, claim_id = null, updated_at = now()
1432
+ where x.id = d.id and x.status in ('sent', 'sending');
1433
+ elsif p_event = 'bounced' then
1434
+ update public.notification_deliveries x
1435
+ set status = 'failed', error = case when p_permanent then 'bounced' else 'bounced_transient' end,
1436
+ secret_ciphertext = null, lease_until = null, claim_id = null, updated_at = now()
1437
+ where x.id = d.id and x.status in ('sent', 'delivered', 'sending');
1438
+ if p_permanent and addr is not null then
1439
+ insert into public.notification_suppressions (email_hash, category, reason, email_masked)
1440
+ values (private.genesis_notifications_email_hash(addr), '*', 'bounce', private.genesis_notifications_mask_email(addr))
1441
+ on conflict on constraint notification_suppressions_once do update set reason = 'bounce';
1442
+ end if;
1443
+ elsif p_event = 'complained' then
1444
+ if addr is not null then
1445
+ insert into public.notification_suppressions (email_hash, category, reason, email_masked)
1446
+ values (private.genesis_notifications_email_hash(addr), '*', 'complaint', private.genesis_notifications_mask_email(addr))
1447
+ on conflict on constraint notification_suppressions_once do nothing;
1448
+ end if;
1449
+ else
1450
+ raise exception 'genesis_notifications_email_event: unknown event' using errcode = '22023';
1451
+ end if;
1452
+ return true;
1453
+ end;
1454
+ $$;
1455
+
1456
+ -- One-click unsubscribe (the signed link's endpoint), for the tenant the email
1457
+ -- came from (p_tenant_id; null: mail sent for no tenant): a person with a login
1458
+ -- turns that tenant's email of the category off in their preferences (a choice
1459
+ -- in Settings turns it back on); someone with none is suppressed for the
1460
+ -- category and tenant, by the address's hash (the link carries only the hash).
1461
+ create function public.genesis_notifications_unsubscribe(p_user_id uuid, p_email_hash text, p_category text, p_tenant_id uuid default null)
1462
+ returns boolean
1463
+ language plpgsql
1464
+ security definer
1465
+ set search_path = ''
1466
+ as $$
1467
+ declare
1468
+ tkey text;
1469
+ begin
1470
+ if p_category !~ '^[a-z0-9][a-z0-9._-]{0,63}$' then
1471
+ raise exception 'genesis_notifications_unsubscribe: malformed category' using errcode = '22023';
1472
+ end if;
1473
+ if p_user_id is not null then
1474
+ if not exists (select 1 from auth.users u where u.id = p_user_id) then
1475
+ return false;
1476
+ end if;
1477
+ tkey := coalesce(p_tenant_id, '00000000-0000-0000-0000-000000000000'::uuid)::text;
1478
+ insert into public.notification_preferences as p (user_id, tenant_channels)
1479
+ values (p_user_id, jsonb_build_object(tkey, jsonb_build_object(p_category, jsonb_build_object('email', false))))
1480
+ on conflict (user_id) do update
1481
+ set tenant_channels = jsonb_set(
1482
+ p.tenant_channels || jsonb_build_object(tkey, coalesce(p.tenant_channels -> tkey, '{}'::jsonb)),
1483
+ array[tkey, p_category],
1484
+ coalesce(p.tenant_channels -> tkey -> p_category, '{}'::jsonb) || '{"email": false}'::jsonb);
1485
+ return true;
1486
+ end if;
1487
+ if p_email_hash is null then
1488
+ return false;
1489
+ end if;
1490
+ if p_email_hash !~ '^[0-9a-f]{64}$' then
1491
+ raise exception 'genesis_notifications_unsubscribe: malformed address hash' using errcode = '22023';
1492
+ end if;
1493
+ insert into public.notification_suppressions (email_hash, category, tenant_id, reason, email_masked)
1494
+ values (p_email_hash, p_category, p_tenant_id, 'unsubscribe', null)
1495
+ on conflict on constraint notification_suppressions_once do nothing;
1496
+ return true;
1497
+ end;
1498
+ $$;
1499
+
1500
+ -- Erases a person: their notifications (and with them every send job), and
1501
+ -- for a login also their devices, receipts, preferences and device moves.
1502
+ -- Pass what you know: the login (which also finds rows addressed to the
1503
+ -- login's own email), the address (someone with no login), the domain's ref. Suppressions stay: they hold a hash only, and keep a bounced
1504
+ -- address from being mailed again. Returns the notifications deleted.
1505
+ create function public.genesis_notifications_erase(p_user_id uuid default null, p_email text default null, p_person_ref text default null)
1506
+ returns integer
1507
+ language plpgsql
1508
+ security definer
1509
+ set search_path = ''
1510
+ as $$
1511
+ declare
1512
+ gone integer;
1513
+ login_email text;
1514
+ begin
1515
+ if p_user_id is null and p_email is null and p_person_ref is null then
1516
+ raise exception 'genesis_notifications_erase: say whom' using errcode = '22023';
1517
+ end if;
1518
+ if p_user_id is not null then
1519
+ select u.email into login_email from auth.users u where u.id = p_user_id;
1520
+ end if;
1521
+ delete from public.notifications n
1522
+ where (p_user_id is not null and n.user_id = p_user_id)
1523
+ or (p_email is not null and lower(n.email) = lower(btrim(p_email)))
1524
+ or (login_email is not null and lower(n.email) = lower(btrim(login_email)))
1525
+ or (p_person_ref is not null and n.person_ref = p_person_ref);
1526
+ get diagnostics gone = row_count;
1527
+ if p_user_id is not null then
1528
+ delete from public.push_devices v where v.user_id = p_user_id;
1529
+ delete from public.push_receipts r where r.user_id = p_user_id;
1530
+ delete from public.notification_preferences p where p.user_id = p_user_id;
1531
+ delete from public.push_device_moves m where m.from_user_id = p_user_id or m.to_user_id = p_user_id;
1532
+ end if;
1533
+ return gone;
1534
+ end;
1535
+ $$;
1536
+
1537
+ -- Keeps the delivery log short: send jobs settled more than p_keep ago are
1538
+ -- deleted (default 30 days), the rendered email of a settled job after
1539
+ -- p_keep_rendered (default a day), receipts, device moves and notifications
1540
+ -- that were never in a feed (email-only recipients) after p_keep, and, when
1541
+ -- p_keep_feed is given, feed rows older than that. Schedule it daily.
1542
+ create function public.genesis_notifications_prune(
1543
+ p_keep interval default interval '30 days',
1544
+ p_keep_rendered interval default interval '1 day',
1545
+ p_keep_feed interval default null
1546
+ )
1547
+ returns jsonb
1548
+ language plpgsql
1549
+ security definer
1550
+ set search_path = ''
1551
+ as $$
1552
+ declare
1553
+ jobs integer;
1554
+ bodies integer;
1555
+ feed integer := 0;
1556
+ begin
1557
+ if p_keep < interval '1 hour' or p_keep_rendered < interval '0' then
1558
+ raise exception 'genesis_notifications_prune: keep at least an hour' using errcode = '22023';
1559
+ end if;
1560
+ update public.notification_deliveries d set rendered = null, updated_at = now()
1561
+ where d.rendered is not null and d.status not in ('pending', 'sending') and d.updated_at < now() - p_keep_rendered;
1562
+ get diagnostics bodies = row_count;
1563
+ delete from public.notification_deliveries d
1564
+ where d.status not in ('pending', 'sending') and d.updated_at < now() - p_keep;
1565
+ get diagnostics jobs = row_count;
1566
+ delete from public.push_receipts r where r.created_at < now() - p_keep;
1567
+ delete from public.push_device_moves m where m.moved_at < now() - p_keep;
1568
+ -- Rows that were never in a feed (email-only recipients, or the in-app channel off) are only
1569
+ -- the send log's: they go with it, address and all.
1570
+ delete from public.notifications n
1571
+ where not n.in_feed and n.created_at < now() - p_keep
1572
+ and not exists (select 1 from public.notification_deliveries d where d.notification_id = n.id);
1573
+ if p_keep_feed is not null then
1574
+ delete from public.notifications n
1575
+ where n.created_at < now() - p_keep_feed
1576
+ and not exists (select 1 from public.notification_deliveries d where d.notification_id = n.id and d.status in ('pending', 'sending'));
1577
+ get diagnostics feed = row_count;
1578
+ end if;
1579
+ return jsonb_build_object('deliveries', jobs, 'rendered', bodies, 'feed', feed);
1580
+ end;
1581
+ $$;
1582
+
1583
+ -- Mirrors the code's types: [{ key, category, channels, critical, transactional,
1584
+ -- email_delay_seconds, secret_link, push_privacy, ios_category }]. Types no
1585
+ -- longer in the list are retired (never deleted: the feed still names them);
1586
+ -- one that comes back is un-retired. Returns the rows written.
1587
+ create function public.genesis_notifications_sync_types(p_rows jsonb)
1588
+ returns integer
1589
+ language plpgsql
1590
+ security definer
1591
+ set search_path = ''
1592
+ as $$
1593
+ declare
1594
+ written integer;
1595
+ begin
1596
+ if jsonb_typeof(p_rows) is distinct from 'array' then
1597
+ raise exception 'genesis_notifications_sync_types: expected an array' using errcode = '22023';
1598
+ end if;
1599
+ insert into public.notification_types as t (key, category, channels, critical, transactional, email_delay_seconds, secret_link, push_privacy, ios_category, retired_at, updated_at)
1600
+ select x.key, x.category, x.channels, coalesce(x.critical, false), coalesce(x.transactional, false), coalesce(x.email_delay_seconds, 0),
1601
+ coalesce(x.secret_link, false), coalesce(x.push_privacy, 'default'), x.ios_category, null, now()
1602
+ from jsonb_to_recordset(p_rows) as x (key text, category text, channels jsonb, critical boolean, transactional boolean, email_delay_seconds integer, secret_link boolean, push_privacy text, ios_category text)
1603
+ on conflict (key) do update
1604
+ set category = excluded.category, channels = excluded.channels, critical = excluded.critical, transactional = excluded.transactional,
1605
+ email_delay_seconds = excluded.email_delay_seconds, secret_link = excluded.secret_link, push_privacy = excluded.push_privacy,
1606
+ ios_category = excluded.ios_category, retired_at = null, updated_at = now();
1607
+ get diagnostics written = row_count;
1608
+ update public.notification_types t set retired_at = now(), updated_at = now()
1609
+ where t.retired_at is null and t.key <> 'genesis.test'
1610
+ and t.key not in (select x.key from jsonb_to_recordset(p_rows) as x (key text));
1611
+ return written;
1612
+ end;
1613
+ $$;
1614
+
1615
+ -- Sets the outcome of a sender domain check (the server runs it: the ownership
1616
+ -- TXT record with the sender's domain_token, SPF, DKIM, DMARC) for the domain
1617
+ -- and token that were checked: if the tenant changed its domain since (even
1618
+ -- back again, which makes a new token), nothing changes (false). A domain verified for another tenant is refused (23505).
1619
+ create function public.genesis_notifications_set_sender_dns(p_tenant uuid, p_domain text, p_token text, p_status text, p_dns jsonb)
1620
+ returns boolean
1621
+ language plpgsql
1622
+ security definer
1623
+ set search_path = ''
1624
+ as $$
1625
+ begin
1626
+ if p_status is null or p_status not in ('pending', 'verified', 'failed') then
1627
+ raise exception 'genesis_notifications_set_sender_dns: status is pending, verified or failed' using errcode = '22023';
1628
+ end if;
1629
+ begin
1630
+ update public.notification_senders s
1631
+ set domain_status = p_status, dns = p_dns, checked_at = now(), updated_at = now()
1632
+ where s.tenant_id = p_tenant and s.domain = lower(p_domain) and s.domain_token = p_token;
1633
+ exception
1634
+ when unique_violation then
1635
+ raise exception 'genesis_notifications_set_sender_dns: that domain is verified for another tenant' using errcode = '23505';
1636
+ end;
1637
+ return found;
1638
+ end;
1639
+ $$;
1640
+
1641
+ -- The person's own --------------------------------------------------------
1642
+
1643
+ -- Marks the caller's own notifications: 'seen', 'read', 'unread', 'archived'
1644
+ -- or 'unarchived'. Returns how many changed.
1645
+ create function public.genesis_notifications_mark(p_ids uuid[], p_state text)
1646
+ returns integer
1647
+ language plpgsql
1648
+ security definer
1649
+ set search_path = ''
1650
+ as $$
1651
+ declare
1652
+ me uuid := (select auth.uid());
1653
+ changed integer;
1654
+ begin
1655
+ if me is null then
1656
+ raise exception 'genesis_notifications_mark: sign in first' using errcode = '42501';
1657
+ end if;
1658
+ if p_state not in ('seen', 'read', 'unread', 'archived', 'unarchived') then
1659
+ raise exception 'genesis_notifications_mark: unknown state' using errcode = '22023';
1660
+ end if;
1661
+ if coalesce(array_length(p_ids, 1), 0) > 500 then
1662
+ raise exception 'genesis_notifications_mark: at most 500 at a time' using errcode = '22023';
1663
+ end if;
1664
+ update public.notifications n
1665
+ set seen_at = case when p_state in ('seen', 'read') then coalesce(n.seen_at, now()) else n.seen_at end,
1666
+ read_at = case when p_state = 'read' then coalesce(n.read_at, now()) when p_state = 'unread' then null else n.read_at end,
1667
+ archived_at = case when p_state = 'archived' then coalesce(n.archived_at, now()) when p_state = 'unarchived' then null else n.archived_at end
1668
+ where n.id = any (p_ids) and n.user_id = me and n.in_feed;
1669
+ get diagnostics changed = row_count;
1670
+ if changed > 0 then
1671
+ perform private.genesis_notifications_broadcast(me, 'notification', jsonb_build_object('kind', p_state));
1672
+ end if;
1673
+ return changed;
1674
+ end;
1675
+ $$;
1676
+
1677
+ -- Marks all the caller's feed 'seen' or 'read'. Returns how many changed.
1678
+ create function public.genesis_notifications_mark_all(p_state text)
1679
+ returns integer
1680
+ language plpgsql
1681
+ security definer
1682
+ set search_path = ''
1683
+ as $$
1684
+ declare
1685
+ me uuid := (select auth.uid());
1686
+ changed integer;
1687
+ begin
1688
+ if me is null then
1689
+ raise exception 'genesis_notifications_mark_all: sign in first' using errcode = '42501';
1690
+ end if;
1691
+ if p_state not in ('seen', 'read') then
1692
+ raise exception 'genesis_notifications_mark_all: seen or read' using errcode = '22023';
1693
+ end if;
1694
+ update public.notifications n
1695
+ set seen_at = coalesce(n.seen_at, now()),
1696
+ read_at = case when p_state = 'read' then coalesce(n.read_at, now()) else n.read_at end
1697
+ where n.user_id = me and n.in_feed and n.archived_at is null
1698
+ and (n.seen_at is null or (p_state = 'read' and n.read_at is null));
1699
+ get diagnostics changed = row_count;
1700
+ if changed > 0 then
1701
+ perform private.genesis_notifications_broadcast(me, 'notification', jsonb_build_object('kind', p_state));
1702
+ end if;
1703
+ return changed;
1704
+ end;
1705
+ $$;
1706
+
1707
+ -- Sets (or with p_on null, clears back to the default) the caller's choice for
1708
+ -- one category and channel, merged into their row so no other choice changes.
1709
+ -- A category whose types are all critical can't be turned off.
1710
+ create function public.genesis_notifications_set_preference(p_category text, p_channel text, p_on boolean)
1711
+ returns jsonb
1712
+ language plpgsql
1713
+ security definer
1714
+ set search_path = ''
1715
+ as $$
1716
+ declare
1717
+ me uuid := (select auth.uid());
1718
+ result jsonb;
1719
+ begin
1720
+ if me is null then
1721
+ raise exception 'genesis_notifications_set_preference: sign in first' using errcode = '42501';
1722
+ end if;
1723
+ if p_channel not in ('in_app', 'push', 'email') then
1724
+ raise exception 'genesis_notifications_set_preference: unknown channel' using errcode = '22023';
1725
+ end if;
1726
+ if not exists (select 1 from public.notification_types t where t.category = p_category and t.retired_at is null and t.channels ? p_channel) then
1727
+ -- Its own code (undefined_object), so a caller can tell it from a refusal.
1728
+ raise exception 'genesis_notifications_set_preference: no type in that category uses that channel' using errcode = '42704';
1729
+ end if;
1730
+ if p_on is false and not exists (
1731
+ select 1 from public.notification_types t where t.category = p_category and t.retired_at is null and t.channels ? p_channel and not t.critical
1732
+ ) then
1733
+ raise exception 'genesis_notifications_set_preference: that category can''t be turned off' using errcode = '22023';
1734
+ end if;
1735
+ -- 0005's switch for the whole category (push and email), if the person had turned it off,
1736
+ -- becomes explicit "off" choices first, so this change doesn't bring back what they turned
1737
+ -- off. A switch left on meant only "the default", so it carries over as nothing.
1738
+ update public.notification_preferences p
1739
+ set channels = jsonb_set(
1740
+ p.channels, array[p_category],
1741
+ case when (p.categories ->> p_category)::boolean is false then '{"push": false, "email": false}'::jsonb else '{}'::jsonb end
1742
+ || coalesce(p.channels -> p_category, '{}'::jsonb)),
1743
+ categories = p.categories - p_category
1744
+ where p.user_id = me and p.categories ? p_category;
1745
+ insert into public.notification_preferences as p (user_id, channels)
1746
+ values (me, case when p_on is null then '{}'::jsonb else jsonb_build_object(p_category, jsonb_build_object(p_channel, p_on)) end)
1747
+ on conflict (user_id) do update
1748
+ set channels = case
1749
+ when p_on is null then
1750
+ case when coalesce(p.channels -> p_category, '{}'::jsonb) - p_channel = '{}'::jsonb then p.channels - p_category
1751
+ else jsonb_set(p.channels, array[p_category], (p.channels -> p_category) - p_channel) end
1752
+ else jsonb_set(p.channels, array[p_category], coalesce(p.channels -> p_category, '{}'::jsonb) || jsonb_build_object(p_channel, p_on))
1753
+ end,
1754
+ -- A choice made here applies to every tenant: it clears that cell's per-tenant choices (unsubscribes).
1755
+ tenant_channels = coalesce((
1756
+ select jsonb_object_agg(t.key, t.value #- array[p_category, p_channel])
1757
+ from pg_catalog.jsonb_each(p.tenant_channels) t
1758
+ ), '{}'::jsonb)
1759
+ returning p.channels into result;
1760
+ return result;
1761
+ end;
1762
+ $$;
1763
+
1764
+ -- Cancels what hasn't gone out for a type, tenant (null: the notifications
1765
+ -- made with no tenant) and idempotency key (a release rolled back, an event
1766
+ -- undone): every pending send job of the notifications made with them, for
1767
+ -- all recipients or only those in p_recipients (the same shape as
1768
+ -- genesis_notify's). A job already sent is left alone; one being sent right
1769
+ -- now ends cancelled unless that attempt goes out (it is never retried). With
1770
+ -- p_archive, the feed rows are archived too. The runner never claims a
1771
+ -- cancelled job. Returns how many jobs and feed rows changed.
1772
+ create function public.genesis_notifications_cancel(p_type text, p_idempotency_key text, p_tenant_id uuid default null, p_recipients jsonb default null, p_archive boolean default false)
1773
+ returns table (cancelled integer, archived integer)
1774
+ language plpgsql
1775
+ security definer
1776
+ set search_path = ''
1777
+ as $$
1778
+ declare
1779
+ keys text[];
1780
+ flagged integer;
1781
+ begin
1782
+ if p_type is null then
1783
+ raise exception 'genesis_notifications_cancel: a type is required' using errcode = '22023';
1784
+ end if;
1785
+ if p_idempotency_key is null or char_length(p_idempotency_key) not between 1 and 200 then
1786
+ raise exception 'genesis_notifications_cancel: an idempotency key (1 to 200 characters) is required' using errcode = '22023';
1787
+ end if;
1788
+ if p_recipients is not null then
1789
+ if jsonb_typeof(p_recipients) <> 'array' or jsonb_array_length(p_recipients) > 1000 then
1790
+ raise exception 'genesis_notifications_cancel: recipients is an array of at most 1000' using errcode = '22023';
1791
+ end if;
1792
+ begin
1793
+ select array_agg(case
1794
+ when jsonb_typeof(r) = 'string' then ((r #>> '{}')::uuid)::text
1795
+ when r ? 'user_id' then ((r ->> 'user_id')::uuid)::text
1796
+ when r ? 'email' then 'email:' || lower(btrim(r ->> 'email'))
1797
+ end)
1798
+ into keys
1799
+ from jsonb_array_elements(p_recipients) r;
1800
+ exception
1801
+ when invalid_text_representation then
1802
+ raise exception 'genesis_notifications_cancel: a recipient is not a user id' using errcode = '22023';
1803
+ end;
1804
+ end if;
1805
+
1806
+ update public.notification_deliveries d
1807
+ set status = 'cancelled', skip_reason = 'cancelled', secret_ciphertext = null, lease_until = null, updated_at = now()
1808
+ from public.notifications n
1809
+ where d.notification_id = n.id
1810
+ and n.idempotency_key = p_idempotency_key and n.type = p_type and n.tenant_id is not distinct from p_tenant_id
1811
+ and (keys is null or n.recipient_key = any (keys))
1812
+ and d.status = 'pending';
1813
+ get diagnostics cancelled = row_count;
1814
+ -- Being sent right now: settle won't put it back in the queue, and an expired lease ends it.
1815
+ update public.notification_deliveries d
1816
+ set cancel_requested = 'cancelled', updated_at = now()
1817
+ from public.notifications n
1818
+ where d.notification_id = n.id
1819
+ and n.idempotency_key = p_idempotency_key and n.type = p_type and n.tenant_id is not distinct from p_tenant_id
1820
+ and (keys is null or n.recipient_key = any (keys))
1821
+ and d.status = 'sending' and d.cancel_requested is distinct from 'cancelled';
1822
+ get diagnostics flagged = row_count;
1823
+ cancelled := cancelled + flagged;
1824
+
1825
+ archived := 0;
1826
+ if coalesce(p_archive, false) then
1827
+ update public.notifications n
1828
+ set archived_at = now()
1829
+ where n.idempotency_key = p_idempotency_key and n.type = p_type and n.tenant_id is not distinct from p_tenant_id
1830
+ and (keys is null or n.recipient_key = any (keys))
1831
+ and n.archived_at is null;
1832
+ get diagnostics archived = row_count;
1833
+ end if;
1834
+ return next;
1835
+ end;
1836
+ $$;
1837
+
1838
+ -- Admins (private.genesis_notifications_admin decides) -------------------------
1839
+
1840
+ -- A tenant's default for a category and channel (p_enabled null removes it).
1841
+ create function public.genesis_notifications_set_tenant_default(p_tenant uuid, p_category text, p_channel text, p_enabled boolean)
1842
+ returns boolean
1843
+ language plpgsql
1844
+ security definer
1845
+ set search_path = ''
1846
+ as $$
1847
+ begin
1848
+ if not private.genesis_notifications_admin((select auth.uid()), p_tenant) or p_tenant is null then
1849
+ raise exception 'genesis_notifications_set_tenant_default: not allowed' using errcode = '42501';
1850
+ end if;
1851
+ if p_enabled is null then
1852
+ delete from public.notification_tenant_defaults d where d.tenant_id = p_tenant and d.category = p_category and d.channel = p_channel;
1853
+ else
1854
+ insert into public.notification_tenant_defaults as d (tenant_id, category, channel, enabled)
1855
+ values (p_tenant, p_category, p_channel, p_enabled)
1856
+ on conflict (tenant_id, category, channel) do update set enabled = excluded.enabled, updated_at = now();
1857
+ end if;
1858
+ return true;
1859
+ end;
1860
+ $$;
1861
+
1862
+ -- A tenant's email identity. Changing the domain makes it 'pending' until
1863
+ -- checked, with a fresh ownership token (domain_token) for the TXT record.
1864
+ -- A domain already verified for another tenant is refused (23505).
1865
+ create function public.genesis_notifications_set_sender(p_tenant uuid, p_from_name text, p_reply_to text, p_domain text default null, p_brand jsonb default null)
1866
+ returns boolean
1867
+ language plpgsql
1868
+ security definer
1869
+ set search_path = ''
1870
+ as $$
1871
+ begin
1872
+ if p_tenant is null or not private.genesis_notifications_admin((select auth.uid()), p_tenant) then
1873
+ raise exception 'genesis_notifications_set_sender: not allowed' using errcode = '42501';
1874
+ end if;
1875
+ if p_domain is not null and exists (
1876
+ select 1 from public.notification_senders o where o.domain = lower(p_domain) and o.domain_status = 'verified' and o.tenant_id <> p_tenant
1877
+ ) then
1878
+ raise exception 'genesis_notifications_set_sender: that domain is verified for another tenant' using errcode = '23505';
1879
+ end if;
1880
+ insert into public.notification_senders as s (tenant_id, from_name, reply_to, domain, domain_status, domain_token, brand)
1881
+ values (p_tenant, p_from_name, p_reply_to, lower(p_domain), case when p_domain is null then 'none' else 'pending' end,
1882
+ case when p_domain is not null then encode(extensions.gen_random_bytes(16), 'hex') end, p_brand)
1883
+ on conflict (tenant_id) do update
1884
+ set from_name = excluded.from_name, reply_to = excluded.reply_to, brand = excluded.brand,
1885
+ domain = excluded.domain,
1886
+ domain_status = case when excluded.domain is not distinct from s.domain then s.domain_status when excluded.domain is null then 'none' else 'pending' end,
1887
+ domain_token = case when excluded.domain is not distinct from s.domain then s.domain_token else excluded.domain_token end,
1888
+ dns = case when excluded.domain is not distinct from s.domain then s.dns end,
1889
+ updated_at = now();
1890
+ return true;
1891
+ end;
1892
+ $$;
1893
+
1894
+ -- Admin -> Send a test: a 'genesis.test' notification to the admin themselves,
1895
+ -- on every channel they have (transactional, so quiet hours don't hold it).
1896
+ create function public.genesis_notifications_send_test(p_tenant uuid default null)
1897
+ returns uuid
1898
+ language plpgsql
1899
+ security definer
1900
+ set search_path = ''
1901
+ as $$
1902
+ declare
1903
+ me uuid := (select auth.uid());
1904
+ made uuid;
1905
+ begin
1906
+ if me is null or not private.genesis_notifications_admin(me, p_tenant) then
1907
+ raise exception 'genesis_notifications_send_test: not allowed' using errcode = '42501';
1908
+ end if;
1909
+ select g.notification_id into made
1910
+ from public.genesis_notify('genesis.test', jsonb_build_array(jsonb_build_object('user_id', me)), jsonb_build_object('sent_at', now()),
1911
+ 'genesis.test:' || gen_random_uuid()::text, null, null, null, p_tenant, null, null) g;
1912
+ return made;
1913
+ end;
1914
+ $$;
1915
+
1916
+ -- Lifts a suppression (an address fixed, a complaint withdrawn). Platform admins only.
1917
+ create function public.genesis_notifications_remove_suppression(p_email_hash text, p_category text default '*', p_tenant_id uuid default null)
1918
+ returns boolean
1919
+ language plpgsql
1920
+ security definer
1921
+ set search_path = ''
1922
+ as $$
1923
+ begin
1924
+ if not private.genesis_notifications_admin((select auth.uid()), null) then
1925
+ raise exception 'genesis_notifications_remove_suppression: not allowed' using errcode = '42501';
1926
+ end if;
1927
+ delete from public.notification_suppressions s
1928
+ where s.email_hash = p_email_hash and s.category = p_category and s.tenant_id is not distinct from p_tenant_id;
1929
+ return found;
1930
+ end;
1931
+ $$;
1932
+
1933
+ -- Row level security ---------------------------------------------------------
1934
+
1935
+ alter table public.notifications enable row level security;
1936
+ create policy notifications_owner_read on public.notifications
1937
+ for select to authenticated
1938
+ using (user_id = (select auth.uid()) and in_feed);
1939
+ revoke all on table public.notifications from public, anon, authenticated;
1940
+ grant select (id, type, category, tenant_id, data, thread_key, created_at, seen_at, read_at, archived_at, in_feed, user_id)
1941
+ on table public.notifications to authenticated;
1942
+ grant select, insert, update, delete on table public.notifications to service_role;
1943
+
1944
+ alter table public.notification_deliveries enable row level security;
1945
+ create policy notification_deliveries_admin_read on public.notification_deliveries
1946
+ for select to authenticated
1947
+ using (private.genesis_notifications_admin((select auth.uid()), tenant_id) or private.genesis_notifications_admin((select auth.uid()), null));
1948
+ revoke all on table public.notification_deliveries from public, anon, authenticated;
1949
+ -- The log columns only: never the rendered email or the secret.
1950
+ grant select (id, notification_id, tenant_id, type, channel, status, attempts, deferrals, send_after, provider_id, error, skip_reason, recipient_masked, created_at, updated_at, sent_at)
1951
+ on table public.notification_deliveries to authenticated;
1952
+ grant select, insert, update, delete on table public.notification_deliveries to service_role;
1953
+
1954
+ alter table public.notification_senders enable row level security;
1955
+ create policy notification_senders_admin_read on public.notification_senders
1956
+ for select to authenticated
1957
+ using (private.genesis_notifications_admin((select auth.uid()), tenant_id));
1958
+ revoke all on table public.notification_senders from public, anon, authenticated;
1959
+ grant select on table public.notification_senders to authenticated;
1960
+ grant select, insert, update, delete on table public.notification_senders to service_role;
1961
+
1962
+ alter table public.notification_tenant_defaults enable row level security;
1963
+ create policy notification_tenant_defaults_admin_read on public.notification_tenant_defaults
1964
+ for select to authenticated
1965
+ using (private.genesis_notifications_admin((select auth.uid()), tenant_id));
1966
+ revoke all on table public.notification_tenant_defaults from public, anon, authenticated;
1967
+ grant select on table public.notification_tenant_defaults to authenticated;
1968
+ grant select, insert, update, delete on table public.notification_tenant_defaults to service_role;
1969
+
1970
+ alter table public.notification_suppressions enable row level security;
1971
+ create policy notification_suppressions_admin_read on public.notification_suppressions
1972
+ for select to authenticated
1973
+ using (private.genesis_notifications_admin((select auth.uid()), null));
1974
+ revoke all on table public.notification_suppressions from public, anon, authenticated;
1975
+ grant select on table public.notification_suppressions to authenticated;
1976
+ grant select, insert, update, delete on table public.notification_suppressions to service_role;
1977
+
1978
+ -- Only the person themself may listen on their private channel.
1979
+ do $rt$
1980
+ begin
1981
+ if to_regclass('realtime.messages') is not null
1982
+ and not exists (select 1 from pg_policies where schemaname = 'realtime' and tablename = 'messages' and policyname = 'genesis_notifications_own_channel') then
1983
+ execute $p$
1984
+ create policy genesis_notifications_own_channel on realtime.messages
1985
+ for select to authenticated
1986
+ using (realtime.topic() = 'user:' || (select auth.uid())::text and extension = 'broadcast')
1987
+ $p$;
1988
+ end if;
1989
+ end;
1990
+ $rt$;
1991
+
1992
+ -- Privileges by name ---------------------------------------------------------
1993
+
1994
+ revoke execute on function private.genesis_notifications_channels_check() from public, anon, authenticated;
1995
+ revoke execute on function private.genesis_notifications_admin(uuid, uuid) from public, anon, authenticated;
1996
+ revoke execute on function private.genesis_notifications_email_hash(text) from public, anon, authenticated;
1997
+ revoke execute on function private.genesis_notifications_mask_email(text) from public, anon, authenticated;
1998
+ revoke execute on function private.genesis_notifications_assert_no_secrets(jsonb) from public, anon, authenticated;
1999
+ revoke execute on function private.genesis_notifications_job_key() from public, anon, authenticated;
2000
+ revoke execute on function private.genesis_notifications_broadcast(uuid, text, jsonb) from public, anon, authenticated;
2001
+ revoke execute on function private.genesis_notifications_local_instant(timestamp, text) from public, anon, authenticated;
2002
+ revoke execute on function private.genesis_notifications_quiet_until(uuid, timestamptz) from public, anon, authenticated;
2003
+ revoke execute on function private.genesis_notifications_channel_on(uuid, uuid, text, text) from public, anon, authenticated;
2004
+
2005
+ revoke execute on function public.genesis_push_register(text, text, text, uuid) from public, anon, authenticated;
2006
+ revoke execute on function public.genesis_push_register(text, text, text) from public, anon, authenticated;
2007
+ revoke execute on function public.genesis_push_forget(text) from public, anon, authenticated;
2008
+ revoke execute on function public.genesis_notify(text, jsonb, jsonb, text, text, text, integer, uuid, jsonb, text) from public, anon, authenticated;
2009
+ revoke execute on function public.genesis_notifications_claim(text, integer, integer, integer) from public, anon, authenticated;
2010
+ revoke execute on function public.genesis_notifications_store_rendered(uuid, uuid, jsonb, text) from public, anon, authenticated;
2011
+ revoke execute on function public.genesis_notifications_settle(uuid, uuid, jsonb) from public, anon, authenticated;
2012
+ revoke execute on function public.genesis_notifications_record_provider_id(uuid, uuid, text) from public, anon, authenticated;
2013
+ revoke execute on function public.genesis_notifications_push_tickets(uuid, jsonb) from public, anon, authenticated;
2014
+ revoke execute on function public.genesis_notifications_receipts_claim(integer) from public, anon, authenticated;
2015
+ revoke execute on function public.genesis_notifications_receipts_settle(jsonb) from public, anon, authenticated;
2016
+ revoke execute on function public.genesis_notifications_email_event(text, text, boolean) from public, anon, authenticated;
2017
+ revoke execute on function public.genesis_notifications_unsubscribe(uuid, text, text, uuid) from public, anon, authenticated;
2018
+ revoke execute on function public.genesis_notifications_erase(uuid, text, text) from public, anon, authenticated;
2019
+ revoke execute on function public.genesis_notifications_cancel(text, text, uuid, jsonb, boolean) from public, anon, authenticated;
2020
+ revoke execute on function public.genesis_notifications_prune(interval, interval, interval) from public, anon, authenticated;
2021
+ revoke execute on function public.genesis_notifications_sync_types(jsonb) from public, anon, authenticated;
2022
+ revoke execute on function public.genesis_notifications_set_sender_dns(uuid, text, text, text, jsonb) from public, anon, authenticated;
2023
+ revoke execute on function public.genesis_notifications_mark(uuid[], text) from public, anon, authenticated;
2024
+ revoke execute on function public.genesis_notifications_mark_all(text) from public, anon, authenticated;
2025
+ revoke execute on function public.genesis_notifications_set_preference(text, text, boolean) from public, anon, authenticated;
2026
+ revoke execute on function public.genesis_notifications_set_tenant_default(uuid, text, text, boolean) from public, anon, authenticated;
2027
+ revoke execute on function public.genesis_notifications_set_sender(uuid, text, text, text, jsonb) from public, anon, authenticated;
2028
+ revoke execute on function public.genesis_notifications_send_test(uuid) from public, anon, authenticated;
2029
+ revoke execute on function public.genesis_notifications_remove_suppression(text, text, uuid) from public, anon, authenticated;
2030
+
2031
+ grant execute on function private.genesis_notifications_admin(uuid, uuid) to authenticated;
2032
+ grant execute on function public.genesis_push_register(text, text, text, uuid) to authenticated;
2033
+ grant execute on function public.genesis_push_register(text, text, text) to authenticated;
2034
+ grant execute on function public.genesis_push_forget(text) to anon, authenticated;
2035
+ grant execute on function public.genesis_notifications_mark(uuid[], text) to authenticated;
2036
+ grant execute on function public.genesis_notifications_mark_all(text) to authenticated;
2037
+ grant execute on function public.genesis_notifications_set_preference(text, text, boolean) to authenticated;
2038
+ grant execute on function public.genesis_notifications_set_tenant_default(uuid, text, text, boolean) to authenticated;
2039
+ grant execute on function public.genesis_notifications_set_sender(uuid, text, text, text, jsonb) to authenticated;
2040
+ grant execute on function public.genesis_notifications_send_test(uuid) to authenticated;
2041
+ grant execute on function public.genesis_notifications_remove_suppression(text, text, uuid) to authenticated;
2042
+
2043
+ grant execute on function public.genesis_notify(text, jsonb, jsonb, text, text, text, integer, uuid, jsonb, text) to service_role;
2044
+ grant execute on function public.genesis_notifications_claim(text, integer, integer, integer) to service_role;
2045
+ grant execute on function public.genesis_notifications_store_rendered(uuid, uuid, jsonb, text) to service_role;
2046
+ grant execute on function public.genesis_notifications_settle(uuid, uuid, jsonb) to service_role;
2047
+ grant execute on function public.genesis_notifications_record_provider_id(uuid, uuid, text) to service_role;
2048
+ grant execute on function public.genesis_notifications_push_tickets(uuid, jsonb) to service_role;
2049
+ grant execute on function public.genesis_notifications_receipts_claim(integer) to service_role;
2050
+ grant execute on function public.genesis_notifications_receipts_settle(jsonb) to service_role;
2051
+ grant execute on function public.genesis_notifications_email_event(text, text, boolean) to service_role;
2052
+ grant execute on function public.genesis_notifications_unsubscribe(uuid, text, text, uuid) to service_role;
2053
+ grant execute on function public.genesis_notifications_erase(uuid, text, text) to service_role;
2054
+ grant execute on function public.genesis_notifications_cancel(text, text, uuid, jsonb, boolean) to service_role;
2055
+ grant execute on function public.genesis_notifications_prune(interval, interval, interval) to service_role;
2056
+ grant execute on function public.genesis_notifications_sync_types(jsonb) to service_role;
2057
+ grant execute on function public.genesis_notifications_set_sender_dns(uuid, text, text, text, jsonb) to service_role;