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:
Thomas Hallock
2025-10-13 11:22:20 -05:00
parent 97d16041df
commit 84f3c4bcfd
4 changed files with 618 additions and 0 deletions

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

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

View File

@@ -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',
})
}
}
/**

View 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))
}