mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
room delete endpoint :(
This commit is contained in:
@@ -16,6 +16,10 @@ export type Env = SharedHonoEnv & {
|
|||||||
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||||
// push RoomUpdate notifications when a room is mutated.
|
// push RoomUpdate notifications when a room is mutated.
|
||||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||||
|
// Shared `recflare-cdn` R2 bucket (the `cdn`/`storage` workers own it). Room
|
||||||
|
// images/files live here under the `room/` key prefix; bound so deleting a room
|
||||||
|
// can remove its image object.
|
||||||
|
CDN_ASSETS: R2Bucket
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
canManageRoom,
|
canManageRoom,
|
||||||
cloneRoom,
|
cloneRoom,
|
||||||
cloneSubRoom,
|
cloneSubRoom,
|
||||||
|
deleteRoom,
|
||||||
findSubRoom,
|
findSubRoom,
|
||||||
getBaseRooms,
|
getBaseRooms,
|
||||||
getFavoritedRooms,
|
getFavoritedRooms,
|
||||||
@@ -582,6 +583,45 @@ const app = new Hono<App>()
|
|||||||
return roomResult(c, { Success: true })
|
return roomResult(c, { Success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Delete a room. Auth-gated (401) and owner-only (the room's CreatorAccountId).
|
||||||
|
// Removes the room record (and per-player interactions with it) and the room's
|
||||||
|
// image object from the shared CDN bucket. Images players *took* in the room are
|
||||||
|
// left alone — they live in the api/img world and outlast the room.
|
||||||
|
.delete('/rooms/:roomId{[0-9]+}', async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) {
|
||||||
|
return roomResult(c, {
|
||||||
|
Success: false,
|
||||||
|
ErrorId: 'Rooms.DoesntExist',
|
||||||
|
Error: 'This room does not exist!',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (room.CreatorAccountId !== accountId) {
|
||||||
|
return roomResult(c, {
|
||||||
|
Success: false,
|
||||||
|
ErrorId: 'Rooms.NotOwner',
|
||||||
|
Error: 'You are not the owner of this room!',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteRoom(c.env.DB, roomId)
|
||||||
|
|
||||||
|
// Remove the room image from the CDN bucket. The stored ImageName is the
|
||||||
|
// un-prefixed key the `cdn` worker serves back under `room/` (see storage
|
||||||
|
// upload + the `GET /room/:dataBlob` route), so the object key is `room/<name>`.
|
||||||
|
// R2 deletes are idempotent, so a canonical/static or already-gone image is fine.
|
||||||
|
const imageName = typeof room.ImageName === 'string' ? room.ImageName : ''
|
||||||
|
if (imageName !== '') {
|
||||||
|
await c.env.CDN_ASSETS.delete(`room/${imageName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return roomResult(c, { Success: true })
|
||||||
|
})
|
||||||
|
|
||||||
// Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to
|
// Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to
|
||||||
// the room creator or a co-owner (403 otherwise) — the same owner/co-owner check
|
// the room creator or a co-owner (403 otherwise) — the same owner/co-owner check
|
||||||
// the other room-admin actions use. Body is the `role` form field (an integer role
|
// the other room-admin actions use. Body is the `role` form field (an integer role
|
||||||
|
|||||||
@@ -582,6 +582,51 @@ describe('rooms endpoints', () => {
|
|||||||
expect(room.ImageName).toBe(imageName)
|
expect(room.ImageName).toBe(imageName)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('DELETE /rooms/:id is auth-gated, owner-only, and removes the room + its CDN image', async () => {
|
||||||
|
// Throwaway room owned by account 1, with its image object in the CDN bucket and
|
||||||
|
// a player interaction row.
|
||||||
|
const ImageName = 'test/2026-07-17/delete-me.jpg'
|
||||||
|
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
RoomId: 9500,
|
||||||
|
Name: 'DeleteMe',
|
||||||
|
CreatorAccountId: 1,
|
||||||
|
IsDorm: false,
|
||||||
|
Accessibility: 1,
|
||||||
|
ImageName,
|
||||||
|
SubRooms: [],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
await env.CDN_ASSETS.put(`room/${ImageName}`, new Uint8Array([1, 2, 3]))
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT INTO interaction (player_id, room_id, cheered, favorited) VALUES (7, 9500, 1, 1)'
|
||||||
|
).run()
|
||||||
|
|
||||||
|
const del = async (sub?: string) =>
|
||||||
|
SELF.fetch(`${ORIGIN}/rooms/9500`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: sub ? await bearer(sub) : {},
|
||||||
|
})
|
||||||
|
const roomExists = async () =>
|
||||||
|
(await env.DB.prepare('SELECT 1 FROM room WHERE room_id = 9500').first()) !== null
|
||||||
|
|
||||||
|
// No token → 401. A non-owner → Success:false (room untouched).
|
||||||
|
expect((await del()).status).toBe(401)
|
||||||
|
expect(await bodyOf(await del('2'))).toMatchObject({ Success: false, ErrorId: 'Rooms.NotOwner' })
|
||||||
|
expect(await roomExists()).toBe(true)
|
||||||
|
|
||||||
|
// Owner → Success:true; the room, its interactions, and the CDN image are gone.
|
||||||
|
expect(await bodyOf(await del('1'))).toMatchObject({ Success: true })
|
||||||
|
expect(await roomExists()).toBe(false)
|
||||||
|
expect(await env.CDN_ASSETS.get(`room/${ImageName}`)).toBeNull()
|
||||||
|
const interactions = await env.DB.prepare(
|
||||||
|
'SELECT COUNT(*) AS n FROM interaction WHERE room_id = 9500'
|
||||||
|
).first<{ n: number }>()
|
||||||
|
expect(interactions!.n).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('PUT /rooms/:id/roles/:accountId is auth-gated, owner/co-owner-only, and persists', async () => {
|
it('PUT /rooms/:id/roles/:accountId is auth-gated, owner/co-owner-only, and persists', async () => {
|
||||||
const rolesOf = async (): Promise<Array<{ AccountId: number; Role: number }>> => {
|
const rolesOf = async (): Promise<Array<{ AccountId: number; Role: number }>> => {
|
||||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
||||||
|
|||||||
@@ -19,6 +19,15 @@
|
|||||||
"migrations_dir": "migrations"
|
"migrations_dir": "migrations"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
// Shared `recflare-cdn` R2 bucket (also bound by the `cdn`/`storage` workers).
|
||||||
|
// Room images/files live here under `room/`; bound so deleting a room removes its
|
||||||
|
// image object. bucket_name is a literal, so nothing is spliced at deploy time.
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "CDN_ASSETS",
|
||||||
|
"bucket_name": "recflare-cdn"
|
||||||
|
}
|
||||||
|
],
|
||||||
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
||||||
// the `notify` worker). We only invoke its RPC methods; no migration here.
|
// the `notify` worker). We only invoke its RPC methods; no migration here.
|
||||||
"durable_objects": {
|
"durable_objects": {
|
||||||
|
|||||||
@@ -400,6 +400,20 @@ export async function getRoomById(db: D1Database, roomId: number): Promise<Room
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a room and every player's interaction (cheer/favorite/visit) with it, in one
|
||||||
|
* batch. Deliberately leaves transient `room_instance`/`presence` rows (they expire on
|
||||||
|
* their own) and any images taken in the room (those live in the api/img world and
|
||||||
|
* outlast the room). Authorization and removing the room image from the CDN bucket are
|
||||||
|
* the caller's responsibility (see the DELETE /rooms/:id route).
|
||||||
|
*/
|
||||||
|
export async function deleteRoom(db: D1Database, roomId: number): Promise<void> {
|
||||||
|
await db.batch([
|
||||||
|
db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
|
||||||
|
db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
/** Look up a single room by name (case-insensitive exact match). */
|
/** Look up a single room by name (case-insensitive exact match). */
|
||||||
export async function getRoomByName(db: D1Database, name: string): Promise<Room | null> {
|
export async function getRoomByName(db: D1Database, name: string): Promise<Room | null> {
|
||||||
return parseOne(
|
return parseOne(
|
||||||
|
|||||||
Reference in New Issue
Block a user