@marktiderman/genesis-chat 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +238 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +344 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +421 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +116 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +116 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +57 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +563 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
-- Genesis Chat 0002: topics, scheduled send, availability, broadcast, search, link previews,
|
|
2
|
+
-- moderation, retention and legal hold
|
|
3
|
+
--
|
|
4
|
+
-- Builds on 0001 (never edited once released). Adds, in the genesis_chat schema:
|
|
5
|
+
--
|
|
6
|
+
-- kind_options per kind: moderate new messages, keep messages for a retention period
|
|
7
|
+
-- topics named sub-threads of a conversation (a cohort's lesson threads): a title,
|
|
8
|
+
-- an order, closed or open; messages already carry `topic`
|
|
9
|
+
-- scheduled a message written now and sent later, by its sender, as its sender
|
|
10
|
+
-- availability a person's away message and working hours, shown to the people they
|
|
11
|
+
-- share a conversation with (the "replies Mon–Fri" line)
|
|
12
|
+
-- link_previews a cache of Open Graph data for links in messages, filled by the runner;
|
|
13
|
+
-- read through link_previews_for(message), never directly
|
|
14
|
+
-- messages gain `moderation` (null | flagged | hidden) and a search vector
|
|
15
|
+
-- conversations gain `legal_hold`, which stops retention from removing anything
|
|
16
|
+
--
|
|
17
|
+
-- Functions for people: topics (set_topic), scheduled send (schedule_message,
|
|
18
|
+
-- cancel_scheduled), availability (set_availability, availability_for), broadcast with
|
|
19
|
+
-- private replies (broadcast_message), search (search_messages) and link previews
|
|
20
|
+
-- (link_previews_for). Functions for the
|
|
21
|
+
-- server: posting what's due (due_scheduled, post_scheduled), moderation results
|
|
22
|
+
-- (moderate_message), link previews (save_link_preview), retention (purge_expired) and kind
|
|
23
|
+
-- options (set_kind_options).
|
|
24
|
+
--
|
|
25
|
+
-- New effects in the outbox: `moderate` (a message in a kind that moderates, sent or edited)
|
|
26
|
+
-- and `unfurl` (a message with a link). The runner in /server works them. In a kind that
|
|
27
|
+
-- moderates, a message's push waits for its verdict and a hidden message is never pushed.
|
|
28
|
+
--
|
|
29
|
+
-- Replaces three of 0001's functions with the same signatures, to leave hidden messages out:
|
|
30
|
+
-- unread_count, push_targets and inbox.
|
|
31
|
+
--
|
|
32
|
+
-- Use: `genesis add chat` copies this after 0001. Verify: tests/0002_genesis_chat_coaching.test.sql.
|
|
33
|
+
|
|
34
|
+
-- ── Kind options ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
create table genesis_chat.kind_options (
|
|
37
|
+
kind text primary key references genesis_chat.kinds (kind) on update cascade on delete cascade,
|
|
38
|
+
moderate boolean not null default false,
|
|
39
|
+
retention interval check (retention is null or retention >= interval '1 day'),
|
|
40
|
+
updated_at timestamptz not null default now()
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
comment on table genesis_chat.kind_options is
|
|
44
|
+
'Per kind: moderate new messages; how long messages are kept (null: forever). Written by set_kind_options. Genesis Chat.';
|
|
45
|
+
|
|
46
|
+
alter table genesis_chat.kind_options enable row level security;
|
|
47
|
+
create policy kind_options_read on genesis_chat.kind_options for select to authenticated using (true);
|
|
48
|
+
grant select on genesis_chat.kind_options to authenticated;
|
|
49
|
+
grant select, insert, update, delete on genesis_chat.kind_options to service_role;
|
|
50
|
+
|
|
51
|
+
create or replace function genesis_chat.set_kind_options(p_kind text, p_moderate boolean default false, p_retention interval default null)
|
|
52
|
+
returns void
|
|
53
|
+
language sql
|
|
54
|
+
security definer
|
|
55
|
+
set search_path = ''
|
|
56
|
+
as $$
|
|
57
|
+
insert into genesis_chat.kind_options (kind, moderate, retention) values (p_kind, coalesce(p_moderate, false), p_retention)
|
|
58
|
+
on conflict (kind) do update set moderate = excluded.moderate, retention = excluded.retention, updated_at = now();
|
|
59
|
+
$$;
|
|
60
|
+
revoke execute on function genesis_chat.set_kind_options(text, boolean, interval) from public, anon, authenticated;
|
|
61
|
+
grant execute on function genesis_chat.set_kind_options(text, boolean, interval) to service_role;
|
|
62
|
+
|
|
63
|
+
-- ── New columns ───────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
alter table genesis_chat.messages
|
|
66
|
+
add column moderation text check (moderation is null or moderation in ('flagged', 'hidden')),
|
|
67
|
+
add column search tsvector generated always as (to_tsvector('simple', coalesce(body, ''))) stored;
|
|
68
|
+
|
|
69
|
+
create index messages_search_idx on genesis_chat.messages using gin (search);
|
|
70
|
+
create index messages_created_idx on genesis_chat.messages (conversation_id, created_at);
|
|
71
|
+
|
|
72
|
+
alter table genesis_chat.attachments
|
|
73
|
+
add column transcript_search tsvector generated always as (to_tsvector('simple', coalesce(transcript, ''))) stored;
|
|
74
|
+
create index attachments_transcript_search_idx on genesis_chat.attachments using gin (transcript_search)
|
|
75
|
+
where transcript is not null;
|
|
76
|
+
|
|
77
|
+
alter table genesis_chat.conversations
|
|
78
|
+
add column legal_hold boolean not null default false;
|
|
79
|
+
|
|
80
|
+
-- A hidden message is read only by its sender and the conversation's observers (moderators).
|
|
81
|
+
-- Restrictive, so it narrows 0001's read policy without replacing it.
|
|
82
|
+
create policy messages_hide_moderated on genesis_chat.messages
|
|
83
|
+
as restrictive for select to authenticated
|
|
84
|
+
using (moderation is distinct from 'hidden' or sender_id = auth.uid() or genesis_chat.i_observe(conversation_id));
|
|
85
|
+
|
|
86
|
+
-- ── Topics ────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
create table genesis_chat.topics (
|
|
89
|
+
conversation_id uuid not null references genesis_chat.conversations (id) on delete cascade,
|
|
90
|
+
topic text not null check (length(topic) between 1 and 120),
|
|
91
|
+
title text not null check (length(title) between 1 and 160),
|
|
92
|
+
position integer not null default 0,
|
|
93
|
+
closed_at timestamptz,
|
|
94
|
+
created_by uuid references auth.users (id) on delete set null,
|
|
95
|
+
created_at timestamptz not null default now(),
|
|
96
|
+
primary key (conversation_id, topic)
|
|
97
|
+
);
|
|
98
|
+
create index topics_created_by_idx on genesis_chat.topics (created_by);
|
|
99
|
+
|
|
100
|
+
comment on table genesis_chat.topics is
|
|
101
|
+
'Named sub-threads of a conversation; a message joins one through messages.topic. A closed topic takes no new messages. Genesis Chat.';
|
|
102
|
+
|
|
103
|
+
alter table genesis_chat.topics enable row level security;
|
|
104
|
+
create policy topics_read on genesis_chat.topics for select to authenticated using (genesis_chat.can_view(conversation_id));
|
|
105
|
+
grant select on genesis_chat.topics to authenticated;
|
|
106
|
+
grant select, insert, update, delete on genesis_chat.topics to service_role;
|
|
107
|
+
|
|
108
|
+
-- Owners and admins (or the server) add, rename, reorder, close and reopen a topic.
|
|
109
|
+
create or replace function genesis_chat.set_topic(
|
|
110
|
+
p_conversation_id uuid,
|
|
111
|
+
p_topic text,
|
|
112
|
+
p_title text default null,
|
|
113
|
+
p_position integer default null,
|
|
114
|
+
p_closed boolean default null
|
|
115
|
+
)
|
|
116
|
+
returns void
|
|
117
|
+
language plpgsql
|
|
118
|
+
security definer
|
|
119
|
+
set search_path = ''
|
|
120
|
+
as $$
|
|
121
|
+
begin
|
|
122
|
+
if auth.role() <> 'service_role' and coalesce(genesis_chat.my_role(p_conversation_id), '') not in ('owner', 'admin') then
|
|
123
|
+
raise exception 'Only an owner or admin manages topics' using errcode = '42501';
|
|
124
|
+
end if;
|
|
125
|
+
insert into genesis_chat.topics as t (conversation_id, topic, title, position, closed_at, created_by)
|
|
126
|
+
values (p_conversation_id, btrim(p_topic), coalesce(nullif(btrim(p_title), ''), btrim(p_topic)), coalesce(p_position, 0),
|
|
127
|
+
case when p_closed then now() end, auth.uid())
|
|
128
|
+
on conflict (conversation_id, topic) do update set
|
|
129
|
+
title = coalesce(nullif(btrim(p_title), ''), t.title),
|
|
130
|
+
position = coalesce(p_position, t.position),
|
|
131
|
+
closed_at = case when p_closed is null then t.closed_at when p_closed then coalesce(t.closed_at, now()) else null end;
|
|
132
|
+
perform genesis_chat.broadcast('genesis-chat:conv:' || p_conversation_id, 'conversation',
|
|
133
|
+
jsonb_build_object('conversation_id', p_conversation_id));
|
|
134
|
+
end;
|
|
135
|
+
$$;
|
|
136
|
+
|
|
137
|
+
-- No new message in a closed topic (except from owners, admins and observers who post).
|
|
138
|
+
create or replace function genesis_chat.topic_open_check()
|
|
139
|
+
returns trigger
|
|
140
|
+
language plpgsql
|
|
141
|
+
security definer
|
|
142
|
+
set search_path = ''
|
|
143
|
+
as $$
|
|
144
|
+
begin
|
|
145
|
+
if new.topic is not null and new.kind = 'text' and exists (
|
|
146
|
+
select 1 from genesis_chat.topics t
|
|
147
|
+
where t.conversation_id = new.conversation_id and t.topic = new.topic and t.closed_at is not null)
|
|
148
|
+
and new.sender_role not in ('owner', 'admin', 'observer') then
|
|
149
|
+
raise exception 'This topic is closed' using errcode = '42501';
|
|
150
|
+
end if;
|
|
151
|
+
return new;
|
|
152
|
+
end;
|
|
153
|
+
$$;
|
|
154
|
+
revoke execute on function genesis_chat.topic_open_check() from public, anon, authenticated;
|
|
155
|
+
create trigger messages_topic_open before insert on genesis_chat.messages
|
|
156
|
+
for each row execute function genesis_chat.topic_open_check();
|
|
157
|
+
|
|
158
|
+
-- ── Scheduled send ────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
create table genesis_chat.scheduled (
|
|
161
|
+
id uuid primary key default gen_random_uuid(),
|
|
162
|
+
conversation_id uuid not null references genesis_chat.conversations (id) on delete cascade,
|
|
163
|
+
sender_id uuid not null references auth.users (id) on delete cascade,
|
|
164
|
+
client_id uuid not null default gen_random_uuid(),
|
|
165
|
+
body text not null default '' check (length(body) <= 10000),
|
|
166
|
+
attachments jsonb not null default '[]' check (jsonb_typeof(attachments) = 'array' and pg_column_size(attachments) <= 65536),
|
|
167
|
+
reply_to_id uuid references genesis_chat.messages (id) on delete set null,
|
|
168
|
+
topic text check (topic is null or length(topic) between 1 and 120),
|
|
169
|
+
mentions uuid[] not null default '{}' check (cardinality(mentions) <= 50),
|
|
170
|
+
silent boolean not null default false,
|
|
171
|
+
send_at timestamptz not null,
|
|
172
|
+
sent_message_id uuid references genesis_chat.messages (id) on delete set null,
|
|
173
|
+
cancelled_at timestamptz,
|
|
174
|
+
error text check (error is null or length(error) <= 500),
|
|
175
|
+
-- Tries that failed for a reason that may pass (a deadlock, a timeout); `error` is set
|
|
176
|
+
-- after the fifth, or at once for a refusal.
|
|
177
|
+
attempts integer not null default 0,
|
|
178
|
+
created_at timestamptz not null default now(),
|
|
179
|
+
unique (sender_id, client_id)
|
|
180
|
+
);
|
|
181
|
+
create index scheduled_due_idx on genesis_chat.scheduled (send_at) where sent_message_id is null and cancelled_at is null and error is null;
|
|
182
|
+
create index scheduled_conversation_idx on genesis_chat.scheduled (conversation_id);
|
|
183
|
+
create index scheduled_reply_idx on genesis_chat.scheduled (reply_to_id) where reply_to_id is not null;
|
|
184
|
+
create index scheduled_sent_idx on genesis_chat.scheduled (sent_message_id) where sent_message_id is not null;
|
|
185
|
+
|
|
186
|
+
comment on table genesis_chat.scheduled is
|
|
187
|
+
'Messages to send later. Read only by their sender; posted as the sender by post_scheduled when due. Genesis Chat.';
|
|
188
|
+
|
|
189
|
+
alter table genesis_chat.scheduled enable row level security;
|
|
190
|
+
create policy scheduled_read on genesis_chat.scheduled for select to authenticated using (sender_id = auth.uid());
|
|
191
|
+
grant select on genesis_chat.scheduled to authenticated;
|
|
192
|
+
grant select, insert, update, delete on genesis_chat.scheduled to service_role;
|
|
193
|
+
|
|
194
|
+
-- Schedules a message (between a minute and a year ahead) where you may post now. Files in
|
|
195
|
+
-- `p_attachments` must already be uploaded, as for send_message.
|
|
196
|
+
create or replace function genesis_chat.schedule_message(
|
|
197
|
+
p_conversation_id uuid,
|
|
198
|
+
p_client_id uuid,
|
|
199
|
+
p_send_at timestamptz,
|
|
200
|
+
p_body text default '',
|
|
201
|
+
p_attachments jsonb default '[]',
|
|
202
|
+
p_reply_to_id uuid default null,
|
|
203
|
+
p_topic text default null,
|
|
204
|
+
p_mentions uuid[] default '{}',
|
|
205
|
+
p_silent boolean default false
|
|
206
|
+
)
|
|
207
|
+
returns uuid
|
|
208
|
+
language plpgsql
|
|
209
|
+
security definer
|
|
210
|
+
set search_path = ''
|
|
211
|
+
as $$
|
|
212
|
+
declare
|
|
213
|
+
id_ uuid;
|
|
214
|
+
begin
|
|
215
|
+
if not genesis_chat.can_post(p_conversation_id) then
|
|
216
|
+
raise exception 'You can''t post in this conversation' using errcode = '42501';
|
|
217
|
+
end if;
|
|
218
|
+
if p_send_at < now() + interval '1 minute' or p_send_at > now() + interval '1 year' then
|
|
219
|
+
raise exception 'Schedule between a minute and a year from now' using errcode = '22023';
|
|
220
|
+
end if;
|
|
221
|
+
if btrim(coalesce(p_body, '')) = '' and coalesce(jsonb_array_length(p_attachments), 0) = 0 then
|
|
222
|
+
raise exception 'A message needs text or an attachment' using errcode = '22023';
|
|
223
|
+
end if;
|
|
224
|
+
if (select count(*) from genesis_chat.scheduled
|
|
225
|
+
where sender_id = auth.uid() and sent_message_id is null and cancelled_at is null) >= 100 then
|
|
226
|
+
raise exception 'At most 100 scheduled messages' using errcode = 'GS429';
|
|
227
|
+
end if;
|
|
228
|
+
insert into genesis_chat.scheduled (conversation_id, sender_id, client_id, body, attachments, reply_to_id, topic, mentions, silent, send_at)
|
|
229
|
+
values (p_conversation_id, auth.uid(), p_client_id, coalesce(p_body, ''), coalesce(p_attachments, '[]'), p_reply_to_id,
|
|
230
|
+
nullif(btrim(p_topic), ''), coalesce(p_mentions, '{}'), coalesce(p_silent, false), p_send_at)
|
|
231
|
+
on conflict (sender_id, client_id) do update set send_at = excluded.send_at
|
|
232
|
+
returning id into id_;
|
|
233
|
+
return id_;
|
|
234
|
+
end;
|
|
235
|
+
$$;
|
|
236
|
+
|
|
237
|
+
create or replace function genesis_chat.cancel_scheduled(p_id uuid)
|
|
238
|
+
returns void
|
|
239
|
+
language plpgsql
|
|
240
|
+
security definer
|
|
241
|
+
set search_path = ''
|
|
242
|
+
as $$
|
|
243
|
+
begin
|
|
244
|
+
update genesis_chat.scheduled set cancelled_at = now()
|
|
245
|
+
where id = p_id and sender_id = auth.uid() and sent_message_id is null and cancelled_at is null;
|
|
246
|
+
if not found then
|
|
247
|
+
raise exception 'Nothing to cancel' using errcode = '22023';
|
|
248
|
+
end if;
|
|
249
|
+
end;
|
|
250
|
+
$$;
|
|
251
|
+
|
|
252
|
+
-- The server's side: what is due.
|
|
253
|
+
create or replace function genesis_chat.due_scheduled(p_limit integer default 100)
|
|
254
|
+
returns setof uuid
|
|
255
|
+
language sql
|
|
256
|
+
stable
|
|
257
|
+
security definer
|
|
258
|
+
set search_path = ''
|
|
259
|
+
as $$
|
|
260
|
+
select id from genesis_chat.scheduled
|
|
261
|
+
where send_at <= now() and sent_message_id is null and cancelled_at is null and error is null
|
|
262
|
+
order by send_at, id
|
|
263
|
+
limit least(greatest(coalesce(p_limit, 100), 1), 500)
|
|
264
|
+
$$;
|
|
265
|
+
|
|
266
|
+
-- The server's side: posts one due message as its sender, with every check send_message makes
|
|
267
|
+
-- for them (they may have left the conversation since). Records the message, or the reason:
|
|
268
|
+
-- a refusal (no right to post, bad input, a constraint) is final at once; anything else (a
|
|
269
|
+
-- deadlock, a statement timeout) is tried again on the next run, up to five times.
|
|
270
|
+
create or replace function genesis_chat.post_scheduled(p_id uuid)
|
|
271
|
+
returns uuid
|
|
272
|
+
language plpgsql
|
|
273
|
+
security definer
|
|
274
|
+
set search_path = ''
|
|
275
|
+
as $$
|
|
276
|
+
declare
|
|
277
|
+
s genesis_chat.scheduled;
|
|
278
|
+
msg genesis_chat.messages;
|
|
279
|
+
claims text := current_setting('request.jwt.claims', true);
|
|
280
|
+
begin
|
|
281
|
+
select * into s from genesis_chat.scheduled where id = p_id for update skip locked;
|
|
282
|
+
if s.id is null or s.sent_message_id is not null or s.cancelled_at is not null or s.error is not null then
|
|
283
|
+
return s.sent_message_id;
|
|
284
|
+
end if;
|
|
285
|
+
-- Act as the sender for this one statement, so every rule is theirs.
|
|
286
|
+
perform set_config('request.jwt.claims', json_build_object('sub', s.sender_id, 'role', 'authenticated')::text, true);
|
|
287
|
+
begin
|
|
288
|
+
msg := genesis_chat.send_message(s.conversation_id, s.client_id, s.body, s.attachments, s.reply_to_id, null,
|
|
289
|
+
s.topic, s.mentions, 'everyone', s.silent);
|
|
290
|
+
update genesis_chat.scheduled set sent_message_id = msg.id where id = s.id;
|
|
291
|
+
exception when others then
|
|
292
|
+
if left(sqlstate, 2) in ('22', '23', '42') or sqlstate = 'P0001' or s.attempts + 1 >= 5 then
|
|
293
|
+
update genesis_chat.scheduled
|
|
294
|
+
set attempts = attempts + 1,
|
|
295
|
+
error = left(case when left(sqlstate, 2) in ('22', '23', '42') or sqlstate = 'P0001' then sqlerrm
|
|
296
|
+
else 'Gave up after 5 tries: ' || sqlerrm end, 500)
|
|
297
|
+
where id = s.id;
|
|
298
|
+
else
|
|
299
|
+
update genesis_chat.scheduled set attempts = attempts + 1 where id = s.id;
|
|
300
|
+
end if;
|
|
301
|
+
end;
|
|
302
|
+
perform set_config('request.jwt.claims', coalesce(claims, ''), true);
|
|
303
|
+
return msg.id;
|
|
304
|
+
end;
|
|
305
|
+
$$;
|
|
306
|
+
|
|
307
|
+
-- ── Availability ──────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
-- Working hours as every client reads them: an object whose keys are days (mon..sun), each an
|
|
310
|
+
-- array of at most four [from, to] pairs of "HH:MM" strings ("24:00" may end a day). Anything
|
|
311
|
+
-- else would reach every member's screen, so the table refuses it.
|
|
312
|
+
create or replace function genesis_chat.valid_working_hours(p_hours jsonb)
|
|
313
|
+
returns boolean
|
|
314
|
+
language sql
|
|
315
|
+
immutable
|
|
316
|
+
set search_path = ''
|
|
317
|
+
as $$
|
|
318
|
+
select case
|
|
319
|
+
when p_hours is null then true
|
|
320
|
+
when jsonb_typeof(p_hours) <> 'object' then false
|
|
321
|
+
else not exists (
|
|
322
|
+
select 1
|
|
323
|
+
from jsonb_each(p_hours) d(day, ranges)
|
|
324
|
+
where case
|
|
325
|
+
when d.day not in ('mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun') then true
|
|
326
|
+
when jsonb_typeof(d.ranges) <> 'array' then true
|
|
327
|
+
when jsonb_array_length(d.ranges) > 4 then true
|
|
328
|
+
else exists (
|
|
329
|
+
select 1
|
|
330
|
+
from jsonb_array_elements(d.ranges) r(pair)
|
|
331
|
+
where case
|
|
332
|
+
when jsonb_typeof(r.pair) <> 'array' then true
|
|
333
|
+
when jsonb_array_length(r.pair) <> 2 then true
|
|
334
|
+
when jsonb_typeof(r.pair -> 0) <> 'string' or jsonb_typeof(r.pair -> 1) <> 'string' then true
|
|
335
|
+
else not ((r.pair ->> 0) ~ '^(([01]?[0-9]|2[0-3]):[0-5][0-9]|24:00)$'
|
|
336
|
+
and (r.pair ->> 1) ~ '^(([01]?[0-9]|2[0-3]):[0-5][0-9]|24:00)$')
|
|
337
|
+
end
|
|
338
|
+
)
|
|
339
|
+
end
|
|
340
|
+
)
|
|
341
|
+
end
|
|
342
|
+
$$;
|
|
343
|
+
|
|
344
|
+
create table genesis_chat.availability (
|
|
345
|
+
user_id uuid primary key references auth.users (id) on delete cascade,
|
|
346
|
+
away_message text check (away_message is null or length(away_message) <= 280),
|
|
347
|
+
away_until timestamptz,
|
|
348
|
+
time_zone text check (time_zone is null or length(time_zone) <= 64),
|
|
349
|
+
-- {"mon": [["09:00","17:00"]], "tue": [...], ...}; a missing day is not working.
|
|
350
|
+
hours jsonb check (hours is null or (genesis_chat.valid_working_hours(hours) and pg_column_size(hours) <= 2048)),
|
|
351
|
+
updated_at timestamptz not null default now()
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
comment on table genesis_chat.availability is
|
|
355
|
+
'A person''s away message and working hours, read through availability_for by people they share a conversation with. Genesis Chat.';
|
|
356
|
+
|
|
357
|
+
alter table genesis_chat.availability enable row level security;
|
|
358
|
+
create policy availability_own on genesis_chat.availability for select to authenticated using (user_id = auth.uid());
|
|
359
|
+
grant select on genesis_chat.availability to authenticated;
|
|
360
|
+
grant select, insert, update, delete on genesis_chat.availability to service_role;
|
|
361
|
+
|
|
362
|
+
create or replace function genesis_chat.set_availability(
|
|
363
|
+
p_away_message text default null,
|
|
364
|
+
p_away_until timestamptz default null,
|
|
365
|
+
p_time_zone text default null,
|
|
366
|
+
p_hours jsonb default null
|
|
367
|
+
)
|
|
368
|
+
returns void
|
|
369
|
+
language plpgsql
|
|
370
|
+
security definer
|
|
371
|
+
set search_path = ''
|
|
372
|
+
as $$
|
|
373
|
+
begin
|
|
374
|
+
if auth.uid() is null then
|
|
375
|
+
raise exception 'Sign in first' using errcode = '42501';
|
|
376
|
+
end if;
|
|
377
|
+
if p_time_zone is not null and not exists (select 1 from pg_timezone_names where name = p_time_zone) then
|
|
378
|
+
raise exception 'Unknown time zone %', p_time_zone using errcode = '22023';
|
|
379
|
+
end if;
|
|
380
|
+
if not genesis_chat.valid_working_hours(p_hours) then
|
|
381
|
+
raise exception 'Working hours are days (mon..sun), each up to 4 ["HH:MM", "HH:MM"] ranges' using errcode = '22023';
|
|
382
|
+
end if;
|
|
383
|
+
insert into genesis_chat.availability as a (user_id, away_message, away_until, time_zone, hours)
|
|
384
|
+
values (auth.uid(), nullif(btrim(p_away_message), ''), p_away_until, p_time_zone, p_hours)
|
|
385
|
+
on conflict (user_id) do update set away_message = excluded.away_message, away_until = excluded.away_until,
|
|
386
|
+
time_zone = excluded.time_zone, hours = excluded.hours, updated_at = now();
|
|
387
|
+
end;
|
|
388
|
+
$$;
|
|
389
|
+
|
|
390
|
+
-- The availability of a conversation's members, for whoever reads it.
|
|
391
|
+
create or replace function genesis_chat.availability_for(p_conversation_id uuid)
|
|
392
|
+
returns table (user_id uuid, away_message text, away_until timestamptz, time_zone text, hours jsonb)
|
|
393
|
+
language sql
|
|
394
|
+
stable
|
|
395
|
+
security definer
|
|
396
|
+
set search_path = ''
|
|
397
|
+
as $$
|
|
398
|
+
select a.user_id, a.away_message, a.away_until, a.time_zone, a.hours
|
|
399
|
+
from genesis_chat.availability a
|
|
400
|
+
join genesis_chat.members m on m.user_id = a.user_id and m.conversation_id = p_conversation_id
|
|
401
|
+
where genesis_chat.can_view(p_conversation_id) and m.left_at is null and m.role <> 'observer'
|
|
402
|
+
$$;
|
|
403
|
+
|
|
404
|
+
-- ── Broadcast with private replies ────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
-- Sends one message into each of these conversations where you may post (a coach to each
|
|
407
|
+
-- client's direct conversation), so every reply comes back privately. Idempotent per
|
|
408
|
+
-- conversation through `p_client_id` (each copy gets an id made from it). Returns the ids.
|
|
409
|
+
create or replace function genesis_chat.broadcast_message(
|
|
410
|
+
p_conversation_ids uuid[],
|
|
411
|
+
p_client_id uuid,
|
|
412
|
+
p_body text default '',
|
|
413
|
+
p_attachments jsonb default '[]',
|
|
414
|
+
p_silent boolean default false
|
|
415
|
+
)
|
|
416
|
+
returns uuid[]
|
|
417
|
+
language plpgsql
|
|
418
|
+
security definer
|
|
419
|
+
set search_path = ''
|
|
420
|
+
as $$
|
|
421
|
+
declare
|
|
422
|
+
conv uuid;
|
|
423
|
+
ids uuid[] := '{}';
|
|
424
|
+
msg genesis_chat.messages;
|
|
425
|
+
begin
|
|
426
|
+
if cardinality(coalesce(p_conversation_ids, '{}')) > 500 then
|
|
427
|
+
raise exception 'At most 500 conversations at once' using errcode = '22023';
|
|
428
|
+
end if;
|
|
429
|
+
if coalesce(jsonb_array_length(p_attachments), 0) > 0 then
|
|
430
|
+
-- Files live in one conversation's folder; a broadcast carries text only (or links).
|
|
431
|
+
raise exception 'A broadcast carries text only' using errcode = '22023';
|
|
432
|
+
end if;
|
|
433
|
+
foreach conv in array (select array_agg(distinct x) from unnest(p_conversation_ids) x where x is not null) loop
|
|
434
|
+
if genesis_chat.can_post(conv) then
|
|
435
|
+
msg := genesis_chat.send_message(conv, md5(p_client_id::text || ':' || conv::text)::uuid, p_body, '[]', null, null, null, '{}',
|
|
436
|
+
'everyone', p_silent);
|
|
437
|
+
ids := ids || msg.id;
|
|
438
|
+
end if;
|
|
439
|
+
end loop;
|
|
440
|
+
return ids;
|
|
441
|
+
end;
|
|
442
|
+
$$;
|
|
443
|
+
|
|
444
|
+
-- ── Search ────────────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
-- Messages (and voice or video transcripts) you can read that match `p_query`, newest first.
|
|
447
|
+
-- Within one conversation, or across all of yours. The snippet marks matches with « and »; the
|
|
448
|
+
-- text's own « and » become ‹ and › first, so a message can't fake a highlight.
|
|
449
|
+
create or replace function genesis_chat.search_messages(p_query text, p_conversation_id uuid default null, p_limit integer default 30)
|
|
450
|
+
returns table (message_id uuid, conversation_id uuid, seq bigint, sender_id uuid, created_at timestamptz, snippet text, in_transcript boolean)
|
|
451
|
+
language plpgsql
|
|
452
|
+
stable
|
|
453
|
+
security definer
|
|
454
|
+
set search_path = ''
|
|
455
|
+
as $$
|
|
456
|
+
declare
|
|
457
|
+
q tsquery;
|
|
458
|
+
begin
|
|
459
|
+
if auth.uid() is null or length(btrim(coalesce(p_query, ''))) < 2 then
|
|
460
|
+
return;
|
|
461
|
+
end if;
|
|
462
|
+
q := websearch_to_tsquery('simple', p_query);
|
|
463
|
+
return query
|
|
464
|
+
with mine as (
|
|
465
|
+
select me.conversation_id from genesis_chat.members me
|
|
466
|
+
where me.user_id = auth.uid() and me.left_at is null and me.role <> 'observer'
|
|
467
|
+
and (p_conversation_id is null or me.conversation_id = p_conversation_id)
|
|
468
|
+
union
|
|
469
|
+
select p_conversation_id where p_conversation_id is not null and genesis_chat.i_observe(p_conversation_id)
|
|
470
|
+
), hits as (
|
|
471
|
+
select m.id, m.conversation_id, m.seq, m.sender_id, m.created_at,
|
|
472
|
+
ts_headline('simple', translate(m.body, '«»', '‹›'), q, 'MaxWords=18, MinWords=6, StartSel=«, StopSel=»') as snippet, false as in_transcript
|
|
473
|
+
from genesis_chat.messages m join mine using (conversation_id)
|
|
474
|
+
where m.search @@ q and m.deleted_at is null
|
|
475
|
+
and (m.visibility = 'everyone' or genesis_chat.i_observe(m.conversation_id))
|
|
476
|
+
and (m.moderation is distinct from 'hidden' or m.sender_id = auth.uid() or genesis_chat.i_observe(m.conversation_id))
|
|
477
|
+
union all
|
|
478
|
+
select m.id, m.conversation_id, m.seq, m.sender_id, m.created_at,
|
|
479
|
+
ts_headline('simple', translate(a.transcript, '«»', '‹›'), q, 'MaxWords=18, MinWords=6, StartSel=«, StopSel=»'), true
|
|
480
|
+
from genesis_chat.attachments a
|
|
481
|
+
join genesis_chat.messages m on m.id = a.message_id
|
|
482
|
+
join mine on mine.conversation_id = m.conversation_id
|
|
483
|
+
where a.transcript is not null and a.transcript_search @@ q and m.deleted_at is null
|
|
484
|
+
and (m.visibility = 'everyone' or genesis_chat.i_observe(m.conversation_id))
|
|
485
|
+
and (m.moderation is distinct from 'hidden' or m.sender_id = auth.uid() or genesis_chat.i_observe(m.conversation_id))
|
|
486
|
+
)
|
|
487
|
+
select distinct on (h.created_at, h.id) h.id, h.conversation_id, h.seq, h.sender_id, h.created_at, h.snippet, h.in_transcript
|
|
488
|
+
from hits h
|
|
489
|
+
order by h.created_at desc, h.id
|
|
490
|
+
limit least(greatest(coalesce(p_limit, 30), 1), 100);
|
|
491
|
+
end;
|
|
492
|
+
$$;
|
|
493
|
+
|
|
494
|
+
-- ── Link previews ─────────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
create table genesis_chat.link_previews (
|
|
497
|
+
url text primary key check (length(url) <= 2000 and url ~ '^https://'),
|
|
498
|
+
status text not null default 'ok' check (status in ('ok', 'failed', 'blocked')),
|
|
499
|
+
title text check (title is null or length(title) <= 300),
|
|
500
|
+
description text check (description is null or length(description) <= 600),
|
|
501
|
+
image_url text check (image_url is null or (length(image_url) <= 2000 and image_url ~ '^https://')),
|
|
502
|
+
site_name text check (site_name is null or length(site_name) <= 120),
|
|
503
|
+
fetched_at timestamptz not null default now()
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
comment on table genesis_chat.link_previews is
|
|
507
|
+
'Open Graph data for links in messages, fetched by the runner. A URL can carry a secret (a share link), so people read a row only through link_previews_for, for a message they can read. Genesis Chat.';
|
|
508
|
+
|
|
509
|
+
-- No policy and no select for people: the URLs come from private messages.
|
|
510
|
+
alter table genesis_chat.link_previews enable row level security;
|
|
511
|
+
grant select, insert, update, delete on genesis_chat.link_previews to service_role;
|
|
512
|
+
|
|
513
|
+
-- The links in a message's text that get a preview: its first three https links, without
|
|
514
|
+
-- any whose query looks like a credential (a reset token, a signed URL), which the server
|
|
515
|
+
-- must not fetch (it could use up a one-time link) or keep.
|
|
516
|
+
create or replace function genesis_chat.message_links(p_body text)
|
|
517
|
+
returns setof text
|
|
518
|
+
language sql
|
|
519
|
+
immutable
|
|
520
|
+
set search_path = ''
|
|
521
|
+
as $$
|
|
522
|
+
select u from (
|
|
523
|
+
select distinct on (x.u) x.u, x.n
|
|
524
|
+
from (select m[1] as u, row_number() over () as n
|
|
525
|
+
from regexp_matches(coalesce(p_body, ''), '(https://[^\s<>()"'']{4,})', 'g') m) x
|
|
526
|
+
where length(x.u) <= 2000
|
|
527
|
+
and x.u !~* '[?&#;](access_token|id_token|refresh_token|token|auth|code|key|api_key|apikey|sig|signature|x-amz-signature|x-amz-credential|x-goog-signature|x-goog-credential|otp|secret|password|session|sessionid)='
|
|
528
|
+
order by x.u, x.n) y
|
|
529
|
+
order by y.n
|
|
530
|
+
limit 3
|
|
531
|
+
$$;
|
|
532
|
+
revoke execute on function genesis_chat.message_links(text) from public, anon, authenticated;
|
|
533
|
+
grant execute on function genesis_chat.message_links(text) to service_role;
|
|
534
|
+
|
|
535
|
+
-- The previews for the links in one message, for someone who can read that message (the
|
|
536
|
+
-- same rules as reading it: a member or observer, internal notes for observers, a hidden
|
|
537
|
+
-- message for its sender and observers). Nothing for anyone else.
|
|
538
|
+
create or replace function genesis_chat.link_previews_for(p_message_id uuid)
|
|
539
|
+
returns table (url text, status text, title text, description text, image_url text, site_name text, fetched_at timestamptz)
|
|
540
|
+
language sql
|
|
541
|
+
stable
|
|
542
|
+
security definer
|
|
543
|
+
set search_path = ''
|
|
544
|
+
as $$
|
|
545
|
+
select p.url, p.status, p.title, p.description, p.image_url, p.site_name, p.fetched_at
|
|
546
|
+
from genesis_chat.messages m
|
|
547
|
+
cross join lateral genesis_chat.message_links(m.body) l(u)
|
|
548
|
+
join genesis_chat.link_previews p on p.url = l.u
|
|
549
|
+
where m.id = p_message_id and m.deleted_at is null and auth.uid() is not null
|
|
550
|
+
and genesis_chat.can_view(m.conversation_id)
|
|
551
|
+
and (m.visibility = 'everyone' or genesis_chat.i_observe(m.conversation_id))
|
|
552
|
+
and (m.moderation is distinct from 'hidden' or m.sender_id = auth.uid() or genesis_chat.i_observe(m.conversation_id))
|
|
553
|
+
$$;
|
|
554
|
+
|
|
555
|
+
create or replace function genesis_chat.save_link_preview(
|
|
556
|
+
p_url text, p_status text, p_title text default null, p_description text default null,
|
|
557
|
+
p_image_url text default null, p_site_name text default null
|
|
558
|
+
)
|
|
559
|
+
returns void
|
|
560
|
+
language sql
|
|
561
|
+
security definer
|
|
562
|
+
set search_path = ''
|
|
563
|
+
as $$
|
|
564
|
+
insert into genesis_chat.link_previews (url, status, title, description, image_url, site_name)
|
|
565
|
+
values (p_url, p_status, left(p_title, 300), left(p_description, 600),
|
|
566
|
+
case when p_image_url ~ '^https://' then left(p_image_url, 2000) end, left(p_site_name, 120))
|
|
567
|
+
on conflict (url) do update set status = excluded.status, title = excluded.title, description = excluded.description,
|
|
568
|
+
image_url = excluded.image_url, site_name = excluded.site_name, fetched_at = now();
|
|
569
|
+
$$;
|
|
570
|
+
|
|
571
|
+
-- ── Moderation ────────────────────────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
-- In a kind that moderates, a message's push waits for the verdict: message_effects_0002
|
|
574
|
+
-- queues `push:<id>` to run at 'infinity' (0001's message_posted then finds the key taken),
|
|
575
|
+
-- and moderate_message releases it (clean or flagged) or settles it unsent (hidden). So a
|
|
576
|
+
-- push never carries text moderation hides. A runner without a moderator never sends those
|
|
577
|
+
-- pushes: give it one when a kind moderates.
|
|
578
|
+
|
|
579
|
+
-- The server's side: what moderation said. `hidden` takes the message from members' view and
|
|
580
|
+
-- files a report the conversation's observers see; `flagged` files the report only. A verdict
|
|
581
|
+
-- never lowers one already given (a retry, or an edit judged again, can't un-hide a message),
|
|
582
|
+
-- and the same verdict on the same text files one report however often it comes.
|
|
583
|
+
create or replace function genesis_chat.moderate_message(p_message_id uuid, p_result text, p_reason text default null)
|
|
584
|
+
returns void
|
|
585
|
+
language plpgsql
|
|
586
|
+
security definer
|
|
587
|
+
set search_path = ''
|
|
588
|
+
as $$
|
|
589
|
+
declare
|
|
590
|
+
msg genesis_chat.messages;
|
|
591
|
+
next_ text;
|
|
592
|
+
begin
|
|
593
|
+
if p_result is null or p_result not in ('clean', 'flagged', 'hidden') then
|
|
594
|
+
raise exception 'Moderation is clean, flagged or hidden' using errcode = '22023';
|
|
595
|
+
end if;
|
|
596
|
+
select * into msg from genesis_chat.messages where id = p_message_id for update;
|
|
597
|
+
if p_result = 'hidden' then
|
|
598
|
+
update genesis_chat.effects set done_at = now(), locked_until = null, last_error = 'hidden by moderation'
|
|
599
|
+
where key = 'push:' || p_message_id and done_at is null;
|
|
600
|
+
else
|
|
601
|
+
update genesis_chat.effects set run_after = now()
|
|
602
|
+
where key = 'push:' || p_message_id and done_at is null and run_after = 'infinity';
|
|
603
|
+
end if;
|
|
604
|
+
if msg.id is null or p_result = 'clean' then
|
|
605
|
+
return;
|
|
606
|
+
end if;
|
|
607
|
+
next_ := case when msg.moderation = 'hidden' or p_result = 'hidden' then 'hidden' else 'flagged' end;
|
|
608
|
+
if next_ is distinct from msg.moderation then
|
|
609
|
+
update genesis_chat.messages set moderation = next_ where id = msg.id;
|
|
610
|
+
perform genesis_chat.broadcast('genesis-chat:conv:' || msg.conversation_id, 'message',
|
|
611
|
+
jsonb_build_object('id', msg.id, 'seq', msg.seq, 'conversation_id', msg.conversation_id, 'op', 'update'));
|
|
612
|
+
end if;
|
|
613
|
+
if not exists (
|
|
614
|
+
select 1 from genesis_chat.reports r
|
|
615
|
+
where r.message_id = msg.id and r.reporter_id is null and r.snapshot ->> 'automatic' = 'true'
|
|
616
|
+
and r.snapshot ->> 'result' = p_result
|
|
617
|
+
and (r.snapshot ->> 'edited_at')::timestamptz is not distinct from msg.edited_at) then
|
|
618
|
+
insert into genesis_chat.reports (message_id, conversation_id, reporter_id, reason, note, snapshot)
|
|
619
|
+
values (msg.id, msg.conversation_id, null, 'safety', left(coalesce(p_reason, 'Automatic moderation'), 1000),
|
|
620
|
+
jsonb_build_object('message', jsonb_build_object('id', msg.id, 'sender_id', msg.sender_id, 'body', msg.body,
|
|
621
|
+
'created_at', msg.created_at),
|
|
622
|
+
'automatic', true, 'result', p_result, 'edited_at', msg.edited_at));
|
|
623
|
+
end if;
|
|
624
|
+
end;
|
|
625
|
+
$$;
|
|
626
|
+
|
|
627
|
+
-- Does this conversation's kind moderate?
|
|
628
|
+
create or replace function genesis_chat.moderates(p_conversation_id uuid)
|
|
629
|
+
returns boolean
|
|
630
|
+
language sql
|
|
631
|
+
stable
|
|
632
|
+
security definer
|
|
633
|
+
set search_path = ''
|
|
634
|
+
as $$
|
|
635
|
+
select exists (select 1 from genesis_chat.kind_options o join genesis_chat.conversations c on c.kind = o.kind
|
|
636
|
+
where c.id = p_conversation_id and o.moderate)
|
|
637
|
+
$$;
|
|
638
|
+
revoke execute on function genesis_chat.moderates(uuid) from public, anon, authenticated;
|
|
639
|
+
grant execute on function genesis_chat.moderates(uuid) to service_role;
|
|
640
|
+
|
|
641
|
+
-- New messages queue moderation (where the kind asks for it, holding the push until it is
|
|
642
|
+
-- done) and link previews (where the text has a link that has none yet).
|
|
643
|
+
create or replace function genesis_chat.message_effects_0002()
|
|
644
|
+
returns trigger
|
|
645
|
+
language plpgsql
|
|
646
|
+
security definer
|
|
647
|
+
set search_path = ''
|
|
648
|
+
as $$
|
|
649
|
+
declare
|
|
650
|
+
link text;
|
|
651
|
+
begin
|
|
652
|
+
if new.kind <> 'text' then
|
|
653
|
+
return null;
|
|
654
|
+
end if;
|
|
655
|
+
if genesis_chat.moderates(new.conversation_id) then
|
|
656
|
+
perform genesis_chat.enqueue('moderate', 'moderate:' || new.id,
|
|
657
|
+
jsonb_build_object('message_id', new.id, 'conversation_id', new.conversation_id));
|
|
658
|
+
-- The push waits for the verdict, whichever of this and 0001's trigger runs first.
|
|
659
|
+
if not new.silent and new.visibility = 'everyone' then
|
|
660
|
+
insert into genesis_chat.effects (kind, key, payload, run_after)
|
|
661
|
+
values ('push', 'push:' || new.id, jsonb_build_object('message_id', new.id, 'conversation_id', new.conversation_id), 'infinity')
|
|
662
|
+
on conflict (key) do update set run_after = 'infinity' where genesis_chat.effects.done_at is null;
|
|
663
|
+
end if;
|
|
664
|
+
end if;
|
|
665
|
+
for link in select genesis_chat.message_links(new.body) loop
|
|
666
|
+
if not exists (select 1 from genesis_chat.link_previews p where p.url = link and p.fetched_at > now() - interval '7 days') then
|
|
667
|
+
perform genesis_chat.enqueue('unfurl', 'unfurl:' || md5(link) || ':' || to_char(now(), 'YYYY-MM-DD'),
|
|
668
|
+
jsonb_build_object('url', link, 'message_id', new.id, 'conversation_id', new.conversation_id));
|
|
669
|
+
end if;
|
|
670
|
+
end loop;
|
|
671
|
+
return null;
|
|
672
|
+
end;
|
|
673
|
+
$$;
|
|
674
|
+
revoke execute on function genesis_chat.message_effects_0002() from public, anon, authenticated;
|
|
675
|
+
create trigger messages_effects_0002 after insert on genesis_chat.messages
|
|
676
|
+
for each row execute function genesis_chat.message_effects_0002();
|
|
677
|
+
|
|
678
|
+
-- An edited message is judged again (sending "hi" and editing it is no way around moderation).
|
|
679
|
+
create or replace function genesis_chat.message_edited_0002()
|
|
680
|
+
returns trigger
|
|
681
|
+
language plpgsql
|
|
682
|
+
security definer
|
|
683
|
+
set search_path = ''
|
|
684
|
+
as $$
|
|
685
|
+
begin
|
|
686
|
+
if new.kind = 'text' and new.deleted_at is null and new.body is distinct from old.body and btrim(new.body) <> ''
|
|
687
|
+
and genesis_chat.moderates(new.conversation_id) then
|
|
688
|
+
perform genesis_chat.enqueue('moderate', 'moderate:' || new.id || ':' || coalesce(new.edited_at, now()),
|
|
689
|
+
jsonb_build_object('message_id', new.id, 'conversation_id', new.conversation_id));
|
|
690
|
+
end if;
|
|
691
|
+
return null;
|
|
692
|
+
end;
|
|
693
|
+
$$;
|
|
694
|
+
revoke execute on function genesis_chat.message_edited_0002() from public, anon, authenticated;
|
|
695
|
+
create trigger messages_edited_0002 after update of body on genesis_chat.messages
|
|
696
|
+
for each row execute function genesis_chat.message_edited_0002();
|
|
697
|
+
|
|
698
|
+
-- 0001's functions that read messages for someone, now leaving out hidden ones: no push
|
|
699
|
+
-- for a hidden message, no badge count for it, no inbox preview of it (except its sender's).
|
|
700
|
+
-- Same signatures; re-granted by name below.
|
|
701
|
+
|
|
702
|
+
create or replace function genesis_chat.unread_count(p_conversation_id uuid, p_user_id uuid, p_after bigint)
|
|
703
|
+
returns integer
|
|
704
|
+
language sql
|
|
705
|
+
stable
|
|
706
|
+
security definer
|
|
707
|
+
set search_path = ''
|
|
708
|
+
as $$
|
|
709
|
+
select count(*)::integer from (
|
|
710
|
+
select 1 from genesis_chat.messages m
|
|
711
|
+
where m.conversation_id = p_conversation_id and m.seq > p_after and m.deleted_at is null
|
|
712
|
+
and m.sender_id is distinct from p_user_id and m.visibility = 'everyone' and m.thread_root_id is null
|
|
713
|
+
and m.moderation is distinct from 'hidden'
|
|
714
|
+
limit 99) u
|
|
715
|
+
$$;
|
|
716
|
+
|
|
717
|
+
-- p_badge_exclude_kinds: kinds the app leaves out of its badge (as unread_total does), so the
|
|
718
|
+
-- push badge and the app's own badge agree.
|
|
719
|
+
drop function genesis_chat.push_targets(uuid);
|
|
720
|
+
create function genesis_chat.push_targets(p_message_id uuid, p_badge_exclude_kinds text[] default null)
|
|
721
|
+
returns table (user_id uuid, conversation_id uuid, title text, sender_id uuid, preview text, mentioned boolean, unread_total integer)
|
|
722
|
+
language sql
|
|
723
|
+
stable
|
|
724
|
+
security definer
|
|
725
|
+
set search_path = ''
|
|
726
|
+
as $$
|
|
727
|
+
with msg as (
|
|
728
|
+
select m.*, c.title as conversation_title
|
|
729
|
+
from genesis_chat.messages m join genesis_chat.conversations c on c.id = m.conversation_id
|
|
730
|
+
where m.id = p_message_id and m.deleted_at is null and not m.silent and m.kind = 'text' and m.visibility = 'everyone'
|
|
731
|
+
and m.moderation is distinct from 'hidden'
|
|
732
|
+
), recipients as (
|
|
733
|
+
select me.user_id from msg join genesis_chat.members me on me.conversation_id = msg.conversation_id
|
|
734
|
+
where me.left_at is null and me.role <> 'observer' and me.user_id is distinct from msg.sender_id
|
|
735
|
+
and coalesce(me.muted_until <= now(), true) and me.notify <> 'none'
|
|
736
|
+
and (me.notify = 'all' or me.user_id = any (msg.mentions))
|
|
737
|
+
union
|
|
738
|
+
select c.assignee_id from msg join genesis_chat.conversations c on c.id = msg.conversation_id
|
|
739
|
+
where c.assignee_id is not null and c.assignee_id is distinct from msg.sender_id
|
|
740
|
+
)
|
|
741
|
+
select r.user_id, msg.conversation_id, msg.conversation_title, msg.sender_id,
|
|
742
|
+
case when msg.body <> '' then left(msg.body, 180)
|
|
743
|
+
else (select case a.kind when 'image' then 'Photo' when 'voice' then 'Voice message' when 'video' then 'Video'
|
|
744
|
+
when 'file' then 'File' else 'Attachment' end
|
|
745
|
+
from genesis_chat.attachments a where a.message_id = msg.id order by a.position limit 1) end,
|
|
746
|
+
r.user_id = any (msg.mentions),
|
|
747
|
+
(select coalesce(sum(genesis_chat.unread_count(c2.id, m2.user_id, m2.last_read_seq)), 0)::integer
|
|
748
|
+
from genesis_chat.members m2 join genesis_chat.conversations c2 on c2.id = m2.conversation_id
|
|
749
|
+
where m2.user_id = r.user_id and m2.left_at is null and m2.role <> 'observer' and not m2.archived
|
|
750
|
+
and coalesce(m2.muted_until <= now(), true) and c2.last_seq > m2.last_read_seq
|
|
751
|
+
and (p_badge_exclude_kinds is null or not (c2.kind = any (p_badge_exclude_kinds))))
|
|
752
|
+
from recipients r cross join msg
|
|
753
|
+
$$;
|
|
754
|
+
|
|
755
|
+
-- The inbox also narrows to several kinds, or leaves some out (an app's Messages without its
|
|
756
|
+
-- support tickets). Same columns as 0001's; the old call shape still works.
|
|
757
|
+
drop function genesis_chat.inbox(text, boolean, integer, timestamptz);
|
|
758
|
+
create function genesis_chat.inbox(
|
|
759
|
+
p_kind text default null,
|
|
760
|
+
p_archived boolean default false,
|
|
761
|
+
p_limit integer default 50,
|
|
762
|
+
p_before timestamptz default null,
|
|
763
|
+
p_kinds text[] default null,
|
|
764
|
+
p_exclude_kinds text[] default null
|
|
765
|
+
)
|
|
766
|
+
returns table (
|
|
767
|
+
conversation_id uuid,
|
|
768
|
+
kind text,
|
|
769
|
+
title text,
|
|
770
|
+
subject_type text,
|
|
771
|
+
subject_id text,
|
|
772
|
+
status text,
|
|
773
|
+
role text,
|
|
774
|
+
other_user_id uuid,
|
|
775
|
+
member_count integer,
|
|
776
|
+
last_seq bigint,
|
|
777
|
+
last_read_seq bigint,
|
|
778
|
+
unread integer,
|
|
779
|
+
mentioned boolean,
|
|
780
|
+
muted boolean,
|
|
781
|
+
pinned boolean,
|
|
782
|
+
archived boolean,
|
|
783
|
+
last_message_at timestamptz,
|
|
784
|
+
last_message jsonb
|
|
785
|
+
)
|
|
786
|
+
language sql
|
|
787
|
+
stable
|
|
788
|
+
security definer
|
|
789
|
+
set search_path = ''
|
|
790
|
+
as $$
|
|
791
|
+
select c.id, c.kind, c.title, c.subject_type, c.subject_id, c.status, me.role,
|
|
792
|
+
(select o.user_id from genesis_chat.members o
|
|
793
|
+
where o.conversation_id = c.id and o.user_id <> me.user_id and o.left_at is null and o.role <> 'observer'
|
|
794
|
+
and k.max_members = 2 limit 1),
|
|
795
|
+
(select count(*)::integer from genesis_chat.members o
|
|
796
|
+
where o.conversation_id = c.id and o.left_at is null and o.role <> 'observer'),
|
|
797
|
+
c.last_seq, me.last_read_seq,
|
|
798
|
+
genesis_chat.unread_count(c.id, me.user_id, me.last_read_seq),
|
|
799
|
+
exists (select 1 from genesis_chat.messages m
|
|
800
|
+
where m.conversation_id = c.id and m.seq > me.last_read_seq and me.user_id = any (m.mentions)
|
|
801
|
+
and m.moderation is distinct from 'hidden'),
|
|
802
|
+
coalesce(me.muted_until > now(), false), me.pinned, me.archived, c.last_message_at,
|
|
803
|
+
(select jsonb_build_object('id', m.id, 'seq', m.seq, 'sender_id', m.sender_id, 'kind', m.kind,
|
|
804
|
+
'body', left(m.body, 160), 'deleted', m.deleted_at is not null, 'created_at', m.created_at,
|
|
805
|
+
'attachment', (select a.kind from genesis_chat.attachments a where a.message_id = m.id order by a.position limit 1))
|
|
806
|
+
from genesis_chat.messages m
|
|
807
|
+
where m.conversation_id = c.id and m.visibility = 'everyone' and m.thread_root_id is null
|
|
808
|
+
and (m.moderation is distinct from 'hidden' or m.sender_id = me.user_id)
|
|
809
|
+
order by m.seq desc limit 1)
|
|
810
|
+
from genesis_chat.members me
|
|
811
|
+
join genesis_chat.conversations c on c.id = me.conversation_id
|
|
812
|
+
join genesis_chat.kinds k on k.kind = c.kind
|
|
813
|
+
where me.user_id = auth.uid() and me.left_at is null and me.role <> 'observer'
|
|
814
|
+
and me.archived = coalesce(p_archived, false)
|
|
815
|
+
and (p_kind is null or c.kind = p_kind)
|
|
816
|
+
and (p_kinds is null or c.kind = any (p_kinds))
|
|
817
|
+
and (p_exclude_kinds is null or not (c.kind = any (p_exclude_kinds)))
|
|
818
|
+
and (p_before is null or coalesce(c.last_message_at, c.created_at) < p_before)
|
|
819
|
+
order by me.pinned desc, coalesce(c.last_message_at, c.created_at) desc, c.id
|
|
820
|
+
limit least(greatest(coalesce(p_limit, 50), 1), 200)
|
|
821
|
+
$$;
|
|
822
|
+
|
|
823
|
+
-- The badge can leave kinds out too, to match an inbox that does.
|
|
824
|
+
drop function genesis_chat.unread_total(uuid);
|
|
825
|
+
create function genesis_chat.unread_total(p_user_id uuid default null, p_exclude_kinds text[] default null)
|
|
826
|
+
returns integer
|
|
827
|
+
language sql
|
|
828
|
+
stable
|
|
829
|
+
security definer
|
|
830
|
+
set search_path = ''
|
|
831
|
+
as $$
|
|
832
|
+
select coalesce(sum(genesis_chat.unread_count(c.id, me.user_id, me.last_read_seq)), 0)::integer
|
|
833
|
+
from genesis_chat.members me join genesis_chat.conversations c on c.id = me.conversation_id
|
|
834
|
+
where me.user_id = case when auth.role() = 'service_role' then coalesce(p_user_id, auth.uid()) else auth.uid() end
|
|
835
|
+
and me.left_at is null and me.role <> 'observer' and me.archived = false
|
|
836
|
+
and coalesce(me.muted_until <= now(), true) and c.last_seq > me.last_read_seq
|
|
837
|
+
and (p_exclude_kinds is null or not (c.kind = any (p_exclude_kinds)))
|
|
838
|
+
$$;
|
|
839
|
+
|
|
840
|
+
-- ── Retention and legal hold ──────────────────────────────────────────────────
|
|
841
|
+
|
|
842
|
+
-- The server's side: removes messages older than their kind's retention, except in
|
|
843
|
+
-- conversations on legal hold. Files are queued for removal by 0001's attachment trigger.
|
|
844
|
+
-- Returns how many went (at most p_limit per call).
|
|
845
|
+
create or replace function genesis_chat.purge_expired(p_limit integer default 1000)
|
|
846
|
+
returns integer
|
|
847
|
+
language plpgsql
|
|
848
|
+
security definer
|
|
849
|
+
set search_path = ''
|
|
850
|
+
as $$
|
|
851
|
+
declare
|
|
852
|
+
n integer;
|
|
853
|
+
begin
|
|
854
|
+
with doomed as (
|
|
855
|
+
select m.id from genesis_chat.messages m
|
|
856
|
+
join genesis_chat.conversations c on c.id = m.conversation_id and not c.legal_hold
|
|
857
|
+
join genesis_chat.kind_options o on o.kind = c.kind and o.retention is not null
|
|
858
|
+
where m.created_at < now() - o.retention
|
|
859
|
+
order by m.created_at
|
|
860
|
+
limit least(greatest(coalesce(p_limit, 1000), 1), 10000)
|
|
861
|
+
)
|
|
862
|
+
delete from genesis_chat.messages m using doomed d where m.id = d.id;
|
|
863
|
+
get diagnostics n = row_count;
|
|
864
|
+
return n;
|
|
865
|
+
end;
|
|
866
|
+
$$;
|
|
867
|
+
|
|
868
|
+
-- Owners and admins (or the server) put a conversation on legal hold, or lift it.
|
|
869
|
+
create or replace function genesis_chat.set_legal_hold(p_conversation_id uuid, p_on boolean)
|
|
870
|
+
returns void
|
|
871
|
+
language plpgsql
|
|
872
|
+
security definer
|
|
873
|
+
set search_path = ''
|
|
874
|
+
as $$
|
|
875
|
+
begin
|
|
876
|
+
if auth.role() <> 'service_role' then
|
|
877
|
+
raise exception 'Legal hold is set by the server' using errcode = '42501';
|
|
878
|
+
end if;
|
|
879
|
+
update genesis_chat.conversations set legal_hold = coalesce(p_on, false) where id = p_conversation_id;
|
|
880
|
+
end;
|
|
881
|
+
$$;
|
|
882
|
+
|
|
883
|
+
-- ── Grants, by name ───────────────────────────────────────────────────────────
|
|
884
|
+
|
|
885
|
+
revoke execute on function genesis_chat.valid_working_hours(jsonb) from public, anon;
|
|
886
|
+
grant execute on function genesis_chat.valid_working_hours(jsonb) to authenticated, service_role;
|
|
887
|
+
|
|
888
|
+
revoke execute on function
|
|
889
|
+
genesis_chat.set_topic(uuid, text, text, integer, boolean),
|
|
890
|
+
genesis_chat.schedule_message(uuid, uuid, timestamptz, text, jsonb, uuid, text, uuid[], boolean),
|
|
891
|
+
genesis_chat.cancel_scheduled(uuid),
|
|
892
|
+
genesis_chat.set_availability(text, timestamptz, text, jsonb),
|
|
893
|
+
genesis_chat.availability_for(uuid),
|
|
894
|
+
genesis_chat.broadcast_message(uuid[], uuid, text, jsonb, boolean),
|
|
895
|
+
genesis_chat.search_messages(text, uuid, integer),
|
|
896
|
+
genesis_chat.link_previews_for(uuid),
|
|
897
|
+
genesis_chat.inbox(text, boolean, integer, timestamptz, text[], text[]),
|
|
898
|
+
genesis_chat.unread_total(uuid, text[])
|
|
899
|
+
from public, anon;
|
|
900
|
+
|
|
901
|
+
grant execute on function
|
|
902
|
+
genesis_chat.set_topic(uuid, text, text, integer, boolean),
|
|
903
|
+
genesis_chat.schedule_message(uuid, uuid, timestamptz, text, jsonb, uuid, text, uuid[], boolean),
|
|
904
|
+
genesis_chat.cancel_scheduled(uuid),
|
|
905
|
+
genesis_chat.set_availability(text, timestamptz, text, jsonb),
|
|
906
|
+
genesis_chat.availability_for(uuid),
|
|
907
|
+
genesis_chat.broadcast_message(uuid[], uuid, text, jsonb, boolean),
|
|
908
|
+
genesis_chat.search_messages(text, uuid, integer),
|
|
909
|
+
genesis_chat.link_previews_for(uuid),
|
|
910
|
+
genesis_chat.inbox(text, boolean, integer, timestamptz, text[], text[]),
|
|
911
|
+
genesis_chat.unread_total(uuid, text[])
|
|
912
|
+
to authenticated, service_role;
|
|
913
|
+
|
|
914
|
+
revoke execute on function
|
|
915
|
+
genesis_chat.due_scheduled(integer),
|
|
916
|
+
genesis_chat.post_scheduled(uuid),
|
|
917
|
+
genesis_chat.save_link_preview(text, text, text, text, text, text),
|
|
918
|
+
genesis_chat.moderate_message(uuid, text, text),
|
|
919
|
+
genesis_chat.purge_expired(integer),
|
|
920
|
+
genesis_chat.set_legal_hold(uuid, boolean),
|
|
921
|
+
genesis_chat.unread_count(uuid, uuid, bigint),
|
|
922
|
+
genesis_chat.push_targets(uuid, text[])
|
|
923
|
+
from public, anon, authenticated;
|
|
924
|
+
|
|
925
|
+
grant execute on function
|
|
926
|
+
genesis_chat.due_scheduled(integer),
|
|
927
|
+
genesis_chat.post_scheduled(uuid),
|
|
928
|
+
genesis_chat.save_link_preview(text, text, text, text, text, text),
|
|
929
|
+
genesis_chat.moderate_message(uuid, text, text),
|
|
930
|
+
genesis_chat.purge_expired(integer),
|
|
931
|
+
genesis_chat.set_legal_hold(uuid, boolean),
|
|
932
|
+
genesis_chat.unread_count(uuid, uuid, bigint),
|
|
933
|
+
genesis_chat.push_targets(uuid, text[])
|
|
934
|
+
to service_role;
|