@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.
- package/LICENSE +21 -0
- package/README.md +163 -2
- package/dist/chunk-27UTR5FW.js +322 -0
- package/dist/chunk-27UTR5FW.js.map +1 -0
- package/dist/chunk-JQXZUEEL.js +525 -0
- package/dist/chunk-JQXZUEEL.js.map +1 -0
- package/dist/chunk-SAY24E6V.js +309 -0
- package/dist/chunk-SAY24E6V.js.map +1 -0
- package/dist/client-BtdALFCx.d.ts +349 -0
- package/dist/index.d.ts +185 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +2 -0
- package/dist/native.js +3 -0
- package/dist/native.js.map +1 -0
- package/dist/react.d.ts +137 -0
- package/dist/react.js +5 -0
- package/dist/react.js.map +1 -0
- package/dist/server.d.ts +311 -0
- package/dist/server.js +562 -0
- package/dist/server.js.map +1 -0
- package/dist/web.d.ts +387 -0
- package/dist/web.js +2405 -0
- package/dist/web.js.map +1 -0
- package/migrations/0001_genesis_chat.sql +1836 -0
- package/migrations/tests/0001_genesis_chat.test.sql +254 -0
- package/package.json +144 -3
|
@@ -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;
|