Chat: rooms and membership
The check everything shares
Section titled “The check everything shares”Every room route asks the same question, so it is written once:
import { Forbidden } from '@theoven/core'import { and, eq } from 'drizzle-orm'import { members } from './schema'
/** * Refuses anyone who is not in the room, and returns their membership when they are. * * `Forbidden`, not `NotFound`: they are authenticated, so hiding the room's existence buys * nothing — and a 403 tells them to ask for an invite rather than to check their spelling. */export async function requireMember(db, roomId: string, userId: string) { const [membership] = await db .select() .from(members) .where(and(eq(members.roomId, roomId), eq(members.userId, userId)))
if (!membership) throw new Forbidden('You are not in this room.') return membership}Verified against the real table:
member : okoutsider: You are not in this room.Creating a room
Section titled “Creating a room”import { z } from 'zod'import { transaction } from '@theoven/db'import { members, rooms } from '../../schema'
export const auth = trueexport const body = z.object({ name: z.string().min(1).max(80) })
export default async ({ body, db, user }) => { const id = crypto.randomUUID()
// Both rows or neither: a room whose creator is not a member is a room nobody can enter, // including the person who just made it. await transaction(db, async (tx) => { await tx.insert(rooms).values({ id, name: body.name, createdBy: user.id }) await tx.insert(members).values({ roomId: id, userId: user.id, role: 'owner' }) })
return { id, name: body.name }}Joining
Section titled “Joining”import { eq } from 'drizzle-orm'import { NotFound } from '@theoven/core'import { z } from 'zod'import { members, rooms } from '../../../schema'
export const auth = trueexport const params = z.object({ id: z.uuid() })
export default async ({ db, params, user }) => { const [room] = await db.select().from(rooms).where(eq(rooms.id, params.id)) if (!room) throw new NotFound('No such room.')
// Joining twice is the same as joining once — the composite primary key says so, and // `onConflictDoNothing` is how you ask the database rather than checking first and racing. await db .insert(members) .values({ roomId: params.id, userId: user.id }) .onConflictDoNothing()
return { joined: true }}The double-click case is handled by the schema. A SELECT then INSERT would have a window
between them, and two fast clicks would produce a constraint error the user did nothing to deserve.
Listing the rooms someone is in
Section titled “Listing the rooms someone is in”import { eq } from 'drizzle-orm'import { members, rooms } from '../../schema'
export const auth = true
export default async ({ db, user }) => db .select({ id: rooms.id, name: rooms.name, role: members.role }) .from(members) .innerJoin(rooms, eq(rooms.id, members.roomId)) .where(eq(members.userId, user.id))Driven from members, not rooms — the answer is “rooms this person is in”, and starting from the
table that knows that avoids filtering a growing table by a subquery.
Leaving
Section titled “Leaving”export const auth = trueexport const params = z.object({ id: z.uuid() })
export default async ({ db, params, user }) => { const membership = await requireMember(db, params.id, user.id)
if (membership.role === 'owner') { const [{ owners }] = await db .select({ owners: count() }) .from(members) .where(and(eq(members.roomId, params.id), eq(members.role, 'owner')))
if (owners === 1) throw new Conflict('Promote another owner before leaving.') }
await db.delete(members).where(and(eq(members.roomId, params.id), eq(members.userId, user.id))) return { left: true }}The owner check is the kind of thing that is obvious in hindsight and absent from most first drafts: without it, the last owner leaves and the room can never be administered again.