Compare commits

..

7 Commits

Author SHA1 Message Date
semantic-release-bot
f9ec5d32c5 chore(release): 3.1.2 [skip ci]
## [3.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.1...v3.1.2) (2025-10-14)

### Bug Fixes

* replace last remaining isLoading with isPending in CreateRoomModal ([85d13cc](85d13cc552))
2025-10-14 01:14:40 +00:00
Thomas Hallock
85d13cc552 fix: replace last remaining isLoading with isPending in CreateRoomModal
Missed one instance in the select dropdown cursor style.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 20:13:44 -05:00
semantic-release-bot
ef8a29e8ef chore(release): 3.1.1 [skip ci]
## [3.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.0...v3.1.1) (2025-10-14)

### Bug Fixes

* use useCreateRoom hook instead of nonexistent createRoom from useRoomData ([f7d63b3](f7d63b30ac))
2025-10-14 00:54:35 +00:00
Thomas Hallock
f7d63b30ac fix: use useCreateRoom hook instead of nonexistent createRoom from useRoomData
The CreateRoomModal was trying to destructure createRoom from useRoomData(),
but that hook doesn't export it. Changed to use the proper useCreateRoom() hook.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 19:53:39 -05:00
semantic-release-bot
441c04f9e6 chore(release): 3.1.0 [skip ci]
## [3.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.0.0...v3.1.0) (2025-10-14)

### Features

* add room access modes and ownership transfer ([6ff21c4](6ff21c4f1d))

### Bug Fixes

* replace isLocked with accessMode and add bcryptjs ([a74b96b](a74b96bb6f))
2025-10-14 00:45:23 +00:00
Thomas Hallock
a74b96bb6f fix: replace isLocked with accessMode and add bcryptjs
- Updated all test files to use accessMode instead of isLocked field
- Fixed room-manager tests to reflect new access control schema
- Installed bcryptjs dependency for password hashing
- All access mode TypeScript compilation errors resolved

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 19:44:14 -05:00
Thomas Hallock
6ff21c4f1d 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>
2025-10-13 19:19:49 -05:00
24 changed files with 7623 additions and 12528 deletions

View File

