@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 +11 -4
- package/lib/commands/init.js +3 -0
- package/lib/commands/upgrade.js +3 -0
- package/lib/index.js +2 -2
- package/lib/install-shared.d.ts +2 -2
- package/lib/install-shared.js +10 -2
- package/package.json +1 -1
- package/templates/functions/_shared/copy.ts +23 -1
- package/templates/functions/_shared/images.ts +62 -0
- package/templates/functions/_shared/moderation.ts +50 -12
- package/templates/functions/_shared/translation.ts +6 -0
- package/templates/functions/broadcast-post/index.ts +9 -7
- package/templates/functions/daily-moderation/index.ts +80 -6
- package/templates/functions/moderate-one/index.ts +21 -2
- package/templates/functions/notify-comment/index.ts +6 -4
- package/templates/functions/report-to-slack/index.ts +13 -1
- package/templates/functions/update-profile/index.ts +3 -1
- package/templates/migrations/images/001_images.sql +119 -0
- package/templates/migrations/translation/003_skip_empty.sql +48 -0
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
|
|
package/lib/commands/init.js
CHANGED
|
@@ -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
|
}
|
package/lib/commands/upgrade.js
CHANGED
|
@@ -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 {
|
package/lib/install-shared.d.ts
CHANGED
|
@@ -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
|
/**
|
package/lib/install-shared.js
CHANGED
|
@@ -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 = [
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
80
|
+
export type ContentPart =
|
|
81
|
+
{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } };
|
|
77
82
|
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
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 |
|
|
83
|
-
|
|
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
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
:
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|