feat: add backend library functions for room moderation
Implement core moderation functionality: - room-moderation.ts: Ban/unban users, kick, submit/manage reports - room-invitations.ts: Create and manage room invitations - room-member-history.ts: Track historical room membership - room-membership.ts: Enhanced to work with new moderation system These functions provide the business logic layer for all moderation features including auto-invite on unban. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
181
apps/web/src/lib/arcade/room-invitations.ts
Normal file
181
apps/web/src/lib/arcade/room-invitations.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Room Invitations Manager
|
||||
* Handles invitation logic for room members
|
||||
*/
|
||||
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { db, schema } from '@/db'
|
||||
|
||||
export interface CreateInvitationParams {
|
||||
roomId: string
|
||||
userId: string
|
||||
userName: string
|
||||
invitedBy: string
|
||||
invitedByName: string
|
||||
invitationType: 'manual' | 'auto-unban' | 'auto-create'
|
||||
message?: string
|
||||
expiresAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update an invitation
|
||||
* If a pending invitation exists, it will be replaced
|
||||
*/
|
||||
export async function createInvitation(
|
||||
params: CreateInvitationParams
|
||||
): Promise<schema.RoomInvitation> {
|
||||
const now = new Date()
|
||||
|
||||
// Check if there's an existing invitation
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.roomInvitations)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.roomInvitations.roomId, params.roomId),
|
||||
eq(schema.roomInvitations.userId, params.userId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing invitation
|
||||
const [updated] = await db
|
||||
.update(schema.roomInvitations)
|
||||
.set({
|
||||
userName: params.userName,
|
||||
invitedBy: params.invitedBy,
|
||||
invitedByName: params.invitedByName,
|
||||
invitationType: params.invitationType,
|
||||
message: params.message,
|
||||
status: 'pending', // Reset to pending
|
||||
createdAt: now, // Update timestamp
|
||||
respondedAt: null,
|
||||
expiresAt: params.expiresAt,
|
||||
})
|
||||
.where(eq(schema.roomInvitations.id, existing[0].id))
|
||||
.returning()
|
||||
|
||||
console.log('[Room Invitations] Updated invitation:', {
|
||||
userId: params.userId,
|
||||
roomId: params.roomId,
|
||||
})
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
// Create new invitation
|
||||
const [invitation] = await db
|
||||
.insert(schema.roomInvitations)
|
||||
.values({
|
||||
roomId: params.roomId,
|
||||
userId: params.userId,
|
||||
userName: params.userName,
|
||||
invitedBy: params.invitedBy,
|
||||
invitedByName: params.invitedByName,
|
||||
invitationType: params.invitationType,
|
||||
message: params.message,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
expiresAt: params.expiresAt,
|
||||
})
|
||||
.returning()
|
||||
|
||||
console.log('[Room Invitations] Created invitation:', {
|
||||
userId: params.userId,
|
||||
roomId: params.roomId,
|
||||
})
|
||||
|
||||
return invitation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending invitations for a user
|
||||
*/
|
||||
export async function getUserPendingInvitations(userId: string): Promise<schema.RoomInvitation[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.roomInvitations)
|
||||
.where(
|
||||
and(eq(schema.roomInvitations.userId, userId), eq(schema.roomInvitations.status, 'pending'))
|
||||
)
|
||||
.orderBy(schema.roomInvitations.createdAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all invitations for a room
|
||||
*/
|
||||
export async function getRoomInvitations(roomId: string): Promise<schema.RoomInvitation[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.roomInvitations)
|
||||
.where(eq(schema.roomInvitations.roomId, roomId))
|
||||
.orderBy(schema.roomInvitations.createdAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation
|
||||
*/
|
||||
export async function acceptInvitation(invitationId: string): Promise<schema.RoomInvitation> {
|
||||
const [invitation] = await db
|
||||
.update(schema.roomInvitations)
|
||||
.set({
|
||||
status: 'accepted',
|
||||
respondedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.roomInvitations.id, invitationId))
|
||||
.returning()
|
||||
|
||||
console.log('[Room Invitations] Accepted invitation:', invitationId)
|
||||
|
||||
return invitation
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline an invitation
|
||||
*/
|
||||
export async function declineInvitation(invitationId: string): Promise<schema.RoomInvitation> {
|
||||
const [invitation] = await db
|
||||
.update(schema.roomInvitations)
|
||||
.set({
|
||||
status: 'declined',
|
||||
respondedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.roomInvitations.id, invitationId))
|
||||
.returning()
|
||||
|
||||
console.log('[Room Invitations] Declined invitation:', invitationId)
|
||||
|
||||
return invitation
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel/delete an invitation
|
||||
*/
|
||||
export async function cancelInvitation(roomId: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.delete(schema.roomInvitations)
|
||||
.where(
|
||||
and(eq(schema.roomInvitations.roomId, roomId), eq(schema.roomInvitations.userId, userId))
|
||||
)
|
||||
|
||||
console.log('[Room Invitations] Cancelled invitation:', { userId, roomId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific invitation
|
||||
*/
|
||||
export async function getInvitation(
|
||||
roomId: string,
|
||||
userId: string
|
||||
): Promise<schema.RoomInvitation | undefined> {
|
||||
const results = await db
|
||||
.select()
|
||||
.from(schema.roomInvitations)
|
||||
.where(
|
||||
and(eq(schema.roomInvitations.roomId, roomId), eq(schema.roomInvitations.userId, userId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return results[0]
|
||||
}
|
||||
209
apps/web/src/lib/arcade/room-member-history.ts
Normal file
209
apps/web/src/lib/arcade/room-member-history.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Room Member History Manager
|
||||
* Tracks all users who have ever been in a room
|
||||
*/
|
||||
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { db, schema } from '@/db'
|
||||
|
||||
/**
|
||||
* Record or update a user's membership in room history
|
||||
* This is append-only for new users, or updates the lastAction for existing users
|
||||
*/
|
||||
export async function recordRoomMemberHistory(params: {
|
||||
roomId: string
|
||||
userId: string
|
||||
displayName: string
|
||||
action: 'active' | 'left' | 'kicked' | 'banned'
|
||||
}): Promise<schema.RoomMemberHistory> {
|
||||
const now = new Date()
|
||||
|
||||
// Check if this user already has a history entry for this room
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.roomMemberHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.roomMemberHistory.roomId, params.roomId),
|
||||
eq(schema.roomMemberHistory.userId, params.userId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing record
|
||||
const [updated] = await db
|
||||
.update(schema.roomMemberHistory)
|
||||
.set({
|
||||
lastSeenAt: now,
|
||||
lastAction: params.action,
|
||||
lastActionAt: now,
|
||||
displayName: params.displayName, // Update display name in case it changed
|
||||
})
|
||||
.where(eq(schema.roomMemberHistory.id, existing[0].id))
|
||||
.returning()
|
||||
return updated
|
||||
}
|
||||
|
||||
// Create new history record
|
||||
const [history] = await db
|
||||
.insert(schema.roomMemberHistory)
|
||||
.values({
|
||||
roomId: params.roomId,
|
||||
userId: params.userId,
|
||||
displayName: params.displayName,
|
||||
firstJoinedAt: now,
|
||||
lastSeenAt: now,
|
||||
lastAction: params.action,
|
||||
lastActionAt: now,
|
||||
})
|
||||
.returning()
|
||||
|
||||
console.log('[Room History] Recorded history:', {
|
||||
userId: params.userId,
|
||||
roomId: params.roomId,
|
||||
action: params.action,
|
||||
})
|
||||
|
||||
return history
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all historical members for a room
|
||||
*/
|
||||
export async function getRoomMemberHistory(roomId: string): Promise<schema.RoomMemberHistory[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.roomMemberHistory)
|
||||
.where(eq(schema.roomMemberHistory.roomId, roomId))
|
||||
.orderBy(schema.roomMemberHistory.firstJoinedAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history for a specific user in a room
|
||||
*/
|
||||
export async function getUserRoomHistory(
|
||||
roomId: string,
|
||||
userId: string
|
||||
): Promise<schema.RoomMemberHistory | undefined> {
|
||||
const results = await db
|
||||
.select()
|
||||
.from(schema.roomMemberHistory)
|
||||
.where(
|
||||
and(eq(schema.roomMemberHistory.roomId, roomId), eq(schema.roomMemberHistory.userId, userId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return results[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last action for a user in a room
|
||||
*/
|
||||
export async function updateRoomMemberAction(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
action: 'active' | 'left' | 'kicked' | 'banned'
|
||||
): Promise<void> {
|
||||
const now = new Date()
|
||||
|
||||
await db
|
||||
.update(schema.roomMemberHistory)
|
||||
.set({
|
||||
lastAction: action,
|
||||
lastActionAt: now,
|
||||
lastSeenAt: now,
|
||||
})
|
||||
.where(
|
||||
and(eq(schema.roomMemberHistory.roomId, roomId), eq(schema.roomMemberHistory.userId, userId))
|
||||
)
|
||||
|
||||
console.log('[Room History] Updated action:', { userId, roomId, action })
|
||||
}
|
||||
|
||||
export interface HistoricalMemberWithStatus {
|
||||
userId: string
|
||||
displayName: string
|
||||
firstJoinedAt: Date
|
||||
lastSeenAt: Date
|
||||
status: 'active' | 'banned' | 'kicked' | 'left'
|
||||
isCurrentlyInRoom: boolean
|
||||
isBanned: boolean
|
||||
banDetails?: {
|
||||
reason: string
|
||||
bannedBy: string
|
||||
bannedByName: string
|
||||
bannedAt: Date
|
||||
}
|
||||
invitationStatus?: 'pending' | 'accepted' | 'declined' | 'expired' | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all historical members with their current status
|
||||
* Combines data from history, current members, bans, and invitations
|
||||
*/
|
||||
export async function getRoomHistoricalMembersWithStatus(
|
||||
roomId: string
|
||||
): Promise<HistoricalMemberWithStatus[]> {
|
||||
// Get all historical members
|
||||
const history = await getRoomMemberHistory(roomId)
|
||||
|
||||
// Get current members
|
||||
const currentMembers = await db
|
||||
.select()
|
||||
.from(schema.roomMembers)
|
||||
.where(eq(schema.roomMembers.roomId, roomId))
|
||||
|
||||
const currentMemberIds = new Set(currentMembers.map((m) => m.userId))
|
||||
|
||||
// Get all bans
|
||||
const bans = await db.select().from(schema.roomBans).where(eq(schema.roomBans.roomId, roomId))
|
||||
|
||||
const banMap = new Map(bans.map((ban) => [ban.userId, ban]))
|
||||
|
||||
// Get all invitations
|
||||
const invitations = await db
|
||||
.select()
|
||||
.from(schema.roomInvitations)
|
||||
.where(eq(schema.roomInvitations.roomId, roomId))
|
||||
|
||||
const invitationMap = new Map(invitations.map((inv) => [inv.userId, inv]))
|
||||
|
||||
// Combine into result
|
||||
const results: HistoricalMemberWithStatus[] = history.map((h) => {
|
||||
const isCurrentlyInRoom = currentMemberIds.has(h.userId)
|
||||
const ban = banMap.get(h.userId)
|
||||
const invitation = invitationMap.get(h.userId)
|
||||
|
||||
// Determine current status
|
||||
let status: 'active' | 'banned' | 'kicked' | 'left'
|
||||
if (ban) {
|
||||
status = 'banned'
|
||||
} else if (isCurrentlyInRoom) {
|
||||
status = 'active'
|
||||
} else {
|
||||
status = h.lastAction // Use the recorded action from history
|
||||
}
|
||||
|
||||
return {
|
||||
userId: h.userId,
|
||||
displayName: h.displayName,
|
||||
firstJoinedAt: h.firstJoinedAt,
|
||||
lastSeenAt: h.lastSeenAt,
|
||||
status,
|
||||
isCurrentlyInRoom,
|
||||
isBanned: !!ban,
|
||||
banDetails: ban
|
||||
? {
|
||||
reason: ban.reason,
|
||||
bannedBy: ban.bannedBy,
|
||||
bannedByName: ban.bannedByName,
|
||||
bannedAt: ban.createdAt,
|
||||
}
|
||||
: undefined,
|
||||
invitationStatus: invitation?.status || null,
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { db, schema } from '@/db'
|
||||
import { recordRoomMemberHistory } from './room-member-history'
|
||||
|
||||
export interface AddMemberOptions {
|
||||
roomId: string
|
||||
@@ -83,6 +84,14 @@ export async function addRoomMember(
|
||||
const [member] = await db.insert(schema.roomMembers).values(newMember).returning()
|
||||
console.log('[Room Membership] Added member:', member.userId, 'to room:', member.roomId)
|
||||
|
||||
// Record in history
|
||||
await recordRoomMemberHistory({
|
||||
roomId: options.roomId,
|
||||
userId: options.userId,
|
||||
displayName: options.displayName,
|
||||
action: 'active',
|
||||
})
|
||||
|
||||
return {
|
||||
member,
|
||||
autoLeaveResult: autoLeaveResult.leftRooms.length > 0 ? autoLeaveResult : undefined,
|
||||
@@ -165,12 +174,28 @@ export async function touchMember(roomId: string, userId: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Remove a member from a room
|
||||
* Note: This only removes from active members. History is preserved.
|
||||
* Use updateRoomMemberAction from room-member-history to set the reason (left/kicked/banned)
|
||||
*/
|
||||
export async function removeMember(roomId: string, userId: string): Promise<void> {
|
||||
// Get member info before deleting
|
||||
const member = await getRoomMember(roomId, userId)
|
||||
|
||||
await db
|
||||
.delete(schema.roomMembers)
|
||||
.where(and(eq(schema.roomMembers.roomId, roomId), eq(schema.roomMembers.userId, userId)))
|
||||
console.log('[Room Membership] Removed member:', userId, 'from room:', roomId)
|
||||
|
||||
// Update history to show they left (default action)
|
||||
// This can be overridden by kick/ban functions
|
||||
if (member) {
|
||||
await recordRoomMemberHistory({
|
||||
roomId,
|
||||
userId,
|
||||
displayName: member.displayName,
|
||||
action: 'left',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
203
apps/web/src/lib/arcade/room-moderation.ts
Normal file
203
apps/web/src/lib/arcade/room-moderation.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Room Moderation Library
|
||||
* Handles reports, bans, and kicks for arcade rooms
|
||||
*/
|
||||
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
roomBans,
|
||||
roomMembers,
|
||||
roomReports,
|
||||
type NewRoomBan,
|
||||
type NewRoomReport,
|
||||
} from '@/db/schema'
|
||||
import { recordRoomMemberHistory } from './room-member-history'
|
||||
|
||||
/**
|
||||
* Check if a user is banned from a room
|
||||
*/
|
||||
export async function isUserBanned(roomId: string, userId: string): Promise<boolean> {
|
||||
const ban = await db
|
||||
.select()
|
||||
.from(roomBans)
|
||||
.where(and(eq(roomBans.roomId, roomId), eq(roomBans.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
return ban.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bans for a room
|
||||
*/
|
||||
export async function getRoomBans(roomId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(roomBans)
|
||||
.where(eq(roomBans.roomId, roomId))
|
||||
.orderBy(desc(roomBans.createdAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ban a user from a room
|
||||
*/
|
||||
export async function banUserFromRoom(params: {
|
||||
roomId: string
|
||||
userId: string
|
||||
userName: string
|
||||
bannedBy: string
|
||||
bannedByName: string
|
||||
reason: 'harassment' | 'cheating' | 'inappropriate-name' | 'spam' | 'afk' | 'other'
|
||||
notes?: string
|
||||
}) {
|
||||
// Insert ban record (upsert in case they were already banned)
|
||||
const [ban] = await db
|
||||
.insert(roomBans)
|
||||
.values({
|
||||
roomId: params.roomId,
|
||||
userId: params.userId,
|
||||
userName: params.userName,
|
||||
bannedBy: params.bannedBy,
|
||||
bannedByName: params.bannedByName,
|
||||
reason: params.reason,
|
||||
notes: params.notes,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [roomBans.userId, roomBans.roomId],
|
||||
set: {
|
||||
bannedBy: params.bannedBy,
|
||||
bannedByName: params.bannedByName,
|
||||
reason: params.reason,
|
||||
notes: params.notes,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Remove user from room members
|
||||
await db
|
||||
.delete(roomMembers)
|
||||
.where(and(eq(roomMembers.roomId, params.roomId), eq(roomMembers.userId, params.userId)))
|
||||
|
||||
// Record in history
|
||||
await recordRoomMemberHistory({
|
||||
roomId: params.roomId,
|
||||
userId: params.userId,
|
||||
displayName: params.userName,
|
||||
action: 'banned',
|
||||
})
|
||||
|
||||
return ban
|
||||
}
|
||||
|
||||
/**
|
||||
* Unban a user from a room
|
||||
*/
|
||||
export async function unbanUserFromRoom(roomId: string, userId: string) {
|
||||
await db.delete(roomBans).where(and(eq(roomBans.roomId, roomId), eq(roomBans.userId, userId)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick a user from a room (remove without banning)
|
||||
*/
|
||||
export async function kickUserFromRoom(roomId: string, userId: string) {
|
||||
// Get member info before deleting
|
||||
const member = await db
|
||||
.select()
|
||||
.from(roomMembers)
|
||||
.where(and(eq(roomMembers.roomId, roomId), eq(roomMembers.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
await db
|
||||
.delete(roomMembers)
|
||||
.where(and(eq(roomMembers.roomId, roomId), eq(roomMembers.userId, userId)))
|
||||
|
||||
// Record in history
|
||||
if (member.length > 0) {
|
||||
await recordRoomMemberHistory({
|
||||
roomId,
|
||||
userId,
|
||||
displayName: member[0].displayName,
|
||||
action: 'kicked',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a report
|
||||
*/
|
||||
export async function createReport(params: {
|
||||
roomId: string
|
||||
reporterId: string
|
||||
reporterName: string
|
||||
reportedUserId: string
|
||||
reportedUserName: string
|
||||
reason: 'harassment' | 'cheating' | 'inappropriate-name' | 'spam' | 'afk' | 'other'
|
||||
details?: string
|
||||
}) {
|
||||
const [report] = await db
|
||||
.insert(roomReports)
|
||||
.values({
|
||||
roomId: params.roomId,
|
||||
reporterId: params.reporterId,
|
||||
reporterName: params.reporterName,
|
||||
reportedUserId: params.reportedUserId,
|
||||
reportedUserName: params.reportedUserName,
|
||||
reason: params.reason,
|
||||
details: params.details,
|
||||
status: 'pending',
|
||||
})
|
||||
.returning()
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending reports for a room
|
||||
*/
|
||||
export async function getPendingReports(roomId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(roomReports)
|
||||
.where(and(eq(roomReports.roomId, roomId), eq(roomReports.status, 'pending')))
|
||||
.orderBy(desc(roomReports.createdAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all reports for a room
|
||||
*/
|
||||
export async function getAllReports(roomId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(roomReports)
|
||||
.where(eq(roomReports.roomId, roomId))
|
||||
.orderBy(desc(roomReports.createdAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark report as reviewed
|
||||
*/
|
||||
export async function markReportReviewed(reportId: string, reviewedBy: string) {
|
||||
await db
|
||||
.update(roomReports)
|
||||
.set({
|
||||
status: 'reviewed',
|
||||
reviewedAt: new Date(),
|
||||
reviewedBy,
|
||||
})
|
||||
.where(eq(roomReports.id, reportId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a report
|
||||
*/
|
||||
export async function dismissReport(reportId: string, reviewedBy: string) {
|
||||
await db
|
||||
.update(roomReports)
|
||||
.set({
|
||||
status: 'dismissed',
|
||||
reviewedAt: new Date(),
|
||||
reviewedBy,
|
||||
})
|
||||
.where(eq(roomReports.id, reportId))
|
||||
}
|
||||
Reference in New Issue
Block a user