@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -0
  3. package/lib/commands/adopt.d.ts +38 -0
  4. package/lib/commands/adopt.js +105 -0
  5. package/lib/commands/init.d.ts +29 -0
  6. package/lib/commands/init.js +152 -0
  7. package/lib/commands/upgrade.d.ts +33 -0
  8. package/lib/commands/upgrade.js +280 -0
  9. package/lib/index.d.ts +2 -0
  10. package/lib/index.js +123 -0
  11. package/lib/install-shared.d.ts +72 -0
  12. package/lib/install-shared.js +256 -0
  13. package/lib/manifest.d.ts +53 -0
  14. package/lib/manifest.js +74 -0
  15. package/lib/substitute.d.ts +15 -0
  16. package/lib/substitute.js +25 -0
  17. package/package.json +32 -0
  18. package/templates/functions/_shared/client.ts +20 -0
  19. package/templates/functions/_shared/config.ts +23 -0
  20. package/templates/functions/_shared/moderation.ts +102 -0
  21. package/templates/functions/_shared/push.ts +33 -0
  22. package/templates/functions/_shared/slack.ts +12 -0
  23. package/templates/functions/broadcast-post/index.ts +73 -0
  24. package/templates/functions/daily-moderation/index.ts +175 -0
  25. package/templates/functions/moderate-one/index.ts +62 -0
  26. package/templates/functions/notify-comment/index.ts +63 -0
  27. package/templates/functions/notify-like/index.ts +103 -0
  28. package/templates/functions/notify-reaction/index.ts +186 -0
  29. package/templates/functions/report-to-slack/index.ts +37 -0
  30. package/templates/functions/update-profile/index.ts +131 -0
  31. package/templates/migrations/core/001_tables.sql +177 -0
  32. package/templates/migrations/core/002_rls.sql +154 -0
  33. package/templates/migrations/core/003_moderation.sql +68 -0
  34. package/templates/migrations/core/004_dashboard.sql +102 -0
  35. package/templates/migrations/core/005_avatars.sql +19 -0
  36. package/templates/migrations/core/006_meta.sql +11 -0
  37. package/templates/migrations/inbox/001_inbox.sql +237 -0
  38. package/templates/migrations/polls/001_polls.sql +120 -0
  39. package/templates/migrations/push/001_push.sql +42 -0
  40. package/templates/migrations/push/002_triggers.sql +89 -0
  41. 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
+ });