@rocapine/community 0.6.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community",
3
- "version": "0.6.0",
3
+ "version": "0.9.0",
4
4
  "description": "CLI to install, upgrade and adopt the Rocapine Community SDK's Supabase backend (migrations, Edge Functions) into a project.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,8 +17,8 @@
17
17
  // Placeholders: `{name}` (actor display name) and `{count}` (the number of
18
18
  // OTHER people in a coalesced push). A `*.many` value is either a string or a
19
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).
20
+ // `{count}` for the recipient's locale (Polish needs all three; Ukrainian and
21
+ // Romanian one/few/other; French treats 0 like 1; everything else is one/other).
22
22
  //
23
23
  // Legacy single-string secrets still apply, below the JSON and above the
24
24
  // built-ins: COMMUNITY_FALLBACK_NAME (every locale's fallbackName) and
@@ -165,13 +165,23 @@ const BUILTIN: Record<string, LocaleCopy> = {
165
165
  // Same rule set as the UI package's `pluralCategory` (packages/ui/src/i18n.ts).
166
166
  export function pluralCategory(locale: string, n: number): keyof Forms {
167
167
  const lang = locale.split("-")[0]?.toLowerCase();
168
+ const m10 = n % 10;
169
+ const m100 = n % 100;
168
170
  if (lang === "pl") {
169
171
  if (n === 1) return "one";
170
- const m10 = n % 10;
171
- const m100 = n % 100;
172
172
  if (m10 >= 2 && m10 <= 4 && !(m100 >= 12 && m100 <= 14)) return "few";
173
173
  return "many";
174
174
  }
175
+ if (lang === "uk") {
176
+ if (m10 === 1 && m100 !== 11) return "one";
177
+ if (m10 >= 2 && m10 <= 4 && !(m100 >= 12 && m100 <= 14)) return "few";
178
+ return "other";
179
+ }
180
+ if (lang === "ro") {
181
+ if (n === 1) return "one";
182
+ if (n === 0 || (m100 >= 2 && m100 <= 19)) return "few";
183
+ return "other";
184
+ }
175
185
  if (lang === "fr") return n === 0 || n === 1 ? "one" : "other";
176
186
  return n === 1 ? "one" : "other";
177
187
  }
@@ -8,11 +8,36 @@ export interface ExpoPushMessage {
8
8
  title: string;
9
9
  body?: string;
10
10
  data: Record<string, string>;
11
- // badge: 1 is a "something new" dot, not a real unread count — the app
12
- // clears it on every foreground.
11
+ // The recipient's unread inbox count at send time (see `unreadBadge`); the
12
+ // app re-syncs the icon badge from the SDK's own count while it runs.
13
13
  badge?: number;
14
14
  }
15
15
 
16
+ /** Unread inbox rows for `uid` (newer than the seen marker), at least 1 since a push
17
+ * is being sent. Falls back to 1 when the inbox module is not installed. */
18
+ export async function unreadBadge(
19
+ supabase: { from: (table: string) => any },
20
+ uid: string,
21
+ ): Promise<number> {
22
+ try {
23
+ const { data: seen } = await supabase
24
+ .from("notification_seen")
25
+ .select("seen_at")
26
+ .eq("user_id", uid)
27
+ .maybeSingle();
28
+ let query = supabase
29
+ .from("notifications")
30
+ .select("*", { count: "exact", head: true })
31
+ .eq("user_id", uid);
32
+ if (seen?.seen_at) query = query.gt("created_at", seen.seen_at);
33
+ const { count, error } = await query;
34
+ if (error) return 1;
35
+ return Math.max(1, count ?? 0);
36
+ } catch {
37
+ return 1;
38
+ }
39
+ }
40
+
16
41
  // Sends in chunks of 100 (Expo API limit), 4 requests in flight at a time, each
17
42
  // aborted after 10 s so one hung request can't stall a large broadcast past the
18
43
  // function's wall-clock limit. Best-effort: push failures must never fail the
@@ -28,14 +53,25 @@ export async function sendExpoPushBatch(
28
53
  if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
29
54
  const chunks: ExpoPushMessage[][] = [];
30
55
  for (let i = 0; i < messages.length; i += 100) chunks.push(messages.slice(i, i + 100));
31
- await runPool(chunks, opts.concurrency ?? 4, (chunk) =>
32
- fetchImpl("https://exp.host/--/api/v2/push/send", {
33
- method: "POST",
34
- headers,
35
- body: JSON.stringify(chunk.length === 1 ? chunk[0] : chunk),
36
- signal: AbortSignal.timeout(10_000),
37
- }).catch(() => {}),
38
- );
56
+ await runPool(chunks, opts.concurrency ?? 4, async (chunk) => {
57
+ try {
58
+ const res = await fetchImpl("https://exp.host/--/api/v2/push/send", {
59
+ method: "POST",
60
+ headers,
61
+ body: JSON.stringify(chunk.length === 1 ? chunk[0] : chunk),
62
+ signal: AbortSignal.timeout(10_000),
63
+ });
64
+ // Best effort, but never silent: a project with push security enabled
65
+ // answers 401 UNAUTHORIZED without EXPO_ACCESS_TOKEN, and bad tokens
66
+ // come back as per-message tickets — both used to vanish here.
67
+ const body = await res.text();
68
+ if (!res.ok || /"status"\s*:\s*"error"/.test(body)) {
69
+ console.error(`expo push: HTTP ${res.status} ${body.slice(0, 500)}`);
70
+ }
71
+ } catch (error) {
72
+ console.error("expo push: request failed", error);
73
+ }
74
+ });
39
75
  }
40
76
 
41
77
  export async function sendExpoPush(message: ExpoPushMessage): Promise<void> {
@@ -18,7 +18,29 @@ export const TARGET_LOCALES: string[] = (Deno.env.get("COMMUNITY_TRANSLATION_LOC
18
18
  .map((l) => l.trim())
19
19
  .filter(Boolean);
20
20
  export const TRANSLATION_MODEL = Deno.env.get("COMMUNITY_TRANSLATION_MODEL") ?? "gpt-5-mini";
21
+ // COMMUNITY_TRANSLATION_STYLE: a sentence (same voice for every target) or a JSON
22
+ // object { "<locale>": "<hint>" } for per-locale register hints — e.g.
23
+ // { "fr": "address the reader as vous", "de": "address the reader as Sie" }.
21
24
  export const TRANSLATION_STYLE = Deno.env.get("COMMUNITY_TRANSLATION_STYLE") ?? "";
25
+
26
+ /** The "App voice" instruction lines for the given targets; empty when unset. */
27
+ export function translationStyleLines(style: string, targets: string[]): string[] {
28
+ const raw = style.trim();
29
+ if (!raw) return [];
30
+ if (!raw.startsWith("{")) return [`App voice: ${raw}`];
31
+ let byLocale: Record<string, unknown>;
32
+ try {
33
+ byLocale = JSON.parse(raw);
34
+ } catch {
35
+ return [`App voice: ${raw}`];
36
+ }
37
+ return targets.flatMap((locale) => {
38
+ const hint = byLocale[locale] ?? byLocale[locale.split("-")[0]];
39
+ return typeof hint === "string" && hint.trim()
40
+ ? [`App voice for ${locale}: ${hint.trim()}`]
41
+ : [];
42
+ });
43
+ }
22
44
  export const ENGINE = `openai:${TRANSLATION_MODEL}`;
23
45
 
24
46
  export type TranslationRow = {
@@ -140,7 +162,7 @@ export async function translateItem(input: {
140
162
  input.options.length > 0
141
163
  ? `The text is a poll: also translate the ${input.options.length} option labels, in the same order and count, into "options". Return an empty options array when there are none.`
142
164
  : "Return an empty options array for every locale.",
143
- TRANSLATION_STYLE ? `App voice: ${TRANSLATION_STYLE}` : "",
165
+ ...translationStyleLines(TRANSLATION_STYLE, input.targets),
144
166
  ]
145
167
  .filter(Boolean)
146
168
  .join("\n");
@@ -8,7 +8,7 @@
8
8
  // push comes from the request itself.
9
9
 
10
10
  import { adminClient } from "../_shared/client.ts";
11
- import { sendExpoPush } from "../_shared/push.ts";
11
+ import { sendExpoPush, unreadBadge } from "../_shared/push.ts";
12
12
  import { pushCopy } from "../_shared/copy.ts";
13
13
  import { ensureTranslations, resolveTargetLocale, languageOf } from "../_shared/translation.ts";
14
14
 
@@ -84,7 +84,7 @@ Deno.serve(async (req) => {
84
84
  title: copy.text("comment.title"),
85
85
  body,
86
86
  data: { route: "/community", kind: "community_comment" },
87
- badge: 1,
87
+ badge: await unreadBadge(supabase, post.author_id),
88
88
  });
89
89
  return new Response("ok");
90
90
  });
@@ -11,7 +11,7 @@
11
11
  // notifications early.
12
12
 
13
13
  import { adminClient } from "../_shared/client.ts";
14
- import { sendExpoPush } from "../_shared/push.ts";
14
+ import { sendExpoPush, unreadBadge } from "../_shared/push.ts";
15
15
  import { pushCopy } from "../_shared/copy.ts";
16
16
 
17
17
  const supabase = adminClient();
@@ -81,7 +81,7 @@ async function flushPost(postId: string) {
81
81
  to: pref.expo_push_token,
82
82
  title,
83
83
  data: { route: "/community", kind: "community_like" },
84
- badge: 1,
84
+ badge: await unreadBadge(supabase, post.author_id),
85
85
  });
86
86
  }
87
87
 
@@ -15,7 +15,7 @@
15
15
  // untrusted body; everything else comes from post_reactions.
16
16
 
17
17
  import { adminClient } from "../_shared/client.ts";
18
- import { sendExpoPush } from "../_shared/push.ts";
18
+ import { sendExpoPush, unreadBadge } from "../_shared/push.ts";
19
19
  import { pushCopy } from "../_shared/copy.ts";
20
20
 
21
21
  const supabase = adminClient();
@@ -80,7 +80,7 @@ async function flushPost(postId: string) {
80
80
  to: pref.expo_push_token,
81
81
  title,
82
82
  data: { route: "/community", kind: "community_reaction" },
83
- badge: 1,
83
+ badge: await unreadBadge(supabase, post.author_id),
84
84
  });
85
85
  }
