@rocapine/community 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,16 @@
1
1
  // Daily moderation batch + backstop. Runs every not-yet-moderated post/comment
2
2
  // through OpenAI's moderation endpoint (free): flagged -> soft-hide, clean
3
3
  // 'pending' -> promote to 'visible' (catches items whose synchronous
4
- // moderate-one call failed), clean 'visible' -> just stamp. Posts a Slack
5
- // summary (no-op if SLACK_WEBHOOK_URL is unset — see _shared/slack.ts). Never
6
- // deletes rows. App criticism is not a category (stays visible).
4
+ // moderate-one call failed), clean 'visible' -> just stamp. Also sweeps
5
+ // not-yet-checked usernames (profiles.username is client-writable, see
6
+ // core/007_username_moderation.sql): flagged -> blanked. Posts a Slack
7
+ // summary when there was something to check (no-op if SLACK_WEBHOOK_URL is
8
+ // unset — see _shared/slack.ts). Never deletes rows. App criticism is not a
9
+ // category (stays visible).
10
+ //
11
+ // Reachable with the anon key (the cron calls it that way) and idempotent:
12
+ // once a sweep has stamped its items, a stray call finds nothing to check and
13
+ // exits without an API call or a Slack post.
7
14
 
8
15
  import { adminClient } from "../_shared/client.ts";
9
16
  import {
@@ -37,11 +44,93 @@ function toOne<T>(v: T | T[] | null | undefined): T | null {
37
44
  return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);
38
45
  }
39
46
 
