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:
@@ -200,8 +200,9 @@ const CuratedListFields = {
|
|||||||
Description: z.string().nullable(),
|
Description: z.string().nullable(),
|
||||||
ImageName: z
|
ImageName: z
|
||||||
.string()
|
.string()
|
||||||
|
.nullable()
|
||||||
.describe(
|
.describe(
|
||||||
'Must be a STRING — the client reads it straight into a string field. `DefaultRoomImage.jpg` where nothing set one; empty or null renders a blank tile.'
|
'A STRING on any list the client draws a tile for — it reads this straight into a string field, and empty or null renders that tile blank. `DefaultRoomImage.jpg` where nothing set one. Null only on a list with no tile to draw, like the `RoomGenreTags` capture, whose items are genre names rather than rooms.'
|
||||||
),
|
),
|
||||||
Type: z.int().describe('The `ListEntityType` — what the `ItemIds` are'),
|
Type: z.int().describe('The `ListEntityType` — what the `ItemIds` are'),
|
||||||
ItemIds: z
|
ItemIds: z
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
|
import curatedLists from '../../../static/curated-lists.json'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
declare module 'cloudflare:test' {
|
declare module 'cloudflare:test' {
|
||||||
@@ -240,40 +242,54 @@ it('serves one curated list object, not a collection', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('serves every capture in static/curated-lists.json by name', async () => {
|
it('serves every capture in static/curated-lists.json by name', async () => {
|
||||||
// One array holds every list, and each entry must be reachable by the keys the client
|
// Driven by the file itself, so a capture added to it is a capture this covers: each
|
||||||
// asks with. `ImageName` must be a string even when empty — the client parses it into
|
// entry must be reachable by the three keys the client asks with, and come back as
|
||||||
// one — while `Description` may be null. The ids the client caches against have to be
|
// itself. The ids the client caches against have to be unique and reach it with their
|
||||||
// unique and reach it with their digits intact.
|
// digits intact.
|
||||||
const names = [
|
expect(curatedLists.length).toBeGreaterThan(0)
|
||||||
'Discovery.PageSource.PlayExplore',
|
|
||||||
'Discovery.PageSource.PlayLibrary',
|
|
||||||
'RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky',
|
|
||||||
]
|
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (const name of names) {
|
for (const capture of curatedLists) {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=${name}`)
|
const res = await SELF.fetch(
|
||||||
|
`${ORIGIN}/curatedlists?creatorAccountId=${capture.CreatorAccountId}` +
|
||||||
|
`&type=${capture.Type}&name=${capture.Name}`
|
||||||
|
)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = await res.text()
|
const body = await res.text()
|
||||||
// Never a quoted id: the client's field is a number.
|
// Never a quoted id: the client's field is a number.
|
||||||
expect(body).toMatch(/"ListId":\d+,/)
|
expect(body).toMatch(/"ListId":\d+,/)
|
||||||
const list = JSON.parse(body) as {
|
const { ListId: _id, ...rest } = JSON.parse(body) as Record<string, unknown>
|
||||||
ListId: number
|
const { ListId: _captured, ...expected } = capture as Record<string, unknown>
|
||||||
Type: number
|
expect(rest).toEqual(expected)
|
||||||
Name: string
|
expect((capture.ItemIds as string[]).length).toBeGreaterThan(0)
|
||||||
ItemIds: string[]
|
|
||||||
Description: string | null
|
|
||||||
ImageName: string
|
|
||||||
}
|
|
||||||
expect(list.Name).toBe(name)
|
|
||||||
expect(list.Type).toBe(7)
|
|
||||||
expect(list.ItemIds.length).toBeGreaterThan(0)
|
|
||||||
expect(typeof list.ImageName).toBe('string')
|
|
||||||
const id = /"ListId":(\d+),/.exec(body)?.[1]
|
const id = /"ListId":(\d+),/.exec(body)?.[1]
|
||||||
|
expect(id).toBe(capture.ListId)
|
||||||
expect(seen.has(id!)).toBe(false)
|
expect(seen.has(id!)).toBe(false)
|
||||||
seen.add(id!)
|
seen.add(id!)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('serves the RoomGenreTags capture with its tag names', async () => {
|
||||||
|
// Genre NAMES, not room or section ids — and the one capture with a null `ImageName`,
|
||||||
|
// since the client draws no tile for it. Served under type 5, where the other captures
|
||||||
|
// are type 7.
|
||||||
|
const res = await SELF.fetch(
|
||||||
|
`${ORIGIN}/curatedlists?creatorAccountId=1&type=5&name=RoomGenreTags`
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.text()
|
||||||
|
expect(body).toContain('"ListId":1')
|
||||||
|
expect(JSON.parse(body)).toEqual({
|
||||||
|
ListId: 1,
|
||||||
|
CreatorAccountId: 1,
|
||||||
|
Name: 'RoomGenreTags',
|
||||||
|
Description: '',
|
||||||
|
ImageName: null,
|
||||||
|
Type: 5,
|
||||||
|
ItemIds: ['quest', 'battle', 'roleplay', 'horror', 'hangout', 'casual', 'explore'],
|
||||||
|
CreatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('matches the name case-insensitively and prefers it over the type', async () => {
|
it('matches the name case-insensitively and prefers it over the type', async () => {
|
||||||
const canonical = await (
|
const canonical = await (
|
||||||
await SELF.fetch(
|
await SELF.fetch(
|
||||||
|
|||||||
@@ -54,5 +54,15 @@
|
|||||||
],
|
],
|
||||||
"Accessibility": 1,
|
"Accessibility": 1,
|
||||||
"CreatedAt": "2024-05-22T05:37:43.7726633Z"
|
"CreatedAt": "2024-05-22T05:37:43.7726633Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ListId": "1",
|
||||||
|
"CreatorAccountId": 1,
|
||||||
|
"Name": "RoomGenreTags",
|
||||||
|
"Description": "",
|
||||||
|
"ImageName": null,
|
||||||
|
"Type": 5,
|
||||||
|
"ItemIds": ["quest", "battle", "roleplay", "horror", "hangout", "casual", "explore"],
|
||||||
|
"CreatedAt": "2026-01-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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(),
|
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({
|
export const RoomTagDto = z.object({
|
||||||
Tag: z.string(),
|
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'),
|
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({
|
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`. */
|
/** `PUT /rooms/{roomId}/image`. */
|
||||||
|
|||||||
+70
-18
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Accessibility,
|
Accessibility,
|
||||||
|
applyRoomTagEdit,
|
||||||
areFriends,
|
areFriends,
|
||||||
autocompleteRoomSearch,
|
autocompleteRoomSearch,
|
||||||
banPlayerFromRoom,
|
banPlayerFromRoom,
|
||||||
@@ -51,7 +52,6 @@ import {
|
|||||||
setSubRoomPermissions,
|
setSubRoomPermissions,
|
||||||
toggleCheer,
|
toggleCheer,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
toggleRoomTag,
|
|
||||||
unbanPlayerFromRoom,
|
unbanPlayerFromRoom,
|
||||||
updateRoomFields,
|
updateRoomFields,
|
||||||
} from '@repo/domain'
|
} 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
|
// Change a room's tags. Auth-gated (401) and owner/co-owner-only (403). Returns the
|
||||||
// the `tag` form field. There's no delete/patch endpoint, so this call toggles: it
|
// `{ success, error, value }` envelope with the updated room as `value`; business
|
||||||
// adds the tag (Type 0) if absent and removes it if present. The "main" tags
|
// failures are 200 with success:false.
|
||||||
// (#pvp/#quest/#game/#hangout/#art) are radio buttons — setting one clears the
|
//
|
||||||
// others. Returns the `{ success, error, value }` envelope with the updated
|
// TWO BODIES reach this one path — Rec Room reshaped the request rather than minting a
|
||||||
// room as `value`; business failures are 200 with success:false.
|
// 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(
|
.put(
|
||||||
'/rooms/:roomId{[0-9]+}/tags',
|
'/rooms/:roomId{[0-9]+}/tags',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Room settings'],
|
tags: ['Room settings'],
|
||||||
summary: 'Toggle a tag on a room',
|
summary: 'Toggle a tag on a room',
|
||||||
description: [
|
description: [
|
||||||
'Owner or co-owner only (403 otherwise). There is no delete/patch counterpart, so',
|
'Owner or co-owner only (403 otherwise). Two bodies reach this one path, and the',
|
||||||
'this call TOGGLES: it adds the tag (Type 0) when absent and removes it when',
|
'FIELDS say which — not the URL.',
|
||||||
'present. The “main” tags (`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio',
|
'',
|
||||||
'buttons — setting one clears the others. Answers the lowercase envelope with the',
|
'**A lone `tag=<name>` TOGGLES** (the 2023 form): there is no delete/patch',
|
||||||
'updated room, which the client re-renders from, and pushes a `RoomUpdate` to the',
|
'counterpart, so the same call adds the tag (Type 0) when absent and removes it when',
|
||||||
'owner for their other sessions.',
|
'present, and the “main” tags (`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio',
|
||||||
].join(' '),
|
'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,
|
security: AUTHED,
|
||||||
parameters: [roomIdParam],
|
parameters: [roomIdParam],
|
||||||
requestBody: form(TagRequest, 'The tag to toggle'),
|
requestBody: form(TagRequest, 'The tag to toggle'),
|
||||||
@@ -1726,11 +1755,34 @@ const app = new Hono<App>()
|
|||||||
// already returned 401 for a missing/invalid token).
|
// already returned 401 for a missing/invalid token).
|
||||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
// `all: true` because `tag` REPEATS on the whole-state save; without it Hono keeps
|
||||||
const tag = typeof body.tag === 'string' ? body.tag.trim() : ''
|
// only the last value and a three-tag save would land as one tag.
|
||||||
if (tag === '') return roomEnvelope(c, null, 'You must provide a 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
|
// 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.
|
// the response; the push is for their other sessions, as on every mutation below.
|
||||||
await pushRoomUpdate(c, accountId, updated)
|
await pushRoomUpdate(c, accountId, updated)
|
||||||
|
|||||||
@@ -2584,6 +2584,181 @@ describe('rooms endpoints', () => {
|
|||||||
expect(tagsIn(byCoOwner)).toContain('spooky')
|
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
|
// 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
|
// invariant that makes that safe: the table is the only copy, and the DTO is rebuilt
|
||||||
// from it on read.
|
// from it on read.
|
||||||
|
|||||||
@@ -63,10 +63,15 @@ export const CURATED_LIST_SCHEMA_DDL: string[] = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One curated list as the client parses it, whether it came out of D1 or out of a static
|
* One curated list as the client parses it, whether it came out of D1 or out of a static
|
||||||
* capture. `Description` may be null but `ImageName` must be a STRING — the client reads it
|
* capture. `Description` may be null, and `ItemIds` are strings even where they stand for
|
||||||
* straight into a string field — and `ItemIds` are strings even where they stand for
|
|
||||||
* numeric ids, which is what the working captures carry.
|
* numeric ids, which is what the working captures carry.
|
||||||
*
|
*
|
||||||
|
* `ImageName` is a STRING on every list the client draws a TILE for — it reads it straight
|
||||||
|
* into a string field, and empty or null renders that tile blank. It is nullable only
|
||||||
|
* because one capture (`RoomGenreTags`, whose `ItemIds` are genre names rather than rooms)
|
||||||
|
* is served with a null, having no tile to draw. A stored list always has a string; don't
|
||||||
|
* reach for the null on anything the client renders as a row.
|
||||||
|
*
|
||||||
* `ListId` is a string HERE ONLY and never reaches the client as one: the `lists` worker's
|
* `ListId` is a string HERE ONLY and never reaches the client as one: the `lists` worker's
|
||||||
* `serializeCuratedList` puts the digits back on the wire unquoted, because the client's
|
* `serializeCuratedList` puts the digits back on the wire unquoted, because the client's
|
||||||
* field is a number and a quoted id fails its parser. It stays a string even though a
|
* field is a number and a quoted id fails its parser. It stays a string even though a
|
||||||
@@ -78,7 +83,7 @@ export interface CuratedList {
|
|||||||
CreatorAccountId: number
|
CreatorAccountId: number
|
||||||
Name: string
|
Name: string
|
||||||
Description: string | null
|
Description: string | null
|
||||||
ImageName: string
|
ImageName: string | null
|
||||||
Type: number
|
Type: number
|
||||||
ItemIds: string[]
|
ItemIds: string[]
|
||||||
Accessibility?: number
|
Accessibility?: number
|
||||||
|
|||||||
+158
-34
@@ -63,10 +63,16 @@ export const ROOM_SCHEMA_DDL: string[] = [
|
|||||||
// feed (a discovery category row, a `#tag` search) select in SQL instead of parsing
|
// feed (a discovery category row, a `#tag` search) select in SQL instead of parsing
|
||||||
// every room blob to ask. `type` is the client's tag-category int — 0 user, 2 the
|
// every room blob to ask. `type` is the client's tag-category int — 0 user, 2 the
|
||||||
// auto-derived ones like `rro` — echoed back as stored.
|
// auto-derived ones like `rro` — echoed back as stored.
|
||||||
|
//
|
||||||
|
// `is_primary_genre` (migrations/0015_room_tag_primary_genre.sql) flags the ONE tag
|
||||||
|
// that is the room's genre, which the 2025 client sets with `primaryGenreTag=` and
|
||||||
|
// draws differently from the rest. It is orthogonal to `type`: the flagged tag is
|
||||||
|
// still an ordinary Type 0 user tag, and a room carries other tags alongside it.
|
||||||
`CREATE TABLE IF NOT EXISTS room_tag (
|
`CREATE TABLE IF NOT EXISTS room_tag (
|
||||||
room_id INTEGER NOT NULL,
|
room_id INTEGER NOT NULL,
|
||||||
tag TEXT NOT NULL,
|
tag TEXT NOT NULL,
|
||||||
type INTEGER NOT NULL DEFAULT 0,
|
type INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_primary_genre INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (room_id, tag)
|
PRIMARY KEY (room_id, tag)
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_room_tag_tag ON room_tag (tag)`,
|
`CREATE INDEX IF NOT EXISTS idx_room_tag_tag ON room_tag (tag)`,
|
||||||
@@ -451,47 +457,150 @@ export async function setRoomRole(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutually-exclusive "main" room tags. The UI presents these as radio buttons, so
|
* Mutually-exclusive "main" room tags. The 2023 UI presents these as radio buttons, so
|
||||||
* setting one clears any other main tag. Compared case-insensitively.
|
* toggling one on clears any other main tag. Compared case-insensitively.
|
||||||
|
*
|
||||||
|
* Only the TOGGLE body obeys this — the newer whole-set body says outright which tags the
|
||||||
|
* room has, and its genre is the `IsPrimaryGenre` flag rather than membership of this set.
|
||||||
*/
|
*/
|
||||||
const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art'])
|
const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art'])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a user tag (`Type: 0`) to a room's tags, or remove it when it's already there
|
* A tag's `Type` — the client's tag CATEGORY, echoed back as stored.
|
||||||
* (case-insensitive). The caller supplies the already-loaded room (owner-checked) to avoid
|
|
||||||
* a re-read. Returns the updated room.
|
|
||||||
*
|
*
|
||||||
* Only `room_tag` is written — the room blob no longer carries tags at all, so the room row
|
* `user` is what a player types or picks. `auto` is what the client derives about the room
|
||||||
* is left alone. The whole resulting set is written rather than a single insert/delete, so
|
* and posts as `autoTag` (`limitsv2`, `beta`). `derived` is this server's own (`rro`).
|
||||||
* the radio-button behaviour below stays one atomic batch.
|
* A tag's category is orthogonal to whether it is the room's primary genre.
|
||||||
*/
|
*/
|
||||||
export async function toggleRoomTag(
|
export const RoomTagType = {
|
||||||
|
user: 0,
|
||||||
|
auto: 1,
|
||||||
|
derived: 2,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tag changes ONE `PUT /rooms/{id}/tags` request asks for. Every field is optional and
|
||||||
|
* they compose: a single request may replace the user tags, add a derived one and move the
|
||||||
|
* genre, and it is applied as one write.
|
||||||
|
*/
|
||||||
|
export interface RoomTagEdit {
|
||||||
|
/**
|
||||||
|
* The 2023 single-tag TOGGLE: the tag is added when the room lacks it and removed when
|
||||||
|
* it has it, and adding one of {@link MAIN_TAGS} clears the others.
|
||||||
|
*/
|
||||||
|
toggle?: string
|
||||||
|
/**
|
||||||
|
* The whole set of USER tags, replacing every `Type: 0` tag the room carries. The
|
||||||
|
* derived tags (`auto`, `derived`) are not the client's to send and are left alone.
|
||||||
|
*/
|
||||||
|
tags?: string[]
|
||||||
|
/**
|
||||||
|
* Tags to ensure present at `Type: 1`. Additive — nothing here removes an auto tag,
|
||||||
|
* since the client posts the ones it wants rather than the full set. A tag already on
|
||||||
|
* the room is re-categorised rather than duplicated.
|
||||||
|
*/
|
||||||
|
autoTags?: string[]
|
||||||
|
/**
|
||||||
|
* The tag to flag as the room's genre. Added (as a user tag) when the room lacks it;
|
||||||
|
* every other tag keeps its place and loses the flag.
|
||||||
|
*/
|
||||||
|
primaryGenre?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tag's name, lowercased — every comparison in here is case-insensitive. */
|
||||||
|
const tagKey = (t: RoomTag): string => String(t?.Tag).toLowerCase()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one request's worth of tag changes to a room and store the result. The caller
|
||||||
|
* supplies the already-loaded (owner-checked) room, so nothing is re-read.
|
||||||
|
*
|
||||||
|
* The changes are composed into ONE set and written once: a request naming tags, an auto
|
||||||
|
* tag and a genre is a single state for the room, and applying it in three writes would
|
||||||
|
* let a reader (or a failure) land between them.
|
||||||
|
*
|
||||||
|
* Only `room_tag` is written — the room blob carries no tags at all, so the room row is
|
||||||
|
* left alone.
|
||||||
|
*/
|
||||||
|
export async function applyRoomTagEdit(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
roomId: number,
|
roomId: number,
|
||||||
room: Room,
|
room: Room,
|
||||||
tag: string
|
edit: RoomTagEdit
|
||||||
): Promise<Room> {
|
): Promise<Room> {
|
||||||
const tags = Array.isArray(room.Tags) ? (room.Tags as RoomTag[]) : []
|
const current = Array.isArray(room.Tags) ? (room.Tags as RoomTag[]) : []
|
||||||
const lower = tag.toLowerCase()
|
// Copies throughout: the room handed in is answered to the client, and the steps below
|
||||||
const tagLower = (t: RoomTag): string => String(t?.Tag).toLowerCase()
|
// mutate what they build.
|
||||||
const existing = tags.findIndex((t) => tagLower(t) === lower)
|
let next: RoomTag[] = current.map((t) => ({ ...t }))
|
||||||
|
|
||||||
// The client has no delete/patch endpoint — the same call toggles a tag: remove
|
if (edit.tags !== undefined) {
|
||||||
// it if already present, add it otherwise. Adding a main tag is a radio pick, so
|
// A SET, not a merge. The posted list is exactly the room's user tags afterwards; a
|
||||||
// it also clears any other main tag already set.
|
// tag already there keeps its row (and its genre flag, until the genre step below
|
||||||
let nextTags: RoomTag[]
|
// says otherwise) rather than being deleted and re-added.
|
||||||
|
const posted = new Set(edit.tags.map((t) => t.toLowerCase()))
|
||||||
|
next = [
|
||||||
|
...next.filter((t) => t.Type !== RoomTagType.user && !posted.has(tagKey(t))),
|
||||||
|
...edit.tags.map(
|
||||||
|
(tag) =>
|
||||||
|
next.find((t) => tagKey(t) === tag.toLowerCase()) ?? { Tag: tag, Type: RoomTagType.user }
|
||||||
|
),
|
||||||
|
]
|
||||||
|
} else if (edit.toggle !== undefined) {
|
||||||
|
// The 2023 client has no delete/patch endpoint, so the same call toggles: remove the
|
||||||
|
// tag if present, add it otherwise. Adding a main tag is a radio pick, so it also
|
||||||
|
// clears any other main tag. Removing the flagged tag takes the genre with it, which
|
||||||
|
// is right — the room's genre WAS that tag.
|
||||||
|
const lower = edit.toggle.toLowerCase()
|
||||||
|
const existing = next.findIndex((t) => tagKey(t) === lower)
|
||||||
if (existing !== -1) {
|
if (existing !== -1) {
|
||||||
nextTags = tags.filter((_, i) => i !== existing)
|
next = next.filter((_, i) => i !== existing)
|
||||||
} else if (MAIN_TAGS.has(lower)) {
|
|
||||||
nextTags = [...tags.filter((t) => !MAIN_TAGS.has(tagLower(t))), { Tag: tag, Type: 0 }]
|
|
||||||
} else {
|
} else {
|
||||||
nextTags = [...tags, { Tag: tag, Type: 0 }]
|
const kept = MAIN_TAGS.has(lower) ? next.filter((t) => !MAIN_TAGS.has(tagKey(t))) : next
|
||||||
|
next = [...kept, { Tag: edit.toggle, Type: RoomTagType.user }]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await setRoomTags(db, roomId, nextTags)
|
for (const auto of edit.autoTags ?? []) {
|
||||||
// Reflect what was just stored, lowercased the way the table holds it, so the caller
|
const existing = next.find((t) => tagKey(t) === auto.toLowerCase())
|
||||||
// answers the client with the tags a re-read would give it.
|
// A tag the room already carries is re-categorised in place rather than duplicated —
|
||||||
return { ...room, Tags: nextTags.map((t) => ({ Tag: t.Tag.toLowerCase(), Type: t.Type })) }
|
// `tag` is the table's key, so there is only ever one row per name anyway.
|
||||||
|
if (existing) existing.Type = RoomTagType.auto
|
||||||
|
else next.push({ Tag: auto, Type: RoomTagType.auto })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edit.primaryGenre !== undefined) {
|
||||||
|
const lower = edit.primaryGenre.toLowerCase()
|
||||||
|
for (const tag of next) delete tag.IsPrimaryGenre
|
||||||
|
const chosen = next.find((t) => tagKey(t) === lower)
|
||||||
|
// A tag the room already carries keeps its category and simply becomes the genre;
|
||||||
|
// one it doesn't is added as an ordinary user tag.
|
||||||
|
if (chosen) chosen.IsPrimaryGenre = true
|
||||||
|
else next.push({ Tag: edit.primaryGenre, Type: RoomTagType.user, IsPrimaryGenre: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return storeRoomTags(db, roomId, room, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a room's whole tag set and answer the room carrying it, lowercased the way the
|
||||||
|
* table holds it — so the caller replies with exactly what a re-read would give, without
|
||||||
|
* paying for the re-read. `IsPrimaryGenre` survives only where it was set, and stays
|
||||||
|
* absent (not false) everywhere else.
|
||||||
|
*/
|
||||||
|
async function storeRoomTags(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
room: Room,
|
||||||
|
tags: RoomTag[]
|
||||||
|
): Promise<Room> {
|
||||||
|
await setRoomTags(db, roomId, tags)
|
||||||
|
return {
|
||||||
|
...room,
|
||||||
|
Tags: tags.map((t) => {
|
||||||
|
const stored: RoomTag = { Tag: t.Tag.toLowerCase(), Type: t.Type }
|
||||||
|
if (t.IsPrimaryGenre) stored.IsPrimaryGenre = true
|
||||||
|
return stored
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find a subroom (by SubRoomId) inside an already-hydrated room's `SubRooms`, or undefined. */
|
/** Find a subroom (by SubRoomId) inside an already-hydrated room's `SubRooms`, or undefined. */
|
||||||
@@ -1136,16 +1245,31 @@ async function attachCurrentSaves(
|
|||||||
// place a tag is stored — and a tag lookup is an indexed query rather than a scan that
|
// place a tag is stored — and a tag lookup is an indexed query rather than a scan that
|
||||||
// parses every room to ask.
|
// parses every room to ask.
|
||||||
|
|
||||||
/** One of a room's tags, as the client's room DTO carries it. */
|
/**
|
||||||
|
* One of a room's tags, as the client's room DTO carries it.
|
||||||
|
*
|
||||||
|
* `IsPrimaryGenre` is PRESENT ONLY on the one tag that is the room's genre — the key is
|
||||||
|
* left off the others rather than sent as false, which is the shape the client sends and
|
||||||
|
* reads back. At most one tag in an array carries it; see {@link setPrimaryGenreTag}.
|
||||||
|
*/
|
||||||
export interface RoomTag {
|
export interface RoomTag {
|
||||||
Tag: string
|
Tag: string
|
||||||
Type: number
|
Type: number
|
||||||
|
IsPrimaryGenre?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoomTagRow {
|
interface RoomTagRow {
|
||||||
room_id: number
|
room_id: number
|
||||||
tag: string
|
tag: string
|
||||||
type: number
|
type: number
|
||||||
|
is_primary_genre: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a stored tag row, adding `IsPrimaryGenre` only when the row is flagged. */
|
||||||
|
function toRoomTag(row: RoomTagRow): RoomTag {
|
||||||
|
const tag: RoomTag = { Tag: row.tag, Type: row.type }
|
||||||
|
if (row.is_primary_genre) tag.IsPrimaryGenre = true
|
||||||
|
return tag
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Group tag rows by RoomId, preserving the order they arrived in (alphabetical by tag). */
|
/** Group tag rows by RoomId, preserving the order they arrived in (alphabetical by tag). */
|
||||||
@@ -1153,7 +1277,7 @@ function groupTags(rows: RoomTagRow[]): Map<number, RoomTag[]> {
|
|||||||
const byRoom = new Map<number, RoomTag[]>()
|
const byRoom = new Map<number, RoomTag[]>()
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const list = byRoom.get(row.room_id) ?? []
|
const list = byRoom.get(row.room_id) ?? []
|
||||||
list.push({ Tag: row.tag, Type: row.type })
|
list.push(toRoomTag(row))
|
||||||
byRoom.set(row.room_id, list)
|
byRoom.set(row.room_id, list)
|
||||||
}
|
}
|
||||||
return byRoom
|
return byRoom
|
||||||
@@ -1183,7 +1307,7 @@ async function tagsByRoom(db: D1Database, roomIds: number[]): Promise<Map<number
|
|||||||
|
|
||||||
if (ids.length > MAX_BOUND_PARAMS) {
|
if (ids.length > MAX_BOUND_PARAMS) {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare('SELECT room_id, tag, type FROM room_tag ORDER BY tag')
|
.prepare('SELECT room_id, tag, type, is_primary_genre FROM room_tag ORDER BY tag')
|
||||||
.all<RoomTagRow>()
|
.all<RoomTagRow>()
|
||||||
const wanted = new Set(ids)
|
const wanted = new Set(ids)
|
||||||
return groupTags(results.filter((row) => wanted.has(row.room_id)))
|
return groupTags(results.filter((row) => wanted.has(row.room_id)))
|
||||||
@@ -1192,7 +1316,7 @@ async function tagsByRoom(db: D1Database, roomIds: number[]): Promise<Map<number
|
|||||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
|
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT room_id, tag, type FROM room_tag
|
`SELECT room_id, tag, type, is_primary_genre FROM room_tag
|
||||||
WHERE room_id IN (${placeholders}) ORDER BY tag`
|
WHERE room_id IN (${placeholders}) ORDER BY tag`
|
||||||
)
|
)
|
||||||
.bind(...ids)
|
.bind(...ids)
|
||||||
@@ -1234,14 +1358,14 @@ async function parseAllWithTags(db: D1Database, rows: RoomRow[]): Promise<Room[]
|
|||||||
*/
|
*/
|
||||||
export async function setRoomTags(db: D1Database, roomId: number, tags: RoomTag[]): Promise<void> {
|
export async function setRoomTags(db: D1Database, roomId: number, tags: RoomTag[]): Promise<void> {
|
||||||
const statements = [db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId)]
|
const statements = [db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId)]
|
||||||
for (const { Tag, Type } of tags) {
|
for (const { Tag, Type, IsPrimaryGenre } of tags) {
|
||||||
statements.push(
|
statements.push(
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO room_tag (room_id, tag, type) VALUES (?1, ?2, ?3)
|
`INSERT INTO room_tag (room_id, tag, type, is_primary_genre) VALUES (?1, ?2, ?3, ?4)
|
||||||
ON CONFLICT (room_id, tag) DO UPDATE SET type = ?3`
|
ON CONFLICT (room_id, tag) DO UPDATE SET type = ?3, is_primary_genre = ?4`
|
||||||
)
|
)
|
||||||
.bind(roomId, String(Tag).toLowerCase(), Number(Type) || 0)
|
.bind(roomId, String(Tag).toLowerCase(), Number(Type) || 0, IsPrimaryGenre ? 1 : 0)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
await db.batch(statements)
|
await db.batch(statements)
|
||||||
|
|||||||
Reference in New Issue
Block a user