Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ jobs:

- name: Typecheck
run: bun run typecheck

- name: Test
run: bun run test
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ can track what's done. Use `- [x]` for completed items.
| `bun run lint:fix` | Auto-fix lint issues |
| `bun run format` | Auto-format code |
| `bun run typecheck` | Type-check all packages |
| `bun run test` | Run API regression tests |

### Project Structure

Expand Down
8 changes: 5 additions & 3 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- [x] **All 4** — Agree on sticker storage format (base64 PNG), admin role mechanism (is_admin), map provider for mobile (react-native-leaflet-view), quest system (parameterised templates), daily quest pool (~5), push timing (8–9am), billboard limits (concurrent cap + Sydney-day posting cap)

- [x] **All 4** — Document commit practice: update PLAN.md before each commit (`CONTRIBUTING.md`)
- [x] **All 4** — Pin TypeScript 5.9 across workspaces so local and CI typechecks use the same compiler

> **Dependency edge:** Everything else depends on the shared schemas and DB schema.

Expand Down Expand Up @@ -62,7 +63,7 @@
- [x] **FE1** — Map: show POI markers with distinct glowing style
- [X] **FE1** — Map: show billboard markers with note icon style
- [x] **FE1** — Map: show user's current location as their 64×64 avatar (instead of a standard dot)
- [ ] **FE1** — POI discovery UX: toast when entering geofence + quest progress trigger (quest-progress toast plumbing exists for API mutations; geofence trigger still pending)
- [ ] **FE1** — POI discovery UX: toast when entering geofence + quest progress trigger (manual POI check-in now calls the live visit API; automatic geofence trigger still pending)
- [X] **FE2** — Billboard expanded view (~60vh overlay): text + username pill + all placements (z-ordered)
- [X] **FE2** — Pixel art sticker editor: 64×64 grid, 8-colour palette, tap-to-fill, save to collection
- [X] **FE2** — Sticky note composer: text input, preview as sticky note, post to billboard
Expand All @@ -76,10 +77,10 @@
- [x] **BE2** — Durable Object: WebSocket handler, Postgres connection, broadcast on mutations
- [ ] **BE2** — Expo Push Notification integration: register token, send on reply + daily reminder
- [x] **FE1** — Quest screen: main quest tiers + daily quest + streak counter + progress bars (currently backed by mock quest data)
- [x] **FE1** — Profile screen: level, perks unlocked, stats (notes placed, stickers saved, POIs visited)
- [x] **FE1** — Profile screen and map HUD modal: level, avatar, perks, stats, and saved stickers backed by the live user/progress API
- [x] **FE1** — Level-up celebration animation/overlay
- [x] **FE2** — WebSocket connection in app: connect to DO, listen for updates, refresh displayed data
- [x] **FE2** — Saved stickers collection picker: browse, select, reuse stickers inside the billboard placement flow
- [x] **FE2** — Saved stickers collection picker: preserve names, browse, select, reuse stickers, and refresh quest/profile progress after saves
- [x] **FE1/FE2** — Wire quest screen to the live quest API instead of `mockQuests`
- [ ] **FE2** — Saved sticky notes collection screen: browse, select, reuse text notes

Expand All @@ -95,6 +96,7 @@
- [x] **FE2** — Realtime refresh on map + billboard screen via WebSocket (pull-to-refresh not needed)
- [x] **FE1** — Remove stale `events/index` and `events/[id]` stack declarations from `apps/app/app/_layout.tsx`
- [x] **FE1** — Auth page polish: tighten Clerk footer gap between "Don't have an account? Sign up" and "Secured by Clerk" branding on `sign-in.web.tsx` / `sign-up.web.tsx` (CSS overrides in `lib/clerkAppearance.ts`)
- [x] **FE1/BE1** — Review hardening: expose profile editing, keep POI state live, normalize avatars, redact mutation logs, and serialize saved-sticker capacity checks

---

Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "latest",
"typescript": "latest",
"typescript": "~5.9.2",
"wrangler": "^4.4.0"
}
}
75 changes: 45 additions & 30 deletions apps/api/src/routes/stickers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { getDb } from "../db";
import { conflict, forbidden, notFound } from "../http";
import { getAuthUser, requireAuth } from "../middleware/auth";
import { jsonObject, isoDateTime } from "../serialize";
import { getUserCapacities, incrementQuestProgress } from "../services/progression";
import {
getUserCapacities,
incrementQuestProgress,
questProgressUpdate,
} from "../services/progression";
import { moderatePng, recordModerationLog } from "../services/moderation";
import type { AppBindings } from "../types";

