@rocapine/community 0.10.2 → 0.12.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
@@ -13,7 +13,7 @@ version. The binary is named `community`.
13
13
  ## `init`
14
14
 
15
15
  ```bash
16
- npx @rocapine/community init [--modules core,push,polls,reaction,inbox,translation] [--project-url <url>] [--anon-key <key>] [--dir supabase]
16
+ npx @rocapine/community init [--modules core,push,polls,reaction,inbox,translation,images] [--project-url <url>] [--anon-key <key>] [--dir supabase]
17
17
  ```
18
18
 
19
19
  Copies the migrations and Edge Functions for the requested modules into
@@ -21,10 +21,10 @@ Copies the migrations and Edge Functions for the requested modules into
21
21
  relative to the current directory), and writes a `community-sdk.json`
22
22
  manifest at the repo root recording what was installed.
23
23
 
24
- - `--modules` — comma-separated (`core,push,polls,reaction,inbox,translation`). `core`
24
+ - `--modules` — comma-separated (`core,push,polls,reaction,inbox,translation,images`). `core`
25
25
  is always implied even if omitted. Omit the flag entirely to install every
26
26
  module. Regardless of the order you type them, modules are installed
27
- `core → push → polls → reaction → inbox → translation` — this order is load-bearing:
27
+ `core → push → polls → reaction → inbox → translation → images` — this order is load-bearing:
28
28
  the inbox module's reaction trigger is guarded at install time and must
29
29
  find the reaction module's table already present, so reaction has to land
30
30
  first. Requesting `inbox` without `reaction`, or `translation` without `polls`, prints a (non-fatal) warning (reactions missing from the inbox; poll labels left untranslated).
@@ -64,7 +64,14 @@ are only needed if a newly-added migration carries a placeholder. Fails if
64
64
  `--add-modules <modules>` installs one or more modules not yet in
65
65
  `community-sdk.json` on top of the existing backend (comma-separated, same
66
66
  parsing as `init --modules`) — e.g. `upgrade --add-modules translation` to
67
- add the translation module to an already-initialized app.
67
+ add the translation module to an already-initialized app, or
68
+ `upgrade --add-modules images` for post and comment images (then
69
+ `supabase db push` and `supabase functions deploy`: the moderation, push-copy
70
+ and report functions changed too). `images` ships `images/001_images.sql`
71
+ (columns, the public `post-images` bucket with its 2 MB / JPEG-only /
72
+ own-folder rules, and the sweep RPC); a host that already has the translation
73
+ module also receives `translation/003_skip_empty.sql` through the plain
74
+ `upgrade`, so image-only items are not picked again by the translation sweep.
68
75
 
69
76
  ## `adopt`
70
77
 
@@ -141,5 +141,8 @@ function printNextSteps(log, modules, values) {
141
141
  if (modules.includes("translation")) {
142
142
  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');
143
143
  }
144
+ if (modules.includes("images")) {
145
+ log(" images module: requires expo-image-manipulator in the app; no secret");
146
+ }
144
147
  log(" 4. Deploy the Edge Functions: supabase functions deploy");
145
148
  }
@@ -284,4 +284,7 @@ function printSummary(log, onWarn, summary) {
284
284
  if (summary.addedModules.includes("translation")) {
285
285
  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');
286
286
  }
287
+ if (summary.addedModules.includes("images")) {
288
+ log(" 4. images module: requires expo-image-manipulator in the app; no secret");
289
+ }
287
290
  }
package/lib/index.js CHANGED
@@ -60,7 +60,7 @@ program
60
60
  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
- .option("--modules <modules>", "comma-separated module list (core,push,polls,reaction,inbox,translation) — core is always implied; defaults to all modules")
63
+ .option("--modules <modules>", "comma-separated module list (core,push,polls,reaction,inbox,translation,images) — core is always implied; defaults to all modules")
64
64
  .option("--project-url <url>", "Supabase project URL, e.g. https://<ref>.supabase.co — optional, only fills in the printed settings seed statement")
65
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")
@@ -103,7 +103,7 @@ program
103
103
  .command("adopt")
104
104
  .description("Register an already-live community backend (e.g. an app that hand-rolled its own before this SDK existed) with community-sdk.json, without copying any files")
105
105
  .requiredOption("--schema-version <n>", "the community-sdk schema version this app's live backend corresponds to")
106
- .requiredOption("--modules <modules>", "comma-separated module list (core,push,polls,reaction,inbox,translation) — core is always implied")
106
+ .requiredOption("--modules <modules>", "comma-separated module list (core,push,polls,reaction,inbox,translation,images) — core is always implied")
107
107
  .option("--dir <dir>", "Supabase directory this app would use, relative to the current directory", "supabase")
