feat: add API routes for moderation and invitations

Add REST API endpoints for:
- POST/DELETE /ban: Ban/unban users (with auto-invite on unban)
- POST /kick: Remove users from room temporarily
- POST /report: Submit player misconduct reports
- GET /reports: Retrieve pending reports for room hosts
- GET /history: Get all historical room members with statuses
- POST /invite: Send room invitations
- GET /invitations/pending: Get user's pending invitations

All endpoints include proper authentication, validation, and
real-time socket notifications for affected users.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-13 11:22:38 -05:00
parent 84f3c4bcfd
commit 79a8518557
7 changed files with 663 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { eq } from 'drizzle-orm'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/arcade/invitations/pending
* Get all pending invitations for the current user with room details
*/
export async function GET(req: NextRequest) {
try {
const viewerId = await getViewerId()
// Get pending invitations with room details
const invitations = await db
.select({
id: schema.roomInvitations.id,
roomId: schema.roomInvitations.roomId,
roomName: schema.arcadeRooms.name,
roomGameName: schema.arcadeRooms.gameName,
userId: schema.roomInvitations.userId,
userName: schema.roomInvitations.userName,
invitedBy: schema.roomInvitations.invitedBy,
invitedByName: schema.roomInvitations.invitedByName,
status: schema.roomInvitations.status,
invitationType: schema.roomInvitations.invitationType,
message: schema.roomInvitations.message,
createdAt: schema.roomInvitations.createdAt,
expiresAt: schema.roomInvitations.expiresAt,
})
.from(schema.roomInvitations)
.innerJoin(schema.arcadeRooms, eq(schema.roomInvitations.roomId, schema.arcadeRooms.id))
.where(eq(schema.roomInvitations.userId, viewerId))
.orderBy(schema.roomInvitations.createdAt)
// Filter to only pending invitations
const pendingInvitations = invitations.filter((inv) => inv.status === 'pending')
return NextResponse.json({ invitations: pendingInvitations }, { status: 200 })
} catch (error: any) {
console.error('Failed to get pending invitations:', error)
return NextResponse.json({ error: 'Failed to get pending invitations' }, { status: 500 })
}
}

View File