47
+ /** One OpenAI moderation call for up to 100 inputs; null on any failure so
48
+ * the caller can skip stamping and retry next run. */
49
+ async function moderateBatch(inputs: string[]): Promise<ModerationResult[] | null> {
50
+ try {
51
+ const res = await fetch("https://api.openai.com/v1/moderations", {
52
+ method: "POST",
53
+ headers: {
54
+ Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
55
+ "Content-Type": "application/json",
56
+ },
57
+ body: JSON.stringify({ model: "omni-moderation-latest", input: inputs }),
58
+ });
59
+ if (!res.ok) return null;
60
+ const { results } = await res.json();
61
+ return results as ModerationResult[];
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ interface ProfileItem {
68
+ id: string;
69
+ username: string;
70
+ amplitude_id: string | null;
71
+ revenuecat_id: string | null;
72
+ }
73
+
74
+ /**
75
+ * Username sweep (core/007): moderate every not-yet-checked username, blank
76
+ * the flagged ones (remembering the value in `username_rejected` so a client
77
+ * re-sync keeps it blank) and stamp the rest. Capped per run so the first
78
+ * sweep after the migration stays within the function's time budget. On an
79
+ * install without core/007 the select errors and the sweep is skipped.
80
+ */
81
+ async function sweepUsernames(): Promise<{
82
+ checked: number;
83
+ blanked: { profile: ProfileItem; categories: string[] }[];
84
+ failedBatches: number;
85
+ skipped: boolean;
86
+ }> {
87
+ const { data, error } = await supabase
88
+ .from("profiles")
89
+ .select("id, username, amplitude_id, revenuecat_id")
90
+ .not("username", "is", null)
91
+ .is("username_checked_at", null)
92
+ .limit(1000);
93
+ if (error) return { checked: 0, blanked: [], failedBatches: 0, skipped: true };
94
+ const profiles = (data ?? []) as ProfileItem[];
95
+ const blanked: { profile: ProfileItem; categories: string[] }[] = [];
96
+ const processed: ProfileItem[] = [];
97
+ let failedBatches = 0;
98
+ for (let i = 0; i < profiles.length; i += 100) {
99
+ const batch = profiles.slice(i, i + 100);
100
+ const results = await moderateBatch(batch.map((p) => p.username));
101
+ if (!results) {
102
+ failedBatches++;
103
+ continue;
104
+ }
105
+ results.forEach((r, idx) => {
106
+ const cats = flaggedCategories(r);
107
+ if (cats.length > 0) blanked.push({ profile: batch[idx], categories: cats });
108
+ });
109
+ processed.push(...batch);
110
+ }
111
+ const now = new Date().toISOString();
112
+ for (const { profile } of blanked) {
113
+ // Guarded on the current value: a name changed since the select is left
114
+ // for the next sweep (the change trigger reset its checked_at anyway).
115
+ await supabase
116
+ .from("profiles")
117
+ .update({ username: null, username_rejected: profile.username, username_checked_at: now })
118
+ .eq("id", profile.id)
119
+ .eq("username", profile.username);
120
+ }
121
+ const blankedIds = new Set(blanked.map((b) => b.profile.id));
122
+ const cleanIds = processed.filter((p) => !blankedIds.has(p.id)).map((p) => p.id);
123
+ if (cleanIds.length > 0) {
124
+ await supabase.from("profiles").update({ username_checked_at: now }).in("id", cleanIds);
125
+ }
126
+ return { checked: processed.length, blanked, failedBatches, skipped: false };
127
+ }
128
+
40
129
  Deno.serve(async () => {
41
130
  const { data: posts, error: postsError } = await supabase
42
131
  .from("posts")
43
132
  .select(
44
- "id, content, author_id, status, poll_options(idx, label), profiles!posts_author_id_fkey(username, amplitude_id, revenuecat_id)",
133
+ "id, content, author_id, status, profiles!posts_author_id_fkey(username, amplitude_id, revenuecat_id)",
45
134
  )
46
135
  .is("moderated_at", null)
47
136
  .in("status", ["pending", "visible"]);
@@ -57,16 +146,34 @@ Deno.serve(async () => {
57
146
  }
58
147
 
59
148
  // Poll option labels are user content: fold them into the post's moderated
60
- // text (same treatment as the synchronous moderate-one function).
149
+ // text (same treatment as the synchronous moderate-one function). The polls
150
+ // module is optional: without it the table doesn't exist, the query errors,
151
+ // and posts are moderated on their text alone (a separate query instead of
152
+ // an embed so a core-only install doesn't 400 the whole sweep).
153
+ const optionsByPost = new Map<string, { idx: number; label: string }[]>();
154
+ if (posts && posts.length > 0) {
155
+ const { data: options, error } = await supabase
156
+ .from("poll_options")
157
+ .select("post_id, idx, label")
158
+ .in(
159
+ "post_id",
160
+ posts.map((p) => p.id),
161
+ );
162
+ if (!error) {
163
+ for (const o of options ?? []) {
164
+ const list = optionsByPost.get(o.post_id) ?? [];
165
+ list.push({ idx: o.idx, label: o.label });
166
+ optionsByPost.set(o.post_id, list);
167
+ }
168
+ }
169
+ }
61
170
  const items: Item[] = [
62
171
  ...(posts ?? []).map((p) => ({
63
172
  ...p,
64
173
  profiles: toOne(p.profiles),
65
174
  content: [
66
175
  p.content,
67
- ...(p.poll_options ?? [])
68
- .sort((a: { idx: number }, b: { idx: number }) => a.idx - b.idx)
69
- .map((o: { label: string }) => o.label),
176
+ ...(optionsByPost.get(p.id) ?? []).sort((a, b) => a.idx - b.idx).map((o) => o.label),
70
177
  ].join("\n"),
71
178
  table: "posts" as const,
72
179
  })),
@@ -76,9 +183,6 @@ Deno.serve(async () => {
76
183
  table: "comments" as const,
77
184
  })),
78
185
  ];
79
- if (items.length === 0) {
80
- return new Response(JSON.stringify({ checked: 0, hidden: 0, failed_batches: 0 }));
81
- }
82
186
 
83
187
  // Moderate in batches of 100 (API limit). Only items whose batch succeeded
84
188
  // get stamped moderated_at, so a transient failure is retried tomorrow.
@@ -87,40 +191,29 @@ Deno.serve(async () => {
87
191
  let failedBatches = 0;
88
192
  for (let i = 0; i < items.length; i += 100) {
89
193
  const batch = items.slice(i, i + 100);
90
- try {
91
- const res = await fetch("https://api.openai.com/v1/moderations", {
92
- method: "POST",
93
- headers: {
94
- Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
95
- "Content-Type": "application/json",
96
- },
97
- body: JSON.stringify({
98
- model: "omni-moderation-latest",
99
- input: batch.map((it) => it.content),
100
- }),
101
- });
102
- if (!res.ok) {
103
- failedBatches++;
104
- continue; // skip stamping; retried next run
105
- }
106
- const { results } = await res.json();
107
- results.forEach((r: ModerationResult, idx: number) => {
108
- const cats = flaggedCategories(r);
109
- if (cats.length > 0) flagged.push({ item: batch[idx], categories: cats });
110
- });
111
- processed.push(...batch);
112
- } catch {
113
- failedBatches++; // network error; skip stamping, retried next run
194
+ const results = await moderateBatch(batch.map((it) => it.content));
195
+ if (!results) {
196
+ failedBatches++; // API/network error; skip stamping, retried next run
197
+ continue;
114
198
  }
199
+ results.forEach((r, idx) => {
200
+ const cats = flaggedCategories(r);
201
+ if (cats.length > 0) flagged.push({ item: batch[idx], categories: cats });
202
+ });
203
+ processed.push(...batch);
115
204
  }
116
205
  const totalItems = items.length;
117
206
 
118
207
  const flaggedIds = new Set(flagged.map((f) => f.item.id));
208
+ // Status guards: a row the author soft-deleted while the sweep ran must not
209
+ // be resurrected as hidden/visible, so every write re-checks the status it
210
+ // was selected with.
119
211
  for (const { item, categories } of flagged) {
120
212
  await supabase
121
213
  .from(item.table)
122
214
  .update({ status: "hidden", moderation_reason: categories.join(",") })
123
- .eq("id", item.id);
215
+ .eq("id", item.id)
216
+ .in("status", ["pending", "visible"]);
124
217
  }
125
218
  // Backstop: promote clean 'pending' items to 'visible' (their synchronous
126
219
  // moderate-one call must have failed).
@@ -128,10 +221,18 @@ Deno.serve(async () => {
128
221
  const promotePostIds = promote.filter((i) => i.table === "posts").map((i) => i.id);
129
222
  const promoteCommentIds = promote.filter((i) => i.table === "comments").map((i) => i.id);
130
223
  if (promotePostIds.length > 0) {
131
- await supabase.from("posts").update({ status: "visible" }).in("id", promotePostIds);
224
+ await supabase
225
+ .from("posts")
226
+ .update({ status: "visible" })
227
+ .in("id", promotePostIds)
228
+ .eq("status", "pending");
132
229
  }
133
230
  if (promoteCommentIds.length > 0) {
134
- await supabase.from("comments").update({ status: "visible" }).in("id", promoteCommentIds);
231
+ await supabase
232
+ .from("comments")
233
+ .update({ status: "visible" })
234
+ .in("id", promoteCommentIds)
235
+ .eq("status", "pending");
135
236
  }
136
237
  const now = new Date().toISOString();
137
238
  const processedPostIds = processed.filter((i) => i.table === "posts").map((i) => i.id);
@@ -143,6 +244,23 @@ Deno.serve(async () => {
143
244
  await supabase.from("comments").update({ moderated_at: now }).in("id", processedCommentIds);
144
245
  }
145
246
 
247
+ const usernames = await sweepUsernames();
248
+ const allFailedBatches = failedBatches + usernames.failedBatches;
249
+
250
+ // Quiet when there was nothing to check at all (a stray or duplicate call
251
+ // finds every item already stamped): no Slack noise, no API cost.
252
+ if (totalItems === 0 && usernames.checked === 0 && allFailedBatches === 0) {
253
+ return new Response(
254
+ JSON.stringify({
255
+ checked: 0,
256
+ hidden: 0,
257
+ usernames_checked: 0,
258
+ usernames_blanked: 0,
259
+ failed_batches: 0,
260
+ }),
261
+ );
262
+ }
263
+
146
264
  const lines = flagged.map(({ item, categories }) => {
147
265
  const p = item.profiles;
148
266
  return (
@@ -151,17 +269,28 @@ Deno.serve(async () => {
151
269
  ` amplitude: \`${p?.amplitude_id ?? "?"}\` | revenuecat: \`${p?.revenuecat_id ?? "?"}\``
152
270
  );
153
271
  });
272
+ const usernameLines = usernames.blanked.map(
273
+ ({ profile, categories }) =>
274
+ `* [username] *${profile.username}* blanked, _${categories.join(", ")}_\n` +
275
+ ` amplitude: \`${profile.amplitude_id ?? "?"}\` | revenuecat: \`${profile.revenuecat_id ?? "?"}\``,
276
+ );
277
+ const usernameSummary =
278
+ usernames.checked > 0
279
+ ? ` Usernames: ${usernames.blanked.length}/${usernames.checked} blanked.`
280
+ : "";
154
281
  let summaryText: string;
155
- if (failedBatches > 0) {
282
+ if (allFailedBatches > 0) {
156
283
  const uncheckedCount = totalItems - processed.length;
157
284
  summaryText =
158
- `Daily moderation: WARNING, ${failedBatches} batch(es) failed to reach the moderation API ` +
285
+ `Daily moderation: WARNING, ${allFailedBatches} batch(es) failed to reach the moderation API ` +
159
286
  `(${uncheckedCount} of ${totalItems} items were not checked and will be retried). ` +
160
- `${flagged.length} items hidden.`;
161
- } else if (flagged.length === 0) {
162
- summaryText = `Daily moderation: ${processed.length} items checked, nothing to report.`;
287
+ `${flagged.length} items hidden.${usernameSummary}`;
288
+ } else if (flagged.length === 0 && usernames.blanked.length === 0) {
289
+ summaryText = `Daily moderation: ${processed.length} items checked, nothing to report.${usernameSummary}`;
163
290
  } else {
164
- summaryText = `Daily moderation: ${flagged.length}/${processed.length} items hidden\n\n${lines.join("\n")}`;
291
+ summaryText =
292
+ `Daily moderation: ${flagged.length}/${processed.length} items hidden.${usernameSummary}\n\n` +
293
+ [...lines, ...usernameLines].join("\n");
165
294
  }
166
295
  await postToSlack({ text: summaryText });
167
296
 
@@ -169,7 +298,9 @@ Deno.serve(async () => {
169
298
  JSON.stringify({
170
299
  checked: processed.length,
171
300
  hidden: flagged.length,
172
- failed_batches: failedBatches,
301
+ usernames_checked: usernames.checked,
302
+ usernames_blanked: usernames.blanked.length,
303
+ failed_batches: allFailedBatches,
173
304
  }),
174
305
  );
175
306
  });
@@ -0,0 +1,120 @@
1
+ // Daily translation sweep: back-fills the history after installation and
2
+ // catches every item translate-one missed (API outage, secret set later, a
3
+ // locale added to COMMUNITY_TRANSLATION_LOCALES). Each invocation runs a
4
+ // bounded time budget at limited concurrency, then self-chains (POSTs itself)
5
+ // to keep going until the backlog is drained or MAX_CHAIN is hit. Quiet
6
+ // unless something failed.
7
+
8
+ import { adminClient, json } from "../_shared/client.ts";
9
+ import { ensureTranslations, runPool, TARGET_LOCALES } from "../_shared/translation.ts";
10
+ import { postToSlack } from "../_shared/slack.ts";
11
+
12
+ const supabase = adminClient();
13
+ // COMMUNITY_TRANSLATION_SWEEP_BATCH: optional test/cost knob (a small value
14
+ // forces chaining on a small backlog).
15
+ const MAX_ITEMS_PER_KIND = Math.max(
16
+ 1,
17
+ Number(Deno.env.get("COMMUNITY_TRANSLATION_SWEEP_BATCH")) || 250,
18
+ );
19
+ const CONCURRENCY = 4;
20
+ const TIME_BUDGET_MS = 60_000;
21
+ const MAX_CHAIN = 200;
22
+
23
+ async function sweep(
24
+ kind: "post" | "comment",
25
+ withinBudget: () => boolean,
26
+ ): Promise<{ done: number; failed: number; remaining: number }> {
27
+ const { data, error } = await supabase.rpc("items_missing_translations", {
28
+ kind,
29
+ target_locales: TARGET_LOCALES,
30
+ max_items: MAX_ITEMS_PER_KIND,
31
+ });
32
+ if (error) {
33
+ console.error("items_missing_translations failed", error.message);
34
+ return { done: 0, failed: 1, remaining: 0 };
35
+ }
36
+ const ids = ((data ?? []) as { id: string }[]).map((row) => row.id);
37
+ const { results, processed } = await runPool(
38
+ ids,
39
+ CONCURRENCY,
40
+ (id) => ensureTranslations(supabase, kind, id),
41
+ withinBudget,
42
+ );
43
+ const done = results.filter((r) => r !== null).length;
44
+ const failed = processed - done;
45
+ const remaining = Math.max(ids.length - processed, ids.length === MAX_ITEMS_PER_KIND ? 1 : 0);
46
+ return { done, failed, remaining };
47
+ }
48
+
49
+ Deno.serve(async (req) => {
50
+ if (TARGET_LOCALES.length === 0) {
51
+ return json({
52
+ posts: 0,
53
+ comments: 0,
54
+ failed: 0,
55
+ note: "COMMUNITY_TRANSLATION_LOCALES not set",
56
+ });
57
+ }
58
+ const started = Date.now();
59
+ const withinBudget = () => Date.now() - started < TIME_BUDGET_MS;
60
+
61
+ const posts = await sweep("post", withinBudget);
62
+ const comments = await sweep("comment", withinBudget);
63
+ const done = posts.done + comments.done;
64
+ const failed = posts.failed + comments.failed;
65
+ const remaining = posts.remaining + comments.remaining;
66
+
67
+ // The endpoint only needs the public anon key, so the caller controls this
68
+ // header: clamp to a non-negative integer so a malicious/garbage value (a
69
+ // large negative number, NaN, -Infinity) can't bypass MAX_CHAIN.
70
+ const depth = Math.max(0, Math.trunc(Number(req.headers.get("x-community-chain"))) || 0);
71
+ let chained = false;
72
+ // Why no chain was started, reported to Slack when items remain.
73
+ let chainFailure = done > 0 ? "depth cap" : "no progress this run";
74
+ if (remaining > 0 && done > 0 && depth < MAX_CHAIN) {
75
+ const controller = new AbortController();
76
+ const timeout = setTimeout(() => controller.abort(), 2_000);
77
+ try {
78
+ const res = await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/daily-translation`, {
79
+ method: "POST",
80
+ headers: {
81
+ // Forward the caller's own JWT (cron/pg_net sends the anon key the CLI
82
+ // substituted, which passes verify_jwt); the platform's SUPABASE_ANON_KEY
83
+ // env may be a publishable sb_ key that verify_jwt rejects.
84
+ Authorization:
85
+ req.headers.get("authorization") ?? `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
86
+ "Content-Type": "application/json",
87
+ "x-community-chain": String(depth + 1),
88
+ },
89
+ body: JSON.stringify({}),
90
+ signal: controller.signal,
91
+ });
92
+ chained = res.ok;
93
+ if (!res.ok) chainFailure = `chain request failed (${res.status})`;
94
+ } catch (e) {
95
+ if (e instanceof DOMException && e.name === "AbortError") {
96
+ // We stopped waiting after 2s; the request was already sent and the
97
+ // chained run continues server-side regardless of our response.
98
+ chained = true;
99
+ } else {
100
+ chainFailure = "chain request failed";
101
+ }
102
+ } finally {
103
+ clearTimeout(timeout);
104
+ }
105
+ }
106
+
107
+ // Only the last link of a chain reports: an item that always fails would
108
+ // otherwise post a warning on every one of up to 200 chained links.
109
+ if (failed > 0 && !chained) {
110
+ await postToSlack({
111
+ text: `Daily translation: WARNING, ${failed} item(s) failed to translate (${posts.done} posts and ${comments.done} comments done, ${remaining} remaining); they will be retried by the next sweep.`,
112
+ });
113
+ }
114
+ if (remaining > 0 && !chained) {
115
+ await postToSlack({
116
+ text: `Translation sweep stopped with ${remaining} items remaining (chain not started: ${chainFailure}); the daily cron continues tomorrow.`,
117
+ });
118
+ }
119
+ return json({ posts: posts.done, comments: comments.done, failed, remaining, chained, depth });
120
+ });
@@ -0,0 +1 @@
1
+ { "compilerOptions": { "lib": ["deno.window"], "strict": true } }
@@ -3,8 +3,14 @@
3
3
  // promoted to 'visible' (published); flagged -> 'hidden' (rejected) with the
4
4
  // reason. On an OpenAI error we fail closed (leave it pending); the daily
5
5
  // backstop re-checks stale pending items so nothing is silently lost.
6
+ //
7
+ // Security: the caller must be the row's author (resolved from their user
8
+ // JWT through Auth) or the service role — an anon-key-only call can't
9
+ // moderate someone else's pending item. Every status write is guarded with
10
+ // `status = 'pending'` so a row the author deleted mid-flight is never
11
+ // resurrected.
6
12
 
7
- import { adminClient, json } from "../_shared/client.ts";
13
+ import { adminClient, callerUserId, isServiceCaller, json } from "../_shared/client.ts";
8
14
  import { assertModerationConfigured, moderateInput } from "../_shared/moderation.ts";
9
15
 
10
16
  assertModerationConfigured();
@@ -12,7 +18,10 @@ assertModerationConfigured();
12
18
  const supabase = adminClient();
13
19
 
14
20
  Deno.serve(async (req) => {
15
- const { kind, id } = (await req.json()) as { kind: "post" | "comment"; id: string };
21
+ const { kind, id } = (await req.json().catch(() => ({}))) as {
22
+ kind?: "post" | "comment";
23
+ id?: string;
24
+ };
16
25
  if ((kind !== "post" && kind !== "comment") || !id) {
17
26
  return json({ status: "error", error: "bad request" }, 400);
18
27
  }
@@ -20,25 +29,33 @@ Deno.serve(async (req) => {
20
29
 
21
30
  const { data: row } = await supabase
22
31
  .from(table)
23
- .select("id, content, status")
32
+ .select("id, author_id, content, status")
24
33
  .eq("id", id)
25
34
  .single();
26
35
  if (!row) return json({ status: "error", error: "not found" });
36
+
37
+ if (!isServiceCaller(req)) {
38
+ const uid = await callerUserId(req);
39
+ if (!uid || uid !== row.author_id) return json({ status: "error", error: "forbidden" }, 403);
40
+ }
41
+
27
42
  // Idempotent: only pending rows are moderated here.
28
43
  if (row.status !== "pending") {
29
44
  return json({ status: row.status === "visible" ? "published" : row.status });
30
45
  }
31
46
 
32
47
  // A poll post's option labels are user content too: moderate them together
33
- // with the question, one verdict for the whole post.
48
+ // with the question, one verdict for the whole post. The polls module is
49
+ // optional — on an install without it the table doesn't exist and the
50
+ // query errors, which simply means "no options".
34
51
  let input = row.content;
35
52
  if (kind === "post") {
36
- const { data: options } = await supabase
53
+ const { data: options, error } = await supabase
37
54
  .from("poll_options")
38
55
  .select("label")
39
56
  .eq("post_id", id)
40
57
  .order("idx");
41
- if (options && options.length > 0) {
58
+ if (!error && options && options.length > 0) {
42
59
  input = [row.content, ...options.map((o) => o.label)].join("\n");
43
60
  }
44
61
  }
@@ -54,9 +71,17 @@ Deno.serve(async (req) => {
54
71
  await supabase
55
72
  .from(table)
56
73
  .update({ status: "hidden", moderation_reason: verdict.reason, moderated_at: now })
57
- .eq("id", id);
74
+ .eq("id", id)
75
+ .eq("status", "pending");
58
76
  return json({ status: "rejected", reason: verdict.reason });
59
77
  }
60
- await supabase.from(table).update({ status: "visible", moderated_at: now }).eq("id", id);
78
+ const { data: updated } = await supabase
79
+ .from(table)
80
+ .update({ status: "visible", moderated_at: now })
81
+ .eq("id", id)
82
+ .eq("status", "pending")
83
+ .select("id");
84
+ // Nothing updated: the author deleted (or a sweep moderated) it meanwhile.
85
+ if (!updated || updated.length === 0) return json({ status: "pending" });
61
86
  return json({ status: "published" });
62
87
  });
@@ -1,29 +1,36 @@
1
- // Real-time comment notification: a pg_net trigger on comments posts the new
2
- // row here. We push to the post author (unless self, opted out, no token, or
3
- // the author has blocked the commenter).
1
+ // Real-time comment notification: a pg_net trigger fires here when a comment
2
+ // becomes visible (published). We push to the post author (unless self, opted
3
+ // out, no token, or the author has blocked the commenter).
4
+ //
5
+ // Security: this function is reachable with the public anon key (the trigger
6
+ // calls it that way), so the body is UNTRUSTED. Only `record.id` is used; the
7
+ // comment is re-read from the database and must be visible — nothing in the
8
+ // push comes from the request itself.
4
9
 
5
10
  import { adminClient } from "../_shared/client.ts";
6
11
  import { sendExpoPush } from "../_shared/push.ts";
7
- import { FALLBACK_ACTOR_NAME } from "../_shared/config.ts";
12
+ import { pushCopy } from "../_shared/copy.ts";
13
+ import { ensureTranslations, resolveTargetLocale, languageOf } from "../_shared/translation.ts";
8
14
 
9
15
  const supabase = adminClient();
10
16
 
11
- interface CommentRecord {
12
- id: string;
13
- post_id: string;
14
- author_id: string;
15
- content: string;
16
- }
17
-
18
17
  Deno.serve(async (req) => {
19
- const { record } = (await req.json()) as { record: CommentRecord };
18
+ const { record } = (await req.json().catch(() => ({}))) as { record?: { id?: string } };
19
+ if (!record?.id) return new Response("skip: no id", { status: 400 });
20
+
21
+ const { data: comment } = await supabase
22
+ .from("comments")
23
+ .select("id, post_id, author_id, content, status")
24
+ .eq("id", record.id)
25
+ .single();
26
+ if (!comment || comment.status !== "visible") return new Response("skip: not visible");
20
27
 
21
28
  const { data: post } = await supabase
22
29
  .from("posts")
23
30
  .select("author_id")
24
- .eq("id", record.post_id)
31
+ .eq("id", comment.post_id)
25
32
  .single();
26
- if (!post || post.author_id === record.author_id) return new Response("skip: self/none");
33
+ if (!post || post.author_id === comment.author_id) return new Response("skip: self/none");
27
34
 
28
35
  const { data: pref } = await supabase
29
36
  .from("push_tokens")
@@ -36,25 +43,43 @@ Deno.serve(async (req) => {
36
43
  .from("blocks")
37
44
  .select("blocker_id")
38
45
  .eq("blocker_id", post.author_id)
39
- .eq("blocked_id", record.author_id)
46
+ .eq("blocked_id", comment.author_id)
40
47
  .maybeSingle();
41
48
  if (blocked) return new Response("skip: blocked");
42
49
 
50
+ const { data: recipient } = await supabase
51
+ .from("profiles")
52
+ .select("locale")
53
+ .eq("id", post.author_id)
54
+ .single();
55
+ const copy = pushCopy(recipient?.locale);
56
+
43
57
  const { data: actor } = await supabase
44
58
  .from("profiles")
45
59
  .select("username")
46
- .eq("id", record.author_id)
60
+ .eq("id", comment.author_id)
47
61
  .single();
48
- const name = actor?.username?.trim() || FALLBACK_ACTOR_NAME;
49
- const excerpt =
50
- record.content.length > 140 ? `${record.content.slice(0, 137)}...` : record.content;
62
+ const name = actor?.username?.trim() || copy.fallbackName;
63
+ // Recipient-language excerpt when the translation module is on: translate
64
+ // now (a few seconds) rather than push the source language; on failure the
65
+ // original goes out and the sweep fills the rows later.
66
+ let excerptSource = comment.content;
67
+ const targetLocale = resolveTargetLocale(recipient?.locale);
68
+ if (targetLocale) {
69
+ const rows = await ensureTranslations(supabase, "comment", comment.id);
70
+ const match = rows?.find(
71
+ (r) => r.locale === targetLocale && languageOf(r.locale) !== r.source_locale,
72
+ );
73
+ if (match) excerptSource = match.content;
74
+ }
75
+ const excerpt = excerptSource.length > 140 ? `${excerptSource.slice(0, 137)}...` : excerptSource;
51
76
  // Title states the event; body is the commenter's name then the comment in
52
77
  // quotes, e.g. Marie: "So happy for you!".
53
78
  const body = `${name}: "${excerpt}"`;
54
79
 
55
80
  await sendExpoPush({
56
81
  to: pref.expo_push_token,
57
- title: "You received a comment",
82
+ title: copy.text("comment.title"),
58
83
  body,
59
84
  data: { route: "/community", kind: "community_comment" },
60
85
  badge: 1,
@@ -1,11 +1,18 @@
1
1
  // Coalesced like notifications. Trigger path (body has record): apply a 10-min
2
2
  // per-post cooldown so bursts do not spam. Digest path (no record, from cron):
3
3
  // sweep posts with unnotified likes older than the cooldown. Either way, group
4
- // as "<name> liked your post" (1) or "<name> and N others liked your post".
4
+ // as "<name> liked your post" (1) or "<name> and N others liked your post",
5
+ // in the recipient's locale (profiles.locale) — see _shared/copy.ts for the
6
+ // built-in copy and the COMMUNITY_PUSH_COPY override.
7
+ //
8
+ // Security: the trigger path only takes `record.post_id` from the (anon-key
9
+ // reachable, hence untrusted) body and re-derives everything from the likes
10
+ // table, so a forged body can at most flush a real post's pending like
11
+ // notifications early.
5
12
 
6
13
  import { adminClient } from "../_shared/client.ts";
7
14
  import { sendExpoPush } from "../_shared/push.ts";
8
- import { FALLBACK_ACTOR_NAME } from "../_shared/config.ts";
15
+ import { pushCopy } from "../_shared/copy.ts";
9
16
 
10
17
  const supabase = adminClient();
11
18
  const COOLDOWN_MS = 10 * 60 * 1000;
@@ -52,16 +59,23 @@ async function flushPost(postId: string) {
52
59
  );
53
60
  if (contributors.length === 0) return;
54
61
 
62
+ const { data: recipient } = await supabase
63
+ .from("profiles")
64
+ .select("locale")
65
+ .eq("id", post.author_id)
66
+ .single();
67
+ const copy = pushCopy(recipient?.locale);
68
+
55
69
  const { data: actor } = await supabase
56
70
  .from("profiles")
57
71
  .select("username")
58
72
  .eq("id", contributors[0].user_id)
59
73
  .single();
60
- const name = actor?.username?.trim() || FALLBACK_ACTOR_NAME;
74
+ const name = actor?.username?.trim() || copy.fallbackName;
61
75
  const title =
62
76
  contributors.length === 1
63
- ? `${name} liked your post`
64
- : `${name} and ${contributors.length - 1} others liked your post`;
77
+ ? copy.text("like.one", { name })
78
+ : copy.text("like.many", { name, count: contributors.length - 1 });
65
79
 
66
80
  await sendExpoPush({
67
81
  to: pref.expo_push_token,