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:
30
apps/web/drizzle/0005_flimsy_squadron_sinister.sql
Normal file
30
apps/web/drizzle/0005_flimsy_squadron_sinister.sql
Normal 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`);
|
||||
29
apps/web/drizzle/0006_pretty_invaders.sql
Normal file
29
apps/web/drizzle/0006_pretty_invaders.sql
Normal 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`);
|
||||
@@ -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": {
|
||||
|
||||
1038
apps/web/drizzle/meta/0006_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
45
apps/web/src/db/schema/room-bans.ts
Normal file
45
apps/web/src/db/schema/room-bans.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'
|
||||
|
||||
/**
|
||||
* 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
|
||||
61
apps/web/src/db/schema/room-invitations.ts
Normal file
61
apps/web/src/db/schema/room-invitations.ts
Normal 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
|
||||
49
apps/web/src/db/schema/room-member-history.ts
Normal file
49
apps/web/src/db/schema/room-member-history.ts
Normal 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
|
||||
47
apps/web/src/db/schema/room-reports.ts
Normal file
47
apps/web/src/db/schema/room-reports.ts
Normal 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
|
||||
Reference in New Issue
Block a user