@@ -0,0 +1,223 @@
import { type NextRequest, NextResponse } from 'next/server'
import { banUserFromRoom, getRoomBans, unbanUserFromRoom } from '@/lib/arcade/room-moderation'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getUserRoomHistory } from '@/lib/arcade/room-member-history'
import { createInvitation } from '@/lib/arcade/room-invitations'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/ban
* Ban a user from the room (host only)
* Body:
* - userId: string
* - reason: string (enum)
* - notes?: string (optional)
*/
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.userId || !body.reason) {
return NextResponse.json(
{ error: 'Missing required fields: userId, reason' },
{ status: 400 }
)
}
// Validate reason
const validReasons = ['harassment', 'cheating', 'inappropriate-name', 'spam', 'afk', 'other']
if (!validReasons.includes(body.reason)) {
return NextResponse.json({ error: 'Invalid reason' }, { status: 400 })
}
// 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 ban users' }, { status: 403 })
}
// Can't ban yourself
if (body.userId === viewerId) {
return NextResponse.json({ error: 'Cannot ban yourself' }, { status: 400 })
}
// Get the user to ban (they might not be in the room anymore)
const targetUser = members.find((m) => m.userId === body.userId)
const userName = targetUser?.displayName || body.userId.slice(-4)
// Ban the user
await banUserFromRoom({
roomId,
userId: body.userId,
userName,
bannedBy: viewerId,
bannedByName: currentMember.displayName,
reason: body.reason,
notes: body.notes,
})
// Broadcast updates via socket
const io = await getSocketIO()
if (io) {
try {
// Get updated member list
const updatedMembers = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Tell the banned user they've been removed
io.to(`user:${body.userId}`).emit('banned-from-room', {
roomId,
bannedBy: currentMember.displayName,
reason: body.reason,
})
// Notify everyone else in the room
io.to(`room:${roomId}`).emit('member-left', {
roomId,
userId: body.userId,
members: updatedMembers,
memberPlayers: memberPlayersObj,
reason: 'banned',
})
console.log(`[Ban API] User ${body.userId} banned from room ${roomId}`)
} catch (socketError) {
console.error('[Ban API] Failed to broadcast ban:', socketError)
}
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
console.error('Failed to ban user:', error)
return NextResponse.json({ error: 'Failed to ban user' }, { status: 500 })
}
}
/**
* DELETE /api/arcade/rooms/:roomId/ban
* Unban a user from the room (host only)
* Body:
* - userId: string
*/
export async function DELETE(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields
if (!body.userId) {
return NextResponse.json({ error: 'Missing required field: userId' }, { status: 400 })
}
// 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 unban users' }, { status: 403 })
}
// Unban the user
await unbanUserFromRoom(roomId, body.userId)
// Auto-invite the unbanned user back to the room
const history = await getUserRoomHistory(roomId, body.userId)
if (history) {
const invitation = await createInvitation({
roomId,
userId: body.userId,
userName: history.displayName,
invitedBy: viewerId,
invitedByName: currentMember.displayName,
invitationType: 'auto-unban',
message: 'You have been unbanned and are welcome to rejoin.',
})
// Broadcast invitation via socket
const io = await getSocketIO()
if (io) {
try {
io.to(`user:${body.userId}`).emit('room-invitation-received', {
invitation: {
id: invitation.id,
roomId: invitation.roomId,
invitedBy: invitation.invitedBy,
invitedByName: invitation.invitedByName,
message: invitation.message,
createdAt: invitation.createdAt,
invitationType: 'auto-unban',
},
})
console.log(
`[Unban API] Auto-invited user ${body.userId} after unban from room ${roomId}`
)
} catch (socketError) {
console.error('[Unban API] Failed to broadcast invitation:', socketError)
}
}
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
console.error('Failed to unban user:', error)
return NextResponse.json({ error: 'Failed to unban user' }, { status: 500 })
}
}
/**
* GET /api/arcade/rooms/:roomId/ban
* Get all bans 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 bans' }, { status: 403 })
}
// Get all bans
const bans = await getRoomBans(roomId)
return NextResponse.json({ bans }, { status: 200 })
} catch (error: any) {
console.error('Failed to get bans:', error)
return NextResponse.json({ error: 'Failed to get bans' }, { status: 500 })
}
}

View File

@@ -0,0 +1,40 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getRoomHistoricalMembersWithStatus } from '@/lib/arcade/room-member-history'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* GET /api/arcade/rooms/:roomId/history
* Get all historical members with their current status (host only)
* Returns: array of historical members with status info
*/
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 room history' }, { status: 403 })
}
// Get all historical members with status
const historicalMembers = await getRoomHistoricalMembersWithStatus(roomId)
return NextResponse.json({ historicalMembers }, { status: 200 })
} catch (error: any) {
console.error('Failed to get room history:', error)
return NextResponse.json({ error: 'Failed to get room history' }, { status: 500 })
}
}

View File

