@rocapine/community 0.4.1 → 0.8.1

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
@@ -107,17 +107,23 @@ the _current_ template set and points at `docs/compat.md` to check/adjust
107
107
  is what `upgrade` diffs against (timestamped destination filenames can't
108
108
  be compared meaningfully run to run).
109
109
 
110
- ## Placeholder guard
111
-
112
- Three backend migration files (`core/003_moderation.sql`,
113
- `push/002_triggers.sql`, `reaction/001_reactions.sql`) embed the project's
114
- own URL/anon key (pg_net crons and webhooks that call back into the same
115
- project) as `__SUPABASE_PROJECT_URL__` / `__SUPABASE_ANON_KEY__`
116
- placeholders in the shipped template. `init`/`upgrade` substitute real
117
- values before writing the
118
- file to disk; if a value is missing and can't be resolved (flag, then
119
- `config.toml`, then a TTY prompt), the command fails rather than writing a
120
- file with a literal placeholder still in it — a migration with an
121
- unsubstituted placeholder would push successfully to Postgres and then fail
122
- silently at cron/webhook runtime, so this guard turns that into a loud,
123
- immediate CLI error instead.
110
+ ## Project settings (no value is ever baked into a migration)
111
+
112
+ The pg_cron jobs and pg_net webhooks that call back into the project's own
113
+ Edge Functions read the project URL and anon key from Supabase Vault, through
114
+ two private helpers defined by `core/000_settings.sql`
115
+ (`community_project_url()` / `community_anon_key()`). The migration files are
116
+ therefore identical for every project: the same `supabase/` folder deploys to
117
+ a sandbox and to production. What differs is one statement, run once per
118
+ project after `db push`:
119
+
120
+ ```bash
121
+ supabase db query --linked "select public.community_settings_set('https://<ref>.supabase.co', '<anon key>');"
122
+ ```
123
+
124
+ `init`/`upgrade` print it (filled in when `--project-url` / `--anon-key` are
125
+ passed, placeholders otherwise). Until a project is seeded, every webhook
126
+ logs a warning instead of calling out — inserts are never blocked — and the
127
+ daily sweeps catch up once it is. The anon key is public by design (it ships
128
+ inside the app binary): the Edge Functions do privileged work through their
129
+ own service-role env, the JWT only passes `verify_jwt`.
@@ -5,6 +5,7 @@ export declare const ALREADY_INITIALIZED_MESSAGE = "community-sdk: already initi
5
5
  export interface InitOptions {
6
6
  /** Modules to install. Defaults to every module. `core` is always implied. */
7
7
  modules?: string[];
8
+ /** Optional: only used to print the settings seed statement ready to run. */
8
9
  projectUrl?: string;
9
10
  anonKey?: string;
10
11
  /** Target Supabase directory, relative to `cwd`. Defaults to "supabase". */
@@ -13,8 +14,6 @@ export interface InitOptions {
13
14
  cwd?: string;
14
15
  /** Source of the migrations/ + functions/ trees. Defaults to the shipped templates. */
15
16
  templatesDir?: string;
16
- /** Used to ask for projectUrl/anonKey when missing and not derivable. */
17
- prompt?: (question: string) => Promise<string>;
18
17
  /** Called for non-fatal dependency warnings (e.g. inbox without reaction). */
19
18
  onWarn?: (message: string) => void;
20
19
  /** Used to print the summary + next steps. */
@@ -37,7 +37,6 @@ exports.ALREADY_INITIALIZED_MESSAGE = exports.MODULE_ORDER = void 0;
37
37
  exports.runInit = runInit;
38
38
  const fs = __importStar(require("node:fs"));
39
39
  const path = __importStar(require("node:path"));
40
- const substitute_1 = require("../substitute");
41
40
  const manifest_1 = require("../manifest");
42
41
  const install_shared_1 = require("../install-shared");
43
42
  Object.defineProperty(exports, "MODULE_ORDER", { enumerable: true, get: function () { return install_shared_1.MODULE_ORDER; } });
@@ -54,17 +53,10 @@ async function runInit(options = {}) {
54
53
  const dir = (0, install_shared_1.resolveContainedDir)(cwd, dirRel);
55
54
  const templatesDir = options.templatesDir ?? (0, install_shared_1.defaultTemplatesDir)();
56
55
  const { migrationsSrcRoot, functionsSrcRoot } = (0, install_shared_1.resolveTemplateRoots)(templatesDir);
57
- const { projectUrl, anonKey } = await (0, install_shared_1.resolveProjectCredentials)({
58
- dir,
59
- projectUrl: options.projectUrl,
60
- anonKey: options.anonKey,
61
- prompt: options.prompt,
62
- });
63
56
  const now = options.now ?? new Date();
64
57
  // ---- migrations + functions, staged atomically: on any failure partway
65
- // through, every file this run wrote (which may already contain the real
66
- // project URL / anon key) is removed before the error propagates, so a
67
- // retry starts clean instead of finding stale/duplicate files and no
58
+ // through, every file this run wrote is removed before the error
59
+ // propagates, so a retry starts clean instead of finding stale/duplicate files and no
68
60
  // manifest to explain them. The manifest itself is written last, only once
69
61
  // every file below has landed successfully.
70
62
  const migrationsDestRoot = path.join(dir, "migrations");
@@ -89,16 +81,8 @@ async function runInit(options = {}) {
89
81
  fileIndex += 1;
90
82
  const destFilename = (0, install_shared_1.migrationDestFilename)(timestamp, moduleName, name);
91
83
  const rawSql = fs.readFileSync(path.join(moduleSrcDir, filename), "utf8");
92
- let substituted;
93
- try {
94
- substituted = (0, substitute_1.substitutePlaceholders)(rawSql, { projectUrl, anonKey });
95
- }
96
- catch (err) {
97
- const message = err instanceof Error ? err.message : String(err);
98
- throw new Error(`${message} (source: ${moduleName}/${filename})`);
99
- }
100
84
  const destPath = path.join(migrationsDestRoot, destFilename);
101
- fs.writeFileSync(destPath, substituted, "utf8");
85
+ fs.writeFileSync(destPath, rawSql, "utf8");
102
86
  writtenPaths.push(destPath);
103
87
  installedTemplates.push((0, manifest_1.migrationTemplateId)(moduleName, name));
104
88
  }
@@ -135,15 +119,19 @@ async function runInit(options = {}) {
135
119
  installedTemplates: installedTemplates.sort(),
136
120
  };
137
121
  (0, manifest_1.writeManifest)(cwd, manifest);
138
- printNextSteps(log, canonicalModules);
122
+ printNextSteps(log, canonicalModules, {
123
+ projectUrl: options.projectUrl,
124
+ anonKey: options.anonKey,
125
+ });
139
126
  return { manifest, dir };
140
127
  }
141
- function printNextSteps(log, modules) {
128
+ function printNextSteps(log, modules, values) {
142
129
  log("");
143
130
  log(`community-sdk initialized (${modules.join(", ")}).`);
144
131
  log("Next steps:");
145
132
  log(" 1. Review the copied migrations, then: supabase db push");
146
- log(" 2. Set Edge Function secrets: supabase secrets set OPENAI_API_KEY=...");
133
+ (0, install_shared_1.printSettingsSeedStep)(log, values, 2);
134
+ log(" 3. Set Edge Function secrets: supabase secrets set OPENAI_API_KEY=...");
147
135
  log(" optional: SLACK_WEBHOOK_URL, COMMUNITY_APP_NAME, COMMUNITY_FALLBACK_NAME");
148
136
  if (modules.includes("push")) {
149
137
  log(" push module: EXPO_ACCESS_TOKEN (only if your Expo project enforces Enhanced Security for push); COMMUNITY_PUSH_COPY (optional per-locale copy)");
@@ -151,5 +139,5 @@ function printNextSteps(log, modules) {
151
139
  if (modules.includes("translation")) {
152
140
  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');
153
141
  }
154
- log(" 3. Deploy the Edge Functions: supabase functions deploy");
142
+ log(" 4. Deploy the Edge Functions: supabase functions deploy");
155
143
  }
@@ -11,8 +11,6 @@ export interface UpgradeOptions {
11
11
  templatesDir?: string;
12
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
13
  addModules?: string[];
14
- /** Used to ask for projectUrl/anonKey when a newly copied migration needs them. */
15
- prompt?: (question: string) => Promise<string>;
16
14
  /** Called for non-fatal warnings — most importantly, functions overwritten with new content. */
17
15
  onWarn?: (message: string) => void;
18
16
  /** Used to print the summary + next steps. */
@@ -38,7 +38,6 @@ exports.runUpgrade = runUpgrade;
38
38
  const fs = __importStar(require("node:fs"));
39
39
  const os = __importStar(require("node:os"));
40
40
  const path = __importStar(require("node:path"));
41
- const substitute_1 = require("../substitute");
42
41
  const manifest_1 = require("../manifest");
43
42
  const install_shared_1 = require("../install-shared");
44
43
  exports.NOT_INITIALIZED_MESSAGE = "community-sdk: not initialized, run init (or adopt)";
@@ -112,20 +111,6 @@ async function runUpgrade(options = {}) {
112
111
  overwrittenFunctions: [],
113
112
  };
114
113
  }
115
- // ---- credentials, only if a pending migration actually needs them ----
116
- const needsCredentials = pending.some((p) => p.rawSql.includes("__SUPABASE"));
117
- let projectUrl;
118
- let anonKey;
119
- if (needsCredentials) {
120
- const creds = await (0, install_shared_1.resolveProjectCredentials)({
121
- dir,
122
- projectUrl: options.projectUrl,
123
- anonKey: options.anonKey,
124
- prompt: options.prompt,
125
- });
126
- projectUrl = creds.projectUrl;
127
- anonKey = creds.anonKey;
128
- }
129
114
  const migrationsDestRoot = path.join(dir, "migrations");
130
115
  const writtenMigrationPaths = [];
131
116
  const addedMigrations = [];
@@ -133,23 +118,8 @@ async function runUpgrade(options = {}) {
133
118
  const createdFunctionDirs = [];
134
119
  const backups = [];
135
120
  try {
136
- // ---- prepare every migration's final content up front: a placeholder
137
- // error here must throw before a single byte is written to disk. ----
138
121
  const now = options.now ?? new Date();
139
- const prepared = pending.map((p) => {
140
- if (!p.rawSql.includes("__SUPABASE"))
141
- return { ...p, content: p.rawSql };
142
- try {
143
- return {
144
- ...p,
145
- content: (0, substitute_1.substitutePlaceholders)(p.rawSql, { projectUrl: projectUrl, anonKey: anonKey }),
146
- };
147
- }
148
- catch (err) {
149
- const message = err instanceof Error ? err.message : String(err);
150
- throw new Error(`${message} (source: ${p.moduleName}/${p.templateFilename})`);
151
- }
152
- });
122
+ const prepared = pending.map((p) => ({ ...p, content: p.rawSql }));
153
123
  if (prepared.length > 0) {
154
124
  fs.mkdirSync(migrationsDestRoot, { recursive: true });
155
125
  prepared.forEach((p, index) => {
@@ -225,6 +195,7 @@ async function runUpgrade(options = {}) {
225
195
  };
226
196
  (0, manifest_1.writeManifest)(cwd, updatedManifest);
227
197
  printSummary(log, onWarn, {
198
+ values: { projectUrl: options.projectUrl, anonKey: options.anonKey },
228
199
  addedModules,
229
200
  addedMigrations,
230
201
  newFunctions,
@@ -296,6 +267,13 @@ function printSummary(log, onWarn, summary) {
296
267
  if (summary.addedMigrations.length > 0) {
297
268
  log(" 1. Review the new migrations, then: supabase db push");
298
269
  }
270
+ if (summary.addedMigrations.some((f) => /_community_core_settings\.sql$/.test(f))) {
271
+ // First upgrade onto the Vault-backed settings: the new *_webhooks
272
+ // migrations read the project URL / anon key from there, so the seed must
273
+ // follow this push (until then every webhook logs a warning and the
274
+ // daily sweeps catch up — nothing is lost, nothing is blocked).
275
+ (0, install_shared_1.printSettingsSeedStep)(log, summary.values, 2);
276
+ }
299
277
  if (summary.newFunctions.length > 0 || summary.overwrittenFunctions.length > 0) {
300
278
  log(" 2. Review the function changes, then: supabase functions deploy");
301
279
  }
package/lib/index.js CHANGED
@@ -61,8 +61,8 @@ program
61
61
  .command("init")
62
62
  .description("Copy the community backend into a Supabase project: migrations, Edge Functions, and a community-sdk.json manifest")
63
63
  .option("--modules <modules>", "comma-separated module list (core,push,polls,reaction,inbox,translation) — core is always implied; defaults to all modules")
64
- .option("--project-url <url>", "Supabase project URL, e.g. https://<ref>.supabase.co")
65
- .option("--anon-key <key>", "Supabase anon key")
64
+ .option("--project-url <url>", "Supabase project URL, e.g. https://<ref>.supabase.co — optional, only fills in the printed settings seed statement")
65
+ .option("--anon-key <key>", "Supabase anon key — optional, only fills in the printed settings seed statement")
66
66
  .option("--dir <dir>", "target Supabase directory, relative to the current directory", "supabase")
67
67
  .action(async (opts) => {
68
68
  try {
@@ -82,8 +82,8 @@ 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
84
  .option("--add-modules <modules>", "comma-separated module list to install on top of what's already in community-sdk.json (e.g. translation)")
85
- .option("--project-url <url>", "Supabase project URL, e.g. https://<ref>.supabase.co (only needed if a new migration carries a placeholder)")
86
- .option("--anon-key <key>", "Supabase anon key (only needed if a new migration carries a placeholder)")
85
+ .option("--project-url <url>", "Supabase project URL, e.g. https://<ref>.supabase.co — optional, only fills in the printed settings seed statement")
86
+ .option("--anon-key <key>", "Supabase anon key — optional, only fills in the printed settings seed statement")
87
87
  .option("--dir <dir>", "target Supabase directory, relative to the current directory", "supabase")
88
88
  .action(async (opts) => {
89
89
  try {
@@ -48,23 +48,6 @@ export declare function resolveTemplateRoots(templatesDir: string): {
48
48
  functionsSrcRoot: string;
49
49
  };
50
50
  export declare function readOwnPackageVersion(): string;
51
- export declare function deriveProjectUrlFromConfig(dir: string): string | null;
52
- /**
53
- * Resolves the Supabase project URL + anon key from (in order) explicit
54
- * flags, `<dir>/config.toml` (URL only), or an interactive prompt — failing
55
- * loudly instead of hanging when stdin is not a TTY and no `prompt`
56
- * override was injected. Shared by `init` and `upgrade`, both of which only
57
- * need this when a placeholder-bearing file is about to be written.
58
- */
59
- export declare function resolveProjectCredentials(options: {
60
- dir: string;
61
- projectUrl?: string;
62
- anonKey?: string;
63
- prompt?: (question: string) => Promise<string>;
64
- }): Promise<{
65
- projectUrl: string;
66
- anonKey: string;
67
- }>;
68
51
  export declare function formatTimestamp(date: Date): string;
69
52
  export declare function addSeconds(date: Date, seconds: number): Date;
70
53
  /** Deno test files (`*_test.ts`) stay in the repo: never copied into a host's
@@ -74,3 +57,18 @@ export declare function listFilesRecursive(dir: string): string[];
74
57
  export declare function toRelativePosix(base: string, target: string): string;
75
58
  /** Resolves `dirRel` against `cwd`, throwing if it would escape the project root. */
76
59
  export declare function resolveContainedDir(cwd: string, dirRel: string): string;
60
+ /**
61
+ * The one statement to run once per Supabase project after `db push`: it
62
+ * stores the project's own URL and anon key in Vault, where the pg_cron jobs
63
+ * and pg_net webhooks read them (core/000_settings.sql). The migration files
64
+ * themselves carry no project-specific value, so the same files deploy to a
65
+ * sandbox and a production project.
66
+ */
67
+ export declare function settingsSeedSql(values?: {
68
+ projectUrl?: string;
69
+ anonKey?: string;
70
+ }): string;
71
+ export declare function printSettingsSeedStep(log: (message: string) => void, values: {
72
+ projectUrl?: string;
73
+ anonKey?: string;
74
+ }, step: number): void;
@@ -42,17 +42,16 @@ exports.parseMigrationDestFilename = parseMigrationDestFilename;
42
42
  exports.defaultTemplatesDir = defaultTemplatesDir;
43
43
  exports.resolveTemplateRoots = resolveTemplateRoots;
44
44
  exports.readOwnPackageVersion = readOwnPackageVersion;
45
- exports.deriveProjectUrlFromConfig = deriveProjectUrlFromConfig;
46
- exports.resolveProjectCredentials = resolveProjectCredentials;
47
45
  exports.formatTimestamp = formatTimestamp;
48
46
  exports.addSeconds = addSeconds;
49
47
  exports.isShippedFunctionFile = isShippedFunctionFile;
50
48
  exports.listFilesRecursive = listFilesRecursive;
51
49
  exports.toRelativePosix = toRelativePosix;
52
50
  exports.resolveContainedDir = resolveContainedDir;
51
+ exports.settingsSeedSql = settingsSeedSql;
52
+ exports.printSettingsSeedStep = printSettingsSeedStep;
53
53
  const fs = __importStar(require("node:fs"));
54
54
  const path = __importStar(require("node:path"));
55
- const promises_1 = require("node:readline/promises");
56
55
  /**
57
56
  * Ruled install order (controller ruling, task 18): core -> push -> polls ->
58
57
  * reaction -> inbox -> translation. Reaction MUST land before inbox —
@@ -177,54 +176,6 @@ function readOwnPackageVersion() {
177
176
  const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
178
177
  return pkg.version;
179
178
  }
180
- function deriveProjectUrlFromConfig(dir) {
181
- const configPath = path.join(dir, "config.toml");
182
- if (!fs.existsSync(configPath))
183
- return null;
184
- const raw = fs.readFileSync(configPath, "utf8");
185
- const match = raw.match(/^\s*project_id\s*=\s*"([^"]+)"/m);
186
- if (!match)
187
- return null;
188
- return `https://${match[1]}.supabase.co`;
189
- }
190
- function defaultPrompt(question) {
191
- const rl = (0, promises_1.createInterface)({ input: process.stdin, output: process.stdout });
192
- return rl.question(question).then((answer) => {
193
- rl.close();
194
- return answer;
195
- });
196
- }
197
- /**
198
- * Resolves the Supabase project URL + anon key from (in order) explicit
199
- * flags, `<dir>/config.toml` (URL only), or an interactive prompt — failing
200
- * loudly instead of hanging when stdin is not a TTY and no `prompt`
201
- * override was injected. Shared by `init` and `upgrade`, both of which only
202
- * need this when a placeholder-bearing file is about to be written.
203
- */
204
- async function resolveProjectCredentials(options) {
205
- const usingDefaultPrompt = !options.prompt;
206
- const prompt = options.prompt ?? defaultPrompt;
207
- let projectUrl = options.projectUrl;
208
- let anonKey = options.anonKey;
209
- if (!projectUrl) {
210
- projectUrl = deriveProjectUrlFromConfig(options.dir) ?? undefined;
211
- }
212
- const stillMissingFlags = [];
213
- if (!projectUrl)
214
- stillMissingFlags.push("--project-url");
215
- if (!anonKey)
216
- stillMissingFlags.push("--anon-key");
217
- if (stillMissingFlags.length > 0 && usingDefaultPrompt && !process.stdin.isTTY) {
218
- throw new Error(`community-sdk: missing required flag(s) ${stillMissingFlags.join(", ")}. stdin is not a TTY, so this command cannot prompt for ${stillMissingFlags.length > 1 ? "them" : "it"} — pass ${stillMissingFlags.length > 1 ? "them" : "it"} explicitly.`);
219
- }
220
- if (!projectUrl) {
221
- projectUrl = (await prompt("Supabase project URL (https://<ref>.supabase.co): ")).trim();
222
- }
223
- if (!anonKey) {
224
- anonKey = (await prompt("Supabase anon key: ")).trim();
225
- }
226
- return { projectUrl, anonKey };
227
- }
228
179
  function formatTimestamp(date) {
229
180
  const pad = (n) => String(n).padStart(2, "0");
230
181
  return (String(date.getUTCFullYear()) +
@@ -268,3 +219,20 @@ function resolveContainedDir(cwd, dirRel) {
268
219
  }
269
220
  return dir;
270
221
  }
222
+ /**
223
+ * The one statement to run once per Supabase project after `db push`: it
224
+ * stores the project's own URL and anon key in Vault, where the pg_cron jobs
225
+ * and pg_net webhooks read them (core/000_settings.sql). The migration files
226
+ * themselves carry no project-specific value, so the same files deploy to a
227
+ * sandbox and a production project.
228
+ */
229
+ function settingsSeedSql(values = {}) {
230
+ const url = values.projectUrl?.trim() || "https://<ref>.supabase.co";
231
+ const key = values.anonKey?.trim() || "<anon key>";
232
+ return `select public.community_settings_set('${url.replace(/'/g, "''")}', '${key.replace(/'/g, "''")}');`;
233
+ }
234
+ function printSettingsSeedStep(log, values, step) {
235
+ log(` ${step}. Seed the project settings (once per Supabase project, after db push):`);
236
+ log(` supabase db query --linked "${settingsSeedSql(values)}"`);
237
+ log(" (or run that statement in the SQL editor — the migrations carry no project URL/key)");
238
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community",
3
- "version": "0.4.1",
3
+ "version": "0.8.1",
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": {
@@ -17,8 +17,8 @@
17
17
  // Placeholders: `{name}` (actor display name) and `{count}` (the number of
18
18
  // OTHER people in a coalesced push). A `*.many` value is either a string or a
19
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).
20
+ // `{count}` for the recipient's locale (Polish needs all three; Ukrainian and
21
+ // Romanian one/few/other; French treats 0 like 1; everything else is one/other).
22
22
  //
23
23
  // Legacy single-string secrets still apply, below the JSON and above the
24
24
  // built-ins: COMMUNITY_FALLBACK_NAME (every locale's fallbackName) and
@@ -165,13 +165,23 @@ const BUILTIN: Record<string, LocaleCopy> = {
165
165
  // Same rule set as the UI package's `pluralCategory` (packages/ui/src/i18n.ts).
166
166
  export function pluralCategory(locale: string, n: number): keyof Forms {
167
167
  const lang = locale.split("-")[0]?.toLowerCase();
168
+ const m10 = n % 10;
169
+ const m100 = n % 100;
168
170
  if (lang === "pl") {
169
171
  if (n === 1) return "one";
170
- const m10 = n % 10;
171
- const m100 = n % 100;
172
172
  if (m10 >= 2 && m10 <= 4 && !(m100 >= 12 && m100 <= 14)) return "few";
173
173
  return "many";
174
174
  }
175
+ if (lang === "uk") {
176
+ if (m10 === 1 && m100 !== 11) return "one";
177
+ if (m10 >= 2 && m10 <= 4 && !(m100 >= 12 && m100 <= 14)) return "few";
178
+ return "other";
179
+ }
180
+ if (lang === "ro") {
181
+ if (n === 1) return "one";
182
+ if (n === 0 || (m100 >= 2 && m100 <= 19)) return "few";
183
+ return "other";
184
+ }
175
185
  if (lang === "fr") return n === 0 || n === 1 ? "one" : "other";
176
186
  return n === 1 ? "one" : "other";
177
187
  }
@@ -28,14 +28,25 @@ export async function sendExpoPushBatch(
28
28
  if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
29
29
  const chunks: ExpoPushMessage[][] = [];
30
30
  for (let i = 0; i < messages.length; i += 100) chunks.push(messages.slice(i, i + 100));
31
- await runPool(chunks, opts.concurrency ?? 4, (chunk) =>
32
- fetchImpl("https://exp.host/--/api/v2/push/send", {
33
- method: "POST",
34
- headers,
35
- body: JSON.stringify(chunk.length === 1 ? chunk[0] : chunk),
36
- signal: AbortSignal.timeout(10_000),
37
- }).catch(() => {}),
38
- );
31
+ await runPool(chunks, opts.concurrency ?? 4, async (chunk) => {
32
+ try {
33
+ const res = await fetchImpl("https://exp.host/--/api/v2/push/send", {
34
+ method: "POST",
35
+ headers,
36
+ body: JSON.stringify(chunk.length === 1 ? chunk[0] : chunk),
37
+ signal: AbortSignal.timeout(10_000),
38
+ });
39
+ // Best effort, but never silent: a project with push security enabled
40
+ // answers 401 UNAUTHORIZED without EXPO_ACCESS_TOKEN, and bad tokens
41
+ // come back as per-message tickets — both used to vanish here.
42
+ const body = await res.text();
43
+ if (!res.ok || /"status"\s*:\s*"error"/.test(body)) {
44
+ console.error(`expo push: HTTP ${res.status} ${body.slice(0, 500)}`);
45
+ }
46
+ } catch (error) {
47
+ console.error("expo push: request failed", error);
48
+ }
49
+ });
39
50
  }
40
51
 
41
52
  export async function sendExpoPush(message: ExpoPushMessage): Promise<void> {
@@ -1,10 +1,10 @@
1
- // update-profile: the ONLY write path for profiles.handle / bio / avatar_url.
1
+ // update-profile: the ONLY moderated write path for profiles.username / handle / bio / avatar_url.
2
2
  // JWT-authenticated: uid comes from the caller's anon session token, never the
3
3
  // body. Text fields run through OpenAI omni-moderation (same category logic
4
4
  // as moderate-one); avatarPath runs image moderation on the object's public
5
5
  // URL. Fail closed: if moderation is unreachable, nothing is written.
6
6
  //
7
- // Fields are applied sequentially (handle, then bio, then avatar); the first
7
+ // Fields are applied sequentially (username, handle, bio, then avatar); the first
8
8
  // failure aborts and reports its field. The app's editors send one field at a
9
9
  // time, so partial application is not observable in practice.
10
10
 
@@ -24,17 +24,23 @@ Deno.serve(async (req) => {
24
24
  const uid = userData.user?.id;
25
25
  if (!uid) return json({ status: "error", error: "unauthorized" }, 401);
26
26
 
27
- let body: { handle?: unknown; bio?: unknown; avatarPath?: unknown };
27
+ let body: { username?: unknown; handle?: unknown; bio?: unknown; avatarPath?: unknown };
28
28
  try {
29
29
  body = await req.json();
30
30
  } catch {
31
31
  return json({ status: "error", error: "bad request" }, 400);
32
32
  }
33
- const { handle, bio, avatarPath } = body;
34
- if (handle === undefined && bio === undefined && avatarPath === undefined) {
33
+ const { username, handle, bio, avatarPath } = body;
34
+ if (
35
+ username === undefined &&
36
+ handle === undefined &&
37
+ bio === undefined &&
38
+ avatarPath === undefined
39
+ ) {
35
40
  return json({ status: "error", error: "bad request" }, 400);
36
41
  }
37
42
  if (
43
+ (username !== undefined && typeof username !== "string") ||
38
44
  (handle !== undefined && typeof handle !== "string") ||
39
45
  (bio !== undefined && typeof bio !== "string") ||
40
46
  (avatarPath !== undefined && typeof avatarPath !== "string")
@@ -42,6 +48,21 @@ Deno.serve(async (req) => {
42
48
  return json({ status: "error", error: "bad request" }, 400);
43
49
  }
44
50
 
51
+ // ============ USERNAME (display name) ============
52
+ if (username !== undefined) {
53
+ const u = (username as string).trim();
54
+ if (u.length < 2 || u.length > 24) {
55
+ return json({ status: "error", error: "invalid_username" }, 400);
56
+ }
57
+ const verdict = await moderateInput(u);
58
+ if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
59
+ if (verdict.flagged) {
60
+ return json({ status: "rejected", field: "username", reason: verdict.reason });
61
+ }
62
+ const { error } = await admin.from("profiles").update({ username: u }).eq("id", uid);
63
+ if (error) return json({ status: "error", error: error.message }, 500);
64
+ }
65
+
45
66
  // ============ HANDLE ============
46
67
  if (handle !== undefined) {
47
68
  const h = (handle as string).trim().toLowerCase();
@@ -119,11 +140,12 @@ Deno.serve(async (req) => {
119
140
  // ============ SUCCESS ============
120
141
  const { data: profile } = await admin
121
142
  .from("profiles")
122
- .select("handle, bio, avatar_url")
143
+ .select("username, handle, bio, avatar_url")
123
144
  .eq("id", uid)
124
145
  .single();
125
146
  return json({
126
147
  status: "ok",
148
+ username: profile?.username ?? null,
127
149
  handle: profile?.handle ?? null,
128
150
  bio: profile?.bio ?? null,
129
151
  avatarUrl: profile?.avatar_url ?? null,
@@ -0,0 +1,128 @@
1
+ -- community-sdk core/000_settings.sql (schema v1)
2
+ -- Community SDK — 00 project settings: where the pg_cron jobs and pg_net
3
+ -- webhooks learn this project's own URL and anon key.
4
+ --
5
+ -- They live in Supabase Vault, seeded ONCE PER PROJECT after `db push`:
6
+ --
7
+ -- select public.community_settings_set('https://<ref>.supabase.co', '<anon key>');
8
+ --
9
+ -- so the very same migration files deploy to a sandbox and a production
10
+ -- project. The anon key is public by design (it ships inside the app binary):
11
+ -- the Edge Functions do privileged work through their own service-role env,
12
+ -- the JWT only passes verify_jwt.
13
+ --
14
+ -- Every helper here is security definer and private (postgres + service_role):
15
+ -- the webhook trigger functions are security definer too, cron runs as
16
+ -- postgres, and nothing is exposed over PostgREST.
17
+
18
+ create extension if not exists supabase_vault;
19
+ create extension if not exists pg_cron;
20
+ create extension if not exists pg_net;
21
+
22
+ -- Upsert both settings. Idempotent: re-run it to rotate the key or move the
23
+ -- project.
24
+ create or replace function public.community_settings_set(p_project_url text, p_anon_key text)
25
+ returns void
26
+ language plpgsql
27
+ security definer set search_path = ''
28
+ as $$
29
+ declare
30
+ v_id uuid;
31
+ v_name text;
32
+ v_value text;
33
+ begin
34
+ for v_name, v_value in
35
+ values ('community_project_url', rtrim(p_project_url, '/')), ('community_anon_key', p_anon_key)
36
+ loop
37
+ select id into v_id from vault.secrets where name = v_name;
38
+ if v_id is null then
39
+ perform vault.create_secret(v_value, v_name);
40
+ else
41
+ perform vault.update_secret(v_id, v_value);
42
+ end if;
43
+ end loop;
44
+ end;
45
+ $$;
46
+
47
+ create or replace function public.community_setting(p_name text)
48
+ returns text
49
+ language plpgsql
50
+ stable
51
+ security definer set search_path = ''
52
+ as $$
53
+ declare
54
+ v_value text;
55
+ begin
56
+ select decrypted_secret into v_value from vault.decrypted_secrets where name = p_name;
57
+ if v_value is null then
58
+ raise exception 'community-sdk: project setting % is not seeded. Run once per project: select public.community_settings_set(''https://<ref>.supabase.co'', ''<anon key>'');', p_name;
59
+ end if;
60
+ return v_value;
61
+ end;
62
+ $$;
63
+
64
+ create or replace function public.community_project_url()
65
+ returns text language sql stable security definer set search_path = ''
66
+ as $$ select public.community_setting('community_project_url') $$;
67
+
68
+ create or replace function public.community_anon_key()
69
+ returns text language sql stable security definer set search_path = ''
70
+ as $$ select public.community_setting('community_anon_key') $$;
71
+
72
+ -- Call one of this project's own Edge Functions through pg_net. A failure
73
+ -- (settings not seeded, pg_net unavailable) is logged as a warning and never
74
+ -- aborts the insert/update that triggered it: the content stays `pending`
75
+ -- (or untranslated) and the daily sweep catches up.
76
+ create or replace function public.community_call_function(p_function text, p_body jsonb default null)
77
+ returns void
78
+ language plpgsql
79
+ security definer set search_path = ''
80
+ as $$
81
+ begin
82
+ perform net.http_post(
83
+ url := public.community_project_url() || '/functions/v1/' || p_function,
84
+ headers := jsonb_build_object(
85
+ 'Content-Type', 'application/json',
86
+ 'Authorization', 'Bearer ' || public.community_anon_key()
87
+ ),
88
+ body := coalesce(p_body, '{}'::jsonb)
89
+ );
90
+ exception when others then
91
+ raise warning 'community-sdk: could not call Edge Function %: %', p_function, sqlerrm;
92
+ end;
93
+ $$;
94
+
95
+ -- (Re)schedule a cron job by name: replaces an existing job of the same name,
96
+ -- so a re-run (or an upgrade from a release that baked the URL into the job
97
+ -- command) always ends with exactly one job carrying the current command.
98
+ create or replace function public.community_schedule(p_name text, p_schedule text, p_command text)
99
+ returns void
100
+ language plpgsql
101
+ security definer set search_path = ''
102
+ as $$
103
+ declare
104
+ v_jobid bigint;
105
+ begin
106
+ for v_jobid in select jobid from cron.job where jobname = p_name loop
107
+ perform cron.unschedule(v_jobid);
108
+ end loop;
109
+ perform cron.schedule(p_name, p_schedule, p_command);
110
+ end;
111
+ $$;
112
+
113
+ revoke execute on function
114
+ public.community_settings_set(text, text),
115
+ public.community_setting(text),
116
+ public.community_project_url(),
117
+ public.community_anon_key(),
118
+ public.community_call_function(text, jsonb),
119
+ public.community_schedule(text, text, text)
120
+ from public, anon, authenticated;
121
+ grant execute on function
122
+ public.community_settings_set(text, text),
123
+ public.community_setting(text),
124
+ public.community_project_url(),
125
+ public.community_anon_key(),
126
+ public.community_call_function(text, jsonb),
127
+ public.community_schedule(text, text, text)
128
+ to service_role;
@@ -1,68 +1,7 @@
1
1
  -- community-sdk core/003_moderation.sql (schema v1)
2
- -- Community SDK — 03 moderation plumbing: daily cron batch + real-time report
3
- -- webhook.
4
- --
5
- -- ⚠️ PLACEHOLDERS: before pushing, replace __SUPABASE_PROJECT_URL__ with the
6
- -- app's project URL (https://<ref>.supabase.co) and __SUPABASE_ANON_KEY__ with
7
- -- its anon key. The anon key is public by design (shipped inside the app
8
- -- binary): the Edge Functions do privileged work through their own
9
- -- service-role env, the JWT only passes verify_jwt.
10
- --
11
- -- The DO block below fails the migration loudly if the placeholders were not
12
- -- replaced.
13
- do $$ begin
14
- if '__SUPABASE_PROJECT_URL__' like '\_\_SUPABASE%' escape '\' then
15
- raise exception 'community-sdk: placeholders not substituted. Run: npx @rocapine/community init';
16
- end if;
17
- end $$;
18
-
2
+ -- Community SDK — 03 moderation plumbing: the extensions the report webhook
3
+ -- and the daily moderation cron need. The webhook function, its trigger and
4
+ -- the cron job themselves live in core/008_webhooks.sql (they read the
5
+ -- project URL / anon key from the settings seeded via core/000_settings.sql).
19
6
  create extension if not exists pg_cron;
20
7
  create extension if not exists pg_net;
21
-
22
- -- Real-time: forward each new report to the report-to-slack Edge Function.
23
- create or replace function public.notify_report_webhook()
24
- returns trigger
25
- language plpgsql
26
- security definer set search_path = public
27
- as $$
28
- begin
29
- perform net.http_post(
30
- url := '__SUPABASE_PROJECT_URL__/functions/v1/report-to-slack',
31
- headers := jsonb_build_object(
32
- 'Content-Type', 'application/json',
33
- 'Authorization', 'Bearer __SUPABASE_ANON_KEY__'
34
- ),
35
- body := jsonb_build_object('record', to_jsonb(new))
36
- );
37
- return new;
38
- end;
39
- $$;
40
-
41
- drop trigger if exists on_report_created on public.reports;
42
- create trigger on_report_created
43
- after insert on public.reports
44
- for each row execute function public.notify_report_webhook();
45
-
46
- -- Daily 08:00 UTC moderation batch. Wrapped defensively: cron.schedule
47
- -- upserts by job name, but if a prior manual run already created the job
48
- -- and this raises anyway, do not fail the whole migration on a rerun.
49
- do $$
50
- begin
51
- perform cron.schedule(
52
- 'daily-moderation',
53
- '0 8 * * *',
54
- $cron$
55
- select net.http_post(
56
- url := '__SUPABASE_PROJECT_URL__/functions/v1/daily-moderation',
57
- headers := jsonb_build_object('Authorization', 'Bearer __SUPABASE_ANON_KEY__')
58
- );
59
- $cron$
60
- );
61
- exception when others then
62
- if sqlerrm like '%already exists%' then
63
- raise notice 'cron job daily-moderation already scheduled, skipping';
64
- else
65
- raise;
66
- end if;
67
- end;
68
- $$;
@@ -0,0 +1,27 @@
1
+ -- community-sdk core/008_webhooks.sql (schema v1)
2
+ -- Community SDK — 08 report webhook + daily moderation cron, built on the
3
+ -- project settings helpers (core/000_settings.sql). On an existing install
4
+ -- this replaces the function and job that carried the project URL baked in.
5
+
6
+ -- Real-time: forward each new report to the report-to-slack Edge Function.
7
+ create or replace function public.notify_report_webhook()
8
+ returns trigger
9
+ language plpgsql
10
+ security definer set search_path = public
11
+ as $$
12
+ begin
13
+ perform public.community_call_function('report-to-slack', jsonb_build_object('record', to_jsonb(new)));
14
+ return new;
15
+ end;
16
+ $$;
17
+
18
+ create or replace trigger on_report_created
19
+ after insert on public.reports
20
+ for each row execute function public.notify_report_webhook();
21
+
22
+ -- Daily 08:00 UTC moderation batch.
23
+ select public.community_schedule(
24
+ 'daily-moderation',
25
+ '0 8 * * *',
26
+ $cron$ select public.community_call_function('daily-moderation'); $cron$
27
+ );
@@ -1,89 +1,9 @@
1
1
  -- community-sdk push/002_triggers.sql (schema v1)
2
- -- Community SDK — push module (OPTIONAL): webhook functions, triggers and the
3
- -- hourly digest cron job. Requires the notify-comment / notify-like Edge
4
- -- Functions and an EXPO_ACCESS_TOKEN secret. References core module tables
2
+ -- Community SDK — push module (OPTIONAL): the extensions the push webhooks
3
+ -- need. The webhook functions, triggers and the hourly digest cron live in
4
+ -- push/003_webhooks.sql (they read the project URL / anon key from the
5
+ -- settings seeded via core/000_settings.sql). References core module tables
5
6
  -- (public.comments, public.likes) — the core module must be installed first,
6
7
  -- and push/001_push.sql (push_tokens) before this file.
7
- --
8
- -- ⚠️ PLACEHOLDERS: before pushing, replace __SUPABASE_PROJECT_URL__ with the
9
- -- app's project URL (https://<ref>.supabase.co) and __SUPABASE_ANON_KEY__ with
10
- -- its anon key. The anon key is public by design (shipped inside the app
11
- -- binary): the Edge Functions do privileged work through their own
12
- -- service-role env, the JWT only passes verify_jwt.
13
- --
14
- -- The DO block below fails the migration loudly if the placeholders were not
15
- -- replaced.
16
- do $$ begin
17
- if '__SUPABASE_PROJECT_URL__' like '\_\_SUPABASE%' escape '\' then
18
- raise exception 'community-sdk: placeholders not substituted. Run: npx @rocapine/community init';
19
- end if;
20
- end $$;
21
-
22
8
  create extension if not exists pg_cron;
23
9
  create extension if not exists pg_net;
24
-
25
- -- ============ WEBHOOK FUNCTIONS ============
26
- create or replace function public.notify_comment_webhook()
27
- returns trigger language plpgsql security definer set search_path = public as $$
28
- begin
29
- perform net.http_post(
30
- url := '__SUPABASE_PROJECT_URL__/functions/v1/notify-comment',
31
- headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__'),
32
- body := jsonb_build_object('record', to_jsonb(new))
33
- );
34
- return new;
35
- end; $$;
36
-
37
- create or replace function public.notify_like_webhook()
38
- returns trigger language plpgsql security definer set search_path = public as $$
39
- begin
40
- perform net.http_post(
41
- url := '__SUPABASE_PROJECT_URL__/functions/v1/notify-like',
42
- headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__'),
43
- body := jsonb_build_object('record', to_jsonb(new))
44
- );
45
- return new;
46
- end; $$;
47
-
48
- -- ============ TRIGGERS ============
49
- -- Notify the post author only once a comment is actually published, never on
50
- -- the pending insert (a rejected comment must never send a push).
51
- create trigger on_comment_published
52
- after update on public.comments
53
- for each row
54
- when (old.status = 'pending' and new.status = 'visible')
55
- execute function public.notify_comment_webhook();
56
-
57
- -- Dashboard comments are inserted directly as 'visible' (service role) and
58
- -- must notify too. App comments are always inserted 'pending' (RLS-enforced),
59
- -- so nothing double-notifies.
60
- create trigger on_comment_created_visible
61
- after insert on public.comments
62
- for each row
63
- when (new.status = 'visible')
64
- execute function public.notify_comment_webhook();
65
-
66
- create trigger on_like_created
67
- after insert on public.likes
68
- for each row execute function public.notify_like_webhook();
69
-
70
- -- Hourly digest: flush the tail of any coalesced like bursts.
71
- do $$
72
- begin
73
- perform cron.schedule(
74
- 'community-like-digest', '0 * * * *',
75
- $cron$
76
- select net.http_post(
77
- url := '__SUPABASE_PROJECT_URL__/functions/v1/notify-like',
78
- headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__')
79
- );
80
- $cron$
81
- );
82
- exception when others then
83
- if sqlerrm like '%already exists%' then
84
- raise notice 'cron job community-like-digest already scheduled, skipping';
85
- else
86
- raise;
87
- end if;
88
- end;
89
- $$;
@@ -0,0 +1,50 @@
1
+ -- community-sdk push/003_webhooks.sql (schema v1)
2
+ -- Community SDK — push module: webhook functions, triggers and the hourly
3
+ -- digest cron, built on the project settings helpers (core/000_settings.sql).
4
+ -- Requires the notify-comment / notify-like Edge Functions. On an existing
5
+ -- install this replaces the functions and job that carried the project URL
6
+ -- baked in.
7
+
8
+ -- ============ WEBHOOK FUNCTIONS ============
9
+ create or replace function public.notify_comment_webhook()
10
+ returns trigger language plpgsql security definer set search_path = public as $$
11
+ begin
12
+ perform public.community_call_function('notify-comment', jsonb_build_object('record', to_jsonb(new)));
13
+ return new;
14
+ end; $$;
15
+
16
+ create or replace function public.notify_like_webhook()
17
+ returns trigger language plpgsql security definer set search_path = public as $$
18
+ begin
19
+ perform public.community_call_function('notify-like', jsonb_build_object('record', to_jsonb(new)));
20
+ return new;
21
+ end; $$;
22
+
23
+ -- ============ TRIGGERS ============
24
+ -- Notify the post author only once a comment is actually published, never on
25
+ -- the pending insert (a rejected comment must never send a push).
26
+ create or replace trigger on_comment_published
27
+ after update on public.comments
28
+ for each row
29
+ when (old.status = 'pending' and new.status = 'visible')
30
+ execute function public.notify_comment_webhook();
31
+
32
+ -- Dashboard comments are inserted directly as 'visible' (service role) and
33
+ -- must notify too. App comments are always inserted 'pending' (RLS-enforced),
34
+ -- so nothing double-notifies.
35
+ create or replace trigger on_comment_created_visible
36
+ after insert on public.comments
37
+ for each row
38
+ when (new.status = 'visible')
39
+ execute function public.notify_comment_webhook();
40
+
41
+ create or replace trigger on_like_created
42
+ after insert on public.likes
43
+ for each row execute function public.notify_like_webhook();
44
+
45
+ -- Hourly digest: flush the tail of any coalesced like bursts.
46
+ select public.community_schedule(
47
+ 'community-like-digest',
48
+ '0 * * * *',
49
+ $cron$ select public.community_call_function('notify-like'); $cron$
50
+ );
@@ -13,20 +13,9 @@
13
13
  -- notify-reaction webhook below still fires either way — it is the Edge
14
14
  -- Function's job to look up push_tokens if/when that module is present).
15
15
  --
16
- -- ⚠️ PLACEHOLDERS: before pushing, replace __SUPABASE_PROJECT_URL__ with the
17
- -- app's project URL (https://<ref>.supabase.co) and __SUPABASE_ANON_KEY__ with
18
- -- its anon key. The anon key is public by design (shipped inside the app
19
- -- binary): the Edge Functions do privileged work through their own
20
- -- service-role env, the JWT only passes verify_jwt.
21
- --
22
- -- The DO block below fails the migration loudly if the placeholders were not
23
- -- replaced.
24
- do $$ begin
25
- if '__SUPABASE_PROJECT_URL__' like '\_\_SUPABASE%' escape '\' then
26
- raise exception 'community-sdk: placeholders not substituted. Run: npx @rocapine/community init';
27
- end if;
28
- end $$;
29
-
16
+ -- The reaction webhook, its trigger and the half-hourly digest cron live in
17
+ -- reaction/002_webhooks.sql (they read the project URL / anon key from the
18
+ -- settings seeded via core/000_settings.sql).
30
19
  create extension if not exists pg_cron;
31
20
  create extension if not exists pg_net;
32
21
 
@@ -97,43 +86,3 @@ begin
97
86
  alter table public.push_tokens add column if not exists notify_reactions boolean not null default true;
98
87
  end if;
99
88
  end $$;
100
-
101
- -- ============ WEBHOOK + DIGEST ============
102
- -- Same pattern as push/002_triggers.sql's notify-like: an immediate webhook
103
- -- on insert plus a coalescing digest cron for anything the webhook missed.
104
- -- Requires the notify-reaction Edge Function.
105
- create or replace function public.notify_reaction_webhook()
106
- returns trigger language plpgsql security definer set search_path = public as $$
107
- begin
108
- perform net.http_post(
109
- url := '__SUPABASE_PROJECT_URL__/functions/v1/notify-reaction',
110
- headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__'),
111
- body := jsonb_build_object('record', to_jsonb(new))
112
- );
113
- return new;
114
- end; $$;
115
-
116
- create trigger on_post_reaction_created
117
- after insert on public.post_reactions
118
- for each row execute function public.notify_reaction_webhook();
119
-
120
- -- Half-hourly digest: flush the tail of any coalesced reaction bursts.
121
- do $$
122
- begin
123
- perform cron.schedule(
124
- 'community-reaction-digest', '30 * * * *',
125
- $cron$
126
- select net.http_post(
127
- url := '__SUPABASE_PROJECT_URL__/functions/v1/notify-reaction',
128
- headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer __SUPABASE_ANON_KEY__')
129
- );
130
- $cron$
131
- );
132
- exception when others then
133
- if sqlerrm like '%already exists%' then
134
- raise notice 'cron job community-reaction-digest already scheduled, skipping';
135
- else
136
- raise;
137
- end if;
138
- end;
139
- $$;
@@ -0,0 +1,24 @@
1
+ -- community-sdk reaction/002_webhooks.sql (schema v1)
2
+ -- Community SDK — reaction module: webhook + half-hourly digest, built on the
3
+ -- project settings helpers (core/000_settings.sql). Same pattern as
4
+ -- push/003_webhooks.sql's notify-like: an immediate webhook on insert plus a
5
+ -- coalescing digest cron for anything the webhook missed. Requires the
6
+ -- notify-reaction Edge Function. On an existing install this replaces the
7
+ -- function and job that carried the project URL baked in.
8
+ create or replace function public.notify_reaction_webhook()
9
+ returns trigger language plpgsql security definer set search_path = public as $$
10
+ begin
11
+ perform public.community_call_function('notify-reaction', jsonb_build_object('record', to_jsonb(new)));
12
+ return new;
13
+ end; $$;
14
+
15
+ create or replace trigger on_post_reaction_created
16
+ after insert on public.post_reactions
17
+ for each row execute function public.notify_reaction_webhook();
18
+
19
+ -- Half-hourly digest: flush the tail of any coalesced reaction bursts.
20
+ select public.community_schedule(
21
+ 'community-reaction-digest',
22
+ '30 * * * *',
23
+ $cron$ select public.community_call_function('notify-reaction'); $cron$
24
+ );
@@ -9,20 +9,9 @@
9
9
  -- likes). Additive: no core table changes. poll_option_translations is
10
10
  -- guarded so this module installs on a backend without the polls module.
11
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
-
12
+ -- The translate webhooks, their triggers and the daily sweep cron live in
13
+ -- translation/002_webhooks.sql (they read the project URL / anon key from the
14
+ -- settings seeded via core/000_settings.sql).
26
15
  create table public.post_translations (
27
16
  post_id uuid not null references public.posts(id) on delete cascade,
28
17
  locale text not null,
@@ -120,71 +109,3 @@ language sql stable security definer set search_path = public as $$
120
109
  $$;
121
110
  revoke execute on function public.items_missing_translations(text, text[], int) from public, anon, authenticated;
122
111
  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 $$;
@@ -0,0 +1,48 @@
1
+ -- community-sdk translation/002_webhooks.sql (schema v1)
2
+ -- Community SDK — translation module: translate webhooks, triggers and the
3
+ -- daily sweep cron, built on the project settings helpers
4
+ -- (core/000_settings.sql). Requires the translate-one / daily-translation
5
+ -- Edge Functions. On an existing install this replaces the functions and job
6
+ -- that carried the project URL baked in.
7
+
8
+ -- ============ TRIGGERS ============
9
+ create or replace function public.translate_post_webhook()
10
+ returns trigger language plpgsql security definer set search_path = public as $$
11
+ begin
12
+ perform public.community_call_function('translate-one', jsonb_build_object('kind', 'post', 'id', new.id));
13
+ return new;
14
+ end; $$;
15
+
16
+ create or replace function public.translate_comment_webhook()
17
+ returns trigger language plpgsql security definer set search_path = public as $$
18
+ begin
19
+ perform public.community_call_function('translate-one', jsonb_build_object('kind', 'comment', 'id', new.id));
20
+ return new;
21
+ end; $$;
22
+
23
+ create or replace trigger on_post_published_translate
24
+ after update of status on public.posts for each row
25
+ when (old.status = 'pending' and new.status = 'visible')
26
+ execute function public.translate_post_webhook();
27
+ create or replace trigger on_post_created_visible_translate
28
+ after insert on public.posts for each row
29
+ when (new.status = 'visible')
30
+ execute function public.translate_post_webhook();
31
+
32
+ create or replace trigger on_comment_published_translate
33
+ after update of status on public.comments for each row
34
+ when (old.status = 'pending' and new.status = 'visible')
35
+ execute function public.translate_comment_webhook();
36
+ create or replace trigger on_comment_created_visible_translate
37
+ after insert on public.comments for each row
38
+ when (new.status = 'visible')
39
+ execute function public.translate_comment_webhook();
40
+
41
+ -- ============ CRON ============
42
+ -- Daily sweep at 08:30 UTC (after the 08:00 moderation sweep): back-fills the
43
+ -- history on first installation, then catches anything translate-one missed.
44
+ select public.community_schedule(
45
+ 'community-translation-sweep',
46
+ '30 8 * * *',
47
+ $cron$ select public.community_call_function('daily-translation'); $cron$
48
+ );