feat: add database schema for room moderation and invitations

Add comprehensive database schema to support:
- Room bans: Track banned users with reasons and timestamps
- Room reports: Allow players to report others for misconduct
- Room invitations: Send and track room invitations
- Room member history: Track all users who have ever been in a room

This foundational schema enables the complete moderation system
including banning, kicking, reporting, and invitation features.

🤖 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:02 -05:00
parent 07696f3264
commit 97d16041df
10 changed files with 1513 additions and 2 deletions

View File

@@ -0,0 +1,30 @@
CREATE TABLE `room_reports` (
`id` text PRIMARY KEY NOT NULL,
`room_id` text NOT NULL,
`reporter_id` text NOT NULL,
`reporter_name` text(50) NOT NULL,
`reported_user_id` text NOT NULL,
`reported_user_name` text(50) NOT NULL,
`reason` text NOT NULL,
`details` text(500),
`status` text DEFAULT 'pending' NOT NULL,
`created_at` integer NOT NULL,
`reviewed_at` integer,
`reviewed_by` text,
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `room_bans` (
`id` text PRIMARY KEY NOT NULL,
`room_id` text NOT NULL,
`user_id` text NOT NULL,
`user_name` text(50) NOT NULL,
`banned_by` text NOT NULL,
`banned_by_name` text(50) NOT NULL,
`reason` text NOT NULL,
`notes` text(500),
`created_at` integer NOT NULL,
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_room_bans_user_room` ON `room_bans` (`user_id`,`room_id`);

View File

@@ -0,0 +1,29 @@
CREATE TABLE `room_member_history` (
`id` text PRIMARY KEY NOT NULL,
`room_id` text NOT NULL,
`user_id` text NOT NULL,
`display_name` text(50) NOT NULL,
`first_joined_at` integer NOT NULL,
`last_seen_at` integer NOT NULL,
`last_action` text DEFAULT 'active' NOT NULL,
`last_action_at` integer NOT NULL,
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `room_invitations` (
`id` text PRIMARY KEY NOT NULL,
`room_id` text NOT NULL,
`user_id` text NOT NULL,
`user_name` text(50) NOT NULL,
`invited_by` text NOT NULL,
`invited_by_name` text(50) NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`invitation_type` text DEFAULT 'manual' NOT NULL,
`message` text(500),
`created_at` integer NOT NULL,
`responded_at` integer,
`expires_at` integer,
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_room_invitations_user_room` ON `room_invitations` (`user_id`,`room_id`);

View File

@@ -1,8 +1,8 @@
{
"version": "6",
"dialect": "sqlite",
"id": "840cc055-2f32-4ae4-81ff-255641cbbd1c",
"prevId": "cbd94d51-1454-467c-a471-ccbfca886a1a",
"id": "e01e9757-73e9-413f-8126-090e6ff156c8",
"prevId": "840cc055-2f32-4ae4-81ff-255641cbbd1c",
"tables": {
"abacus_settings": {
"name": "abacus_settings",
@@ -513,6 +513,200 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"room_reports": {
"name": "room_reports",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reporter_id": {
"name": "reporter_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reporter_name": {
"name": "reporter_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reported_user_id": {
"name": "reported_user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reported_user_name": {
"name": "reported_user_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text(500)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reviewed_at": {
"name": "reviewed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reviewed_by": {
"name": "reviewed_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"room_reports_room_id_arcade_rooms_id_fk": {
"name": "room_reports_room_id_arcade_rooms_id_fk",
"tableFrom": "room_reports",
"tableTo": "arcade_rooms",
"columnsFrom": ["room_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"room_bans": {
"name": "room_bans",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_name": {
"name": "user_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"banned_by": {
"name": "banned_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"banned_by_name": {
"name": "banned_by_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text(500)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_room_bans_user_room": {
"name": "idx_room_bans_user_room",
"columns": ["user_id", "room_id"],
"isUnique": true
}
},
"foreignKeys": {
"room_bans_room_id_arcade_rooms_id_fk": {
"name": "room_bans_room_id_arcade_rooms_id_fk",
"tableFrom": "room_bans",
"tableTo": "arcade_rooms",
"columnsFrom": ["room_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stats": {
"name": "user_stats",
"columns": {

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,20 @@
"when": 1759930182541,
"tag": "0004_shiny_madelyne_pryor",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1760362058906,
"tag": "0005_flimsy_squadron_sinister",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1760365860888,
"tag": "0006_pretty_invaders",
"breakpoints": true
}
]
}

View File

@@ -10,5 +10,9 @@ export * from './arcade-rooms'
export * from './arcade-sessions'
export * from './players'
export * from './room-members'
export * from './room-member-history'
export * from './room-invitations'
export * from './room-reports'
export * from './room-bans'
export * from './user-stats'
export * from './users'

View File

@@ -0,0 +1,45 @@
import { createId } from '@paralleldrive/cuid2'
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { arcadeRooms } from './arcade-rooms'
/**
* Users banned from specific rooms by hosts
*/
export const roomBans = sqliteTable(
'room_bans',
{
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
// Banned user
userId: text('user_id').notNull(),
userName: text('user_name', { length: 50 }).notNull(), // Name at time of ban
// Who banned them
bannedBy: text('banned_by').notNull(), // Host user ID
bannedByName: text('banned_by_name', { length: 50 }).notNull(),
// Ban details
reason: text('reason', {
enum: ['harassment', 'cheating', 'inappropriate-name', 'spam', 'afk', 'other'],
}).notNull(),
notes: text('notes', { length: 500 }), // Optional notes from host
// Timestamps
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
// One ban record per user per room
userRoomIdx: uniqueIndex('idx_room_bans_user_room').on(table.userId, table.roomId),
})
)
export type RoomBan = typeof roomBans.$inferSelect
export type NewRoomBan = typeof roomBans.$inferInsert

View File

@@ -0,0 +1,61 @@
import { createId } from '@paralleldrive/cuid2'
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { arcadeRooms } from './arcade-rooms'
/**
* Room invitations sent by hosts to users
* Used to invite users back after unbanning or to invite new users
*/
export const roomInvitations = sqliteTable(
'room_invitations',
{
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
// Invited user
userId: text('user_id').notNull(),
userName: text('user_name', { length: 50 }).notNull(), // Name at time of invitation
// Who invited them
invitedBy: text('invited_by').notNull(), // Host user ID
invitedByName: text('invited_by_name', { length: 50 }).notNull(),
// Invitation status
status: text('status', {
enum: ['pending', 'accepted', 'declined', 'expired'],
})
.notNull()
.default('pending'),
// Type of invitation
invitationType: text('invitation_type', {
enum: ['manual', 'auto-unban', 'auto-create'],
})
.notNull()
.default('manual'),
// Optional message
message: text('message', { length: 500 }),
// Timestamps
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
respondedAt: integer('responded_at', { mode: 'timestamp' }),
expiresAt: integer('expires_at', { mode: 'timestamp' }), // Optional expiration
},
(table) => ({
// One pending invitation per user per room
userRoomIdx: uniqueIndex('idx_room_invitations_user_room').on(table.userId, table.roomId),
})
)
export type RoomInvitation = typeof roomInvitations.$inferSelect
export type NewRoomInvitation = typeof roomInvitations.$inferInsert

View File

@@ -0,0 +1,49 @@
import { createId } from '@paralleldrive/cuid2'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { arcadeRooms } from './arcade-rooms'
/**
* Historical record of all users who have ever been in a room
* This table is append-only and tracks the complete history of room membership
*/
export const roomMemberHistory = sqliteTable('room_member_history', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
displayName: text('display_name', { length: 50 }).notNull(),
// First time this user joined the room
firstJoinedAt: integer('first_joined_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
// Last time we saw this user in the room
lastSeenAt: integer('last_seen_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
// Track what happened to this membership
// 'active' - currently in room
// 'left' - voluntarily left
// 'kicked' - kicked by host
// 'banned' - banned by host
lastAction: text('last_action', {
enum: ['active', 'left', 'kicked', 'banned'],
})
.notNull()
.default('active'),
// When the last action occurred
lastActionAt: integer('last_action_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
})
export type RoomMemberHistory = typeof roomMemberHistory.$inferSelect
export type NewRoomMemberHistory = typeof roomMemberHistory.$inferInsert

View File

@@ -0,0 +1,47 @@
import { createId } from '@paralleldrive/cuid2'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { arcadeRooms } from './arcade-rooms'
/**
* Reports submitted by room members about other members
*/
export const roomReports = sqliteTable('room_reports', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
// Who reported
reporterId: text('reporter_id').notNull(),
reporterName: text('reporter_name', { length: 50 }).notNull(),
// Who was reported
reportedUserId: text('reported_user_id').notNull(),
reportedUserName: text('reported_user_name', { length: 50 }).notNull(),
// Report details
reason: text('reason', {
enum: ['harassment', 'cheating', 'inappropriate-name', 'spam', 'afk', 'other'],
}).notNull(),
details: text('details', { length: 500 }), // Optional additional context
// Status tracking
status: text('status', {
enum: ['pending', 'reviewed', 'dismissed'],
})
.notNull()
.default('pending'),
// Timestamps
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
reviewedAt: integer('reviewed_at', { mode: 'timestamp' }),
reviewedBy: text('reviewed_by'), // Host user ID who reviewed
})
export type RoomReport = typeof roomReports.$inferSelect
export type NewRoomReport = typeof roomReports.$inferInsert