Expand Down Expand Up @@ -47,6 +51,8 @@ type SavedStickerRow = {
userId: string;
};

type DatabaseExecutor = Pick<ReturnType<typeof getDb>, "execute">;

export const stickersRoute = new Hono<AppBindings>();

stickersRoute.post(
Expand Down Expand Up @@ -116,39 +122,48 @@ stickersRoute.post(
const db = getDb(c.env);
const authUser = getAuthUser(c);
const input = c.req.valid("json");
const capacity = await getUserCapacities(db, authUser.id);
const countRows = await db.execute<{ count: number }>(sql`
select count(*)::int as count
from app.saved_stickers
where user_id = ${authUser.id} and deleted_at is null
`);
const response = await db.transaction(async (tx) => {
await tx.execute(sql`select id from app.users where id = ${authUser.id} for update`);

if ((countRows[0]?.count ?? 0) >= capacity.stickerSlots) {
conflict("Saved sticker capacity reached.");
}
const capacity = await getUserCapacities(tx, authUser.id);
const countRows = await tx.execute<{ count: number }>(sql`
select count(*)::int as count
from app.saved_stickers
where user_id = ${authUser.id} and deleted_at is null
`);

const rows = await db.execute<{ id: string }>(sql`
insert into app.saved_stickers (user_id, kind, sticker_asset_id, body, label)
values (
${authUser.id},
${input.kind},
${input.kind === "sticker" ? input.stickerAssetId : null},
${input.kind === "sticky_note" ? input.body : null},
${input.label ?? null}
)
returning id
`);
const saved = (await loadSavedStickers(db, authUser.id)).find(
(item) => item.id === rows[0]!.id,
);
if ((countRows[0]?.count ?? 0) >= capacity.stickerSlots) {
conflict("Saved sticker capacity reached.");
}

if (!saved) {
throw new Error("Saved sticker was inserted but could not be loaded.");
}
const rows = await tx.execute<{ id: string }>(sql`
insert into app.saved_stickers (user_id, kind, sticker_asset_id, body, label)
values (
${authUser.id},
${input.kind},
${input.kind === "sticker" ? input.stickerAssetId : null},
${input.kind === "sticky_note" ? input.body : null},
${input.label ?? null}
)
returning id
`);
const saved = (await loadSavedStickers(tx, authUser.id)).find(
(item) => item.id === rows[0]!.id,
);

if (!saved) {
throw new Error("Saved sticker was inserted but could not be loaded.");
}

const progress = await incrementQuestProgress(tx, authUser.id, "save_stickers");

await incrementQuestProgress(db, authUser.id, "save_stickers");
return createSavedStickerResponseSchema.parse({
questProgress: progress.map(questProgressUpdate),
savedSticker: savedSticker(saved),
});
});

return c.json(createSavedStickerResponseSchema.parse({ savedSticker: savedSticker(saved) }));
return c.json(response);
},
);

Expand Down Expand Up @@ -198,7 +213,7 @@ async function loadStickerAsset(db: ReturnType<typeof getDb>, id: string) {
return sticker;
}

