@rocapine/community 0.10.2 → 0.11.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.11.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
  }
@@ -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
  }));
@@ -16,8 +16,10 @@ import { adminClient } from "../_shared/client.ts";
16
16
  import {
17
17
  assertModerationConfigured,
18
18
  flaggedCategories,
19
+ moderateItem,
19
20
  type ModerationResult,
20
21
  } from "../_shared/moderation.ts";
22
+ import { fetchImagePaths, POST_IMAGES_BUCKET, postImageUrl } from "../_shared/images.ts";
21
23
  import { postToSlack } from "../_shared/slack.ts";
22
24
 
23
25
  assertModerationConfigured();
@@ -29,6 +31,9 @@ interface Item {
29
31
  content: string;
30
32
  author_id: string;
31
33
  status: string;
34
+ imagePath: string | null;
35
+ // The image_path lookup errored: not moderated (nor stamped/promoted) this run.
36
+ imageLookupFailed: boolean;
32
37
  profiles: {
33
38
  username: string | null;
34
39
  amplitude_id: string | null;
@@ -126,6 +131,24 @@ async function sweepUsernames(): Promise<{
126
131
  return { checked: processed.length, blanked, failedBatches, skipped: false };
127
132
  }
128
133
 
134
+ /** Deletes storage objects no live post/comment references (abandoned uploads,
135
+ * deleted authors' files), capped per run. Skipped silently without the images
136
+ * module (the RPC doesn't exist). Returns how many files were removed, and the
137
+ * storage error when the removal failed (surfaced in the Slack summary). */
138
+ async function sweepUnreferencedImages(): Promise<{ removed: number; error?: string }> {
139
+ const { data, error } = await supabase.rpc("post_images_unreferenced", { p_limit: 500 });
140
+ const names = (data ?? []) as string[];
141
+ if (error || names.length === 0) return { removed: 0 };
142
+ const { data: removed, error: removeError } = await supabase.storage
143
+ .from(POST_IMAGES_BUCKET)
144
+ .remove(names);
145
+ if (removeError) {
146
+ console.error("image sweep: remove failed", removeError.message);
147
+ return { removed: 0, error: removeError.message };
148
+ }
149
+ return { removed: removed?.length ?? 0 };
150
+ }
151
+
129
152
  Deno.serve(async () => {
130
153
  const { data: posts, error: postsError } = await supabase
131
154
  .from("posts")
@@ -167,9 +190,21 @@ Deno.serve(async () => {
167
190
  }
168
191
  }
169
192
  }
193
+ const postImages = await fetchImagePaths(
194
+ supabase,
195
+ "posts",
196
+ (posts ?? []).map((p) => p.id),
197
+ );
198
+ const commentImages = await fetchImagePaths(
199
+ supabase,
200
+ "comments",
201
+ (comments ?? []).map((c) => c.id),
202
+ );
170
203
  const items: Item[] = [
171
204
  ...(posts ?? []).map((p) => ({
172
205
  ...p,
206
+ imagePath: postImages.paths.get(p.id) ?? null,
207
+ imageLookupFailed: postImages.failedIds.has(p.id),
173
208
  profiles: toOne(p.profiles),
174
209
  content: [
175
210
  p.content,
@@ -179,6 +214,8 @@ Deno.serve(async () => {
179
214
  })),
180
215
  ...(comments ?? []).map((c) => ({
181
216
  ...c,
217
+ imagePath: commentImages.paths.get(c.id) ?? null,
218
+ imageLookupFailed: commentImages.failedIds.has(c.id),
182
219
  profiles: toOne(c.profiles),
183
220
  table: "comments" as const,
184
221
  })),
@@ -189,8 +226,10 @@ Deno.serve(async () => {
189
226
  const flagged: { item: Item; categories: string[] }[] = [];
190
227
  const processed: Item[] = [];
191
228
  let failedBatches = 0;
192
- for (let i = 0; i < items.length; i += 100) {
193
- const batch = items.slice(i, i + 100);
229
+ const textItems = items.filter((it) => !it.imagePath && !it.imageLookupFailed);
230
+ if (items.some((it) => it.imageLookupFailed)) failedBatches++;
231
+ for (let i = 0; i < textItems.length; i += 100) {
232
+ const batch = textItems.slice(i, i + 100);
194
233
  const results = await moderateBatch(batch.map((it) => it.content));
195
234
  if (!results) {
196
235
  failedBatches++; // API/network error; skip stamping, retried next run
@@ -202,13 +241,35 @@ Deno.serve(async () => {
202
241
  });
203
242
  processed.push(...batch);
204
243
  }
244
+ // Items with an image go one by one (text + image calls); a failed one
245
+ // counts as a failed batch: not stamped, retried next run.
246
+ for (const item of items.filter((it) => it.imagePath)) {
247
+ const verdict = await moderateItem(item.content, postImageUrl(item.imagePath!));
248
+ if (!verdict) {
249
+ failedBatches++;
250
+ continue;
251
+ }
252
+ if (verdict.flagged) flagged.push({ item, categories: verdict.categories });
253
+ processed.push(item);
254
+ }
205
255
  const totalItems = items.length;
206
256
 
207
257
  const flaggedIds = new Set(flagged.map((f) => f.item.id));
208
258
  // Status guards: a row the author soft-deleted while the sweep ran must not
209
259
  // be resurrected as hidden/visible, so every write re-checks the status it
210
260
  // was selected with.
261
+ const imageErrors: string[] = [];
211
262
  for (const { item, categories } of flagged) {
263
+ // Child-safety hit: the file goes now. Any other hide keeps it.
264
+ if (item.imagePath && categories.includes("sexual/minors")) {
265
+ const { error } = await supabase.storage.from(POST_IMAGES_BUCKET).remove([item.imagePath]);
266
+ if (error) {
267
+ console.error("sexual/minors image not deleted", item.imagePath, error.message);
268
+ imageErrors.push(
269
+ `sexual/minors image \`${item.imagePath}\` NOT deleted (${error.message}), remove it by hand`,
270
+ );
271
+ }
272
+ }
212
273
  await supabase
213
274
  .from(item.table)
214
275
  .update({ status: "hidden", moderation_reason: categories.join(",") })
@@ -245,11 +306,20 @@ Deno.serve(async () => {
245
306
  }
246
307
 
247
308
  const usernames = await sweepUsernames();
309
+ const sweep = await sweepUnreferencedImages();
310
+ const imagesSwept = sweep.removed;
311
+ if (sweep.error) imageErrors.push(`image sweep failed: ${sweep.error}`);
248
312
  const allFailedBatches = failedBatches + usernames.failedBatches;
249
313
 
250
314
  // Quiet when there was nothing to check at all (a stray or duplicate call
251
315
  // finds every item already stamped): no Slack noise, no API cost.
252
- if (totalItems === 0 && usernames.checked === 0 && allFailedBatches === 0) {
316
+ if (
317
+ totalItems === 0 &&
318
+ usernames.checked === 0 &&
319
+ allFailedBatches === 0 &&
320
+ imagesSwept === 0 &&
321
+ imageErrors.length === 0
322
+ ) {
253
323
  return new Response(
254
324
  JSON.stringify({
255
325
  checked: 0,
@@ -278,20 +348,24 @@ Deno.serve(async () => {
278
348
  usernames.checked > 0
279
349
  ? ` Usernames: ${usernames.blanked.length}/${usernames.checked} blanked.`
280
350
  : "";
351
+ const sweepSummary = imagesSwept > 0 ? ` Images swept: ${imagesSwept}.` : "";
281
352
  let summaryText: string;
282
353
  if (allFailedBatches > 0) {
283
354
  const uncheckedCount = totalItems - processed.length;
284
355
  summaryText =
285
356
  `Daily moderation: WARNING, ${allFailedBatches} batch(es) failed to reach the moderation API ` +
286
357
  `(${uncheckedCount} of ${totalItems} items were not checked and will be retried). ` +
287
- `${flagged.length} items hidden.${usernameSummary}`;
358
+ `${flagged.length} items hidden.${usernameSummary}${sweepSummary}`;
288
359
  } else if (flagged.length === 0 && usernames.blanked.length === 0) {
289
- summaryText = `Daily moderation: ${processed.length} items checked, nothing to report.${usernameSummary}`;
360
+ summaryText = `Daily moderation: ${processed.length} items checked, nothing to report.${usernameSummary}${sweepSummary}`;
290
361
  } else {
291
362
  summaryText =
292
- `Daily moderation: ${flagged.length}/${processed.length} items hidden.${usernameSummary}\n\n` +
363
+ `Daily moderation: ${flagged.length}/${processed.length} items hidden.${usernameSummary}${sweepSummary}\n\n` +
293
364
  [...lines, ...usernameLines].join("\n");
294
365
  }
366
+ if (imageErrors.length > 0) {
367
+ summaryText += `\n\n${imageErrors.map((e) => `* WARNING: ${e}`).join("\n")}`;
368
+ }
295
369
  await postToSlack({ text: summaryText });
296
370
 
297
371
  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");
@@ -111,7 +111,9 @@ Deno.serve(async (req) => {
111
111
  return json({ status: "error", error: "forbidden_path" }, 403);
112
112
  }
113
113
  const publicUrl = `${SUPABASE_URL}/storage/v1/object/public/avatars/${path}`;
114
- const verdict = await moderateInput([{ type: "image_url", image_url: { url: publicUrl } }]);
114
+ const verdict = await moderateInput([{ type: "image_url", image_url: { url: publicUrl } }], {
115
+ strict: true,
116
+ });
115
117
  if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
116
118
  if (verdict.flagged) {
117
119
  await admin.storage.from("avatars").remove([path]);
@@ -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;