feat: add room access modes and ownership transfer

Add comprehensive access control system for arcade rooms with 6 modes:
- open: Anyone can join (default)
- locked: Only current members allowed
- retired: Room no longer functions
- password: Requires password to join
- restricted: Only users with pending invitations can join
- approval-only: Requires host approval via join request system

Database Changes:
- Add accessMode field to arcade_rooms (replaces isLocked boolean with enum)
- Add password field to arcade_rooms (hashed with bcrypt)
- Create room_join_requests table for approval-only mode

New API Endpoints:
- PATCH /api/arcade/rooms/:roomId/settings - Update room access mode and password (host only)
- POST /api/arcade/rooms/:roomId/transfer-ownership - Transfer ownership to another member (host only)
- POST /api/arcade/rooms/:roomId/join-request - Request to join approval-only room
- GET /api/arcade/rooms/:roomId/join-requests - Get pending join requests (host only)
- POST /api/arcade/rooms/:roomId/join-requests/:requestId/approve - Approve join request (host only)
- POST /api/arcade/rooms/:roomId/join-requests/:requestId/deny - Deny join request (host only)

Updated Endpoints:
- POST /api/arcade/rooms/:roomId/join - Now validates access modes before allowing join:
  * locked: Rejects all joins
  * retired: Rejects all joins (410 Gone)
  * password: Requires password validation
  * restricted: Requires valid pending invitation
  * approval-only: Requires approved join request
  * open: Allows anyone (existing behavior)

Libraries:
- Add room-join-requests.ts for managing join request lifecycle
- Ownership transfer updates room.createdBy and member.isCreator flags
- Socket.io events for join request notifications and ownership transfers

Migration: 0007_access_modes.sql

Next Steps (UI not included in this commit):
- RoomSettingsModal for configuring access mode and password
- Join request approval UI in ModerationPanel
- Ownership transfer UI in ModerationPanel
- Password input in join flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-13 19:16:47 -05:00
parent 21009f8a34
commit 6ff21c4f1d
17 changed files with 782 additions and 11 deletions

View File

@@ -43,7 +43,10 @@
"Bash(npx playwright test:*)",
"Bash(npm run:*)",
"Bash(\"\")",
"Bash(npx @biomejs/biome check:*)"
"Bash(npx @biomejs/biome check:*)",
"Bash(printf '\\n')",
"Bash(npm install bcryptjs)",
"Bash(npm install:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,18 @@
-- Add access control columns to arcade_rooms
ALTER TABLE `arcade_rooms` ADD `access_mode` text DEFAULT 'open' NOT NULL;--> statement-breakpoint
ALTER TABLE `arcade_rooms` ADD `password` text(255);--> statement-breakpoint
-- Create room_join_requests table for approval-only mode
CREATE TABLE `room_join_requests` (
`id` text PRIMARY KEY NOT NULL,
`room_id` text NOT NULL,
`user_id` text NOT NULL,
`user_name` text(50) NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`requested_at` integer NOT NULL,
`reviewed_at` integer,
`reviewed_by` text,
`reviewed_by_name` text(50),
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
CREATE UNIQUE INDEX `idx_room_join_requests_user_room` ON `room_join_requests` (`user_id`,`room_id`);

View File

@@ -50,6 +50,13 @@
"when": 1760365860888,
"tag": "0006_pretty_invaders",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1760527200000,
"tag": "0007_access_modes",
"breakpoints": true
}
]
}

View File

@@ -54,6 +54,7 @@
"@tanstack/react-form": "^0.19.0",
"@tanstack/react-query": "^5.90.2",
"@types/jsdom": "^21.1.7",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.4.1",
"drizzle-orm": "^0.44.6",
"emojibase-data": "^16.0.3",
@@ -77,6 +78,7 @@
"@storybook/nextjs": "^9.1.7",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",

View File

