@rocapine/community 0.2.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.
@@ -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,19 @@ Deno.serve(async (req) => {
59
60
  .eq("id", comment.author_id)
60
61
  .single();
61
62
  const name = actor?.username?.trim() || copy.fallbackName;
62
- const excerpt =
63
- comment.content.length > 140 ? `${comment.content.slice(0, 137)}...` : comment.content;
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;
64
76
  // Title states the event; body is the commenter's name then the comment in
65
77
  // quotes, e.g. Marie: "So happy for you!".
66
78
  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 $$;