@@ -1,3 +1,29 @@
## [3.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.1...v3.1.2) (2025-10-14)
### Bug Fixes
* replace last remaining isLoading with isPending in CreateRoomModal ([85d13cc](https://github.com/antialias/soroban-abacus-flashcards/commit/85d13cc552cfe2e825f8ea20c7db00d666599134))
## [3.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.0...v3.1.1) (2025-10-14)
### Bug Fixes
* use useCreateRoom hook instead of nonexistent createRoom from useRoomData ([f7d63b3](https://github.com/antialias/soroban-abacus-flashcards/commit/f7d63b30ac498b63797ae8683a0beb435a1c97b3))
## [3.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.0.0...v3.1.0) (2025-10-14)
### Features
* add room access modes and ownership transfer ([6ff21c4](https://github.com/antialias/soroban-abacus-flashcards/commit/6ff21c4f1dd0dd1db14257612809b4d40512689a))
### Bug Fixes
* replace isLocked with accessMode and add bcryptjs ([a74b96b](https://github.com/antialias/soroban-abacus-flashcards/commit/a74b96bb6fe331d27f3d27b8f77a3ce32b254bce))
## [3.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v3.0.0) (2025-10-13)

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

@@ -65,7 +65,7 @@ describe('Arcade Rooms API', () => {
expect(room.createdBy).toBe(testGuestId1)
expect(room.gameName).toBe('matching')
expect(room.status).toBe('lobby')
expect(room.isLocked).toBe(false)
expect(room.accessMode).toBe('open')
expect(room.ttlMinutes).toBe(60)
expect(room.code).toMatch(/^[A-Z0-9]{6}$/)
})
@@ -180,11 +180,11 @@ describe('Arcade Rooms API', () => {
it('locks room', async () => {
const [updated] = await db
.update(schema.arcadeRooms)
.set({ isLocked: true })
.set({ accessMode: 'locked' })
.where(eq(schema.arcadeRooms.id, testRoomId))
.returning()
expect(updated.isLocked).toBe(true)
expect(updated.accessMode).toBe('locked')
})
it('updates room status', async () => {
@@ -442,14 +442,14 @@ describe('Arcade Rooms API', () => {
// Lock one room
await db
.update(schema.arcadeRooms)
.set({ isLocked: true })
.set({ accessMode: 'locked' })
.where(eq(schema.arcadeRooms.id, testRoomId))
const unlockedRooms = await db.query.arcadeRooms.findMany({
where: eq(schema.arcadeRooms.isLocked, false),
const openRooms = await db.query.arcadeRooms.findMany({
where: eq(schema.arcadeRooms.accessMode, 'open'),
})
expect(unlockedRooms.every((r) => !r.isLocked)).toBe(true)
expect(openRooms.every((r) => r.accessMode === 'open')).toBe(true)
})
})
})

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

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { useRoomData } from '@/hooks/useRoomData'
import { useCreateRoom } from '@/hooks/useRoomData'
export interface CreateRoomModalProps {
/**
@@ -23,13 +23,11 @@ export interface CreateRoomModalProps {
* Modal for creating a new multiplayer room
*/
export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalProps) {
const { createRoom } = useRoomData()
const { mutateAsync: createRoom, isPending } = useCreateRoom()
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleClose = () => {
setError('')
setIsLoading(false)
onClose()
}
@@ -46,8 +44,6 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
return
}
setIsLoading(true)
try {
// Create the room (creator is auto-added as first member)
await createRoom({
@@ -62,8 +58,6 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
onSuccess?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create room')
} finally {
setIsLoading(false)
}
}
@@ -113,7 +107,7 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
type="text"
required
placeholder="My Awesome Room"
disabled={isLoading}
disabled={isPending}
style={{
width: '100%',
padding: '12px',
@@ -148,7 +142,7 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
<select
name="gameName"
required
disabled={isLoading}
disabled={isPending}
style={{
width: '100%',
padding: '12px',
@@ -158,7 +152,7 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
color: 'rgba(209, 213, 219, 1)',
fontSize: '15px',
outline: 'none',
cursor: isLoading ? 'not-allowed' : 'pointer',
cursor: isPending ? 'not-allowed' : 'pointer',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.6)'
@@ -190,7 +184,7 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
<button
type="button"
onClick={handleClose}
disabled={isLoading}
disabled={isPending}
style={{
flex: 1,
padding: '12px',
@@ -200,17 +194,17 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
cursor: isPending ? 'not-allowed' : 'pointer',
opacity: isPending ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
@@ -219,38 +213,38 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
</button>
<button
type="submit"
disabled={isLoading}
disabled={isPending}
style={{
flex: 1,
padding: '12px',
background: isLoading
background: isPending
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))',
color: 'rgba(255, 255, 255, 1)',
border: isLoading
border: isPending
? '2px solid rgba(75, 85, 99, 0.5)'
: '2px solid rgba(34, 197, 94, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
cursor: isPending ? 'not-allowed' : 'pointer',
opacity: isPending ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(34, 197, 94, 0.9), rgba(22, 163, 74, 0.9))'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))'
}
}}
>
{isLoading ? 'Creating...' : 'Create Room'}
{isPending ? 'Creating...' : 'Create Room'}
</button>
</div>
</form>

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

@@ -33,7 +33,8 @@ vi.mock('@/db', () => ({
code: 'code',
name: 'name',
gameName: 'gameName',
isLocked: 'isLocked',
accessMode: 'accessMode',
password: 'password',
status: 'status',
lastActivity: 'lastActivity',
},
@@ -59,7 +60,8 @@ describe('Room Manager', () => {
createdAt: new Date(),
lastActivity: new Date(),
ttlMinutes: 60,
isLocked: false,
accessMode: 'open',
password: null,
gameName: 'matching',
gameConfig: { difficulty: 6 },
status: 'lobby',
@@ -245,7 +247,7 @@ describe('Room Manager', () => {
describe('updateRoom', () => {
it('updates room and returns updated data', async () => {
const updates = { name: 'Updated Room', isLocked: true }
const updates = { name: 'Updated Room', status: 'playing' as const }
const mockUpdate = {
set: vi.fn().mockReturnThis(),
@@ -257,7 +259,7 @@ describe('Room Manager', () => {
const room = await updateRoom('room-123', updates)
expect(room?.name).toBe('Updated Room')
expect(room?.isLocked).toBe(true)
expect(room?.status).toBe('playing')
expect(db.update).toHaveBeenCalled()
})
@@ -328,12 +330,12 @@ describe('Room Manager', () => {
expect(db.query.arcadeRooms.findMany).toHaveBeenCalled()
})
it('excludes locked rooms', async () => {
it('only includes open and password-protected rooms', async () => {
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms)
await listActiveRooms()
// Verify the where clause excludes locked rooms
// Verify the where clause filters by accessMode
const call = vi.mocked(db.query.arcadeRooms.findMany).mock.calls[0][0]
expect(call).toBeDefined()
})

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]
}

View File

@@ -19,7 +19,6 @@ export interface CreateRoomOptions {
export interface UpdateRoomOptions {
name?: string
isLocked?: boolean
status?: 'lobby' | 'playing' | 'finished'
currentSessionId?: string | null
totalGamesPlayed?: number
@@ -56,7 +55,7 @@ export async function createRoom(options: CreateRoomOptions): Promise<schema.Arc
createdAt: now,
lastActivity: now,
ttlMinutes: options.ttlMinutes || 60,
isLocked: false,
accessMode: 'open', // Default to open access
gameName: options.gameName,
gameConfig: options.gameConfig as any,
status: 'lobby',
@@ -134,6 +133,7 @@ export async function deleteRoom(roomId: string): Promise<void> {
/**
* List active rooms
* Returns rooms ordered by most recently active
* Only returns openly accessible rooms (accessMode: 'open' or 'password')
*/
export async function listActiveRooms(gameName?: GameName): Promise<schema.ArcadeRoom[]> {
const whereConditions = []
@@ -143,9 +143,10 @@ export async function listActiveRooms(gameName?: GameName): Promise<schema.Arcad
whereConditions.push(eq(schema.arcadeRooms.gameName, gameName))
}
// Only return non-locked rooms in lobby or playing status
// Only return accessible rooms in lobby or playing status
// Exclude locked, retired, restricted, and approval-only rooms
whereConditions.push(
eq(schema.arcadeRooms.isLocked, false),
or(eq(schema.arcadeRooms.accessMode, 'open'), eq(schema.arcadeRooms.accessMode, 'password')),
or(eq(schema.arcadeRooms.status, 'lobby'), eq(schema.arcadeRooms.status, 'playing'))
)

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "3.0.0",
"version": "3.1.2",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [

19251
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff