@rocapine/community 0.6.0 → 0.8.1

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.8.1",
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
  }
@@ -28,14 +28,25 @@ export async function sendExpoPushBatch(
28
28
  if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
29
29
  const chunks: ExpoPushMessage[][] = [];
30
30
  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
- );
31
+ await runPool(chunks, opts.concurrency ?? 4, async (chunk) => {
32
+ try {
33
+ const res = await fetchImpl("https://exp.host/--/api/v2/push/send", {
34
+ method: "POST",
35
+ headers,
36
+ body: JSON.stringify(chunk.length === 1 ? chunk[0] : chunk),
37
+ signal: AbortSignal.timeout(10_000),
38
+ });
39
+ // Best effort, but never silent: a project with push security enabled
40
+ // answers 401 UNAUTHORIZED without EXPO_ACCESS_TOKEN, and bad tokens
41
+ // come back as per-message tickets — both used to vanish here.
42
+ const body = await res.text();
43
+ if (!res.ok || /"status"\s*:\s*"error"/.test(body)) {
44
+ console.error(`expo push: HTTP ${res.status} ${body.slice(0, 500)}`);
45
+ }
46
+ } catch (error) {
47
+ console.error("expo push: request failed", error);
48
+ }
49
+ });
39
50
  }
40
51
 
41
52
  export async function sendExpoPush(message: ExpoPushMessage): Promise<void> {
@@ -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,