@rocapine/community 0.2.0 → 0.3.2
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 +5 -0
- package/lib/commands/init.js +4 -1
- package/lib/commands/upgrade.d.ts +4 -0
- package/lib/commands/upgrade.js +34 -9
- package/lib/index.js +2 -0
- package/lib/install-shared.d.ts +9 -5
- package/lib/install-shared.js +19 -5
- package/package.json +1 -1
- package/templates/functions/_shared/client.ts +25 -0
- package/templates/functions/_shared/push.ts +19 -9
- package/templates/functions/_shared/translation.ts +513 -0
- package/templates/functions/broadcast-post/index.ts +48 -13
- package/templates/functions/daily-moderation/index.ts +138 -37
- package/templates/functions/daily-translation/index.ts +120 -0
- package/templates/functions/deno.json +1 -0
- package/templates/functions/notify-comment/index.ts +16 -2
- package/templates/functions/translate-one/index.ts +20 -0
- package/templates/migrations/core/007_username_moderation.sql +44 -0
- package/templates/migrations/polls/001_polls.sql +24 -0
- package/templates/migrations/translation/001_translations.sql +190 -0
|
@@ -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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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,6 +44,88 @@ 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")
|
|
@@ -94,9 +183,6 @@ Deno.serve(async () => {
|
|
|
94
183
|
table: "comments" as const,
|
|
95
184
|
})),
|
|
96
185
|
];
|
|
97
|
-
if (items.length === 0) {
|
|
98
|
-
return new Response(JSON.stringify({ checked: 0, hidden: 0, failed_batches: 0 }));
|
|
99
|
-
}
|
|
100
186
|
|
|
101
187
|
// Moderate in batches of 100 (API limit). Only items whose batch succeeded
|
|
102
188
|
// get stamped moderated_at, so a transient failure is retried tomorrow.
|
|
@@ -105,31 +191,16 @@ Deno.serve(async () => {
|
|
|
105
191
|
let failedBatches = 0;
|
|
106
192
|
for (let i = 0; i < items.length; i += 100) {
|
|
107
193
|
const batch = items.slice(i, i + 100);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
|
|
113
|
-
"Content-Type": "application/json",
|
|
114
|
-
},
|
|
115
|
-
body: JSON.stringify({
|
|
116
|
-
model: "omni-moderation-latest",
|
|
117
|
-
input: batch.map((it) => it.content),
|
|
118
|
-
}),
|
|
119
|
-
});
|
|
120
|
-
if (!res.ok) {
|
|
121
|
-
failedBatches++;
|
|
122
|
-
continue; // skip stamping; retried next run
|
|
123
|
-
}
|
|
124
|
-
const { results } = await res.json();
|
|
125
|
-
results.forEach((r: ModerationResult, idx: number) => {
|
|
126
|
-
const cats = flaggedCategories(r);
|
|
127
|
-
if (cats.length > 0) flagged.push({ item: batch[idx], categories: cats });
|
|
128
|
-
});
|
|
129
|
-
processed.push(...batch);
|
|
130
|
-
} catch {
|
|
131
|
-
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;
|
|
132
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);
|
|
133
204
|
}
|
|
134
205
|
const totalItems = items.length;
|
|
135
206
|
|
|
@@ -173,6 +244,23 @@ Deno.serve(async () => {
|
|
|
173
244
|
await supabase.from("comments").update({ moderated_at: now }).in("id", processedCommentIds);
|
|
174
245
|
}
|
|
175
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
|
+
|
|
176
264
|
const lines = flagged.map(({ item, categories }) => {
|
|
177
265
|
const p = item.profiles;
|
|
178
266
|
return (
|
|
@@ -181,17 +269,28 @@ Deno.serve(async () => {
|
|
|
181
269
|
` amplitude: \`${p?.amplitude_id ?? "?"}\` | revenuecat: \`${p?.revenuecat_id ?? "?"}\``
|
|
182
270
|
);
|
|
183
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
|
+
: "";
|
|
184
281
|
let summaryText: string;
|
|
185
|
-
if (
|
|
282
|
+
if (allFailedBatches > 0) {
|
|
186
283
|
const uncheckedCount = totalItems - processed.length;
|
|
187
284
|
summaryText =
|
|
188
|
-
`Daily moderation: WARNING, ${
|
|
285
|
+
`Daily moderation: WARNING, ${allFailedBatches} batch(es) failed to reach the moderation API ` +
|
|
189
286
|
`(${uncheckedCount} of ${totalItems} items were not checked and will be retried). ` +
|
|
190
|
-
`${flagged.length} items hidden
|
|
191
|
-
} else if (flagged.length === 0) {
|
|
192
|
-
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}`;
|
|
193
290
|
} else {
|
|
194
|
-
summaryText =
|
|
291
|
+
summaryText =
|
|
292
|
+
`Daily moderation: ${flagged.length}/${processed.length} items hidden.${usernameSummary}\n\n` +
|
|
293
|
+
[...lines, ...usernameLines].join("\n");
|
|
195
294
|
}
|
|
196
295
|
await postToSlack({ text: summaryText });
|
|
197
296
|
|
|
@@ -199,7 +298,9 @@ Deno.serve(async () => {
|
|
|
199
298
|
JSON.stringify({
|
|
200
299
|
checked: processed.length,
|
|
201
300
|
hidden: flagged.length,
|
|
202
|
-
|
|
301
|
+
usernames_checked: usernames.checked,
|
|
302
|
+
usernames_blanked: usernames.blanked.length,
|
|
303
|
+
failed_batches: allFailedBatches,
|
|
203
304
|
}),
|
|
204
305
|
);
|
|
205
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 } }
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { adminClient } from "../_shared/client.ts";
|
|
11
11
|
import { sendExpoPush } from "../_shared/push.ts";
|
|
12
12
|
import { pushCopy } from "../_shared/copy.ts";
|
|
13
|
+
import { ensureTranslations, resolveTargetLocale, languageOf } from "../_shared/translation.ts";
|
|
13
14
|
|
|
14
15
|
const supabase = adminClient();
|
|
15
16
|
|
|
@@ -59,8 +60,21 @@ Deno.serve(async (req) => {
|
|
|
59
60
|
.eq("id", comment.author_id)
|
|
60
61
|
.single();
|
|
61
62
|
const name = actor?.username?.trim() || copy.fallbackName;
|
|
62
|
-
|
|
63
|
-
|
|
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. translate-one fires
|
|
66
|
+
// on the same insert, so when it holds the claim wait up to 20 s (the OpenAI
|
|
67
|
+
// request timeout) for its rows.
|
|
68
|
+
let excerptSource = comment.content;
|
|
69
|
+
const targetLocale = resolveTargetLocale(recipient?.locale);
|
|
70
|
+
if (targetLocale) {
|
|
71
|
+
const rows = await ensureTranslations(supabase, "comment", comment.id, { waitMs: 20_000 });
|
|
72
|
+
const match = rows?.find(
|
|
73
|
+
(r) => r.locale === targetLocale && languageOf(r.locale) !== r.source_locale,
|
|
74
|
+
);
|
|
75
|
+
if (match) excerptSource = match.content;
|
|
76
|
+
}
|
|
77
|
+
const excerpt = excerptSource.length > 140 ? `${excerptSource.slice(0, 137)}...` : excerptSource;
|
|
64
78
|
// Title states the event; body is the commenter's name then the comment in
|
|
65
79
|
// quotes, e.g. Marie: "So happy for you!".
|
|
66
80
|
const body = `${name}: "${excerpt}"`;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Translate one published post or comment into every target locale. Called by
|
|
2
|
+
// the pg_net triggers in translation/001 (anon key, untrusted body: only
|
|
3
|
+
// `kind` and `id` are read, the row is re-read). Idempotent: an item that
|
|
4
|
+
// already has every translation costs no API call.
|
|
5
|
+
|
|
6
|
+
import { adminClient, json } from "../_shared/client.ts";
|
|
7
|
+
import { ensureTranslations } from "../_shared/translation.ts";
|
|
8
|
+
|
|
9
|
+
const supabase = adminClient();
|
|
10
|
+
|
|
11
|
+
Deno.serve(async (req) => {
|
|
12
|
+
const body = (await req.json().catch(() => ({}))) as { kind?: unknown; id?: unknown };
|
|
13
|
+
const kind = body.kind === "post" || body.kind === "comment" ? body.kind : null;
|
|
14
|
+
if (!kind || typeof body.id !== "string")
|
|
15
|
+
return json({ status: "error", error: "bad request" }, 400);
|
|
16
|
+
|
|
17
|
+
const rows = await ensureTranslations(supabase, kind, body.id);
|
|
18
|
+
if (rows === null) return json({ status: "failed" }, 502);
|
|
19
|
+
return json({ status: rows.length === 0 ? "skipped" : "ok", locales: rows.map((r) => r.locale) });
|
|
20
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- Username moderation (daily sweep).
|
|
2
|
+
--
|
|
3
|
+
-- `profiles.username` is client-writable by design: the host app syncs its
|
|
4
|
+
-- own onboarding display name straight into it, so it never went through
|
|
5
|
+
-- update-profile's synchronous moderation (handle / bio / avatar do). Revoking
|
|
6
|
+
-- the grant would break the pre-SDK app versions still installed, which write
|
|
7
|
+
-- the column directly, so moderation happens asynchronously instead: the
|
|
8
|
+
-- daily-moderation sweep checks every username it has not checked yet
|
|
9
|
+
-- (`username_checked_at is null`) and blanks the ones the moderation API
|
|
10
|
+
-- flags — the app then shows its anonymous fallback name. `username_rejected`
|
|
11
|
+
-- remembers the last blanked value so a client that re-syncs the same name
|
|
12
|
+
-- (every launch, in both source apps) is silently kept blank instead of
|
|
13
|
+
-- getting re-flagged every night.
|
|
14
|
+
--
|
|
15
|
+
-- Neither column is in the client-facing SELECT / UPDATE grants (core/002
|
|
16
|
+
-- scopes both to an explicit column list), so clients can't read or tamper
|
|
17
|
+
-- with the moderation state.
|
|
18
|
+
alter table public.profiles
|
|
19
|
+
add column if not exists username_checked_at timestamptz,
|
|
20
|
+
add column if not exists username_rejected text;
|
|
21
|
+
|
|
22
|
+
create or replace function public.profiles_username_changed()
|
|
23
|
+
returns trigger language plpgsql as $$
|
|
24
|
+
begin
|
|
25
|
+
if new.username is distinct from old.username then
|
|
26
|
+
if new.username is not null and new.username = old.username_rejected then
|
|
27
|
+
-- Re-sync of a name the sweep already blanked: keep it blank.
|
|
28
|
+
new.username := null;
|
|
29
|
+
else
|
|
30
|
+
-- A genuinely new name: re-check it on the next sweep.
|
|
31
|
+
new.username_checked_at := null;
|
|
32
|
+
end if;
|
|
33
|
+
end if;
|
|
34
|
+
return new;
|
|
35
|
+
end;
|
|
36
|
+
$$;
|
|
37
|
+
|
|
38
|
+
drop trigger if exists profiles_username_changed_trg on public.profiles;
|
|
39
|
+
create trigger profiles_username_changed_trg
|
|
40
|
+
before update of username on public.profiles
|
|
41
|
+
for each row execute function public.profiles_username_changed();
|
|
42
|
+
|
|
43
|
+
-- Existing usernames are checked by the first sweeps after this migration
|
|
44
|
+
-- (batches of up to 1000 per run, see daily-moderation).
|
|
@@ -118,3 +118,27 @@ $$;
|
|
|
118
118
|
|
|
119
119
|
revoke execute on function public.create_poll_post(text, text, text[]) from public, anon;
|
|
120
120
|
grant execute on function public.create_poll_post(text, text, text[]) to authenticated;
|
|
121
|
+
|
|
122
|
+
-- ============ TRANSLATION (installed after the translation module) ============
|
|
123
|
+
-- translation/001 creates poll_option_translations only when poll_options
|
|
124
|
+
-- already exists; when polls is added later (`upgrade --add-modules polls` on
|
|
125
|
+
-- a translation-enabled backend) this block creates it instead. Same
|
|
126
|
+
-- definition as translation/001's guarded block.
|
|
127
|
+
do $$
|
|
128
|
+
begin
|
|
129
|
+
if to_regclass('public.post_translations') is not null then
|
|
130
|
+
create table if not exists public.poll_option_translations (
|
|
131
|
+
option_id uuid not null references public.poll_options(id) on delete cascade,
|
|
132
|
+
locale text not null,
|
|
133
|
+
content text not null,
|
|
134
|
+
primary key (option_id, locale)
|
|
135
|
+
);
|
|
136
|
+
alter table public.poll_option_translations enable row level security;
|
|
137
|
+
drop policy if exists "poll option translations readable where option readable"
|
|
138
|
+
on public.poll_option_translations;
|
|
139
|
+
create policy "poll option translations readable where option readable"
|
|
140
|
+
on public.poll_option_translations for select to authenticated
|
|
141
|
+
using (exists (select 1 from public.poll_options o where o.id = poll_option_translations.option_id));
|
|
142
|
+
grant select on public.poll_option_translations to authenticated;
|
|
143
|
+
end if;
|
|
144
|
+
end $$;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
-- Translation module: posts, comments and poll option labels translated at
|
|
2
|
+
-- publication into the app's declared locales (COMMUNITY_TRANSLATION_LOCALES
|
|
3
|
+
-- secret, mirrored by the client's modules.translation.locales), shown in the
|
|
4
|
+
-- reader's language by @rocapine/community-ui with a per-item "see original".
|
|
5
|
+
--
|
|
6
|
+
-- Written only by the service role (translate-one / daily-translation Edge
|
|
7
|
+
-- Functions). Readable by authenticated users where the parent row is
|
|
8
|
+
-- readable (RLS on posts/comments does the filtering, same pattern as
|
|
9
|
+
-- likes). Additive: no core table changes. poll_option_translations is
|
|
10
|
+
-- guarded so this module installs on a backend without the polls module.
|
|
11
|
+
--
|
|
12
|
+
-- ⚠️ PLACEHOLDERS: before pushing, replace __SUPABASE_PROJECT_URL__ with the
|
|
13
|
+
-- app's project URL (https://<ref>.supabase.co) and __SUPABASE_ANON_KEY__ with
|
|
14
|
+
-- its anon key. The anon key is public by design (shipped inside the app
|
|
15
|
+
-- binary): the Edge Functions do privileged work through their own
|
|
16
|
+
-- service-role env, the JWT only passes verify_jwt.
|
|
17
|
+
--
|
|
18
|
+
-- The DO block below fails the migration loudly if the placeholders were not
|
|
19
|
+
-- replaced.
|
|
20
|
+
do $$ begin
|
|
21
|
+
if '__SUPABASE_PROJECT_URL__' like '\_\_SUPABASE%' escape '\' then
|
|
22
|
+
raise exception 'community-sdk: placeholders not substituted. Run: npx @rocapine/community init';
|
|
23
|
+
end if;
|
|
24
|
+
end $$;
|
|
25
|
+
|
|
26
|
+
create table public.post_translations (
|
|
27
|
+
post_id uuid not null references public.posts(id) on delete cascade,
|
|
28
|
+
locale text not null,
|
|
29
|
+
source_locale text not null,
|
|
30
|
+
content text not null,
|
|
31
|
+
engine text not null,
|
|
32
|
+
created_at timestamptz not null default now(),
|
|
33
|
+
primary key (post_id, locale)
|
|
34
|
+
);
|
|
35
|
+
alter table public.post_translations enable row level security;
|
|
36
|
+
create policy "post translations readable where post readable"
|
|
37
|
+
on public.post_translations for select to authenticated
|
|
38
|
+
using (exists (select 1 from public.posts p where p.id = post_translations.post_id));
|
|
39
|
+
grant select on public.post_translations to authenticated;
|
|
40
|
+
|
|
41
|
+
create table public.comment_translations (
|
|
42
|
+
comment_id uuid not null references public.comments(id) on delete cascade,
|
|
43
|
+
locale text not null,
|
|
44
|
+
source_locale text not null,
|
|
45
|
+
content text not null,
|
|
46
|
+
engine text not null,
|
|
47
|
+
created_at timestamptz not null default now(),
|
|
48
|
+
primary key (comment_id, locale)
|
|
49
|
+
);
|
|
50
|
+
alter table public.comment_translations enable row level security;
|
|
51
|
+
create policy "comment translations readable where comment readable"
|
|
52
|
+
on public.comment_translations for select to authenticated
|
|
53
|
+
using (exists (select 1 from public.comments c where c.id = comment_translations.comment_id));
|
|
54
|
+
grant select on public.comment_translations to authenticated;
|
|
55
|
+
|
|
56
|
+
do $$
|
|
57
|
+
begin
|
|
58
|
+
if to_regclass('public.poll_options') is not null then
|
|
59
|
+
create table if not exists public.poll_option_translations (
|
|
60
|
+
option_id uuid not null references public.poll_options(id) on delete cascade,
|
|
61
|
+
locale text not null,
|
|
62
|
+
content text not null,
|
|
63
|
+
primary key (option_id, locale)
|
|
64
|
+
);
|
|
65
|
+
alter table public.poll_option_translations enable row level security;
|
|
66
|
+
drop policy if exists "poll option translations readable where option readable"
|
|
67
|
+
on public.poll_option_translations;
|
|
68
|
+
create policy "poll option translations readable where option readable"
|
|
69
|
+
on public.poll_option_translations for select to authenticated
|
|
70
|
+
using (exists (select 1 from public.poll_options o where o.id = poll_option_translations.option_id));
|
|
71
|
+
grant select on public.poll_option_translations to authenticated;
|
|
72
|
+
end if;
|
|
73
|
+
end $$;
|
|
74
|
+
|
|
75
|
+
-- ============ SWEEP HELPER ============
|
|
76
|
+
-- Visible items that lack at least one target locale other than their own
|
|
77
|
+
-- source language. Marker rows are not translations: 'source' (the detected
|
|
78
|
+
-- source had nothing to translate) and 'attempt' (a translation claim, deleted
|
|
79
|
+
-- on success). Items with no row at all have an unknown source and are always
|
|
80
|
+
-- returned; an item with an 'attempt' younger than 6 h is skipped (in flight,
|
|
81
|
+
-- or failed recently — retried once it ages out). Service role only.
|
|
82
|
+
create or replace function public.items_missing_translations(
|
|
83
|
+
kind text, target_locales text[], max_items int
|
|
84
|
+
) returns table (id uuid)
|
|
85
|
+
language sql stable security definer set search_path = public as $$
|
|
86
|
+
select x.id from (
|
|
87
|
+
select p.id, p.created_at,
|
|
88
|
+
exists (select 1 from public.post_translations t where t.post_id = p.id) as has_any,
|
|
89
|
+
(select array_agg(t.locale) filter (where t.locale not in ('source', 'attempt'))
|
|
90
|
+
from public.post_translations t where t.post_id = p.id) as have,
|
|
91
|
+
(select min(t.source_locale) filter (where t.locale <> 'attempt')
|
|
92
|
+
from public.post_translations t where t.post_id = p.id) as src,
|
|
93
|
+
exists (select 1 from public.post_translations t
|
|
94
|
+
where t.post_id = p.id and t.locale = 'attempt'
|
|
95
|
+
and t.created_at > now() - interval '6 hours') as fresh_attempt
|
|
96
|
+
from public.posts p where kind = 'post' and p.status = 'visible'
|
|
97
|
+
union all
|
|
98
|
+
select c.id, c.created_at,
|
|
99
|
+
exists (select 1 from public.comment_translations t where t.comment_id = c.id) as has_any,
|
|
100
|
+
(select array_agg(t.locale) filter (where t.locale not in ('source', 'attempt'))
|
|
101
|
+
from public.comment_translations t where t.comment_id = c.id) as have,
|
|
102
|
+
(select min(t.source_locale) filter (where t.locale <> 'attempt')
|
|
103
|
+
from public.comment_translations t where t.comment_id = c.id) as src,
|
|
104
|
+
exists (select 1 from public.comment_translations t
|
|
105
|
+
where t.comment_id = c.id and t.locale = 'attempt'
|
|
106
|
+
and t.created_at > now() - interval '6 hours') as fresh_attempt
|
|
107
|
+
from public.comments c where kind = 'comment' and c.status = 'visible'
|
|
108
|
+
) x
|
|
109
|
+
where not x.fresh_attempt
|
|
110
|
+
and (not x.has_any
|
|
111
|
+
or exists (
|
|
112
|
+
select 1 from unnest(target_locales) l
|
|
113
|
+
-- `is distinct from`: src is null when the only row is a stale attempt
|
|
114
|
+
-- (a failed try), and that item must be retried.
|
|
115
|
+
where split_part(l, '-', 1) is distinct from x.src
|
|
116
|
+
and not (l = any (coalesce(x.have, '{}')))
|
|
117
|
+
))
|
|
118
|
+
order by x.created_at desc
|
|
119
|
+
limit max_items
|
|
120
|
+
$$;
|
|
121
|
+
revoke execute on function public.items_missing_translations(text, text[], int) from public, anon, authenticated;
|
|
122
|
+
grant execute on function public.items_missing_translations(text, text[], int) to service_role;
|
|
123
|
+
|
|
124
|
+
-- ============ TRIGGERS ============
|
|
125
|
+
create or replace function public.translate_post_webhook()
|
|
126
|
+
returns trigger language plpgsql security definer set search_path = public as $$
|
|
127
|
+
begin
|
|
128
|
+
perform net.http_post(
|
|
129
|
+
url := '__SUPABASE_PROJECT_URL__/functions/v1/translate-one',
|
|
130
|
+
headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__'),
|
|
131
|
+
body := jsonb_build_object('kind', 'post', 'id', new.id)
|
|
132
|
+
);
|
|
133
|
+
return new;
|
|
134
|
+
end; $$;
|
|
135
|
+
|
|
136
|
+
create or replace function public.translate_comment_webhook()
|
|
137
|
+
returns trigger language plpgsql security definer set search_path = public as $$
|
|
138
|
+
begin
|
|
139
|
+
perform net.http_post(
|
|
140
|
+
url := '__SUPABASE_PROJECT_URL__/functions/v1/translate-one',
|
|
141
|
+
headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__'),
|
|
142
|
+
body := jsonb_build_object('kind', 'comment', 'id', new.id)
|
|
143
|
+
);
|
|
144
|
+
return new;
|
|
145
|
+
end; $$;
|
|
146
|
+
|
|
147
|
+
drop trigger if exists on_post_published_translate on public.posts;
|
|
148
|
+
create trigger on_post_published_translate
|
|
149
|
+
after update of status on public.posts for each row
|
|
150
|
+
when (old.status = 'pending' and new.status = 'visible')
|
|
151
|
+
execute function public.translate_post_webhook();
|
|
152
|
+
drop trigger if exists on_post_created_visible_translate on public.posts;
|
|
153
|
+
create trigger on_post_created_visible_translate
|
|
154
|
+
after insert on public.posts for each row
|
|
155
|
+
when (new.status = 'visible')
|
|
156
|
+
execute function public.translate_post_webhook();
|
|
157
|
+
|
|
158
|
+
drop trigger if exists on_comment_published_translate on public.comments;
|
|
159
|
+
create trigger on_comment_published_translate
|
|
160
|
+
after update of status on public.comments for each row
|
|
161
|
+
when (old.status = 'pending' and new.status = 'visible')
|
|
162
|
+
execute function public.translate_comment_webhook();
|
|
163
|
+
drop trigger if exists on_comment_created_visible_translate on public.comments;
|
|
164
|
+
create trigger on_comment_created_visible_translate
|
|
165
|
+
after insert on public.comments for each row
|
|
166
|
+
when (new.status = 'visible')
|
|
167
|
+
execute function public.translate_comment_webhook();
|
|
168
|
+
|
|
169
|
+
-- ============ CRON ============
|
|
170
|
+
-- Daily sweep at 08:30 UTC (after the 08:00 moderation sweep): back-fills the
|
|
171
|
+
-- history on first installation, then catches anything translate-one missed.
|
|
172
|
+
do $$
|
|
173
|
+
begin
|
|
174
|
+
perform cron.schedule(
|
|
175
|
+
'community-translation-sweep',
|
|
176
|
+
'30 8 * * *',
|
|
177
|
+
$cron$
|
|
178
|
+
select net.http_post(
|
|
179
|
+
url := '__SUPABASE_PROJECT_URL__/functions/v1/daily-translation',
|
|
180
|
+
headers := jsonb_build_object('Authorization', 'Bearer __SUPABASE_ANON_KEY__')
|
|
181
|
+
);
|
|
182
|
+
$cron$
|
|
183
|
+
);
|
|
184
|
+
exception when others then
|
|
185
|
+
if sqlerrm like '%already exists%' then
|
|
186
|
+
raise notice 'cron job community-translation-sweep already scheduled, skipping';
|
|
187
|
+
else
|
|
188
|
+
raise;
|
|
189
|
+
end if;
|
|
190
|
+
end $$;
|