@rocapine/community 0.1.0 → 0.2.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/lib/commands/init.js +1 -1
- package/package.json +1 -1
- package/templates/functions/_shared/client.ts +35 -2
- package/templates/functions/_shared/config.ts +3 -19
- package/templates/functions/_shared/copy.ts +247 -0
- package/templates/functions/broadcast-post/index.ts +1 -19
- package/templates/functions/daily-moderation/index.ts +38 -8
- package/templates/functions/moderate-one/index.ts +33 -8
- package/templates/functions/notify-comment/index.ts +32 -19
- package/templates/functions/notify-like/index.ts +19 -5
- package/templates/functions/notify-reaction/index.ts +12 -84
- package/templates/functions/report-to-slack/index.ts +17 -11
package/lib/commands/init.js
CHANGED
|
@@ -146,7 +146,7 @@ function printNextSteps(log, modules) {
|
|
|
146
146
|
log(" 2. Set Edge Function secrets: supabase secrets set OPENAI_API_KEY=...");
|
|
147
147
|
log(" optional: SLACK_WEBHOOK_URL, COMMUNITY_APP_NAME, COMMUNITY_FALLBACK_NAME");
|
|
148
148
|
if (modules.includes("push")) {
|
|
149
|
-
log(" push module: EXPO_ACCESS_TOKEN (
|
|
149
|
+
log(" push module: EXPO_ACCESS_TOKEN (only if your Expo project enforces Enhanced Security for push); COMMUNITY_PUSH_COPY (optional per-locale copy)");
|
|
150
150
|
}
|
|
151
151
|
log(" 3. Deploy the Edge Functions: supabase functions deploy");
|
|
152
152
|
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,48 @@
|
|
|
1
|
-
// Shared Supabase admin client + JSON response helper.
|
|
1
|
+
// Shared Supabase admin client, caller identity helpers + JSON response helper.
|
|
2
2
|
|
|
3
3
|
import { createClient } from "jsr:@supabase/supabase-js@2";
|
|
4
4
|
|
|
5
5
|
export const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
|
6
6
|
|
|
7
7
|
// Service-role client: bypasses RLS. Every function does privileged work
|
|
8
|
-
// through this; callers only ever pass the public anon JWT (verify_jwt)
|
|
8
|
+
// through this; callers only ever pass the public anon JWT (verify_jwt) or a
|
|
9
|
+
// user JWT.
|
|
9
10
|
export function adminClient() {
|
|
10
11
|
return createClient(SUPABASE_URL, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
// The gateway (verify_jwt) has already validated the bearer's signature, so
|
|
15
|
+
// only the role claim needs checking here. An exact string match against the
|
|
16
|
+
// injected SUPABASE_SERVICE_ROLE_KEY broke when a project migrated to the new
|
|
17
|
+
// API keys: the runtime then receives the sb_secret_* value while callers
|
|
18
|
+
// still hold the legacy service_role JWT. Accept either proof.
|
|
19
|
+
export function isServiceCaller(req: Request): boolean {
|
|
20
|
+
const bearer = (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
21
|
+
if (bearer && bearer === Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")) return true;
|
|
22
|
+
try {
|
|
23
|
+
const payload = JSON.parse(
|
|
24
|
+
atob(bearer.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
|
|
25
|
+
) as { role?: string };
|
|
26
|
+
return payload.role === "service_role";
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The user behind the request's bearer, or null for an anon-key-only call
|
|
33
|
+
// (or an invalid/expired user JWT). Resolved through Auth so a forged `sub`
|
|
34
|
+
// claim can't be smuggled in with a plain anon token.
|
|
35
|
+
export async function callerUserId(req: Request): Promise<string | null> {
|
|
36
|
+
const authorization = req.headers.get("authorization");
|
|
37
|
+
if (!authorization) return null;
|
|
38
|
+
const client = createClient(SUPABASE_URL, Deno.env.get("SUPABASE_ANON_KEY")!, {
|
|
39
|
+
global: { headers: { Authorization: authorization } },
|
|
40
|
+
});
|
|
41
|
+
const { data, error } = await client.auth.getUser();
|
|
42
|
+
if (error || !data.user) return null;
|
|
43
|
+
return data.user.id;
|
|
44
|
+
}
|
|
45
|
+
|
|
13
46
|
// The client consumes responses via supabase.functions.invoke, which only
|
|
14
47
|
// parses the body as JSON when the Content-Type says so. Without this header
|
|
15
48
|
// invoke returns a raw string and verdict.status is undefined.
|
|
@@ -1,23 +1,7 @@
|
|
|
1
|
-
// Per-app copy
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// supabase secrets set COMMUNITY_FALLBACK_NAME="A member"
|
|
5
|
-
// supabase secrets set COMMUNITY_APP_NAME="My App"
|
|
6
|
-
// supabase secrets set COMMUNITY_REACTION_PUSH_TEXT="{name} sent you support"
|
|
7
|
-
//
|
|
8
|
-
// Defaults are deliberately neutral.
|
|
9
|
-
|
|
10
|
-
// Display name used when the acting user has no username yet
|
|
11
|
-
// ("<name>: ..." in comment pushes, "<name> liked your post").
|
|
12
|
-
export const FALLBACK_ACTOR_NAME = Deno.env.get("COMMUNITY_FALLBACK_NAME") ?? "Someone";
|
|
1
|
+
// Per-app copy injected as function secrets so the same function code
|
|
2
|
+
// deploys unchanged to every app. Push copy (actor fallback name, like /
|
|
3
|
+
// comment / reaction titles) lives in _shared/copy.ts (COMMUNITY_PUSH_COPY).
|
|
13
4
|
|
|
14
5
|
// Push title fallback for official broadcast posts whose author has no
|
|
15
6
|
// username (normally the official account's display name).
|
|
16
7
|
export const BROADCAST_FALLBACK_TITLE = Deno.env.get("COMMUNITY_APP_NAME") ?? "Community";
|
|
17
|
-
|
|
18
|
-
// notify-reaction's single-reactor push title template. `{name}` is
|
|
19
|
-
// substituted with the reactor's display name (or FALLBACK_ACTOR_NAME).
|
|
20
|
-
// Neutral default; an app with its own reaction semantics overrides this
|
|
21
|
-
// secret rather than the function code.
|
|
22
|
-
export const REACTION_PUSH_TEXT =
|
|
23
|
-
Deno.env.get("COMMUNITY_REACTION_PUSH_TEXT") ?? "{name} is thinking of you";
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Locale-aware push copy for notify-comment / notify-like / notify-reaction.
|
|
2
|
+
//
|
|
3
|
+
// Every string below is a neutral built-in for the 9 locales the UI package
|
|
4
|
+
// ships. An app with its own voice overrides ANY of them — per locale, per
|
|
5
|
+
// key — through ONE JSON secret instead of forking the function bodies (the
|
|
6
|
+
// way one host app had to before this existed):
|
|
7
|
+
//
|
|
8
|
+
// supabase secrets set COMMUNITY_PUSH_COPY='{
|
|
9
|
+
// "fallbackName": { "en": "A sister", "pt-PT": "Uma irmã" },
|
|
10
|
+
// "comment.title": { "pl": "Otrzymałaś komentarz" },
|
|
11
|
+
// "like.one": { "pl": "{name} polubiła twój post" },
|
|
12
|
+
// "reaction.one": { "en": "{name} prayed for you 🙏" },
|
|
13
|
+
// "reaction.many": { "en": "{name} and {count} others prayed for you 🙏",
|
|
14
|
+
// "pl": { "one": "…", "few": "…", "many": "…" } }
|
|
15
|
+
// }'
|
|
16
|
+
//
|
|
17
|
+
// Placeholders: `{name}` (actor display name) and `{count}` (the number of
|
|
18
|
+
// OTHER people in a coalesced push). A `*.many` value is either a string or a
|
|
19
|
+
// `{ one, few, many, other }` object picked by the CLDR plural category of
|
|
20
|
+
// `{count}` for the recipient's locale (Polish needs all three; French treats
|
|
21
|
+
// 0 like 1; everything else is one/other).
|
|
22
|
+
//
|
|
23
|
+
// Legacy single-string secrets still apply, below the JSON and above the
|
|
24
|
+
// built-ins: COMMUNITY_FALLBACK_NAME (every locale's fallbackName) and
|
|
25
|
+
// COMMUNITY_REACTION_PUSH_TEXT (every locale's reaction.one).
|
|
26
|
+
|
|
27
|
+
type Forms = { one?: string; few?: string; many?: string; other?: string };
|
|
28
|
+
type CopyValue = string | Forms;
|
|
29
|
+
type LocaleCopy = Record<string, CopyValue>;
|
|
30
|
+
|
|
31
|
+
export type PushCopyKey =
|
|
32
|
+
"fallbackName" | "comment.title" | "like.one" | "like.many" | "reaction.one" | "reaction.many";
|
|
33
|
+
|
|
34
|
+
const BUILTIN: Record<string, LocaleCopy> = {
|
|
35
|
+
en: {
|
|
36
|
+
fallbackName: "Someone",
|
|
37
|
+
"comment.title": "You received a comment",
|
|
38
|
+
"like.one": "{name} liked your post",
|
|
39
|
+
"like.many": {
|
|
40
|
+
one: "{name} and {count} other liked your post",
|
|
41
|
+
other: "{name} and {count} others liked your post",
|
|
42
|
+
},
|
|
43
|
+
"reaction.one": "{name} is thinking of you",
|
|
44
|
+
"reaction.many": {
|
|
45
|
+
one: "{name} and {count} other are thinking of you",
|
|
46
|
+
other: "{name} and {count} others are thinking of you",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
"pt-PT": {
|
|
50
|
+
fallbackName: "Alguém",
|
|
51
|
+
"comment.title": "Recebeste um comentário",
|
|
52
|
+
"like.one": "{name} gostou da tua publicação",
|
|
53
|
+
"like.many": {
|
|
54
|
+
one: "{name} e mais {count} pessoa gostou da tua publicação",
|
|
55
|
+
other: "{name} e mais {count} pessoas gostaram da tua publicação",
|
|
56
|
+
},
|
|
57
|
+
"reaction.one": "{name} está a pensar em ti",
|
|
58
|
+
"reaction.many": {
|
|
59
|
+
one: "{name} e mais {count} pessoa está a pensar em ti",
|
|
60
|
+
other: "{name} e mais {count} pessoas estão a pensar em ti",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"pt-BR": {
|
|
64
|
+
fallbackName: "Alguém",
|
|
65
|
+
"comment.title": "Você recebeu um comentário",
|
|
66
|
+
"like.one": "{name} curtiu seu post",
|
|
67
|
+
"like.many": {
|
|
68
|
+
one: "{name} e mais {count} pessoa curtiu seu post",
|
|
69
|
+
other: "{name} e mais {count} pessoas curtiram seu post",
|
|
70
|
+
},
|
|
71
|
+
"reaction.one": "{name} está pensando em você",
|
|
72
|
+
"reaction.many": {
|
|
73
|
+
one: "{name} e mais {count} pessoa está pensando em você",
|
|
74
|
+
other: "{name} e mais {count} pessoas estão pensando em você",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
"es-ES": {
|
|
78
|
+
fallbackName: "Alguien",
|
|
79
|
+
"comment.title": "Has recibido un comentario",
|
|
80
|
+
"like.one": "A {name} le ha gustado tu publicación",
|
|
81
|
+
"like.many": {
|
|
82
|
+
one: "A {name} y a {count} persona más les ha gustado tu publicación",
|
|
83
|
+
other: "A {name} y a {count} personas más les ha gustado tu publicación",
|
|
84
|
+
},
|
|
85
|
+
"reaction.one": "{name} está pensando en ti",
|
|
86
|
+
"reaction.many": {
|
|
87
|
+
one: "{name} y {count} persona más está pensando en ti",
|
|
88
|
+
other: "{name} y {count} personas más están pensando en ti",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
"es-419": {
|
|
92
|
+
fallbackName: "Alguien",
|
|
93
|
+
"comment.title": "Recibiste un comentario",
|
|
94
|
+
"like.one": "A {name} le gustó tu publicación",
|
|
95
|
+
"like.many": {
|
|
96
|
+
one: "A {name} y a {count} persona más les gustó tu publicación",
|
|
97
|
+
other: "A {name} y a {count} personas más les gustó tu publicación",
|
|
98
|
+
},
|
|
99
|
+
"reaction.one": "{name} está pensando en ti",
|
|
100
|
+
"reaction.many": {
|
|
101
|
+
one: "{name} y {count} persona más está pensando en ti",
|
|
102
|
+
other: "{name} y {count} personas más están pensando en ti",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
it: {
|
|
106
|
+
fallbackName: "Qualcuno",
|
|
107
|
+
"comment.title": "Hai ricevuto un commento",
|
|
108
|
+
"like.one": "A {name} piace il tuo post",
|
|
109
|
+
"like.many": {
|
|
110
|
+
one: "A {name} e ad altra {count} persona piace il tuo post",
|
|
111
|
+
other: "A {name} e ad altre {count} persone piace il tuo post",
|
|
112
|
+
},
|
|
113
|
+
"reaction.one": "{name} sta pensando a te",
|
|
114
|
+
"reaction.many": {
|
|
115
|
+
one: "{name} e altra {count} persona sta pensando a te",
|
|
116
|
+
other: "{name} e altre {count} persone stanno pensando a te",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
pl: {
|
|
120
|
+
fallbackName: "Ktoś",
|
|
121
|
+
"comment.title": "Masz nowy komentarz",
|
|
122
|
+
"like.one": "{name} polubił(a) twój post",
|
|
123
|
+
"like.many": {
|
|
124
|
+
one: "{name} i {count} inna osoba polubiły twój post",
|
|
125
|
+
few: "{name} i {count} inne osoby polubiły twój post",
|
|
126
|
+
many: "{name} i {count} innych osób polubiło twój post",
|
|
127
|
+
},
|
|
128
|
+
"reaction.one": "{name} myśli o tobie",
|
|
129
|
+
"reaction.many": {
|
|
130
|
+
one: "{name} i {count} inna osoba myśli o tobie",
|
|
131
|
+
few: "{name} i {count} inne osoby myślą o tobie",
|
|
132
|
+
many: "{name} i {count} innych osób myśli o tobie",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
fr: {
|
|
136
|
+
fallbackName: "Quelqu'un",
|
|
137
|
+
"comment.title": "Tu as reçu un commentaire",
|
|
138
|
+
"like.one": "{name} a aimé ta publication",
|
|
139
|
+
"like.many": {
|
|
140
|
+
one: "{name} et {count} autre personne ont aimé ta publication",
|
|
141
|
+
other: "{name} et {count} autres personnes ont aimé ta publication",
|
|
142
|
+
},
|
|
143
|
+
"reaction.one": "{name} pense à toi",
|
|
144
|
+
"reaction.many": {
|
|
145
|
+
one: "{name} et {count} autre personne pensent à toi",
|
|
146
|
+
other: "{name} et {count} autres personnes pensent à toi",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
de: {
|
|
150
|
+
fallbackName: "Jemand",
|
|
151
|
+
"comment.title": "Du hast einen Kommentar erhalten",
|
|
152
|
+
"like.one": "{name} gefällt dein Beitrag",
|
|
153
|
+
"like.many": {
|
|
154
|
+
one: "{name} und {count} weiterer Person gefällt dein Beitrag",
|
|
155
|
+
other: "{name} und {count} weiteren Personen gefällt dein Beitrag",
|
|
156
|
+
},
|
|
157
|
+
"reaction.one": "{name} denkt an dich",
|
|
158
|
+
"reaction.many": {
|
|
159
|
+
one: "{name} und {count} weitere Person denken an dich",
|
|
160
|
+
other: "{name} und {count} weitere Personen denken an dich",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Same rule set as the UI package's `pluralCategory` (packages/ui/src/i18n.ts).
|
|
166
|
+
export function pluralCategory(locale: string, n: number): keyof Forms {
|
|
167
|
+
const lang = locale.split("-")[0]?.toLowerCase();
|
|
168
|
+
if (lang === "pl") {
|
|
169
|
+
if (n === 1) return "one";
|
|
170
|
+
const m10 = n % 10;
|
|
171
|
+
const m100 = n % 100;
|
|
172
|
+
if (m10 >= 2 && m10 <= 4 && !(m100 >= 12 && m100 <= 14)) return "few";
|
|
173
|
+
return "many";
|
|
174
|
+
}
|
|
175
|
+
if (lang === "fr") return n === 0 || n === 1 ? "one" : "other";
|
|
176
|
+
return n === 1 ? "one" : "other";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseOverrides(): Record<string, Record<string, CopyValue>> {
|
|
180
|
+
const raw = Deno.env.get("COMMUNITY_PUSH_COPY");
|
|
181
|
+
if (!raw) return {};
|
|
182
|
+
try {
|
|
183
|
+
const parsed = JSON.parse(raw);
|
|
184
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
185
|
+
} catch (e) {
|
|
186
|
+
console.error("community-sdk: COMMUNITY_PUSH_COPY is not valid JSON, ignoring it", e);
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const OVERRIDES = parseOverrides();
|
|
192
|
+
const LEGACY_FALLBACK_NAME = Deno.env.get("COMMUNITY_FALLBACK_NAME");
|
|
193
|
+
const LEGACY_REACTION_ONE = Deno.env.get("COMMUNITY_REACTION_PUSH_TEXT");
|
|
194
|
+
|
|
195
|
+
/** `es-MX` → `es-ES`, `pt-AO` → `pt-PT`, `fr-CA` → `fr`; unknown → `en`. */
|
|
196
|
+
function resolveLocale(locale: string | null | undefined): string {
|
|
197
|
+
if (!locale) return "en";
|
|
198
|
+
if (BUILTIN[locale]) return locale;
|
|
199
|
+
const lang = locale.split("-")[0].toLowerCase();
|
|
200
|
+
if (BUILTIN[lang]) return lang;
|
|
201
|
+
if (lang === "es") return "es-ES";
|
|
202
|
+
if (lang === "pt") return "pt-PT";
|
|
203
|
+
return "en";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function pick(key: PushCopyKey, locale: string, exact: string | null | undefined): CopyValue {
|
|
207
|
+
const overrideByLocale = OVERRIDES[key] ?? {};
|
|
208
|
+
// Override lookup: exact locale as sent by the client, then the resolved one.
|
|
209
|
+
const override =
|
|
210
|
+
(exact ? overrideByLocale[exact] : undefined) ??
|
|
211
|
+
overrideByLocale[locale] ??
|
|
212
|
+
overrideByLocale[locale.split("-")[0]];
|
|
213
|
+
if (override !== undefined) return override;
|
|
214
|
+
if (key === "fallbackName" && LEGACY_FALLBACK_NAME) return LEGACY_FALLBACK_NAME;
|
|
215
|
+
if (key === "reaction.one" && LEGACY_REACTION_ONE) return LEGACY_REACTION_ONE;
|
|
216
|
+
return BUILTIN[locale][key] ?? BUILTIN.en[key];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function fill(template: string, params: { name?: string; count?: number }): string {
|
|
220
|
+
return template
|
|
221
|
+
.replace(/\{name\}/g, params.name ?? "")
|
|
222
|
+
.replace(/\{count\}/g, params.count === undefined ? "" : String(params.count));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Copy resolver for one recipient (`profiles.locale`). */
|
|
226
|
+
export function pushCopy(recipientLocale: string | null | undefined) {
|
|
227
|
+
const locale = resolveLocale(recipientLocale);
|
|
228
|
+
const text = (key: PushCopyKey, params: { name?: string; count?: number } = {}): string => {
|
|
229
|
+
const value = pick(key, locale, recipientLocale);
|
|
230
|
+
if (typeof value === "string") return fill(value, params);
|
|
231
|
+
const category = pluralCategory(locale, params.count ?? 0);
|
|
232
|
+
const form =
|
|
233
|
+
value[category] ??
|
|
234
|
+
value.other ??
|
|
235
|
+
value.many ??
|
|
236
|
+
value.one ??
|
|
237
|
+
// A partial override object: fall through to the built-in forms.
|
|
238
|
+
(() => {
|
|
239
|
+
const builtin = BUILTIN[locale][key] ?? BUILTIN.en[key];
|
|
240
|
+
return typeof builtin === "string"
|
|
241
|
+
? builtin
|
|
242
|
+
: (builtin[category] ?? builtin.other ?? builtin.many ?? builtin.one ?? "");
|
|
243
|
+
})();
|
|
244
|
+
return fill(form, params);
|
|
245
|
+
};
|
|
246
|
+
return { locale, fallbackName: text("fallbackName"), text };
|
|
247
|
+
}
|
|
@@ -4,30 +4,12 @@
|
|
|
4
4
|
// Guarded by a service-role check — the public anon key passes the platform's
|
|
5
5
|
// verify_jwt but must NOT be able to trigger a broadcast.
|
|
6
6
|
|
|
7
|
-
import { adminClient } from "../_shared/client.ts";
|
|
7
|
+
import { adminClient, isServiceCaller } from "../_shared/client.ts";
|
|
8
8
|
import { sendExpoPushBatch } from "../_shared/push.ts";
|
|
9
9
|
import { BROADCAST_FALLBACK_TITLE } from "../_shared/config.ts";
|
|
10
10
|
|
|
11
11
|
const supabase = adminClient();
|
|
12
12
|
|
|
13
|
-
// The gateway (verify_jwt) has already validated the bearer's signature, so
|
|
14
|
-
// only the role claim needs checking here. An exact string match against the
|
|
15
|
-
// injected SUPABASE_SERVICE_ROLE_KEY broke when the project migrated to the
|
|
16
|
-
// new API keys: the runtime now receives the sb_secret_* value while callers
|
|
17
|
-
// still hold the legacy service_role JWT. Accept either proof.
|
|
18
|
-
function isServiceCaller(req: Request): boolean {
|
|
19
|
-
const bearer = (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
20
|
-
if (bearer && bearer === Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")) return true;
|
|
21
|
-
try {
|
|
22
|
-
const payload = JSON.parse(
|
|
23
|
-
atob(bearer.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
|
|
24
|
-
) as { role?: string };
|
|
25
|
-
return payload.role === "service_role";
|
|
26
|
-
} catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
13
|
Deno.serve(async (req) => {
|
|
32
14
|
if (!isServiceCaller(req)) {
|
|
33
15
|
return new Response("unauthorized", { status: 401 });
|
|
@@ -41,7 +41,7 @@ Deno.serve(async () => {
|
|
|
41
41
|
const { data: posts, error: postsError } = await supabase
|
|
42
42
|
.from("posts")
|
|
43
43
|
.select(
|
|
44
|
-
"id, content, author_id, status,
|
|
44
|
+
"id, content, author_id, status, profiles!posts_author_id_fkey(username, amplitude_id, revenuecat_id)",
|
|
45
45
|
)
|
|
46
46
|
.is("moderated_at", null)
|
|
47
47
|
.in("status", ["pending", "visible"]);
|
|
@@ -57,16 +57,34 @@ Deno.serve(async () => {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// Poll option labels are user content: fold them into the post's moderated
|
|
60
|
-
// text (same treatment as the synchronous moderate-one function).
|
|
60
|
+
// text (same treatment as the synchronous moderate-one function). The polls
|
|
61
|
+
// module is optional: without it the table doesn't exist, the query errors,
|
|
62
|
+
// and posts are moderated on their text alone (a separate query instead of
|
|
63
|
+
// an embed so a core-only install doesn't 400 the whole sweep).
|
|
64
|
+
const optionsByPost = new Map<string, { idx: number; label: string }[]>();
|
|
65
|
+
if (posts && posts.length > 0) {
|
|
66
|
+
const { data: options, error } = await supabase
|
|
67
|
+
.from("poll_options")
|
|
68
|
+
.select("post_id, idx, label")
|
|
69
|
+
.in(
|
|
70
|
+
"post_id",
|
|
71
|
+
posts.map((p) => p.id),
|
|
72
|
+
);
|
|
73
|
+
if (!error) {
|
|
74
|
+
for (const o of options ?? []) {
|
|
75
|
+
const list = optionsByPost.get(o.post_id) ?? [];
|
|
76
|
+
list.push({ idx: o.idx, label: o.label });
|
|
77
|
+
optionsByPost.set(o.post_id, list);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
61
81
|
const items: Item[] = [
|
|
62
82
|
...(posts ?? []).map((p) => ({
|
|
63
83
|
...p,
|
|
64
84
|
profiles: toOne(p.profiles),
|
|
65
85
|
content: [
|
|
66
86
|
p.content,
|
|
67
|
-
...(p.
|
|
68
|
-
.sort((a: { idx: number }, b: { idx: number }) => a.idx - b.idx)
|
|
69
|
-
.map((o: { label: string }) => o.label),
|
|
87
|
+
...(optionsByPost.get(p.id) ?? []).sort((a, b) => a.idx - b.idx).map((o) => o.label),
|
|
70
88
|
].join("\n"),
|
|
71
89
|
table: "posts" as const,
|
|
72
90
|
})),
|
|
@@ -116,11 +134,15 @@ Deno.serve(async () => {
|
|
|
116
134
|
const totalItems = items.length;
|
|
117
135
|
|
|
118
136
|
const flaggedIds = new Set(flagged.map((f) => f.item.id));
|
|
137
|
+
// Status guards: a row the author soft-deleted while the sweep ran must not
|
|
138
|
+
// be resurrected as hidden/visible, so every write re-checks the status it
|
|
139
|
+
// was selected with.
|
|
119
140
|
for (const { item, categories } of flagged) {
|
|
120
141
|
await supabase
|
|
121
142
|
.from(item.table)
|
|
122
143
|
.update({ status: "hidden", moderation_reason: categories.join(",") })
|
|
123
|
-
.eq("id", item.id)
|
|
144
|
+
.eq("id", item.id)
|
|
145
|
+
.in("status", ["pending", "visible"]);
|
|
124
146
|
}
|
|
125
147
|
// Backstop: promote clean 'pending' items to 'visible' (their synchronous
|
|
126
148
|
// moderate-one call must have failed).
|
|
@@ -128,10 +150,18 @@ Deno.serve(async () => {
|
|
|
128
150
|
const promotePostIds = promote.filter((i) => i.table === "posts").map((i) => i.id);
|
|
129
151
|
const promoteCommentIds = promote.filter((i) => i.table === "comments").map((i) => i.id);
|
|
130
152
|
if (promotePostIds.length > 0) {
|
|
131
|
-
await supabase
|
|
153
|
+
await supabase
|
|
154
|
+
.from("posts")
|
|
155
|
+
.update({ status: "visible" })
|
|
156
|
+
.in("id", promotePostIds)
|
|
157
|
+
.eq("status", "pending");
|
|
132
158
|
}
|
|
133
159
|
if (promoteCommentIds.length > 0) {
|
|
134
|
-
await supabase
|
|
160
|
+
await supabase
|
|
161
|
+
.from("comments")
|
|
162
|
+
.update({ status: "visible" })
|
|
163
|
+
.in("id", promoteCommentIds)
|
|
164
|
+
.eq("status", "pending");
|
|
135
165
|
}
|
|
136
166
|
const now = new Date().toISOString();
|
|
137
167
|
const processedPostIds = processed.filter((i) => i.table === "posts").map((i) => i.id);
|
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
// promoted to 'visible' (published); flagged -> 'hidden' (rejected) with the
|
|
4
4
|
// reason. On an OpenAI error we fail closed (leave it pending); the daily
|
|
5
5
|
// backstop re-checks stale pending items so nothing is silently lost.
|
|
6
|
+
//
|
|
7
|
+
// Security: the caller must be the row's author (resolved from their user
|
|
8
|
+
// JWT through Auth) or the service role — an anon-key-only call can't
|
|
9
|
+
// moderate someone else's pending item. Every status write is guarded with
|
|
10
|
+
// `status = 'pending'` so a row the author deleted mid-flight is never
|
|
11
|
+
// resurrected.
|
|
6
12
|
|
|
7
|
-
import { adminClient, json } from "../_shared/client.ts";
|
|
13
|
+
import { adminClient, callerUserId, isServiceCaller, json } from "../_shared/client.ts";
|
|
8
14
|
import { assertModerationConfigured, moderateInput } from "../_shared/moderation.ts";
|
|
9
15
|
|
|
10
16
|
assertModerationConfigured();
|
|
@@ -12,7 +18,10 @@ assertModerationConfigured();
|
|
|
12
18
|
const supabase = adminClient();
|
|
13
19
|
|
|
14
20
|
Deno.serve(async (req) => {
|
|
15
|
-
const { kind, id } = (await req.json())
|
|
21
|
+
const { kind, id } = (await req.json().catch(() => ({}))) as {
|
|
22
|
+
kind?: "post" | "comment";
|
|
23
|
+
id?: string;
|
|
24
|
+
};
|
|
16
25
|
if ((kind !== "post" && kind !== "comment") || !id) {
|
|
17
26
|
return json({ status: "error", error: "bad request" }, 400);
|
|
18
27
|
}
|
|
@@ -20,25 +29,33 @@ Deno.serve(async (req) => {
|
|
|
20
29
|
|
|
21
30
|
const { data: row } = await supabase
|
|
22
31
|
.from(table)
|
|
23
|
-
.select("id, content, status")
|
|
32
|
+
.select("id, author_id, content, status")
|
|
24
33
|
.eq("id", id)
|
|
25
34
|
.single();
|
|
26
35
|
if (!row) return json({ status: "error", error: "not found" });
|
|
36
|
+
|
|
37
|
+
if (!isServiceCaller(req)) {
|
|
38
|
+
const uid = await callerUserId(req);
|
|
39
|
+
if (!uid || uid !== row.author_id) return json({ status: "error", error: "forbidden" }, 403);
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
// Idempotent: only pending rows are moderated here.
|
|
28
43
|
if (row.status !== "pending") {
|
|
29
44
|
return json({ status: row.status === "visible" ? "published" : row.status });
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
// A poll post's option labels are user content too: moderate them together
|
|
33
|
-
// with the question, one verdict for the whole post.
|
|
48
|
+
// with the question, one verdict for the whole post. The polls module is
|
|
49
|
+
// optional — on an install without it the table doesn't exist and the
|
|
50
|
+
// query errors, which simply means "no options".
|
|
34
51
|
let input = row.content;
|
|
35
52
|
if (kind === "post") {
|
|
36
|
-
const { data: options } = await supabase
|
|
53
|
+
const { data: options, error } = await supabase
|
|
37
54
|
.from("poll_options")
|
|
38
55
|
.select("label")
|
|
39
56
|
.eq("post_id", id)
|
|
40
57
|
.order("idx");
|
|
41
|
-
if (options && options.length > 0) {
|
|
58
|
+
if (!error && options && options.length > 0) {
|
|
42
59
|
input = [row.content, ...options.map((o) => o.label)].join("\n");
|
|
43
60
|
}
|
|
44
61
|
}
|
|
@@ -54,9 +71,17 @@ Deno.serve(async (req) => {
|
|
|
54
71
|
await supabase
|
|
55
72
|
.from(table)
|
|
56
73
|
.update({ status: "hidden", moderation_reason: verdict.reason, moderated_at: now })
|
|
57
|
-
.eq("id", id)
|
|
74
|
+
.eq("id", id)
|
|
75
|
+
.eq("status", "pending");
|
|
58
76
|
return json({ status: "rejected", reason: verdict.reason });
|
|
59
77
|
}
|
|
60
|
-
|
|
78
|
+
const { data: updated } = await supabase
|
|
79
|
+
.from(table)
|
|
80
|
+
.update({ status: "visible", moderated_at: now })
|
|
81
|
+
.eq("id", id)
|
|
82
|
+
.eq("status", "pending")
|
|
83
|
+
.select("id");
|
|
84
|
+
// Nothing updated: the author deleted (or a sweep moderated) it meanwhile.
|
|
85
|
+
if (!updated || updated.length === 0) return json({ status: "pending" });
|
|
61
86
|
return json({ status: "published" });
|
|
62
87
|
});
|
|
@@ -1,29 +1,35 @@
|
|
|
1
|
-
// Real-time comment notification: a pg_net trigger
|
|
2
|
-
//
|
|
3
|
-
// the author has blocked the commenter).
|
|
1
|
+
// Real-time comment notification: a pg_net trigger fires here when a comment
|
|
2
|
+
// becomes visible (published). We push to the post author (unless self, opted
|
|
3
|
+
// out, no token, or the author has blocked the commenter).
|
|
4
|
+
//
|
|
5
|
+
// Security: this function is reachable with the public anon key (the trigger
|
|
6
|
+
// calls it that way), so the body is UNTRUSTED. Only `record.id` is used; the
|
|
7
|
+
// comment is re-read from the database and must be visible — nothing in the
|
|
8
|
+
// push comes from the request itself.
|
|
4
9
|
|
|
5
10
|
import { adminClient } from "../_shared/client.ts";
|
|
6
11
|
import { sendExpoPush } from "../_shared/push.ts";
|
|
7
|
-
import {
|
|
12
|
+
import { pushCopy } from "../_shared/copy.ts";
|
|
8
13
|
|
|
9
14
|
const supabase = adminClient();
|
|
10
15
|
|
|
11
|
-
interface CommentRecord {
|
|
12
|
-
id: string;
|
|
13
|
-
post_id: string;
|
|
14
|
-
author_id: string;
|
|
15
|
-
content: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
16
|
Deno.serve(async (req) => {
|
|
19
|
-
const { record } = (await req.json()) as { record
|
|
17
|
+
const { record } = (await req.json().catch(() => ({}))) as { record?: { id?: string } };
|
|
18
|
+
if (!record?.id) return new Response("skip: no id", { status: 400 });
|
|
19
|
+
|
|
20
|
+
const { data: comment } = await supabase
|
|
21
|
+
.from("comments")
|
|
22
|
+
.select("id, post_id, author_id, content, status")
|
|
23
|
+
.eq("id", record.id)
|
|
24
|
+
.single();
|
|
25
|
+
if (!comment || comment.status !== "visible") return new Response("skip: not visible");
|
|
20
26
|
|
|
21
27
|
const { data: post } = await supabase
|
|
22
28
|
.from("posts")
|
|
23
29
|
.select("author_id")
|
|
24
|
-
.eq("id",
|
|
30
|
+
.eq("id", comment.post_id)
|
|
25
31
|
.single();
|
|
26
|
-
if (!post || post.author_id ===
|
|
32
|
+
if (!post || post.author_id === comment.author_id) return new Response("skip: self/none");
|
|
27
33
|
|
|
28
34
|
const { data: pref } = await supabase
|
|
29
35
|
.from("push_tokens")
|
|
@@ -36,25 +42,32 @@ Deno.serve(async (req) => {
|
|
|
36
42
|
.from("blocks")
|
|
37
43
|
.select("blocker_id")
|
|
38
44
|
.eq("blocker_id", post.author_id)
|
|
39
|
-
.eq("blocked_id",
|
|
45
|
+
.eq("blocked_id", comment.author_id)
|
|
40
46
|
.maybeSingle();
|
|
41
47
|
if (blocked) return new Response("skip: blocked");
|
|
42
48
|
|
|
49
|
+
const { data: recipient } = await supabase
|
|
50
|
+
.from("profiles")
|
|
51
|
+
.select("locale")
|
|
52
|
+
.eq("id", post.author_id)
|
|
53
|
+
.single();
|
|
54
|
+
const copy = pushCopy(recipient?.locale);
|
|
55
|
+
|
|
43
56
|
const { data: actor } = await supabase
|
|
44
57
|
.from("profiles")
|
|
45
58
|
.select("username")
|
|
46
|
-
.eq("id",
|
|
59
|
+
.eq("id", comment.author_id)
|
|
47
60
|
.single();
|
|
48
|
-
const name = actor?.username?.trim() ||
|
|
61
|
+
const name = actor?.username?.trim() || copy.fallbackName;
|
|
49
62
|
const excerpt =
|
|
50
|
-
|
|
63
|
+
comment.content.length > 140 ? `${comment.content.slice(0, 137)}...` : comment.content;
|
|
51
64
|
// Title states the event; body is the commenter's name then the comment in
|
|
52
65
|
// quotes, e.g. Marie: "So happy for you!".
|
|
53
66
|
const body = `${name}: "${excerpt}"`;
|
|
54
67
|
|
|
55
68
|
await sendExpoPush({
|
|
56
69
|
to: pref.expo_push_token,
|
|
57
|
-
title: "
|
|
70
|
+
title: copy.text("comment.title"),
|
|
58
71
|
body,
|
|
59
72
|
data: { route: "/community", kind: "community_comment" },
|
|
60
73
|
badge: 1,
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
// Coalesced like notifications. Trigger path (body has record): apply a 10-min
|
|
2
2
|
// per-post cooldown so bursts do not spam. Digest path (no record, from cron):
|
|
3
3
|
// sweep posts with unnotified likes older than the cooldown. Either way, group
|
|
4
|
-
// as "<name> liked your post" (1) or "<name> and N others liked your post"
|
|
4
|
+
// as "<name> liked your post" (1) or "<name> and N others liked your post",
|
|
5
|
+
// in the recipient's locale (profiles.locale) — see _shared/copy.ts for the
|
|
6
|
+
// built-in copy and the COMMUNITY_PUSH_COPY override.
|
|
7
|
+
//
|
|
8
|
+
// Security: the trigger path only takes `record.post_id` from the (anon-key
|
|
9
|
+
// reachable, hence untrusted) body and re-derives everything from the likes
|
|
10
|
+
// table, so a forged body can at most flush a real post's pending like
|
|
11
|
+
// notifications early.
|
|
5
12
|
|
|
6
13
|
import { adminClient } from "../_shared/client.ts";
|
|
7
14
|
import { sendExpoPush } from "../_shared/push.ts";
|
|
8
|
-
import {
|
|
15
|
+
import { pushCopy } from "../_shared/copy.ts";
|
|
9
16
|
|
|
10
17
|
const supabase = adminClient();
|
|
11
18
|
const COOLDOWN_MS = 10 * 60 * 1000;
|
|
@@ -52,16 +59,23 @@ async function flushPost(postId: string) {
|
|
|
52
59
|
);
|
|
53
60
|
if (contributors.length === 0) return;
|
|
54
61
|
|
|
62
|
+
const { data: recipient } = await supabase
|
|
63
|
+
.from("profiles")
|
|
64
|
+
.select("locale")
|
|
65
|
+
.eq("id", post.author_id)
|
|
66
|
+
.single();
|
|
67
|
+
const copy = pushCopy(recipient?.locale);
|
|
68
|
+
|
|
55
69
|
const { data: actor } = await supabase
|
|
56
70
|
.from("profiles")
|
|
57
71
|
.select("username")
|
|
58
72
|
.eq("id", contributors[0].user_id)
|
|
59
73
|
.single();
|
|
60
|
-
const name = actor?.username?.trim() ||
|
|
74
|
+
const name = actor?.username?.trim() || copy.fallbackName;
|
|
61
75
|
const title =
|
|
62
76
|
contributors.length === 1
|
|
63
|
-
?
|
|
64
|
-
:
|
|
77
|
+
? copy.text("like.one", { name })
|
|
78
|
+
: copy.text("like.many", { name, count: contributors.length - 1 });
|
|
65
79
|
|
|
66
80
|
await sendExpoPush({
|
|
67
81
|
to: pref.expo_push_token,
|
|
@@ -5,90 +5,22 @@
|
|
|
5
5
|
// sweep posts with unnotified reactions older than the cooldown. Never
|
|
6
6
|
// includes any reaction content — the reaction table carries no text.
|
|
7
7
|
//
|
|
8
|
-
// Push copy: the recipient's profiles.locale selects
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// "and N others" phrasing; there is no secret for it, only the built-in
|
|
14
|
-
// translations below.
|
|
8
|
+
// Push copy: the recipient's profiles.locale selects the built-in neutral
|
|
9
|
+
// copy ("{name} is thinking of you" / "… and N others …") — an app with its
|
|
10
|
+
// own reaction semantics overrides it per locale with the COMMUNITY_PUSH_COPY
|
|
11
|
+
// secret (see _shared/copy.ts; the older COMMUNITY_REACTION_PUSH_TEXT single
|
|
12
|
+
// string still works for the single-reactor case).
|
|
15
13
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// for a reference migration adding it.
|
|
14
|
+
// Security: same as notify-like — only `record.post_id` is read from the
|
|
15
|
+
// untrusted body; everything else comes from post_reactions.
|
|
19
16
|
|
|
20
17
|
import { adminClient } from "../_shared/client.ts";
|
|
21
18
|
import { sendExpoPush } from "../_shared/push.ts";
|
|
22
|
-
import {
|
|
19
|
+
import { pushCopy } from "../_shared/copy.ts";
|
|
23
20
|
|
|
24
21
|
const supabase = adminClient();
|
|
25
22
|
const COOLDOWN_MS = 10 * 60 * 1000;
|
|
26
23
|
|
|
27
|
-
/**
|
|
28
|
-
* CLDR plural form for a count: 1 -> `one`, 2-4 -> `few`, 5+ -> `many` (the
|
|
29
|
-
* Polish rule, minus the fractional `other` case a push count never hits).
|
|
30
|
-
* Every other locale below needs only a singular/plural pair.
|
|
31
|
-
*/
|
|
32
|
-
function plForm(n: number, one: string, few: string, many: string) {
|
|
33
|
-
if (n === 1) return one;
|
|
34
|
-
const lastTwo = n % 100;
|
|
35
|
-
const inTeens = lastTwo >= 10 && lastTwo < 20;
|
|
36
|
-
return !inTeens && n % 10 >= 2 && n % 10 <= 4 ? few : many;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Built-in neutral push copy per recipient locale (profiles.locale). Keys
|
|
40
|
-
// mirror SupportedLanguage in host apps; unknown locales fall back to en.
|
|
41
|
-
// `one` is only used when COMMUNITY_REACTION_PUSH_TEXT is unset (its default
|
|
42
|
-
// already matches the `en` entry below).
|
|
43
|
-
const COPY = {
|
|
44
|
-
en: {
|
|
45
|
-
fallbackName: "Someone",
|
|
46
|
-
one: (name: string) => `${name} is thinking of you`,
|
|
47
|
-
many: (name: string, extra: number) =>
|
|
48
|
-
`${name} and ${extra} ${extra === 1 ? "other" : "others"} are thinking of you`,
|
|
49
|
-
},
|
|
50
|
-
"pt-PT": {
|
|
51
|
-
fallbackName: "Alguém",
|
|
52
|
-
one: (name: string) => `${name} está a pensar em ti`,
|
|
53
|
-
many: (name: string, extra: number) =>
|
|
54
|
-
`${name} e mais ${extra} ${extra === 1 ? "pessoa está" : "pessoas estão"} a pensar em ti`,
|
|
55
|
-
},
|
|
56
|
-
"pt-BR": {
|
|
57
|
-
fallbackName: "Alguém",
|
|
58
|
-
one: (name: string) => `${name} está pensando em você`,
|
|
59
|
-
many: (name: string, extra: number) =>
|
|
60
|
-
`${name} e mais ${extra} ${extra === 1 ? "pessoa está" : "pessoas estão"} pensando em você`,
|
|
61
|
-
},
|
|
62
|
-
"es-ES": {
|
|
63
|
-
fallbackName: "Alguien",
|
|
64
|
-
one: (name: string) => `${name} está pensando en ti`,
|
|
65
|
-
many: (name: string, extra: number) =>
|
|
66
|
-
`${name} y ${extra} ${extra === 1 ? "persona más está" : "personas más están"} pensando en ti`,
|
|
67
|
-
},
|
|
68
|
-
"es-419": {
|
|
69
|
-
fallbackName: "Alguien",
|
|
70
|
-
one: (name: string) => `${name} está pensando en ti`,
|
|
71
|
-
many: (name: string, extra: number) =>
|
|
72
|
-
`${name} y ${extra} ${extra === 1 ? "persona más está" : "personas más están"} pensando en ti`,
|
|
73
|
-
},
|
|
74
|
-
it: {
|
|
75
|
-
fallbackName: "Qualcuno",
|
|
76
|
-
one: (name: string) => `${name} sta pensando a te`,
|
|
77
|
-
many: (name: string, extra: number) =>
|
|
78
|
-
`${name} e altre ${extra} ${extra === 1 ? "persona sta" : "persone stanno"} pensando a te`,
|
|
79
|
-
},
|
|
80
|
-
pl: {
|
|
81
|
-
fallbackName: "Ktoś",
|
|
82
|
-
one: (name: string) => `${name} myśli o tobie`,
|
|
83
|
-
many: (name: string, extra: number) =>
|
|
84
|
-
`${name} i ${plForm(extra, `${extra} inna osoba myśli`, `${extra} inne osoby myślą`, `${extra} innych osób myśli`)} o tobie`,
|
|
85
|
-
},
|
|
86
|
-
} as const;
|
|
87
|
-
|
|
88
|
-
function copyFor(locale: string | null | undefined) {
|
|
89
|
-
return COPY[(locale ?? "en") as keyof typeof COPY] ?? COPY.en;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
24
|
async function flushPost(postId: string) {
|
|
93
25
|
const { data: post } = await supabase.from("posts").select("author_id").eq("id", postId).single();
|
|
94
26
|
if (!post) return;
|
|
@@ -98,7 +30,7 @@ async function flushPost(postId: string) {
|
|
|
98
30
|
.select("locale")
|
|
99
31
|
.eq("id", post.author_id)
|
|
100
32
|
.single();
|
|
101
|
-
const copy =
|
|
33
|
+
const copy = pushCopy(recipient?.locale);
|
|
102
34
|
|
|
103
35
|
const { data: pref } = await supabase
|
|
104
36
|
.from("push_tokens")
|
|
@@ -138,15 +70,11 @@ async function flushPost(postId: string) {
|
|
|
138
70
|
.select("username")
|
|
139
71
|
.eq("id", contributors[0].user_id)
|
|
140
72
|
.single();
|
|
141
|
-
|
|
142
|
-
// notify-like/notify-comment and meant to match the client's
|
|
143
|
-
// anonymousAuthorFallback — see docs/backend-runbook.md) takes precedence
|
|
144
|
-
// over the locale table's own fallbackName default.
|
|
145
|
-
const name = actor?.username?.trim() || FALLBACK_ACTOR_NAME || copy.fallbackName;
|
|
73
|
+
const name = actor?.username?.trim() || copy.fallbackName;
|
|
146
74
|
const title =
|
|
147
75
|
contributors.length === 1
|
|
148
|
-
?
|
|
149
|
-
: copy.many
|
|
76
|
+
? copy.text("reaction.one", { name })
|
|
77
|
+
: copy.text("reaction.many", { name, count: contributors.length - 1 });
|
|
150
78
|
|
|
151
79
|
await sendExpoPush({
|
|
152
80
|
to: pref.expo_push_token,
|
|
@@ -2,35 +2,41 @@
|
|
|
2
2
|
// record here; we enrich it with the reported account's identity and its
|
|
3
3
|
// cumulative report count, then alert Slack (no-op if SLACK_WEBHOOK_URL is
|
|
4
4
|
// unset — see _shared/slack.ts). Bans stay manual (dashboard).
|
|
5
|
+
//
|
|
6
|
+
// Security: reachable with the anon key, so only `record.id` is trusted; the
|
|
7
|
+
// report row is re-read from the database (a forged body can't inject text
|
|
8
|
+
// into the ops channel or probe another user's report count).
|
|
5
9
|
|
|
6
10
|
import { adminClient } from "../_shared/client.ts";
|
|
7
11
|
import { postToSlack } from "../_shared/slack.ts";
|
|
8
12
|
|
|
9
13
|
const supabase = adminClient();
|
|
10
14
|
|
|
11
|
-
interface ReportRecord {
|
|
12
|
-
reported_user_id: string;
|
|
13
|
-
reason: string;
|
|
14
|
-
details: string | null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
15
|
Deno.serve(async (req) => {
|
|
18
|
-
const { record } = (await req.json()) as { record
|
|
16
|
+
const { record } = (await req.json().catch(() => ({}))) as { record?: { id?: string } };
|
|
17
|
+
if (!record?.id) return new Response("skip: no id", { status: 400 });
|
|
18
|
+
|
|
19
|
+
const { data: report } = await supabase
|
|
20
|
+
.from("reports")
|
|
21
|
+
.select("id, reported_user_id, reason, details")
|
|
22
|
+
.eq("id", record.id)
|
|
23
|
+
.single();
|
|
24
|
+
if (!report) return new Response("skip: unknown report");
|
|
19
25
|
|
|
20
26
|
const { data: reported } = await supabase
|
|
21
27
|
.from("profiles")
|
|
22
28
|
.select("username, amplitude_id, revenuecat_id")
|
|
23
|
-
.eq("id",
|
|
29
|
+
.eq("id", report.reported_user_id)
|
|
24
30
|
.single();
|
|
25
31
|
const { count } = await supabase
|
|
26
32
|
.from("reports")
|
|
27
33
|
.select("*", { count: "exact", head: true })
|
|
28
|
-
.eq("reported_user_id",
|
|
34
|
+
.eq("reported_user_id", report.reported_user_id);
|
|
29
35
|
|
|
30
36
|
await postToSlack({
|
|
31
37
|
text:
|
|
32
|
-
`:rotating_light: Report (*${
|
|
33
|
-
`${
|
|
38
|
+
`:rotating_light: Report (*${report.reason}*) against *${reported?.username ?? report.reported_user_id}*, ${count ?? "?"} report(s) total\n` +
|
|
39
|
+
`${report.details ? `> ${report.details}\n` : ""}` +
|
|
34
40
|
`amplitude: \`${reported?.amplitude_id ?? "?"}\` | revenuecat: \`${reported?.revenuecat_id ?? "?"}\``,
|
|
35
41
|
});
|
|
36
42
|
return new Response("ok");
|