108
108
  .action(async (opts) => {
109
109
  try {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Ruled install order (controller ruling, task 18): core -> push -> polls ->
3
- * reaction -> inbox -> translation. Reaction MUST land before inbox —
3
+ * reaction -> inbox -> translation -> images. Reaction MUST land before inbox —
4
4
  * inbox/001's reaction trigger is guarded with
5
5
  * `to_regclass('public.post_reactions')` at install time and is never
6
6
  * retroactively wired if the reaction module shows up later, so `reaction`
@@ -10,7 +10,7 @@
10
10
  * single source of truth for module names, order, and the function sets
11
11
  * each module pulls in.
12
12
  */
13
- export declare const MODULE_ORDER: readonly ["core", "push", "polls", "reaction", "inbox", "translation"];
13
+ export declare const MODULE_ORDER: readonly ["core", "push", "polls", "reaction", "inbox", "translation", "images"];
14
14
  export type Module = (typeof MODULE_ORDER)[number];
15
15
  export declare function functionsForModules(modules: readonly Module[]): string[];
16
16
  /**
@@ -55,7 +55,7 @@ const fs = __importStar(require("node:fs"));
55
55
  const path = __importStar(require("node:path"));
56
56
  /**
57
57
  * Ruled install order (controller ruling, task 18): core -> push -> polls ->
58
- * reaction -> inbox -> translation. Reaction MUST land before inbox —
58
+ * reaction -> inbox -> translation -> images. Reaction MUST land before inbox —
59
59
  * inbox/001's reaction trigger is guarded with
60
60
  * `to_regclass('public.post_reactions')` at install time and is never
61
61
  * retroactively wired if the reaction module shows up later, so `reaction`
@@ -65,7 +65,15 @@ const path = __importStar(require("node:path"));
65
65
  * single source of truth for module names, order, and the function sets
66
66
  * each module pulls in.
67
67
  */
68
- exports.MODULE_ORDER = ["core", "push", "polls", "reaction", "inbox", "translation"];
68
+ exports.MODULE_ORDER = [
69
+ "core",
70
+ "push",
71
+ "polls",
72
+ "reaction",
73
+ "inbox",
74
+ "translation",
75
+ "images",
76
+ ];
69
77
  const ALWAYS_FUNCTIONS = [
70
78
  "moderate-one",
71
79
  "daily-moderation",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community",
3
- "version": "0.10.2",
3
+ "version": "0.12.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": {
@@ -10,6 +10,7 @@
10
10
  // "comment.title": { "pl": "Otrzymałaś komentarz" },
11
11
  // "like.one": { "pl": "{name} polubiła twój post" },
12
12
  // "reaction.one": { "en": "{name} prayed for you 🙏" },
13
+ // "photo": { "en": "📷 A picture" },
13
14
  // "reaction.many": { "en": "{name} and {count} others prayed for you 🙏",
14
15
  // "pl": { "one": "…", "few": "…", "many": "…" } }
15
16
  // }'
@@ -29,11 +30,18 @@ type CopyValue = string | Forms;
29
30
  type LocaleCopy = Record<string, CopyValue>;
30
31
 
31
32
  export type PushCopyKey =
32
- "fallbackName" | "comment.title" | "like.one" | "like.many" | "reaction.one" | "reaction.many";
33
+ | "fallbackName"
34
+ | "comment.title"
35
+ | "like.one"
36
+ | "like.many"
37
+ | "reaction.one"
38
+ | "reaction.many"
39
+ | "photo";
33
40
 
34
41
  const BUILTIN: Record<string, LocaleCopy> = {
35
42
  en: {
36
43
  fallbackName: "Someone",
44
+ photo: "📷 Photo",
37
45
  "comment.title": "You received a comment",
38
46
  "like.one": "{name} liked your post",
39
47
  "like.many": {
@@ -48,6 +56,7 @@ const BUILTIN: Record<string, LocaleCopy> = {
48
56
  },
49
57
  "pt-PT": {
50
58
  fallbackName: "Alguém",
59
+ photo: "📷 Foto",
51
60
  "comment.title": "Recebeste um comentário",
52
61
  "like.one": "{name} gostou da tua publicação",
53
62
  "like.many": {
@@ -62,6 +71,7 @@ const BUILTIN: Record<string, LocaleCopy> = {
62
71
  },
63
72
  "pt-BR": {
64
73
  fallbackName: "Alguém",
74
+ photo: "📷 Foto",
65
75
  "comment.title": "Você recebeu um comentário",
66
76
  "like.one": "{name} curtiu seu post",
67
77
  "like.many": {
@@ -76,6 +86,7 @@ const BUILTIN: Record<string, LocaleCopy> = {
76
86
  },
77
87
  "es-ES": {
78
88
  fallbackName: "Alguien",
89
+ photo: "📷 Foto",
79
90
  "comment.title": "Has recibido un comentario",
80
91
  "like.one": "A {name} le ha gustado tu publicación",
81
92
  "like.many": {
@@ -90,6 +101,7 @@ const BUILTIN: Record<string, LocaleCopy> = {
90
101
  },
91
102
  "es-419": {
92
103
  fallbackName: "Alguien",
104
+ photo: "📷 Foto",
93
105
  "comment.title": "Recibiste un comentario",
94
106
  "like.one": "A {name} le gustó tu publicación",
95
107
  "like.many": {
@@ -104,6 +116,7 @@ const BUILTIN: Record<string, LocaleCopy> = {
104
116
  },
105
117
  it: {
106
118
  fallbackName: "Qualcuno",
119
+ photo: "📷 Foto",
107
120
  "comment.title": "Hai ricevuto un commento",
108
121
  "like.one": "A {name} piace il tuo post",
109
122
  "like.many": {
@@ -118,6 +131,7 @@ const BUILTIN: Record<string, LocaleCopy> = {
118
131
  },
119
132
  pl: {
120
133
  fallbackName: "Ktoś",
134
+ photo: "📷 Zdjęcie",
121
135
  "comment.title": "Masz nowy komentarz",
122
136
  "like.one": "{name} polubił(a) twój post",
123
137
  "like.many": {
@@ -134,6 +148,7 @@ const BUILTIN: Record<string, LocaleCopy> = {
134
148
  },
135
149
  fr: {
136
150
  fallbackName: "Quelqu'un",
151
+ photo: "📷 Photo",
137
152
  "comment.title": "Tu as reçu un commentaire",
138
153
  "like.one": "{name} a aimé ta publication",
139
154
  "like.many": {
@@ -148,6 +163,7 @@ const BUILTIN: Record<string, LocaleCopy> = {
148
163
  },
149
164
  de: {
150
165
  fallbackName: "Jemand",
166
+ photo: "📷 Foto",
151
167
  "comment.title": "Du hast einen Kommentar erhalten",
152
168
  "like.one": "{name} gefällt dein Beitrag",
153
169
  "like.many": {
@@ -232,6 +248,12 @@ function fill(template: string, params: { name?: string; count?: number }): stri
232
248
  .replace(/\{count\}/g, params.count === undefined ? "" : String(params.count));
233
249
  }
234
250
 
251
+ /** Push/inbox excerpt: the first 140 characters, or `photo` for an image-only item. */
252
+ export function excerptOrPhoto(content: string, photo: string): string {
253
+ if (!content.trim()) return photo;
254
+ return content.length > 140 ? `${content.slice(0, 137)}...` : content;
255
+ }
256
+
235
257
  /** Copy resolver for one recipient (`profiles.locale`). */
236
258
  export function pushCopy(recipientLocale: string | null | undefined) {
237
259
  const locale = resolveLocale(recipientLocale);
@@ -0,0 +1,62 @@
1
+ // Post/comment images (optional `images` module): bucket name, public URL and
2
+ // the soft-failing lookups the moderation functions share.
3
+
4
+ import { adminClient, SUPABASE_URL } from "./client.ts";
5
+
6
+ type Admin = ReturnType<typeof adminClient>;
7
+
8
+ export const POST_IMAGES_BUCKET = "post-images";
9
+
10
+ export function postImageUrl(path: string): string {
11
+ return `${SUPABASE_URL}/storage/v1/object/public/${POST_IMAGES_BUCKET}/${path}`;
12
+ }
13
+
14
+ // Postgres' undefined_column (42703) or PostgREST's own "column not found"
15
+ // (PGRST204): the install has no images module. Any other error is a transient
16
+ // failure, never "no image".
17
+ const isMissingColumn = (code: string | undefined) => code === "42703" || code === "PGRST204";
18
+
19
+ // Separate select (never part of a wider one) so an install without the images
20
+ // module keeps its text-only behavior: only a missing column means "no image".
21
+ // Any other error is `ok: false` and the caller must fail closed, otherwise an
22
+ // unchecked image could be published.
23
+ export async function fetchImagePath(
24
+ supabase: Admin,
25
+ table: "posts" | "comments",
26
+ id: string,
27
+ ): Promise<{ ok: true; path: string | null } | { ok: false }> {
28
+ const { data, error } = await supabase.from(table).select("image_path").eq("id", id).single();
29
+ if (error) return isMissingColumn(error.code) ? { ok: true, path: null } : { ok: false };
30
+ return { ok: true, path: (data as { image_path: string | null } | null)?.image_path ?? null };
31
+ }
32
+
33
+ // Batch variant for the daily sweep, chunked so a backlog can't build an
34
+ // over-long URL. `paths` holds the rows that have an image; `failedIds` the
35
+ // rows whose chunk errored (caller must not process them this run). A missing
36
+ // column ends the lookup: no images module, nobody has an image.
37
+ export async function fetchImagePaths(
38
+ supabase: Admin,
39
+ table: "posts" | "comments",
40
+ ids: string[],
41
+ chunkSize = 100,
42
+ ): Promise<{ paths: Map<string, string>; failedIds: Set<string> }> {
43
+ const paths = new Map<string, string>();
44
+ const failedIds = new Set<string>();
45
+ for (let i = 0; i < ids.length; i += chunkSize) {
46
+ const chunk = ids.slice(i, i + chunkSize);
47
+ const { data, error } = await supabase
48
+ .from(table)
49
+ .select("id, image_path")
50
+ .in("id", chunk)
51
+ .not("image_path", "is", null);
52
+ if (error) {
53
+ if (isMissingColumn(error.code)) return { paths: new Map(), failedIds: new Set() };
54
+ chunk.forEach((id) => failedIds.add(id));
55
+ continue;
56
+ }
57
+ for (const r of (data ?? []) as { id: string; image_path: string }[]) {
58
+ paths.set(r.id, r.image_path);
59
+ }
60
+ }
61
+ return { paths, failedIds };
62
+ }
@@ -49,13 +49,17 @@ export interface ModerationResult {
49
49
  category_scores: Record<string, number>;
50
50
  }
51
51
 
52
- export function flaggedCategories(result: ModerationResult): string[] {
52
+ // `strict` (image calls) ignores MODERATION_EXCLUDED_CATEGORIES: the exclusion
53
+ // exists for text discussion, never for pictures. Set by the caller, not
54
+ // inferred from the response, so a missing response field can't relax it.
55
+ export function flaggedCategories(result: ModerationResult, strict = false): string[] {
56
+ const hide = strict ? CATEGORIES_TO_HIDE : SCORE_HIDE;
53
57
  const cats = new Set<string>();
54
58
  for (const [cat, v] of Object.entries(result.categories)) {
55
59
  if (v && CATEGORIES_TO_HIDE.includes(cat)) cats.add(cat);
56
60
  }
57
61
  for (const [cat, score] of Object.entries(result.category_scores)) {
58
- if (SCORE_HIDE.includes(cat) && score >= SCORE_THRESHOLD) cats.add(cat);
62
+ if (hide.includes(cat) && score >= SCORE_THRESHOLD) cats.add(cat);
59
63
  }
60
64
  return [...cats];
61
65
  }
@@ -73,14 +77,24 @@ export function assertModerationConfigured(): void {
73
77
  }
74
78
  }
75
79
 
76
- export type ImageInput = { type: "image_url"; image_url: { url: string } }[];
80
+ export type ContentPart =
81
+ { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } };
77
82
 
78
- // POSTs to OpenAI moderation. `input` is a string for text or the image_url
79
- // content-part array for an avatar. Returns null on a non-OK response so the
80
- // caller can fail closed (nothing published / nothing written).
83
+ // Kept as an alias for update-profile (avatars).
84
+ export type ImageInput = ContentPart[];
85
+
86
+ // POSTs to OpenAI moderation. `input` is a string for text or a content-part
87
+ // array; pass `{ strict: true }` for image calls (see flaggedCategories).
88
+ // Returns null on a non-OK response so the caller can fail closed (nothing
89
+ // published / nothing written).
81
90
  export async function moderateInput(
82
- input: string | ImageInput,
83
- ): Promise<{ flagged: boolean; reason: string | null } | null> {
91
+ input: string | ContentPart[],
92
+ { strict = false }: { strict?: boolean } = {},
93
+ ): Promise<{
94
+ flagged: boolean;
95
+ reason: string | null;
96
+ categories: string[];
97
+ } | null> {
84
98
  const res = await fetch("https://api.openai.com/v1/moderations", {
85
99
  method: "POST",
86
100
  headers: {
@@ -95,8 +109,32 @@ export async function moderateInput(
95
109
  return null;
96
110
  }
97
111
  const { results } = await res.json();
98
- const cats = flaggedCategories(results[0]);
99
- return cats.length > 0
100
- ? { flagged: true, reason: cats.join(",") }
101
- : { flagged: false, reason: null };
112
+ const categories = flaggedCategories(results[0], strict);
113
+ return {
114
+ flagged: categories.length > 0,
115
+ reason: categories.length > 0 ? categories.join(",") : null,
116
+ categories,
117
+ };
118
+ }
119
+
120
+ // One verdict for a post/comment. Text and image are moderated in separate
121
+ // calls so only the image is judged by the strict rules: a combined call can't
122
+ // tell which part drove a score, so allowed (excluded) text would be too.
123
+ export async function moderateItem(
124
+ text: string,
125
+ imageUrl: string | null,
126
+ ): Promise<{ flagged: boolean; reason: string | null; categories: string[] } | null> {
127
+ const verdicts = await Promise.all([
128
+ text ? moderateInput(text) : { categories: [] as string[] },
129
+ imageUrl
130
+ ? moderateInput([{ type: "image_url", image_url: { url: imageUrl } }], { strict: true })
131
+ : { categories: [] as string[] },
132
+ ]);
133
+ if (verdicts.some((v) => v === null)) return null;
134
+ const categories = [...new Set(verdicts.flatMap((v) => v!.categories))];
135
+ return {
136
+ flagged: categories.length > 0,
137
+ reason: categories.length > 0 ? categories.join(",") : null,
138
+ categories,
139
+ };
102
140
  }
@@ -0,0 +1,53 @@
1
+ // Display name / handle rules shared by update-profile and the daily sweep:
2
+ // the app's own name is reserved (no "Unchaind Team" impersonating the app),
3
+ // a few staff-sounding handles too, and a taken handle gets a free suggestion.
4
+
5
+ const APP_NAME = Deno.env.get("COMMUNITY_APP_NAME") ?? "";
6
+
7
+ /** Lowercase, accents stripped, letters and digits only. */
8
+ function collapse(s: string): string {
9
+ return s
10
+ .normalize("NFD")
11
+ .replace(/\p{Diacritic}/gu, "")
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9]/g, "");
14
+ }
15
+
16
+ /** True when `value` contains the app's name. Short names (< 4 chars, "Eve") only match a whole word so "Steve" stays allowed. */
17
+ export function containsAppName(value: string, appName: string = APP_NAME): boolean {
18
+ const app = collapse(appName);
19
+ if (app === "") return false;
20
+ if (app.length >= 4) return collapse(value).includes(app);
21
+ return value
22
+ .normalize("NFD")
23
+ .replace(/\p{Diacritic}/gu, "")
24
+ .toLowerCase()
25
+ .split(/[^a-z0-9]+/)
26
+ .includes(app);
27
+ }
28
+
29
+ const RESERVED_HANDLES = ["admin", "support", "official", "team", "moderator"];
30
+
31
+ export function isReservedHandle(handle: string, appName: string = APP_NAME): boolean {
32
+ return RESERVED_HANDLES.includes(handle) || containsAppName(handle, appName);
33
+ }
34
+
35
+ /**
36
+ * A free handle on `handle`'s base: "superuser" -> "superuser-4821". Same
37
+ * digit widening as generate_handle (core/009). `isFree` checks the table.
38
+ */
39
+ export async function suggestHandle(
40
+ handle: string,
41
+ isFree: (candidate: string) => Promise<boolean>,
42
+ ): Promise<string | null> {
43
+ const base = handle.replace(/-[0-9]+$/, "").slice(0, 15) || "user";
44
+ for (let tries = 0; tries < 10; tries++) {
45
+ const digits =
46
+ tries < 5
47
+ ? Math.floor(Math.random() * 9000) + 1000
48
+ : Math.floor(Math.random() * 900000) + 100000;
49
+ const candidate = `${base}-${digits}`;
50
+ if (await isFree(candidate)) return candidate;
51
+ }
52
+ return null;
53
+ }
@@ -68,6 +68,11 @@ export type ParsedTranslation = {
68
68
  translations: Record<string, { content: string; options: string[] }>;
69
69
  };
70
70
 
71
+ /** Image-only items store content = "": nothing to translate, never sent to OpenAI. */
72
+ export function hasTranslatableContent(content: string): boolean {
73
+ return content.trim().length > 0;
74
+ }
75
+
71
76
  export function languageOf(locale: string): string {
72
77
  return locale.split("-")[0].toLowerCase();
73
78
  }
@@ -395,6 +400,7 @@ export async function ensureTranslations(
395
400
  return null;
396
401
  }
397
402
  if (!row || row.status !== "visible") return [];
403
+ if (!hasTranslatableContent(row.content)) return [];
398
404
 
399
405
  const { data: existing, error: existingError } = await supabase
400
406
  .from(tTable)
@@ -5,6 +5,7 @@
5
5
  // verify_jwt but must NOT be able to trigger a broadcast.
6
6
 
7
7
  import { adminClient, fetchAllRows, isServiceCaller } from "../_shared/client.ts";
8
+ import { excerptOrPhoto, pushCopy } from "../_shared/copy.ts";
8
9
  import { sendExpoPushBatch } from "../_shared/push.ts";
9
10
  import { BROADCAST_FALLBACK_TITLE } from "../_shared/config.ts";
10
11
  import { ensureTranslations, resolveTargetLocale, TARGET_LOCALES } from "../_shared/translation.ts";
@@ -33,17 +34,17 @@ Deno.serve(async (req) => {
33
34
  .single();
34
35
  const title = author?.username?.trim() || BROADCAST_FALLBACK_TITLE;
35
36
 
36
- const original = post.content.length > 140 ? `${post.content.slice(0, 137)}...` : post.content;
37
37
  // waitMs: when translate-one holds the claim (it fires on the same insert),
38
38
  // wait for its rows instead of pushing the source language.
39
39
  const translations =
40
40
  TARGET_LOCALES.length === 0
41
41
  ? []
42
42
  : ((await ensureTranslations(supabase, "post", post.id, { waitMs: 20_000 })) ?? []);
43
- const excerptFor = (locale: string | null): string => {
44
- if (!locale) return original;
45
- const t = translations.find((r) => r.locale === locale);
46
- return t ? (t.content.length > 140 ? `${t.content.slice(0, 137)}...` : t.content) : original;
43
+ // `profileLocale` picks the "Photo" label for an image-only post (empty
44
+ // content has no translations); `locale` is the resolved translation target.
45
+ const excerptFor = (locale: string | null, profileLocale: string | null): string => {
46
+ const t = locale ? translations.find((r) => r.locale === locale) : undefined;
47
+ return excerptOrPhoto(t?.content ?? post.content, pushCopy(profileLocale).text("photo"));
47
48
  };
48
49
 
49
50
  // Paged (PostgREST max_rows caps a single select, default 1000) with a total
@@ -66,6 +67,7 @@ Deno.serve(async (req) => {
66
67
  const profile = Array.isArray(r.profiles) ? r.profiles[0] : r.profiles;
67
68
  return {
68
69
  to: r.expo_push_token as string,
70
+ profileLocale: (profile?.locale ?? null) as string | null,
69
71
  locale: resolveTargetLocale(profile?.locale ?? null),
70
72
  };
71
73
  })
@@ -75,10 +77,10 @@ Deno.serve(async (req) => {
75
77
  seen.add(m.to);
76
78
  return true;
77
79
  })
78
- .map(({ to, locale }) => ({
80
+ .map(({ to, locale, profileLocale }) => ({
79
81
  to,
80
82
  title,
81
- body: excerptFor(locale),
83
+ body: excerptFor(locale, profileLocale),
82
84
  data: { route: "/community", kind: "community_official_post" },
83
85
  badge: 1,
84
86
  }));
@@ -3,7 +3,8 @@
3
3
  // 'pending' -> promote to 'visible' (catches items whose synchronous
4
4
  // moderate-one call failed), clean 'visible' -> just stamp. Also sweeps
5
5
  // not-yet-checked usernames (profiles.username is client-writable, see
6
- // core/007_username_moderation.sql): flagged -> blanked. Posts a Slack
6
+ // core/007_username_moderation.sql): flagged or containing the app's name ->
7
+ // blanked, i.e. back to the default name (core/009). Posts a Slack
7
8
  // summary when there was something to check (no-op if SLACK_WEBHOOK_URL is
8
9
  // unset — see _shared/slack.ts). Never deletes rows. App criticism is not a
9
10
  // category (stays visible).
@@ -16,8 +17,11 @@ import { adminClient } from "../_shared/client.ts";
16
17
  import {
17
18
  assertModerationConfigured,
18
19
  flaggedCategories,
20
+ moderateItem,
19
21
  type ModerationResult,
20
22
  } from "../_shared/moderation.ts";
23
+ import { fetchImagePaths, POST_IMAGES_BUCKET, postImageUrl } from "../_shared/images.ts";
24
+ import { containsAppName } from "../_shared/names.ts";
21
25
  import { postToSlack } from "../_shared/slack.ts";
22
26
 
23
27
  assertModerationConfigured();
@@ -29,6 +33,9 @@ interface Item {
29
33
  content: string;
30
34
  author_id: string;
31
35
  status: string;
36
+ imagePath: string | null;
37
+ // The image_path lookup errored: not moderated (nor stamped/promoted) this run.
38
+ imageLookupFailed: boolean;
32
39
  profiles: {
33
40
  username: string | null;
34
41
  amplitude_id: string | null;
@@ -104,6 +111,7 @@ async function sweepUsernames(): Promise<{
104
111
  }
105
112
  results.forEach((r, idx) => {
106
113
  const cats = flaggedCategories(r);
114
+ if (containsAppName(batch[idx].username)) cats.push("app_name");
107
115
  if (cats.length > 0) blanked.push({ profile: batch[idx], categories: cats });
108
116
  });
109
117
  processed.push(...batch);
@@ -126,6 +134,24 @@ async function sweepUsernames(): Promise<{
126
134
  return { checked: processed.length, blanked, failedBatches, skipped: false };
127
135
  }
128
136
 
137
+ /** Deletes storage objects no live post/comment references (abandoned uploads,
138
+ * deleted authors' files), capped per run. Skipped silently without the images
139
+ * module (the RPC doesn't exist). Returns how many files were removed, and the
140
+ * storage error when the removal failed (surfaced in the Slack summary). */
141
+ async function sweepUnreferencedImages(): Promise<{ removed: number; error?: string }> {
142
+ const { data, error } = await supabase.rpc("post_images_unreferenced", { p_limit: 500 });
143
+ const names = (data ?? []) as string[];
144
+ if (error || names.length === 0) return { removed: 0 };
145
+ const { data: removed, error: removeError } = await supabase.storage
146
+ .from(POST_IMAGES_BUCKET)
147
+ .remove(names);
148
+ if (removeError) {
149
+ console.error("image sweep: remove failed", removeError.message);
150
+ return { removed: 0, error: removeError.message };
151
+ }
152
+ return { removed: removed?.length ?? 0 };
153
+ }
154
+
129
155
  Deno.serve(async () => {
130
156
  const { data: posts, error: postsError } = await supabase
131
157
  .from("posts")
@@ -167,9 +193,21 @@ Deno.serve(async () => {
167
193
  }
168
194
  }
169
195
  }
196
+ const postImages = await fetchImagePaths(
197
+ supabase,
198
+ "posts",
199
+ (posts ?? []).map((p) => p.id),
200
+ );
201
+ const commentImages = await fetchImagePaths(
202
+ supabase,
203
+ "comments",
204
+ (comments ?? []).map((c) => c.id),
205
+ );
170
206
  const items: Item[] = [
171
207
  ...(posts ?? []).map((p) => ({
172
208
  ...p,
209
+ imagePath: postImages.paths.get(p.id) ?? null,
210
+ imageLookupFailed: postImages.failedIds.has(p.id),
173
211
  profiles: toOne(p.profiles),
174
212
  content: [
175
213
  p.content,
@@ -179,6 +217,8 @@ Deno.serve(async () => {
179
217
  })),
180
218
  ...(comments ?? []).map((c) => ({
181
219
  ...c,
220
+ imagePath: commentImages.paths.get(c.id) ?? null,
221
+ imageLookupFailed: commentImages.failedIds.has(c.id),
182
222
  profiles: toOne(c.profiles),
183
223
  table: "comments" as const,
184
224
  })),
@@ -189,8 +229,10 @@ Deno.serve(async () => {
189
229
  const flagged: { item: Item; categories: string[] }[] = [];
190
230
  const processed: Item[] = [];
191
231
  let failedBatches = 0;
192
- for (let i = 0; i < items.length; i += 100) {
193
- const batch = items.slice(i, i + 100);
232
+ const textItems = items.filter((it) => !it.imagePath && !it.imageLookupFailed);
233
+ if (items.some((it) => it.imageLookupFailed)) failedBatches++;
234
+ for (let i = 0; i < textItems.length; i += 100) {
235
+ const batch = textItems.slice(i, i + 100);
194
236
  const results = await moderateBatch(batch.map((it) => it.content));
195
237
  if (!results) {
196
238
  failedBatches++; // API/network error; skip stamping, retried next run
@@ -202,13 +244,35 @@ Deno.serve(async () => {
202
244
  });
203
245
  processed.push(...batch);
204
246
  }
247
+ // Items with an image go one by one (text + image calls); a failed one
248
+ // counts as a failed batch: not stamped, retried next run.
249
+ for (const item of items.filter((it) => it.imagePath)) {
250
+ const verdict = await moderateItem(item.content, postImageUrl(item.imagePath!));
251
+ if (!verdict) {
252
+ failedBatches++;
253
+ continue;
254
+ }
255
+ if (verdict.flagged) flagged.push({ item, categories: verdict.categories });
256
+ processed.push(item);
257
+ }
205
258
  const totalItems = items.length;
206
259
 
207
260
  const flaggedIds = new Set(flagged.map((f) => f.item.id));
208
261
  // Status guards: a row the author soft-deleted while the sweep ran must not
209
262
  // be resurrected as hidden/visible, so every write re-checks the status it
210
263
  // was selected with.
264
+ const imageErrors: string[] = [];
211
265
  for (const { item, categories } of flagged) {
266
+ // Child-safety hit: the file goes now. Any other hide keeps it.
267
+ if (item.imagePath && categories.includes("sexual/minors")) {
268
+ const { error } = await supabase.storage.from(POST_IMAGES_BUCKET).remove([item.imagePath]);
269
+ if (error) {
270
+ console.error("sexual/minors image not deleted", item.imagePath, error.message);
271
+ imageErrors.push(
272
+ `sexual/minors image \`${item.imagePath}\` NOT deleted (${error.message}), remove it by hand`,
273
+ );
274
+ }
275
+ }
212
276
  await supabase
213
277
  .from(item.table)
214
278
  .update({ status: "hidden", moderation_reason: categories.join(",") })
@@ -245,11 +309,20 @@ Deno.serve(async () => {
245
309
  }
246
310
 
247
311
  const usernames = await sweepUsernames();
312
+ const sweep = await sweepUnreferencedImages();
313
+ const imagesSwept = sweep.removed;
314
+ if (sweep.error) imageErrors.push(`image sweep failed: ${sweep.error}`);
248
315
  const allFailedBatches = failedBatches + usernames.failedBatches;
249
316
 
250
317
  // Quiet when there was nothing to check at all (a stray or duplicate call
251
318
  // finds every item already stamped): no Slack noise, no API cost.
252
- if (totalItems === 0 && usernames.checked === 0 && allFailedBatches === 0) {
319
+ if (
320
+ totalItems === 0 &&
321
+ usernames.checked === 0 &&
322
+ allFailedBatches === 0 &&
323
+ imagesSwept === 0 &&
324
+ imageErrors.length === 0
325
+ ) {
253
326
  return new Response(
254
327
  JSON.stringify({
255
328
  checked: 0,
@@ -278,20 +351,24 @@ Deno.serve(async () => {
278
351
  usernames.checked > 0
279
352
  ? ` Usernames: ${usernames.blanked.length}/${usernames.checked} blanked.`
280
353
  : "";
354
+ const sweepSummary = imagesSwept > 0 ? ` Images swept: ${imagesSwept}.` : "";
281
355
  let summaryText: string;
282
356
  if (allFailedBatches > 0) {
283
357
  const uncheckedCount = totalItems - processed.length;
284
358
  summaryText =
285
359
  `Daily moderation: WARNING, ${allFailedBatches} batch(es) failed to reach the moderation API ` +
286
360
  `(${uncheckedCount} of ${totalItems} items were not checked and will be retried). ` +
287
- `${flagged.length} items hidden.${usernameSummary}`;
361
+ `${flagged.length} items hidden.${usernameSummary}${sweepSummary}`;
288
362
  } else if (flagged.length === 0 && usernames.blanked.length === 0) {
289
- summaryText = `Daily moderation: ${processed.length} items checked, nothing to report.${usernameSummary}`;
363
+ summaryText = `Daily moderation: ${processed.length} items checked, nothing to report.${usernameSummary}${sweepSummary}`;
290
364
  } else {
291
365
  summaryText =
292
- `Daily moderation: ${flagged.length}/${processed.length} items hidden.${usernameSummary}\n\n` +
366
+ `Daily moderation: ${flagged.length}/${processed.length} items hidden.${usernameSummary}${sweepSummary}\n\n` +
293
367
  [...lines, ...usernameLines].join("\n");
294
368
  }
369
+ if (imageErrors.length > 0) {
370
+ summaryText += `\n\n${imageErrors.map((e) => `* WARNING: ${e}`).join("\n")}`;
371
+ }
295
372
  await postToSlack({ text: summaryText });
296
373
 
297
374
  return new Response(
@@ -11,7 +11,9 @@
11
11
  // resurrected.
12
12
 
13
13
  import { adminClient, callerUserId, isServiceCaller, json } from "../_shared/client.ts";
14
- import { assertModerationConfigured, moderateInput } from "../_shared/moderation.ts";
14
+ import { fetchImagePath, POST_IMAGES_BUCKET, postImageUrl } from "../_shared/images.ts";
15
+ import { assertModerationConfigured, moderateItem } from "../_shared/moderation.ts";
16
+ import { postToSlack } from "../_shared/slack.ts";
15
17
 
16
18
  assertModerationConfigured();
17
19
 
@@ -60,7 +62,13 @@ Deno.serve(async (req) => {
60
62
  }
61
63
  }
62
64
 
63
- const verdict = await moderateInput(input);
65
+ // Optional images module: no column = text-only, as before. Any other
66
+ // lookup error fails closed (an unchecked image must never be published).
67
+ const image = await fetchImagePath(supabase, table, id);
68
+ if (!image.ok) return json({ status: "pending", error: "image lookup failed" });
69
+ const imagePath = image.path;
70
+
71
+ const verdict = await moderateItem(input, imagePath ? postImageUrl(imagePath) : null);
64
72
  if (!verdict) {
65
73
  // Fail closed: leave pending, let the client retry / the daily sweep catch it.
66
74
  return json({ status: "pending", error: "moderation unavailable" });
@@ -68,6 +76,17 @@ Deno.serve(async (req) => {
68
76
 
69
77
  const now = new Date().toISOString();
70
78
  if (verdict.flagged) {
79
+ // Child-safety hit: the file goes now. Any other hide keeps it.
80
+ if (imagePath && verdict.categories.includes("sexual/minors")) {
81
+ const { error } = await supabase.storage.from(POST_IMAGES_BUCKET).remove([imagePath]);
82
+ if (error) {
83
+ console.error("sexual/minors image not deleted", imagePath, error.message);
84
+ // The row is hidden below; the file stays public until removed by hand.
85
+ await postToSlack({
86
+ text: `Moderation: sexual/minors image \`${imagePath}\` (${kind} ${id}) NOT deleted (${error.message}), remove it by hand.`,
87
+ }).catch((e) => console.error("slack alert failed", e));
88
+ }
89
+ }
71
90
  await supabase
72
91
  .from(table)
73
92
  .update({ status: "hidden", moderation_reason: verdict.reason, moderated_at: now })
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { adminClient } from "../_shared/client.ts";
11
11
  import { sendExpoPush, unreadBadge } from "../_shared/push.ts";
12
- import { pushCopy } from "../_shared/copy.ts";
12
+ import { excerptOrPhoto, pushCopy } from "../_shared/copy.ts";
13
13
  import { ensureTranslations, resolveTargetLocale, languageOf } from "../_shared/translation.ts";
14
14
 
15
15
  const supabase = adminClient();
@@ -74,10 +74,12 @@ Deno.serve(async (req) => {
74
74
  );
75
75
  if (match) excerptSource = match.content;
76
76
  }
77
- const excerpt = excerptSource.length > 140 ? `${excerptSource.slice(0, 137)}...` : excerptSource;
78
77
  // Title states the event; body is the commenter's name then the comment in
79
- // quotes, e.g. Marie: "So happy for you!".
80
- const body = `${name}: "${excerpt}"`;
78
+ // quotes, e.g. Marie: "So happy for you!". An image-only comment has no text:
79
+ // the localized "Photo" label stands in, unquoted.
80
+ const body = comment.content.trim()
81
+ ? `${name}: "${excerptOrPhoto(excerptSource, "")}"`
82
+ : `${name}: ${copy.text("photo")}`;
81
83
 
82
84
  await sendExpoPush({
83
85
  to: pref.expo_push_token,
@@ -8,6 +8,7 @@
8
8
  // into the ops channel or probe another user's report count).
9
9
 
10
10
  import { adminClient } from "../_shared/client.ts";
11
+ import { fetchImagePath, postImageUrl } from "../_shared/images.ts";
11
12
  import { postToSlack } from "../_shared/slack.ts";
12
13
 
13
14
  const supabase = adminClient();
@@ -18,7 +19,7 @@ Deno.serve(async (req) => {
18
19
 
19
20
  const { data: report } = await supabase
20
21
  .from("reports")
21
- .select("id, reported_user_id, reason, details")
22
+ .select("id, reported_user_id, post_id, comment_id, reason, details")
22
23
  .eq("id", record.id)
23
24
  .single();
24
25
  if (!report) return new Response("skip: unknown report");
@@ -33,10 +34,21 @@ Deno.serve(async (req) => {
33
34
  .select("*", { count: "exact", head: true })
34
35
  .eq("reported_user_id", report.reported_user_id);
35
36
 
37
+ // The reported item's image, when it has one. A failed lookup only drops the
38
+ // line: an alert must never be blocked by it.
39
+ const target = report.comment_id
40
+ ? ({ table: "comments", id: report.comment_id } as const)
41
+ : report.post_id
42
+ ? ({ table: "posts", id: report.post_id } as const)
43
+ : null;
44
+ const image = target ? await fetchImagePath(supabase, target.table, target.id) : null;
45
+ const imageLine = image?.ok && image.path ? `Image: ${postImageUrl(image.path)}\n` : "";
46
+
36
47
  await postToSlack({
37
48
  text:
38
49
  `:rotating_light: Report (*${report.reason}*) against *${reported?.username ?? report.reported_user_id}*, ${count ?? "?"} report(s) total\n` +
39
50
  `${report.details ? `> ${report.details}\n` : ""}` +
51
+ imageLine +
40
52
  `amplitude: \`${reported?.amplitude_id ?? "?"}\` | revenuecat: \`${reported?.revenuecat_id ?? "?"}\``,
41
53
  });
42
54
  return new Response("ok");
@@ -11,6 +11,7 @@
11
11
  import { createClient } from "jsr:@supabase/supabase-js@2";
12
12
  import { adminClient, json, SUPABASE_URL } from "../_shared/client.ts";
13
13
  import { assertModerationConfigured, moderateInput } from "../_shared/moderation.ts";
14
+ import { containsAppName, isReservedHandle, suggestHandle } from "../_shared/names.ts";
14
15
 
15
16
  assertModerationConfigured();
16
17
 
@@ -54,12 +55,17 @@ Deno.serve(async (req) => {
54
55
  if (u.length < 2 || u.length > 24) {
55
56
  return json({ status: "error", error: "invalid_username" }, 400);
56
57
  }
58
+ if (containsAppName(u)) return json({ status: "error", error: "reserved_name" }, 400);
57
59
  const verdict = await moderateInput(u);
58
60
  if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
59
61
  if (verdict.flagged) {
60
62
  return json({ status: "rejected", field: "username", reason: verdict.reason });
61
63
  }
62
- const { error } = await admin.from("profiles").update({ username: u }).eq("id", uid);
64
+ // The user's own name: the host app's sync no longer overwrites it (core/009).
65
+ const { error } = await admin
66
+ .from("profiles")
67
+ .update({ username: u, username_set_by_user: true })
68
+ .eq("id", uid);
63
69
  if (error) return json({ status: "error", error: error.message }, 500);
64
70
  }
65
71
 
@@ -69,15 +75,26 @@ Deno.serve(async (req) => {
69
75
  if (!/^[a-z0-9-]{3,20}$/.test(h)) {
70
76
  return json({ status: "error", error: "invalid_handle" }, 400);
71
77
  }
78
+ if (isReservedHandle(h)) return json({ status: "error", error: "reserved_handle" }, 400);
72
79
  const verdict = await moderateInput(h);
73
80
  if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
74
81
  if (verdict.flagged) {
75
82
  return json({ status: "rejected", field: "handle", reason: verdict.reason });
76
83
  }
77
- const { error } = await admin.from("profiles").update({ handle: h }).eq("id", uid);
84
+ const { error } = await admin
85
+ .from("profiles")
86
+ .update({ handle: h, handle_set_by_user: true })
87
+ .eq("id", uid);
78
88
  if (error) {
79
89
  if (error.code === "23505") {
80
- return json({ status: "error", code: "handle_taken" }, 409);
90
+ const suggestion = await suggestHandle(h, async (candidate) => {
91
+ const { count } = await admin
92
+ .from("profiles")
93
+ .select("id", { count: "exact", head: true })
94
+ .eq("handle", candidate);
95
+ return count === 0;
96
+ });
97
+ return json({ status: "error", code: "handle_taken", suggestion }, 409);
81
98
  }
82
99
  return json({ status: "error", error: error.message }, 500);
83
100
  }
@@ -111,7 +128,9 @@ Deno.serve(async (req) => {
111
128
  return json({ status: "error", error: "forbidden_path" }, 403);
112
129
  }
113
130
  const publicUrl = `${SUPABASE_URL}/storage/v1/object/public/avatars/${path}`;
114
- const verdict = await moderateInput([{ type: "image_url", image_url: { url: publicUrl } }]);
131
+ const verdict = await moderateInput([{ type: "image_url", image_url: { url: publicUrl } }], {
132
+ strict: true,
133
+ });
115
134
  if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
116
135
  if (verdict.flagged) {
117
136
  await admin.storage.from("avatars").remove([path]);
@@ -0,0 +1,199 @@
1
+ -- community-sdk core/009_default_names.sql
2
+ -- Default names: every profile gets "<label> <digits>" / "<prefix>-<digits>"
3
+ -- (assign_default_profile, called by the client once per launch). The host
4
+ -- app's name seeds the profile until the user edits it through update-profile;
5
+ -- from then on a client write of `username` is ignored (the user wins). See
6
+ -- docs/superpowers/specs/2026-10-26-default-names-design.md.
7
+
8
+ alter table public.profiles
9
+ add column if not exists default_username text,
10
+ add column if not exists default_handle text,
11
+ add column if not exists username_set_by_user boolean not null default false,
12
+ add column if not exists handle_set_by_user boolean not null default false;
13
+
14
+ -- generate_handle with the slug to use when `base` has no usable letters
15
+ -- (a Korean or emoji-only name): the app's handle prefix instead of "member".
16
+ create or replace function public.generate_handle(base text, fallback text)
17
+ returns text
18
+ language plpgsql volatile security definer
19
+ set search_path = public, extensions
20
+ as $$
21
+ declare
22
+ slug text;
23
+ candidate text;
24
+ tries int := 0;
25
+ begin
26
+ slug := left(regexp_replace(lower(unaccent(coalesce(base, ''))), '[^a-z0-9]', '', 'g'), 12);
27
+ if slug = '' then
28
+ slug := left(regexp_replace(lower(coalesce(fallback, '')), '[^a-z0-9]', '', 'g'), 12);
29
+ end if;
30
+ if slug = '' then
31
+ slug := 'member';
32
+ end if;
33
+ loop
34
+ if tries < 5 then
35
+ candidate := slug || '-' || (floor(random() * 9000) + 1000)::int;
36
+ else
37
+ candidate := slug || '-' || (floor(random() * 900000) + 100000)::int;
38
+ end if;
39
+ exit when not exists (select 1 from public.profiles where handle = candidate);
40
+ tries := tries + 1;
41
+ end loop;
42
+ return candidate;
43
+ end;
44
+ $$;
45
+
46
+ create or replace function public.generate_handle(base text)
47
+ returns text
48
+ language sql volatile security definer
49
+ set search_path = public, extensions
50
+ as $$ select public.generate_handle(base, 'member') $$;
51
+
52
+ -- The default handle when it is still free, else a fresh one on its prefix.
53
+ create or replace function public.default_handle_for(p_id uuid, p_default text)
54
+ returns text
55
+ language plpgsql volatile security definer
56
+ set search_path = public, extensions
57
+ as $$
58
+ begin
59
+ if p_default is null then
60
+ return null;
61
+ end if;
62
+ if not exists (select 1 from public.profiles where handle = p_default and id <> p_id) then
63
+ return p_default;
64
+ end if;
65
+ return public.generate_handle('', regexp_replace(p_default, '-[0-9]+$', ''));
66
+ end;
67
+ $$;
68
+
69
+ -- One trigger owns username + handle consistency (replaces core/001's
70
+ -- assign_handle and core/007's profiles_username_changed, whose relative order
71
+ -- mattered). "Trusted" writes are update-profile (service role) and this
72
+ -- file's security definer functions (they set community.trusted).
73
+ create or replace function public.profiles_names()
74
+ returns trigger
75
+ language plpgsql security definer
76
+ set search_path = public, extensions
77
+ as $$
78
+ declare
79
+ trusted boolean := coalesce(auth.role(), '') = 'service_role'
80
+ or coalesce(current_setting('community.trusted', true), '') = 'on';
81
+ prefix text;
82
+ begin
83
+ if tg_op = 'UPDATE' and new.username is distinct from old.username then
84
+ if not trusted and old.username_set_by_user then
85
+ -- Host sync after the user picked their own name: the user wins.
86
+ new.username := old.username;
87
+ elsif new.username is null
88
+ or (new.username = old.username_rejected and not trusted) then
89
+ -- Blanked (moderation sweep, old clients, account scrub) or a re-sync of
90
+ -- a name the sweep already blanked: back to the default name.
91
+ new.username := old.default_username;
92
+ new.username_checked_at := now();
93
+ elsif new.username is not distinct from new.default_username then
94
+ new.username_checked_at := now();
95
+ else
96
+ -- A genuinely new name: re-check it on the next sweep.
97
+ new.username_checked_at := null;
98
+ end if;
99
+ end if;
100
+
101
+ if new.username is not null and new.handle is null then
102
+ new.handle := public.generate_handle(new.username,
103
+ regexp_replace(coalesce(new.default_handle, ''), '-[0-9]+$', ''));
104
+ elsif tg_op = 'UPDATE'
105
+ and not new.handle_set_by_user
106
+ and not new.username_set_by_user
107
+ and new.username is distinct from old.username
108
+ and new.handle is not distinct from old.handle then
109
+ -- The host's name changed (or the sweep blanked it) and the user never
110
+ -- picked a handle: follow it.
111
+ if new.username is not distinct from new.default_username then
112
+ new.handle := coalesce(public.default_handle_for(new.id, new.default_handle), new.handle);
113
+ else
114
+ prefix := regexp_replace(coalesce(new.default_handle, ''), '-[0-9]+$', '');
115
+ new.handle := public.generate_handle(new.username, prefix);
116
+ end if;
117
+ end if;
118
+ return new;
119
+ end;
120
+ $$;
121
+
122
+ drop trigger if exists profiles_assign_handle on public.profiles;
123
+ drop trigger if exists profiles_username_changed_trg on public.profiles;
124
+ drop function if exists public.assign_handle();
125
+ drop function if exists public.profiles_username_changed();
126
+
127
+ create trigger profiles_names_trg
128
+ before insert or update of username, handle on public.profiles
129
+ for each row execute function public.profiles_names();
130
+
131
+ -- Called by the client once per launch; idempotent. `p_label` is the app's
132
+ -- default name in the user's language ("Frère"), `p_handle` its ASCII handle
133
+ -- prefix ("brother"). Fills username / handle only when still null.
134
+ create or replace function public.assign_default_profile(p_label text, p_handle text)
135
+ returns void
136
+ language plpgsql volatile security definer
137
+ set search_path = public, extensions
138
+ as $$
139
+ declare
140
+ uid uuid := auth.uid();
141
+ label text := left(btrim(coalesce(p_label, '')), 17);
142
+ h text;
143
+ name text;
144
+ begin
145
+ if uid is null then
146
+ return;
147
+ end if;
148
+ if exists (select 1 from public.profiles where id = uid and default_username is not null) then
149
+ return;
150
+ end if;
151
+ if label = '' then
152
+ label := 'User';
153
+ end if;
154
+ h := public.generate_handle('', coalesce(nullif(p_handle, ''), 'user'));
155
+ name := label || ' ' || substring(h from '-([0-9]+)$');
156
+ perform set_config('community.trusted', 'on', true);
157
+ update public.profiles
158
+ set default_username = name,
159
+ default_handle = h,
160
+ username = coalesce(username, name),
161
+ handle = coalesce(handle, h)
162
+ where id = uid;
163
+ perform set_config('community.trusted', '', true);
164
+ end;
165
+ $$;
166
+
167
+ -- Account deletion: default name and handle back, bio / avatar / edits cleared.
168
+ create or replace function public.reset_profile_identity()
169
+ returns void
170
+ language plpgsql volatile security definer
171
+ set search_path = public, extensions
172
+ as $$
173
+ declare
174
+ uid uuid := auth.uid();
175
+ begin
176
+ if uid is null then
177
+ return;
178
+ end if;
179
+ perform set_config('community.trusted', 'on', true);
180
+ update public.profiles
181
+ set username = default_username,
182
+ handle = coalesce(public.default_handle_for(id, default_handle), handle),
183
+ username_set_by_user = false,
184
+ handle_set_by_user = false,
185
+ bio = null,
186
+ avatar_url = null
187
+ where id = uid;
188
+ perform set_config('community.trusted', '', true);
189
+ end;
190
+ $$;
191
+
192
+ revoke execute on function public.generate_handle(text, text) from public, anon, authenticated;
193
+ revoke execute on function public.generate_handle(text) from public, anon, authenticated;
194
+ revoke execute on function public.default_handle_for(uuid, text) from public, anon, authenticated;
195
+ revoke execute on function public.profiles_names() from public, anon, authenticated;
196
+ revoke execute on function public.assign_default_profile(text, text) from public, anon;
197
+ revoke execute on function public.reset_profile_identity() from public, anon;
198
+ grant execute on function public.assign_default_profile(text, text) to authenticated;
199
+ grant execute on function public.reset_profile_identity() to authenticated;
@@ -0,0 +1,119 @@
1
+ -- community-sdk images/001_images.sql (schema v1)
2
+ -- Images module: one optional image per post and per comment.
3
+ -- Columns hold the Storage object name in the public `post-images` bucket
4
+ -- ({uid}/{uuid}.jpg) plus the dimensions the client needs to lay the frame out
5
+ -- before the bytes arrive. Public read (plain public object URLs), 2MB cap,
6
+ -- JPEG only (the client re-encodes on device, which also strips EXIF/GPS).
7
+ -- Unlike avatars, a hidden item KEEPS its file; the daily sweep removes only
8
+ -- objects no live (status <> 'deleted') row references, via
9
+ -- post_images_unreferenced() below. Fully idempotent.
10
+
11
+ -- ============ COLUMNS ============
12
+ alter table public.posts
13
+ add column if not exists image_path text,
14
+ add column if not exists image_width int,
15
+ add column if not exists image_height int;
16
+ alter table public.comments
17
+ add column if not exists image_path text,
18
+ add column if not exists image_width int,
19
+ add column if not exists image_height int;
20
+
21
+ -- Dimensions are set iff the path is set, and positive.
22
+ alter table public.posts drop constraint if exists posts_image_check;
23
+ alter table public.posts add constraint posts_image_check check (
24
+ (image_path is null and image_width is null and image_height is null)
25
+ or (image_path is not null and image_width > 0 and image_height > 0)
26
+ );
27
+ alter table public.comments drop constraint if exists comments_image_check;
28
+ alter table public.comments add constraint comments_image_check check (
29
+ (image_path is null and image_width is null and image_height is null)
30
+ or (image_path is not null and image_width > 0 and image_height > 0)
31
+ );
32
+
33
+ -- The path is `{uid}/{name}.jpg` (name: a uuid, or the client's
34
+ -- `{Date.now()}-{base36}` fallback when crypto.randomUUID is missing), so a
35
+ -- crafted insert can't point at another bucket path or a non-JPEG name.
36
+ alter table public.posts drop constraint if exists posts_image_path_format;
37
+ alter table public.posts add constraint posts_image_path_format check (
38
+ image_path is null or image_path ~ '^[0-9a-f-]{36}/[A-Za-z0-9_-]+\.jpg$'
39
+ );
40
+ alter table public.comments drop constraint if exists comments_image_path_format;
41
+ alter table public.comments add constraint comments_image_path_format check (
42
+ image_path is null or image_path ~ '^[0-9a-f-]{36}/[A-Za-z0-9_-]+\.jpg$'
43
+ );
44
+
45
+ -- Text becomes optional when an image is present (image-only items store '').
46
+ -- core/001_tables.sql leaves the inline check auto-named <table>_content_check.
47
+ alter table public.posts drop constraint if exists posts_content_check;
48
+ alter table public.posts add constraint posts_content_check check (
49
+ char_length(content) <= 2000 and (char_length(content) >= 1 or image_path is not null)
50
+ );
51
+ alter table public.comments drop constraint if exists comments_content_check;
52
+ alter table public.comments add constraint comments_content_check check (
53
+ char_length(content) <= 1000 and (char_length(content) >= 1 or image_path is not null)
54
+ );
55
+
56
+ -- ============ CLIENT WRITE ACCESS ============
57
+ -- Both tables use column-scoped insert grants (core/002_rls.sql); extend them.
58
+ grant insert (image_path, image_width, image_height) on table public.posts to authenticated;
59
+ grant insert (image_path, image_width, image_height) on table public.comments to authenticated;
60
+
61
+ -- Restrictive: ANDed with the existing permissive insert policies, so an image
62
+ -- can only point into the author's own storage folder.
63
+ drop policy if exists "post image in own folder" on public.posts;
64
+ create policy "post image in own folder"
65
+ on public.posts as restrictive for insert to authenticated
66
+ with check (
67
+ image_path is null or (storage.foldername(image_path))[1] = auth.uid()::text
68
+ );
69
+ drop policy if exists "comment image in own folder" on public.comments;
70
+ create policy "comment image in own folder"
71
+ on public.comments as restrictive for insert to authenticated
72
+ with check (
73
+ image_path is null or (storage.foldername(image_path))[1] = auth.uid()::text
74
+ );
75
+
76
+ -- ============ STORAGE ============
77
+ insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
78
+ values ('post-images', 'post-images', true, 2097152, array['image/jpeg'])
79
+ on conflict (id) do nothing;
80
+
81
+ -- Clients may only CREATE objects inside their own folder. No UPDATE/DELETE
82
+ -- policies: removal is done by the Edge Functions with the service role.
83
+ drop policy if exists "post image upload to own folder" on storage.objects;
84
+ create policy "post image upload to own folder"
85
+ on storage.objects for insert to authenticated
86
+ with check (
87
+ bucket_id = 'post-images'
88
+ and (storage.foldername(name))[1] = auth.uid()::text
89
+ );
90
+
91
+ -- ============ SWEEP ============
92
+ -- Object names older than 24h that no live post/comment references: abandoned
93
+ -- uploads, plus files of deleted items. Hidden items stay referenced (file
94
+ -- kept). Service role only (called by daily-moderation).
95
+ create or replace function public.post_images_unreferenced(p_limit int default 500)
96
+ returns setof text
97
+ language sql
98
+ stable
99
+ security definer
100
+ set search_path = ''
101
+ as $$
102
+ select o.name
103
+ from storage.objects o
104
+ where o.bucket_id = 'post-images'
105
+ and o.created_at < now() - interval '24 hours'
106
+ and not exists (
107
+ select 1 from public.posts p
108
+ where p.image_path = o.name and p.status <> 'deleted'
109
+ )
110
+ and not exists (
111
+ select 1 from public.comments c
112
+ where c.image_path = o.name and c.status <> 'deleted'
113
+ )
114
+ order by o.created_at
115
+ limit p_limit;
116
+ $$;
117
+
118
+ revoke all on function public.post_images_unreferenced(int) from public, anon, authenticated;
119
+ grant execute on function public.post_images_unreferenced(int) to service_role;
@@ -0,0 +1,48 @@
1
+ -- community-sdk translation/003_skip_empty.sql (schema v1)
2
+ -- Image-only posts and comments (images module) store content = '': there is
3
+ -- nothing to translate, so ensureTranslations writes no row for them, and the
4
+ -- sweep helper below would re-pick them on every daily run (filling its
5
+ -- newest-first window and keeping the chain going). Same function as
6
+ -- 001_translations.sql with `content <> ''` added to both inner selects.
7
+ -- Idempotent (create or replace); service role only, as before.
8
+ create or replace function public.items_missing_translations(
9
+ kind text, target_locales text[], max_items int
10
+ ) returns table (id uuid)
11
+ language sql stable security definer set search_path = public as $$
12
+ select x.id from (
13
+ select p.id, p.created_at,
14
+ exists (select 1 from public.post_translations t where t.post_id = p.id) as has_any,
15
+ (select array_agg(t.locale) filter (where t.locale not in ('source', 'attempt'))
16
+ from public.post_translations t where t.post_id = p.id) as have,
17
+ (select min(t.source_locale) filter (where t.locale <> 'attempt')
18
+ from public.post_translations t where t.post_id = p.id) as src,
19
+ exists (select 1 from public.post_translations t
20
+ where t.post_id = p.id and t.locale = 'attempt'
21
+ and t.created_at > now() - interval '6 hours') as fresh_attempt
22
+ from public.posts p where kind = 'post' and p.status = 'visible' and p.content <> ''
23
+ union all
24
+ select c.id, c.created_at,
25
+ exists (select 1 from public.comment_translations t where t.comment_id = c.id) as has_any,
26
+ (select array_agg(t.locale) filter (where t.locale not in ('source', 'attempt'))
27
+ from public.comment_translations t where t.comment_id = c.id) as have,
28
+ (select min(t.source_locale) filter (where t.locale <> 'attempt')
29
+ from public.comment_translations t where t.comment_id = c.id) as src,
30
+ exists (select 1 from public.comment_translations t
31
+ where t.comment_id = c.id and t.locale = 'attempt'
32
+ and t.created_at > now() - interval '6 hours') as fresh_attempt
33
+ from public.comments c where kind = 'comment' and c.status = 'visible' and c.content <> ''
34
+ ) x
35
+ where not x.fresh_attempt
36
+ and (not x.has_any
37
+ or exists (
38
+ select 1 from unnest(target_locales) l
39
+ -- `is distinct from`: src is null when the only row is a stale attempt
40
+ -- (a failed try), and that item must be retried.
41
+ where split_part(l, '-', 1) is distinct from x.src
42
+ and not (l = any (coalesce(x.have, '{}')))
43
+ ))
44
+ order by x.created_at desc
45
+ limit max_items
46
+ $$;
47
+ revoke execute on function public.items_missing_translations(text, text[], int) from public, anon, authenticated;
48
+ grant execute on function public.items_missing_translations(text, text[], int) to service_role;