@rocapine/community 0.10.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/names.ts +53 -0
- package/templates/functions/_shared/translation.ts +6 -0
- package/templates/functions/broadcast-post/index.ts +9 -7
- package/templates/functions/daily-moderation/index.ts +84 -7
- 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 +23 -4
- package/templates/migrations/core/009_default_names.sql +199 -0
- 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
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Display name / handle rules shared by update-profile and the daily sweep:
|
|
2
|
+
// the app's own name is reserved (no "Unchaind Team" impersonating the app),
|
|
3
|
+
// a few staff-sounding handles too, and a taken handle gets a free suggestion.
|
|
4
|
+
|
|
5
|
+
const APP_NAME = Deno.env.get("COMMUNITY_APP_NAME") ?? "";
|
|
6
|
+
|
|
7
|
+
/** Lowercase, accents stripped, letters and digits only. */
|
|
8
|
+
function collapse(s: string): string {
|
|
9
|
+
return s
|
|
10
|
+
.normalize("NFD")
|
|
11
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]/g, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** True when `value` contains the app's name. Short names (< 4 chars, "Eve") only match a whole word so "Steve" stays allowed. */
|
|
17
|
+
export function containsAppName(value: string, appName: string = APP_NAME): boolean {
|
|
18
|
+
const app = collapse(appName);
|
|
19
|
+
if (app === "") return false;
|
|
20
|
+
if (app.length >= 4) return collapse(value).includes(app);
|
|
21
|
+
return value
|
|
22
|
+
.normalize("NFD")
|
|
23
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.split(/[^a-z0-9]+/)
|
|
26
|
+
.includes(app);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const RESERVED_HANDLES = ["admin", "support", "official", "team", "moderator"];
|
|
30
|
+
|
|
31
|
+
export function isReservedHandle(handle: string, appName: string = APP_NAME): boolean {
|
|
32
|
+
return RESERVED_HANDLES.includes(handle) || containsAppName(handle, appName);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A free handle on `handle`'s base: "superuser" -> "superuser-4821". Same
|
|
37
|
+
* digit widening as generate_handle (core/009). `isFree` checks the table.
|
|
38
|
+
*/
|
|
39
|
+
export async function suggestHandle(
|
|
40
|
+
handle: string,
|
|
41
|
+
isFree: (candidate: string) => Promise<boolean>,
|
|
42
|
+
): Promise<string | null> {
|
|
43
|
+
const base = handle.replace(/-[0-9]+$/, "").slice(0, 15) || "user";
|
|
44
|
+
for (let tries = 0; tries < 10; tries++) {
|
|
45
|
+
const digits =
|
|
46
|
+
tries < 5
|
|
47
|
+
? Math.floor(Math.random() * 9000) + 1000
|
|
48
|
+
: Math.floor(Math.random() * 900000) + 100000;
|
|
49
|
+
const candidate = `${base}-${digits}`;
|
|
50
|
+
if (await isFree(candidate)) return candidate;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
@@ -68,6 +68,11 @@ export type ParsedTranslation = {
|
|
|
68
68
|
translations: Record<string, { content: string; options: string[] }>;
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
+
/** Image-only items store content = "": nothing to translate, never sent to OpenAI. */
|
|
72
|
+
export function hasTranslatableContent(content: string): boolean {
|
|
73
|
+
return content.trim().length > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
71
76
|
export function languageOf(locale: string): string {
|
|
72
77
|
return locale.split("-")[0].toLowerCase();
|
|
73
78
|
}
|
|
@@ -395,6 +400,7 @@ export async function ensureTranslations(
|
|
|
395
400
|
return null;
|
|
396
401
|
}
|
|
397
402
|
if (!row || row.status !== "visible") return [];
|
|
403
|
+
if (!hasTranslatableContent(row.content)) return [];
|
|
398
404
|
|
|
399
405
|
const { data: existing, error: existingError } = await supabase
|
|
400
406
|
.from(tTable)
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// verify_jwt but must NOT be able to trigger a broadcast.
|
|
6
6
|
|
|
7
7
|
import { adminClient, fetchAllRows, isServiceCaller } from "../_shared/client.ts";
|
|
8
|
+
import { excerptOrPhoto, pushCopy } from "../_shared/copy.ts";
|
|
8
9
|
import { sendExpoPushBatch } from "../_shared/push.ts";
|
|
9
10
|
import { BROADCAST_FALLBACK_TITLE } from "../_shared/config.ts";
|
|
10
11
|
import { ensureTranslations, resolveTargetLocale, TARGET_LOCALES } from "../_shared/translation.ts";
|
|
@@ -33,17 +34,17 @@ Deno.serve(async (req) => {
|
|
|
33
34
|
.single();
|
|
34
35
|
const title = author?.username?.trim() || BROADCAST_FALLBACK_TITLE;
|
|
35
36
|
|
|
36
|
-
const original = post.content.length > 140 ? `${post.content.slice(0, 137)}...` : post.content;
|
|
37
37
|
// waitMs: when translate-one holds the claim (it fires on the same insert),
|
|
38
38
|
// wait for its rows instead of pushing the source language.
|
|
39
39
|
const translations =
|
|
40
40
|
TARGET_LOCALES.length === 0
|
|
41
41
|
? []
|
|
42
42
|
: ((await ensureTranslations(supabase, "post", post.id, { waitMs: 20_000 })) ?? []);
|
|
43
|
-
|
|
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
|
}));
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// 'pending' -> promote to 'visible' (catches items whose synchronous
|
|
4
4
|
// moderate-one call failed), clean 'visible' -> just stamp. Also sweeps
|
|
5
5
|
// not-yet-checked usernames (profiles.username is client-writable, see
|
|
6
|
-
// core/007_username_moderation.sql): flagged
|
|
6
|
+
// core/007_username_moderation.sql): flagged or containing the app's name ->
|
|
7
|
+
// blanked, i.e. back to the default name (core/009). Posts a Slack
|
|
7
8
|
// summary when there was something to check (no-op if SLACK_WEBHOOK_URL is
|
|
8
9
|
// unset — see _shared/slack.ts). Never deletes rows. App criticism is not a
|
|
9
10
|
// category (stays visible).
|
|
@@ -16,8 +17,11 @@ import { adminClient } from "../_shared/client.ts";
|
|
|
16
17
|
import {
|
|
17
18
|
assertModerationConfigured,
|
|
18
19
|
flaggedCategories,
|
|
20
|
+
moderateItem,
|
|
19
21
|
type ModerationResult,
|
|
20
22
|
} from "../_shared/moderation.ts";
|
|
23
|
+
import { fetchImagePaths, POST_IMAGES_BUCKET, postImageUrl } from "../_shared/images.ts";
|
|
24
|
+
import { containsAppName } from "../_shared/names.ts";
|
|
21
25
|
import { postToSlack } from "../_shared/slack.ts";
|
|
22
26
|
|
|
23
27
|
assertModerationConfigured();
|
|
@@ -29,6 +33,9 @@ interface Item {
|
|
|
29
33
|
content: string;
|
|
30
34
|
author_id: string;
|
|
31
35
|
status: string;
|
|
36
|
+
imagePath: string | null;
|
|
37
|
+
// The image_path lookup errored: not moderated (nor stamped/promoted) this run.
|
|
38
|
+
imageLookupFailed: boolean;
|
|
32
39
|
profiles: {
|
|
33
40
|
username: string | null;
|
|
34
41
|
amplitude_id: string | null;
|
|
@@ -104,6 +111,7 @@ async function sweepUsernames(): Promise<{
|
|
|
104
111
|
}
|
|
105
112
|
results.forEach((r, idx) => {
|
|
106
113
|
const cats = flaggedCategories(r);
|
|
114
|
+
if (containsAppName(batch[idx].username)) cats.push("app_name");
|
|
107
115
|
if (cats.length > 0) blanked.push({ profile: batch[idx], categories: cats });
|
|
108
116
|
});
|
|
109
117
|
processed.push(...batch);
|
|
@@ -126,6 +134,24 @@ async function sweepUsernames(): Promise<{
|
|
|
126
134
|
return { checked: processed.length, blanked, failedBatches, skipped: false };
|
|
127
135
|
}
|
|
128
136
|
|
|
137
|
+
/** Deletes storage objects no live post/comment references (abandoned uploads,
|
|
138
|
+
* deleted authors' files), capped per run. Skipped silently without the images
|
|
139
|
+
* module (the RPC doesn't exist). Returns how many files were removed, and the
|
|
140
|
+
* storage error when the removal failed (surfaced in the Slack summary). */
|
|
141
|
+
async function sweepUnreferencedImages(): Promise<{ removed: number; error?: string }> {
|
|
142
|
+
const { data, error } = await supabase.rpc("post_images_unreferenced", { p_limit: 500 });
|
|
143
|
+
const names = (data ?? []) as string[];
|
|
144
|
+
if (error || names.length === 0) return { removed: 0 };
|
|
145
|
+
const { data: removed, error: removeError } = await supabase.storage
|
|
146
|
+
.from(POST_IMAGES_BUCKET)
|
|
147
|
+
.remove(names);
|
|
148
|
+
if (removeError) {
|
|
149
|
+
console.error("image sweep: remove failed", removeError.message);
|
|
150
|
+
return { removed: 0, error: removeError.message };
|
|
151
|
+
}
|
|
152
|
+
return { removed: removed?.length ?? 0 };
|
|
153
|
+
}
|
|
154
|
+
|
|
129
155
|
Deno.serve(async () => {
|
|
130
156
|
const { data: posts, error: postsError } = await supabase
|
|
131
157
|
.from("posts")
|
|
@@ -167,9 +193,21 @@ Deno.serve(async () => {
|
|
|
167
193
|
}
|
|
168
194
|
}
|
|
169
195
|
}
|
|
196
|
+
const postImages = await fetchImagePaths(
|
|
197
|
+
supabase,
|
|
198
|
+
"posts",
|
|
199
|
+
(posts ?? []).map((p) => p.id),
|
|
200
|
+
);
|
|
201
|
+
const commentImages = await fetchImagePaths(
|
|
202
|
+
supabase,
|
|
203
|
+
"comments",
|
|
204
|
+
(comments ?? []).map((c) => c.id),
|
|
205
|
+
);
|
|
170
206
|
const items: Item[] = [
|
|
171
207
|
...(posts ?? []).map((p) => ({
|
|
172
208
|
...p,
|
|
209
|
+
imagePath: postImages.paths.get(p.id) ?? null,
|
|
210
|
+
imageLookupFailed: postImages.failedIds.has(p.id),
|
|
173
211
|
profiles: toOne(p.profiles),
|
|
174
212
|
content: [
|
|
175
213
|
p.content,
|
|
@@ -179,6 +217,8 @@ Deno.serve(async () => {
|
|
|
179
217
|
})),
|
|
180
218
|
...(comments ?? []).map((c) => ({
|
|
181
219
|
...c,
|
|
220
|
+
imagePath: commentImages.paths.get(c.id) ?? null,
|
|
221
|
+
imageLookupFailed: commentImages.failedIds.has(c.id),
|
|
182
222
|
profiles: toOne(c.profiles),
|
|
183
223
|
table: "comments" as const,
|
|
184
224
|
})),
|
|
@@ -189,8 +229,10 @@ Deno.serve(async () => {
|
|
|
189
229
|
const flagged: { item: Item; categories: string[] }[] = [];
|
|
190
230
|
const processed: Item[] = [];
|
|
191
231
|
let failedBatches = 0;
|
|
192
|
-
|
|
193
|
-
|
|
232
|
+
const textItems = items.filter((it) => !it.imagePath && !it.imageLookupFailed);
|
|
233
|
+
if (items.some((it) => it.imageLookupFailed)) failedBatches++;
|
|
234
|
+
for (let i = 0; i < textItems.length; i += 100) {
|
|
235
|
+
const batch = textItems.slice(i, i + 100);
|
|
194
236
|
const results = await moderateBatch(batch.map((it) => it.content));
|
|
195
237
|
if (!results) {
|
|
196
238
|
failedBatches++; // API/network error; skip stamping, retried next run
|
|
@@ -202,13 +244,35 @@ Deno.serve(async () => {
|
|
|
202
244
|
});
|
|
203
245
|
processed.push(...batch);
|
|
204
246
|
}
|
|
247
|
+
// Items with an image go one by one (text + image calls); a failed one
|
|
248
|
+
// counts as a failed batch: not stamped, retried next run.
|
|
249
|
+
for (const item of items.filter((it) => it.imagePath)) {
|
|
250
|
+
const verdict = await moderateItem(item.content, postImageUrl(item.imagePath!));
|
|
251
|
+
if (!verdict) {
|
|
252
|
+
failedBatches++;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (verdict.flagged) flagged.push({ item, categories: verdict.categories });
|
|
256
|
+
processed.push(item);
|
|
257
|
+
}
|
|
205
258
|
const totalItems = items.length;
|
|
206
259
|
|
|
207
260
|
const flaggedIds = new Set(flagged.map((f) => f.item.id));
|
|
208
261
|
// Status guards: a row the author soft-deleted while the sweep ran must not
|
|
209
262
|
// be resurrected as hidden/visible, so every write re-checks the status it
|
|
210
263
|
// was selected with.
|
|
264
|
+
const imageErrors: string[] = [];
|
|
211
265
|
for (const { item, categories } of flagged) {
|
|
266
|
+
// Child-safety hit: the file goes now. Any other hide keeps it.
|
|
267
|
+
if (item.imagePath && categories.includes("sexual/minors")) {
|
|
268
|
+
const { error } = await supabase.storage.from(POST_IMAGES_BUCKET).remove([item.imagePath]);
|
|
269
|
+
if (error) {
|
|
270
|
+
console.error("sexual/minors image not deleted", item.imagePath, error.message);
|
|
271
|
+
imageErrors.push(
|
|
272
|
+
`sexual/minors image \`${item.imagePath}\` NOT deleted (${error.message}), remove it by hand`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
212
276
|
await supabase
|
|
213
277
|
.from(item.table)
|
|
214
278
|
.update({ status: "hidden", moderation_reason: categories.join(",") })
|
|
@@ -245,11 +309,20 @@ Deno.serve(async () => {
|
|
|
245
309
|
}
|
|
246
310
|
|
|
247
311
|
const usernames = await sweepUsernames();
|
|
312
|
+
const sweep = await sweepUnreferencedImages();
|
|
313
|
+
const imagesSwept = sweep.removed;
|
|
314
|
+
if (sweep.error) imageErrors.push(`image sweep failed: ${sweep.error}`);
|
|
248
315
|
const allFailedBatches = failedBatches + usernames.failedBatches;
|
|
249
316
|
|
|
250
317
|
// Quiet when there was nothing to check at all (a stray or duplicate call
|
|
251
318
|
// finds every item already stamped): no Slack noise, no API cost.
|
|
252
|
-
if (
|
|
319
|
+
if (
|
|
320
|
+
totalItems === 0 &&
|
|
321
|
+
usernames.checked === 0 &&
|
|
322
|
+
allFailedBatches === 0 &&
|
|
323
|
+
imagesSwept === 0 &&
|
|
324
|
+
imageErrors.length === 0
|
|
325
|
+
) {
|
|
253
326
|
return new Response(
|
|
254
327
|
JSON.stringify({
|
|
255
328
|
checked: 0,
|
|
@@ -278,20 +351,24 @@ Deno.serve(async () => {
|
|
|
278
351
|
usernames.checked > 0
|
|
279
352
|
? ` Usernames: ${usernames.blanked.length}/${usernames.checked} blanked.`
|
|
280
353
|
: "";
|
|
354
|
+
const sweepSummary = imagesSwept > 0 ? ` Images swept: ${imagesSwept}.` : "";
|
|
281
355
|
let summaryText: string;
|
|
282
356
|
if (allFailedBatches > 0) {
|
|
283
357
|
const uncheckedCount = totalItems - processed.length;
|
|
284
358
|
summaryText =
|
|
285
359
|
`Daily moderation: WARNING, ${allFailedBatches} batch(es) failed to reach the moderation API ` +
|
|
286
360
|
`(${uncheckedCount} of ${totalItems} items were not checked and will be retried). ` +
|
|
287
|
-
`${flagged.length} items hidden.${usernameSummary}`;
|
|
361
|
+
`${flagged.length} items hidden.${usernameSummary}${sweepSummary}`;
|
|
288
362
|
} else if (flagged.length === 0 && usernames.blanked.length === 0) {
|
|
289
|
-
summaryText = `Daily moderation: ${processed.length} items checked, nothing to report.${usernameSummary}`;
|
|
363
|
+
summaryText = `Daily moderation: ${processed.length} items checked, nothing to report.${usernameSummary}${sweepSummary}`;
|
|
290
364
|
} else {
|
|
291
365
|
summaryText =
|
|
292
|
-
`Daily moderation: ${flagged.length}/${processed.length} items hidden.${usernameSummary}\n\n` +
|
|
366
|
+
`Daily moderation: ${flagged.length}/${processed.length} items hidden.${usernameSummary}${sweepSummary}\n\n` +
|
|
293
367
|
[...lines, ...usernameLines].join("\n");
|
|
294
368
|
}
|
|
369
|
+
if (imageErrors.length > 0) {
|
|
370
|
+
summaryText += `\n\n${imageErrors.map((e) => `* WARNING: ${e}`).join("\n")}`;
|
|
371
|
+
}
|
|
295
372
|
await postToSlack({ text: summaryText });
|
|
296
373
|
|
|
297
374
|
return new Response(
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
// resurrected.
|
|
12
12
|
|
|
13
13
|
import { adminClient, callerUserId, isServiceCaller, json } from "../_shared/client.ts";
|
|
14
|
-
import {
|
|
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");
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { createClient } from "jsr:@supabase/supabase-js@2";
|
|
12
12
|
import { adminClient, json, SUPABASE_URL } from "../_shared/client.ts";
|
|
13
13
|
import { assertModerationConfigured, moderateInput } from "../_shared/moderation.ts";
|
|
14
|
+
import { containsAppName, isReservedHandle, suggestHandle } from "../_shared/names.ts";
|
|
14
15
|
|
|
15
16
|
assertModerationConfigured();
|
|
16
17
|
|
|
@@ -54,12 +55,17 @@ Deno.serve(async (req) => {
|
|
|
54
55
|
if (u.length < 2 || u.length > 24) {
|
|
55
56
|
return json({ status: "error", error: "invalid_username" }, 400);
|
|
56
57
|
}
|
|
58
|
+
if (containsAppName(u)) return json({ status: "error", error: "reserved_name" }, 400);
|
|
57
59
|
const verdict = await moderateInput(u);
|
|
58
60
|
if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
|
|
59
61
|
if (verdict.flagged) {
|
|
60
62
|
return json({ status: "rejected", field: "username", reason: verdict.reason });
|
|
61
63
|
}
|
|
62
|
-
|
|
64
|
+
// The user's own name: the host app's sync no longer overwrites it (core/009).
|
|
65
|
+
const { error } = await admin
|
|
66
|
+
.from("profiles")
|
|
67
|
+
.update({ username: u, username_set_by_user: true })
|
|
68
|
+
.eq("id", uid);
|
|
63
69
|
if (error) return json({ status: "error", error: error.message }, 500);
|
|
64
70
|
}
|
|
65
71
|
|
|
@@ -69,15 +75,26 @@ Deno.serve(async (req) => {
|
|
|
69
75
|
if (!/^[a-z0-9-]{3,20}$/.test(h)) {
|
|
70
76
|
return json({ status: "error", error: "invalid_handle" }, 400);
|
|
71
77
|
}
|
|
78
|
+
if (isReservedHandle(h)) return json({ status: "error", error: "reserved_handle" }, 400);
|
|
72
79
|
const verdict = await moderateInput(h);
|
|
73
80
|
if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
|
|
74
81
|
if (verdict.flagged) {
|
|
75
82
|
return json({ status: "rejected", field: "handle", reason: verdict.reason });
|
|
76
83
|
}
|
|
77
|
-
const { error } = await admin
|
|
84
|
+
const { error } = await admin
|
|
85
|
+
.from("profiles")
|
|
86
|
+
.update({ handle: h, handle_set_by_user: true })
|
|
87
|
+
.eq("id", uid);
|
|
78
88
|
if (error) {
|
|
79
89
|
if (error.code === "23505") {
|
|
80
|
-
|
|
90
|
+
const suggestion = await suggestHandle(h, async (candidate) => {
|
|
91
|
+
const { count } = await admin
|
|
92
|
+
.from("profiles")
|
|
93
|
+
.select("id", { count: "exact", head: true })
|
|
94
|
+
.eq("handle", candidate);
|
|
95
|
+
return count === 0;
|
|
96
|
+
});
|
|
97
|
+
return json({ status: "error", code: "handle_taken", suggestion }, 409);
|
|
81
98
|
}
|
|
82
99
|
return json({ status: "error", error: error.message }, 500);
|
|
83
100
|
}
|
|
@@ -111,7 +128,9 @@ Deno.serve(async (req) => {
|
|
|
111
128
|
return json({ status: "error", error: "forbidden_path" }, 403);
|
|
112
129
|
}
|
|
113
130
|
const publicUrl = `${SUPABASE_URL}/storage/v1/object/public/avatars/${path}`;
|
|
114
|
-
const verdict = await moderateInput([{ type: "image_url", image_url: { url: publicUrl } }]
|
|
131
|
+
const verdict = await moderateInput([{ type: "image_url", image_url: { url: publicUrl } }], {
|
|
132
|
+
strict: true,
|
|
133
|
+
});
|
|
115
134
|
if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
|
|
116
135
|
if (verdict.flagged) {
|
|
117
136
|
await admin.storage.from("avatars").remove([path]);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
-- community-sdk core/009_default_names.sql
|
|
2
|
+
-- Default names: every profile gets "<label> <digits>" / "<prefix>-<digits>"
|
|
3
|
+
-- (assign_default_profile, called by the client once per launch). The host
|
|
4
|
+
-- app's name seeds the profile until the user edits it through update-profile;
|
|
5
|
+
-- from then on a client write of `username` is ignored (the user wins). See
|
|
6
|
+
-- docs/superpowers/specs/2026-10-26-default-names-design.md.
|
|
7
|
+
|
|
8
|
+
alter table public.profiles
|
|
9
|
+
add column if not exists default_username text,
|
|
10
|
+
add column if not exists default_handle text,
|
|
11
|
+
add column if not exists username_set_by_user boolean not null default false,
|
|
12
|
+
add column if not exists handle_set_by_user boolean not null default false;
|
|
13
|
+
|
|
14
|
+
-- generate_handle with the slug to use when `base` has no usable letters
|
|
15
|
+
-- (a Korean or emoji-only name): the app's handle prefix instead of "member".
|
|
16
|
+
create or replace function public.generate_handle(base text, fallback text)
|
|
17
|
+
returns text
|
|
18
|
+
language plpgsql volatile security definer
|
|
19
|
+
set search_path = public, extensions
|
|
20
|
+
as $$
|
|
21
|
+
declare
|
|
22
|
+
slug text;
|
|
23
|
+
candidate text;
|
|
24
|
+
tries int := 0;
|
|
25
|
+
begin
|
|
26
|
+
slug := left(regexp_replace(lower(unaccent(coalesce(base, ''))), '[^a-z0-9]', '', 'g'), 12);
|
|
27
|
+
if slug = '' then
|
|
28
|
+
slug := left(regexp_replace(lower(coalesce(fallback, '')), '[^a-z0-9]', '', 'g'), 12);
|
|
29
|
+
end if;
|
|
30
|
+
if slug = '' then
|
|
31
|
+
slug := 'member';
|
|
32
|
+
end if;
|
|
33
|
+
loop
|
|
34
|
+
if tries < 5 then
|
|
35
|
+
candidate := slug || '-' || (floor(random() * 9000) + 1000)::int;
|
|
36
|
+
else
|
|
37
|
+
candidate := slug || '-' || (floor(random() * 900000) + 100000)::int;
|
|
38
|
+
end if;
|
|
39
|
+
exit when not exists (select 1 from public.profiles where handle = candidate);
|
|
40
|
+
tries := tries + 1;
|
|
41
|
+
end loop;
|
|
42
|
+
return candidate;
|
|
43
|
+
end;
|
|
44
|
+
$$;
|
|
45
|
+
|
|
46
|
+
create or replace function public.generate_handle(base text)
|
|
47
|
+
returns text
|
|
48
|
+
language sql volatile security definer
|
|
49
|
+
set search_path = public, extensions
|
|
50
|
+
as $$ select public.generate_handle(base, 'member') $$;
|
|
51
|
+
|
|
52
|
+
-- The default handle when it is still free, else a fresh one on its prefix.
|
|
53
|
+
create or replace function public.default_handle_for(p_id uuid, p_default text)
|
|
54
|
+
returns text
|
|
55
|
+
language plpgsql volatile security definer
|
|
56
|
+
set search_path = public, extensions
|
|
57
|
+
as $$
|
|
58
|
+
begin
|
|
59
|
+
if p_default is null then
|
|
60
|
+
return null;
|
|
61
|
+
end if;
|
|
62
|
+
if not exists (select 1 from public.profiles where handle = p_default and id <> p_id) then
|
|
63
|
+
return p_default;
|
|
64
|
+
end if;
|
|
65
|
+
return public.generate_handle('', regexp_replace(p_default, '-[0-9]+$', ''));
|
|
66
|
+
end;
|
|
67
|
+
$$;
|
|
68
|
+
|
|
69
|
+
-- One trigger owns username + handle consistency (replaces core/001's
|
|
70
|
+
-- assign_handle and core/007's profiles_username_changed, whose relative order
|
|
71
|
+
-- mattered). "Trusted" writes are update-profile (service role) and this
|
|
72
|
+
-- file's security definer functions (they set community.trusted).
|
|
73
|
+
create or replace function public.profiles_names()
|
|
74
|
+
returns trigger
|
|
75
|
+
language plpgsql security definer
|
|
76
|
+
set search_path = public, extensions
|
|
77
|
+
as $$
|
|
78
|
+
declare
|
|
79
|
+
trusted boolean := coalesce(auth.role(), '') = 'service_role'
|
|
80
|
+
or coalesce(current_setting('community.trusted', true), '') = 'on';
|
|
81
|
+
prefix text;
|
|
82
|
+
begin
|
|
83
|
+
if tg_op = 'UPDATE' and new.username is distinct from old.username then
|
|
84
|
+
if not trusted and old.username_set_by_user then
|
|
85
|
+
-- Host sync after the user picked their own name: the user wins.
|
|
86
|
+
new.username := old.username;
|
|
87
|
+
elsif new.username is null
|
|
88
|
+
or (new.username = old.username_rejected and not trusted) then
|
|
89
|
+
-- Blanked (moderation sweep, old clients, account scrub) or a re-sync of
|
|
90
|
+
-- a name the sweep already blanked: back to the default name.
|
|
91
|
+
new.username := old.default_username;
|
|
92
|
+
new.username_checked_at := now();
|
|
93
|
+
elsif new.username is not distinct from new.default_username then
|
|
94
|
+
new.username_checked_at := now();
|
|
95
|
+
else
|
|
96
|
+
-- A genuinely new name: re-check it on the next sweep.
|
|
97
|
+
new.username_checked_at := null;
|
|
98
|
+
end if;
|
|
99
|
+
end if;
|
|
100
|
+
|
|
101
|
+
if new.username is not null and new.handle is null then
|
|
102
|
+
new.handle := public.generate_handle(new.username,
|
|
103
|
+
regexp_replace(coalesce(new.default_handle, ''), '-[0-9]+$', ''));
|
|
104
|
+
elsif tg_op = 'UPDATE'
|
|
105
|
+
and not new.handle_set_by_user
|
|
106
|
+
and not new.username_set_by_user
|
|
107
|
+
and new.username is distinct from old.username
|
|
108
|
+
and new.handle is not distinct from old.handle then
|
|
109
|
+
-- The host's name changed (or the sweep blanked it) and the user never
|
|
110
|
+
-- picked a handle: follow it.
|
|
111
|
+
if new.username is not distinct from new.default_username then
|
|
112
|
+
new.handle := coalesce(public.default_handle_for(new.id, new.default_handle), new.handle);
|
|
113
|
+
else
|
|
114
|
+
prefix := regexp_replace(coalesce(new.default_handle, ''), '-[0-9]+$', '');
|
|
115
|
+
new.handle := public.generate_handle(new.username, prefix);
|
|
116
|
+
end if;
|
|
117
|
+
end if;
|
|
118
|
+
return new;
|
|
119
|
+
end;
|
|
120
|
+
$$;
|
|
121
|
+
|
|
122
|
+
drop trigger if exists profiles_assign_handle on public.profiles;
|
|
123
|
+
drop trigger if exists profiles_username_changed_trg on public.profiles;
|
|
124
|
+
drop function if exists public.assign_handle();
|
|
125
|
+
drop function if exists public.profiles_username_changed();
|
|
126
|
+
|
|
127
|
+
create trigger profiles_names_trg
|
|
128
|
+
before insert or update of username, handle on public.profiles
|
|
129
|
+
for each row execute function public.profiles_names();
|
|
130
|
+
|
|
131
|
+
-- Called by the client once per launch; idempotent. `p_label` is the app's
|
|
132
|
+
-- default name in the user's language ("Frère"), `p_handle` its ASCII handle
|
|
133
|
+
-- prefix ("brother"). Fills username / handle only when still null.
|
|
134
|
+
create or replace function public.assign_default_profile(p_label text, p_handle text)
|
|
135
|
+
returns void
|
|
136
|
+
language plpgsql volatile security definer
|
|
137
|
+
set search_path = public, extensions
|
|
138
|
+
as $$
|
|
139
|
+
declare
|
|
140
|
+
uid uuid := auth.uid();
|
|
141
|
+
label text := left(btrim(coalesce(p_label, '')), 17);
|
|
142
|
+
h text;
|
|
143
|
+
name text;
|
|
144
|
+
begin
|
|
145
|
+
if uid is null then
|
|
146
|
+
return;
|
|
147
|
+
end if;
|
|
148
|
+
if exists (select 1 from public.profiles where id = uid and default_username is not null) then
|
|
149
|
+
return;
|
|
150
|
+
end if;
|
|
151
|
+
if label = '' then
|
|
152
|
+
label := 'User';
|
|
153
|
+
end if;
|
|
154
|
+
h := public.generate_handle('', coalesce(nullif(p_handle, ''), 'user'));
|
|
155
|
+
name := label || ' ' || substring(h from '-([0-9]+)$');
|
|
156
|
+
perform set_config('community.trusted', 'on', true);
|
|
157
|
+
update public.profiles
|
|
158
|
+
set default_username = name,
|
|
159
|
+
default_handle = h,
|
|
160
|
+
username = coalesce(username, name),
|
|
161
|
+
handle = coalesce(handle, h)
|
|
162
|
+
where id = uid;
|
|
163
|
+
perform set_config('community.trusted', '', true);
|
|
164
|
+
end;
|
|
165
|
+
$$;
|
|
166
|
+
|
|
167
|
+
-- Account deletion: default name and handle back, bio / avatar / edits cleared.
|
|
168
|
+
create or replace function public.reset_profile_identity()
|
|
169
|
+
returns void
|
|
170
|
+
language plpgsql volatile security definer
|
|
171
|
+
set search_path = public, extensions
|
|
172
|
+
as $$
|
|
173
|
+
declare
|
|
174
|
+
uid uuid := auth.uid();
|
|
175
|
+
begin
|
|
176
|
+
if uid is null then
|
|
177
|
+
return;
|
|
178
|
+
end if;
|
|
179
|
+
perform set_config('community.trusted', 'on', true);
|
|
180
|
+
update public.profiles
|
|
181
|
+
set username = default_username,
|
|
182
|
+
handle = coalesce(public.default_handle_for(id, default_handle), handle),
|
|
183
|
+
username_set_by_user = false,
|
|
184
|
+
handle_set_by_user = false,
|
|
185
|
+
bio = null,
|
|
186
|
+
avatar_url = null
|
|
187
|
+
where id = uid;
|
|
188
|
+
perform set_config('community.trusted', '', true);
|
|
189
|
+
end;
|
|
190
|
+
$$;
|
|
191
|
+
|
|
192
|
+
revoke execute on function public.generate_handle(text, text) from public, anon, authenticated;
|
|
193
|
+
revoke execute on function public.generate_handle(text) from public, anon, authenticated;
|
|
194
|
+
revoke execute on function public.default_handle_for(uuid, text) from public, anon, authenticated;
|
|
195
|
+
revoke execute on function public.profiles_names() from public, anon, authenticated;
|
|
196
|
+
revoke execute on function public.assign_default_profile(text, text) from public, anon;
|
|
197
|
+
revoke execute on function public.reset_profile_identity() from public, anon;
|
|
198
|
+
grant execute on function public.assign_default_profile(text, text) to authenticated;
|
|
199
|
+
grant execute on function public.reset_profile_identity() to authenticated;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
-- community-sdk images/001_images.sql (schema v1)
|
|
2
|
+
-- Images module: one optional image per post and per comment.
|
|
3
|
+
-- Columns hold the Storage object name in the public `post-images` bucket
|
|
4
|
+
-- ({uid}/{uuid}.jpg) plus the dimensions the client needs to lay the frame out
|
|
5
|
+
-- before the bytes arrive. Public read (plain public object URLs), 2MB cap,
|
|
6
|
+
-- JPEG only (the client re-encodes on device, which also strips EXIF/GPS).
|
|
7
|
+
-- Unlike avatars, a hidden item KEEPS its file; the daily sweep removes only
|
|
8
|
+
-- objects no live (status <> 'deleted') row references, via
|
|
9
|
+
-- post_images_unreferenced() below. Fully idempotent.
|
|
10
|
+
|
|
11
|
+
-- ============ COLUMNS ============
|
|
12
|
+
alter table public.posts
|
|
13
|
+
add column if not exists image_path text,
|
|
14
|
+
add column if not exists image_width int,
|
|
15
|
+
add column if not exists image_height int;
|
|
16
|
+
alter table public.comments
|
|
17
|
+
add column if not exists image_path text,
|
|
18
|
+
add column if not exists image_width int,
|
|
19
|
+
add column if not exists image_height int;
|
|
20
|
+
|
|
21
|
+
-- Dimensions are set iff the path is set, and positive.
|
|
22
|
+
alter table public.posts drop constraint if exists posts_image_check;
|
|
23
|
+
alter table public.posts add constraint posts_image_check check (
|
|
24
|
+
(image_path is null and image_width is null and image_height is null)
|
|
25
|
+
or (image_path is not null and image_width > 0 and image_height > 0)
|
|
26
|
+
);
|
|
27
|
+
alter table public.comments drop constraint if exists comments_image_check;
|
|
28
|
+
alter table public.comments add constraint comments_image_check check (
|
|
29
|
+
(image_path is null and image_width is null and image_height is null)
|
|
30
|
+
or (image_path is not null and image_width > 0 and image_height > 0)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
-- The path is `{uid}/{name}.jpg` (name: a uuid, or the client's
|
|
34
|
+
-- `{Date.now()}-{base36}` fallback when crypto.randomUUID is missing), so a
|
|
35
|
+
-- crafted insert can't point at another bucket path or a non-JPEG name.
|
|
36
|
+
alter table public.posts drop constraint if exists posts_image_path_format;
|
|
37
|
+
alter table public.posts add constraint posts_image_path_format check (
|
|
38
|
+
image_path is null or image_path ~ '^[0-9a-f-]{36}/[A-Za-z0-9_-]+\.jpg$'
|
|
39
|
+
);
|
|
40
|
+
alter table public.comments drop constraint if exists comments_image_path_format;
|
|
41
|
+
alter table public.comments add constraint comments_image_path_format check (
|
|
42
|
+
image_path is null or image_path ~ '^[0-9a-f-]{36}/[A-Za-z0-9_-]+\.jpg$'
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
-- Text becomes optional when an image is present (image-only items store '').
|
|
46
|
+
-- core/001_tables.sql leaves the inline check auto-named <table>_content_check.
|
|
47
|
+
alter table public.posts drop constraint if exists posts_content_check;
|
|
48
|
+
alter table public.posts add constraint posts_content_check check (
|
|
49
|
+
char_length(content) <= 2000 and (char_length(content) >= 1 or image_path is not null)
|
|
50
|
+
);
|
|
51
|
+
alter table public.comments drop constraint if exists comments_content_check;
|
|
52
|
+
alter table public.comments add constraint comments_content_check check (
|
|
53
|
+
char_length(content) <= 1000 and (char_length(content) >= 1 or image_path is not null)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
-- ============ CLIENT WRITE ACCESS ============
|
|
57
|
+
-- Both tables use column-scoped insert grants (core/002_rls.sql); extend them.
|
|
58
|
+
grant insert (image_path, image_width, image_height) on table public.posts to authenticated;
|
|
59
|
+
grant insert (image_path, image_width, image_height) on table public.comments to authenticated;
|
|
60
|
+
|
|
61
|
+
-- Restrictive: ANDed with the existing permissive insert policies, so an image
|
|
62
|
+
-- can only point into the author's own storage folder.
|
|
63
|
+
drop policy if exists "post image in own folder" on public.posts;
|
|
64
|
+
create policy "post image in own folder"
|
|
65
|
+
on public.posts as restrictive for insert to authenticated
|
|
66
|
+
with check (
|
|
67
|
+
image_path is null or (storage.foldername(image_path))[1] = auth.uid()::text
|
|
68
|
+
);
|
|
69
|
+
drop policy if exists "comment image in own folder" on public.comments;
|
|
70
|
+
create policy "comment image in own folder"
|
|
71
|
+
on public.comments as restrictive for insert to authenticated
|
|
72
|
+
with check (
|
|
73
|
+
image_path is null or (storage.foldername(image_path))[1] = auth.uid()::text
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
-- ============ STORAGE ============
|
|
77
|
+
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
|
78
|
+
values ('post-images', 'post-images', true, 2097152, array['image/jpeg'])
|
|
79
|
+
on conflict (id) do nothing;
|
|
80
|
+
|
|
81
|
+
-- Clients may only CREATE objects inside their own folder. No UPDATE/DELETE
|
|
82
|
+
-- policies: removal is done by the Edge Functions with the service role.
|
|
83
|
+
drop policy if exists "post image upload to own folder" on storage.objects;
|
|
84
|
+
create policy "post image upload to own folder"
|
|
85
|
+
on storage.objects for insert to authenticated
|
|
86
|
+
with check (
|
|
87
|
+
bucket_id = 'post-images'
|
|
88
|
+
and (storage.foldername(name))[1] = auth.uid()::text
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
-- ============ SWEEP ============
|
|
92
|
+
-- Object names older than 24h that no live post/comment references: abandoned
|
|
93
|
+
-- uploads, plus files of deleted items. Hidden items stay referenced (file
|
|
94
|
+
-- kept). Service role only (called by daily-moderation).
|
|
95
|
+
create or replace function public.post_images_unreferenced(p_limit int default 500)
|
|
96
|
+
returns setof text
|
|
97
|
+
language sql
|
|
98
|
+
stable
|
|
99
|
+
security definer
|
|
100
|
+
set search_path = ''
|
|
101
|
+
as $$
|
|
102
|
+
select o.name
|
|
103
|
+
from storage.objects o
|
|
104
|
+
where o.bucket_id = 'post-images'
|
|
105
|
+
and o.created_at < now() - interval '24 hours'
|
|
106
|
+
and not exists (
|
|
107
|
+
select 1 from public.posts p
|
|
108
|
+
where p.image_path = o.name and p.status <> 'deleted'
|
|
109
|
+
)
|
|
110
|
+
and not exists (
|
|
111
|
+
select 1 from public.comments c
|
|
112
|
+
where c.image_path = o.name and c.status <> 'deleted'
|
|
113
|
+
)
|
|
114
|
+
order by o.created_at
|
|
115
|
+
limit p_limit;
|
|
116
|
+
$$;
|
|
117
|
+
|
|
118
|
+
revoke all on function public.post_images_unreferenced(int) from public, anon, authenticated;
|
|
119
|
+
grant execute on function public.post_images_unreferenced(int) to service_role;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- community-sdk translation/003_skip_empty.sql (schema v1)
|
|
2
|
+
-- Image-only posts and comments (images module) store content = '': there is
|
|
3
|
+
-- nothing to translate, so ensureTranslations writes no row for them, and the
|
|
4
|
+
-- sweep helper below would re-pick them on every daily run (filling its
|
|
5
|
+
-- newest-first window and keeping the chain going). Same function as
|
|
6
|
+
-- 001_translations.sql with `content <> ''` added to both inner selects.
|
|
7
|
+
-- Idempotent (create or replace); service role only, as before.
|
|
8
|
+
create or replace function public.items_missing_translations(
|
|
9
|
+
kind text, target_locales text[], max_items int
|
|
10
|
+
) returns table (id uuid)
|
|
11
|
+
language sql stable security definer set search_path = public as $$
|
|
12
|
+
select x.id from (
|
|
13
|
+
select p.id, p.created_at,
|
|
14
|
+
exists (select 1 from public.post_translations t where t.post_id = p.id) as has_any,
|
|
15
|
+
(select array_agg(t.locale) filter (where t.locale not in ('source', 'attempt'))
|
|
16
|
+
from public.post_translations t where t.post_id = p.id) as have,
|
|
17
|
+
(select min(t.source_locale) filter (where t.locale <> 'attempt')
|
|
18
|
+
from public.post_translations t where t.post_id = p.id) as src,
|
|
19
|
+
exists (select 1 from public.post_translations t
|
|
20
|
+
where t.post_id = p.id and t.locale = 'attempt'
|
|
21
|
+
and t.created_at > now() - interval '6 hours') as fresh_attempt
|
|
22
|
+
from public.posts p where kind = 'post' and p.status = 'visible' and p.content <> ''
|
|
23
|
+
union all
|
|
24
|
+
select c.id, c.created_at,
|
|
25
|
+
exists (select 1 from public.comment_translations t where t.comment_id = c.id) as has_any,
|
|
26
|
+
(select array_agg(t.locale) filter (where t.locale not in ('source', 'attempt'))
|
|
27
|
+
from public.comment_translations t where t.comment_id = c.id) as have,
|
|
28
|
+
(select min(t.source_locale) filter (where t.locale <> 'attempt')
|
|
29
|
+
from public.comment_translations t where t.comment_id = c.id) as src,
|
|
30
|
+
exists (select 1 from public.comment_translations t
|
|
31
|
+
where t.comment_id = c.id and t.locale = 'attempt'
|
|
32
|
+
and t.created_at > now() - interval '6 hours') as fresh_attempt
|
|
33
|
+
from public.comments c where kind = 'comment' and c.status = 'visible' and c.content <> ''
|
|
34
|
+
) x
|
|
35
|
+
where not x.fresh_attempt
|
|
36
|
+
and (not x.has_any
|
|
37
|
+
or exists (
|
|
38
|
+
select 1 from unnest(target_locales) l
|
|
39
|
+
-- `is distinct from`: src is null when the only row is a stale attempt
|
|
40
|
+
-- (a failed try), and that item must be retried.
|
|
41
|
+
where split_part(l, '-', 1) is distinct from x.src
|
|
42
|
+
and not (l = any (coalesce(x.have, '{}')))
|
|
43
|
+
))
|
|
44
|
+
order by x.created_at desc
|
|
45
|
+
limit max_items
|
|
46
|
+
$$;
|
|
47
|
+
revoke execute on function public.items_missing_translations(text, text[], int) from public, anon, authenticated;
|
|
48
|
+
grant execute on function public.items_missing_translations(text, text[], int) to service_role;
|