more routes

This commit is contained in:
Devin Zuczek
2026-07-01 01:03:39 -04:00
parent ba33003343
commit f35e803267
12 changed files with 242 additions and 9 deletions
+15
View File
@@ -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<Account |
)
}
/** Look up a single account by username (case-insensitive), or null if none. */
export async function getAccountByUsername(
db: D1Database,
username: string
): Promise<Account | null> {
return parseOne(
await db
.prepare('SELECT data FROM accounts WHERE username_lower = ?1')
.bind(username.toLowerCase())
.first<AccountRow>()
)
}
/** Look up multiple accounts by AccountId (order not guaranteed). */
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
if (ids.length === 0) return []
+53 -4
View File
@@ -7,6 +7,7 @@ import {
createAccount,
defaultAccount,
getAccount,
getAccountByUsername,
getAccountsByIds,
updateAccount,
} from './accounts-db'
@@ -47,6 +48,18 @@ function unauthorized(c: Context<App>) {
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<App>, 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<App>, name: string): Promise<string> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
@@ -103,7 +116,7 @@ const app = new Hono<App>()
...toAccountDto(account),
email: account.email ?? null,
birthday: null,
availableUsernameChanges: 1,
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
})
})
@@ -164,18 +177,44 @@ const app = new Hono<App>()
})
// ---- 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<App>()
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)
+81 -3
View File
@@ -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)