@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
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
|
package/lib/commands/init.js
CHANGED
|
@@ -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 (
|
|
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. */
|
package/lib/commands/upgrade.js
CHANGED
|
@@ -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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
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, {
|
|
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, {
|
|
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
|
|
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
|
|
246
|
-
|
|
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,
|
package/lib/install-shared.d.ts
CHANGED
|
@@ -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 —
|
|
4
|
-
* trigger is guarded with
|
|
5
|
-
* time and is never
|
|
6
|
-
*
|
|
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. */
|
package/lib/install-shared.js
CHANGED
|
@@ -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 —
|
|
58
|
-
* trigger is guarded with
|
|
59
|
-
* time and is never
|
|
60
|
-
*
|
|
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,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
|
|
2
|
-
//
|
|
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
|
+
}
|