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:
@@ -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": []
|
||||
|
||||
18
apps/web/drizzle/0007_access_modes.sql
Normal file
18
apps/web/drizzle/0007_access_modes.sql
Normal 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`);
|
||||
@@ -50,6 +50,13 @@
|
||||
"when": 1760365860888,
|
||||
"tag": "0006_pretty_invaders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1760527200000,
|
||||
"tag": "0007_access_modes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
101
apps/web/src/app/api/arcade/rooms/[roomId]/join-request/route.ts
Normal file
101
apps/web/src/app/api/arcade/rooms/[roomId]/join-request/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)}`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
87
apps/web/src/app/api/arcade/rooms/[roomId]/settings/route.ts
Normal file
87
apps/web/src/app/api/arcade/rooms/[roomId]/settings/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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'
|
||||
|
||||
45
apps/web/src/db/schema/room-join-requests.ts
Normal file
45
apps/web/src/db/schema/room-join-requests.ts
Normal 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
|
||||
152
apps/web/src/lib/arcade/room-join-requests.ts
Normal file
152
apps/web/src/lib/arcade/room-join-requests.ts
Normal 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]
|
||||
}
|
||||
Reference in New Issue
Block a user