@@ -0,0 +1,101 @@
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { eq } from 'drizzle-orm'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { createJoinRequest, getJoinRequest } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/join-request
* Request to join an approval-only room
* Body:
* - userName: string
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields
if (!body.userName) {
return NextResponse.json({ error: 'Missing required field: userName' }, { status: 400 })
}
// Get room details
const [room] = await db
.select()
.from(schema.arcadeRooms)
.where(eq(schema.arcadeRooms.id, roomId))
.limit(1)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Check if room is approval-only
if (room.accessMode !== 'approval-only') {
return NextResponse.json(
{ error: 'This room does not require approval to join' },
{ status: 400 }
)
}
// Check if user is already in the room
const members = await getRoomMembers(roomId)
const existingMember = members.find((m) => m.userId === viewerId)
if (existingMember) {
return NextResponse.json({ error: 'You are already in this room' }, { status: 400 })
}
// Check if user already has a pending request
const existingRequest = await getJoinRequest(roomId, viewerId)
if (existingRequest && existingRequest.status === 'pending') {
return NextResponse.json(
{ error: 'You already have a pending join request' },
{ status: 400 }
)
}
// Create join request
const request = await createJoinRequest({
roomId,
userId: viewerId,
userName: body.userName,
})
// Broadcast to host via socket
const io = await getSocketIO()
if (io) {
try {
// Get host user ID
const host = members.find((m) => m.isCreator)
if (host) {
io.to(`user:${host.userId}`).emit('join-request-received', {
roomId,
request: {
id: request.id,
userId: request.userId,
userName: request.userName,
requestedAt: request.requestedAt,
},
})
}
console.log(`[Join Request API] User ${viewerId} requested to join room ${roomId}`)
} catch (socketError) {
console.error('[Join Request API] Failed to broadcast request:', socketError)
}
}
return NextResponse.json({ request }, { status: 200 })
} catch (error: any) {
console.error('Failed to create join request:', error)
return NextResponse.json({ error: 'Failed to create join request' }, { status: 500 })
}
}

View File

@@ -0,0 +1,78 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { approveJoinRequest } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string; requestId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/join-requests/:requestId/approve
* Approve a join request (host only)
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId, requestId } = await context.params
const viewerId = await getViewerId()
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
if (!currentMember) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!currentMember.isCreator) {
return NextResponse.json(
{ error: 'Only the host can approve join requests' },
{ status: 403 }
)
}
// Get the request
const [request] = await db
.select()
.from(schema.roomJoinRequests)
.where(eq(schema.roomJoinRequests.id, requestId))
.limit(1)
if (!request) {
return NextResponse.json({ error: 'Join request not found' }, { status: 404 })
}
if (request.status !== 'pending') {
return NextResponse.json({ error: 'Join request is not pending' }, { status: 400 })
}
// Approve the request
const approvedRequest = await approveJoinRequest(requestId, viewerId, currentMember.displayName)
// Notify the requesting user via socket
const io = await getSocketIO()
if (io) {
try {
io.to(`user:${request.userId}`).emit('join-request-approved', {
roomId,
requestId,
approvedBy: currentMember.displayName,
})
console.log(
`[Approve Join Request API] Request ${requestId} approved for user ${request.userId} to join room ${roomId}`
)
} catch (socketError) {
console.error('[Approve Join Request API] Failed to broadcast approval:', socketError)
}
}
return NextResponse.json({ request: approvedRequest }, { status: 200 })
} catch (error: any) {
console.error('Failed to approve join request:', error)
return NextResponse.json({ error: 'Failed to approve join request' }, { status: 500 })
}
}

View File

@@ -0,0 +1,75 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { denyJoinRequest } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string; requestId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/join-requests/:requestId/deny
* Deny a join request (host only)
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId, requestId } = await context.params
const viewerId = await getViewerId()
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
if (!currentMember) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!currentMember.isCreator) {
return NextResponse.json({ error: 'Only the host can deny join requests' }, { status: 403 })
}
// Get the request
const [request] = await db
.select()
.from(schema.roomJoinRequests)
.where(eq(schema.roomJoinRequests.id, requestId))
.limit(1)
if (!request) {
return NextResponse.json({ error: 'Join request not found' }, { status: 404 })
}
if (request.status !== 'pending') {
return NextResponse.json({ error: 'Join request is not pending' }, { status: 400 })
}
// Deny the request
const deniedRequest = await denyJoinRequest(requestId, viewerId, currentMember.displayName)
// Notify the requesting user via socket
const io = await getSocketIO()
if (io) {
try {
io.to(`user:${request.userId}`).emit('join-request-denied', {
roomId,
requestId,
deniedBy: currentMember.displayName,
})
console.log(
`[Deny Join Request API] Request ${requestId} denied for user ${request.userId} to join room ${roomId}`
)
} catch (socketError) {
console.error('[Deny Join Request API] Failed to broadcast denial:', socketError)
}
}
return NextResponse.json({ request: deniedRequest }, { status: 200 })
} catch (error: any) {
console.error('Failed to deny join request:', error)
return NextResponse.json({ error: 'Failed to deny join request' }, { status: 500 })
}
}

