@marktiderman/genesis-notifications 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +251 -2
- package/dist/blocks.d.ts +10 -0
- package/dist/blocks.js +35 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-2BG336II.js +124 -0
- package/dist/chunk-2BG336II.js.map +1 -0
- package/dist/chunk-7PHA3WVG.js +51 -0
- package/dist/chunk-7PHA3WVG.js.map +1 -0
- package/dist/chunk-EWZB35V6.js +113 -0
- package/dist/chunk-EWZB35V6.js.map +1 -0
- package/dist/chunk-UXNWI7OS.js +118 -0
- package/dist/chunk-UXNWI7OS.js.map +1 -0
- package/dist/email-render-CID_pXDy.d.ts +53 -0
- package/dist/email.d.ts +46 -0
- package/dist/email.js +64 -0
- package/dist/email.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.js +170 -0
- package/dist/native.js.map +1 -0
- package/dist/react.d.ts +98 -0
- package/dist/react.js +4 -0
- package/dist/react.js.map +1 -0
- package/dist/runner.d.ts +174 -0
- package/dist/runner.js +3 -0
- package/dist/runner.js.map +1 -0
- package/dist/server.d.ts +658 -0
- package/dist/server.js +995 -0
- package/dist/server.js.map +1 -0
- package/dist/supabase.d.ts +118 -0
- package/dist/supabase.js +3 -0
- package/dist/supabase.js.map +1 -0
- package/dist/types-BilguCPW.d.ts +332 -0
- package/dist/web.d.ts +79 -0
- package/dist/web.js +233 -0
- package/dist/web.js.map +1 -0
- package/functions/genesis-notifications/index.ts +60 -0
- package/functions/genesis-notifications/notifications.ts +18 -0
- package/migrations/0001_genesis_notifications.sql +2057 -0
- package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
- package/package.json +191 -3
- package/src/blocks.ts +42 -0
- package/src/core/index.ts +43 -0
- package/src/core/registry.ts +354 -0
- package/src/core/types.ts +90 -0
- package/src/core/validate.ts +73 -0
- package/src/email/index.tsx +142 -0
- package/src/index.ts +6 -0
- package/src/native/index.tsx +283 -0
- package/src/react/index.tsx +226 -0
- package/src/runner/index.ts +299 -0
- package/src/server/crypto.ts +44 -0
- package/src/server/dns.ts +106 -0
- package/src/server/email-render.ts +90 -0
- package/src/server/errors.ts +61 -0
- package/src/server/expo.ts +133 -0
- package/src/server/handler.ts +127 -0
- package/src/server/index.ts +57 -0
- package/src/server/notify.ts +100 -0
- package/src/server/rate.ts +47 -0
- package/src/server/resend.ts +143 -0
- package/src/server/runner.ts +524 -0
- package/src/server/secret-links.ts +53 -0
- package/src/server/store.ts +150 -0
- package/src/server/unsubscribe.ts +78 -0
- package/src/server/webhook.ts +55 -0
- package/src/supabase/index.ts +210 -0
- 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;
|