@marktiderman/genesis-supabase 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +367 -23
  2. package/dist/blocks.d.ts +19 -0
  3. package/dist/blocks.js +3 -0
  4. package/dist/blocks.js.map +1 -0
  5. package/dist/chunk-FAA2FTF4.js +110 -0
  6. package/dist/chunk-FAA2FTF4.js.map +1 -0
  7. package/dist/chunk-T2HGFC4C.js +37 -0
  8. package/dist/chunk-T2HGFC4C.js.map +1 -0
  9. package/dist/index-BSGz3RGk.d.ts +317 -0
  10. package/dist/index.d.ts +4 -103
  11. package/dist/index.js +60 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/native.d.ts +90 -6
  14. package/dist/native.js +70 -2
  15. package/dist/native.js.map +1 -1
  16. package/functions/genesis-apple-revoke/apple.ts +237 -0
  17. package/functions/genesis-apple-revoke/handler.ts +227 -0
  18. package/functions/genesis-apple-revoke/index.ts +55 -0
  19. package/functions/genesis-push-send/expo.ts +117 -0
  20. package/functions/genesis-push-send/handler.ts +462 -0
  21. package/functions/genesis-push-send/index.ts +54 -0
  22. package/functions/genesis-support-cleanup/handler.ts +80 -0
  23. package/functions/genesis-support-cleanup/index.ts +39 -0
  24. package/functions/genesis-support-intake/contract.ts +193 -0
  25. package/functions/genesis-support-intake/handler.ts +170 -0
  26. package/functions/genesis-support-intake/index.ts +47 -0
  27. package/functions/genesis-support-notify/handler.ts +167 -0
  28. package/functions/genesis-support-notify/index.ts +15 -0
  29. package/functions/tests/genesis-apple-revoke_test.ts +81 -0
  30. package/functions/tests/genesis-push-send_test.ts +53 -0
  31. package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
  32. package/migrations/0004_genesis_tenant_features.sql +333 -0
  33. package/migrations/0005_genesis_push.sql +524 -0
  34. package/migrations/0006_genesis_support.sql +846 -0
  35. package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
  36. package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
  37. package/migrations/tests/0005_genesis_push.test.sql +407 -0
  38. package/migrations/tests/0006_genesis_support.test.sql +552 -0
  39. package/package.json +91 -4
