@marktiderman/genesis-chat 0.1.0 → 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.
Files changed (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. 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;