@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 CHANGED
@@ -61,6 +61,11 @@ warning** — review the diff before deploying. `--project-url`/`--anon-key`
61
61
  are only needed if a newly-added migration carries a placeholder. Fails if
62
62
  `community-sdk.json` doesn't exist yet (run `init` or `adopt` first).
63
63
 
64
+ `--add-modules <modules>` installs one or more modules not yet in
65
+ `community-sdk.json` on top of the existing backend (comma-separated, same
66
+ parsing as `init --modules`) — e.g. `upgrade --add-modules translation` to
67
+ add the translation module to an already-initialized app.
68
+
64
69
  ## `adopt`
65
70
 
66
71
  ```bash
@@ -110,7 +110,7 @@ async function runInit(options = {}) {
110
110
  if (!fs.existsSync(srcDir))
111
111
  continue;
112
112
  const destDir = path.join(functionsDestRoot, fnName);
113
- fs.cpSync(srcDir, destDir, { recursive: true });
113
+ fs.cpSync(srcDir, destDir, { recursive: true, filter: install_shared_1.isShippedFunctionFile });
114
114
  writtenPaths.push(...(0, install_shared_1.listFilesRecursive)(destDir));
115
115
  }
116
116
  }
@@ -146,7 +146,10 @@ function printNextSteps(log, modules) {
146
146
  log(" 2. Set Edge Function secrets: supabase secrets set OPENAI_API_KEY=...");
147
147
  log(" optional: SLACK_WEBHOOK_URL, COMMUNITY_APP_NAME, COMMUNITY_FALLBACK_NAME");
148
148
  if (modules.includes("push")) {
149
- log(" push module: EXPO_ACCESS_TOKEN (required for notify-like/notify-comment)");
149
+ log(" push module: EXPO_ACCESS_TOKEN (only if your Expo project enforces Enhanced Security for push); COMMUNITY_PUSH_COPY (optional per-locale copy)");
150
+ }
151
+ if (modules.includes("translation")) {
152
+ log(' translation module: COMMUNITY_TRANSLATION_LOCALES="en,es-419,..." (required, same list as modules.translation.locales in the app); optional: COMMUNITY_TRANSLATION_MODEL, COMMUNITY_TRANSLATION_STYLE');
150
153
  }
151
154
  log(" 3. Deploy the Edge Functions: supabase functions deploy");
152
155
  }
@@ -9,6 +9,8 @@ export interface UpgradeOptions {
9
9
  cwd?: string;
10
10
  /** Source of the migrations/ + functions/ trees. Defaults to the shipped templates. */
11
11
  templatesDir?: string;
12
+ /** Module names to install on top of what's already in the manifest — validated, ordered and merged via resolveModules, same rules as `init --modules` (unknown names throw, dependency gaps warn). */
13
+ addModules?: string[];
12
14
  /** Used to ask for projectUrl/anonKey when a newly copied migration needs them. */
13
15
  prompt?: (question: string) => Promise<string>;
14
16
  /** Called for non-fatal warnings — most importantly, functions overwritten with new content. */
@@ -23,6 +25,8 @@ export interface UpgradeResult {
23
25
  dir: string;
24
26
  /** True when nothing needed to change — no new migrations, no function drift. */
25
27
  upToDate: boolean;
28
+ /** Modules newly added by `addModules` (not already in the manifest). Empty when `addModules` was omitted or every named module was already installed. */
29
+ addedModules: string[];
26
30
  /** Relative (to `cwd`) paths of newly copied migration files, in copy order. */
27
31
  addedMigrations: string[];
28
32
  /** Function names copied because they weren't present on disk yet. */
@@ -54,10 +54,15 @@ async function runUpgrade(options = {}) {
54
54
  const dir = (0, install_shared_1.resolveContainedDir)(cwd, dirRel);
55
55
  const templatesDir = options.templatesDir ?? (0, install_shared_1.defaultTemplatesDir)();
56
56
  const { migrationsSrcRoot, functionsSrcRoot } = (0, install_shared_1.resolveTemplateRoots)(templatesDir);
57
- // Only modules the app actually has installed are diffed/re-synced —
58
- // upgrade never adds or removes modules (that's what re-running init on a
59
- // fresh dir, or a future dedicated command, would be for).
60
- const canonicalModules = install_shared_1.MODULE_ORDER.filter((m) => manifest.modules.includes(m));
57
+ // By default only modules the app already has installed are diffed/re-synced.
58
+ // `addModules` extends that list through the same validation/ordering/warning
59
+ // rules `init --modules` uses; left empty/undefined, behaviour here must stay
60
+ // byte-identical to the pre-`addModules` path (no resolveModules call, no
61
+ // spurious warnings for e.g. an existing inbox-without-reaction install).
62
+ const canonicalModules = options.addModules && options.addModules.length > 0
63
+ ? (0, install_shared_1.resolveModules)([...manifest.modules, ...options.addModules], onWarn)
64
+ : install_shared_1.MODULE_ORDER.filter((m) => manifest.modules.includes(m));
65
+ const addedModules = canonicalModules.filter((m) => !manifest.modules.includes(m));
61
66
  const installedTemplateIds = new Set(manifest.installedTemplates ?? reconstructInstalledTemplateIds(manifest.installedFiles));
62
67
  const pending = [];
63
68
  for (const moduleName of canonicalModules) {
@@ -101,6 +106,7 @@ async function runUpgrade(options = {}) {
101
106
  manifest,
102
107
  dir,
103
108
  upToDate: true,
109
+ addedModules,
104
110
  addedMigrations: [],
105
111
  newFunctions: [],
106
112
  overwrittenFunctions: [],
@@ -162,7 +168,10 @@ async function runUpgrade(options = {}) {
162
168
  }
163
169
  for (const fnName of newFunctions) {
164
170
  const destDir = path.join(functionsDestRoot, fnName);
165
- fs.cpSync(path.join(functionsSrcRoot, fnName), destDir, { recursive: true });
171
+ fs.cpSync(path.join(functionsSrcRoot, fnName), destDir, {
172
+ recursive: true,
173
+ filter: install_shared_1.isShippedFunctionFile,
174
+ });
166
175
  createdFunctionDirs.push(destDir);
167
176
  }
168
177
  for (const fnName of changedFunctions) {
@@ -171,7 +180,10 @@ async function runUpgrade(options = {}) {
171
180
  fs.cpSync(destDir, backupDir, { recursive: true });
172
181
  backups.push({ destDir, backupDir });
173
182
  fs.rmSync(destDir, { recursive: true, force: true });
174
- fs.cpSync(path.join(functionsSrcRoot, fnName), destDir, { recursive: true });
183
+ fs.cpSync(path.join(functionsSrcRoot, fnName), destDir, {
184
+ recursive: true,
185
+ filter: install_shared_1.isShippedFunctionFile,
186
+ });
175
187
  }
176
188
  }
177
189
  catch (err) {
@@ -205,12 +217,15 @@ async function runUpgrade(options = {}) {
205
217
  const updatedManifest = {
206
218
  schemaVersion: manifest_1.CURRENT_SCHEMA_VERSION,
207
219
  sdkVersion: (0, install_shared_1.readOwnPackageVersion)(),
208
- modules: manifest.modules,
220
+ // Only --add-modules rewrites the module list; a plain upgrade keeps the
221
+ // manifest's own (order and any entry this CLI doesn't know).
222
+ modules: options.addModules && options.addModules.length > 0 ? canonicalModules : manifest.modules,
209
223
  installedFiles,
210
224
  installedTemplates: [...newInstalledTemplateIds].sort(),
211
225
  };
212
226
  (0, manifest_1.writeManifest)(cwd, updatedManifest);
213
227
  printSummary(log, onWarn, {
228
+ addedModules,
214
229
  addedMigrations,
215
230
  newFunctions,
216
231
  overwrittenFunctions: changedFunctions,
@@ -219,6 +234,7 @@ async function runUpgrade(options = {}) {
219
234
  manifest: updatedManifest,
220
235
  dir,
221
236
  upToDate: false,
237
+ addedModules,
222
238
  addedMigrations,
223
239
  newFunctions,
224
240
  overwrittenFunctions: changedFunctions,
@@ -242,8 +258,11 @@ function reconstructInstalledTemplateIds(installedFiles) {
242
258
  return ids;
243
259
  }
244
260
  function functionDirDiffers(srcDir, destDir) {
245
- const srcRel = new Set((0, install_shared_1.listFilesRecursive)(srcDir).map((f) => path.relative(srcDir, f)));
246
- const destRel = new Set((0, install_shared_1.listFilesRecursive)(destDir).map((f) => path.relative(destDir, f)));
261
+ const rel = (dir) => new Set((0, install_shared_1.listFilesRecursive)(dir)
262
+ .filter(install_shared_1.isShippedFunctionFile)
263
+ .map((f) => path.relative(dir, f)));
264
+ const srcRel = rel(srcDir);
265
+ const destRel = rel(destDir);
247
266
  if (srcRel.size !== destRel.size)
248
267
  return true;
249
268
  for (const rel of srcRel) {
@@ -259,6 +278,9 @@ function functionDirDiffers(srcDir, destDir) {
259
278
  function printSummary(log, onWarn, summary) {
260
279
  log("");
261
280
  log("community-sdk upgraded.");
281
+ if (summary.addedModules.length > 0) {
282
+ log(`Added module(s): ${summary.addedModules.join(", ")}`);
283
+ }
262
284
  if (summary.addedMigrations.length > 0) {
263
285
  log(`Added ${summary.addedMigrations.length} migration(s):`);
264
286
  for (const f of summary.addedMigrations)
@@ -277,4 +299,7 @@ function printSummary(log, onWarn, summary) {
277
299
  if (summary.newFunctions.length > 0 || summary.overwrittenFunctions.length > 0) {
278
300
  log(" 2. Review the function changes, then: supabase functions deploy");
279
301
  }
302
+ if (summary.addedModules.includes("translation")) {
303
+ log(' 3. translation module: COMMUNITY_TRANSLATION_LOCALES="en,es-419,..." (required, same list as modules.translation.locales in the app); optional: COMMUNITY_TRANSLATION_MODEL, COMMUNITY_TRANSLATION_STYLE');
304
+ }
280
305
  }
package/lib/index.js CHANGED
@@ -81,12 +81,14 @@ program
81
81
  program
82
82
  .command("upgrade")
83
83
  .description("Copy any new migrations/Edge Functions shipped since this repo's community-sdk.json was last written, and overwrite changed function code (with a warning)")
84
+ .option("--add-modules <modules>", "comma-separated module list to install on top of what's already in community-sdk.json (e.g. translation)")
84
85
  .option("--project-url <url>", "Supabase project URL, e.g. https://<ref>.supabase.co (only needed if a new migration carries a placeholder)")
85
86
  .option("--anon-key <key>", "Supabase anon key (only needed if a new migration carries a placeholder)")
86
87
  .option("--dir <dir>", "target Supabase directory, relative to the current directory", "supabase")
87
88
  .action(async (opts) => {
88
89
  try {
89
90
  await (0, upgrade_1.runUpgrade)({
91
+ addModules: parseModulesFlag(opts.addModules),
90
92
  projectUrl: opts.projectUrl,
91
93
  anonKey: opts.anonKey,
92
94
  dir: opts.dir,
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Ruled install order (controller ruling, task 18): core -> push -> polls ->
3
- * reaction -> inbox. Reaction MUST land before inbox — inbox/001's reaction
4
- * trigger is guarded with `to_regclass('public.post_reactions')` at install
5
- * time and is never retroactively wired if the reaction module shows up
6
- * later, so `reaction` needs to already exist when `inbox` runs.
3
+ * reaction -> inbox -> translation. Reaction MUST land before inbox —
4
+ * inbox/001's reaction trigger is guarded with
5
+ * `to_regclass('public.post_reactions')` at install time and is never
6
+ * retroactively wired if the reaction module shows up later, so `reaction`
7
+ * needs to already exist when `inbox` runs.
7
8
  *
8
9
  * Shared between `init` (task 18) and `upgrade`/`adopt` (task 19) — the
9
10
  * single source of truth for module names, order, and the function sets
10
11
  * each module pulls in.
11
12
  */
12
- export declare const MODULE_ORDER: readonly ["core", "push", "polls", "reaction", "inbox"];
13
+ export declare const MODULE_ORDER: readonly ["core", "push", "polls", "reaction", "inbox", "translation"];
13
14
  export type Module = (typeof MODULE_ORDER)[number];
14
15
  export declare function functionsForModules(modules: readonly Module[]): string[];
15
16
  /**
@@ -66,6 +67,9 @@ export declare function resolveProjectCredentials(options: {
66
67
  }>;
67
68
  export declare function formatTimestamp(date: Date): string;
68
69
  export declare function addSeconds(date: Date, seconds: number): Date;
70
+ /** Deno test files (`*_test.ts`) stay in the repo: never copied into a host's
71
+ * functions, and ignored when diffing a host's function dir. */
72
+ export declare function isShippedFunctionFile(file: string): boolean;
69
73
  export declare function listFilesRecursive(dir: string): string[];
70
74
  export declare function toRelativePosix(base: string, target: string): string;
71
75
  /** Resolves `dirRel` against `cwd`, throwing if it would escape the project root. */
@@ -46,6 +46,7 @@ exports.deriveProjectUrlFromConfig = deriveProjectUrlFromConfig;
46
46
  exports.resolveProjectCredentials = resolveProjectCredentials;
47
47
  exports.formatTimestamp = formatTimestamp;
48
48
  exports.addSeconds = addSeconds;
49
+ exports.isShippedFunctionFile = isShippedFunctionFile;
49
50
  exports.listFilesRecursive = listFilesRecursive;
50
51
  exports.toRelativePosix = toRelativePosix;
51
52
  exports.resolveContainedDir = resolveContainedDir;
@@ -54,16 +55,17 @@ const path = __importStar(require("node:path"));
54
55
  const promises_1 = require("node:readline/promises");
55
56
  /**
56
57
  * Ruled install order (controller ruling, task 18): core -> push -> polls ->
57
- * reaction -> inbox. Reaction MUST land before inbox — inbox/001's reaction
58
- * trigger is guarded with `to_regclass('public.post_reactions')` at install
59
- * time and is never retroactively wired if the reaction module shows up
60
- * later, so `reaction` needs to already exist when `inbox` runs.
58
+ * reaction -> inbox -> translation. Reaction MUST land before inbox —
59
+ * inbox/001's reaction trigger is guarded with
60
+ * `to_regclass('public.post_reactions')` at install time and is never
61
+ * retroactively wired if the reaction module shows up later, so `reaction`
62
+ * needs to already exist when `inbox` runs.
61
63
  *
62
64
  * Shared between `init` (task 18) and `upgrade`/`adopt` (task 19) — the
63
65
  * single source of truth for module names, order, and the function sets
64
66
  * each module pulls in.
65
67
  */
66
- exports.MODULE_ORDER = ["core", "push", "polls", "reaction", "inbox"];
68
+ exports.MODULE_ORDER = ["core", "push", "polls", "reaction", "inbox", "translation"];
67
69
  const ALWAYS_FUNCTIONS = [
68
70
  "moderate-one",
69
71
  "daily-moderation",
@@ -73,6 +75,7 @@ const ALWAYS_FUNCTIONS = [
73
75
  ];
74
76
  const PUSH_FUNCTIONS = ["notify-like", "notify-comment", "broadcast-post"];
75
77
  const REACTION_FUNCTIONS = ["notify-reaction"];
78
+ const TRANSLATION_FUNCTIONS = ["translate-one", "daily-translation"];
76
79
  function functionsForModules(modules) {
77
80
  const set = new Set(ALWAYS_FUNCTIONS);
78
81
  if (modules.includes("push"))
@@ -81,6 +84,9 @@ function functionsForModules(modules) {
81
84
  if (modules.includes("reaction"))
82
85
  for (const f of REACTION_FUNCTIONS)
83
86
  set.add(f);
87
+ if (modules.includes("translation"))
88
+ for (const f of TRANSLATION_FUNCTIONS)
89
+ set.add(f);
84
90
  return [...set];
85
91
  }
86
92
  /**
@@ -102,6 +108,9 @@ function resolveModules(requested, onWarn) {
102
108
  if (canonicalModules.includes("inbox") && !canonicalModules.includes("reaction")) {
103
109
  onWarn("community-sdk: module 'inbox' selected without 'reaction' — reaction notifications won't appear in the inbox until the reaction module is also installed.");
104
110
  }
111
+ if (canonicalModules.includes("translation") && !canonicalModules.includes("polls")) {
112
+ onWarn("community-sdk: module 'translation' selected without 'polls' — poll option labels won't be translated (posts and comments are).");
113
+ }
105
114
  return canonicalModules;
106
115
  }
107
116
  /**
@@ -228,6 +237,11 @@ function formatTimestamp(date) {
228
237
  function addSeconds(date, seconds) {
229
238
  return new Date(date.getTime() + seconds * 1000);
230
239
  }
240
+ /** Deno test files (`*_test.ts`) stay in the repo: never copied into a host's
241
+ * functions, and ignored when diffing a host's function dir. */
242
+ function isShippedFunctionFile(file) {
243
+ return !file.endsWith("_test.ts");
244
+ }
231
245
  function listFilesRecursive(dir) {
232
246
  const entries = fs.readdirSync(dir, { withFileTypes: true });
233
247
  const files = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "CLI to install, upgrade and adopt the Rocapine Community SDK's Supabase backend (migrations, Edge Functions) into a project.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,15 +1,48 @@
1
- // Shared Supabase admin client + JSON response helper.
1
+ // Shared Supabase admin client, caller identity helpers + JSON response helper.
2
2
 
3
3
  import { createClient } from "jsr:@supabase/supabase-js@2";
4
4
 
5
5
  export const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
6
6
 
7
7
  // Service-role client: bypasses RLS. Every function does privileged work
8
- // through this; callers only ever pass the public anon JWT (verify_jwt).
8
+ // through this; callers only ever pass the public anon JWT (verify_jwt) or a
9
+ // user JWT.
9
10
  export function adminClient() {
10
11
  return createClient(SUPABASE_URL, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
11
12
  }
12
13
 
14
+ // The gateway (verify_jwt) has already validated the bearer's signature, so
15
+ // only the role claim needs checking here. An exact string match against the
16
+ // injected SUPABASE_SERVICE_ROLE_KEY broke when a project migrated to the new
17
+ // API keys: the runtime then receives the sb_secret_* value while callers
18
+ // still hold the legacy service_role JWT. Accept either proof.
19
+ export function isServiceCaller(req: Request): boolean {
20
+ const bearer = (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
21
+ if (bearer && bearer === Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")) return true;
22
+ try {
23
+ const payload = JSON.parse(
24
+ atob(bearer.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
25
+ ) as { role?: string };
26
+ return payload.role === "service_role";
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ // The user behind the request's bearer, or null for an anon-key-only call
33
+ // (or an invalid/expired user JWT). Resolved through Auth so a forged `sub`
34
+ // claim can't be smuggled in with a plain anon token.
35
+ export async function callerUserId(req: Request): Promise<string | null> {
36
+ const authorization = req.headers.get("authorization");
37
+ if (!authorization) return null;
38
+ const client = createClient(SUPABASE_URL, Deno.env.get("SUPABASE_ANON_KEY")!, {
39
+ global: { headers: { Authorization: authorization } },
40
+ });
41
+ const { data, error } = await client.auth.getUser();
42
+ if (error || !data.user) return null;
43
+ return data.user.id;
44
+ }
45
+
13
46
  // The client consumes responses via supabase.functions.invoke, which only
14
47
  // parses the body as JSON when the Content-Type says so. Without this header
15
48
  // invoke returns a raw string and verdict.status is undefined.
@@ -1,23 +1,7 @@
1
- // Per-app copy used inside notifications, injected as function secrets so the
2
- // same function code deploys unchanged to every app:
3
- //
4
- // supabase secrets set COMMUNITY_FALLBACK_NAME="A member"
5
- // supabase secrets set COMMUNITY_APP_NAME="My App"
6
- // supabase secrets set COMMUNITY_REACTION_PUSH_TEXT="{name} sent you support"
7
- //
8
- // Defaults are deliberately neutral.
9
-
10
- // Display name used when the acting user has no username yet
11
- // ("<name>: ..." in comment pushes, "<name> liked your post").
12
- export const FALLBACK_ACTOR_NAME = Deno.env.get("COMMUNITY_FALLBACK_NAME") ?? "Someone";
1
+ // Per-app copy injected as function secrets so the same function code
2
+ // deploys unchanged to every app. Push copy (actor fallback name, like /
3
+ // comment / reaction titles) lives in _shared/copy.ts (COMMUNITY_PUSH_COPY).
13
4
 
14
5
  // Push title fallback for official broadcast posts whose author has no
15
6
  // username (normally the official account's display name).
16
7
  export const BROADCAST_FALLBACK_TITLE = Deno.env.get("COMMUNITY_APP_NAME") ?? "Community";
17
-
18
- // notify-reaction's single-reactor push title template. `{name}` is
19
- // substituted with the reactor's display name (or FALLBACK_ACTOR_NAME).
20
- // Neutral default; an app with its own reaction semantics overrides this
21
- // secret rather than the function code.
22
- export const REACTION_PUSH_TEXT =
23
- Deno.env.get("COMMUNITY_REACTION_PUSH_TEXT") ?? "{name} is thinking of you";
@@ -0,0 +1,247 @@
1
+ // Locale-aware push copy for notify-comment / notify-like / notify-reaction.
2
+ //
3
+ // Every string below is a neutral built-in for the 9 locales the UI package
4
+ // ships. An app with its own voice overrides ANY of them — per locale, per
5
+ // key — through ONE JSON secret instead of forking the function bodies (the
6
+ // way one host app had to before this existed):
7
+ //
8
+ // supabase secrets set COMMUNITY_PUSH_COPY='{
9
+ // "fallbackName": { "en": "A sister", "pt-PT": "Uma irmã" },
10
+ // "comment.title": { "pl": "Otrzymałaś komentarz" },
11
+ // "like.one": { "pl": "{name} polubiła twój post" },
12
+ // "reaction.one": { "en": "{name} prayed for you 🙏" },
13
+ // "reaction.many": { "en": "{name} and {count} others prayed for you 🙏",
14
+ // "pl": { "one": "…", "few": "…", "many": "…" } }
15
+ // }'
16
+ //
17
+ // Placeholders: `{name}` (actor display name) and `{count}` (the number of
18
+ // OTHER people in a coalesced push). A `*.many` value is either a string or a
19
+ // `{ one, few, many, other }` object picked by the CLDR plural category of
20
+ // `{count}` for the recipient's locale (Polish needs all three; French treats
21
+ // 0 like 1; everything else is one/other).
22
+ //
23
+ // Legacy single-string secrets still apply, below the JSON and above the
24
+ // built-ins: COMMUNITY_FALLBACK_NAME (every locale's fallbackName) and
25
+ // COMMUNITY_REACTION_PUSH_TEXT (every locale's reaction.one).
26
+
27
+ type Forms = { one?: string; few?: string; many?: string; other?: string };
28
+ type CopyValue = string | Forms;
29
+ type LocaleCopy = Record<string, CopyValue>;
30
+
31
+ export type PushCopyKey =
32
+ "fallbackName" | "comment.title" | "like.one" | "like.many" | "reaction.one" | "reaction.many";
33
+
34
+ const BUILTIN: Record<string, LocaleCopy> = {
35
+ en: {
36
+ fallbackName: "Someone",
37
+ "comment.title": "You received a comment",
38
+ "like.one": "{name} liked your post",
39
+ "like.many": {
40
+ one: "{name} and {count} other liked your post",
41
+ other: "{name} and {count} others liked your post",
42
+ },
43
+ "reaction.one": "{name} is thinking of you",
44
+ "reaction.many": {
45
+ one: "{name} and {count} other are thinking of you",
46
+ other: "{name} and {count} others are thinking of you",
47
+ },
48
+ },
49
+ "pt-PT": {
50
+ fallbackName: "Alguém",
51
+ "comment.title": "Recebeste um comentário",
52
+ "like.one": "{name} gostou da tua publicação",
53
+ "like.many": {
54
+ one: "{name} e mais {count} pessoa gostou da tua publicação",
55
+ other: "{name} e mais {count} pessoas gostaram da tua publicação",
56
+ },
57
+ "reaction.one": "{name} está a pensar em ti",
58
+ "reaction.many": {
59
+ one: "{name} e mais {count} pessoa está a pensar em ti",
60
+ other: "{name} e mais {count} pessoas estão a pensar em ti",
61
+ },
62
+ },
63
+ "pt-BR": {
64
+ fallbackName: "Alguém",
65
+ "comment.title": "Você recebeu um comentário",
66
+ "like.one": "{name} curtiu seu post",
67
+ "like.many": {
68
+ one: "{name} e mais {count} pessoa curtiu seu post",
69
+ other: "{name} e mais {count} pessoas curtiram seu post",
70
+ },
71
+ "reaction.one": "{name} está pensando em você",
72
+ "reaction.many": {
73
+ one: "{name} e mais {count} pessoa está pensando em você",
74
+ other: "{name} e mais {count} pessoas estão pensando em você",
75
+ },
76
+ },
77
+ "es-ES": {
78
+ fallbackName: "Alguien",
79
+ "comment.title": "Has recibido un comentario",
80
+ "like.one": "A {name} le ha gustado tu publicación",
81
+ "like.many": {
82
+ one: "A {name} y a {count} persona más les ha gustado tu publicación",
83
+ other: "A {name} y a {count} personas más les ha gustado tu publicación",
84
+ },
85
+ "reaction.one": "{name} está pensando en ti",
86
+ "reaction.many": {
87
+ one: "{name} y {count} persona más está pensando en ti",
88
+ other: "{name} y {count} personas más están pensando en ti",
89
+ },
90
+ },
91
+ "es-419": {
92
+ fallbackName: "Alguien",
93
+ "comment.title": "Recibiste un comentario",
94
+ "like.one": "A {name} le gustó tu publicación",
95
+ "like.many": {
96
+ one: "A {name} y a {count} persona más les gustó tu publicación",
97
+ other: "A {name} y a {count} personas más les gustó tu publicación",
98
+ },
99
+ "reaction.one": "{name} está pensando en ti",
100
+ "reaction.many": {
101
+ one: "{name} y {count} persona más está pensando en ti",
102
+ other: "{name} y {count} personas más están pensando en ti",
103
+ },
104
+ },
105
+ it: {
106
+ fallbackName: "Qualcuno",
107
+ "comment.title": "Hai ricevuto un commento",
108
+ "like.one": "A {name} piace il tuo post",
109
+ "like.many": {
110
+ one: "A {name} e ad altra {count} persona piace il tuo post",
111
+ other: "A {name} e ad altre {count} persone piace il tuo post",
112
+ },
113
+ "reaction.one": "{name} sta pensando a te",
114
+ "reaction.many": {
115
+ one: "{name} e altra {count} persona sta pensando a te",
116
+ other: "{name} e altre {count} persone stanno pensando a te",
117
+ },
118
+ },
119
+ pl: {
120
+ fallbackName: "Ktoś",
121
+ "comment.title": "Masz nowy komentarz",
122
+ "like.one": "{name} polubił(a) twój post",
123
+ "like.many": {
124
+ one: "{name} i {count} inna osoba polubiły twój post",
125
+ few: "{name} i {count} inne osoby polubiły twój post",
126
+ many: "{name} i {count} innych osób polubiło twój post",
127
+ },
128
+ "reaction.one": "{name} myśli o tobie",
129
+ "reaction.many": {
130
+ one: "{name} i {count} inna osoba myśli o tobie",
131
+ few: "{name} i {count} inne osoby myślą o tobie",
132
+ many: "{name} i {count} innych osób myśli o tobie",
133
+ },
134
+ },
135
+ fr: {
136
+ fallbackName: "Quelqu'un",
137
+ "comment.title": "Tu as reçu un commentaire",
138
+ "like.one": "{name} a aimé ta publication",
139
+ "like.many": {
140
+ one: "{name} et {count} autre personne ont aimé ta publication",
141
+ other: "{name} et {count} autres personnes ont aimé ta publication",
142
+ },
143
+ "reaction.one": "{name} pense à toi",
144
+ "reaction.many": {
145
+ one: "{name} et {count} autre personne pensent à toi",
146
+ other: "{name} et {count} autres personnes pensent à toi",
147
+ },
148
+ },
149
+ de: {
150
+ fallbackName: "Jemand",
151
+ "comment.title": "Du hast einen Kommentar erhalten",
152
+ "like.one": "{name} gefällt dein Beitrag",
153
+ "like.many": {
154
+ one: "{name} und {count} weiterer Person gefällt dein Beitrag",
155
+ other: "{name} und {count} weiteren Personen gefällt dein Beitrag",
156
+ },
157
+ "reaction.one": "{name} denkt an dich",
158
+ "reaction.many": {
159
+ one: "{name} und {count} weitere Person denken an dich",
160
+ other: "{name} und {count} weitere Personen denken an dich",
161
+ },
162
+ },
163
+ };
164
+
165
+ // Same rule set as the UI package's `pluralCategory` (packages/ui/src/i18n.ts).
166
+ export function pluralCategory(locale: string, n: number): keyof Forms {
167
+ const lang = locale.split("-")[0]?.toLowerCase();
168
+ if (lang === "pl") {
169
+ if (n === 1) return "one";
170
+ const m10 = n % 10;
171
+ const m100 = n % 100;
172
+ if (m10 >= 2 && m10 <= 4 && !(m100 >= 12 && m100 <= 14)) return "few";
173
+ return "many";
174
+ }
175
+ if (lang === "fr") return n === 0 || n === 1 ? "one" : "other";
176
+ return n === 1 ? "one" : "other";
177
+ }
178
+
179
+ function parseOverrides(): Record<string, Record<string, CopyValue>> {
180
+ const raw = Deno.env.get("COMMUNITY_PUSH_COPY");
181
+ if (!raw) return {};
182
+ try {
183
+ const parsed = JSON.parse(raw);
184
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
185
+ } catch (e) {
186
+ console.error("community-sdk: COMMUNITY_PUSH_COPY is not valid JSON, ignoring it", e);
187
+ return {};
188
+ }
189
+ }
190
+
191
+ const OVERRIDES = parseOverrides();
192
+ const LEGACY_FALLBACK_NAME = Deno.env.get("COMMUNITY_FALLBACK_NAME");
193
+ const LEGACY_REACTION_ONE = Deno.env.get("COMMUNITY_REACTION_PUSH_TEXT");
194
+
195
+ /** `es-MX` → `es-ES`, `pt-AO` → `pt-PT`, `fr-CA` → `fr`; unknown → `en`. */
196
+ function resolveLocale(locale: string | null | undefined): string {
197
+ if (!locale) return "en";
198
+ if (BUILTIN[locale]) return locale;
199
+ const lang = locale.split("-")[0].toLowerCase();
200
+ if (BUILTIN[lang]) return lang;
201
+ if (lang === "es") return "es-ES";
202
+ if (lang === "pt") return "pt-PT";
203
+ return "en";
204
+ }
205
+
206
+ function pick(key: PushCopyKey, locale: string, exact: string | null | undefined): CopyValue {
207
+ const overrideByLocale = OVERRIDES[key] ?? {};
208
+ // Override lookup: exact locale as sent by the client, then the resolved one.
209
+ const override =
210
+ (exact ? overrideByLocale[exact] : undefined) ??
211
+ overrideByLocale[locale] ??
212
+ overrideByLocale[locale.split("-")[0]];
213
+ if (override !== undefined) return override;
214
+ if (key === "fallbackName" && LEGACY_FALLBACK_NAME) return LEGACY_FALLBACK_NAME;
215
+ if (key === "reaction.one" && LEGACY_REACTION_ONE) return LEGACY_REACTION_ONE;
216
+ return BUILTIN[locale][key] ?? BUILTIN.en[key];
217
+ }
218
+
219
+ function fill(template: string, params: { name?: string; count?: number }): string {
220
+ return template
221
+ .replace(/\{name\}/g, params.name ?? "")
222
+ .replace(/\{count\}/g, params.count === undefined ? "" : String(params.count));
223
+ }
224
+
225
+ /** Copy resolver for one recipient (`profiles.locale`). */
226
+ export function pushCopy(recipientLocale: string | null | undefined) {
227
+ const locale = resolveLocale(recipientLocale);
228
+ const text = (key: PushCopyKey, params: { name?: string; count?: number } = {}): string => {
229
+ const value = pick(key, locale, recipientLocale);
230
+ if (typeof value === "string") return fill(value, params);
231
+ const category = pluralCategory(locale, params.count ?? 0);
232
+ const form =
233
+ value[category] ??
234
+ value.other ??
235
+ value.many ??
236
+ value.one ??
237
+ // A partial override object: fall through to the built-in forms.
238
+ (() => {
239
+ const builtin = BUILTIN[locale][key] ?? BUILTIN.en[key];
240
+ return typeof builtin === "string"
241
+ ? builtin
242
+ : (builtin[category] ?? builtin.other ?? builtin.many ?? builtin.one ?? "");
243
+ })();
244
+ return fill(form, params);
245
+ };
246
+ return { locale, fallbackName: text("fallbackName"), text };
247
+ }