@marktiderman/genesis-chat 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.
@@ -0,0 +1,1836 @@
1
+ -- Genesis Chat: conversations, members, messages, attachments, reactions and read state
2
+ --
3
+ -- The server half of `@marktiderman/genesis-chat`, in the app's own Supabase, in the
4
+ -- genesis_chat schema:
5
+ --
6
+ -- kinds the kinds of conversation the app uses (direct, group, and its own:
7
+ -- a cohort, a support ticket…), each with its rules: how many members,
8
+ -- who posts, who adds members, whether threads are on, who else may read
9
+ -- conversations a place messages live, optionally tied to one of the app's records
10
+ -- (subject_type + subject_id, e.g. a group), with a running sequence
11
+ -- members who is in it (owner, admin, member), how far they have read, and their
12
+ -- own settings (muted, notify level, archived, pinned)
13
+ -- messages text (markdown) with mentions, a reply, one level of thread, a topic,
14
+ -- edits and tombstoned deletes; numbered per conversation by the server,
15
+ -- made idempotent by the sender's client_id
16
+ -- attachments images, files, voice notes, videos and links, or the app's own kinds
17
+ -- (a form, a lesson), each with its media metadata (duration, waveform,
18
+ -- poster, thumbhash, transcript)
19
+ -- reactions emoji, any number per person per message
20
+ -- pins, saved a conversation's pinned messages; a person's own saved messages
21
+ -- reports a reported message, with a snapshot of it and its context
22
+ -- effects an outbox: push, transcription and storage removal, worked by a runner
23
+ --
24
+ -- Access, enforced here by row level security:
25
+ -- - A conversation is its members'. Members read it; who posts follows the kind.
26
+ -- - A kind may name an observer check, a function (conversation_id uuid, user_id uuid)
27
+ -- returns boolean of the app's: those it says yes to read the conversation without
28
+ -- being in it (a practice's staff, a support team), and post when the kind lets
29
+ -- observers post. Messages with visibility 'observers' (internal notes) are read only
30
+ -- by observers.
31
+ -- - A kind may name a start check, a function (member_ids uuid[], subject_type text,
32
+ -- subject_id text, user_id uuid) returns boolean of the app's: whether that person
33
+ -- may start a conversation of the kind with those people. Without one, only the
34
+ -- server (service role) starts conversations of the kind.
35
+ -- - Everything else goes through the functions below. anon reaches nothing.
36
+ --
37
+ -- Register the app's rules in a migration of your own, after this one:
38
+ --
39
+ -- create function public.chat_may_dm(member_ids uuid[], subject_type text, subject_id text, user_id uuid)
40
+ -- returns boolean language sql stable security definer set search_path = '' as $$
41
+ -- select exists (select 1 from public.team t1 join public.team t2 using (org_id)
42
+ -- where t1.user_id = chat_may_dm.user_id and t2.user_id = member_ids[1])
43
+ -- $$;
44
+ -- revoke execute on function public.chat_may_dm(uuid[], text, text, uuid) from public, anon, authenticated;
45
+ -- select genesis_chat.register_kind('direct', 'Direct message', p_max_members => 2,
46
+ -- p_start_check => 'public.chat_may_dm(uuid[], text, text, uuid)');
47
+ --
48
+ -- Media: files go in the private `genesis-chat` bucket under
49
+ -- `<conversation id>/<uploader id>/<name>.<ext>`, uploaded by someone who may post there,
50
+ -- read by whoever reads the message that carries them. Restrictive policies keep the
51
+ -- bucket to these rules whatever other storage policies the app has.
52
+ --
53
+ -- Live updates: each new or changed message, reaction and membership is broadcast to
54
+ -- private Realtime topics, `genesis-chat:conv:<id>` (its readers) and
55
+ -- `genesis-chat:user:<id>` (a member's inbox), as a hint carrying ids only; clients
56
+ -- fetch through row level security. Typing and presence are broadcast by clients on
57
+ -- the conversation topic and never stored.
58
+ --
59
+ -- Side effects: a message queues a `push` effect, a voice or video attachment a
60
+ -- `transcribe` effect, a deleted file a `storage.remove` effect. A runner claims them
61
+ -- (claim_effects), does them, and settles them (complete_effect). Nothing is sent
62
+ -- from a trigger.
63
+ --
64
+ -- Account deletion: a login's memberships, messages, reactions and saved messages go
65
+ -- with it, and its files are queued for removal.
66
+ --
67
+ -- Use: `genesis add chat` copies this file into supabase/migrations under a new
68
+ -- timestamped name. Never edit it once applied; change it with a new migration.
69
+ --
70
+ -- Verify: the pgTAP test beside this file (`tests/0001_genesis_chat.test.sql`).
71
+
72
+ create schema if not exists genesis_chat;
73
+
74
+ comment on schema genesis_chat is 'Genesis Chat: conversations, members, messages and attachments. Reached through RLS and the functions in this schema.';
75
+
76
+ grant usage on schema genesis_chat to authenticated, service_role;
77
+
78
+ -- ── Kinds ─────────────────────────────────────────────────────────────────────
79
+
80
+ create table genesis_chat.kinds (
81
+ kind text primary key check (kind ~ '^[a-z][a-z0-9_]{0,39}$'),
82
+ label text not null check (length(label) between 1 and 80),
83
+ max_members integer check (max_members is null or max_members >= 2),
84
+ who_posts text not null default 'members' check (who_posts in ('members', 'admins')),
85
+ membership text not null default 'app' check (membership in ('app', 'admins')),
86
+ threads boolean not null default true,
87
+ observer_check regprocedure,
88
+ observers_post boolean not null default false,
89
+ start_check regprocedure,
90
+ edit_window interval,
91
+ created_at timestamptz not null default now(),
92
+ updated_at timestamptz not null default now()
93
+ );
94
+
95
+ comment on table genesis_chat.kinds is 'The kinds of conversation and their rules. Written by register_kind (the service role or a migration). Genesis Chat.';
96
+ comment on column genesis_chat.kinds.membership is 'Who adds and removes members: the app only (sync_members), or also the conversation''s owners and admins.';
97
+ comment on column genesis_chat.kinds.observer_check is 'f(conversation_id uuid, user_id uuid) returns boolean: who reads without being a member.';
98
+ comment on column genesis_chat.kinds.start_check is 'f(member_ids uuid[], subject_type text, subject_id text, user_id uuid) returns boolean: who may start one.';
99
+
100
+ alter table genesis_chat.kinds enable row level security;
101
+ create policy kinds_read on genesis_chat.kinds for select to authenticated using (true);
102
+ grant select on genesis_chat.kinds to authenticated;
103
+ grant select, insert, update, delete on genesis_chat.kinds to service_role;
104
+
105
+ create or replace function genesis_chat.register_kind(
106
+ p_kind text,
107
+ p_label text,
108
+ p_max_members integer default null,
109
+ p_who_posts text default 'members',
110
+ p_membership text default 'app',
111
+ p_threads boolean default true,
112
+ p_observer_check regprocedure default null,
113
+ p_observers_post boolean default false,
114
+ p_start_check regprocedure default null,
115
+ p_edit_window interval default null
116
+ )
117
+ returns void
118
+ language plpgsql
119
+ security definer
120
+ set search_path = ''
121
+ as $$
122
+ begin
123
+ if p_observer_check is not null and (
124
+ pg_get_function_identity_arguments(p_observer_check) !~ '^[a-z_]* ?uuid, [a-z_]* ?uuid$'
125
+ or (select prorettype from pg_proc where oid = p_observer_check) <> 'boolean'::regtype) then
126
+ raise exception 'An observer check takes (conversation_id uuid, user_id uuid) and returns boolean' using errcode = '22023';
127
+ end if;
128
+ if p_start_check is not null and (
129
+ pg_get_function_identity_arguments(p_start_check) !~ '^[a-z_]* ?uuid\[\], [a-z_]* ?text, [a-z_]* ?text, [a-z_]* ?uuid$'
130
+ or (select prorettype from pg_proc where oid = p_start_check) <> 'boolean'::regtype) then
131
+ raise exception 'A start check takes (member_ids uuid[], subject_type text, subject_id text, user_id uuid) and returns boolean' using errcode = '22023';
132
+ end if;
133
+ insert into genesis_chat.kinds as k (kind, label, max_members, who_posts, membership, threads,
134
+ observer_check, observers_post, start_check, edit_window)
135
+ values (p_kind, p_label, p_max_members, p_who_posts, p_membership, p_threads,
136
+ p_observer_check, p_observers_post, p_start_check, p_edit_window)
137
+ on conflict (kind) do update set
138
+ label = excluded.label, max_members = excluded.max_members, who_posts = excluded.who_posts,
139
+ membership = excluded.membership, threads = excluded.threads, observer_check = excluded.observer_check,
140
+ observers_post = excluded.observers_post, start_check = excluded.start_check,
141
+ edit_window = excluded.edit_window, updated_at = now();
142
+ end;
143
+ $$;
144
+
145
+ revoke execute on function genesis_chat.register_kind(text, text, integer, text, text, boolean, regprocedure, boolean, regprocedure, interval) from public, anon, authenticated;
146
+ grant execute on function genesis_chat.register_kind(text, text, integer, text, text, boolean, regprocedure, boolean, regprocedure, interval) to service_role;
147
+
148
+ comment on function genesis_chat.register_kind(text, text, integer, text, text, boolean, regprocedure, boolean, regprocedure, interval) is
149
+ 'Adds or replaces a kind of conversation and its rules. Run from a migration or the service role. Genesis Chat.';
150
+
151
+ -- Two kinds every app has. Neither may be started by a person until the app registers
152
+ -- a start check for it.
153
+ select genesis_chat.register_kind('direct', 'Direct message', p_max_members => 2);
154
+ select genesis_chat.register_kind('group', 'Group', p_membership => 'admins');
155
+
156
+ -- ── Conversations and members ──────────────────────────────────────────────────
157
+
158
+ create table genesis_chat.conversations (
159
+ id uuid primary key default gen_random_uuid(),
160
+ kind text not null references genesis_chat.kinds (kind) on update cascade,
161
+ title text check (title is null or length(title) between 1 and 120),
162
+ subject_type text check (subject_type is null or subject_type ~ '^[a-z][a-z0-9_]{0,39}$'),
163
+ subject_id text check (subject_id is null or length(subject_id) between 1 and 200),
164
+ direct_key text unique,
165
+ settings jsonb not null default '{}' check (jsonb_typeof(settings) = 'object' and pg_column_size(settings) <= 8192),
166
+ status text check (status is null or status in ('open', 'pending', 'solved', 'closed')),
167
+ assignee_id uuid references auth.users (id) on delete set null,
168
+ last_seq bigint not null default 0,
169
+ last_message_at timestamptz,
170
+ created_by uuid references auth.users (id) on delete set null,
171
+ created_at timestamptz not null default now(),
172
+ updated_at timestamptz not null default now(),
173
+ archived_at timestamptz,
174
+ constraint conversations_subject_pair check ((subject_type is null) = (subject_id is null))
175
+ );
176
+
177
+ create unique index conversations_one_per_subject on genesis_chat.conversations (kind, subject_type, subject_id)
178
+ where subject_type is not null;
179
+ create index conversations_assignee_idx on genesis_chat.conversations (assignee_id) where assignee_id is not null;
180
+ create index conversations_created_by_idx on genesis_chat.conversations (created_by);
181
+ create index conversations_kind_recent_idx on genesis_chat.conversations (kind, last_message_at desc);
182
+
183
+ comment on table genesis_chat.conversations is 'A place messages live. Read by its members and its kind''s observers. Genesis Chat.';
184
+ comment on column genesis_chat.conversations.settings is 'Per conversation overrides: {"who_posts": "admins"} makes it one-way. Genesis Chat.';
185
+
186
+ create table genesis_chat.members (
187
+ conversation_id uuid not null references genesis_chat.conversations (id) on delete cascade,
188
+ user_id uuid not null references auth.users (id) on delete cascade,
189
+ role text not null default 'member' check (role in ('owner', 'admin', 'member', 'observer')),
190
+ last_read_seq bigint not null default 0,
191
+ muted_until timestamptz,
192
+ notify text not null default 'all' check (notify in ('all', 'mentions', 'none')),
193
+ archived boolean not null default false,
194
+ pinned boolean not null default false,
195
+ joined_at timestamptz not null default now(),
196
+ left_at timestamptz,
197
+ primary key (conversation_id, user_id)
198
+ );
199
+
200
+ create index members_user_idx on genesis_chat.members (user_id) where left_at is null;
201
+
202
+ comment on table genesis_chat.members is
203
+ 'Who is in a conversation. An observer row only keeps an observer''s read state and settings: it grants nothing. Genesis Chat.';
204
+
205
+ -- ── Messages ──────────────────────────────────────────────────────────────────
206
+
207
+ create table genesis_chat.messages (
208
+ id uuid primary key default gen_random_uuid(),
209
+ conversation_id uuid not null references genesis_chat.conversations (id) on delete cascade,
210
+ seq bigint not null,
211
+ client_id uuid not null default gen_random_uuid(),
212
+ sender_id uuid references auth.users (id) on delete cascade,
213
+ sender_role text not null check (sender_role in ('owner', 'admin', 'member', 'observer', 'system')),
214
+ kind text not null default 'text' check (kind in ('text', 'system')),
215
+ body text not null default '' check (length(body) <= 10000),
216
+ mentions uuid[] not null default '{}' check (cardinality(mentions) <= 50),
217
+ reply_to_id uuid references genesis_chat.messages (id) on delete set null,
218
+ thread_root_id uuid references genesis_chat.messages (id) on delete cascade,
219
+ thread_count integer not null default 0,
220
+ thread_last_at timestamptz,
221
+ topic text check (topic is null or length(topic) between 1 and 120),
222
+ visibility text not null default 'everyone' check (visibility in ('everyone', 'observers')),
223
+ silent boolean not null default false,
224
+ meta jsonb not null default '{}' check (jsonb_typeof(meta) = 'object' and pg_column_size(meta) <= 4096),
225
+ created_at timestamptz not null default now(),
226
+ edited_at timestamptz,
227
+ deleted_at timestamptz,
228
+ constraint messages_seq_unique unique (conversation_id, seq),
229
+ constraint messages_client_unique unique (sender_id, client_id),
230
+ constraint messages_system_sender check ((kind = 'system') = (sender_role = 'system'))
231
+ );
232
+
233
+ create index messages_thread_idx on genesis_chat.messages (thread_root_id, seq) where thread_root_id is not null;
234
+ create index messages_topic_idx on genesis_chat.messages (conversation_id, topic, seq) where topic is not null;
235
+ create index messages_reply_idx on genesis_chat.messages (reply_to_id) where reply_to_id is not null;
236
+ create index messages_sender_idx on genesis_chat.messages (sender_id);
237
+
238
+ comment on table genesis_chat.messages is 'Messages, numbered per conversation (seq). Written through send_message, edit_message and delete_message. Genesis Chat.';
239
+
240
+ create table genesis_chat.attachments (
241
+ id uuid primary key default gen_random_uuid(),
242
+ message_id uuid not null references genesis_chat.messages (id) on delete cascade,
243
+ conversation_id uuid not null references genesis_chat.conversations (id) on delete cascade,
244
+ position smallint not null default 0 check (position between 0 and 19),
245
+ kind text not null check (kind ~ '^[a-z][a-z0-9_.-]{0,39}$'),
246
+ ref jsonb not null default '{}' check (jsonb_typeof(ref) = 'object' and pg_column_size(ref) <= 2048),
247
+ provider text not null default 'storage' check (provider in ('storage', 'mux', 'cloudflare', 'external', 'none')),
248
+ path text check (path is null or length(path) <= 400),
249
+ provider_id text check (provider_id is null or length(provider_id) <= 200),
250
+ playback_id text check (playback_id is null or length(playback_id) <= 200),
251
+ url text check (url is null or (length(url) <= 2000 and url ~ '^https://')),
252
+ status text not null default 'ready' check (status in ('pending', 'ready', 'failed')),
253
+ name text check (name is null or length(name) <= 200),
254
+ mime text check (mime is null or length(mime) <= 120),
255
+ bytes bigint check (bytes is null or bytes >= 0),
256
+ width integer check (width is null or width > 0),
257
+ height integer check (height is null or height > 0),
258
+ duration_ms integer check (duration_ms is null or duration_ms >= 0),
259
+ waveform smallint[] check (waveform is null or cardinality(waveform) <= 128),
260
+ poster_path text check (poster_path is null or length(poster_path) <= 400),
261
+ thumbhash text check (thumbhash is null or length(thumbhash) <= 64),
262
+ transcript text check (transcript is null or length(transcript) <= 100000),
263
+ transcript_status text check (transcript_status is null or transcript_status in ('pending', 'ready', 'failed', 'off')),
264
+ created_at timestamptz not null default now(),
265
+ constraint attachments_storage_path check (provider <> 'storage' or path is not null)
266
+ );
267
+
268
+ create index attachments_message_idx on genesis_chat.attachments (message_id, position);
269
+ create index attachments_conversation_idx on genesis_chat.attachments (conversation_id, kind);
270
+ create index attachments_path_idx on genesis_chat.attachments (path) where path is not null;
271
+ create index attachments_poster_idx on genesis_chat.attachments (poster_path) where poster_path is not null;
272
+ -- A hosted video belongs to one attachment, so nobody can attach (and later delete, or
273
+ -- receive the webhook for) someone else's upload by copying its id.
274
+ create unique index attachments_provider_unique on genesis_chat.attachments (provider, provider_id)
275
+ where provider_id is not null and provider in ('mux', 'cloudflare');
276
+
277
+ comment on table genesis_chat.attachments is 'What a message carries: a file in the genesis-chat bucket, a hosted video, a link or an app reference (ref). Genesis Chat.';
278
+
279
+ create table genesis_chat.reactions (
280
+ message_id uuid not null references genesis_chat.messages (id) on delete cascade,
281
+ user_id uuid not null references auth.users (id) on delete cascade,
282
+ emoji text not null check (length(emoji) between 1 and 32),
283
+ created_at timestamptz not null default now(),
284
+ primary key (message_id, user_id, emoji)
285
+ );
286
+ create index reactions_user_idx on genesis_chat.reactions (user_id);
287
+
288
+ create table genesis_chat.pins (
289
+ conversation_id uuid not null references genesis_chat.conversations (id) on delete cascade,
290
+ message_id uuid not null references genesis_chat.messages (id) on delete cascade,
291
+ pinned_by uuid references auth.users (id) on delete set null,
292
+ created_at timestamptz not null default now(),
293
+ primary key (conversation_id, message_id)
294
+ );
295
+ create index pins_message_idx on genesis_chat.pins (message_id);
296
+ create index pins_pinned_by_idx on genesis_chat.pins (pinned_by);
297
+
298
+ create table genesis_chat.saved (
299
+ user_id uuid not null references auth.users (id) on delete cascade,
300
+ message_id uuid not null references genesis_chat.messages (id) on delete cascade,
301
+ note text check (note is null or length(note) <= 500),
302
+ created_at timestamptz not null default now(),
303
+ primary key (user_id, message_id)
304
+ );
305
+ create index saved_message_idx on genesis_chat.saved (message_id);
306
+
307
+ create table genesis_chat.reports (
308
+ id uuid primary key default gen_random_uuid(),
309
+ message_id uuid references genesis_chat.messages (id) on delete set null,
310
+ conversation_id uuid not null references genesis_chat.conversations (id) on delete cascade,
311
+ reporter_id uuid references auth.users (id) on delete set null,
312
+ reason text not null check (reason in ('spam', 'harassment', 'safety', 'other')),
313
+ note text check (note is null or length(note) <= 1000),
314
+ snapshot jsonb not null check (pg_column_size(snapshot) <= 65536),
315
+ created_at timestamptz not null default now(),
316
+ resolved_at timestamptz,
317
+ resolved_by uuid references auth.users (id) on delete set null
318
+ );
319
+ create index reports_conversation_idx on genesis_chat.reports (conversation_id, created_at desc);
320
+ create index reports_reporter_idx on genesis_chat.reports (reporter_id);
321
+ create index reports_message_idx on genesis_chat.reports (message_id);
322
+ create index reports_resolved_by_idx on genesis_chat.reports (resolved_by);
323
+
324
+ comment on table genesis_chat.reports is 'Reported messages, with a snapshot of the message and the ten before it. Read by the reporter and the conversation''s observers. Genesis Chat.';
325
+
326
+ -- ── The outbox ────────────────────────────────────────────────────────────────
327
+
328
+ create table genesis_chat.effects (
329
+ id bigint generated always as identity primary key,
330
+ kind text not null check (kind ~ '^[a-z][a-z0-9_.]{0,39}$'),
331
+ key text not null unique check (length(key) <= 300),
332
+ payload jsonb not null default '{}',
333
+ run_after timestamptz not null default now(),
334
+ attempts integer not null default 0,
335
+ locked_until timestamptz,
336
+ last_error text,
337
+ done_at timestamptz,
338
+ created_at timestamptz not null default now()
339
+ );
340
+ create index effects_due_idx on genesis_chat.effects (run_after) where done_at is null;
341
+
342
+ comment on table genesis_chat.effects is 'Side effects to do (push, transcribe, storage.remove), each once by key. Worked through claim_effects and complete_effect by the service role. Genesis Chat.';
343
+
344
+ alter table genesis_chat.effects enable row level security;
345
+ grant select, insert, update, delete on genesis_chat.effects to service_role;
346
+
347
+ create or replace function genesis_chat.enqueue(p_kind text, p_key text, p_payload jsonb, p_run_after timestamptz default now())
348
+ returns void
349
+ language sql
350
+ security definer
351
+ set search_path = ''
352
+ as $$
353
+ insert into genesis_chat.effects (kind, key, payload, run_after)
354
+ values (p_kind, p_key, coalesce(p_payload, '{}'), coalesce(p_run_after, now()))
355
+ on conflict (key) do nothing;
356
+ $$;
357
+ revoke execute on function genesis_chat.enqueue(text, text, jsonb, timestamptz) from public, anon, authenticated;
358
+ grant execute on function genesis_chat.enqueue(text, text, jsonb, timestamptz) to service_role;
359
+
360
+ -- ── Access ────────────────────────────────────────────────────────────────────
361
+
362
+ -- Is this person a current member (owner, admin or member) of the conversation?
363
+ create or replace function genesis_chat.is_member(p_conversation_id uuid, p_user_id uuid default auth.uid())
364
+ returns boolean
365
+ language sql
366
+ stable
367
+ security definer
368
+ set search_path = ''
369
+ as $$
370
+ select p_user_id is not null and exists (
371
+ select 1 from genesis_chat.members m
372
+ where m.conversation_id = p_conversation_id and m.user_id = p_user_id
373
+ and m.left_at is null and m.role <> 'observer')
374
+ $$;
375
+
376
+ -- Does the conversation's kind say this person reads it without being in it?
377
+ create or replace function genesis_chat.observes(p_conversation_id uuid, p_user_id uuid default auth.uid())
378
+ returns boolean
379
+ language plpgsql
380
+ stable
381
+ security definer
382
+ set search_path = ''
383
+ as $$
384
+ declare
385
+ check_fn regprocedure;
386
+ result boolean;
387
+ begin
388
+ if p_user_id is null then
389
+ return false;
390
+ end if;
391
+ select k.observer_check into check_fn
392
+ from genesis_chat.conversations c join genesis_chat.kinds k on k.kind = c.kind
393
+ where c.id = p_conversation_id;
394
+ if check_fn is null then
395
+ return false;
396
+ end if;
397
+ execute format('select %s($1::uuid, $2::uuid)', check_fn::regproc) into result using p_conversation_id, p_user_id;
398
+ return coalesce(result, false);
399
+ end;
400
+ $$;
401
+
402
+ -- May the signed-in person read the conversation?
403
+ create or replace function genesis_chat.can_view(p_conversation_id uuid)
404
+ returns boolean
405
+ language sql
406
+ stable
407
+ security definer
408
+ set search_path = ''
409
+ as $$
410
+ select genesis_chat.is_member(p_conversation_id) or genesis_chat.observes(p_conversation_id)
411
+ $$;
412
+
413
+ -- The signed-in person's part in the conversation: owner, admin, member, observer or null.
414
+ create or replace function genesis_chat.my_role(p_conversation_id uuid)
415
+ returns text
416
+ language sql
417
+ stable
418
+ security definer
419
+ set search_path = ''
420
+ as $$
421
+ select coalesce(
422
+ (select m.role from genesis_chat.members m
423
+ where m.conversation_id = p_conversation_id and m.user_id = auth.uid()
424
+ and m.left_at is null and m.role <> 'observer'),
425
+ case when genesis_chat.observes(p_conversation_id) then 'observer' end)
426
+ $$;
427
+
428
+ -- May the signed-in person post in the conversation?
429
+ create or replace function genesis_chat.can_post(p_conversation_id uuid)
430
+ returns boolean
431
+ language sql
432
+ stable
433
+ security definer
434
+ set search_path = ''
435
+ as $$
436
+ select coalesce((
437
+ select case genesis_chat.my_role(c.id)
438
+ when 'owner' then true
439
+ when 'admin' then true
440
+ when 'member' then coalesce(c.settings ->> 'who_posts', k.who_posts) = 'members'
441
+ when 'observer' then k.observers_post
442
+ else false
443
+ end
444
+ from genesis_chat.conversations c join genesis_chat.kinds k on k.kind = c.kind
445
+ where c.id = p_conversation_id and c.archived_at is null), false)
446
+ $$;
447
+
448
+ -- Does the signed-in person observe the conversation? (For policies: observes() itself is
449
+ -- the server's, since it answers for anyone.)
450
+ create or replace function genesis_chat.i_observe(p_conversation_id uuid)
451
+ returns boolean
452
+ language sql
453
+ stable
454
+ security definer
455
+ set search_path = ''
456
+ as $$
457
+ select genesis_chat.observes(p_conversation_id, auth.uid())
458
+ $$;
459
+
460
+ revoke execute on function genesis_chat.is_member(uuid, uuid), genesis_chat.observes(uuid, uuid) from public, anon, authenticated;
461
+ revoke execute on function genesis_chat.i_observe(uuid) from public, anon;
462
+ grant execute on function genesis_chat.i_observe(uuid) to authenticated, service_role;
463
+ grant execute on function genesis_chat.is_member(uuid, uuid), genesis_chat.observes(uuid, uuid) to service_role;
464
+ revoke execute on function genesis_chat.can_view(uuid), genesis_chat.my_role(uuid), genesis_chat.can_post(uuid) from public, anon;
465
+ grant execute on function genesis_chat.can_view(uuid), genesis_chat.my_role(uuid), genesis_chat.can_post(uuid) to authenticated, service_role;
466
+
467
+ -- ── Row level security ────────────────────────────────────────────────────────
468
+ -- Reads only; every write goes through a function below.
469
+
470
+ alter table genesis_chat.conversations enable row level security;
471
+ alter table genesis_chat.members enable row level security;
472
+ alter table genesis_chat.messages enable row level security;
473
+ alter table genesis_chat.attachments enable row level security;
474
+ alter table genesis_chat.reactions enable row level security;
475
+ alter table genesis_chat.pins enable row level security;
476
+ alter table genesis_chat.saved enable row level security;
477
+ alter table genesis_chat.reports enable row level security;
478
+
479
+ create policy conversations_read on genesis_chat.conversations for select to authenticated
480
+ using (genesis_chat.can_view(id));
481
+
482
+ -- Members see who else is in it; observers see the members too.
483
+ create policy members_read on genesis_chat.members for select to authenticated
484
+ using (user_id = auth.uid() or (role <> 'observer' and left_at is null and genesis_chat.can_view(conversation_id)));
485
+
486
+ create policy messages_read on genesis_chat.messages for select to authenticated
487
+ using (genesis_chat.can_view(conversation_id)
488
+ and (visibility = 'everyone' or genesis_chat.i_observe(conversation_id)));
489
+
490
+ create policy attachments_read on genesis_chat.attachments for select to authenticated
491
+ using (exists (select 1 from genesis_chat.messages m where m.id = message_id));
492
+
493
+ create policy reactions_read on genesis_chat.reactions for select to authenticated
494
+ using (exists (select 1 from genesis_chat.messages m where m.id = message_id));
495
+
496
+ create policy pins_read on genesis_chat.pins for select to authenticated
497
+ using (genesis_chat.can_view(conversation_id));
498
+
499
+ create policy saved_read on genesis_chat.saved for select to authenticated
500
+ using (user_id = auth.uid());
501
+
502
+ create policy reports_read on genesis_chat.reports for select to authenticated
503
+ using (reporter_id = auth.uid() or genesis_chat.i_observe(conversation_id));
504
+
505
+ grant select on genesis_chat.conversations, genesis_chat.members, genesis_chat.messages, genesis_chat.attachments,
506
+ genesis_chat.reactions, genesis_chat.pins, genesis_chat.saved, genesis_chat.reports to authenticated;
507
+ grant select, insert, update, delete on genesis_chat.conversations, genesis_chat.members, genesis_chat.messages,
508
+ genesis_chat.attachments, genesis_chat.reactions, genesis_chat.pins, genesis_chat.saved, genesis_chat.reports to service_role;
509
+
510
+ -- ── Live updates ──────────────────────────────────────────────────────────────
511
+
512
+ -- Sends a hint to a private Realtime topic. Chat never fails because Realtime can't
513
+ -- take a message: a missed hint is caught up on the next fetch.
514
+ create or replace function genesis_chat.broadcast(p_topic text, p_event text, p_payload jsonb)
515
+ returns void
516
+ language plpgsql
517
+ security definer
518
+ set search_path = ''
519
+ as $$
520
+ begin
521
+ if to_regprocedure('realtime.send(jsonb, text, text, boolean)') is null then
522
+ return;
523
+ end if;
524
+ execute 'select realtime.send($1, $2, $3, true)' using p_payload, p_event, p_topic;
525
+ exception when others then
526
+ null;
527
+ end;
528
+ $$;
529
+ revoke execute on function genesis_chat.broadcast(text, text, jsonb) from public, anon, authenticated;
530
+
531
+ -- May the signed-in person join this Realtime topic? Its readers join a conversation's
532
+ -- topic (and broadcast typing and presence there); only you join your own inbox topic.
533
+ create or replace function genesis_chat.can_join_topic(p_topic text, p_write boolean default false)
534
+ returns boolean
535
+ language plpgsql
536
+ stable
537
+ security definer
538
+ set search_path = ''
539
+ as $$
540
+ declare
541
+ me uuid := auth.uid();
542
+ begin
543
+ if me is null or p_topic is null then
544
+ return false;
545
+ end if;
546
+ if p_topic ~ '^genesis-chat:user:[0-9a-f-]{36}$' then
547
+ return not p_write and split_part(p_topic, ':', 3) = me::text;
548
+ end if;
549
+ if p_topic ~ '^genesis-chat:conv:[0-9a-f-]{36}$' then
550
+ return genesis_chat.can_view(split_part(p_topic, ':', 3)::uuid);
551
+ end if;
552
+ return false;
553
+ end;
554
+ $$;
555
+ revoke execute on function genesis_chat.can_join_topic(text, boolean) from public, anon;
556
+ grant execute on function genesis_chat.can_join_topic(text, boolean) to authenticated;
557
+
558
+ do $do$
559
+ begin
560
+ if to_regclass('realtime.messages') is not null then
561
+ execute $p$
562
+ create policy genesis_chat_join on realtime.messages for select to authenticated
563
+ using (genesis_chat.can_join_topic(realtime.topic()))
564
+ $p$;
565
+ execute $p$
566
+ create policy genesis_chat_signal on realtime.messages for insert to authenticated
567
+ with check (realtime.messages.extension in ('broadcast', 'presence')
568
+ and genesis_chat.can_join_topic(realtime.topic(), true))
569
+ $p$;
570
+ end if;
571
+ end;
572
+ $do$;
573
+
574
+ -- ── Triggers ──────────────────────────────────────────────────────────────────
575
+
576
+ -- The next number in the conversation, taken under the conversation's row lock, so
577
+ -- numbers are dense and in commit order per conversation.
578
+ create or replace function genesis_chat.number_message()
579
+ returns trigger
580
+ language plpgsql
581
+ security definer
582
+ set search_path = ''
583
+ as $$
584
+ begin
585
+ update genesis_chat.conversations
586
+ set last_seq = last_seq + 1, last_message_at = new.created_at, updated_at = now()
587
+ where id = new.conversation_id
588
+ returning last_seq into new.seq;
589
+ if new.seq is null then
590
+ raise exception 'No such conversation' using errcode = '23503';
591
+ end if;
592
+ return new;
593
+ end;
594
+ $$;
595
+ revoke execute on function genesis_chat.number_message() from public, anon, authenticated;
596
+ create trigger messages_number before insert on genesis_chat.messages
597
+ for each row execute function genesis_chat.number_message();
598
+
599
+ -- A message posted: the sender has read up to it, the thread root counts it, push is
600
+ -- queued (a moment later, so a burst collapses), and readers get a hint.
601
+ create or replace function genesis_chat.message_posted()
602
+ returns trigger
603
+ language plpgsql
604
+ security definer
605
+ set search_path = ''
606
+ as $$
607
+ declare
608
+ member record;
609
+ fanout integer;
610
+ begin
611
+ if new.sender_id is not null then
612
+ update genesis_chat.members set last_read_seq = greatest(last_read_seq, new.seq)
613
+ where conversation_id = new.conversation_id and user_id = new.sender_id;
614
+ end if;
615
+ if new.thread_root_id is not null then
616
+ update genesis_chat.messages set thread_count = thread_count + 1, thread_last_at = new.created_at
617
+ where id = new.thread_root_id;
618
+ end if;
619
+ if new.kind = 'text' and not new.silent and new.visibility = 'everyone' then
620
+ perform genesis_chat.enqueue('push', 'push:' || new.id,
621
+ jsonb_build_object('message_id', new.id, 'conversation_id', new.conversation_id), now() + interval '2 seconds');
622
+ end if;
623
+ perform genesis_chat.broadcast('genesis-chat:conv:' || new.conversation_id, 'message',
624
+ jsonb_build_object('id', new.id, 'seq', new.seq, 'conversation_id', new.conversation_id, 'op', 'insert'));
625
+ if new.visibility = 'everyone' then
626
+ select count(*) into fanout from genesis_chat.members
627
+ where conversation_id = new.conversation_id and left_at is null;
628
+ if fanout <= 500 then
629
+ for member in select user_id from genesis_chat.members
630
+ where conversation_id = new.conversation_id and left_at is null loop
631
+ perform genesis_chat.broadcast('genesis-chat:user:' || member.user_id, 'inbox',
632
+ jsonb_build_object('conversation_id', new.conversation_id, 'seq', new.seq));
633
+ end loop;
634
+ end if;
635
+ end if;
636
+ return null;
637
+ end;
638
+ $$;
639
+ revoke execute on function genesis_chat.message_posted() from public, anon, authenticated;
640
+ create trigger messages_posted after insert on genesis_chat.messages
641
+ for each row execute function genesis_chat.message_posted();
642
+
643
+ create or replace function genesis_chat.message_changed()
644
+ returns trigger
645
+ language plpgsql
646
+ security definer
647
+ set search_path = ''
648
+ as $$
649
+ begin
650
+ if new.body is distinct from old.body or new.deleted_at is distinct from old.deleted_at
651
+ or new.thread_count is distinct from old.thread_count then
652
+ perform genesis_chat.broadcast('genesis-chat:conv:' || new.conversation_id, 'message',
653
+ jsonb_build_object('id', new.id, 'seq', new.seq, 'conversation_id', new.conversation_id, 'op', 'update'));
654
+ end if;
655
+ return null;
656
+ end;
657
+ $$;
658
+ revoke execute on function genesis_chat.message_changed() from public, anon, authenticated;
659
+ create trigger messages_changed after update on genesis_chat.messages
660
+ for each row execute function genesis_chat.message_changed();
661
+
662
+ -- Voice and video get a transcript; a deleted file is removed from storage.
663
+ create or replace function genesis_chat.attachment_added()
664
+ returns trigger
665
+ language plpgsql
666
+ security definer
667
+ set search_path = ''
668
+ as $$
669
+ begin
670
+ if new.kind in ('voice', 'video') and new.transcript_status = 'pending' then
671
+ perform genesis_chat.enqueue('transcribe', 'transcribe:' || new.id,
672
+ jsonb_build_object('attachment_id', new.id, 'conversation_id', new.conversation_id));
673
+ end if;
674
+ return null;
675
+ end;
676
+ $$;
677
+ revoke execute on function genesis_chat.attachment_added() from public, anon, authenticated;
678
+ create trigger attachments_added after insert on genesis_chat.attachments
679
+ for each row execute function genesis_chat.attachment_added();
680
+
681
+ create or replace function genesis_chat.attachment_removed()
682
+ returns trigger
683
+ language plpgsql
684
+ security definer
685
+ set search_path = ''
686
+ as $$
687
+ begin
688
+ if old.provider = 'storage' and old.path is not null then
689
+ perform genesis_chat.enqueue('storage.remove', 'storage.remove:' || old.path,
690
+ jsonb_build_object('bucket', 'genesis-chat', 'path', old.path));
691
+ end if;
692
+ if old.poster_path is not null then
693
+ perform genesis_chat.enqueue('storage.remove', 'storage.remove:' || old.poster_path,
694
+ jsonb_build_object('bucket', 'genesis-chat', 'path', old.poster_path));
695
+ end if;
696
+ if old.provider in ('mux', 'cloudflare') and old.provider_id is not null then
697
+ perform genesis_chat.enqueue('video.remove', 'video.remove:' || old.provider || ':' || old.provider_id,
698
+ jsonb_build_object('provider', old.provider, 'provider_id', old.provider_id));
699
+ end if;
700
+ return null;
701
+ end;
702
+ $$;
703
+ revoke execute on function genesis_chat.attachment_removed() from public, anon, authenticated;
704
+ create trigger attachments_removed after delete on genesis_chat.attachments
705
+ for each row execute function genesis_chat.attachment_removed();
706
+
707
+ create or replace function genesis_chat.reaction_changed()
708
+ returns trigger
709
+ language plpgsql
710
+ security definer
711
+ set search_path = ''
712
+ as $$
713
+ declare
714
+ row_ genesis_chat.reactions := coalesce(new, old);
715
+ conv uuid;
716
+ begin
717
+ select m.conversation_id into conv from genesis_chat.messages m where m.id = row_.message_id;
718
+ if conv is not null then
719
+ perform genesis_chat.broadcast('genesis-chat:conv:' || conv, 'reaction',
720
+ jsonb_build_object('message_id', row_.message_id, 'conversation_id', conv));
721
+ end if;
722
+ return null;
723
+ end;
724
+ $$;
725
+ revoke execute on function genesis_chat.reaction_changed() from public, anon, authenticated;
726
+ create trigger reactions_changed after insert or delete on genesis_chat.reactions
727
+ for each row execute function genesis_chat.reaction_changed();
728
+
729
+ create or replace function genesis_chat.membership_changed()
730
+ returns trigger
731
+ language plpgsql
732
+ security definer
733
+ set search_path = ''
734
+ as $$
735
+ declare
736
+ row_ genesis_chat.members := coalesce(new, old);
737
+ begin
738
+ if tg_op = 'UPDATE' and new.role is not distinct from old.role and new.left_at is not distinct from old.left_at then
739
+ return null;
740
+ end if;
741
+ perform genesis_chat.broadcast('genesis-chat:user:' || row_.user_id, 'inbox',
742
+ jsonb_build_object('conversation_id', row_.conversation_id));
743
+ perform genesis_chat.broadcast('genesis-chat:conv:' || row_.conversation_id, 'members',
744
+ jsonb_build_object('conversation_id', row_.conversation_id));
745
+ return null;
746
+ end;
747
+ $$;
748
+ revoke execute on function genesis_chat.membership_changed() from public, anon, authenticated;
749
+ create trigger members_changed after insert or update or delete on genesis_chat.members
750
+ for each row execute function genesis_chat.membership_changed();
751
+
752
+ create or replace function genesis_chat.touch()
753
+ returns trigger
754
+ language plpgsql
755
+ set search_path = ''
756
+ as $$
757
+ begin
758
+ new.updated_at := now();
759
+ return new;
760
+ end;
761
+ $$;
762
+ revoke execute on function genesis_chat.touch() from public, anon, authenticated;
763
+ create trigger conversations_touch before update on genesis_chat.conversations
764
+ for each row when (new.last_seq is not distinct from old.last_seq)
765
+ execute function genesis_chat.touch();
766
+
767
+ -- ── Storage ───────────────────────────────────────────────────────────────────
768
+
769
+ insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
770
+ values ('genesis-chat', 'genesis-chat', false, 104857600, array[
771
+ 'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/heic', 'image/heif',
772
+ 'audio/mp4', 'audio/aac', 'audio/mpeg', 'audio/ogg', 'audio/webm', 'audio/x-m4a',
773
+ 'video/mp4', 'video/quicktime', 'video/webm',
774
+ 'application/pdf', 'text/plain', 'text/csv',
775
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
776
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
777
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation'])
778
+ on conflict (id) do update
779
+ set public = false, file_size_limit = excluded.file_size_limit, allowed_mime_types = excluded.allowed_mime_types;
780
+
781
+ -- The extensions a chat file may have: never SVG or HTML, which a browser runs as a page.
782
+ create or replace function genesis_chat.allowed_extension(p_ext text)
783
+ returns boolean
784
+ language sql
785
+ immutable
786
+ set search_path = ''
787
+ as $$
788
+ select lower(coalesce(p_ext, '')) = any (array[
789
+ 'png', 'jpg', 'jpeg', 'webp', 'gif', 'heic', 'heif',
790
+ 'm4a', 'aac', 'mp3', 'ogg', 'oga', 'opus', 'weba', 'webm',
791
+ 'mp4', 'mov', 'pdf', 'txt', 'csv', 'docx', 'xlsx', 'pptx'])
792
+ $$;
793
+ revoke execute on function genesis_chat.allowed_extension(text) from public, anon;
794
+ grant execute on function genesis_chat.allowed_extension(text) to authenticated, service_role;
795
+
796
+ -- May the signed-in person upload `object_name`? Only `<conversation they post in>/<own id>/<name>.<ext>`.
797
+ create or replace function genesis_chat.can_upload(object_name text)
798
+ returns boolean
799
+ language plpgsql
800
+ stable
801
+ security definer
802
+ set search_path = ''
803
+ as $$
804
+ declare
805
+ me uuid := auth.uid();
806
+ begin
807
+ if me is null or object_name is null
808
+ or object_name !~ '^[0-9a-f-]{36}/[0-9a-f-]{36}/[A-Za-z0-9_-]{1,100}\.[A-Za-z0-9]{1,5}$'
809
+ or split_part(object_name, '/', 2) <> me::text
810
+ or not genesis_chat.allowed_extension(split_part(split_part(object_name, '/', 3), '.', 2)) then
811
+ return false;
812
+ end if;
813
+ return genesis_chat.can_post(split_part(object_name, '/', 1)::uuid);
814
+ end;
815
+ $$;
816
+
817
+ -- May the signed-in person read `object_name`? Whoever reads a message that carries it;
818
+ -- its uploader for a day before it is sent.
819
+ create or replace function genesis_chat.can_read_object(object_name text, object_owner text, uploaded_at timestamptz)
820
+ returns boolean
821
+ language sql
822
+ stable
823
+ security definer
824
+ set search_path = ''
825
+ as $$
826
+ select auth.uid() is not null and (
827
+ exists (
828
+ select 1 from genesis_chat.attachments a
829
+ join genesis_chat.messages m on m.id = a.message_id
830
+ where (a.path = object_name or a.poster_path = object_name)
831
+ and m.deleted_at is null
832
+ and genesis_chat.can_view(m.conversation_id)
833
+ and (m.visibility = 'everyone' or genesis_chat.observes(m.conversation_id)))
834
+ or (object_owner = auth.uid()::text and split_part(object_name, '/', 2) = auth.uid()::text
835
+ and uploaded_at > now() - interval '1 day'))
836
+ $$;
837
+
838
+ revoke execute on function genesis_chat.can_upload(text), genesis_chat.can_read_object(text, text, timestamptz) from public, anon;
839
+ grant execute on function genesis_chat.can_upload(text), genesis_chat.can_read_object(text, text, timestamptz) to authenticated;
840
+
841
+ create policy genesis_chat_upload on storage.objects
842
+ for insert to authenticated
843
+ with check (bucket_id = 'genesis-chat' and (select genesis_chat.can_upload(name)));
844
+ create policy genesis_chat_read on storage.objects
845
+ for select to authenticated
846
+ using (bucket_id = 'genesis-chat' and genesis_chat.can_read_object(name, owner_id, created_at));
847
+
848
+ -- Restrictive: inside this bucket only the rules above hold, whatever the app's other
849
+ -- storage policies, for every role but the service role.
850
+ -- Scoped by role: a policy that applies to a role needs that role to be able to run its
851
+ -- functions, and anon can't run these, so anon gets its own policies without them.
852
+ create policy genesis_chat_only_read on storage.objects
853
+ as restrictive for select to authenticated
854
+ using (bucket_id <> 'genesis-chat' or genesis_chat.can_read_object(name, owner_id, created_at));
855
+ create policy genesis_chat_anon_read on storage.objects
856
+ as restrictive for select to anon
857
+ using (bucket_id <> 'genesis-chat');
858
+ create policy genesis_chat_only_upload on storage.objects
859
+ as restrictive for insert to authenticated
860
+ with check (bucket_id <> 'genesis-chat' or (select genesis_chat.can_upload(name)));
861
+ create policy genesis_chat_anon_upload on storage.objects
862
+ as restrictive for insert to anon
863
+ with check (bucket_id <> 'genesis-chat');
864
+ create policy genesis_chat_no_update on storage.objects
865
+ as restrictive for update to public
866
+ using (bucket_id <> 'genesis-chat')
867
+ with check (bucket_id <> 'genesis-chat');
868
+ create policy genesis_chat_no_delete on storage.objects
869
+ as restrictive for delete to public
870
+ using (bucket_id <> 'genesis-chat');
871
+
872
+ -- ── Starting conversations and membership ─────────────────────────────────────
873
+
874
+ -- Starts a conversation of `p_kind` with these people, if the kind's start check says
875
+ -- the signed-in person may. A direct conversation with someone is found again, not
876
+ -- made twice. The starter owns a group; both people are members of a direct one.
877
+ create or replace function genesis_chat.start_conversation(
878
+ p_kind text,
879
+ p_member_ids uuid[],
880
+ p_title text default null,
881
+ p_subject_type text default null,
882
+ p_subject_id text default null
883
+ )
884
+ returns uuid
885
+ language plpgsql
886
+ security definer
887
+ set search_path = ''
888
+ as $$
889
+ declare
890
+ me uuid := auth.uid();
891
+ k genesis_chat.kinds;
892
+ others uuid[];
893
+ allowed boolean;
894
+ key text;
895
+ conv uuid;
896
+ begin
897
+ if me is null then
898
+ raise exception 'Sign in to start a conversation' using errcode = '42501';
899
+ end if;
900
+ select * into k from genesis_chat.kinds where kind = p_kind;
901
+ if k.kind is null then
902
+ raise exception 'No such kind of conversation: %', p_kind using errcode = '22023';
903
+ end if;
904
+ select coalesce(array_agg(distinct x), '{}') into others
905
+ from unnest(coalesce(p_member_ids, '{}')) x where x is not null and x <> me;
906
+ if cardinality(others) = 0 then
907
+ raise exception 'Choose someone to talk to' using errcode = '22023';
908
+ end if;
909
+ if k.max_members is not null and cardinality(others) + 1 > k.max_members then
910
+ raise exception 'A % has at most % members', lower(k.label), k.max_members using errcode = '22023';
911
+ end if;
912
+ if k.start_check is null then
913
+ raise exception 'You can''t start this kind of conversation' using errcode = '42501';
914
+ end if;
915
+ execute format('select %s($1::uuid[], $2::text, $3::text, $4::uuid)', k.start_check::regproc)
916
+ into allowed using others, p_subject_type, p_subject_id, me;
917
+ if not coalesce(allowed, false) then
918
+ raise exception 'You can''t start a conversation with these people' using errcode = '42501';
919
+ end if;
920
+
921
+ if k.max_members = 2 then
922
+ key := p_kind || ':' || least(me, others[1])::text || ':' || greatest(me, others[1])::text;
923
+ perform pg_advisory_xact_lock(hashtextextended(key, 0));
924
+ select id into conv from genesis_chat.conversations where direct_key = key;
925
+ if conv is not null then
926
+ insert into genesis_chat.members (conversation_id, user_id, role)
927
+ select conv, u, 'member' from unnest(array[me, others[1]]) u
928
+ on conflict (conversation_id, user_id) do update set left_at = null, role = 'member', joined_at =
929
+ case when genesis_chat.members.left_at is null then genesis_chat.members.joined_at else now() end;
930
+ return conv;
931
+ end if;
932
+ end if;
933
+
934
+ insert into genesis_chat.conversations (kind, title, subject_type, subject_id, direct_key, created_by)
935
+ values (p_kind, nullif(btrim(p_title), ''), p_subject_type, p_subject_id, key, me)
936
+ returning id into conv;
937
+ insert into genesis_chat.members (conversation_id, user_id, role)
938
+ values (conv, me, case when k.max_members = 2 then 'member' else 'owner' end);
939
+ insert into genesis_chat.members (conversation_id, user_id, role)
940
+ select conv, u, 'member' from unnest(others) u;
941
+ return conv;
942
+ end;
943
+ $$;
944
+
945
+ -- The server's side: the conversation for one of the app's records, made once.
946
+ create or replace function genesis_chat.ensure_conversation(
947
+ p_kind text,
948
+ p_subject_type text,
949
+ p_subject_id text,
950
+ p_title text default null,
951
+ p_settings jsonb default '{}'
952
+ )
953
+ returns uuid
954
+ language plpgsql
955
+ security definer
956
+ set search_path = ''
957
+ as $$
958
+ declare
959
+ conv uuid;
960
+ begin
961
+ if p_subject_type is null or p_subject_id is null then
962
+ raise exception 'A subject (type and id) is required' using errcode = '22023';
963
+ end if;
964
+ select id into conv from genesis_chat.conversations
965
+ where kind = p_kind and subject_type = p_subject_type and subject_id = p_subject_id;
966
+ if conv is null then
967
+ insert into genesis_chat.conversations (kind, subject_type, subject_id, title, settings)
968
+ values (p_kind, p_subject_type, p_subject_id, nullif(btrim(p_title), ''), coalesce(p_settings, '{}'))
969
+ on conflict (kind, subject_type, subject_id) where subject_type is not null do nothing
970
+ returning id into conv;
971
+ if conv is null then
972
+ select id into conv from genesis_chat.conversations
973
+ where kind = p_kind and subject_type = p_subject_type and subject_id = p_subject_id;
974
+ end if;
975
+ elsif p_title is not null then
976
+ update genesis_chat.conversations set title = nullif(btrim(p_title), '')
977
+ where id = conv and title is distinct from nullif(btrim(p_title), '');
978
+ end if;
979
+ return conv;
980
+ end;
981
+ $$;
982
+
983
+ -- The server's side: the members holding `p_role` become exactly `p_user_ids`. Others
984
+ -- with that role leave (keeping their history out of reach); those listed join or come
985
+ -- back with that role. Returns how many rows changed.
986
+ create or replace function genesis_chat.sync_members(p_conversation_id uuid, p_user_ids uuid[], p_role text default 'member')
987
+ returns integer
988
+ language plpgsql
989
+ security definer
990
+ set search_path = ''
991
+ as $$
992
+ declare
993
+ changed integer := 0;
994
+ n integer;
995
+ k genesis_chat.kinds;
996
+ begin
997
+ if p_role not in ('owner', 'admin', 'member') then
998
+ raise exception 'A member is an owner, admin or member' using errcode = '22023';
999
+ end if;
1000
+ select kd.* into k from genesis_chat.conversations c join genesis_chat.kinds kd on kd.kind = c.kind
1001
+ where c.id = p_conversation_id;
1002
+ if k.kind is null then
1003
+ raise exception 'No such conversation' using errcode = '23503';
1004
+ end if;
1005
+ update genesis_chat.members set left_at = now()
1006
+ where conversation_id = p_conversation_id and role = p_role and left_at is null
1007
+ and not (user_id = any (coalesce(p_user_ids, '{}')));
1008
+ get diagnostics n = row_count; changed := changed + n;
1009
+ insert into genesis_chat.members as m (conversation_id, user_id, role)
1010
+ select p_conversation_id, u, p_role from (select distinct u from unnest(coalesce(p_user_ids, '{}')) u where u is not null) s
1011
+ on conflict (conversation_id, user_id) do update
1012
+ set role = excluded.role, left_at = null,
1013
+ joined_at = case when m.left_at is null then m.joined_at else now() end
1014
+ where m.role is distinct from excluded.role or m.left_at is not null;
1015
+ get diagnostics n = row_count; changed := changed + n;
1016
+ if k.max_members is not null and (select count(*) from genesis_chat.members
1017
+ where conversation_id = p_conversation_id and left_at is null and role <> 'observer') > k.max_members then
1018
+ raise exception 'A % has at most % members', lower(k.label), k.max_members using errcode = '22023';
1019
+ end if;
1020
+ return changed;
1021
+ end;
1022
+ $$;
1023
+
1024
+ -- Owners and admins add people to a conversation whose kind lets them, if the kind's
1025
+ -- start check says they may be with them.
1026
+ create or replace function genesis_chat.add_members(p_conversation_id uuid, p_user_ids uuid[])
1027
+ returns void
1028
+ language plpgsql
1029
+ security definer
1030
+ set search_path = ''
1031
+ as $$
1032
+ declare
1033
+ me uuid := auth.uid();
1034
+ c genesis_chat.conversations;
1035
+ k genesis_chat.kinds;
1036
+ adding uuid[];
1037
+ allowed boolean;
1038
+ begin
1039
+ select * into c from genesis_chat.conversations where id = p_conversation_id;
1040
+ select * into k from genesis_chat.kinds where kind = c.kind;
1041
+ if c.id is null or coalesce(genesis_chat.my_role(c.id), '') not in ('owner', 'admin') or k.membership <> 'admins' then
1042
+ raise exception 'You can''t add people to this conversation' using errcode = '42501';
1043
+ end if;
1044
+ select coalesce(array_agg(distinct x), '{}') into adding from unnest(coalesce(p_user_ids, '{}')) x where x is not null;
1045
+ if k.start_check is null then
1046
+ raise exception 'You can''t add people to this conversation' using errcode = '42501';
1047
+ end if;
1048
+ execute format('select %s($1::uuid[], $2::text, $3::text, $4::uuid)', k.start_check::regproc)
1049
+ into allowed using adding, c.subject_type, c.subject_id, me;
1050
+ if not coalesce(allowed, false) then
1051
+ raise exception 'You can''t add these people' using errcode = '42501';
1052
+ end if;
1053
+ insert into genesis_chat.members as m (conversation_id, user_id, role)
1054
+ select c.id, u, 'member' from unnest(adding) u
1055
+ on conflict (conversation_id, user_id) do update set left_at = null, role = 'member', joined_at = now()
1056
+ where m.left_at is not null or m.role = 'observer';
1057
+ if k.max_members is not null and (select count(*) from genesis_chat.members
1058
+ where conversation_id = c.id and left_at is null and role <> 'observer') > k.max_members then
1059
+ raise exception 'A % has at most % members', lower(k.label), k.max_members using errcode = '22023';
1060
+ end if;
1061
+ end;
1062
+ $$;
1063
+
1064
+ -- Leave a conversation, or (an owner or admin) remove someone from it, where the kind
1065
+ -- lets its admins manage members. Nobody leaves a conversation the app keeps.
1066
+ create or replace function genesis_chat.remove_member(p_conversation_id uuid, p_user_id uuid default null)
1067
+ returns void
1068
+ language plpgsql
1069
+ security definer
1070
+ set search_path = ''
1071
+ as $$
1072
+ declare
1073
+ me uuid := auth.uid();
1074
+ target uuid := coalesce(p_user_id, auth.uid());
1075
+ k genesis_chat.kinds;
1076
+ target_role text;
1077
+ begin
1078
+ select kd.* into k from genesis_chat.conversations c join genesis_chat.kinds kd on kd.kind = c.kind
1079
+ where c.id = p_conversation_id;
1080
+ select role into target_role from genesis_chat.members
1081
+ where conversation_id = p_conversation_id and user_id = target and left_at is null and role <> 'observer';
1082
+ if k.kind is null or target_role is null or k.membership <> 'admins' or k.max_members = 2
1083
+ or (target <> me and (coalesce(genesis_chat.my_role(p_conversation_id), '') not in ('owner', 'admin')
1084
+ or (target_role = 'owner' and coalesce(genesis_chat.my_role(p_conversation_id), '') <> 'owner'))) then
1085
+ raise exception 'You can''t remove this person' using errcode = '42501';
1086
+ end if;
1087
+ update genesis_chat.members set left_at = now() where conversation_id = p_conversation_id and user_id = target;
1088
+ end;
1089
+ $$;
1090
+
1091
+ -- An owner sets someone's role (owner, admin or member).
1092
+ create or replace function genesis_chat.set_role(p_conversation_id uuid, p_user_id uuid, p_role text)
1093
+ returns void
1094
+ language plpgsql
1095
+ security definer
1096
+ set search_path = ''
1097
+ as $$
1098
+ begin
1099
+ if p_role not in ('owner', 'admin', 'member') then
1100
+ raise exception 'A member is an owner, admin or member' using errcode = '22023';
1101
+ end if;
1102
+ if genesis_chat.my_role(p_conversation_id) is distinct from 'owner' then
1103
+ raise exception 'Only an owner changes roles' using errcode = '42501';
1104
+ end if;
1105
+ update genesis_chat.members set role = p_role
1106
+ where conversation_id = p_conversation_id and user_id = p_user_id and left_at is null and role <> 'observer';
1107
+ if not found then
1108
+ raise exception 'They aren''t in this conversation' using errcode = '22023';
1109
+ end if;
1110
+ end;
1111
+ $$;
1112
+
1113
+ -- Owners and admins rename a conversation or change its settings (one-way: who_posts).
1114
+ create or replace function genesis_chat.update_conversation(p_conversation_id uuid, p_title text default null, p_settings jsonb default null)
1115
+ returns void
1116
+ language plpgsql
1117
+ security definer
1118
+ set search_path = ''
1119
+ as $$
1120
+ begin
1121
+ if coalesce(genesis_chat.my_role(p_conversation_id), '') not in ('owner', 'admin') then
1122
+ raise exception 'Only an owner or admin changes this conversation' using errcode = '42501';
1123
+ end if;
1124
+ if p_settings is not null and p_settings ? 'who_posts' and p_settings ->> 'who_posts' not in ('members', 'admins') then
1125
+ raise exception 'who_posts is members or admins' using errcode = '22023';
1126
+ end if;
1127
+ update genesis_chat.conversations
1128
+ set title = coalesce(nullif(btrim(p_title), ''), title),
1129
+ settings = case when p_settings is null then settings else settings || p_settings end
1130
+ where id = p_conversation_id;
1131
+ end;
1132
+ $$;
1133
+
1134
+ -- Observers who may post (a support team), or owners and admins, set a ticket's status
1135
+ -- and who has it.
1136
+ create or replace function genesis_chat.set_status(p_conversation_id uuid, p_status text, p_assignee_id uuid default null)
1137
+ returns void
1138
+ language plpgsql
1139
+ security definer
1140
+ set search_path = ''
1141
+ as $$
1142
+ declare
1143
+ role_ text := coalesce(genesis_chat.my_role(p_conversation_id), '');
1144
+ begin
1145
+ if not (role_ in ('owner', 'admin') or (role_ = 'observer' and genesis_chat.can_post(p_conversation_id))) then
1146
+ raise exception 'You can''t change this conversation''s status' using errcode = '42501';
1147
+ end if;
1148
+ update genesis_chat.conversations set status = p_status, assignee_id = p_assignee_id where id = p_conversation_id;
1149
+ perform genesis_chat.broadcast('genesis-chat:conv:' || p_conversation_id, 'conversation',
1150
+ jsonb_build_object('conversation_id', p_conversation_id));
1151
+ end;
1152
+ $$;
1153
+
1154
+ -- ── Messages ──────────────────────────────────────────────────────────────────
1155
+
1156
+ -- Sends a message. Sending the same client_id again returns the first one, so a retry
1157
+ -- never posts twice. Attachments: [{ "kind", "path" | "provider" + "provider_id" |
1158
+ -- "url" | "ref", "name", "mime", "bytes", "width", "height", "duration_ms",
1159
+ -- "waveform", "poster_path", "thumbhash", "status" }]. A file must be one the sender
1160
+ -- uploaded into this conversation's folder.
1161
+ create or replace function genesis_chat.send_message(
1162
+ p_conversation_id uuid,
1163
+ p_client_id uuid,
1164
+ p_body text default '',
1165
+ p_attachments jsonb default '[]',
1166
+ p_reply_to_id uuid default null,
1167
+ p_thread_root_id uuid default null,
1168
+ p_topic text default null,
1169
+ p_mentions uuid[] default '{}',
1170
+ p_visibility text default 'everyone',
1171
+ p_silent boolean default false
1172
+ )
1173
+ returns genesis_chat.messages
1174
+ language plpgsql
1175
+ security definer
1176
+ set search_path = ''
1177
+ as $$
1178
+ declare
1179
+ me uuid := auth.uid();
1180
+ msg genesis_chat.messages;
1181
+ role_ text;
1182
+ k genesis_chat.kinds;
1183
+ a jsonb;
1184
+ i integer := 0;
1185
+ folder text := p_conversation_id::text || '/' || auth.uid()::text || '/';
1186
+ file_path text;
1187
+ attachment_count integer := coalesce(jsonb_array_length(case when jsonb_typeof(p_attachments) = 'array' then p_attachments end), 0);
1188
+ begin
1189
+ if me is null then
1190
+ raise exception 'Sign in to send a message' using errcode = '42501';
1191
+ end if;
1192
+ if p_client_id is null then
1193
+ raise exception 'A message needs a client_id' using errcode = '22023';
1194
+ end if;
1195
+ select * into msg from genesis_chat.messages where sender_id = me and client_id = p_client_id;
1196
+ if msg.id is not null then
1197
+ if msg.conversation_id <> p_conversation_id then
1198
+ raise exception 'That client_id was used in another conversation' using errcode = '22023';
1199
+ end if;
1200
+ return msg;
1201
+ end if;
1202
+ if not genesis_chat.can_post(p_conversation_id) then
1203
+ raise exception 'You can''t post in this conversation' using errcode = '42501';
1204
+ end if;
1205
+ role_ := genesis_chat.my_role(p_conversation_id);
1206
+ select kd.* into k from genesis_chat.conversations c join genesis_chat.kinds kd on kd.kind = c.kind
1207
+ where c.id = p_conversation_id;
1208
+ if p_attachments is not null and jsonb_typeof(p_attachments) <> 'array' then
1209
+ raise exception 'Attachments are a list' using errcode = '22023';
1210
+ end if;
1211
+ if attachment_count > 20 then
1212
+ raise exception 'A message carries at most 20 attachments' using errcode = '22023';
1213
+ end if;
1214
+ if btrim(coalesce(p_body, '')) = '' and attachment_count = 0 then
1215
+ raise exception 'A message needs text or an attachment' using errcode = '22023';
1216
+ end if;
1217
+ if p_visibility not in ('everyone', 'observers') then
1218
+ raise exception 'Visibility is everyone or observers' using errcode = '22023';
1219
+ end if;
1220
+ if p_visibility = 'observers' and not genesis_chat.observes(p_conversation_id) then
1221
+ raise exception 'Only observers write internal notes' using errcode = '42501';
1222
+ end if;
1223
+ if p_reply_to_id is not null and not exists (
1224
+ select 1 from genesis_chat.messages where id = p_reply_to_id and conversation_id = p_conversation_id) then
1225
+ raise exception 'The message you reply to isn''t in this conversation' using errcode = '22023';
1226
+ end if;
1227
+ if p_thread_root_id is not null then
1228
+ if not k.threads then
1229
+ raise exception 'This conversation has no threads' using errcode = '22023';
1230
+ end if;
1231
+ if not exists (select 1 from genesis_chat.messages where id = p_thread_root_id
1232
+ and conversation_id = p_conversation_id and thread_root_id is null and deleted_at is null) then
1233
+ raise exception 'A thread starts on a message in this conversation' using errcode = '22023';
1234
+ end if;
1235
+ end if;
1236
+
1237
+ insert into genesis_chat.messages (conversation_id, client_id, sender_id, sender_role, body, mentions,
1238
+ reply_to_id, thread_root_id, topic, visibility, silent)
1239
+ values (p_conversation_id, p_client_id, me, role_, coalesce(p_body, ''),
1240
+ coalesce((select array_agg(distinct u) from unnest(coalesce(p_mentions, '{}')) u
1241
+ where genesis_chat.is_member(p_conversation_id, u)), '{}'),
1242
+ p_reply_to_id, p_thread_root_id, nullif(btrim(p_topic), ''), p_visibility, coalesce(p_silent, false))
1243
+ returning * into msg;
1244
+
1245
+ for a in select value from jsonb_array_elements(coalesce(p_attachments, '[]')) loop
1246
+ file_path := a ->> 'path';
1247
+ if coalesce(a ->> 'provider', 'storage') = 'storage' then
1248
+ if file_path is null or left(file_path, length(folder)) <> folder
1249
+ or not exists (select 1 from storage.objects o where o.bucket_id = 'genesis-chat'
1250
+ and o.name = file_path and o.owner_id = me::text) then
1251
+ raise exception 'Upload the file into this conversation before sending it' using errcode = '22023';
1252
+ end if;
1253
+ end if;
1254
+ if a ->> 'poster_path' is not null and (left(a ->> 'poster_path', length(folder)) <> folder
1255
+ or not exists (select 1 from storage.objects o where o.bucket_id = 'genesis-chat'
1256
+ and o.name = a ->> 'poster_path' and o.owner_id = me::text)) then
1257
+ raise exception 'Upload the poster into this conversation before sending it' using errcode = '22023';
1258
+ end if;
1259
+ insert into genesis_chat.attachments (message_id, conversation_id, position, kind, ref, provider, path, provider_id,
1260
+ url, status, name, mime, bytes, width, height, duration_ms, waveform,
1261
+ poster_path, thumbhash, transcript_status)
1262
+ values (msg.id, p_conversation_id, i, a ->> 'kind', coalesce(a -> 'ref', '{}'),
1263
+ coalesce(a ->> 'provider', 'storage'), file_path, a ->> 'provider_id', a ->> 'url',
1264
+ -- A hosted video is ready only when its provider's webhook says so.
1265
+ case when a ->> 'status' = 'pending' or coalesce(a ->> 'provider', 'storage') in ('mux', 'cloudflare')
1266
+ then 'pending' else 'ready' end,
1267
+ a ->> 'name', a ->> 'mime', (a ->> 'bytes')::bigint, (a ->> 'width')::integer, (a ->> 'height')::integer,
1268
+ (a ->> 'duration_ms')::integer,
1269
+ case when jsonb_typeof(a -> 'waveform') = 'array'
1270
+ then (select array_agg(least(greatest(x::integer, 0), 100)::smallint) from jsonb_array_elements_text(a -> 'waveform') x) end,
1271
+ a ->> 'poster_path', a ->> 'thumbhash',
1272
+ case when a ->> 'kind' in ('voice', 'video') then 'pending' end);
1273
+ i := i + 1;
1274
+ end loop;
1275
+ return msg;
1276
+ end;
1277
+ $$;
1278
+
1279
+ -- Edits your own message (within the kind's edit window, if it has one).
1280
+ create or replace function genesis_chat.edit_message(p_message_id uuid, p_body text, p_mentions uuid[] default '{}')
1281
+ returns genesis_chat.messages
1282
+ language plpgsql
1283
+ security definer
1284
+ set search_path = ''
1285
+ as $$
1286
+ declare
1287
+ msg genesis_chat.messages;
1288
+ window_ interval;
1289
+ begin
1290
+ select * into msg from genesis_chat.messages where id = p_message_id;
1291
+ if msg.id is null or msg.sender_id is distinct from auth.uid() or msg.deleted_at is not null or msg.kind <> 'text'
1292
+ or not genesis_chat.can_view(msg.conversation_id) then
1293
+ raise exception 'You can''t edit this message' using errcode = '42501';
1294
+ end if;
1295
+ select k.edit_window into window_ from genesis_chat.conversations c join genesis_chat.kinds k on k.kind = c.kind
1296
+ where c.id = msg.conversation_id;
1297
+ if window_ is not null and msg.created_at < now() - window_ then
1298
+ raise exception 'This message can no longer be edited' using errcode = '42501';
1299
+ end if;
1300
+ if btrim(coalesce(p_body, '')) = '' and not exists (select 1 from genesis_chat.attachments where message_id = msg.id) then
1301
+ raise exception 'A message needs text or an attachment' using errcode = '22023';
1302
+ end if;
1303
+ update genesis_chat.messages
1304
+ set body = coalesce(p_body, ''), edited_at = now(),
1305
+ mentions = coalesce((select array_agg(distinct u) from unnest(coalesce(p_mentions, '{}')) u
1306
+ where genesis_chat.is_member(msg.conversation_id, u)), '{}')
1307
+ where id = msg.id
1308
+ returning * into msg;
1309
+ return msg;
1310
+ end;
1311
+ $$;
1312
+
1313
+ -- Deletes a message: its sender, the conversation's owners and admins, or observers who
1314
+ -- may post (moderators). The row stays as a placeholder; text, mentions and files go.
1315
+ create or replace function genesis_chat.delete_message(p_message_id uuid)
1316
+ returns void
1317
+ language plpgsql
1318
+ security definer
1319
+ set search_path = ''
1320
+ as $$
1321
+ declare
1322
+ msg genesis_chat.messages;
1323
+ role_ text;
1324
+ begin
1325
+ select * into msg from genesis_chat.messages where id = p_message_id;
1326
+ if msg.id is null then
1327
+ raise exception 'No such message' using errcode = '42501';
1328
+ end if;
1329
+ role_ := coalesce(genesis_chat.my_role(msg.conversation_id), '');
1330
+ if not (msg.sender_id = auth.uid() and role_ <> ''
1331
+ or role_ in ('owner', 'admin')
1332
+ or (role_ = 'observer' and genesis_chat.can_post(msg.conversation_id))) then
1333
+ raise exception 'You can''t delete this message' using errcode = '42501';
1334
+ end if;
1335
+ delete from genesis_chat.attachments where message_id = msg.id;
1336
+ delete from genesis_chat.reactions where message_id = msg.id;
1337
+ delete from genesis_chat.pins where message_id = msg.id;
1338
+ update genesis_chat.messages set body = '', mentions = '{}', meta = '{}', deleted_at = now()
1339
+ where id = msg.id and deleted_at is null;
1340
+ end;
1341
+ $$;
1342
+
1343
+ -- The server's side: an event in the conversation (someone joined, a form was answered).
1344
+ create or replace function genesis_chat.post_system_message(p_conversation_id uuid, p_body text, p_meta jsonb default '{}', p_key uuid default null)
1345
+ returns uuid
1346
+ language plpgsql
1347
+ security definer
1348
+ set search_path = ''
1349
+ as $$
1350
+ declare
1351
+ id_ uuid;
1352
+ begin
1353
+ if p_key is not null then
1354
+ select id into id_ from genesis_chat.messages
1355
+ where conversation_id = p_conversation_id and sender_id is null and client_id = p_key;
1356
+ if id_ is not null then
1357
+ return id_;
1358
+ end if;
1359
+ end if;
1360
+ insert into genesis_chat.messages (conversation_id, client_id, sender_id, sender_role, kind, body, meta)
1361
+ values (p_conversation_id, coalesce(p_key, gen_random_uuid()), null, 'system', 'system', coalesce(p_body, ''), coalesce(p_meta, '{}'))
1362
+ returning id into id_;
1363
+ return id_;
1364
+ end;
1365
+ $$;
1366
+
1367
+ -- Adds or takes away your reaction.
1368
+ create or replace function genesis_chat.react(p_message_id uuid, p_emoji text, p_on boolean default true)
1369
+ returns void
1370
+ language plpgsql
1371
+ security definer
1372
+ set search_path = ''
1373
+ as $$
1374
+ declare
1375
+ conv uuid;
1376
+ begin
1377
+ select m.conversation_id into conv from genesis_chat.messages m
1378
+ where m.id = p_message_id and m.deleted_at is null
1379
+ and (m.visibility = 'everyone' or genesis_chat.observes(m.conversation_id));
1380
+ if conv is null or not genesis_chat.can_post(conv) then
1381
+ raise exception 'You can''t react to this message' using errcode = '42501';
1382
+ end if;
1383
+ if p_on then
1384
+ if (select count(distinct emoji) from genesis_chat.reactions where message_id = p_message_id and user_id = auth.uid()) >= 10 then
1385
+ raise exception 'At most 10 reactions each' using errcode = '22023';
1386
+ end if;
1387
+ insert into genesis_chat.reactions (message_id, user_id, emoji) values (p_message_id, auth.uid(), p_emoji)
1388
+ on conflict do nothing;
1389
+ else
1390
+ delete from genesis_chat.reactions where message_id = p_message_id and user_id = auth.uid() and emoji = p_emoji;
1391
+ end if;
1392
+ end;
1393
+ $$;
1394
+
1395
+ -- Marks the conversation read up to `p_seq` (or its latest). Only moves forward. An
1396
+ -- observer gets a row of their own to keep their place.
1397
+ create or replace function genesis_chat.mark_read(p_conversation_id uuid, p_seq bigint default null)
1398
+ returns bigint
1399
+ language plpgsql
1400
+ security definer
1401
+ set search_path = ''
1402
+ as $$
1403
+ declare
1404
+ me uuid := auth.uid();
1405
+ latest bigint;
1406
+ result bigint;
1407
+ begin
1408
+ select last_seq into latest from genesis_chat.conversations where id = p_conversation_id;
1409
+ if latest is null or not genesis_chat.can_view(p_conversation_id) then
1410
+ raise exception 'You aren''t in this conversation' using errcode = '42501';
1411
+ end if;
1412
+ if genesis_chat.is_member(p_conversation_id) then
1413
+ update genesis_chat.members set last_read_seq = greatest(last_read_seq, least(coalesce(p_seq, latest), latest))
1414
+ where conversation_id = p_conversation_id and user_id = me
1415
+ returning last_read_seq into result;
1416
+ else
1417
+ insert into genesis_chat.members as m (conversation_id, user_id, role, last_read_seq)
1418
+ values (p_conversation_id, me, 'observer', least(coalesce(p_seq, latest), latest))
1419
+ on conflict (conversation_id, user_id) do update
1420
+ set last_read_seq = greatest(m.last_read_seq, excluded.last_read_seq)
1421
+ returning last_read_seq into result;
1422
+ end if;
1423
+ return result;
1424
+ end;
1425
+ $$;
1426
+
1427
+ -- Your own settings for a conversation: muted until, notify level, archived, pinned.
1428
+ create or replace function genesis_chat.set_my_settings(
1429
+ p_conversation_id uuid,
1430
+ p_muted_until timestamptz default null,
1431
+ p_notify text default null,
1432
+ p_archived boolean default null,
1433
+ p_pinned boolean default null,
1434
+ p_clear_mute boolean default false
1435
+ )
1436
+ returns void
1437
+ language plpgsql
1438
+ security definer
1439
+ set search_path = ''
1440
+ as $$
1441
+ begin
1442
+ if p_notify is not null and p_notify not in ('all', 'mentions', 'none') then
1443
+ raise exception 'Notify is all, mentions or none' using errcode = '22023';
1444
+ end if;
1445
+ update genesis_chat.members
1446
+ set muted_until = case when p_clear_mute then null else coalesce(p_muted_until, muted_until) end,
1447
+ notify = coalesce(p_notify, notify),
1448
+ archived = coalesce(p_archived, archived),
1449
+ pinned = coalesce(p_pinned, pinned)
1450
+ where conversation_id = p_conversation_id and user_id = auth.uid() and left_at is null;
1451
+ if not found then
1452
+ raise exception 'You aren''t in this conversation' using errcode = '42501';
1453
+ end if;
1454
+ end;
1455
+ $$;
1456
+
1457
+ -- Pins or unpins a message: owners and admins, or anyone in a direct conversation.
1458
+ create or replace function genesis_chat.pin_message(p_message_id uuid, p_on boolean default true)
1459
+ returns void
1460
+ language plpgsql
1461
+ security definer
1462
+ set search_path = ''
1463
+ as $$
1464
+ declare
1465
+ msg genesis_chat.messages;
1466
+ role_ text;
1467
+ direct boolean;
1468
+ begin
1469
+ select * into msg from genesis_chat.messages where id = p_message_id and deleted_at is null and visibility = 'everyone';
1470
+ role_ := coalesce(genesis_chat.my_role(msg.conversation_id), '');
1471
+ select k.max_members = 2 into direct from genesis_chat.conversations c join genesis_chat.kinds k on k.kind = c.kind
1472
+ where c.id = msg.conversation_id;
1473
+ if msg.id is null or not (role_ in ('owner', 'admin') or (coalesce(direct, false) and role_ = 'member')) then
1474
+ raise exception 'You can''t pin this message' using errcode = '42501';
1475
+ end if;
1476
+ if p_on then
1477
+ insert into genesis_chat.pins (conversation_id, message_id, pinned_by) values (msg.conversation_id, msg.id, auth.uid())
1478
+ on conflict do nothing;
1479
+ else
1480
+ delete from genesis_chat.pins where message_id = msg.id;
1481
+ end if;
1482
+ perform genesis_chat.broadcast('genesis-chat:conv:' || msg.conversation_id, 'conversation',
1483
+ jsonb_build_object('conversation_id', msg.conversation_id));
1484
+ end;
1485
+ $$;
1486
+
1487
+ -- Saves a message for yourself, or forgets it.
1488
+ create or replace function genesis_chat.save_message(p_message_id uuid, p_on boolean default true, p_note text default null)
1489
+ returns void
1490
+ language plpgsql
1491
+ security definer
1492
+ set search_path = ''
1493
+ as $$
1494
+ begin
1495
+ if p_on then
1496
+ if not exists (select 1 from genesis_chat.messages m where m.id = p_message_id and m.deleted_at is null
1497
+ and genesis_chat.can_view(m.conversation_id)
1498
+ and (m.visibility = 'everyone' or genesis_chat.observes(m.conversation_id))) then
1499
+ raise exception 'You can''t save this message' using errcode = '42501';
1500
+ end if;
1501
+ insert into genesis_chat.saved (user_id, message_id, note) values (auth.uid(), p_message_id, p_note)
1502
+ on conflict (user_id, message_id) do update set note = excluded.note;
1503
+ else
1504
+ delete from genesis_chat.saved where user_id = auth.uid() and message_id = p_message_id;
1505
+ end if;
1506
+ end;
1507
+ $$;
1508
+
1509
+ -- Reports a message, keeping a copy of it and the ten messages before it.
1510
+ create or replace function genesis_chat.report_message(p_message_id uuid, p_reason text, p_note text default null)
1511
+ returns uuid
1512
+ language plpgsql
1513
+ security definer
1514
+ set search_path = ''
1515
+ as $$
1516
+ declare
1517
+ msg genesis_chat.messages;
1518
+ id_ uuid;
1519
+ begin
1520
+ select * into msg from genesis_chat.messages m where m.id = p_message_id
1521
+ and genesis_chat.can_view(m.conversation_id) and (m.visibility = 'everyone' or genesis_chat.observes(m.conversation_id));
1522
+ if msg.id is null then
1523
+ raise exception 'You can''t report this message' using errcode = '42501';
1524
+ end if;
1525
+ if (select count(*) from genesis_chat.reports where reporter_id = auth.uid() and created_at > now() - interval '1 hour') >= 20 then
1526
+ raise exception 'Too many reports; try again later' using errcode = 'GS429';
1527
+ end if;
1528
+ insert into genesis_chat.reports (message_id, conversation_id, reporter_id, reason, note, snapshot)
1529
+ values (msg.id, msg.conversation_id, auth.uid(), p_reason, p_note, jsonb_build_object(
1530
+ 'message', jsonb_build_object('id', msg.id, 'sender_id', msg.sender_id, 'body', msg.body, 'created_at', msg.created_at),
1531
+ 'context', coalesce((select jsonb_agg(jsonb_build_object('id', c.id, 'sender_id', c.sender_id, 'body', c.body, 'created_at', c.created_at) order by c.seq)
1532
+ from (select * from genesis_chat.messages
1533
+ where conversation_id = msg.conversation_id and seq < msg.seq and visibility = 'everyone'
1534
+ order by seq desc limit 10) c), '[]')))
1535
+ returning id into id_;
1536
+ return id_;
1537
+ end;
1538
+ $$;
1539
+
1540
+ -- ── The inbox ─────────────────────────────────────────────────────────────────
1541
+
1542
+ -- What a member hasn't read in one conversation, counted to 99: messages after their place,
1543
+ -- from someone else, readable by them (not an internal note), not deleted, on the main
1544
+ -- timeline (a thread's replies are counted on the thread). The inbox, the badge and push
1545
+ -- all count this way.
1546
+ create or replace function genesis_chat.unread_count(p_conversation_id uuid, p_user_id uuid, p_after bigint)
1547
+ returns integer
1548
+ language sql
1549
+ stable
1550
+ security definer
1551
+ set search_path = ''
1552
+ as $$
1553
+ select count(*)::integer from (
1554
+ select 1 from genesis_chat.messages m
1555
+ where m.conversation_id = p_conversation_id and m.seq > p_after and m.deleted_at is null
1556
+ and m.sender_id is distinct from p_user_id and m.visibility = 'everyone' and m.thread_root_id is null
1557
+ limit 99) u
1558
+ $$;
1559
+ revoke execute on function genesis_chat.unread_count(uuid, uuid, bigint) from public, anon, authenticated;
1560
+ grant execute on function genesis_chat.unread_count(uuid, uuid, bigint) to service_role;
1561
+
1562
+ -- Your conversations, pinned first then newest activity, with the latest message you
1563
+ -- can read and how many you haven't (counted to 99). `p_kind` narrows it to one kind;
1564
+ -- `p_archived` shows the archived ones instead.
1565
+ create or replace function genesis_chat.inbox(
1566
+ p_kind text default null,
1567
+ p_archived boolean default false,
1568
+ p_limit integer default 50,
1569
+ p_before timestamptz default null
1570
+ )
1571
+ returns table (
1572
+ conversation_id uuid,
1573
+ kind text,
1574
+ title text,
1575
+ subject_type text,
1576
+ subject_id text,
1577
+ status text,
1578
+ role text,
1579
+ other_user_id uuid,
1580
+ member_count integer,
1581
+ last_seq bigint,
1582
+ last_read_seq bigint,
1583
+ unread integer,
1584
+ mentioned boolean,
1585
+ muted boolean,
1586
+ pinned boolean,
1587
+ archived boolean,
1588
+ last_message_at timestamptz,
1589
+ last_message jsonb
1590
+ )
1591
+ language sql
1592
+ stable
1593
+ security definer
1594
+ set search_path = ''
1595
+ as $$
1596
+ select c.id, c.kind, c.title, c.subject_type, c.subject_id, c.status, me.role,
1597
+ (select o.user_id from genesis_chat.members o
1598
+ where o.conversation_id = c.id and o.user_id <> me.user_id and o.left_at is null and o.role <> 'observer'
1599
+ and k.max_members = 2 limit 1),
1600
+ (select count(*)::integer from genesis_chat.members o
1601
+ where o.conversation_id = c.id and o.left_at is null and o.role <> 'observer'),
1602
+ c.last_seq, me.last_read_seq,
1603
+ genesis_chat.unread_count(c.id, me.user_id, me.last_read_seq),
1604
+ exists (select 1 from genesis_chat.messages m
1605
+ where m.conversation_id = c.id and m.seq > me.last_read_seq and me.user_id = any (m.mentions)),
1606
+ coalesce(me.muted_until > now(), false), me.pinned, me.archived, c.last_message_at,
1607
+ (select jsonb_build_object('id', m.id, 'seq', m.seq, 'sender_id', m.sender_id, 'kind', m.kind,
1608
+ 'body', left(m.body, 160), 'deleted', m.deleted_at is not null, 'created_at', m.created_at,
1609
+ 'attachment', (select a.kind from genesis_chat.attachments a where a.message_id = m.id order by a.position limit 1))
1610
+ from genesis_chat.messages m
1611
+ where m.conversation_id = c.id and m.visibility = 'everyone' and m.thread_root_id is null
1612
+ order by m.seq desc limit 1)
1613
+ from genesis_chat.members me
1614
+ join genesis_chat.conversations c on c.id = me.conversation_id
1615
+ join genesis_chat.kinds k on k.kind = c.kind
1616
+ where me.user_id = auth.uid() and me.left_at is null and me.role <> 'observer'
1617
+ and me.archived = coalesce(p_archived, false)
1618
+ and (p_kind is null or c.kind = p_kind)
1619
+ and (p_before is null or coalesce(c.last_message_at, c.created_at) < p_before)
1620
+ order by me.pinned desc, coalesce(c.last_message_at, c.created_at) desc, c.id
1621
+ limit least(greatest(coalesce(p_limit, 50), 1), 200)
1622
+ $$;
1623
+
1624
+ -- The conversations of `p_kind` you observe (a support queue), newest first, with your
1625
+ -- place in each if you have kept one.
1626
+ create or replace function genesis_chat.observed(p_kind text, p_status text default null, p_limit integer default 50, p_before timestamptz default null)
1627
+ returns table (
1628
+ conversation_id uuid,
1629
+ title text,
1630
+ subject_type text,
1631
+ subject_id text,
1632
+ status text,
1633
+ assignee_id uuid,
1634
+ last_seq bigint,
1635
+ last_read_seq bigint,
1636
+ last_message_at timestamptz,
1637
+ last_message jsonb
1638
+ )
1639
+ language sql
1640
+ stable
1641
+ security definer
1642
+ set search_path = ''
1643
+ as $$
1644
+ select c.id, c.title, c.subject_type, c.subject_id, c.status, c.assignee_id, c.last_seq,
1645
+ coalesce((select m.last_read_seq from genesis_chat.members m where m.conversation_id = c.id and m.user_id = auth.uid()), 0),
1646
+ c.last_message_at,
1647
+ (select jsonb_build_object('id', m.id, 'seq', m.seq, 'sender_id', m.sender_id, 'body', left(m.body, 160),
1648
+ 'visibility', m.visibility, 'created_at', m.created_at)
1649
+ from genesis_chat.messages m where m.conversation_id = c.id order by m.seq desc limit 1)
1650
+ from genesis_chat.conversations c
1651
+ where c.kind = p_kind and (p_status is null or c.status = p_status)
1652
+ and (p_before is null or coalesce(c.last_message_at, c.created_at) < p_before)
1653
+ and genesis_chat.observes(c.id)
1654
+ order by coalesce(c.last_message_at, c.created_at) desc, c.id
1655
+ limit least(greatest(coalesce(p_limit, 50), 1), 200)
1656
+ $$;
1657
+
1658
+ -- How many messages wait for you across every conversation not muted (for a badge).
1659
+ create or replace function genesis_chat.unread_total(p_user_id uuid default null)
1660
+ returns integer
1661
+ language sql
1662
+ stable
1663
+ security definer
1664
+ set search_path = ''
1665
+ as $$
1666
+ select coalesce(sum(genesis_chat.unread_count(c.id, me.user_id, me.last_read_seq)), 0)::integer
1667
+ from genesis_chat.members me join genesis_chat.conversations c on c.id = me.conversation_id
1668
+ where me.user_id = case when auth.role() = 'service_role' then coalesce(p_user_id, auth.uid()) else auth.uid() end
1669
+ and me.left_at is null and me.role <> 'observer' and me.archived = false
1670
+ and coalesce(me.muted_until <= now(), true) and c.last_seq > me.last_read_seq
1671
+ $$;
1672
+
1673
+ -- ── The runner's side ─────────────────────────────────────────────────────────
1674
+
1675
+ -- Claims up to `p_limit` due effects of these kinds (all kinds when null) for five
1676
+ -- minutes. A claimed effect not settled in time is claimed again.
1677
+ create or replace function genesis_chat.claim_effects(p_kinds text[] default null, p_limit integer default 50)
1678
+ returns setof genesis_chat.effects
1679
+ language sql
1680
+ security definer
1681
+ set search_path = ''
1682
+ as $$
1683
+ update genesis_chat.effects e
1684
+ set locked_until = now() + interval '5 minutes', attempts = e.attempts + 1
1685
+ where e.id in (
1686
+ select id from genesis_chat.effects
1687
+ where done_at is null and run_after <= now() and (locked_until is null or locked_until < now())
1688
+ and attempts < 8 and (p_kinds is null or kind = any (p_kinds))
1689
+ order by run_after, id
1690
+ limit least(greatest(coalesce(p_limit, 50), 1), 500)
1691
+ for update skip locked)
1692
+ returning e.*;
1693
+ $$;
1694
+
1695
+ -- Settles a claimed effect: done, or (with an error) tried again later, backing off.
1696
+ create or replace function genesis_chat.complete_effect(p_id bigint, p_error text default null)
1697
+ returns void
1698
+ language sql
1699
+ security definer
1700
+ set search_path = ''
1701
+ as $$
1702
+ update genesis_chat.effects
1703
+ set done_at = case when p_error is null then now() end,
1704
+ last_error = left(p_error, 2000),
1705
+ locked_until = null,
1706
+ run_after = case when p_error is null then run_after else now() + (interval '30 seconds' * power(2, least(attempts, 8))) end
1707
+ where id = p_id;
1708
+ $$;
1709
+
1710
+ -- Who to push a message to: current members other than the sender who haven't muted
1711
+ -- the conversation or turned it to mentions-only (unless mentioned), plus the
1712
+ -- assignee; with each one's unread total for the badge.
1713
+ create or replace function genesis_chat.push_targets(p_message_id uuid)
1714
+ returns table (user_id uuid, conversation_id uuid, title text, sender_id uuid, preview text, mentioned boolean, unread_total integer)
1715
+ language sql
1716
+ stable
1717
+ security definer
1718
+ set search_path = ''
1719
+ as $$
1720
+ with msg as (
1721
+ select m.*, c.title as conversation_title
1722
+ from genesis_chat.messages m join genesis_chat.conversations c on c.id = m.conversation_id
1723
+ where m.id = p_message_id and m.deleted_at is null and not m.silent and m.kind = 'text' and m.visibility = 'everyone'
1724
+ ), recipients as (
1725
+ select me.user_id from msg join genesis_chat.members me on me.conversation_id = msg.conversation_id
1726
+ where me.left_at is null and me.role <> 'observer' and me.user_id is distinct from msg.sender_id
1727
+ and coalesce(me.muted_until <= now(), true) and me.notify <> 'none'
1728
+ and (me.notify = 'all' or me.user_id = any (msg.mentions))
1729
+ union
1730
+ select c.assignee_id from msg join genesis_chat.conversations c on c.id = msg.conversation_id
1731
+ where c.assignee_id is not null and c.assignee_id is distinct from msg.sender_id
1732
+ )
1733
+ select r.user_id, msg.conversation_id, msg.conversation_title, msg.sender_id,
1734
+ case when msg.body <> '' then left(msg.body, 180)
1735
+ else (select case a.kind when 'image' then 'Photo' when 'voice' then 'Voice message' when 'video' then 'Video'
1736
+ when 'file' then 'File' else 'Attachment' end
1737
+ from genesis_chat.attachments a where a.message_id = msg.id order by a.position limit 1) end,
1738
+ r.user_id = any (msg.mentions),
1739
+ (select coalesce(sum(genesis_chat.unread_count(c2.id, m2.user_id, m2.last_read_seq)), 0)::integer
1740
+ from genesis_chat.members m2 join genesis_chat.conversations c2 on c2.id = m2.conversation_id
1741
+ where m2.user_id = r.user_id and m2.left_at is null and m2.role <> 'observer' and not m2.archived
1742
+ and coalesce(m2.muted_until <= now(), true) and c2.last_seq > m2.last_read_seq)
1743
+ from recipients r cross join msg
1744
+ $$;
1745
+
1746
+ -- Updates what the server learned about an attachment: a video ready to play, a poster,
1747
+ -- a duration, a transcript.
1748
+ create or replace function genesis_chat.update_attachment(p_attachment_id uuid, p_patch jsonb)
1749
+ returns void
1750
+ language plpgsql
1751
+ security definer
1752
+ set search_path = ''
1753
+ as $$
1754
+ begin
1755
+ update genesis_chat.attachments set
1756
+ status = coalesce(p_patch ->> 'status', status),
1757
+ provider_id = coalesce(p_patch ->> 'provider_id', provider_id),
1758
+ playback_id = coalesce(p_patch ->> 'playback_id', playback_id),
1759
+ url = coalesce(p_patch ->> 'url', url),
1760
+ poster_path = coalesce(p_patch ->> 'poster_path', poster_path),
1761
+ thumbhash = coalesce(p_patch ->> 'thumbhash', thumbhash),
1762
+ duration_ms = coalesce((p_patch ->> 'duration_ms')::integer, duration_ms),
1763
+ width = coalesce((p_patch ->> 'width')::integer, width),
1764
+ height = coalesce((p_patch ->> 'height')::integer, height),
1765
+ transcript = coalesce(p_patch ->> 'transcript', transcript),
1766
+ transcript_status = coalesce(p_patch ->> 'transcript_status', transcript_status)
1767
+ where id = p_attachment_id;
1768
+ perform genesis_chat.broadcast('genesis-chat:conv:' || a.conversation_id, 'message',
1769
+ jsonb_build_object('id', a.message_id, 'conversation_id', a.conversation_id, 'op', 'update'))
1770
+ from genesis_chat.attachments a where a.id = p_attachment_id;
1771
+ end;
1772
+ $$;
1773
+
1774
+ -- ── Grants, by name ───────────────────────────────────────────────────────────
1775
+
1776
+ revoke execute on function
1777
+ genesis_chat.start_conversation(text, uuid[], text, text, text),
1778
+ genesis_chat.add_members(uuid, uuid[]),
1779
+ genesis_chat.remove_member(uuid, uuid),
1780
+ genesis_chat.set_role(uuid, uuid, text),
1781
+ genesis_chat.update_conversation(uuid, text, jsonb),
1782
+ genesis_chat.set_status(uuid, text, uuid),
1783
+ genesis_chat.send_message(uuid, uuid, text, jsonb, uuid, uuid, text, uuid[], text, boolean),
1784
+ genesis_chat.edit_message(uuid, text, uuid[]),
1785
+ genesis_chat.delete_message(uuid),
1786
+ genesis_chat.react(uuid, text, boolean),
1787
+ genesis_chat.mark_read(uuid, bigint),
1788
+ genesis_chat.set_my_settings(uuid, timestamptz, text, boolean, boolean, boolean),
1789
+ genesis_chat.pin_message(uuid, boolean),
1790
+ genesis_chat.save_message(uuid, boolean, text),
1791
+ genesis_chat.report_message(uuid, text, text),
1792
+ genesis_chat.inbox(text, boolean, integer, timestamptz),
1793
+ genesis_chat.observed(text, text, integer, timestamptz),
1794
+ genesis_chat.unread_total(uuid)
1795
+ from public, anon;
1796
+
1797
+ grant execute on function
1798
+ genesis_chat.start_conversation(text, uuid[], text, text, text),
1799
+ genesis_chat.add_members(uuid, uuid[]),
1800
+ genesis_chat.remove_member(uuid, uuid),
1801
+ genesis_chat.set_role(uuid, uuid, text),
1802
+ genesis_chat.update_conversation(uuid, text, jsonb),
1803
+ genesis_chat.set_status(uuid, text, uuid),
1804
+ genesis_chat.send_message(uuid, uuid, text, jsonb, uuid, uuid, text, uuid[], text, boolean),
1805
+ genesis_chat.edit_message(uuid, text, uuid[]),
1806
+ genesis_chat.delete_message(uuid),
1807
+ genesis_chat.react(uuid, text, boolean),
1808
+ genesis_chat.mark_read(uuid, bigint),
1809
+ genesis_chat.set_my_settings(uuid, timestamptz, text, boolean, boolean, boolean),
1810
+ genesis_chat.pin_message(uuid, boolean),
1811
+ genesis_chat.save_message(uuid, boolean, text),
1812
+ genesis_chat.report_message(uuid, text, text),
1813
+ genesis_chat.inbox(text, boolean, integer, timestamptz),
1814
+ genesis_chat.observed(text, text, integer, timestamptz),
1815
+ genesis_chat.unread_total(uuid)
1816
+ to authenticated, service_role;
1817
+
1818
+ revoke execute on function
1819
+ genesis_chat.ensure_conversation(text, text, text, text, jsonb),
1820
+ genesis_chat.sync_members(uuid, uuid[], text),
1821
+ genesis_chat.post_system_message(uuid, text, jsonb, uuid),
1822
+ genesis_chat.claim_effects(text[], integer),
1823
+ genesis_chat.complete_effect(bigint, text),
1824
+ genesis_chat.push_targets(uuid),
1825
+ genesis_chat.update_attachment(uuid, jsonb)
1826
+ from public, anon, authenticated;
1827
+
1828
+ grant execute on function
1829
+ genesis_chat.ensure_conversation(text, text, text, text, jsonb),
1830
+ genesis_chat.sync_members(uuid, uuid[], text),
1831
+ genesis_chat.post_system_message(uuid, text, jsonb, uuid),
1832
+ genesis_chat.claim_effects(text[], integer),
1833
+ genesis_chat.complete_effect(bigint, text),
1834
+ genesis_chat.push_targets(uuid),
1835
+ genesis_chat.update_attachment(uuid, jsonb)
1836
+ to service_role;