86
86
 
@@ -1,10 +1,10 @@
1
- // update-profile: the ONLY write path for profiles.handle / bio / avatar_url.
1
+ // update-profile: the ONLY moderated write path for profiles.username / handle / bio / avatar_url.
2
2
  // JWT-authenticated: uid comes from the caller's anon session token, never the
3
3
  // body. Text fields run through OpenAI omni-moderation (same category logic
4
4
  // as moderate-one); avatarPath runs image moderation on the object's public
5
5
  // URL. Fail closed: if moderation is unreachable, nothing is written.
6
6
  //
7
- // Fields are applied sequentially (handle, then bio, then avatar); the first
7
+ // Fields are applied sequentially (username, handle, bio, then avatar); the first
8
8
  // failure aborts and reports its field. The app's editors send one field at a
9
9
  // time, so partial application is not observable in practice.
10
10
 
@@ -24,17 +24,23 @@ Deno.serve(async (req) => {
24
24
  const uid = userData.user?.id;
25
25
  if (!uid) return json({ status: "error", error: "unauthorized" }, 401);
26
26
 
27
- let body: { handle?: unknown; bio?: unknown; avatarPath?: unknown };
27
+ let body: { username?: unknown; handle?: unknown; bio?: unknown; avatarPath?: unknown };
28
28
  try {
29
29
  body = await req.json();
30
30
  } catch {
31
31
  return json({ status: "error", error: "bad request" }, 400);
32
32
  }
33
- const { handle, bio, avatarPath } = body;
34
- if (handle === undefined && bio === undefined && avatarPath === undefined) {
33
+ const { username, handle, bio, avatarPath } = body;
34
+ if (
35
+ username === undefined &&
36
+ handle === undefined &&
37
+ bio === undefined &&
38
+ avatarPath === undefined
39
+ ) {
35
40
  return json({ status: "error", error: "bad request" }, 400);
36
41
  }
37
42
  if (
43
+ (username !== undefined && typeof username !== "string") ||
38
44
  (handle !== undefined && typeof handle !== "string") ||
39
45
  (bio !== undefined && typeof bio !== "string") ||
40
46
  (avatarPath !== undefined && typeof avatarPath !== "string")
@@ -42,6 +48,21 @@ Deno.serve(async (req) => {
42
48
  return json({ status: "error", error: "bad request" }, 400);
43
49
  }
44
50
 
51
+ // ============ USERNAME (display name) ============
52
+ if (username !== undefined) {
53
+ const u = (username as string).trim();
54
+ if (u.length < 2 || u.length > 24) {
55
+ return json({ status: "error", error: "invalid_username" }, 400);
56
+ }
57
+ const verdict = await moderateInput(u);
58
+ if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
59
+ if (verdict.flagged) {
60
+ return json({ status: "rejected", field: "username", reason: verdict.reason });
61
+ }
62
+ const { error } = await admin.from("profiles").update({ username: u }).eq("id", uid);
63
+ if (error) return json({ status: "error", error: error.message }, 500);
64
+ }
65
+
45
66
  // ============ HANDLE ============
46
67
  if (handle !== undefined) {
47
68
  const h = (handle as string).trim().toLowerCase();
@@ -119,11 +140,12 @@ Deno.serve(async (req) => {
119
140
  // ============ SUCCESS ============
120
141
  const { data: profile } = await admin
121
142
  .from("profiles")
122
- .select("handle, bio, avatar_url")
143
+ .select("username, handle, bio, avatar_url")
123
144
  .eq("id", uid)
124
145
  .single();
125
146
  return json({
126
147
  status: "ok",
148
+ username: profile?.username ?? null,
127
149
  handle: profile?.handle ?? null,
128
150
  bio: profile?.bio ?? null,
129
151
  avatarUrl: profile?.avatar_url ?? null,
@@ -0,0 +1,5 @@
1
+ -- Lets a user delete their own push row (account deletion). Until now only
2
+ -- select/insert/update existed, so a client delete() was a silent no-op.
3
+ drop policy if exists "delete own push row" on public.push_tokens;
4
+ create policy "delete own push row" on public.push_tokens for delete to authenticated
5
+ using (user_id = auth.uid());