From f35e80326791bd1e08a7862ac55ee969db52ec23 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 1 Jul 2026 01:03:39 -0400 Subject: [PATCH] more routes --- apps/accounts/src/accounts-db.ts | 15 ++++ apps/accounts/src/accounts.app.ts | 57 ++++++++++++- .../accounts/src/test/integration/api.test.ts | 84 ++++++++++++++++++- apps/api/src/api.app.ts | 6 ++ apps/api/src/test/integration/api.test.ts | 6 ++ apps/clubs/src/clubs.app.ts | 16 +++- apps/clubs/src/test/integration/api.test.ts | 12 +++ apps/econ/src/econ.app.ts | 7 ++ apps/econ/src/test/integration/api.test.ts | 10 +++ apps/rooms/src/rooms-db.ts | 10 +++ apps/rooms/src/rooms.app.ts | 7 ++ apps/rooms/src/test/integration/api.test.ts | 21 +++++ 12 files changed, 242 insertions(+), 9 deletions(-) diff --git a/apps/accounts/src/accounts-db.ts b/apps/accounts/src/accounts-db.ts index fd05867..10abf06 100644 --- a/apps/accounts/src/accounts-db.ts +++ b/apps/accounts/src/accounts-db.ts @@ -39,6 +39,8 @@ export interface Account { phone?: string /** Set via PUT /account/me/bio; read back via GET /account/:id/bio. */ bio?: string + /** Remaining username changes; decremented by PUT /account/me/username. */ + availableUsernameChanges?: number } interface AccountRow { @@ -122,6 +124,19 @@ export async function getAccount(db: D1Database, id: number): Promise { + return parseOne( + await db + .prepare('SELECT data FROM accounts WHERE username_lower = ?1') + .bind(username.toLowerCase()) + .first() + ) +} + /** Look up multiple accounts by AccountId (order not guaranteed). */ export async function getAccountsByIds(db: D1Database, ids: number[]): Promise { if (ids.length === 0) return [] diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index a997593..42d51e6 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -7,6 +7,7 @@ import { createAccount, defaultAccount, getAccount, + getAccountByUsername, getAccountsByIds, updateAccount, } from './accounts-db' @@ -47,6 +48,18 @@ function unauthorized(c: Context) { return c.body(null, 401) } +/** Username changes a fresh account starts with (until one has been consumed). */ +const DEFAULT_USERNAME_CHANGES = 1 + +/** + * Username-change result envelope: `{ success, error, value }`, always HTTP 200. + * On success `value` is the updated account; on error `error` carries the message + * and `value` is an empty string. + */ +function usernameResult(c: Context, error = '', value: unknown = '') { + return c.json({ success: error === '', error, value }) +} + /** Read a single string field from a form-urlencoded / multipart body. */ async function formField(c: Context, name: string): Promise { const body = await c.req.parseBody().catch(() => ({}) as Record) @@ -103,7 +116,7 @@ const app = new Hono() ...toAccountDto(account), email: account.email ?? null, birthday: null, - availableUsernameChanges: 1, + availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES, }) }) @@ -164,18 +177,44 @@ const app = new Hono() }) // ---- Profile mutations --------------------------------------------------- + // Set the player's display name (persisted on the account row). .put('/account/me/displayname', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - await formField(c, 'displayName') // TODO: persist on the account row. + const displayName = (await formField(c, 'displayName')).trim() + if (displayName === '') return c.body(null, 400) + await updateAccount(c.env.DB, id, { displayName }) return c.json({ success: true }) }) + // Change the caller's username. Rejects a name already taken by another account, + // and requires the account to have username changes remaining. On success the + // new name is persisted and the remaining-changes counter is decremented. .put('/account/me/username', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - await formField(c, 'username') // TODO: persist on the account row. - return c.json({ success: true }) + + const username = (await formField(c, 'username')).trim() + if (username === '') return usernameResult(c, 'You must enter a username.') + + // Duplicate check first (case-insensitive); keeping your own name is allowed. + const existing = await getAccountByUsername(c.env.DB, username) + if (existing && existing.accountId !== id) { + return usernameResult(c, 'That username is already taken.') + } + + // Then require a remaining change. + const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id) + const remaining = account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES + if (remaining <= 0) { + return usernameResult(c, 'You have no username changes remaining.') + } + + const updated = await updateAccount(c.env.DB, id, { + username, + availableUsernameChanges: remaining - 1, + }) + return usernameResult(c, '', toAccountDto(updated)) }) // Set the player's email (persisted on the account row; surfaced by /account/me). @@ -208,6 +247,16 @@ const app = new Hono() return c.json({ success: true }) }) + // Set the player's personalPronouns (posted as `pronounFlags`; persisted). + .put('/account/me/personalpronouns', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const personalPronouns = Number.parseInt((await formField(c, 'pronounFlags')).trim(), 10) + if (Number.isNaN(personalPronouns)) return c.body(null, 400) + await updateAccount(c.env.DB, id, { personalPronouns }) + return c.json({ success: true }) + }) + .put('/account/me/bio', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index 6804312..bca1430 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -168,13 +168,79 @@ describe('auth-gated endpoints', () => { expect(res.status).toBe(401) }) - test('PUT /account/me/displayname acks with a valid token', async () => { + test('PUT /account/me/displayname persists the display name', async () => { + const headers = { + ...(await bearer('895')), + 'Content-Type': 'application/x-www-form-urlencoded', + } const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, { - ...form({ displayName: 'Bob' }), - headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' }, + ...form({ displayName: 'laskdjfasdlfkj' }), + headers, }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ success: true }) + + const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('895') }) + expect(((await me.json()) as { displayName: string }).displayName).toBe('laskdjfasdlfkj') + }) + + test('PUT /account/me/username 401s without a token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, { + ...form({ username: 'whoever' }), + }) + expect(res.status).toBe(401) + }) + + test('PUT /account/me/username returns a Success:false envelope for a taken name', async () => { + // "Coach" is the seeded account 1. + const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, { + ...form({ username: 'Coach' }), + headers: { ...(await bearer('893')), 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + // Business errors are HTTP 200 with the { success, error, value } envelope. + expect(res.status).toBe(200) + const body = (await res.json()) as { success: boolean; error: string; value: string } + expect(body.success).toBe(false) + expect(body.error).toMatch(/already taken/i) + expect(body.value).toBe('') + }) + + test('PUT /account/me/username changes the name, decrements the counter, then blocks', async () => { + const headers = { + ...(await bearer('892')), + 'Content-Type': 'application/x-www-form-urlencoded', + } + // First change succeeds — value is the updated account. + const ok = await exports.default.fetch(`${ORIGIN}/account/me/username`, { + ...form({ username: 'coachx' }), + headers, + }) + expect(ok.status).toBe(200) + const okBody = (await ok.json()) as { + success: boolean + error: string + value: { accountId: number; username: string } + } + expect(okBody.success).toBe(true) + expect(okBody.error).toBe('') + expect(okBody.value).toMatchObject({ accountId: 892, username: 'coachx' }) + + // /account/me reflects the new name and the decremented counter. + const me = (await ( + await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') }) + ).json()) as { username: string; availableUsernameChanges: number } + expect(me.username).toBe('coachx') + expect(me.availableUsernameChanges).toBe(0) + + // A second change is blocked — no changes remaining (still HTTP 200). + const blocked = await exports.default.fetch(`${ORIGIN}/account/me/username`, { + ...form({ username: 'coachy' }), + headers, + }) + expect(blocked.status).toBe(200) + const blockedBody = (await blocked.json()) as { success: boolean; error: string } + expect(blockedBody.success).toBe(false) + expect(blockedBody.error).toMatch(/no username changes/i) }) test('PUT /account/me/profileimage 401s without a token', async () => { @@ -264,6 +330,18 @@ describe('auth-gated endpoints', () => { expect(await res.json()).toEqual({ success: true }) }) + test('PUT /account/me/personalpronouns persists the value, surfaced by /account/me', async () => { + const res = await exports.default.fetch(`${ORIGIN}/account/me/personalpronouns`, { + ...form({ pronounFlags: '2' }), + headers: { ...(await bearer('894')), 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + + const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('894') }) + expect(((await me.json()) as { personalPronouns: number }).personalPronouns).toBe(2) + }) + test('PUT /account/me/bio 401s without a token', async () => { const res = await exports.default.fetch(`${ORIGIN}/account/me/bio`, { ...form({ bio: 'x' }) }) expect(res.status).toBe(401) diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index 166e9bc..256e77d 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -284,6 +284,12 @@ const app = new Hono({ strict: false }) .get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true)) .get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true)) + // Custom avatar items created by a given account. No storage yet → an empty + // paginated result (matches the econ `customAvatarItems/v1/owned` shape). + .get('/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}', (c) => + c.json({ Results: [], TotalResults: 0 }) + ) + // Voice chat config. The client fetches it to set up voice. // No reference shape, so return an empty object until the client needs fields. .get('/voice/config', (c) => c.json({})) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 49dab52..25dceaf 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -201,6 +201,12 @@ describe('public endpoints', () => { expect(await res.json()).toBe(true) }) + test('GET /api/customAvatarItems/v2/fromCreator/:id returns an empty paginated result', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ Results: [], TotalResults: 0 }) + }) + test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`) expect(res.status).toBe(200) diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index a18c249..49a6f6a 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -57,10 +57,22 @@ const app = new Hono() // array = no club subscription memberships (the client chokes on null). .get('/subscription/mine/member', (c) => c.json([])) - // Details for a given subscription. The client deserializes this into an - // object, so it must return `{}` (not `[]`). + // Subscription details for an account (numeric id) — simulated: no club, no subs. + .get('/subscription/details/:accountId{[0-9]+}', (c) => + c.json({ + accountId: Number.parseInt(c.req.param('accountId'), 10), + clubId: 0, + subscriberCount: 0, + }) + ) + + // Details for a named subscription (e.g. `rrplus`). The client deserializes this + // into an object, so it must return `{}` (not `[]`). .get('/subscription/details/:subscription', (c) => c.json({})) + // Subscriber count for an account. No club subscriptions yet → 0. + .get('/subscription/subscriberCount/:accountId{[0-9]+}', (c) => c.json(0)) + // The player's clubs that have unread announcements (MyClubsWithUnread- // Announcements). No DB → empty list. .get('/announcements/v2/mine/unread', (c) => c.json([])) diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 3e627fe..b18ccaa 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -56,6 +56,18 @@ describe('clubs endpoints', () => { expect(await res.json()).toEqual({}) }) + test('GET /subscription/details/:accountId returns simulated details', async () => { + const res = await exports.default.fetch(`${ORIGIN}/subscription/details/2`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 }) + }) + + test('GET /subscription/subscriberCount/:id returns 0', async () => { + const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/2`) + expect(res.status).toBe(200) + expect(await res.json()).toBe(0) + }) + test('GET /announcements/v2/mine/unread returns []', async () => { const res = await exports.default.fetch(`${ORIGIN}/announcements/v2/mine/unread`) expect(res.status).toBe(200) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index b56e8d8..14a1170 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -130,6 +130,13 @@ const app = new Hono() return c.json([]) }) + // The player's item wishlist. [Authorize]; empty without a DB binding. + .get('/api/itemWishlists/v1/wishlist/me', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json([]) + }) + // The player's saved outfits. [Authorize]; empty without a DB binding. .get('/api/avatar/v3/saved', async (c) => { const id = await authedId(c) diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index bc6ab8d..edf39e8 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -174,6 +174,16 @@ describe('econ endpoints', () => { expect(await res.json()).toEqual([]) }) + test('GET /api/itemWishlists/v1/wishlist/me 401s without a token, returns [] with one', async () => { + const anon = await exports.default.fetch(`${ORIGIN}/api/itemWishlists/v1/wishlist/me`) + expect(anon.status).toBe(401) + const res = await exports.default.fetch(`${ORIGIN}/api/itemWishlists/v1/wishlist/me`, { + headers: await bearer(), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + test('GET /api/avatar/v3/saved 401s without a token, returns [] with one', async () => { const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`) expect(anon.status).toBe(401) diff --git a/apps/rooms/src/rooms-db.ts b/apps/rooms/src/rooms-db.ts index 090936d..442361d 100644 --- a/apps/rooms/src/rooms-db.ts +++ b/apps/rooms/src/rooms-db.ts @@ -142,6 +142,16 @@ export async function getRoomsByCreator(db: D1Database, accountId: number): Prom return parseAll(results) } +/** + * An account's public, non-dorm rooms — the publicly viewable "rooms owned by + * " list (excludes private rooms, dorms, and list-excluded rooms). + */ +export async function getPublicRoomsByCreator(db: D1Database, accountId: number): Promise { + return (await getRoomsByCreator(db, accountId)).filter( + (r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true + ) +} + /** * Rooms the player has favorited (interaction.favorited = 1), most recently * interacted first. Joins the `interaction` table to `rooms`, so a favorited room diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 405c0bc..39ea190 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -10,6 +10,7 @@ import { getFavoritedRooms, getHotRooms, getInteraction, + getPublicRoomsByCreator, getRoomById, getRoomByName, getRoomsByCreator, @@ -206,6 +207,12 @@ const app = new Hono() .get('/rooms/ownedby/me', ownedRooms) .get('/rooms/createdby/me', ownedRooms) + // Public: the rooms a given account owns that are publicly viewable. No auth — + // returns a bare array (empty when the account owns no public rooms). + .get('/rooms/ownedby/:accountId{[0-9]+}', async (c) => + c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10))) + ) + // Rooms the caller has favorited (from the interaction table). Auth-gated. // Paginated via skip/take (take defaults to 100). Returns a bare array, like the // other room-source `*by/me` lists the client loads. diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 3f16ea3..448a67f 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -115,6 +115,27 @@ describe('rooms endpoints', () => { expect(other).toEqual([]) }) + it('GET /rooms/ownedby/:id returns an account public rooms (no auth)', async () => { + const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/1`) + expect(res.status).toBe(200) + const body = (await res.json()) as Array<{ + RoomId: number + Accessibility: number + IsDorm?: boolean + CreatorAccountId: number + }> + expect(body.length).toBeGreaterThan(0) + // Only public, non-dorm rooms owned by account 1 — the private dorm (RoomId 1) + // is excluded. + expect( + body.every((r) => r.Accessibility === 1 && r.IsDorm !== true && r.CreatorAccountId === 1) + ).toBe(true) + expect(body.some((r) => r.RoomId === 1)).toBe(false) + + // An account that owns no public rooms → empty array. + expect(await (await SELF.fetch(`${ORIGIN}/rooms/ownedby/999`)).json()).toEqual([]) + }) + it('GET /rooms/search returns a paginated { Results, TotalResults }', async () => { // Name-term search resolves a known public room. const res = await SELF.fetch(`${ORIGIN}/rooms/search?query=reccenter&skip=0&take=100`)