@@ -0,0 +1,125 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { createInvitation, getRoomInvitations } from '@/lib/arcade/room-invitations'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/invite
* Send an invitation to a user (host only)
* Body:
* - userId: string
* - userName: string
* - message?: string (optional)
*/
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.userId || !body.userName) {
return NextResponse.json(
{ error: 'Missing required fields: userId, userName' },
{ status: 400 }
)
}
// 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 send invitations' }, { status: 403 })
}
// Can't invite yourself
if (body.userId === viewerId) {
return NextResponse.json({ error: 'Cannot invite yourself' }, { status: 400 })
}
// Can't invite someone who's already in the room
const targetUser = members.find((m) => m.userId === body.userId)
if (targetUser) {
return NextResponse.json({ error: 'User is already in this room' }, { status: 400 })
}
// Create invitation
const invitation = await createInvitation({
roomId,
userId: body.userId,
userName: body.userName,
invitedBy: viewerId,
invitedByName: currentMember.displayName,
invitationType: 'manual',
message: body.message,
})
// Broadcast invitation via socket
const io = await getSocketIO()
if (io) {
try {
// Send to the invited user's channel
io.to(`user:${body.userId}`).emit('room-invitation-received', {
invitation: {
id: invitation.id,
roomId: invitation.roomId,
invitedBy: invitation.invitedBy,
invitedByName: invitation.invitedByName,
message: invitation.message,
createdAt: invitation.createdAt,
},
})
console.log(`[Invite API] Sent invitation to user ${body.userId} for room ${roomId}`)
} catch (socketError) {
console.error('[Invite API] Failed to broadcast invitation:', socketError)
}
}
return NextResponse.json({ invitation }, { status: 200 })
} catch (error: any) {
console.error('Failed to send invitation:', error)
return NextResponse.json({ error: 'Failed to send invitation' }, { status: 500 })
}
}
/**
* GET /api/arcade/rooms/:roomId/invite
* Get all invitations 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 invitations' }, { status: 403 })
}
// Get all invitations
const invitations = await getRoomInvitations(roomId)
return NextResponse.json({ invitations }, { status: 200 })
} catch (error: any) {
console.error('Failed to get invitations:', error)
return NextResponse.json({ error: 'Failed to get invitations' }, { status: 500 })
}
}

View File

@@ -0,0 +1,95 @@
import { type NextRequest, NextResponse } from 'next/server'
import { kickUserFromRoom } from '@/lib/arcade/room-moderation'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/kick
* Kick a user from the room (host only)
* Body:
* - userId: 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.userId) {
return NextResponse.json({ error: 'Missing required field: userId' }, { status: 400 })
}
// 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 kick users' }, { status: 403 })
}
// Can't kick yourself
if (body.userId === viewerId) {
return NextResponse.json({ error: 'Cannot kick yourself' }, { status: 400 })
}
// Verify the user to kick is in the room
const targetUser = members.find((m) => m.userId === body.userId)
if (!targetUser) {
return NextResponse.json({ error: 'User is not in this room' }, { status: 404 })
}
// Kick the user
await kickUserFromRoom(roomId, body.userId)
// Broadcast updates via socket
const io = await getSocketIO()
if (io) {
try {
// Get updated member list
const updatedMembers = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Tell the kicked user they've been removed
io.to(`user:${body.userId}`).emit('kicked-from-room', {
roomId,
kickedBy: currentMember.displayName,
})
// Notify everyone else in the room
io.to(`room:${roomId}`).emit('member-left', {
roomId,
userId: body.userId,
members: updatedMembers,
memberPlayers: memberPlayersObj,
reason: 'kicked',
})
console.log(`[Kick API] User ${body.userId} kicked from room ${roomId}`)
} catch (socketError) {
console.error('[Kick API] Failed to broadcast kick:', socketError)
}
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
console.error('Failed to kick user:', error)
return NextResponse.json({ error: 'Failed to kick user' }, { status: 500 })
}
}

View File

@@ -0,0 +1,97 @@
import { type NextRequest, NextResponse } from 'next/server'
import { createReport } from '@/lib/arcade/room-moderation'
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/report
* Submit a report about another player
* Body:
* - reportedUserId: string
* - reason: string (enum)
* - details?: string (optional)
*/
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.reportedUserId || !body.reason) {
return NextResponse.json(
{ error: 'Missing required fields: reportedUserId, reason' },
{ status: 400 }
)
}
// Validate reason
const validReasons = ['harassment', 'cheating', 'inappropriate-name', 'spam', 'afk', 'other']
if (!validReasons.includes(body.reason)) {
return NextResponse.json({ error: 'Invalid reason' }, { status: 400 })
}
// Can't report yourself
if (body.reportedUserId === viewerId) {
return NextResponse.json({ error: 'Cannot report yourself' }, { status: 400 })
}
// Get room members to verify both users are in the room and get names
const members = await getRoomMembers(roomId)
const reporter = members.find((m) => m.userId === viewerId)
const reported = members.find((m) => m.userId === body.reportedUserId)
if (!reporter) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!reported) {
return NextResponse.json({ error: 'Reported user is not in this room' }, { status: 404 })
}
// Create report
const report = await createReport({
roomId,
reporterId: viewerId,
reporterName: reporter.displayName,
reportedUserId: body.reportedUserId,
reportedUserName: reported.displayName,
reason: body.reason,
details: body.details,
})
// Notify host via socket (find the host)
const host = members.find((m) => m.isCreator)
if (host) {
const io = await getSocketIO()
if (io) {
try {
// Send notification only to the host
io.to(`user:${host.userId}`).emit('report-submitted', {
roomId,
report: {
id: report.id,
reporterName: report.reporterName,
reportedUserName: report.reportedUserName,
reportedUserId: report.reportedUserId,
reason: report.reason,
createdAt: report.createdAt,
},
})
} catch (socketError) {
console.error('[Report API] Failed to notify host:', socketError)
}
}
}
return NextResponse.json({ success: true, report }, { status: 201 })
} catch (error: any) {
console.error('Failed to submit report:', error)
return NextResponse.json({ error: 'Failed to submit report' }, { status: 500 })
}
}

View File

@@ -0,0 +1,39 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getAllReports } from '@/lib/arcade/room-moderation'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* GET /api/arcade/rooms/:roomId/reports
* Get all reports 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 reports' }, { status: 403 })
}
// Get all reports
const reports = await getAllReports(roomId)
return NextResponse.json({ reports }, { status: 200 })
} catch (error: any) {
console.error('Failed to get reports:', error)
return NextResponse.json({ error: 'Failed to get reports' }, { status: 500 })
}
}