@marktiderman/genesis-supabase 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +367 -23
  2. package/dist/blocks.d.ts +19 -0
  3. package/dist/blocks.js +3 -0
  4. package/dist/blocks.js.map +1 -0
  5. package/dist/chunk-FAA2FTF4.js +110 -0
  6. package/dist/chunk-FAA2FTF4.js.map +1 -0
  7. package/dist/chunk-T2HGFC4C.js +37 -0
  8. package/dist/chunk-T2HGFC4C.js.map +1 -0
  9. package/dist/index-BSGz3RGk.d.ts +317 -0
  10. package/dist/index.d.ts +4 -103
  11. package/dist/index.js +60 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/native.d.ts +90 -6
  14. package/dist/native.js +70 -2
  15. package/dist/native.js.map +1 -1
  16. package/functions/genesis-apple-revoke/apple.ts +237 -0
  17. package/functions/genesis-apple-revoke/handler.ts +227 -0
  18. package/functions/genesis-apple-revoke/index.ts +55 -0
  19. package/functions/genesis-push-send/expo.ts +117 -0
  20. package/functions/genesis-push-send/handler.ts +462 -0
  21. package/functions/genesis-push-send/index.ts +54 -0
  22. package/functions/genesis-support-cleanup/handler.ts +80 -0
  23. package/functions/genesis-support-cleanup/index.ts +39 -0
  24. package/functions/genesis-support-intake/contract.ts +193 -0
  25. package/functions/genesis-support-intake/handler.ts +170 -0
  26. package/functions/genesis-support-intake/index.ts +47 -0
  27. package/functions/genesis-support-notify/handler.ts +167 -0
  28. package/functions/genesis-support-notify/index.ts +15 -0
  29. package/functions/tests/genesis-apple-revoke_test.ts +81 -0
  30. package/functions/tests/genesis-push-send_test.ts +53 -0
  31. package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
  32. package/migrations/0004_genesis_tenant_features.sql +333 -0
  33. package/migrations/0005_genesis_push.sql +524 -0
  34. package/migrations/0006_genesis_support.sql +846 -0
  35. package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
  36. package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
  37. package/migrations/tests/0005_genesis_push.test.sql +407 -0
  38. package/migrations/tests/0006_genesis_support.test.sql +552 -0
  39. package/package.json +91 -4
