Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9ec5d32c5 | ||
|
|
85d13cc552 | ||
|
|
ef8a29e8ef | ||
|
|
f7d63b30ac | ||
|
|
441c04f9e6 | ||
|
|
a74b96bb6f | ||
|
|
6ff21c4f1d |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
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]
|
||||
}
|
||||
@@ -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'))
|
||||
)
|
||||
|
||||
|
||||
@@ -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
19251
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user