mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[rooms] add support for beta/limitsv2
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
-- Which of a room's tags is its PRIMARY GENRE.
|
||||
--
|
||||
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- The 2023 client picked a genre by toggling one of five "main" tags
|
||||
-- (`pvp`/`quest`/`game`/`hangout`/`art`) as radio buttons, which the tag row alone could
|
||||
-- express: whichever of the five was present was the genre. The 2025 client instead posts
|
||||
-- `primaryGenreTag=<tag>` to `PUT /rooms/{id}/tags` over a genre vocabulary it reads from
|
||||
-- the `RoomGenreTags` curated list, and it renders the chosen one differently from the
|
||||
-- room's other tags — so "is this tag the genre" is now a fact about the ROW, separate
|
||||
-- from whether the tag is there at all. A room can carry `puzzle` and `social` as ordinary
|
||||
-- tags with only `social` flagged.
|
||||
--
|
||||
-- A real column rather than a `type` value: `type` is the client's tag-CATEGORY int
|
||||
-- (0 user, 1 beta, 2 auto-derived like `rro`) which is echoed back as stored, and the
|
||||
-- primary genre is orthogonal to it — the flagged tag is still a plain Type 0 user tag.
|
||||
--
|
||||
-- Defaults to 0, so every existing row reads as "not the genre", which is what they were.
|
||||
ALTER TABLE room_tag ADD COLUMN is_primary_genre INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -132,10 +132,20 @@ export const RoomRoleDto = z.object({
|
||||
InvitedRole: z.int(),
|
||||
})
|
||||
|
||||
/** A tag on a room. `Type` 0 = set by the owner, 2 = auto-derived (e.g. `rro`). */
|
||||
/**
|
||||
* A tag on a room. `Type` 0 = set by the owner, 2 = auto-derived (e.g. `rro`).
|
||||
*
|
||||
* `IsPrimaryGenre` marks the one tag that is the room's genre, and is PRESENT ONLY on
|
||||
* that tag — the key is absent on the others rather than sent as false. It is orthogonal
|
||||
* to `Type`: the flagged tag is an ordinary owner-set tag that happens to be the genre.
|
||||
*/
|
||||
export const RoomTagDto = z.object({
|
||||
Tag: z.string(),
|
||||
Type: z.int().describe('0 = owner-set, 2 = auto'),
|
||||
Type: z.int().describe('0 = owner-set, 1 = client-derived (`autoTag`), 2 = server-derived'),
|
||||
IsPrimaryGenre: z
|
||||
.literal(true)
|
||||
.optional()
|
||||
.describe('Present only on the room’s primary genre tag; absent, never false, on the rest'),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -524,9 +534,33 @@ export const NameRequest = z.object({
|
||||
name: z.string().describe('Non-empty, and not already taken by another room'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/tags` — a toggle, not a set. */
|
||||
/**
|
||||
* `PUT /rooms/{roomId}/tags` — one route, two bodies, told apart by their FIELDS.
|
||||
*
|
||||
* A lone `tag` is the 2023 toggle: added when absent, removed when present. A `tag`
|
||||
* alongside anything else is part of a whole-state save, where nothing toggles — `tag`
|
||||
* repeats and is the complete user-tag set, `autoTag` adds a derived (Type 1) tag, and
|
||||
* `primaryGenreTag` flags the genre. They compose into one write.
|
||||
*/
|
||||
export const TagRequest = z.object({
|
||||
tag: z.string().describe('Added when absent, removed when present'),
|
||||
tag: z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.optional()
|
||||
.describe(
|
||||
'Alone: toggled (added when absent, removed when present). Alongside any other field, or repeated: the COMPLETE set of user (Type 0) tags — an omitted one is removed'
|
||||
),
|
||||
autoTag: z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.optional()
|
||||
.describe(
|
||||
'A derived tag to add at Type 1 (`limitsv2`, `beta`). Repeatable and additive — never removes one'
|
||||
),
|
||||
primaryGenreTag: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set as the room’s primary genre. Added as a Type 0 tag if the room lacks it; every other tag keeps its place and loses the flag'
|
||||
),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/image`. */
|
||||
|
||||
+70
-18
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
Accessibility,
|
||||
applyRoomTagEdit,
|
||||
areFriends,
|
||||
autocompleteRoomSearch,
|
||||
banPlayerFromRoom,
|
||||
@@ -51,7 +52,6 @@ import {
|
||||
setSubRoomPermissions,
|
||||
toggleCheer,
|
||||
toggleFavorite,
|
||||
toggleRoomTag,
|
||||
unbanPlayerFromRoom,
|
||||
updateRoomFields,
|
||||
} from '@repo/domain'
|
||||
@@ -1687,25 +1687,54 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Toggle a tag on a room. Auth-gated (401) and owner/co-owner-only (403). Body is
|
||||
// the `tag` form field. There's no delete/patch endpoint, so this call toggles: it
|
||||
// adds the tag (Type 0) if absent and removes it if present. The "main" tags
|
||||
// (#pvp/#quest/#game/#hangout/#art) are radio buttons — setting one clears the
|
||||
// others. Returns the `{ success, error, value }` envelope with the updated
|
||||
// room as `value`; business failures are 200 with success:false.
|
||||
// Change a room's tags. Auth-gated (401) and owner/co-owner-only (403). Returns the
|
||||
// `{ success, error, value }` envelope with the updated room as `value`; business
|
||||
// failures are 200 with success:false.
|
||||
//
|
||||
// TWO BODIES reach this one path — Rec Room reshaped the request rather than minting a
|
||||
// second route, so the fields, not the URL, say which one this is:
|
||||
//
|
||||
// - `tag=<name>` ALONE is the 2023 toggle. There is no delete/patch counterpart, so
|
||||
// the same call adds the tag (Type 0) when absent and removes it when present, and
|
||||
// the five "main" tags (pvp/quest/game/hangout/art) act as radio buttons.
|
||||
// - Anything else is the whole-state save both clients send from room settings:
|
||||
// `autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay`.
|
||||
// `tag` repeats and is the complete set of USER tags, `autoTag` adds a derived one
|
||||
// (Type 1), and `primaryGenreTag` flags the genre. All three compose into one write.
|
||||
//
|
||||
// The discriminator is deliberately "is there more than a lone `tag`": a save that
|
||||
// happens to carry one selected tag must not TOGGLE it back off, which is what made
|
||||
// this worth spelling out rather than counting `tag` alone.
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/tags',
|
||||
describeRoute({
|
||||
tags: ['Room settings'],
|
||||
summary: 'Toggle a tag on a room',
|
||||
description: [
|
||||
'Owner or co-owner only (403 otherwise). There is no delete/patch counterpart, so',
|
||||
'this call TOGGLES: it adds the tag (Type 0) when absent and removes it when',
|
||||
'present. The “main” tags (`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio',
|
||||
'buttons — setting one clears the others. Answers the lowercase envelope with the',
|
||||
'updated room, which the client re-renders from, and pushes a `RoomUpdate` to the',
|
||||
'owner for their other sessions.',
|
||||
].join(' '),
|
||||
'Owner or co-owner only (403 otherwise). Two bodies reach this one path, and the',
|
||||
'FIELDS say which — not the URL.',
|
||||
'',
|
||||
'**A lone `tag=<name>` TOGGLES** (the 2023 form): there is no delete/patch',
|
||||
'counterpart, so the same call adds the tag (Type 0) when absent and removes it when',
|
||||
'present, and the “main” tags (`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio',
|
||||
'buttons among themselves.',
|
||||
'',
|
||||
'**Anything else is a whole-state save** — the form room settings posts, e.g.',
|
||||
'`autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay`.',
|
||||
'Nothing toggles here; the three fields compose into one write:',
|
||||
'',
|
||||
'- `tag` repeats and is the COMPLETE set of user (Type 0) tags — one the body omits',
|
||||
'is removed. Derived tags are not the client’s to send and are left alone.',
|
||||
'- `autoTag` repeats and adds a derived tag at **Type 1** (`limitsv2`, `beta`). It is',
|
||||
'additive: it never removes one, since the client posts what it wants rather than the',
|
||||
'full set. A tag already on the room is re-categorised rather than duplicated.',
|
||||
'- `primaryGenreTag` flags the room’s genre. The tag is added as a Type 0 tag when',
|
||||
'the room lacks it and left as it stands when it has it; `IsPrimaryGenre: true` moves',
|
||||
'onto it, and every OTHER tag loses the flag but KEEPS its place.',
|
||||
'',
|
||||
'Answers the lowercase envelope with the updated room, which the client re-renders',
|
||||
'from, and pushes a `RoomUpdate` to the owner for their other sessions.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam],
|
||||
requestBody: form(TagRequest, 'The tag to toggle'),
|
||||
@@ -1726,11 +1755,34 @@ const app = new Hono<App>()
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const tag = typeof body.tag === 'string' ? body.tag.trim() : ''
|
||||
if (tag === '') return roomEnvelope(c, null, 'You must provide a tag!')
|
||||
// `all: true` because `tag` REPEATS on the whole-state save; without it Hono keeps
|
||||
// only the last value and a three-tag save would land as one tag.
|
||||
const body: Record<string, unknown> = await c.req.parseBody({ all: true }).catch(() => ({}))
|
||||
// An empty value is the same nothing as an absent field. There is no "clear the
|
||||
// genre" request, so a blank `primaryGenreTag` is a malformed post rather than an
|
||||
// instruction to unset — and a blank `tag` can't name what to toggle.
|
||||
const values = (name: string): string[] =>
|
||||
(Array.isArray(body[name]) ? body[name] : [body[name]])
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v !== '')
|
||||
|
||||
const updated = await toggleRoomTag(c.env.DB, roomId, room, tag)
|
||||
const tags = values('tag')
|
||||
const autoTags = values('autoTag')
|
||||
const primaryGenre = values('primaryGenreTag')[0]
|
||||
if (tags.length === 0 && autoTags.length === 0 && primaryGenre === undefined) {
|
||||
return roomEnvelope(c, null, 'You must provide a tag!')
|
||||
}
|
||||
|
||||
// A LONE tag is the 2023 toggle; a tag alongside anything else — another tag, an
|
||||
// auto tag, a genre — is part of a whole-state save, where nothing toggles.
|
||||
const isToggle = tags.length === 1 && autoTags.length === 0 && primaryGenre === undefined
|
||||
const updated = await applyRoomTagEdit(c.env.DB, roomId, room, {
|
||||
toggle: isToggle ? tags[0] : undefined,
|
||||
tags: isToggle ? undefined : tags.length > 0 ? tags : undefined,
|
||||
autoTags,
|
||||
primaryGenre,
|
||||
})
|
||||
// This one DOES answer the updated room, so the caller's own client redraws from
|
||||
// the response; the push is for their other sessions, as on every mutation below.
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
|
||||
@@ -2584,6 +2584,181 @@ describe('rooms endpoints', () => {
|
||||
expect(tagsIn(byCoOwner)).toContain('spooky')
|
||||
})
|
||||
|
||||
it('PUT /rooms/:id/tags sets the primary genre from primaryGenreTag', async () => {
|
||||
type TagResult = {
|
||||
success: boolean
|
||||
error: string
|
||||
value: { Tags?: Array<{ Tag: string; Type: number; IsPrimaryGenre?: boolean }> } | null
|
||||
}
|
||||
const tags = async (res: Response) => ((await res.json()) as TagResult).value?.Tags ?? []
|
||||
const genre = (list: Awaited<ReturnType<typeof tags>>) =>
|
||||
list.filter((t) => t.IsPrimaryGenre).map((t) => t.Tag)
|
||||
|
||||
// Same gates as the toggle body — the second shape doesn't open a second door.
|
||||
expect((await putForm('/rooms/4/tags', { primaryGenreTag: 'social' })).status).toBe(401)
|
||||
expect((await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '999')).status).toBe(403)
|
||||
// A blank value is a malformed post, not "unset the genre".
|
||||
expect(
|
||||
await (await putForm('/rooms/4/tags', { primaryGenreTag: ' ' }, '1')).json()
|
||||
).toMatchObject({ success: false, error: 'You must provide a tag!' })
|
||||
|
||||
// Room 4 is seeded with the auto-derived `rro` (Type 2); add an ordinary tag too, so
|
||||
// the genre can be seen not to disturb either of them.
|
||||
await putForm('/rooms/4/tags', { tag: 'puzzle' }, '1')
|
||||
|
||||
// The genre tag is ADDED when the room lacks it — a Type 0 tag, flagged.
|
||||
const set = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '1'))
|
||||
expect(set).toContainEqual({ Tag: 'social', Type: 0, IsPrimaryGenre: true })
|
||||
// …and the key is absent on the others rather than false.
|
||||
expect(set).toContainEqual({ Tag: 'puzzle', Type: 0 })
|
||||
|
||||
// Choosing another genre MOVES the flag. Unlike the old five-way radio it does not
|
||||
// remove the tag it displaced: `social` stays on the room, just no longer the genre.
|
||||
const moved = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'horror' }, '1'))
|
||||
expect(genre(moved)).toEqual(['horror'])
|
||||
expect(moved.map((t) => t.Tag).sort()).toEqual(['horror', 'puzzle', 'rro', 'social'])
|
||||
|
||||
// A tag the room already carries keeps its Type and simply becomes the genre — the
|
||||
// seeded `rro` is Type 2 and stays Type 2.
|
||||
const promoted = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'rro' }, '1'))
|
||||
expect(promoted).toContainEqual({ Tag: 'rro', Type: 2, IsPrimaryGenre: true })
|
||||
expect(genre(promoted)).toEqual(['rro'])
|
||||
|
||||
// Stored on the row, so a cold read says the same thing.
|
||||
expect(
|
||||
await env.DB.prepare(
|
||||
'SELECT tag, is_primary_genre FROM room_tag WHERE room_id = 4 AND is_primary_genre = 1'
|
||||
).all<{ tag: string; is_primary_genre: number }>()
|
||||
).toMatchObject({ results: [{ tag: 'rro', is_primary_genre: 1 }] })
|
||||
const read = (await (await SELF.fetch(`${ORIGIN}/rooms/4`)).json()) as {
|
||||
Tags: Array<{ Tag: string; IsPrimaryGenre?: boolean }>
|
||||
}
|
||||
expect(read.Tags.filter((t) => t.IsPrimaryGenre).map((t) => t.Tag)).toEqual(['rro'])
|
||||
|
||||
// The toggle body still works on the same room, and toggling the flagged tag off
|
||||
// takes the genre with it — the room's genre WAS that tag.
|
||||
const toggledOff = await tags(await putForm('/rooms/4/tags', { tag: 'rro' }, '1'))
|
||||
expect(toggledOff.map((t) => t.Tag)).not.toContain('rro')
|
||||
expect(genre(toggledOff)).toEqual([])
|
||||
// …while toggling an unrelated tag leaves a genre alone.
|
||||
await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '1')
|
||||
const other = await tags(await putForm('/rooms/4/tags', { tag: 'campfire' }, '1'))
|
||||
expect(genre(other)).toEqual(['social'])
|
||||
|
||||
// A `tag` alongside the genre is a whole-state save, NOT a toggle: `campfire` stays
|
||||
// rather than being toggled back off. (The set semantics themselves are next.)
|
||||
const both = await tags(
|
||||
await putForm('/rooms/4/tags', { tag: 'campfire', primaryGenreTag: 'puzzle' }, '1')
|
||||
)
|
||||
expect(genre(both)).toEqual(['puzzle'])
|
||||
expect(both.map((t) => t.Tag)).toContain('campfire')
|
||||
|
||||
// Put room 4 back the way it was seeded — its `rro` tag is what the rro feeds count.
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM room_tag WHERE room_id = 4'),
|
||||
env.DB.prepare(
|
||||
"INSERT INTO room_tag (room_id, tag, type, is_primary_genre) VALUES (4, 'rro', 2, 0)"
|
||||
),
|
||||
])
|
||||
})
|
||||
|
||||
it('PUT /rooms/:id/tags takes the whole-state save: repeated tag, autoTag, genre', async () => {
|
||||
type Tag = { Tag: string; Type: number; IsPrimaryGenre?: boolean }
|
||||
// A room of this test's own: the whole-state save REPLACES the user tags, and doing
|
||||
// that to a seeded room would strip tags the feeds above are asserted on.
|
||||
const ROOM = 9700
|
||||
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
RoomId: ROOM,
|
||||
Name: 'TagSaveRoom',
|
||||
CreatorAccountId: 1,
|
||||
IsDorm: false,
|
||||
Accessibility: 1,
|
||||
SubRooms: [],
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
const save = async (query: string, sub = '1') => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/${ROOM}/tags`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: query,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { success: boolean; value: { Tags?: Tag[] } | null }
|
||||
expect(body.success).toBe(true)
|
||||
return [...(body.value?.Tags ?? [])].sort((a, b) => a.Tag.localeCompare(b.Tag))
|
||||
}
|
||||
|
||||
// The form room settings posts. `tag` repeats — without `all: true` on the parse only
|
||||
// the last would arrive, and a three-tag save would land as one.
|
||||
const saved = await save(
|
||||
'autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay'
|
||||
)
|
||||
expect(saved).toEqual([
|
||||
{ Tag: 'limitsv2', Type: 1 },
|
||||
{ Tag: 'roleplay', Type: 0, IsPrimaryGenre: true },
|
||||
{ Tag: 'social', Type: 0 },
|
||||
{ Tag: 'sports', Type: 0 },
|
||||
])
|
||||
|
||||
// Idempotent — the same save twice is the same room. This is why a lone `tag` toggles
|
||||
// but a `tag` in company does not: toggling here would clear the room on every save.
|
||||
expect(
|
||||
await save('autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay')
|
||||
).toEqual(saved)
|
||||
|
||||
// `tag` is the COMPLETE user set: dropping one removes it. The auto tag is NOT the
|
||||
// client's to send here and survives a save that never mentions it.
|
||||
expect(await save('tag=roleplay&primaryGenreTag=roleplay')).toEqual([
|
||||
{ Tag: 'limitsv2', Type: 1 },
|
||||
{ Tag: 'roleplay', Type: 0, IsPrimaryGenre: true },
|
||||
])
|
||||
|
||||
// autoTag is additive and repeatable, and re-categorises a tag the room already has
|
||||
// rather than duplicating it — `tag` is the table's key, so there is one row per name.
|
||||
expect(await save('autoTag=beta&autoTag=roleplay')).toEqual([
|
||||
{ Tag: 'beta', Type: 1 },
|
||||
{ Tag: 'limitsv2', Type: 1 },
|
||||
// Was a Type 0 user tag; posting it as an auto tag moves its category, and the
|
||||
// genre flag rides along with the row.
|
||||
{ Tag: 'roleplay', Type: 1, IsPrimaryGenre: true },
|
||||
])
|
||||
|
||||
// An autoTag alone is a valid request — it names no `tag`, and must not be refused
|
||||
// for it.
|
||||
const bare = await save('autoTag=limitsv2')
|
||||
expect(bare.map((t) => t.Tag)).toContain('limitsv2')
|
||||
|
||||
// A save that names no user tags at all clears them, leaving the derived ones.
|
||||
expect((await save('tag=&autoTag=limitsv2')).map((t) => t.Tag)).toEqual([
|
||||
'beta',
|
||||
'limitsv2',
|
||||
'roleplay',
|
||||
])
|
||||
|
||||
// Same gates as every other body.
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(`${ORIGIN}/rooms/${ROOM}/tags`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'autoTag=limitsv2',
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(ROOM),
|
||||
env.DB.prepare('DELETE FROM room WHERE room_id = ?1').bind(ROOM),
|
||||
])
|
||||
})
|
||||
|
||||
// Tags live in `room_tag`, not in the room blob (migration 0013). These pin the
|
||||
// invariant that makes that safe: the table is the only copy, and the DTO is rebuilt
|
||||
// from it on read.
|
||||
|
||||
Reference in New Issue
Block a user