async function loadSavedStickers(db: ReturnType<typeof getDb>, userId: string) {
async function loadSavedStickers(db: DatabaseExecutor, userId: string) {
return db.execute<SavedStickerRow>(sql`
select
saved_stickers.id,
Expand Down
97 changes: 73 additions & 24 deletions apps/api/src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { sql } from "drizzle-orm";
import { Hono } from "hono";

import { getDb } from "../db";
import { forbidden, notFound } from "../http";
import { badRequest, conflict, forbidden, notFound } from "../http";
import { getAuthUser, requireAuth } from "../middleware/auth";
import {
ensureQuestProgress,
Expand Down Expand Up @@ -68,29 +68,37 @@ usersRoute.patch(
const input = c.req.valid("json");
const authUser = getAuthUser(c);
const db = getDb(c.env);
const rows = await db.execute<UserRow>(sql`
update app.users
set
username = coalesce(${input.username ?? null}, username),
display_name = coalesce(${input.displayName ?? null}, display_name),
updated_at = now()
where id = ${authUser.id}
returning
id,
username,
display_name as "displayName",
avatar_base64 as "avatarBase64",
is_admin as "isAdmin",
level,
xp,
daily_streak as "dailyStreak",
streak_updated_on as "streakUpdatedOn",
last_daily_claimed_on as "lastDailyClaimedOn",
banned_at as "bannedAt",
deleted_at as "deletedAt",
created_at as "createdAt",
updated_at as "updatedAt"
`);
let rows: UserRow[];
try {
rows = await db.execute<UserRow>(sql`
update app.users
set
username = coalesce(${input.username ?? null}, username),
display_name = coalesce(${input.displayName ?? null}, display_name),
updated_at = now()
where id = ${authUser.id}
returning
id,
username,
display_name as "displayName",
avatar_base64 as "avatarBase64",
is_admin as "isAdmin",
level,
xp,
daily_streak as "dailyStreak",
streak_updated_on as "streakUpdatedOn",
last_daily_claimed_on as "lastDailyClaimedOn",
banned_at as "bannedAt",
deleted_at as "deletedAt",
created_at as "createdAt",
updated_at as "updatedAt"
`);
} catch (error) {
if (isUniqueViolation(error)) {
conflict("Username is already taken.");
}
throw error;
}

return c.json(updateCurrentUserResponseSchema.parse({ user: currentUser(rows[0]!) }));
},
Expand All @@ -104,6 +112,7 @@ usersRoute.patch(
const input = c.req.valid("json");
const authUser = getAuthUser(c);
const db = getDb(c.env);
validateAvatarPng(input.avatarBase64);
const rows = await db.execute<UserRow>(sql`
update app.users
set avatar_base64 = ${input.avatarBase64}, updated_at = now()
Expand Down Expand Up @@ -268,6 +277,46 @@ export function requireAdmin(user: { isAdmin: boolean }) {
}
}

export function isUniqueViolation(error: unknown, depth = 0): boolean {
if (depth > 4 || typeof error !== "object" || error === null) {
return false;
}
if ("code" in error && error.code === "23505") {
return true;
}
return "cause" in error && isUniqueViolation(error.cause, depth + 1);
}

export function validateAvatarPng(value: string) {
const payload = value.startsWith("data:image/png;base64,") ? value.slice(22) : value;
let header: string;
try {
header = atob(payload.slice(0, 32));
} catch {
badRequest("Avatar must be a valid PNG.");
}

const bytes = Uint8Array.from(header, (character) => character.charCodeAt(0));
const isPng =
bytes.length >= 24 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47 &&
bytes[12] === 0x49 &&
bytes[13] === 0x48 &&
bytes[14] === 0x44 &&
bytes[15] === 0x52;
if (!isPng) {
badRequest("Avatar must be a valid PNG.");
}

const view = new DataView(bytes.buffer);
if (view.getUint32(16) !== 64 || view.getUint32(20) !== 64) {
badRequest("Avatar PNG must be 64x64 pixels.");
}
}

function publicUser(user: UserRow) {
return {
avatarBase64: user.avatarBase64,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/services/progression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { sql } from "drizzle-orm";
import type { getDb } from "../db";
import { isoDate, isoDateTime, nullableIsoDateTime } from "../serialize";

type Database = ReturnType<typeof getDb>;
type Database = Pick<ReturnType<typeof getDb>, "execute">;

export type QuestTriggerType =
| "leave_billboards"
Expand Down
36 changes: 36 additions & 0 deletions apps/api/test/users.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";

import { isUniqueViolation, validateAvatarPng } from "../src/routes/users";

function pngHeader(width: number, height: number) {
const bytes = new Uint8Array(24);
bytes.set([0x89, 0x50, 0x4e, 0x47], 0);
bytes.set([0x49, 0x48, 0x44, 0x52], 12);
const view = new DataView(bytes.buffer);
view.setUint32(16, width);
view.setUint32(20, height);
return btoa(String.fromCharCode(...bytes));
}

describe("user route validation", () => {
test("finds a wrapped PostgreSQL unique violation", () => {
expect(isUniqueViolation({ cause: { cause: { code: "23505" } } })).toBe(true);
expect(isUniqueViolation({ cause: { code: "23503" } })).toBe(false);
});

test("accepts a 64x64 PNG header", () => {
expect(() => validateAvatarPng(pngHeader(64, 64))).not.toThrow();
});

test("rejects other PNG dimensions", () => {
expect(() => validateAvatarPng(pngHeader(1024, 1024))).toThrow(
"Avatar PNG must be 64x64 pixels.",
);
});

test("rejects a non-PNG payload", () => {
expect(() => validateAvatarPng(btoa("not a png header"))).toThrow(
"Avatar must be a valid PNG.",
);
});
});
Loading
Loading