@rocapine/community 0.1.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/LICENSE +21 -0
- package/README.md +118 -0
- package/lib/commands/adopt.d.ts +38 -0
- package/lib/commands/adopt.js +105 -0
- package/lib/commands/init.d.ts +29 -0
- package/lib/commands/init.js +152 -0
- package/lib/commands/upgrade.d.ts +33 -0
- package/lib/commands/upgrade.js +280 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +123 -0
- package/lib/install-shared.d.ts +72 -0
- package/lib/install-shared.js +256 -0
- package/lib/manifest.d.ts +53 -0
- package/lib/manifest.js +74 -0
- package/lib/substitute.d.ts +15 -0
- package/lib/substitute.js +25 -0
- package/package.json +32 -0
- package/templates/functions/_shared/client.ts +20 -0
- package/templates/functions/_shared/config.ts +23 -0
- package/templates/functions/_shared/moderation.ts +102 -0
- package/templates/functions/_shared/push.ts +33 -0
- package/templates/functions/_shared/slack.ts +12 -0
- package/templates/functions/broadcast-post/index.ts +73 -0
- package/templates/functions/daily-moderation/index.ts +175 -0
- package/templates/functions/moderate-one/index.ts +62 -0
- package/templates/functions/notify-comment/index.ts +63 -0
- package/templates/functions/notify-like/index.ts +103 -0
- package/templates/functions/notify-reaction/index.ts +186 -0
- package/templates/functions/report-to-slack/index.ts +37 -0
- package/templates/functions/update-profile/index.ts +131 -0
- package/templates/migrations/core/001_tables.sql +177 -0
- package/templates/migrations/core/002_rls.sql +154 -0
- package/templates/migrations/core/003_moderation.sql +68 -0
- package/templates/migrations/core/004_dashboard.sql +102 -0
- package/templates/migrations/core/005_avatars.sql +19 -0
- package/templates/migrations/core/006_meta.sql +11 -0
- package/templates/migrations/inbox/001_inbox.sql +237 -0
- package/templates/migrations/polls/001_polls.sql +120 -0
- package/templates/migrations/push/001_push.sql +42 -0
- package/templates/migrations/push/002_triggers.sql +89 -0
- package/templates/migrations/reaction/001_reactions.sql +139 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Broadcast push for an official dashboard post: Rocactopus invokes this with
|
|
2
|
+
// the service-role key after composing a post with "Send push notification"
|
|
3
|
+
// checked. Sends an Expo push to every registered token (batches of 100).
|
|
4
|
+
// Guarded by a service-role check — the public anon key passes the platform's
|
|
5
|
+
// verify_jwt but must NOT be able to trigger a broadcast.
|
|
6
|
+
|
|
7
|
+
import { adminClient } from "../_shared/client.ts";
|
|
8
|
+
import { sendExpoPushBatch } from "../_shared/push.ts";
|
|
9
|
+
import { BROADCAST_FALLBACK_TITLE } from "../_shared/config.ts";
|
|
10
|
+
|
|
11
|
+
const supabase = adminClient();
|
|
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
|
+
Deno.serve(async (req) => {
|
|
32
|
+
if (!isServiceCaller(req)) {
|
|
33
|
+
return new Response("unauthorized", { status: 401 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { post_id } = (await req.json()) as { post_id?: string };
|
|
37
|
+
if (!post_id) return new Response("missing post_id", { status: 400 });
|
|
38
|
+
|
|
39
|
+
const { data: post } = await supabase
|
|
40
|
+
.from("posts")
|
|
41
|
+
.select("id, author_id, content, status")
|
|
42
|
+
.eq("id", post_id)
|
|
43
|
+
.single();
|
|
44
|
+
if (!post || post.status !== "visible") return new Response("skip: post not visible");
|
|
45
|
+
|
|
46
|
+
const { data: author } = await supabase
|
|
47
|
+
.from("profiles")
|
|
48
|
+
.select("username")
|
|
49
|
+
.eq("id", post.author_id)
|
|
50
|
+
.single();
|
|
51
|
+
const title = author?.username?.trim() || BROADCAST_FALLBACK_TITLE;
|
|
52
|
+
const excerpt = post.content.length > 140 ? `${post.content.slice(0, 137)}...` : post.content;
|
|
53
|
+
|
|
54
|
+
const { data: rows } = await supabase
|
|
55
|
+
.from("push_tokens")
|
|
56
|
+
.select("expo_push_token")
|
|
57
|
+
.not("expo_push_token", "is", null);
|
|
58
|
+
const tokens = (rows ?? []).map((r) => r.expo_push_token as string).filter(Boolean);
|
|
59
|
+
|
|
60
|
+
await sendExpoPushBatch(
|
|
61
|
+
tokens.map((to) => ({
|
|
62
|
+
to,
|
|
63
|
+
title,
|
|
64
|
+
body: excerpt,
|
|
65
|
+
data: { route: "/community", kind: "community_official_post" },
|
|
66
|
+
badge: 1,
|
|
67
|
+
})),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return new Response(JSON.stringify({ sent: tokens.length }), {
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Daily moderation batch + backstop. Runs every not-yet-moderated post/comment
|
|
2
|
+
// through OpenAI's moderation endpoint (free): flagged -> soft-hide, clean
|
|
3
|
+
// 'pending' -> promote to 'visible' (catches items whose synchronous
|
|
4
|
+
// moderate-one call failed), clean 'visible' -> just stamp. Posts a Slack
|
|
5
|
+
// summary (no-op if SLACK_WEBHOOK_URL is unset — see _shared/slack.ts). Never
|
|
6
|
+
// deletes rows. App criticism is not a category (stays visible).
|
|
7
|
+
|
|
8
|
+
import { adminClient } from "../_shared/client.ts";
|
|
9
|
+
import {
|
|
10
|
+
assertModerationConfigured,
|
|
11
|
+
flaggedCategories,
|
|
12
|
+
type ModerationResult,
|
|
13
|
+
} from "../_shared/moderation.ts";
|
|
14
|
+
import { postToSlack } from "../_shared/slack.ts";
|
|
15
|
+
|
|
16
|
+
assertModerationConfigured();
|
|
17
|
+
|
|
18
|
+
const supabase = adminClient();
|
|
19
|
+
|
|
20
|
+
interface Item {
|
|
21
|
+
id: string;
|
|
22
|
+
content: string;
|
|
23
|
+
author_id: string;
|
|
24
|
+
status: string;
|
|
25
|
+
profiles: {
|
|
26
|
+
username: string | null;
|
|
27
|
+
amplitude_id: string | null;
|
|
28
|
+
revenuecat_id: string | null;
|
|
29
|
+
} | null;
|
|
30
|
+
table: "posts" | "comments";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Without generated DB types, supabase-js can't tell a `profiles!fk(...)`
|
|
34
|
+
// embed is to-one, so its inferred type is an array; normalize it to a
|
|
35
|
+
// single row (or null) to match Item.profiles above.
|
|
36
|
+
function toOne<T>(v: T | T[] | null | undefined): T | null {
|
|
37
|
+
return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Deno.serve(async () => {
|
|
41
|
+
const { data: posts, error: postsError } = await supabase
|
|
42
|
+
.from("posts")
|
|
43
|
+
.select(
|
|
44
|
+
"id, content, author_id, status, poll_options(idx, label), profiles!posts_author_id_fkey(username, amplitude_id, revenuecat_id)",
|
|
45
|
+
)
|
|
46
|
+
.is("moderated_at", null)
|
|
47
|
+
.in("status", ["pending", "visible"]);
|
|
48
|
+
const { data: comments, error: commentsError } = await supabase
|
|
49
|
+
.from("comments")
|
|
50
|
+
.select("id, content, author_id, status, profiles(username, amplitude_id, revenuecat_id)")
|
|
51
|
+
.is("moderated_at", null)
|
|
52
|
+
.in("status", ["pending", "visible"]);
|
|
53
|
+
if (postsError || commentsError) {
|
|
54
|
+
return new Response(JSON.stringify({ error: (postsError ?? commentsError)!.message }), {
|
|
55
|
+
status: 500,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Poll option labels are user content: fold them into the post's moderated
|
|
60
|
+
// text (same treatment as the synchronous moderate-one function).
|
|
61
|
+
const items: Item[] = [
|
|
62
|
+
...(posts ?? []).map((p) => ({
|
|
63
|
+
...p,
|
|
64
|
+
profiles: toOne(p.profiles),
|
|
65
|
+
content: [
|
|
66
|
+
p.content,
|
|
67
|
+
...(p.poll_options ?? [])
|
|
68
|
+
.sort((a: { idx: number }, b: { idx: number }) => a.idx - b.idx)
|
|
69
|
+
.map((o: { label: string }) => o.label),
|
|
70
|
+
].join("\n"),
|
|
71
|
+
table: "posts" as const,
|
|
72
|
+
})),
|
|
73
|
+
...(comments ?? []).map((c) => ({
|
|
74
|
+
...c,
|
|
75
|
+
profiles: toOne(c.profiles),
|
|
76
|
+
table: "comments" as const,
|
|
77
|
+
})),
|
|
78
|
+
];
|
|
79
|
+
if (items.length === 0) {
|
|
80
|
+
return new Response(JSON.stringify({ checked: 0, hidden: 0, failed_batches: 0 }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Moderate in batches of 100 (API limit). Only items whose batch succeeded
|
|
84
|
+
// get stamped moderated_at, so a transient failure is retried tomorrow.
|
|
85
|
+
const flagged: { item: Item; categories: string[] }[] = [];
|
|
86
|
+
const processed: Item[] = [];
|
|
87
|
+
let failedBatches = 0;
|
|
88
|
+
for (let i = 0; i < items.length; i += 100) {
|
|
89
|
+
const batch = items.slice(i, i + 100);
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch("https://api.openai.com/v1/moderations", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
model: "omni-moderation-latest",
|
|
99
|
+
input: batch.map((it) => it.content),
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
failedBatches++;
|
|
104
|
+
continue; // skip stamping; retried next run
|
|
105
|
+
}
|
|
106
|
+
const { results } = await res.json();
|
|
107
|
+
results.forEach((r: ModerationResult, idx: number) => {
|
|
108
|
+
const cats = flaggedCategories(r);
|
|
109
|
+
if (cats.length > 0) flagged.push({ item: batch[idx], categories: cats });
|
|
110
|
+
});
|
|
111
|
+
processed.push(...batch);
|
|
112
|
+
} catch {
|
|
113
|
+
failedBatches++; // network error; skip stamping, retried next run
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const totalItems = items.length;
|
|
117
|
+
|
|
118
|
+
const flaggedIds = new Set(flagged.map((f) => f.item.id));
|
|
119
|
+
for (const { item, categories } of flagged) {
|
|
120
|
+
await supabase
|
|
121
|
+
.from(item.table)
|
|
122
|
+
.update({ status: "hidden", moderation_reason: categories.join(",") })
|
|
123
|
+
.eq("id", item.id);
|
|
124
|
+
}
|
|
125
|
+
// Backstop: promote clean 'pending' items to 'visible' (their synchronous
|
|
126
|
+
// moderate-one call must have failed).
|
|
127
|
+
const promote = processed.filter((i) => i.status === "pending" && !flaggedIds.has(i.id));
|
|
128
|
+
const promotePostIds = promote.filter((i) => i.table === "posts").map((i) => i.id);
|
|
129
|
+
const promoteCommentIds = promote.filter((i) => i.table === "comments").map((i) => i.id);
|
|
130
|
+
if (promotePostIds.length > 0) {
|
|
131
|
+
await supabase.from("posts").update({ status: "visible" }).in("id", promotePostIds);
|
|
132
|
+
}
|
|
133
|
+
if (promoteCommentIds.length > 0) {
|
|
134
|
+
await supabase.from("comments").update({ status: "visible" }).in("id", promoteCommentIds);
|
|
135
|
+
}
|
|
136
|
+
const now = new Date().toISOString();
|
|
137
|
+
const processedPostIds = processed.filter((i) => i.table === "posts").map((i) => i.id);
|
|
138
|
+
const processedCommentIds = processed.filter((i) => i.table === "comments").map((i) => i.id);
|
|
139
|
+
if (processedPostIds.length > 0) {
|
|
140
|
+
await supabase.from("posts").update({ moderated_at: now }).in("id", processedPostIds);
|
|
141
|
+
}
|
|
142
|
+
if (processedCommentIds.length > 0) {
|
|
143
|
+
await supabase.from("comments").update({ moderated_at: now }).in("id", processedCommentIds);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const lines = flagged.map(({ item, categories }) => {
|
|
147
|
+
const p = item.profiles;
|
|
148
|
+
return (
|
|
149
|
+
`* [${item.table}] *${p?.username ?? item.author_id}*, _${categories.join(", ")}_\n` +
|
|
150
|
+
` > ${item.content.slice(0, 200)}\n` +
|
|
151
|
+
` amplitude: \`${p?.amplitude_id ?? "?"}\` | revenuecat: \`${p?.revenuecat_id ?? "?"}\``
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
let summaryText: string;
|
|
155
|
+
if (failedBatches > 0) {
|
|
156
|
+
const uncheckedCount = totalItems - processed.length;
|
|
157
|
+
summaryText =
|
|
158
|
+
`Daily moderation: WARNING, ${failedBatches} batch(es) failed to reach the moderation API ` +
|
|
159
|
+
`(${uncheckedCount} of ${totalItems} items were not checked and will be retried). ` +
|
|
160
|
+
`${flagged.length} items hidden.`;
|
|
161
|
+
} else if (flagged.length === 0) {
|
|
162
|
+
summaryText = `Daily moderation: ${processed.length} items checked, nothing to report.`;
|
|
163
|
+
} else {
|
|
164
|
+
summaryText = `Daily moderation: ${flagged.length}/${processed.length} items hidden\n\n${lines.join("\n")}`;
|
|
165
|
+
}
|
|
166
|
+
await postToSlack({ text: summaryText });
|
|
167
|
+
|
|
168
|
+
return new Response(
|
|
169
|
+
JSON.stringify({
|
|
170
|
+
checked: processed.length,
|
|
171
|
+
hidden: flagged.length,
|
|
172
|
+
failed_batches: failedBatches,
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Synchronous single-item moderation. The client inserts a post/comment as
|
|
2
|
+
// 'pending' (invisible to others) then calls this to get a verdict: clean ->
|
|
3
|
+
// promoted to 'visible' (published); flagged -> 'hidden' (rejected) with the
|
|
4
|
+
// reason. On an OpenAI error we fail closed (leave it pending); the daily
|
|
5
|
+
// backstop re-checks stale pending items so nothing is silently lost.
|
|
6
|
+
|
|
7
|
+
import { adminClient, json } from "../_shared/client.ts";
|
|
8
|
+
import { assertModerationConfigured, moderateInput } from "../_shared/moderation.ts";
|
|
9
|
+
|
|
10
|
+
assertModerationConfigured();
|
|
11
|
+
|
|
12
|
+
const supabase = adminClient();
|
|
13
|
+
|
|
14
|
+
Deno.serve(async (req) => {
|
|
15
|
+
const { kind, id } = (await req.json()) as { kind: "post" | "comment"; id: string };
|
|
16
|
+
if ((kind !== "post" && kind !== "comment") || !id) {
|
|
17
|
+
return json({ status: "error", error: "bad request" }, 400);
|
|
18
|
+
}
|
|
19
|
+
const table = kind === "post" ? "posts" : "comments";
|
|
20
|
+
|
|
21
|
+
const { data: row } = await supabase
|
|
22
|
+
.from(table)
|
|
23
|
+
.select("id, content, status")
|
|
24
|
+
.eq("id", id)
|
|
25
|
+
.single();
|
|
26
|
+
if (!row) return json({ status: "error", error: "not found" });
|
|
27
|
+
// Idempotent: only pending rows are moderated here.
|
|
28
|
+
if (row.status !== "pending") {
|
|
29
|
+
return json({ status: row.status === "visible" ? "published" : row.status });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A poll post's option labels are user content too: moderate them together
|
|
33
|
+
// with the question, one verdict for the whole post.
|
|
34
|
+
let input = row.content;
|
|
35
|
+
if (kind === "post") {
|
|
36
|
+
const { data: options } = await supabase
|
|
37
|
+
.from("poll_options")
|
|
38
|
+
.select("label")
|
|
39
|
+
.eq("post_id", id)
|
|
40
|
+
.order("idx");
|
|
41
|
+
if (options && options.length > 0) {
|
|
42
|
+
input = [row.content, ...options.map((o) => o.label)].join("\n");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const verdict = await moderateInput(input);
|
|
47
|
+
if (!verdict) {
|
|
48
|
+
// Fail closed: leave pending, let the client retry / the daily sweep catch it.
|
|
49
|
+
return json({ status: "pending", error: "moderation unavailable" });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const now = new Date().toISOString();
|
|
53
|
+
if (verdict.flagged) {
|
|
54
|
+
await supabase
|
|
55
|
+
.from(table)
|
|
56
|
+
.update({ status: "hidden", moderation_reason: verdict.reason, moderated_at: now })
|
|
57
|
+
.eq("id", id);
|
|
58
|
+
return json({ status: "rejected", reason: verdict.reason });
|
|
59
|
+
}
|
|
60
|
+
await supabase.from(table).update({ status: "visible", moderated_at: now }).eq("id", id);
|
|
61
|
+
return json({ status: "published" });
|
|
62
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Real-time comment notification: a pg_net trigger on comments posts the new
|
|
2
|
+
// row here. We push to the post author (unless self, opted out, no token, or
|
|
3
|
+
// the author has blocked the commenter).
|
|
4
|
+
|
|
5
|
+
import { adminClient } from "../_shared/client.ts";
|
|
6
|
+
import { sendExpoPush } from "../_shared/push.ts";
|
|
7
|
+
import { FALLBACK_ACTOR_NAME } from "../_shared/config.ts";
|
|
8
|
+
|
|
9
|
+
const supabase = adminClient();
|
|
10
|
+
|
|
11
|
+
interface CommentRecord {
|
|
12
|
+
id: string;
|
|
13
|
+
post_id: string;
|
|
14
|
+
author_id: string;
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Deno.serve(async (req) => {
|
|
19
|
+
const { record } = (await req.json()) as { record: CommentRecord };
|
|
20
|
+
|
|
21
|
+
const { data: post } = await supabase
|
|
22
|
+
.from("posts")
|
|
23
|
+
.select("author_id")
|
|
24
|
+
.eq("id", record.post_id)
|
|
25
|
+
.single();
|
|
26
|
+
if (!post || post.author_id === record.author_id) return new Response("skip: self/none");
|
|
27
|
+
|
|
28
|
+
const { data: pref } = await supabase
|
|
29
|
+
.from("push_tokens")
|
|
30
|
+
.select("expo_push_token, notify_comments")
|
|
31
|
+
.eq("user_id", post.author_id)
|
|
32
|
+
.single();
|
|
33
|
+
if (!pref?.expo_push_token || !pref.notify_comments) return new Response("skip: no token/pref");
|
|
34
|
+
|
|
35
|
+
const { data: blocked } = await supabase
|
|
36
|
+
.from("blocks")
|
|
37
|
+
.select("blocker_id")
|
|
38
|
+
.eq("blocker_id", post.author_id)
|
|
39
|
+
.eq("blocked_id", record.author_id)
|
|
40
|
+
.maybeSingle();
|
|
41
|
+
if (blocked) return new Response("skip: blocked");
|
|
42
|
+
|
|
43
|
+
const { data: actor } = await supabase
|
|
44
|
+
.from("profiles")
|
|
45
|
+
.select("username")
|
|
46
|
+
.eq("id", record.author_id)
|
|
47
|
+
.single();
|
|
48
|
+
const name = actor?.username?.trim() || FALLBACK_ACTOR_NAME;
|
|
49
|
+
const excerpt =
|
|
50
|
+
record.content.length > 140 ? `${record.content.slice(0, 137)}...` : record.content;
|
|
51
|
+
// Title states the event; body is the commenter's name then the comment in
|
|
52
|
+
// quotes, e.g. Marie: "So happy for you!".
|
|
53
|
+
const body = `${name}: "${excerpt}"`;
|
|
54
|
+
|
|
55
|
+
await sendExpoPush({
|
|
56
|
+
to: pref.expo_push_token,
|
|
57
|
+
title: "You received a comment",
|
|
58
|
+
body,
|
|
59
|
+
data: { route: "/community", kind: "community_comment" },
|
|
60
|
+
badge: 1,
|
|
61
|
+
});
|
|
62
|
+
return new Response("ok");
|
|
63
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Coalesced like notifications. Trigger path (body has record): apply a 10-min
|
|
2
|
+
// per-post cooldown so bursts do not spam. Digest path (no record, from cron):
|
|
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".
|
|
5
|
+
|
|
6
|
+
import { adminClient } from "../_shared/client.ts";
|
|
7
|
+
import { sendExpoPush } from "../_shared/push.ts";
|
|
8
|
+
import { FALLBACK_ACTOR_NAME } from "../_shared/config.ts";
|
|
9
|
+
|
|
10
|
+
const supabase = adminClient();
|
|
11
|
+
const COOLDOWN_MS = 10 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
/** Notify the author for a single post's currently-unnotified likes, then
|
|
14
|
+
* stamp them. Assumes the cooldown decision is already made. */
|
|
15
|
+
async function flushPost(postId: string) {
|
|
16
|
+
const { data: post } = await supabase.from("posts").select("author_id").eq("id", postId).single();
|
|
17
|
+
if (!post) return;
|
|
18
|
+
|
|
19
|
+
const { data: pref } = await supabase
|
|
20
|
+
.from("push_tokens")
|
|
21
|
+
.select("expo_push_token, notify_likes")
|
|
22
|
+
.eq("user_id", post.author_id)
|
|
23
|
+
.single();
|
|
24
|
+
|
|
25
|
+
const { data: unnotified } = await supabase
|
|
26
|
+
.from("likes")
|
|
27
|
+
.select("user_id, created_at")
|
|
28
|
+
.eq("post_id", postId)
|
|
29
|
+
.is("notified_at", null)
|
|
30
|
+
.order("created_at", { ascending: false });
|
|
31
|
+
if (!unnotified || unnotified.length === 0) return;
|
|
32
|
+
|
|
33
|
+
// Stamp ALL unnotified likes for this post (clears the queue), regardless of
|
|
34
|
+
// whether they contribute to the message.
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
await supabase
|
|
37
|
+
.from("likes")
|
|
38
|
+
.update({ notified_at: now })
|
|
39
|
+
.eq("post_id", postId)
|
|
40
|
+
.is("notified_at", null);
|
|
41
|
+
|
|
42
|
+
if (!pref?.expo_push_token || !pref.notify_likes) return;
|
|
43
|
+
|
|
44
|
+
// Contributors = likers who are not the author and not blocked by the author.
|
|
45
|
+
const { data: blocks } = await supabase
|
|
46
|
+
.from("blocks")
|
|
47
|
+
.select("blocked_id")
|
|
48
|
+
.eq("blocker_id", post.author_id);
|
|
49
|
+
const blockedSet = new Set((blocks ?? []).map((b) => b.blocked_id as string));
|
|
50
|
+
const contributors = unnotified.filter(
|
|
51
|
+
(l) => l.user_id !== post.author_id && !blockedSet.has(l.user_id as string),
|
|
52
|
+
);
|
|
53
|
+
if (contributors.length === 0) return;
|
|
54
|
+
|
|
55
|
+
const { data: actor } = await supabase
|
|
56
|
+
.from("profiles")
|
|
57
|
+
.select("username")
|
|
58
|
+
.eq("id", contributors[0].user_id)
|
|
59
|
+
.single();
|
|
60
|
+
const name = actor?.username?.trim() || FALLBACK_ACTOR_NAME;
|
|
61
|
+
const title =
|
|
62
|
+
contributors.length === 1
|
|
63
|
+
? `${name} liked your post`
|
|
64
|
+
: `${name} and ${contributors.length - 1} others liked your post`;
|
|
65
|
+
|
|
66
|
+
await sendExpoPush({
|
|
67
|
+
to: pref.expo_push_token,
|
|
68
|
+
title,
|
|
69
|
+
data: { route: "/community", kind: "community_like" },
|
|
70
|
+
badge: 1,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
Deno.serve(async (req) => {
|
|
75
|
+
const payload = (await req.json().catch(() => ({}))) as {
|
|
76
|
+
record?: { post_id: string };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (payload.record?.post_id) {
|
|
80
|
+
const postId = payload.record.post_id;
|
|
81
|
+
// Cooldown: if any like on this post was notified within COOLDOWN_MS, hold.
|
|
82
|
+
const since = new Date(Date.now() - COOLDOWN_MS).toISOString();
|
|
83
|
+
const { count } = await supabase
|
|
84
|
+
.from("likes")
|
|
85
|
+
.select("*", { count: "exact", head: true })
|
|
86
|
+
.eq("post_id", postId)
|
|
87
|
+
.gt("notified_at", since);
|
|
88
|
+
if ((count ?? 0) > 0) return new Response("hold: cooldown");
|
|
89
|
+
await flushPost(postId);
|
|
90
|
+
return new Response("ok");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Digest (cron): sweep posts whose oldest unnotified like is past the cooldown.
|
|
94
|
+
const cutoff = new Date(Date.now() - COOLDOWN_MS).toISOString();
|
|
95
|
+
const { data: stale } = await supabase
|
|
96
|
+
.from("likes")
|
|
97
|
+
.select("post_id")
|
|
98
|
+
.is("notified_at", null)
|
|
99
|
+
.lt("created_at", cutoff);
|
|
100
|
+
const postIds = [...new Set((stale ?? []).map((r) => r.post_id as string))];
|
|
101
|
+
for (const pid of postIds) await flushPost(pid);
|
|
102
|
+
return new Response(JSON.stringify({ swept: postIds.length }));
|
|
103
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Coalesced reaction notifications (generic "I stand with you" reaction — see
|
|
2
|
+
// supabase/migrations/reaction/001_reactions.sql; the reaction's *meaning* is
|
|
3
|
+
// 100% client-side, this function only sends the push). Trigger path (body
|
|
4
|
+
// has record): 10-min per-post cooldown. Digest path (no record, from cron):
|
|
5
|
+
// sweep posts with unnotified reactions older than the cooldown. Never
|
|
6
|
+
// includes any reaction content — the reaction table carries no text.
|
|
7
|
+
//
|
|
8
|
+
// Push copy: the recipient's profiles.locale selects a built-in neutral
|
|
9
|
+
// template for the common single-reactor case (COMMUNITY_REACTION_PUSH_TEXT
|
|
10
|
+
// overrides that template for every locale, `{name}` substituted with the
|
|
11
|
+
// reactor's display name — an app with its own reaction semantics sets its
|
|
12
|
+
// own copy there). The multi-reactor case always uses the locale's own
|
|
13
|
+
// "and N others" phrasing; there is no secret for it, only the built-in
|
|
14
|
+
// translations below.
|
|
15
|
+
//
|
|
16
|
+
// NOTE: profiles.locale is not part of the SDK's core schema yet (task 16) —
|
|
17
|
+
// it must exist before this function can run. See the internal port notes
|
|
18
|
+
// for a reference migration adding it.
|
|
19
|
+
|
|
20
|
+
import { adminClient } from "../_shared/client.ts";
|
|
21
|
+
import { sendExpoPush } from "../_shared/push.ts";
|
|
22
|
+
import { FALLBACK_ACTOR_NAME, REACTION_PUSH_TEXT } from "../_shared/config.ts";
|
|
23
|
+
|
|
24
|
+
const supabase = adminClient();
|
|
25
|
+
const COOLDOWN_MS = 10 * 60 * 1000;
|
|
26
|
+
|
|
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
|
+
async function flushPost(postId: string) {
|
|
93
|
+
const { data: post } = await supabase.from("posts").select("author_id").eq("id", postId).single();
|
|
94
|
+
if (!post) return;
|
|
95
|
+
|
|
96
|
+
const { data: recipient } = await supabase
|
|
97
|
+
.from("profiles")
|
|
98
|
+
.select("locale")
|
|
99
|
+
.eq("id", post.author_id)
|
|
100
|
+
.single();
|
|
101
|
+
const copy = copyFor(recipient?.locale);
|
|
102
|
+
|
|
103
|
+
const { data: pref } = await supabase
|
|
104
|
+
.from("push_tokens")
|
|
105
|
+
.select("expo_push_token, notify_reactions")
|
|
106
|
+
.eq("user_id", post.author_id)
|
|
107
|
+
.single();
|
|
108
|
+
|
|
109
|
+
const { data: unnotified } = await supabase
|
|
110
|
+
.from("post_reactions")
|
|
111
|
+
.select("user_id, created_at")
|
|
112
|
+
.eq("post_id", postId)
|
|
113
|
+
.is("notified_at", null)
|
|
114
|
+
.order("created_at", { ascending: false });
|
|
115
|
+
if (!unnotified || unnotified.length === 0) return;
|
|
116
|
+
|
|
117
|
+
const now = new Date().toISOString();
|
|
118
|
+
await supabase
|
|
119
|
+
.from("post_reactions")
|
|
120
|
+
.update({ notified_at: now })
|
|
121
|
+
.eq("post_id", postId)
|
|
122
|
+
.is("notified_at", null);
|
|
123
|
+
|
|
124
|
+
if (!pref?.expo_push_token || !pref.notify_reactions) return;
|
|
125
|
+
|
|
126
|
+
const { data: blocks } = await supabase
|
|
127
|
+
.from("blocks")
|
|
128
|
+
.select("blocked_id")
|
|
129
|
+
.eq("blocker_id", post.author_id);
|
|
130
|
+
const blockedSet = new Set((blocks ?? []).map((b) => b.blocked_id as string));
|
|
131
|
+
const contributors = unnotified.filter(
|
|
132
|
+
(l) => l.user_id !== post.author_id && !blockedSet.has(l.user_id as string),
|
|
133
|
+
);
|
|
134
|
+
if (contributors.length === 0) return;
|
|
135
|
+
|
|
136
|
+
const { data: actor } = await supabase
|
|
137
|
+
.from("profiles")
|
|
138
|
+
.select("username")
|
|
139
|
+
.eq("id", contributors[0].user_id)
|
|
140
|
+
.single();
|
|
141
|
+
// COMMUNITY_FALLBACK_NAME (the deployer-set env var, shared with
|
|
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;
|
|
146
|
+
const title =
|
|
147
|
+
contributors.length === 1
|
|
148
|
+
? REACTION_PUSH_TEXT.replace("{name}", name)
|
|
149
|
+
: copy.many(name, contributors.length - 1);
|
|
150
|
+
|
|
151
|
+
await sendExpoPush({
|
|
152
|
+
to: pref.expo_push_token,
|
|
153
|
+
title,
|
|
154
|
+
data: { route: "/community", kind: "community_reaction" },
|
|
155
|
+
badge: 1,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
Deno.serve(async (req) => {
|
|
160
|
+
const payload = (await req.json().catch(() => ({}))) as {
|
|
161
|
+
record?: { post_id: string };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (payload.record?.post_id) {
|
|
165
|
+
const postId = payload.record.post_id;
|
|
166
|
+
const since = new Date(Date.now() - COOLDOWN_MS).toISOString();
|
|
167
|
+
const { count } = await supabase
|
|
168
|
+
.from("post_reactions")
|
|
169
|
+
.select("*", { count: "exact", head: true })
|
|
170
|
+
.eq("post_id", postId)
|
|
171
|
+
.gt("notified_at", since);
|
|
172
|
+
if ((count ?? 0) > 0) return new Response("hold: cooldown");
|
|
173
|
+
await flushPost(postId);
|
|
174
|
+
return new Response("ok");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const cutoff = new Date(Date.now() - COOLDOWN_MS).toISOString();
|
|
178
|
+
const { data: stale } = await supabase
|
|
179
|
+
.from("post_reactions")
|
|
180
|
+
.select("post_id")
|
|
181
|
+
.is("notified_at", null)
|
|
182
|
+
.lt("created_at", cutoff);
|
|
183
|
+
const postIds = [...new Set((stale ?? []).map((r) => r.post_id as string))];
|
|
184
|
+
for (const pid of postIds) await flushPost(pid);
|
|
185
|
+
return new Response(JSON.stringify({ swept: postIds.length }));
|
|
186
|
+
});
|