@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.
- package/README.md +5 -0
- package/lib/commands/init.js +5 -2
- 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 +35 -2
- package/templates/functions/_shared/config.ts +3 -19
- package/templates/functions/_shared/copy.ts +247 -0
- package/templates/functions/_shared/translation.ts +389 -0
- package/templates/functions/broadcast-post/index.ts +28 -29
- package/templates/functions/daily-moderation/index.ts +176 -45
- package/templates/functions/daily-translation/index.ts +120 -0
- package/templates/functions/deno.json +1 -0
- package/templates/functions/moderate-one/index.ts +33 -8
- package/templates/functions/notify-comment/index.ts +45 -20
- package/templates/functions/notify-like/index.ts +19 -5
- package/templates/functions/notify-reaction/index.ts +12 -84
- package/templates/functions/report-to-slack/index.ts +17 -11
- 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
|
@@ -5,90 +5,22 @@
|
|
|
5
5
|
// sweep posts with unnotified reactions older than the cooldown. Never
|
|
6
6
|
// includes any reaction content — the reaction table carries no text.
|
|
7
7
|
//
|
|
8
|
-
// Push copy: the recipient's profiles.locale selects
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// "and N others" phrasing; there is no secret for it, only the built-in
|
|
14
|
-
// translations below.
|
|
8
|
+
// Push copy: the recipient's profiles.locale selects the built-in neutral
|
|
9
|
+
// copy ("{name} is thinking of you" / "… and N others …") — an app with its
|
|
10
|
+
// own reaction semantics overrides it per locale with the COMMUNITY_PUSH_COPY
|
|
11
|
+
// secret (see _shared/copy.ts; the older COMMUNITY_REACTION_PUSH_TEXT single
|
|
12
|
+
// string still works for the single-reactor case).
|
|
15
13
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// for a reference migration adding it.
|
|
14
|
+
// Security: same as notify-like — only `record.post_id` is read from the
|
|
15
|
+
// untrusted body; everything else comes from post_reactions.
|
|
19
16
|
|
|
20
17
|
import { adminClient } from "../_shared/client.ts";
|
|
21
18
|
import { sendExpoPush } from "../_shared/push.ts";
|
|
22
|
-
import {
|
|
19
|
+
import { pushCopy } from "../_shared/copy.ts";
|
|
23
20
|
|
|
24
21
|
const supabase = adminClient();
|
|
25
22
|
const COOLDOWN_MS = 10 * 60 * 1000;
|
|
26
23
|
|
|
27
|
-
/**
|
|
28
|
-
* CLDR plural form for a count: 1 -> `one`, 2-4 -> `few`, 5+ -> `many` (the
|
|
29
|
-
* Polish rule, minus the fractional `other` case a push count never hits).
|
|
30
|
-
* Every other locale below needs only a singular/plural pair.
|
|
31
|
-
*/
|
|
32
|
-
function plForm(n: number, one: string, few: string, many: string) {
|
|
33
|
-
if (n === 1) return one;
|
|
34
|
-
const lastTwo = n % 100;
|
|
35
|
-
const inTeens = lastTwo >= 10 && lastTwo < 20;
|
|
36
|
-
return !inTeens && n % 10 >= 2 && n % 10 <= 4 ? few : many;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Built-in neutral push copy per recipient locale (profiles.locale). Keys
|
|
40
|
-
// mirror SupportedLanguage in host apps; unknown locales fall back to en.
|
|
41
|
-
// `one` is only used when COMMUNITY_REACTION_PUSH_TEXT is unset (its default
|
|
42
|
-
// already matches the `en` entry below).
|
|
43
|
-
const COPY = {
|
|
44
|
-
en: {
|
|
45
|
-
fallbackName: "Someone",
|
|
46
|
-
one: (name: string) => `${name} is thinking of you`,
|
|
47
|
-
many: (name: string, extra: number) =>
|
|
48
|
-
`${name} and ${extra} ${extra === 1 ? "other" : "others"} are thinking of you`,
|
|
49
|
-
},
|
|
50
|
-
"pt-PT": {
|
|
51
|
-
fallbackName: "Alguém",
|
|
52
|
-
one: (name: string) => `${name} está a pensar em ti`,
|
|
53
|
-
many: (name: string, extra: number) =>
|
|
54
|
-
`${name} e mais ${extra} ${extra === 1 ? "pessoa está" : "pessoas estão"} a pensar em ti`,
|
|
55
|
-
},
|
|
56
|
-
"pt-BR": {
|
|
57
|
-
fallbackName: "Alguém",
|
|
58
|
-
one: (name: string) => `${name} está pensando em você`,
|
|
59
|
-
many: (name: string, extra: number) =>
|
|
60
|
-
`${name} e mais ${extra} ${extra === 1 ? "pessoa está" : "pessoas estão"} pensando em você`,
|
|
61
|
-
},
|
|
62
|
-
"es-ES": {
|
|
63
|
-
fallbackName: "Alguien",
|
|
64
|
-
one: (name: string) => `${name} está pensando en ti`,
|
|
65
|
-
many: (name: string, extra: number) =>
|
|
66
|
-
`${name} y ${extra} ${extra === 1 ? "persona más está" : "personas más están"} pensando en ti`,
|
|
67
|
-
},
|
|
68
|
-
"es-419": {
|
|
69
|
-
fallbackName: "Alguien",
|
|
70
|
-
one: (name: string) => `${name} está pensando en ti`,
|
|
71
|
-
many: (name: string, extra: number) =>
|
|
72
|
-
`${name} y ${extra} ${extra === 1 ? "persona más está" : "personas más están"} pensando en ti`,
|
|
73
|
-
},
|
|
74
|
-
it: {
|
|
75
|
-
fallbackName: "Qualcuno",
|
|
76
|
-
one: (name: string) => `${name} sta pensando a te`,
|
|
77
|
-
many: (name: string, extra: number) =>
|
|
78
|
-
`${name} e altre ${extra} ${extra === 1 ? "persona sta" : "persone stanno"} pensando a te`,
|
|
79
|
-
},
|
|
80
|
-
pl: {
|
|
81
|
-
fallbackName: "Ktoś",
|
|
82
|
-
one: (name: string) => `${name} myśli o tobie`,
|
|
83
|
-
many: (name: string, extra: number) =>
|
|
84
|
-
`${name} i ${plForm(extra, `${extra} inna osoba myśli`, `${extra} inne osoby myślą`, `${extra} innych osób myśli`)} o tobie`,
|
|
85
|
-
},
|
|
86
|
-
} as const;
|
|
87
|
-
|
|
88
|
-
function copyFor(locale: string | null | undefined) {
|
|
89
|
-
return COPY[(locale ?? "en") as keyof typeof COPY] ?? COPY.en;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
24
|
async function flushPost(postId: string) {
|
|
93
25
|
const { data: post } = await supabase.from("posts").select("author_id").eq("id", postId).single();
|
|
94
26
|
if (!post) return;
|
|
@@ -98,7 +30,7 @@ async function flushPost(postId: string) {
|
|
|
98
30
|
.select("locale")
|
|
99
31
|
.eq("id", post.author_id)
|
|
100
32
|
.single();
|
|
101
|
-
const copy =
|
|
33
|
+
const copy = pushCopy(recipient?.locale);
|
|
102
34
|
|
|
103
35
|
const { data: pref } = await supabase
|
|
104
36
|
.from("push_tokens")
|
|
@@ -138,15 +70,11 @@ async function flushPost(postId: string) {
|
|
|
138
70
|
.select("username")
|
|
139
71
|
.eq("id", contributors[0].user_id)
|
|
140
72
|
.single();
|
|
141
|
-
|
|
142
|
-
// notify-like/notify-comment and meant to match the client's
|
|
143
|
-
// anonymousAuthorFallback — see docs/backend-runbook.md) takes precedence
|
|
144
|
-
// over the locale table's own fallbackName default.
|
|
145
|
-
const name = actor?.username?.trim() || FALLBACK_ACTOR_NAME || copy.fallbackName;
|
|
73
|
+
const name = actor?.username?.trim() || copy.fallbackName;
|
|
146
74
|
const title =
|
|
147
75
|
contributors.length === 1
|
|
148
|
-
?
|
|
149
|
-
: copy.many
|
|
76
|
+
? copy.text("reaction.one", { name })
|
|
77
|
+
: copy.text("reaction.many", { name, count: contributors.length - 1 });
|
|
150
78
|
|
|
151
79
|
await sendExpoPush({
|
|
152
80
|
to: pref.expo_push_token,
|
|
@@ -2,35 +2,41 @@
|
|
|
2
2
|
// record here; we enrich it with the reported account's identity and its
|
|
3
3
|
// cumulative report count, then alert Slack (no-op if SLACK_WEBHOOK_URL is
|
|
4
4
|
// unset — see _shared/slack.ts). Bans stay manual (dashboard).
|
|
5
|
+
//
|
|
6
|
+
// Security: reachable with the anon key, so only `record.id` is trusted; the
|
|
7
|
+
// report row is re-read from the database (a forged body can't inject text
|
|
8
|
+
// into the ops channel or probe another user's report count).
|
|
5
9
|
|
|
6
10
|
import { adminClient } from "../_shared/client.ts";
|
|
7
11
|
import { postToSlack } from "../_shared/slack.ts";
|
|
8
12
|
|
|
9
13
|
const supabase = adminClient();
|
|
10
14
|
|
|
11
|
-
interface ReportRecord {
|
|
12
|
-
reported_user_id: string;
|
|
13
|
-
reason: string;
|
|
14
|
-
details: string | null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
15
|
Deno.serve(async (req) => {
|
|
18
|
-
const { record } = (await req.json()) as { record
|
|
16
|
+
const { record } = (await req.json().catch(() => ({}))) as { record?: { id?: string } };
|
|
17
|
+
if (!record?.id) return new Response("skip: no id", { status: 400 });
|
|
18
|
+
|
|
19
|
+
const { data: report } = await supabase
|
|
20
|
+
.from("reports")
|
|
21
|
+
.select("id, reported_user_id, reason, details")
|
|
22
|
+
.eq("id", record.id)
|
|
23
|
+
.single();
|
|
24
|
+
if (!report) return new Response("skip: unknown report");
|
|
19
25
|
|
|
20
26
|
const { data: reported } = await supabase
|
|
21
27
|
.from("profiles")
|
|
22
28
|
.select("username, amplitude_id, revenuecat_id")
|
|
23
|
-
.eq("id",
|
|
29
|
+
.eq("id", report.reported_user_id)
|
|
24
30
|
.single();
|
|
25
31
|
const { count } = await supabase
|
|
26
32
|
.from("reports")
|
|
27
33
|
.select("*", { count: "exact", head: true })
|
|
28
|
-
.eq("reported_user_id",
|
|
34
|
+
.eq("reported_user_id", report.reported_user_id);
|
|
29
35
|
|
|
30
36
|
await postToSlack({
|
|
31
37
|
text:
|
|
32
|
-
`:rotating_light: Report (*${
|
|
33
|
-
`${
|
|
38
|
+
`:rotating_light: Report (*${report.reason}*) against *${reported?.username ?? report.reported_user_id}*, ${count ?? "?"} report(s) total\n` +
|
|
39
|
+
`${report.details ? `> ${report.details}\n` : ""}` +
|
|
34
40
|
`amplitude: \`${reported?.amplitude_id ?? "?"}\` | revenuecat: \`${reported?.revenuecat_id ?? "?"}\``,
|
|
35
41
|
});
|
|
36
42
|
return new Response("ok");
|
|
@@ -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 $$;
|