@@ -0,0 +1,846 @@
1
+ -- Genesis support: feedback reports, screenshots, staff replies, an idea board and votes
2
+ --
3
+ -- The server half of the support block (`@marktiderman/genesis-support`), in the app's
4
+ -- own Supabase:
5
+ --
6
+ -- public.support_reports what a person sends (a bug, an idea, a question or praise)
7
+ -- with the context the app collected (version signature,
8
+ -- route, device), a status and an optional FeatureId
9
+ -- public.support_attachments a report's screenshots, kept in the private
10
+ -- `genesis-support` storage bucket under <user id>/<report id>/
11
+ -- public.support_replies the conversation on a report, between the person and staff
12
+ -- public.support_ideas the idea board: anyone signed in suggests and reads, staff
13
+ -- set the status (shipped links the FeatureId that delivered it)
14
+ -- public.support_idea_votes one vote per person per idea
15
+ -- public.support_attachment_purges
16
+ -- storage objects to remove after their attachment row went
17
+ -- (a deleted report or account); the genesis-support-cleanup
18
+ -- function removes them through the Storage API
19
+ --
20
+ -- Access, enforced here by row level security and column privileges:
21
+ -- - A person sees, edits (while it is open) and deletes their own reports, replies on
22
+ -- them and sees their own screenshots. Nobody else does, except staff.
23
+ -- - Every signed-in person reads the idea board. A person edits their own idea while it
24
+ -- is open. Votes: one per person and idea, and a person sees only their own; the count
25
+ -- is on the idea, kept by a trigger. Supabase anonymous sign-ins (`is_anonymous`) may
26
+ -- report but never vote or suggest, so minting throwaway logins can't stuff the board.
27
+ -- - Staff read every report and screenshot, reply, set statuses and hide ideas. Beyond
28
+ -- reading and replying, staff act only through the functions below.
29
+ -- - anon (no session) reaches nothing.
30
+ --
31
+ -- The app hook, like the tenant-admin hook in 0004: staff are whoever
32
+ -- `private.genesis_support_staff(person uuid) returns boolean` says. Without it nobody is
33
+ -- staff, so a fresh install fails closed. It runs with this file's (the owner's) rights,
34
+ -- so it reads your membership table whatever its RLS:
35
+ --
36
+ -- create function private.genesis_support_staff(person uuid) returns boolean
37
+ -- language sql stable set search_path = '' as $$
38
+ -- select exists (select 1 from public.staff s where s.user_id = person)
39
+ -- $$;
40
+ -- revoke execute on function private.genesis_support_staff(uuid) from public, anon, authenticated;
41
+ --
42
+ -- Limits (each raises SQLSTATE GS429): 20 reports and 60 replies per person per hour,
43
+ -- 10 ideas per person per day, 5 screenshots per report. They count what was sent, in a log
44
+ -- deleting doesn't touch, under a per-person lock, so deleting and sending again doesn't reset
45
+ -- them. Text and jsonb are capped by checks.
46
+ --
47
+ -- The bucket stays private whatever other storage policies the app has: restrictive policies
48
+ -- hold every role but the service role to this file's rules inside `genesis-support`.
49
+ --
50
+ -- Account deletion (0001): every row here goes with the login (reports, the replies on
51
+ -- them, screenshots and votes cascade; ideas and replies on other people's reports keep
52
+ -- their text without the person). A deleted screenshot's storage object is queued in
53
+ -- support_attachment_purges, because Supabase refuses direct deletes from storage.objects.
54
+ -- Supabase documents that a login owning Storage objects can't be deleted (see 0001): where
55
+ -- that holds, delete the person's reports first (their screenshots are queued) and run the
56
+ -- cleanup function, then delete the account. Not verified against a hosted project.
57
+ --
58
+ -- Use: copy this file into your app's `supabase/migrations/` under a new timestamped
59
+ -- name. Never edit it once applied; change it with a new migration. It stands alone (it
60
+ -- doesn't need 0001 to 0005).
61
+ --
62
+ -- Verify: the pgTAP test beside this file (`tests/0006_genesis_support.test.sql`).
63
+
64
+ create schema if not exists private;
65
+
66
+ -- Helpers -----------------------------------------------------------------------------
67
+
68
+ -- Is the signed-in person staff? Missing hook, nobody signed in, or a null answer: false.
69
+ -- In public so policies can call it; it answers only about the caller.
70
+ create or replace function public.support_is_staff()
71
+ returns boolean
72
+ language plpgsql
73
+ stable
74
+ security definer
75
+ set search_path = ''
76
+ as $$
77
+ declare
78
+ me uuid := auth.uid();
79
+ begin
80
+ if me is null or to_regprocedure('private.genesis_support_staff(uuid)') is null then
81
+ return false;
82
+ end if;
83
+ return coalesce(private.genesis_support_staff(me), false);
84
+ end;
85
+ $$;
86
+
87
+ comment on function public.support_is_staff() is
88
+ 'True when the signed-in person is support staff (private.genesis_support_staff). Genesis support.';
89
+
90
+ -- Is the signed-in person a Supabase anonymous sign-in? Read from auth.users, not from the
91
+ -- JWT, so a custom access-token hook that drops the claim can't hide it. Answers only about the caller.
92
+ create or replace function public.support_is_anonymous()
93
+ returns boolean
94
+ language sql
95
+ stable
96
+ security definer
97
+ set search_path = ''
98
+ as $$
99
+ select coalesce((select u.is_anonymous from auth.users u where u.id = auth.uid()), true)
100
+ $$;
101
+
102
+ comment on function public.support_is_anonymous() is
103
+ 'True when the signed-in person is a Supabase anonymous sign-in (or nobody). Genesis support.';
104
+
105
+ -- What each person sent, for the limits: kept when the report is deleted, gone with the login.
106
+ create table private.support_rate_events (
107
+ user_id uuid not null references auth.users (id) on delete cascade,
108
+ kind text not null,
109
+ at timestamptz not null default clock_timestamp()
110
+ );
111
+
112
+ create index support_rate_events_lookup on private.support_rate_events (user_id, kind, at desc);
113
+
114
+ -- Counts the person's sends of `kind` within `window_size`, refuses past `max_count`, then
115
+ -- records this one. A per-person lock makes parallel sends wait their turn.
116
+ create or replace function private.genesis_support_rate_limit(person uuid, kind text, max_count integer, window_size interval)
117
+ returns void
118
+ language plpgsql
119
+ security definer
120
+ set search_path = ''
121
+ as $$
122
+ begin
123
+ if person is null then
124
+ return;
125
+ end if;
126
+ perform pg_advisory_xact_lock(hashtextextended('genesis_support:' || person::text, 0));
127
+ delete from private.support_rate_events e where e.user_id = person and e.at < clock_timestamp() - interval '2 days';
128
+ if (select count(*) from private.support_rate_events e
129
+ where e.user_id = person and e.kind = genesis_support_rate_limit.kind and e.at > clock_timestamp() - window_size) >= max_count then
130
+ raise exception 'support: too many % sent; try again later', kind using errcode = 'GS429';
131
+ end if;
132
+ insert into private.support_rate_events (user_id, kind) values (person, kind);
133
+ end;
134
+ $$;
135
+
136
+ -- updated_at moves when what was said or decided changes, not when a thread is read or a
137
+ -- vote is counted.
138
+ create or replace function private.genesis_support_touch()
139
+ returns trigger
140
+ language plpgsql
141
+ set search_path = ''
142
+ as $$
143
+ begin
144
+ if (to_jsonb(new) - array['updated_at', 'user_read_at', 'staff_read_at', 'last_user_reply_at', 'last_staff_reply_at', 'vote_count'])
145
+ is distinct from (to_jsonb(old) - array['updated_at', 'user_read_at', 'staff_read_at', 'last_user_reply_at', 'last_staff_reply_at', 'vote_count']) then
146
+ new.updated_at := now();
147
+ end if;
148
+ return new;
149
+ end;
150
+ $$;
151
+
152
+ -- Reports -----------------------------------------------------------------------------
153
+
154
+ create table public.support_reports (
155
+ id uuid primary key default gen_random_uuid(),
156
+ user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
157
+ type text not null default 'bug'
158
+ constraint support_reports_type check (type in ('bug', 'idea', 'question', 'praise')),
159
+ title text
160
+ constraint support_reports_title_length check (char_length(title) <= 300),
161
+ message text not null
162
+ constraint support_reports_message_length check (char_length(btrim(message)) between 1 and 20000),
163
+ -- The screen, as a path: never a query string or fragment (tokens, codes and emails live there).
164
+ route text
165
+ constraint support_reports_route_shape check (char_length(route) <= 2000 and route !~ '[?#]'),
166
+ -- The version signature (`1.4.0 (37) · preview · 3f2a9c1b`).
167
+ app_version text
168
+ constraint support_reports_app_version_length check (char_length(app_version) <= 200),
169
+ platform text
170
+ constraint support_reports_platform_length check (char_length(platform) <= 40),
171
+ device jsonb not null default '{}'::jsonb
172
+ constraint support_reports_device_shape check (jsonb_typeof(device) = 'object' and pg_column_size(device) <= 4096),
173
+ context jsonb not null default '{}'::jsonb
174
+ constraint support_reports_context_shape check (jsonb_typeof(context) = 'object' and pg_column_size(context) <= 16384),
175
+ status text not null default 'open'
176
+ constraint support_reports_status check (status in ('open', 'submitted', 'in_progress', 'addressed', 'wont_fix')),
177
+ -- The FeatureId (features.ts) the report is about, set by staff.
178
+ feature_id text
179
+ constraint support_reports_feature_id_format check (feature_id ~ '^[a-z][a-z0-9]*([-_.][a-z0-9]+)*$' and char_length(feature_id) <= 100),
180
+ last_user_reply_at timestamptz,
181
+ last_staff_reply_at timestamptz,
182
+ -- Read receipts: when the person, and when any staff member, last read the thread.
183
+ user_read_at timestamptz,
184
+ staff_read_at timestamptz,
185
+ created_at timestamptz not null default now(),
186
+ updated_at timestamptz not null default now()
187
+ );
188
+
189
+ create index support_reports_user on public.support_reports (user_id, created_at desc);
190
+ create index support_reports_status on public.support_reports (status, created_at desc);
191
+
192
+ comment on table public.support_reports is
193
+ 'Feedback a person sent from the app, with its context and status. Their own; staff read all. Genesis support.';
194
+
195
+ create trigger genesis_support_touch
196
+ before update on public.support_reports
197
+ for each row execute function private.genesis_support_touch();
198
+
199
+ -- A new report starts open, about no feature, with no replies, whatever was sent; a
200
+ -- person sends at most 20 an hour.
201
+ create or replace function private.genesis_support_new_report()
202
+ returns trigger
203
+ language plpgsql
204
+ security definer
205
+ set search_path = ''
206
+ as $$
207
+ begin
208
+ new.status := 'open';
209
+ new.feature_id := null;
210
+ new.last_user_reply_at := null;
211
+ new.last_staff_reply_at := null;
212
+ new.user_read_at := null;
213
+ new.staff_read_at := null;
214
+ new.created_at := now();
215
+ new.updated_at := now();
216
+ perform private.genesis_support_rate_limit(new.user_id, 'report', 20, interval '1 hour');
217
+ return new;
218
+ end;
219
+ $$;
220
+
221
+ create trigger genesis_support_new_report
222
+ before insert on public.support_reports
223
+ for each row execute function private.genesis_support_new_report();
224
+
225
+ -- Attachments -------------------------------------------------------------------------
226
+
227
+ create table public.support_attachments (
228
+ id uuid primary key default gen_random_uuid(),
229
+ report_id uuid not null references public.support_reports (id) on delete cascade,
230
+ user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
231
+ bucket text not null default 'genesis-support'
232
+ constraint support_attachments_bucket check (bucket = 'genesis-support'),
233
+ -- <user id>/<report id>/<name>.<png|jpg|jpeg|webp>
234
+ path text not null unique
235
+ constraint support_attachments_path_shape check (path ~ '^[0-9a-f-]{36}/[0-9a-f-]{36}/[A-Za-z0-9_-]{1,80}\.(png|jpg|jpeg|webp)$'),
236
+ content_type text not null
237
+ constraint support_attachments_content_type check (content_type in ('image/png', 'image/jpeg', 'image/webp')),
238
+ byte_size integer
239
+ constraint support_attachments_byte_size check (byte_size between 1 and 5242880),
240
+ width integer
241
+ constraint support_attachments_width check (width between 1 and 10000),
242
+ height integer
243
+ constraint support_attachments_height check (height between 1 and 10000),
244
+ created_at timestamptz not null default now()
245
+ );
246
+
247
+ create index support_attachments_report on public.support_attachments (report_id);
248
+ create index support_attachments_user on public.support_attachments (user_id);
249
+
250
+ comment on table public.support_attachments is
251
+ 'Screenshots of a report, in the private genesis-support bucket. Genesis support.';
252
+
253
+ -- An attachment names an object the person already uploaded under their own report's folder.
254
+ create or replace function private.genesis_support_new_attachment()
255
+ returns trigger
256
+ language plpgsql
257
+ security definer
258
+ set search_path = ''
259
+ as $$
260
+ begin
261
+ new.bucket := 'genesis-support';
262
+ new.created_at := now();
263
+ if new.path is null or split_part(new.path, '/', 1) <> new.user_id::text or split_part(new.path, '/', 2) <> new.report_id::text then
264
+ raise exception 'support: an attachment lives under <user id>/<report id>/' using errcode = '42501';
265
+ end if;
266
+ if not exists (select 1 from public.support_reports r where r.id = new.report_id and r.user_id = new.user_id) then
267
+ raise exception 'support: not your report' using errcode = '42501';
268
+ end if;
269
+ if not exists (select 1 from storage.objects o where o.bucket_id = 'genesis-support' and o.name = new.path) then
270
+ raise exception 'support: upload the file before recording it' using errcode = '23503';
271
+ end if;
272
+ if (select count(*) from public.support_attachments a where a.report_id = new.report_id) >= 5 then
273
+ raise exception 'support: at most 5 screenshots per report' using errcode = 'GS429';
274
+ end if;
275
+ return new;
276
+ end;
277
+ $$;
278
+
279
+ create trigger genesis_support_new_attachment
280
+ before insert on public.support_attachments
281
+ for each row execute function private.genesis_support_new_attachment();
282
+
283
+ -- Objects whose attachment row is gone, for the cleanup function (service role) to remove
284
+ -- through the Storage API. Nobody else reads it.
285
+ create table public.support_attachment_purges (
286
+ id bigint generated always as identity primary key,
287
+ bucket text not null,
288
+ path text not null,
289
+ queued_at timestamptz not null default now()
290
+ );
291
+
292
+ comment on table public.support_attachment_purges is
293
+ 'Storage objects to remove after their support attachment was deleted. Service role only. Genesis support.';
294
+
295
+ create or replace function private.genesis_support_queue_purge()
296
+ returns trigger
297
+ language plpgsql
298
+ security definer
299
+ set search_path = ''
300
+ as $$
301
+ begin
302
+ insert into public.support_attachment_purges (bucket, path) values (old.bucket, old.path);
303
+ return null;
304
+ end;
305
+ $$;
306
+
307
+ create trigger genesis_support_queue_purge
308
+ after delete on public.support_attachments
309
+ for each row execute function private.genesis_support_queue_purge();
310
+
311
+ -- Replies -----------------------------------------------------------------------------
312
+
313
+ create table public.support_replies (
314
+ id uuid primary key default gen_random_uuid(),
315
+ report_id uuid not null references public.support_reports (id) on delete cascade,
316
+ author_id uuid default auth.uid() references auth.users (id) on delete set null,
317
+ from_staff boolean not null default false,
318
+ body text not null
319
+ constraint support_replies_body_length check (char_length(btrim(body)) between 1 and 10000),
320
+ created_at timestamptz not null default now()
321
+ );
322
+
323
+ create index support_replies_report on public.support_replies (report_id, created_at);
324
+ create index support_replies_author on public.support_replies (author_id, created_at desc);
325
+
326
+ comment on table public.support_replies is
327
+ 'The conversation on a report, between the person and staff. Genesis support.';
328
+
329
+ -- Who wrote it, and whether as staff, come from the session, never from the request.
330
+ create or replace function private.genesis_support_new_reply()
331
+ returns trigger
332
+ language plpgsql
333
+ security definer
334
+ set search_path = ''
335
+ as $$
336
+ declare
337
+ me uuid := auth.uid();
338
+ owner uuid;
339
+ staff boolean := public.support_is_staff();
340
+ begin
341
+ select r.user_id into owner from public.support_reports r where r.id = new.report_id;
342
+ if me is null or owner is null or (owner <> me and not staff) then
343
+ raise exception 'support: not your report' using errcode = '42501';
344
+ end if;
345
+ new.author_id := me;
346
+ -- On their own report a staff member writes as the person.
347
+ new.from_staff := staff and owner <> me;
348
+ new.created_at := clock_timestamp();
349
+ perform private.genesis_support_rate_limit(me, 'reply', 60, interval '1 hour');
350
+ return new;
351
+ end;
352
+ $$;
353
+
354
+ create trigger genesis_support_new_reply
355
+ before insert on public.support_replies
356
+ for each row execute function private.genesis_support_new_reply();
357
+
358
+ -- A reply moves the thread on: the writer's side has read it, the other side has something new.
359
+ create or replace function private.genesis_support_reply_posted()
360
+ returns trigger
361
+ language plpgsql
362
+ security definer
363
+ set search_path = ''
364
+ as $$
365
+ begin
366
+ if new.from_staff then
367
+ update public.support_reports
368
+ set last_staff_reply_at = new.created_at, staff_read_at = new.created_at
369
+ where id = new.report_id;
370
+ else
371
+ update public.support_reports
372
+ set last_user_reply_at = new.created_at, user_read_at = new.created_at
373
+ where id = new.report_id;
374
+ end if;
375
+ return null;
376
+ end;
377
+ $$;
378
+
379
+ create trigger genesis_support_reply_posted
380
+ after insert on public.support_replies
381
+ for each row execute function private.genesis_support_reply_posted();
382
+
383
+ -- Ideas and votes ---------------------------------------------------------------------
384
+
385
+ create table public.support_ideas (
386
+ id uuid primary key default gen_random_uuid(),
387
+ author_id uuid default auth.uid() references auth.users (id) on delete set null,
388
+ title text not null
389
+ constraint support_ideas_title_length check (char_length(btrim(title)) between 3 and 140),
390
+ body text
391
+ constraint support_ideas_body_length check (char_length(body) <= 4000),
392
+ -- hidden: taken off the board by staff (spam, a duplicate); its author and staff still see it.
393
+ status text not null default 'open'
394
+ constraint support_ideas_status check (status in ('open', 'planned', 'in_progress', 'shipped', 'declined', 'hidden')),
395
+ -- The FeatureId (features.ts) that delivers it, set by staff.
396
+ feature_id text
397
+ constraint support_ideas_feature_id_format check (feature_id ~ '^[a-z][a-z0-9]*([-_.][a-z0-9]+)*$' and char_length(feature_id) <= 100),
398
+ shipped_at timestamptz,
399
+ vote_count integer not null default 0,
400
+ created_at timestamptz not null default now(),
401
+ updated_at timestamptz not null default now()
402
+ );
403
+
404
+ create index support_ideas_board on public.support_ideas (status, vote_count desc, created_at desc);
405
+ create index support_ideas_author on public.support_ideas (author_id, created_at desc);
406
+
407
+ comment on table public.support_ideas is
408
+ 'The idea board: ideas every signed-in person reads and votes on, with a status staff set. Genesis support.';
409
+
410
+ create trigger genesis_support_touch
411
+ before update on public.support_ideas
412
+ for each row execute function private.genesis_support_touch();
413
+
414
+ create or replace function private.genesis_support_new_idea()
415
+ returns trigger
416
+ language plpgsql
417
+ security definer
418
+ set search_path = ''
419
+ as $$
420
+ begin
421
+ new.status := 'open';
422
+ new.feature_id := null;
423
+ new.shipped_at := null;
424
+ new.vote_count := 0;
425
+ new.created_at := now();
426
+ new.updated_at := now();
427
+ perform private.genesis_support_rate_limit(new.author_id, 'idea', 10, interval '1 day');
428
+ return new;
429
+ end;
430
+ $$;
431
+
432
+ create trigger genesis_support_new_idea
433
+ before insert on public.support_ideas
434
+ for each row execute function private.genesis_support_new_idea();
435
+
436
+ create table public.support_idea_votes (
437
+ idea_id uuid not null references public.support_ideas (id) on delete cascade,
438
+ user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
439
+ created_at timestamptz not null default now(),
440
+ primary key (idea_id, user_id)
441
+ );
442
+
443
+ create index support_idea_votes_user on public.support_idea_votes (user_id, created_at desc);
444
+
445
+ comment on table public.support_idea_votes is
446
+ 'One vote per person per idea. A person sees only their own; the count is on the idea. Genesis support.';
447
+
448
+ create or replace function private.genesis_support_count_vote()
449
+ returns trigger
450
+ language plpgsql
451
+ security definer
452
+ set search_path = ''
453
+ as $$
454
+ begin
455
+ if tg_op = 'INSERT' then
456
+ update public.support_ideas set vote_count = vote_count + 1 where id = new.idea_id;
457
+ else
458
+ update public.support_ideas set vote_count = greatest(vote_count - 1, 0) where id = old.idea_id;
459
+ end if;
460
+ return null;
461
+ end;
462
+ $$;
463
+
464
+ create trigger genesis_support_count_vote
465
+ after insert or delete on public.support_idea_votes
466
+ for each row execute function private.genesis_support_count_vote();
467
+
468
+ -- Row level security ------------------------------------------------------------------
469
+
470
+ alter table public.support_reports enable row level security;
471
+ alter table public.support_attachments enable row level security;
472
+ alter table public.support_attachment_purges enable row level security;
473
+ alter table public.support_replies enable row level security;
474
+ alter table public.support_ideas enable row level security;
475
+ alter table public.support_idea_votes enable row level security;
476
+
477
+ create policy support_reports_read on public.support_reports
478
+ for select to authenticated
479
+ using (user_id = (select auth.uid()) or (select public.support_is_staff()));
480
+ create policy support_reports_send on public.support_reports
481
+ for insert to authenticated
482
+ with check (user_id = (select auth.uid()));
483
+ -- Only until staff pick it up or answer: nobody's reply ends up under words it didn't answer.
484
+ create policy support_reports_edit on public.support_reports
485
+ for update to authenticated
486
+ using (user_id = (select auth.uid()) and status = 'open' and last_staff_reply_at is null)
487
+ with check (user_id = (select auth.uid()) and status = 'open' and last_staff_reply_at is null);
488
+ create policy support_reports_delete on public.support_reports
489
+ for delete to authenticated
490
+ using (user_id = (select auth.uid()));
491
+
492
+ create policy support_attachments_read on public.support_attachments
493
+ for select to authenticated
494
+ using (user_id = (select auth.uid()) or (select public.support_is_staff()));
495
+ create policy support_attachments_add on public.support_attachments
496
+ for insert to authenticated
497
+ with check (user_id = (select auth.uid()));
498
+
499
+ create policy support_replies_read on public.support_replies
500
+ for select to authenticated
501
+ using (
502
+ (select public.support_is_staff())
503
+ or exists (select 1 from public.support_reports r where r.id = report_id and r.user_id = (select auth.uid()))
504
+ );
505
+ create policy support_replies_send on public.support_replies
506
+ for insert to authenticated
507
+ with check (
508
+ (select public.support_is_staff())
509
+ or exists (select 1 from public.support_reports r where r.id = report_id and r.user_id = (select auth.uid()))
510
+ );
511
+
512
+ create policy support_ideas_read on public.support_ideas
513
+ for select to authenticated
514
+ using (status <> 'hidden' or author_id = (select auth.uid()) or (select public.support_is_staff()));
515
+ create policy support_ideas_suggest on public.support_ideas
516
+ for insert to authenticated
517
+ with check (
518
+ author_id = (select auth.uid())
519
+ and not (select public.support_is_anonymous())
520
+ );
521
+ -- Only until the first vote: nobody's vote moves to an idea they didn't vote for.
522
+ create policy support_ideas_edit on public.support_ideas
523
+ for update to authenticated
524
+ using (author_id = (select auth.uid()) and status = 'open' and vote_count = 0)
525
+ with check (author_id = (select auth.uid()) and status = 'open' and vote_count = 0);
526
+
527
+ create policy support_idea_votes_read on public.support_idea_votes
528
+ for select to authenticated
529
+ using (user_id = (select auth.uid()));
530
+ create policy support_idea_votes_cast on public.support_idea_votes
531
+ for insert to authenticated
532
+ with check (
533
+ user_id = (select auth.uid())
534
+ and not (select public.support_is_anonymous())
535
+ and exists (select 1 from public.support_ideas i where i.id = idea_id and i.status in ('open', 'planned', 'in_progress'))
536
+ );
537
+ create policy support_idea_votes_withdraw on public.support_idea_votes
538
+ for delete to authenticated
539
+ using (user_id = (select auth.uid()));
540
+
541
+ -- The storage bucket: private, images only, 5 MB each ---------------------------------
542
+
543
+ insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
544
+ values ('genesis-support', 'genesis-support', false, 5242880, array['image/png', 'image/jpeg', 'image/webp'])
545
+ on conflict (id) do update
546
+ set public = false, file_size_limit = excluded.file_size_limit, allowed_mime_types = excluded.allowed_mime_types;
547
+
548
+ -- May the signed-in person upload `object_name`? Only `<own id>/<own open report>/<name>.<image>`,
549
+ -- within an hour of sending the report, and at most 5 objects in its folder. A function, so the
550
+ -- policy can count the folder.
551
+ create or replace function public.support_can_upload(object_name text)
552
+ returns boolean
553
+ language plpgsql
554
+ stable
555
+ security definer
556
+ set search_path = ''
557
+ as $$
558
+ declare
559
+ me uuid := auth.uid();
560
+ folder text;
561
+ begin
562
+ if me is null or object_name is null
563
+ or object_name !~ '^[0-9a-f-]{36}/[0-9a-f-]{36}/[A-Za-z0-9_-]{1,80}\.(png|jpg|jpeg|webp)$'
564
+ or split_part(object_name, '/', 1) <> me::text then
565
+ return false;
566
+ end if;
567
+ if not exists (select 1 from public.support_reports r
568
+ where r.id::text = split_part(object_name, '/', 2) and r.user_id = me and r.status = 'open'
569
+ and r.created_at > now() - interval '1 hour') then
570
+ return false;
571
+ end if;
572
+ folder := split_part(object_name, '/', 1) || '/' || split_part(object_name, '/', 2) || '/';
573
+ return (select count(*) from storage.objects o where o.bucket_id = 'genesis-support' and left(o.name, length(folder)) = folder) < 5;
574
+ end;
575
+ $$;
576
+
577
+ comment on function public.support_can_upload(text) is
578
+ 'Whether the signed-in person may upload this support screenshot path. Genesis support.';
579
+
580
+ -- May the signed-in person read `object_name`? Their own while it is attached (or within an
581
+ -- hour of uploading it, before the attachment row lands); staff, only while it is attached.
582
+ -- A deleted report's screenshots can't be opened again, before the cleanup removes them.
583
+ create or replace function public.support_can_read(object_name text, uploaded_at timestamptz)
584
+ returns boolean
585
+ language sql
586
+ stable
587
+ security definer
588
+ set search_path = ''
589
+ as $$
590
+ select auth.uid() is not null and (
591
+ exists (select 1 from public.support_attachments a where a.path = object_name)
592
+ and (split_part(object_name, '/', 1) = auth.uid()::text or public.support_is_staff())
593
+ or (split_part(object_name, '/', 1) = auth.uid()::text and uploaded_at > now() - interval '1 hour')
594
+ )
595
+ $$;
596
+
597
+ comment on function public.support_can_read(text, timestamptz) is
598
+ 'Whether the signed-in person may read this support screenshot. Genesis support.';
599
+
600
+ -- Upload only into <your id>/<your open report>/; read your own, or any attached one as staff.
601
+ -- No update policy (nothing is overwritten or moved) and no delete policy (removal is the
602
+ -- cleanup function's, through the Storage API).
603
+ create policy genesis_support_upload on storage.objects
604
+ for insert to authenticated
605
+ with check (bucket_id = 'genesis-support' and (select public.support_can_upload(name)));
606
+ create policy genesis_support_read on storage.objects
607
+ for select to authenticated
608
+ using (bucket_id = 'genesis-support' and public.support_can_read(name, created_at));
609
+
610
+ -- Restrictive: whatever permissive storage policies the app already has (a "read everything",
611
+ -- an "owners may update"), inside this bucket only the rules above hold, for every role but
612
+ -- the service role (which bypasses row level security).
613
+ create policy genesis_support_only_read on storage.objects
614
+ as restrictive for select to public
615
+ using (bucket_id <> 'genesis-support' or public.support_can_read(name, created_at));
616
+ create policy genesis_support_only_upload on storage.objects
617
+ as restrictive for insert to public
618
+ with check (bucket_id <> 'genesis-support' or (select public.support_can_upload(name)));
619
+ create policy genesis_support_no_update on storage.objects
620
+ as restrictive for update to public
621
+ using (bucket_id <> 'genesis-support')
622
+ with check (bucket_id <> 'genesis-support');
623
+ create policy genesis_support_no_delete on storage.objects
624
+ as restrictive for delete to public
625
+ using (bucket_id <> 'genesis-support');
626
+
627
+ -- Functions ---------------------------------------------------------------------------
628
+
629
+ -- Reading a thread: the person (on their own report) or staff mark it read.
630
+ create or replace function public.support_mark_read(report uuid)
631
+ returns void
632
+ language plpgsql
633
+ security definer
634
+ set search_path = ''
635
+ as $$
636
+ declare
637
+ me uuid := auth.uid();
638
+ owner uuid;
639
+ begin
640
+ select r.user_id into owner from public.support_reports r where r.id = report;
641
+ if me is null or owner is null then
642
+ raise exception 'support_mark_read: no such report' using errcode = '42501';
643
+ end if;
644
+ if owner = me then
645
+ update public.support_reports set user_read_at = clock_timestamp() where id = report;
646
+ elsif public.support_is_staff() then
647
+ update public.support_reports set staff_read_at = clock_timestamp() where id = report;
648
+ else
649
+ raise exception 'support_mark_read: no such report' using errcode = '42501';
650
+ end if;
651
+ end;
652
+ $$;
653
+
654
+ comment on function public.support_mark_read(uuid) is
655
+ 'Marks a report''s thread read for its person, or for staff. Genesis support.';
656
+
657
+ -- Staff: a report's status, and the FeatureId it is about (null keeps it, '' clears it).
658
+ create or replace function public.support_set_report_status(report uuid, status text, feature text default null)
659
+ returns void
660
+ language plpgsql
661
+ security definer
662
+ set search_path = ''
663
+ as $$
664
+ begin
665
+ if not public.support_is_staff() then
666
+ raise exception 'support_set_report_status: only staff' using errcode = '42501';
667
+ end if;
668
+ update public.support_reports r
669
+ set status = support_set_report_status.status,
670
+ feature_id = case when feature = '' then null else coalesce(feature, r.feature_id) end
671
+ where r.id = report;
672
+ if not found then
673
+ raise exception 'support_set_report_status: no such report' using errcode = 'P0002';
674
+ end if;
675
+ end;
676
+ $$;
677
+
678
+ comment on function public.support_set_report_status(uuid, text, text) is
679
+ 'Staff set a report''s status and the FeatureId it is about. Genesis support.';
680
+
681
+ -- Staff: an idea's status (shipped stamps shipped_at), and the FeatureId that delivers it
682
+ -- (null keeps it, '' clears it).
683
+ create or replace function public.support_set_idea_status(idea uuid, status text, feature text default null)
684
+ returns void
685
+ language plpgsql
686
+ security definer
687
+ set search_path = ''
688
+ as $$
689
+ begin
690
+ if not public.support_is_staff() then
691
+ raise exception 'support_set_idea_status: only staff' using errcode = '42501';
692
+ end if;
693
+ update public.support_ideas i
694
+ set status = support_set_idea_status.status,
695
+ feature_id = case when feature = '' then null else coalesce(feature, i.feature_id) end,
696
+ shipped_at = case when support_set_idea_status.status = 'shipped' then coalesce(i.shipped_at, now()) end
697
+ where i.id = idea;
698
+ if not found then
699
+ raise exception 'support_set_idea_status: no such idea' using errcode = 'P0002';
700
+ end if;
701
+ end;
702
+ $$;
703
+
704
+ comment on function public.support_set_idea_status(uuid, text, text) is
705
+ 'Staff set an idea''s status (shipped stamps shipped_at) and the FeatureId that delivers it. Genesis support.';
706
+
707
+ -- "You asked, we shipped": shipped ideas the caller suggested or voted for, newest first.
708
+ create or replace function public.support_shipped_for_me(max_rows integer default 50)
709
+ returns table (id uuid, title text, body text, status text, feature_id text, shipped_at timestamptz, vote_count integer, created_at timestamptz, mine boolean, voted boolean)
710
+ language sql
711
+ stable
712
+ security definer
713
+ set search_path = ''
714
+ as $$
715
+ select i.id, i.title, i.body, i.status, i.feature_id, i.shipped_at, i.vote_count, i.created_at,
716
+ i.author_id = (select auth.uid()),
717
+ exists (select 1 from public.support_idea_votes v where v.idea_id = i.id and v.user_id = (select auth.uid()))
718
+ from public.support_ideas i
719
+ where i.status = 'shipped'
720
+ and (i.author_id = (select auth.uid())
721
+ or exists (select 1 from public.support_idea_votes v where v.idea_id = i.id and v.user_id = (select auth.uid())))
722
+ order by i.shipped_at desc nulls last, i.id
723
+ limit greatest(1, least(coalesce(max_rows, 50), 200))
724
+ $$;
725
+
726
+ comment on function public.support_shipped_for_me(integer) is
727
+ 'Shipped ideas the caller suggested or voted for, with their FeatureIds. Genesis support.';
728
+
729
+ -- The ideas the caller suggested (who suggested an idea isn't readable by others).
730
+ create or replace function public.support_my_idea_ids()
731
+ returns setof uuid
732
+ language sql
733
+ stable
734
+ security definer
735
+ set search_path = ''
736
+ as $$
737
+ select i.id from public.support_ideas i where i.author_id = (select auth.uid())
738
+ $$;
739
+
740
+ comment on function public.support_my_idea_ids() is
741
+ 'The ids of the ideas the caller suggested. Genesis support.';
742
+
743
+ -- For the cleanup function: objects in the bucket no attachment names, older than
744
+ -- `older_than` (an upload whose attachment row never landed). Service role only.
745
+ create or replace function public.support_orphan_objects(older_than interval default interval '1 day', max_rows integer default 500)
746
+ returns table (path text)
747
+ language sql
748
+ stable
749
+ security definer
750
+ set search_path = ''
751
+ as $$
752
+ select o.name from storage.objects o
753
+ where o.bucket_id = 'genesis-support'
754
+ and o.created_at <= now() - older_than
755
+ and not exists (select 1 from public.support_attachments a where a.path = o.name)
756
+ order by o.created_at
757
+ limit greatest(1, least(coalesce(max_rows, 500), 1000))
758
+ $$;
759
+
760
+ comment on function public.support_orphan_objects(interval, integer) is
761
+ 'Screenshots uploaded but never recorded, for the cleanup function. Service role only. Genesis support.';
762
+
763
+ -- Privileges by name ------------------------------------------------------------------
764
+ -- Supabase's default privileges give anon and authenticated everything on a new public
765
+ -- table and function, and Postgres gives every function to public: take them back, then
766
+ -- grant what's needed, column by column where a column is the server's to set.
767
+
768
+ revoke all on table public.support_reports from public, anon, authenticated;
769
+ grant select on table public.support_reports to authenticated;
770
+ grant insert (type, title, message, route, app_version, platform, device, context) on table public.support_reports to authenticated;
771
+ grant update (type, title, message) on table public.support_reports to authenticated;
772
+ grant delete on table public.support_reports to authenticated;
773
+
774
+ revoke all on table public.support_attachments from public, anon, authenticated;
775
+ grant select on table public.support_attachments to authenticated;
776
+ grant insert (report_id, path, content_type, byte_size, width, height) on table public.support_attachments to authenticated;
777
+
778
+ revoke all on table public.support_attachment_purges from public, anon, authenticated;
779
+ revoke all on sequence public.support_attachment_purges_id_seq from public, anon, authenticated;
780
+
781
+ revoke all on table public.support_replies from public, anon, authenticated;
782
+ -- Not author_id: a person never learns staff members' ids (from_staff says who wrote it).
783
+ grant select (id, report_id, from_staff, body, created_at) on table public.support_replies to authenticated;
784
+ grant insert (report_id, body) on table public.support_replies to authenticated;
785
+
786
+ revoke all on table public.support_ideas from public, anon, authenticated;
787
+ -- Not author_id: ideas aren't linked to who suggested them (support_my_idea_ids answers "mine").
788
+ grant select (id, title, body, status, feature_id, shipped_at, vote_count, created_at, updated_at) on table public.support_ideas to authenticated;
789
+ grant insert (title, body) on table public.support_ideas to authenticated;
790
+ grant update (title, body) on table public.support_ideas to authenticated;
791
+
792
+ revoke all on table public.support_idea_votes from public, anon, authenticated;
793
+ grant select on table public.support_idea_votes to authenticated;
794
+ grant insert (idea_id) on table public.support_idea_votes to authenticated;
795
+ grant delete on table public.support_idea_votes to authenticated;
796
+
797
+ -- The service role (server jobs: the cleanup and notify functions, a staff tool). No
798
+ -- truncate (it would skip the triggers), references or trigger.
799
+ revoke all on table public.support_reports from service_role;
800
+ grant select, insert, update, delete on table public.support_reports to service_role;
801
+ revoke all on table public.support_attachments from service_role;
802
+ grant select, insert, update, delete on table public.support_attachments to service_role;
803
+ revoke all on table public.support_attachment_purges from service_role;
804
+ grant select, delete on table public.support_attachment_purges to service_role;
805
+ revoke all on table public.support_replies from service_role;
806
+ grant select, insert, update, delete on table public.support_replies to service_role;
807
+ revoke all on table public.support_ideas from service_role;
808
+ grant select, insert, update, delete on table public.support_ideas to service_role;
809
+ revoke all on table public.support_idea_votes from service_role;
810
+ grant select, insert, delete on table public.support_idea_votes to service_role;
811
+
812
+ revoke execute on function public.support_is_staff() from public, anon;
813
+ grant execute on function public.support_is_staff() to authenticated, service_role;
814
+ revoke execute on function public.support_mark_read(uuid) from public, anon;
815
+ grant execute on function public.support_mark_read(uuid) to authenticated;
816
+ revoke execute on function public.support_set_report_status(uuid, text, text) from public, anon;
817
+ grant execute on function public.support_set_report_status(uuid, text, text) to authenticated;
818
+ revoke execute on function public.support_set_idea_status(uuid, text, text) from public, anon;
819
+ grant execute on function public.support_set_idea_status(uuid, text, text) to authenticated;
820
+ revoke execute on function public.support_shipped_for_me(integer) from public, anon;
821
+ grant execute on function public.support_shipped_for_me(integer) to authenticated;
822
+ revoke execute on function public.support_my_idea_ids() from public, anon;
823
+ grant execute on function public.support_my_idea_ids() to authenticated;
824
+ revoke execute on function public.support_is_anonymous() from public, anon;
825
+ grant execute on function public.support_is_anonymous() to authenticated;
826
+ -- The storage policies call these for every role they hold, anon included; each answers
827
+ -- false without a session.
828
+ revoke execute on function public.support_can_upload(text) from public;
829
+ grant execute on function public.support_can_upload(text) to anon, authenticated;
830
+ revoke execute on function public.support_can_read(text, timestamptz) from public;
831
+ grant execute on function public.support_can_read(text, timestamptz) to anon, authenticated;
832
+ revoke execute on function public.support_orphan_objects(interval, integer) from public, anon, authenticated;
833
+ grant execute on function public.support_orphan_objects(interval, integer) to service_role;
834
+
835
+ revoke all on table private.support_rate_events from public, anon, authenticated;
836
+
837
+ -- Only the triggers above call these.
838
+ revoke execute on function private.genesis_support_rate_limit(uuid, text, integer, interval) from public, anon, authenticated;
839
+ revoke execute on function private.genesis_support_touch() from public, anon, authenticated;
840
+ revoke execute on function private.genesis_support_new_report() from public, anon, authenticated;
841
+ revoke execute on function private.genesis_support_new_attachment() from public, anon, authenticated;
842
+ revoke execute on function private.genesis_support_queue_purge() from public, anon, authenticated;
843
+ revoke execute on function private.genesis_support_new_reply() from public, anon, authenticated;
844
+ revoke execute on function private.genesis_support_reply_posted() from public, anon, authenticated;
845
+ revoke execute on function private.genesis_support_new_idea() from public, anon, authenticated;
846
+ revoke execute on function private.genesis_support_count_vote() from public, anon, authenticated;