@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.
@@ -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 a built-in neutral
9
- // template for the common single-reactor case (COMMUNITY_REACTION_PUSH_TEXT
10
- // overrides that template for every locale, `{name}` substituted with the
11
- // reactor's display name — an app with its own reaction semantics sets its
12
- // own copy there). The multi-reactor case always uses the locale's own
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
- // NOTE: profiles.locale is not part of the SDK's core schema yet (task 16) —
17
- // it must exist before this function can run. See the internal port notes
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 { FALLBACK_ACTOR_NAME, REACTION_PUSH_TEXT } from "../_shared/config.ts";
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 = copyFor(recipient?.locale);
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
- // COMMUNITY_FALLBACK_NAME (the deployer-set env var, shared with
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
- ? REACTION_PUSH_TEXT.replace("{name}", name)
149
- : copy.many(name, contributors.length - 1);
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: ReportRecord };
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", record.reported_user_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", record.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 (*${record.reason}*) against *${reported?.username ?? record.reported_user_id}*, ${count ?? "?"} report(s) total\n` +
33
- `${record.details ? `> ${record.details}\n` : ""}` +
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 $$;