View File

@@ -0,0 +1,39 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getPendingJoinRequests } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* GET /api/arcade/rooms/:roomId/join-requests
* Get all pending join requests for a room (host only)
*/
export async function GET(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
if (!currentMember) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!currentMember.isCreator) {
return NextResponse.json({ error: 'Only the host can view join requests' }, { status: 403 })
}
// Get all pending requests
const requests = await getPendingJoinRequests(roomId)
return NextResponse.json({ requests }, { status: 200 })
} catch (error: any) {
console.error('Failed to get join requests:', error)
return NextResponse.json({ error: 'Failed to get join requests' }, { status: 500 })
}
}

View File

@@ -3,8 +3,11 @@ import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { isUserBanned } from '@/lib/arcade/room-moderation'
import { getInvitation } from '@/lib/arcade/room-invitations'
import { getJoinRequest } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
import bcrypt from 'bcryptjs'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -15,6 +18,7 @@ type RouteContext = {
* Join a room
* Body:
* - displayName?: string (optional, will generate from viewerId if not provided)
* - password?: string (required for password-protected rooms)
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
@@ -28,17 +32,67 @@ export async function POST(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Check if room is locked
if (room.isLocked) {
return NextResponse.json({ error: 'Room is locked' }, { status: 403 })
}
// Check if user is banned
const banned = await isUserBanned(roomId, viewerId)
if (banned) {
return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 })
}
// Validate access mode
switch (room.accessMode) {
case 'locked':
return NextResponse.json({ error: 'This room is locked' }, { status: 403 })
case 'retired':
return NextResponse.json({ error: 'This room has been retired' }, { status: 410 })
case 'password': {
if (!body.password) {
return NextResponse.json(
{ error: 'Password required to join this room' },
{ status: 401 }
)
}
if (!room.password) {
return NextResponse.json({ error: 'Room password not configured' }, { status: 500 })
}
const passwordMatch = await bcrypt.compare(body.password, room.password)
if (!passwordMatch) {
return NextResponse.json({ error: 'Incorrect password' }, { status: 401 })
}
break
}
case 'restricted': {
// Check for valid pending invitation
const invitation = await getInvitation(roomId, viewerId)
if (!invitation || invitation.status !== 'pending') {
return NextResponse.json(
{ error: 'You need a valid invitation to join this room' },
{ status: 403 }
)
}
break
}
case 'approval-only': {
// Check for approved join request
const joinRequest = await getJoinRequest(roomId, viewerId)
if (!joinRequest || joinRequest.status !== 'approved') {
return NextResponse.json(
{ error: 'Your join request must be approved by the host' },
{ status: 403 }
)
}
break
}
case 'open':
default:
// No additional checks needed
break
}
// Get or generate display name
const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`

View File

@@ -59,8 +59,9 @@ export async function GET(_req: NextRequest, context: RouteContext) {
* Update room (creator only)
* Body:
* - name?: string
* - isLocked?: boolean
* - status?: 'lobby' | 'playing' | 'finished'
*
* Note: For access control (accessMode, password), use PATCH /api/arcade/rooms/:roomId/settings
*/
export async function PATCH(req: NextRequest, context: RouteContext) {
try {
@@ -86,12 +87,10 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
const updates: {
name?: string
isLocked?: boolean
status?: 'lobby' | 'playing' | 'finished'
} = {}
if (body.name !== undefined) updates.name = body.name
if (body.isLocked !== undefined) updates.isLocked = body.isLocked
if (body.status !== undefined) updates.status = body.status
const room = await updateRoom(roomId, updates)

View File

@@ -0,0 +1,87 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getViewerId } from '@/lib/viewer'
import bcrypt from 'bcryptjs'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* PATCH /api/arcade/rooms/:roomId/settings
* Update room settings (host only)
* Body:
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
* - password?: string (plain text, will be hashed)
*/
export async function PATCH(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json()
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
if (!currentMember) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!currentMember.isCreator) {
return NextResponse.json({ error: 'Only the host can change room settings' }, { status: 403 })
}
// Validate accessMode if provided
const validAccessModes = [
'open',
'locked',
'retired',
'password',
'restricted',
'approval-only',
]
if (body.accessMode && !validAccessModes.includes(body.accessMode)) {
return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 })
}
// Validate password requirements
if (body.accessMode === 'password' && !body.password) {
return NextResponse.json(
{ error: 'Password is required for password-protected rooms' },
{ status: 400 }
)
}
// Prepare update data
const updateData: Record<string, any> = {}
if (body.accessMode !== undefined) {
updateData.accessMode = body.accessMode
}
// Hash password if provided
if (body.password !== undefined) {
if (body.password === null || body.password === '') {
updateData.password = null // Clear password
} else {
const hashedPassword = await bcrypt.hash(body.password, 10)
updateData.password = hashedPassword
}
}
// Update room settings
const [updatedRoom] = await db
.update(schema.arcadeRooms)
.set(updateData)
.where(eq(schema.arcadeRooms.id, roomId))
.returning()
return NextResponse.json({ room: updatedRoom }, { status: 200 })
} catch (error: any) {
console.error('Failed to update room settings:', error)
return NextResponse.json({ error: 'Failed to update room settings' }, { status: 500 })
}
}

View File

@@ -0,0 +1,103 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/transfer-ownership
* Transfer room ownership to another member (host only)
* Body:
* - newOwnerId: string
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields
if (!body.newOwnerId) {
return NextResponse.json({ error: 'Missing required field: newOwnerId' }, { status: 400 })
}
// Check if user is the current host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
if (!currentMember) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!currentMember.isCreator) {
return NextResponse.json(
{ error: 'Only the current host can transfer ownership' },
{ status: 403 }
)
}
// Can't transfer to yourself
if (body.newOwnerId === viewerId) {
return NextResponse.json({ error: 'You are already the owner' }, { status: 400 })
}
// Verify new owner is in the room
const newOwner = members.find((m) => m.userId === body.newOwnerId)
if (!newOwner) {
return NextResponse.json({ error: 'New owner must be a member of the room' }, { status: 404 })
}
// Remove isCreator from current owner
await db
.update(schema.roomMembers)
.set({ isCreator: false })
.where(eq(schema.roomMembers.id, currentMember.id))
// Set isCreator on new owner
await db
.update(schema.roomMembers)
.set({ isCreator: true })
.where(eq(schema.roomMembers.id, newOwner.id))
// Update room createdBy field
await db
.update(schema.arcadeRooms)
.set({
createdBy: body.newOwnerId,
creatorName: newOwner.displayName,
})
.where(eq(schema.arcadeRooms.id, roomId))
// Broadcast ownership transfer via socket
const io = await getSocketIO()
if (io) {
try {
const updatedMembers = await getRoomMembers(roomId)
io.to(`room:${roomId}`).emit('ownership-transferred', {
roomId,
oldOwnerId: viewerId,
newOwnerId: body.newOwnerId,
newOwnerName: newOwner.displayName,
members: updatedMembers,
})
console.log(
`[Ownership Transfer] Room ${roomId} ownership transferred from ${viewerId} to ${body.newOwnerId}`
)
} catch (socketError) {
console.error('[Ownership Transfer] Failed to broadcast transfer:', socketError)
}
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
console.error('Failed to transfer ownership:', error)
return NextResponse.json({ error: 'Failed to transfer ownership' }, { status: 500 })
}
}

View File

@@ -39,7 +39,7 @@ export async function GET(req: NextRequest) {
status: room.status,
createdAt: room.createdAt,
creatorName: room.creatorName,
isLocked: room.isLocked,
accessMode: room.accessMode,
memberCount: members.length,
playerCount: totalPlayers,
isMember: userIsMember,

View File

@@ -22,7 +22,14 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
.notNull()
.$defaultFn(() => new Date()),
ttlMinutes: integer('ttl_minutes').notNull().default(60), // Time to live
isLocked: integer('is_locked', { mode: 'boolean' }).notNull().default(false),
// Access control
accessMode: text('access_mode', {
enum: ['open', 'locked', 'retired', 'password', 'restricted', 'approval-only'],
})
.notNull()
.default('open'),
password: text('password', { length: 255 }), // Hashed password for password-protected rooms
// Game configuration
gameName: text('game_name', {

View File

@@ -14,5 +14,6 @@ export * from './room-member-history'
export * from './room-invitations'
export * from './room-reports'
export * from './room-bans'
export * from './room-join-requests'
export * from './user-stats'
export * from './users'

View File

@@ -0,0 +1,45 @@
import { createId } from '@paralleldrive/cuid2'
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { arcadeRooms } from './arcade-rooms'
/**
* Join requests for approval-only rooms
*/
export const roomJoinRequests = sqliteTable(
'room_join_requests',
{
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
// Requesting user
userId: text('user_id').notNull(),
userName: text('user_name', { length: 50 }).notNull(),
// Request status
status: text('status', {
enum: ['pending', 'approved', 'denied'],
})
.notNull()
.default('pending'),
// Timestamps
requestedAt: integer('requested_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
reviewedAt: integer('reviewed_at', { mode: 'timestamp' }),
reviewedBy: text('reviewed_by'), // Host user ID who reviewed
reviewedByName: text('reviewed_by_name', { length: 50 }),
},
(table) => ({
// One pending request per user per room
userRoomIdx: uniqueIndex('idx_room_join_requests_user_room').on(table.userId, table.roomId),
})
)
export type RoomJoinRequest = typeof roomJoinRequests.$inferSelect
export type NewRoomJoinRequest = typeof roomJoinRequests.$inferInsert

View File

@@ -0,0 +1,152 @@
/**
* Room Join Requests Manager
* Handles join request logic for approval-only rooms
*/
import { and, eq } from 'drizzle-orm'
import { db, schema } from '@/db'
export interface CreateJoinRequestParams {
roomId: string
userId: string
userName: string
}
/**
* Create a join request
*/
export async function createJoinRequest(
params: CreateJoinRequestParams
): Promise<schema.RoomJoinRequest> {
const now = new Date()
// Check if there's an existing request
const existing = await db
.select()
.from(schema.roomJoinRequests)
.where(
and(
eq(schema.roomJoinRequests.roomId, params.roomId),
eq(schema.roomJoinRequests.userId, params.userId)
)
)
.limit(1)
if (existing.length > 0) {
// Update existing request (reset to pending)
const [updated] = await db
.update(schema.roomJoinRequests)
.set({
userName: params.userName,
status: 'pending',
requestedAt: now,
reviewedAt: null,
reviewedBy: null,
reviewedByName: null,
})
.where(eq(schema.roomJoinRequests.id, existing[0].id))
.returning()
return updated
}
// Create new request
const [request] = await db
.insert(schema.roomJoinRequests)
.values({
roomId: params.roomId,
userId: params.userId,
userName: params.userName,
status: 'pending',
requestedAt: now,
})
.returning()
return request
}
/**
* Get all pending join requests for a room
*/
export async function getPendingJoinRequests(roomId: string): Promise<schema.RoomJoinRequest[]> {
return await db
.select()
.from(schema.roomJoinRequests)
.where(
and(eq(schema.roomJoinRequests.roomId, roomId), eq(schema.roomJoinRequests.status, 'pending'))
)
.orderBy(schema.roomJoinRequests.requestedAt)
}
/**
* Get all join requests for a room (any status)
*/
export async function getAllJoinRequests(roomId: string): Promise<schema.RoomJoinRequest[]> {
return await db
.select()
.from(schema.roomJoinRequests)
.where(eq(schema.roomJoinRequests.roomId, roomId))
.orderBy(schema.roomJoinRequests.requestedAt)
}
/**
* Approve a join request
*/
export async function approveJoinRequest(
requestId: string,
reviewedBy: string,
reviewedByName: string
): Promise<schema.RoomJoinRequest> {
const [request] = await db
.update(schema.roomJoinRequests)
.set({
status: 'approved',
reviewedAt: new Date(),
reviewedBy,
reviewedByName,
})
.where(eq(schema.roomJoinRequests.id, requestId))
.returning()
return request
}
/**
* Deny a join request
*/
export async function denyJoinRequest(
requestId: string,
reviewedBy: string,
reviewedByName: string
): Promise<schema.RoomJoinRequest> {
const [request] = await db
.update(schema.roomJoinRequests)
.set({
status: 'denied',
reviewedAt: new Date(),
reviewedBy,
reviewedByName,
})
.where(eq(schema.roomJoinRequests.id, requestId))
.returning()
return request
}
/**
* Get a specific join request
*/
export async function getJoinRequest(
roomId: string,
userId: string
): Promise<schema.RoomJoinRequest | undefined> {
const results = await db
.select()
.from(schema.roomJoinRequests)
.where(
and(eq(schema.roomJoinRequests.roomId, roomId), eq(schema.roomJoinRequests.userId, userId))
)
.limit(1)
return results[0]
}