@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.
- package/README.md +367 -23
- package/dist/blocks.d.ts +19 -0
- package/dist/blocks.js +3 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-FAA2FTF4.js +110 -0
- package/dist/chunk-FAA2FTF4.js.map +1 -0
- package/dist/chunk-T2HGFC4C.js +37 -0
- package/dist/chunk-T2HGFC4C.js.map +1 -0
- package/dist/index-BSGz3RGk.d.ts +317 -0
- package/dist/index.d.ts +4 -103
- package/dist/index.js +60 -3
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +90 -6
- package/dist/native.js +70 -2
- package/dist/native.js.map +1 -1
- package/functions/genesis-apple-revoke/apple.ts +237 -0
- package/functions/genesis-apple-revoke/handler.ts +227 -0
- package/functions/genesis-apple-revoke/index.ts +55 -0
- package/functions/genesis-push-send/expo.ts +117 -0
- package/functions/genesis-push-send/handler.ts +462 -0
- package/functions/genesis-push-send/index.ts +54 -0
- package/functions/genesis-support-cleanup/handler.ts +80 -0
- package/functions/genesis-support-cleanup/index.ts +39 -0
- package/functions/genesis-support-intake/contract.ts +193 -0
- package/functions/genesis-support-intake/handler.ts +170 -0
- package/functions/genesis-support-intake/index.ts +47 -0
- package/functions/genesis-support-notify/handler.ts +167 -0
- package/functions/genesis-support-notify/index.ts +15 -0
- package/functions/tests/genesis-apple-revoke_test.ts +81 -0
- package/functions/tests/genesis-push-send_test.ts +53 -0
- package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
- package/migrations/0004_genesis_tenant_features.sql +333 -0
- package/migrations/0005_genesis_push.sql +524 -0
- package/migrations/0006_genesis_support.sql +846 -0
- package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
- package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
- package/migrations/tests/0005_genesis_push.test.sql +407 -0
- package/migrations/tests/0006_genesis_support.test.sql +552 -0
- 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;
|