@@ -0,0 +1,524 @@
1
+ -- Genesis push: the devices a person gets pushes on, their notification
2
+ -- preferences, the receipts of what was sent, and who a push may go to now.
3
+ --
4
+ -- The client half is `@marktiderman/genesis-native/push` (it registers this
5
+ -- device's Expo push token and edits the preferences); the sender is the
6
+ -- `genesis-push-send` Edge Function shipped beside this file in
7
+ -- @marktiderman/genesis-supabase.
8
+ --
9
+ -- Use: copy this file into your app's `supabase/migrations/` under a new
10
+ -- timestamped name. Never edit it once applied; change it with a new
11
+ -- migration. It stands alone (it needs none of 0001 to 0004).
12
+ --
13
+ -- Tables (all in `public`, row level security on):
14
+ -- push_devices one row per device token: whose it is, the
15
+ -- platform, the app version, when it was last seen.
16
+ -- The owner reads and deletes their own rows; rows
17
+ -- are written only through genesis_push_register.
18
+ -- notification_preferences one row per person: categories turned off or on,
19
+ -- quiet hours (start, end, IANA time zone) and
20
+ -- hide_previews. The owner reads and writes their
21
+ -- own row; absent means the defaults.
22
+ -- push_receipts one row per message handed to Expo: category,
23
+ -- Expo's ticket id and status, never the content.
24
+ -- The owner reads their own; the sender writes.
25
+ --
26
+ -- Who can do what (granted and revoked by name, never in bulk):
27
+ -- authenticated genesis_push_register (its own token)
28
+ -- anon, genesis_push_forget(token): holding the token is the proof,
29
+ -- authenticated so a phone whose session has already ended (signed out,
30
+ -- expired) still stops getting that person's pushes.
31
+ -- service_role genesis_push_status / genesis_push_targets (who to push to
32
+ -- now), genesis_push_tickets_record, genesis_push_receipts_claim,
33
+ -- genesis_push_receipts_settle, genesis_push_devices_forget.
34
+ --
35
+ -- An Expo push token is a credential: with it (and no Expo access token
36
+ -- required on the project) anyone can push to that phone. Nothing here hands
37
+ -- one to anyone but its owner and the service role; turn on "Enhanced Security
38
+ -- for Push Notifications" in your Expo project and give the sender
39
+ -- EXPO_ACCESS_TOKEN as well.
40
+ --
41
+ -- Quiet hours are read in the person's time zone and may run past midnight
42
+ -- (22:00 to 07:00). Each night's window runs from the first moment the local
43
+ -- clock reads the start to the first moment after it that it reads the end,
44
+ -- so a daylight-saving change neither ends it early (a repeated hour) nor
45
+ -- strands it (a skipped end time ends it at the change). A start equal to the
46
+ -- end means no quiet hours (not all day). A zone that later vanishes from the
47
+ -- server's tz database turns quiet hours off rather than silencing the person.
48
+ --
49
+ -- Deleting a login (any way) deletes its devices, preferences and receipts
50
+ -- (foreign keys with on delete cascade).
51
+ --
52
+ -- Verify: the pgTAP test beside this file (`tests/0005_genesis_push.test.sql`).
53
+
54
+ create schema if not exists private;
55
+
56
+ -- Devices -------------------------------------------------------------------
57
+
58
+ create table public.push_devices (
59
+ id uuid primary key default gen_random_uuid(),
60
+ user_id uuid not null references auth.users (id) on delete cascade,
61
+ token text not null unique
62
+ constraint push_devices_token_format check (token ~ '^Expo(nent)?PushToken\[[A-Za-z0-9_-]{1,200}\]$'),
63
+ platform text not null
64
+ constraint push_devices_platform check (platform in ('ios', 'android')),
65
+ app_version text
66
+ constraint push_devices_app_version_length check (char_length(app_version) <= 64),
67
+ created_at timestamptz not null default now(),
68
+ last_seen_at timestamptz not null default now()
69
+ );
70
+ create index push_devices_user_id on public.push_devices (user_id, last_seen_at desc);
71
+
72
+ comment on table public.push_devices is
73
+ 'Expo push tokens of each login''s devices. The owner reads and deletes their own; written through genesis_push_register. Genesis push block.';
74
+
75
+ alter table public.push_devices enable row level security;
76
+
77
+ create policy push_devices_owner_read on public.push_devices
78
+ for select to authenticated
79
+ using (user_id = (select auth.uid()));
80
+ create policy push_devices_owner_delete on public.push_devices
81
+ for delete to authenticated
82
+ using (user_id = (select auth.uid()));
83
+
84
+ revoke all on table public.push_devices from public, anon, authenticated;
85
+ grant select, delete on table public.push_devices to authenticated;
86
+ grant select, insert, update, delete on table public.push_devices to service_role;
87
+
88
+ -- Preferences ---------------------------------------------------------------
89
+
90
+ create table public.notification_preferences (
91
+ user_id uuid primary key default auth.uid() references auth.users (id) on delete cascade,
92
+ -- Category id -> on (true) or off (false). A category not listed has the
93
+ -- default the sender names (`p_default_on`), so a new category needs no row.
94
+ categories jsonb not null default '{}'::jsonb
95
+ constraint notification_preferences_categories_shape check (
96
+ jsonb_typeof(categories) = 'object'
97
+ and not jsonb_path_exists(categories, '$.* ? (@.type() != "boolean")')
98
+ and pg_column_size(categories) <= 8192
99
+ ),
100
+ quiet_hours_enabled boolean not null default false,
101
+ quiet_hours_start time not null default '22:00',
102
+ quiet_hours_end time not null default '07:00',
103
+ -- IANA name ("Europe/London"), checked against the server's tz database on write.
104
+ time_zone text
105
+ constraint notification_preferences_time_zone_length check (char_length(time_zone) between 1 and 64),
106
+ -- Hide what a push says (title and body) on the lock screen and in Notification
107
+ -- Center: the sender replaces it with neutral text, so the words never leave the server.
108
+ hide_previews boolean not null default false,
109
+ updated_at timestamptz not null default now(),
110
+ constraint notification_preferences_quiet_hours_zone check (not quiet_hours_enabled or time_zone is not null)
111
+ );
112
+
113
+ comment on table public.notification_preferences is
114
+ 'Per-person notification categories, quiet hours and lock-screen privacy. The owner reads and writes their own row. Genesis push block.';
115
+
116
+ -- Rejects an unknown time zone or a malformed category id, and stamps updated_at.
117
+ create function private.genesis_notification_preferences_check()
118
+ returns trigger
119
+ language plpgsql
120
+ set search_path = ''
121
+ as $$
122
+ begin
123
+ 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
124
+ raise exception 'notification_preferences: unknown time zone' using errcode = '22023';
125
+ end if;
126
+ if exists (select 1 from pg_catalog.jsonb_object_keys(new.categories) k where k !~ '^[a-z0-9][a-z0-9._-]{0,63}$') then
127
+ raise exception 'notification_preferences: a category id must be lower case letters, digits, ".", "_" or "-"' using errcode = '22023';
128
+ end if;
129
+ new.updated_at := now();
130
+ return new;
131
+ end;
132
+ $$;
133
+
134
+ create trigger genesis_notification_preferences_check
135
+ before insert or update on public.notification_preferences
136
+ for each row execute function private.genesis_notification_preferences_check();
137
+
138
+ alter table public.notification_preferences enable row level security;
139
+
140
+ create policy notification_preferences_owner_read on public.notification_preferences
141
+ for select to authenticated
142
+ using (user_id = (select auth.uid()));
143
+ create policy notification_preferences_owner_insert on public.notification_preferences
144
+ for insert to authenticated
145
+ with check (user_id = (select auth.uid()));
146
+ create policy notification_preferences_owner_update on public.notification_preferences
147
+ for update to authenticated
148
+ using (user_id = (select auth.uid()))
149
+ with check (user_id = (select auth.uid()));
150
+ create policy notification_preferences_owner_delete on public.notification_preferences
151
+ for delete to authenticated
152
+ using (user_id = (select auth.uid()));
153
+
154
+ revoke all on table public.notification_preferences from public, anon, authenticated;
155
+ grant select, insert, update, delete on table public.notification_preferences to authenticated;
156
+ grant select, insert, update, delete on table public.notification_preferences to service_role;
157
+
158
+ -- Receipts ------------------------------------------------------------------
159
+
160
+ create table public.push_receipts (
161
+ id bigint generated always as identity primary key,
162
+ user_id uuid not null references auth.users (id) on delete cascade,
163
+ device_id uuid references public.push_devices (id) on delete set null,
164
+ category text not null
165
+ constraint push_receipts_category_format check (category ~ '^[a-z0-9][a-z0-9._-]{0,63}$'),
166
+ -- Expo's ticket id, polled for its receipt.
167
+ ticket_id text
168
+ constraint push_receipts_ticket_id_length check (char_length(ticket_id) <= 128),
169
+ -- sent: Expo took it; delivered: Expo handed it to Apple or Google;
170
+ -- failed: refused (error says why); unknown: no receipt within a day.
171
+ status text not null
172
+ constraint push_receipts_status check (status in ('sent', 'delivered', 'failed', 'unknown')),
173
+ -- An Expo error code ("DeviceNotRegistered"), never a message or content.
174
+ error text
175
+ constraint push_receipts_error_format check (error ~ '^[A-Za-z_]{1,64}$'),
176
+ created_at timestamptz not null default now(),
177
+ check_after timestamptz,
178
+ checked_at timestamptz,
179
+ attempts integer not null default 0,
180
+ constraint push_receipts_sent_has_ticket check (status <> 'sent' or ticket_id is not null)
181
+ );
182
+ create index push_receipts_due on public.push_receipts (check_after) where status = 'sent';
183
+ create index push_receipts_user_id on public.push_receipts (user_id, created_at desc);
184
+ create index push_receipts_created_at on public.push_receipts (created_at);
185
+
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; written by genesis-push-send. Kept 30 days. Genesis push block.';
188
+
189
+ alter table public.push_receipts enable row level security;
190
+
191
+ create policy push_receipts_owner_read on public.push_receipts
192
+ for select to authenticated
193
+ using (user_id = (select auth.uid()));
194
+
195
+ revoke all on table public.push_receipts from public, anon, authenticated;
196
+ grant select on table public.push_receipts to authenticated;
197
+ grant select, insert, update, delete on table public.push_receipts to service_role;
198
+
199
+ -- Client functions ----------------------------------------------------------
200
+
201
+ -- Keeps this device's token against the caller and returns its device id. A
202
+ -- token another login held (the phone changed hands, or someone else signed in
203
+ -- on it) moves to the caller, leaving the last login's receipts behind. The
204
+ -- caller's ten most recently seen devices are kept; older ones are phones long gone.
205
+ create function public.genesis_push_register(p_token text, p_platform text, p_app_version text default null)
206
+ returns uuid
207
+ language plpgsql
208
+ security definer
209
+ set search_path = ''
210
+ as $$
211
+ declare
212
+ me uuid := (select auth.uid());
213
+ device uuid;
214
+ begin
215
+ if me is null then
216
+ raise exception 'genesis_push_register: sign in first' using errcode = '42501';
217
+ end if;
218
+ if p_token is null or p_platform is null then
219
+ raise exception 'genesis_push_register: malformed token, platform or app version' using errcode = '23514';
220
+ end if;
221
+ delete from public.push_devices where token = p_token and user_id <> me;
222
+ insert into public.push_devices as d (user_id, token, platform, app_version)
223
+ values (me, p_token, p_platform, p_app_version)
224
+ on conflict (token) do update
225
+ set platform = excluded.platform, app_version = excluded.app_version, last_seen_at = now()
226
+ where d.user_id = me
227
+ returning d.id into device;
228
+ if device is null then
229
+ -- Another login registered the same token in the same moment: never hand back its row.
230
+ raise exception 'genesis_push_register: the token changed hands; try again' using errcode = '40001';
231
+ end if;
232
+ delete from public.push_devices d
233
+ where d.user_id = me
234
+ and d.id not in (
235
+ select k.id from public.push_devices k where k.user_id = me
236
+ order by k.id = device desc, k.last_seen_at desc, k.id limit 10
237
+ );
238
+ return device;
239
+ exception
240
+ when check_violation or not_null_violation then
241
+ -- Raised afresh so the error (and the server log) never carries the failing row, token and all.
242
+ raise exception 'genesis_push_register: malformed token, platform or app version' using errcode = '23514';
243
+ end;
244
+ $$;
245
+
246
+ -- Forgets a token, whoever holds it. Returns true when there was one.
247
+ create function public.genesis_push_forget(p_token text)
248
+ returns boolean
249
+ language plpgsql
250
+ security definer
251
+ set search_path = ''
252
+ as $$
253
+ begin
254
+ delete from public.push_devices where token = p_token;
255
+ return found;
256
+ end;
257
+ $$;
258
+
259
+ -- Server functions ----------------------------------------------------------
260
+
261
+ -- The first instant at which the clock in `p_zone` reads `p_local` or later,
262
+ -- coming from the day before: the first of a repeated (fall-back) time, the
263
+ -- moment of the change for a skipped (spring-forward) one. Outside a change
264
+ -- it is plain `at time zone`; near one (the zone's offset differs three hours
265
+ -- either side) it walks back a minute at a time to the earliest crossing.
266
+ create function private.genesis_push_local_instant(p_local timestamp, p_zone text)
267
+ returns timestamptz
268
+ language plpgsql
269
+ stable
270
+ set search_path = ''
271
+ as $$
272
+ declare
273
+ guess timestamptz := p_local at time zone p_zone;
274
+ t timestamptz;
275
+ found_at timestamptz := null;
276
+ begin
277
+ if ((guess - interval '3 hours') at time zone p_zone) - (guess - interval '3 hours') at time zone 'UTC'
278
+ = ((guess + interval '3 hours') at time zone p_zone) - (guess + interval '3 hours') at time zone 'UTC' then
279
+ return guess;
280
+ end if;
281
+ t := guess + interval '1 hour';
282
+ while t > guess - interval '3 hours' loop
283
+ if (t at time zone p_zone) >= p_local and ((t - interval '1 minute') at time zone p_zone) < p_local then
284
+ found_at := t;
285
+ end if;
286
+ t := t - interval '1 minute';
287
+ end loop;
288
+ return coalesce(found_at, guess);
289
+ end;
290
+ $$;
291
+
292
+ -- May `p_category` reach `p_user_id` at `p_at`? `deliver` is false with a
293
+ -- `reason` ('category_off' or 'quiet_hours'; 'quiet_hours' says until when);
294
+ -- `hide_previews` tells the sender to replace what the push says.
295
+ create function public.genesis_push_status(
296
+ p_user_id uuid,
297
+ p_category text,
298
+ p_default_on boolean default true,
299
+ p_bypass_quiet_hours boolean default false,
300
+ p_at timestamptz default now()
301
+ )
302
+ returns table (deliver boolean, reason text, quiet_until timestamptz, hide_previews boolean)
303
+ language plpgsql
304
+ stable
305
+ security definer
306
+ set search_path = ''
307
+ as $$
308
+ declare
309
+ pref public.notification_preferences;
310
+ at_ timestamptz := coalesce(p_at, now());
311
+ local_now timestamp;
312
+ day date;
313
+ window_start timestamptz;
314
+ window_end timestamptz;
315
+ quiet boolean := false;
316
+ begin
317
+ if p_category is null or p_category !~ '^[a-z0-9][a-z0-9._-]{0,63}$' then
318
+ raise exception 'genesis_push_status: malformed category' using errcode = '22023';
319
+ end if;
320
+ select * into pref from public.notification_preferences n where n.user_id = p_user_id;
321
+ hide_previews := coalesce(pref.hide_previews, false);
322
+ quiet_until := null;
323
+
324
+ if not coalesce((pref.categories ->> p_category)::boolean, coalesce(p_default_on, true)) then
325
+ deliver := false;
326
+ reason := 'category_off';
327
+ return next;
328
+ return;
329
+ end if;
330
+
331
+ if pref.quiet_hours_enabled and not coalesce(p_bypass_quiet_hours, false) and pref.quiet_hours_start <> pref.quiet_hours_end then
332
+ begin
333
+ local_now := at_ at time zone pref.time_zone;
334
+ exception
335
+ when invalid_parameter_value then
336
+ local_now := null;
337
+ end;
338
+ if local_now is not null then
339
+ -- The latest window that has started: tonight's, or last night's.
340
+ foreach day in array array[local_now::date, local_now::date - 1] loop
341
+ window_start := private.genesis_push_local_instant(day + pref.quiet_hours_start, pref.time_zone);
342
+ if window_start <= at_ then
343
+ window_end := private.genesis_push_local_instant(
344
+ day + pref.quiet_hours_end + case when pref.quiet_hours_end < pref.quiet_hours_start then interval '1 day' else interval '0' end,
345
+ pref.time_zone
346
+ );
347
+ quiet := at_ < window_end;
348
+ exit;
349
+ end if;
350
+ end loop;
351
+ if quiet then
352
+ quiet_until := window_end;
353
+ end if;
354
+ end if;
355
+ end if;
356
+
357
+ deliver := not quiet;
358
+ reason := case when quiet then 'quiet_hours' else 'ok' end;
359
+ return next;
360
+ end;
361
+ $$;
362
+
363
+ -- The devices to push `p_category` to now: none when the person turned the
364
+ -- category off or is in quiet hours. Each row says whether to hide the preview.
365
+ create function public.genesis_push_targets(
366
+ p_user_id uuid,
367
+ p_category text,
368
+ p_default_on boolean default true,
369
+ p_bypass_quiet_hours boolean default false,
370
+ p_at timestamptz default now()
371
+ )
372
+ returns table (device_id uuid, token text, platform text, hide_previews boolean)
373
+ language sql
374
+ stable
375
+ security definer
376
+ set search_path = ''
377
+ as $$
378
+ select d.id, d.token, d.platform, s.hide_previews
379
+ from public.genesis_push_status(p_user_id, p_category, p_default_on, p_bypass_quiet_hours, p_at) s
380
+ join public.push_devices d on d.user_id = p_user_id
381
+ where s.deliver
382
+ order by d.last_seen_at desc, d.id;
383
+ $$;
384
+
385
+ -- Records what Expo answered for each message sent:
386
+ -- [{ "user_id", "device_id", "category", "ticket_id", "status": "sent" | "failed", "error" }].
387
+ -- A login or device deleted meanwhile is skipped or left null. Returns the rows kept.
388
+ create function public.genesis_push_tickets_record(p_tickets jsonb)
389
+ returns integer
390
+ language plpgsql
391
+ security definer
392
+ set search_path = ''
393
+ as $$
394
+ declare
395
+ kept integer;
396
+ begin
397
+ if jsonb_typeof(p_tickets) is distinct from 'array' or jsonb_array_length(p_tickets) > 1000 then
398
+ raise exception 'genesis_push_tickets_record: expected an array of at most 1000 tickets' using errcode = '22023';
399
+ end if;
400
+ insert into public.push_receipts (user_id, device_id, category, ticket_id, status, error, check_after)
401
+ select t.user_id,
402
+ (select d.id from public.push_devices d where d.id = t.device_id),
403
+ t.category,
404
+ t.ticket_id,
405
+ t.status,
406
+ t.error,
407
+ case when t.status = 'sent' then now() + interval '15 minutes' end
408
+ from jsonb_to_recordset(p_tickets) as t (user_id uuid, device_id uuid, category text, ticket_id text, status text, error text)
409
+ where t.status in ('sent', 'failed')
410
+ and exists (select 1 from auth.users u where u.id = t.user_id);
411
+ get diagnostics kept = row_count;
412
+ return kept;
413
+ end;
414
+ $$;
415
+
416
+ -- Hands out up to p_limit tickets whose receipts are due (Expo has them about
417
+ -- 15 minutes after sending and keeps them a day), each leased for 30 minutes
418
+ -- so two pollers never take the same one. Tickets without a receipt after a
419
+ -- day become 'unknown'; receipts older than 30 days are deleted.
420
+ create function public.genesis_push_receipts_claim(p_limit integer default 300)
421
+ returns table (id bigint, ticket_id text)
422
+ language plpgsql
423
+ security definer
424
+ set search_path = ''
425
+ as $$
426
+ begin
427
+ update public.push_receipts r
428
+ set status = 'unknown', check_after = null, checked_at = now()
429
+ where r.status = 'sent' and r.created_at < now() - interval '1 day';
430
+ delete from public.push_receipts r where r.created_at < now() - interval '30 days';
431
+
432
+ return query
433
+ update public.push_receipts r
434
+ set attempts = r.attempts + 1, check_after = now() + interval '30 minutes'
435
+ where r.id in (
436
+ select q.id from public.push_receipts q
437
+ where q.status = 'sent' and q.check_after <= now()
438
+ order by q.check_after
439
+ limit greatest(1, least(coalesce(p_limit, 300), 1000))
440
+ for update skip locked
441
+ )
442
+ returning r.id, r.ticket_id;
443
+ end;
444
+ $$;
445
+
446
+ -- Records receipts: [{ "id", "status": "delivered" | "failed", "error" }]. A
447
+ -- DeviceNotRegistered receipt also forgets its device (the app was deleted or
448
+ -- notifications were turned off). Returns the receipts settled.
449
+ create function public.genesis_push_receipts_settle(p_results jsonb)
450
+ returns integer
451
+ language plpgsql
452
+ security definer
453
+ set search_path = ''
454
+ as $$
455
+ declare
456
+ settled integer;
457
+ dead jsonb;
458
+ begin
459
+ if jsonb_typeof(p_results) is distinct from 'array' or jsonb_array_length(p_results) > 1000 then
460
+ raise exception 'genesis_push_receipts_settle: expected an array of at most 1000 results' using errcode = '22023';
461
+ end if;
462
+ with results as (
463
+ select x.id, x.status, x.error
464
+ from jsonb_to_recordset(p_results) as x (id bigint, status text, error text)
465
+ where x.status in ('delivered', 'failed')
466
+ ), updated as (
467
+ update public.push_receipts r
468
+ set status = s.status, error = case when s.status = 'failed' then s.error end, check_after = null, checked_at = now()
469
+ from results s
470
+ where r.id = s.id and r.status = 'sent'
471
+ returning r.device_id, r.error, r.created_at
472
+ )
473
+ select count(*), 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)
474
+ into settled, dead
475
+ from updated u;
476
+ -- A separate statement: the receipts' foreign key sets their device_id to null. A device
477
+ -- registered again after the push was sent is alive again, so it stays.
478
+ if dead is not null then
479
+ delete from public.push_devices d
480
+ using jsonb_to_recordset(dead) as x (device_id uuid, sent_at timestamptz)
481
+ where d.id = x.device_id and d.last_seen_at <= x.sent_at;
482
+ end if;
483
+ return settled;
484
+ end;
485
+ $$;
486
+
487
+ -- Forgets devices Expo called dead when it answered the send. Returns how many.
488
+ create function public.genesis_push_devices_forget(p_device_ids uuid[])
489
+ returns integer
490
+ language plpgsql
491
+ security definer
492
+ set search_path = ''
493
+ as $$
494
+ declare
495
+ gone integer;
496
+ begin
497
+ delete from public.push_devices d where d.id = any (p_device_ids);
498
+ get diagnostics gone = row_count;
499
+ return gone;
500
+ end;
501
+ $$;
502
+
503
+ -- Privileges by name. Supabase's default privileges grant execute on a new
504
+ -- public function to anon and authenticated, so each is revoked here; a later
505
+ -- blanket `grant … on all functions` would hand them back, so such a grant
506
+ -- must exclude them.
507
+ revoke execute on function private.genesis_notification_preferences_check() from public, anon, authenticated;
508
+ revoke execute on function private.genesis_push_local_instant(timestamp, text) from public, anon, authenticated;
509
+ revoke execute on function public.genesis_push_register(text, text, text) from public, anon, authenticated;
510
+ revoke execute on function public.genesis_push_forget(text) from public, anon, authenticated;
511
+ revoke execute on function public.genesis_push_status(uuid, text, boolean, boolean, timestamptz) from public, anon, authenticated;
512
+ revoke execute on function public.genesis_push_targets(uuid, text, boolean, boolean, timestamptz) from public, anon, authenticated;
513
+ revoke execute on function public.genesis_push_tickets_record(jsonb) from public, anon, authenticated;
514
+ revoke execute on function public.genesis_push_receipts_claim(integer) from public, anon, authenticated;
515
+ revoke execute on function public.genesis_push_receipts_settle(jsonb) from public, anon, authenticated;
516
+ revoke execute on function public.genesis_push_devices_forget(uuid[]) from public, anon, authenticated;
517
+ grant execute on function public.genesis_push_register(text, text, text) to authenticated;
518
+ grant execute on function public.genesis_push_forget(text) to anon, authenticated;
519
+ grant execute on function public.genesis_push_status(uuid, text, boolean, boolean, timestamptz) to service_role;
520
+ grant execute on function public.genesis_push_targets(uuid, text, boolean, boolean, timestamptz) to service_role;
521
+ grant execute on function public.genesis_push_tickets_record(jsonb) to service_role;
522
+ grant execute on function public.genesis_push_receipts_claim(integer) to service_role;
523
+ grant execute on function public.genesis_push_receipts_settle(jsonb) to service_role;
524
+ grant execute on function public.genesis_push_devices_forget(uuid[]) to service_role;