Files
website/server/db/schema.sql
Claude 5daf260db9 Player accounts frontend + Swagger + schema comment fix
- Player portal: RequirePlayer guard, /account routes (login, register,
  settings) with shared PlayerShell; register reads /public/settings derived
  flags; AuthContext.register; api.register + api.player.* namespace.
- Admin UI: player role + status/email + reset-password hint in UserEditor,
  status column + badge-player in UsersAdmin, player_registration select in
  SettingsAdmin; 'disabled' SSO error copy.
- Swagger: Player tag + RegisterRequest/ChangeUsername/ChangePassword/
  PlayerAccount/OkFlag schemas; regenerated swagger-output.json.
- Fix: remove a semicolon from a schema.sql inline comment that broke the
  statement splitter in ensureSchema.

Verified against the live dev DB: schema migrations apply (player enum,
nullable password_hash, email/status/last_login_ip, seeded setting); 21-check
controller smoke (register gating, dup/reserved, null-hash rules, self change
username/password with session re-issue surviving the cutoff, SSO-only initial
password, banned-login refusal); case-insensitive uniqueness; public settings
expose only derived registration flags. Client builds; 133 server tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 01:49:12 -05:00

502 lines
26 KiB
SQL

-- UOMysticmoon database schema (MariaDB)
-- Run automatically by the MariaDB container (docker-entrypoint-initdb.d) on a
-- fresh volume, and idempotently by ensureSchema() on every server boot.
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
-- COLLATE is pinned to a case-insensitive (_ci) collation so uniqueness and
-- findByUsername lookups both fold case identically ('Foo' == 'foo'). This is
-- the atomic backstop for the username-uniqueness race (see the register /
-- change-username duplicate-key handling).
username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci UNIQUE,
-- Nullable: SSO-provisioned players have no password until they choose to set
-- one. A NULL hash means password login is impossible for that account
-- (validatePassword returns false).
password_hash VARCHAR(72) NULL,
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin',
-- Optional contact email (players). Not unique — SSO emails may repeat. Used
-- only for display + a future self-serve reset. email_verified is wired now so
-- an eventual SMTP verification flow needs no schema change.
email VARCHAR(255) NULL,
email_verified TINYINT(1) NOT NULL DEFAULT 0,
-- Account lifecycle, independent of role: staff can disable/ban a player
-- without changing their role. active = normal; disabled = admin-locked;
-- banned = moderation ban; pending = reserved for future email-verify gating.
-- Enforced in requireAuth + login (non-active is rejected).
status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active',
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
-- Any session token issued before this instant is rejected (see requireAuth).
-- Bumped on password change / "log out everywhere". NULL = no cutoff yet.
tokens_valid_after DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL,
last_login_ip VARCHAR(45) NULL -- IPv6-capable, set on each login
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
category ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL,
title VARCHAR(200) NOT NULL,
slug VARCHAR(220) NULL,
excerpt VARCHAR(400) NULL,
body MEDIUMTEXT NULL,
image_url VARCHAR(500) NULL,
published TINYINT(1) NOT NULL DEFAULT 0,
author_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
published_at DATETIME NULL,
CONSTRAINT fk_posts_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_posts_feed (category, published, published_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Wiki categories / sections. Defined before wiki_pages so the FK resolves on a
-- fresh install. Pages reference a category (nullable = "Uncategorized").
CREATE TABLE IF NOT EXISTS wiki_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
description VARCHAR(400) NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS wiki_pages (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
body MEDIUMTEXT NULL,
excerpt VARCHAR(400) NULL,
category_id INT NULL,
published TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
published_at DATETIME NULL,
CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_wiki_category FOREIGN KEY (category_id) REFERENCES wiki_categories(id) ON DELETE SET NULL,
FULLTEXT INDEX idx_wiki_search (title, body)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Wiki tags (many-to-many with pages).
CREATE TABLE IF NOT EXISTS wiki_tags (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
label VARCHAR(120) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS wiki_page_tags (
page_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (page_id, tag_id),
CONSTRAINT fk_wpt_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_wpt_tag FOREIGN KEY (tag_id) REFERENCES wiki_tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Internal-link index, rebuilt on each save. target_slug may point at a page
-- that does not exist yet (a "red link").
CREATE TABLE IF NOT EXISTS wiki_links (
source_page_id INT NOT NULL,
target_slug VARCHAR(120) NOT NULL,
CONSTRAINT fk_wiki_links_src FOREIGN KEY (source_page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
INDEX idx_wiki_links_target (target_slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Per-save content snapshots for history / diff / restore.
CREATE TABLE IF NOT EXISTS wiki_revisions (
id INT AUTO_INCREMENT PRIMARY KEY,
page_id INT NOT NULL,
title VARCHAR(200) NOT NULL,
body MEDIUMTEXT NULL,
excerpt VARCHAR(400) NULL,
category_id INT NULL,
editor_id INT NULL,
change_note VARCHAR(280) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_wiki_rev_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_wiki_rev_editor FOREIGN KEY (editor_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_wiki_rev_page (page_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS settings (
`key` VARCHAR(64) PRIMARY KEY,
value TEXT NULL,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_settings_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS activity_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
action VARCHAR(64) NOT NULL,
detail TEXT NULL,
ip VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_activity_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Pluggable SSO / OAuth2 provider configuration. Rows exist for the built-in
-- providers ('google', 'discord') once an admin configures them, plus any custom
-- OIDC/OAuth2 providers (id = a slug). Client secrets are stored ENCRYPTED
-- (client_secret_enc) and are never returned to a client. Built-in providers
-- hardcode their endpoint URLs in code; the *_url columns are used only by
-- custom (oidc/oauth2) providers.
CREATE TABLE IF NOT EXISTS auth_providers (
id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug
kind ENUM('google','discord','oidc','oauth2') NOT NULL,
name VARCHAR(80) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
client_id VARCHAR(255) NULL,
client_secret_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
authorize_url VARCHAR(500) NULL, -- custom providers only
token_url VARCHAR(500) NULL,
userinfo_url VARCHAR(500) NULL,
scopes VARCHAR(500) NULL,
priority INT NOT NULL DEFAULT 100,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Account linking: maps an external SSO identity to an internal user. A login via
-- SSO succeeds only if a matching (provider, subject) row exists (link-only —
-- external identities are never auto-provisioned into accounts). UNIQUE(provider,
-- subject) guarantees one external identity maps to exactly one internal user.
CREATE TABLE IF NOT EXISTS user_identities (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
provider VARCHAR(64) NOT NULL, -- matches auth_providers.id
subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id)
email VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_identity_provider_subject (provider, subject),
INDEX idx_identity_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Long-lived, revocable refresh tokens for mobile (Android) bearer-token auth.
-- The opaque refresh token is never stored in the clear — only its sha256 hash —
-- so a DB read does not leak usable tokens. Rows are rotated on every refresh
-- (old row revoked, new row inserted) and revoked on logout. Web cookie sessions
-- do NOT use this table; it is purely for the mobile bearer flow.
CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token
device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort)
user_agent VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL,
CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_mrt_user (user_id),
INDEX idx_mrt_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted
-- per session in createSession. A single logout adds this session's jti here;
-- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at
-- mirrors the token's own exp, after which the JWT fails verification anyway, so
-- the row is dead weight and gets pruned. "Log out everywhere" / password change
-- do NOT use this table — they bump users.tokens_valid_after instead (one row vs.
-- one-per-session). This is the web/cookie analogue of mobile_refresh_tokens.
CREATE TABLE IF NOT EXISTS revoked_sessions (
jti CHAR(36) PRIMARY KEY, -- the session's JWT jti (uuid v4)
user_id INT NULL,
expires_at DATETIME NOT NULL, -- mirrors the token exp (prune after)
revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_revoked_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_revoked_sessions_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
-- client secrets are, and is only ever decrypted server-side to push to the
-- bot process over the internal API; it is never returned to the admin UI
-- and the bot process never reads this table directly. `status`/`status_detail`
-- /`last_connected_at` are last-known-state mirrors of what the bot reported,
-- shown in the admin panel between polls.
CREATE TABLE IF NOT EXISTS bot_config (
id INT PRIMARY KEY DEFAULT 1,
guild_id VARCHAR(32) NULL,
bot_token_enc TEXT NULL,
application_id VARCHAR(32) NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
status_detail VARCHAR(500) NULL,
last_connected_at DATETIME NULL,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_bot_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
-- writes them. They live in the same physical database as everything else
-- (per the spec's "shared instance, clearly prefixed where needed" option)
-- purely because there's no separate migration tooling to stand up a second
-- database for a single-guild v1 bot.
-- Per-guild key/value config the bot needs at runtime (currently just the
-- mod-log channel; filters/schedules/role-menu config lands here in later
-- phases). Set via the `/modlog set` slash command, not the admin panel —
-- unlike bot_config (identity/connection secrets), this is routine Discord
-- server administration staff already do inside Discord.
CREATE TABLE IF NOT EXISTS guild_config (
guild_id VARCHAR(32) NOT NULL,
`key` VARCHAR(64) NOT NULL,
value VARCHAR(500) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (guild_id, `key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Audit trail + mod-log source of truth for ban/kick/mute/warn actions.
-- duration_seconds is only set for timed mutes; NULL for permanent
-- ban/kick/warn actions.
CREATE TABLE IF NOT EXISTS mod_actions (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
action_type ENUM('ban','kick','mute','warn') NOT NULL,
target_user_id VARCHAR(32) NOT NULL,
target_tag VARCHAR(120) NULL,
staff_user_id VARCHAR(32) NOT NULL,
staff_tag VARCHAR(120) NULL,
reason VARCHAR(500) NULL,
duration_seconds INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_mod_actions_target (guild_id, target_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Standing warnings, separate from mod_actions so /warnings can list active
-- warnings per user. expires_at is unused in Phase 2 (no decay/escalation
-- yet — deferred, see mute/warn command comments) but the column is cheap to
-- add now rather than migrate in later.
CREATE TABLE IF NOT EXISTS warnings (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
target_user_id VARCHAR(32) NOT NULL,
target_tag VARCHAR(120) NULL,
staff_user_id VARCHAR(32) NOT NULL,
staff_tag VARCHAR(120) NULL,
reason VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NULL,
INDEX idx_warnings_target (guild_id, target_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Banned-word list (Phase 3). `word` is stored as the admin typed it; matching
-- normalizes both sides at runtime (case, leetspeak, repeated chars — see
-- bot/src/filter/normalize.js), so the stored value doesn't need every
-- obfuscated variant. severity drives the auto-action: delete-only, delete +
-- warn, or delete + mute (see messageFilter.js). The role/channel allowlist
-- that bypasses filtering entirely lives in guild_config (keys
-- filter_allow_roles / filter_allow_channels, CSV of snowflake ids) rather
-- than a separate table — it's a short, rarely-changed list.
CREATE TABLE IF NOT EXISTS filter_words (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
word VARCHAR(200) NOT NULL,
severity ENUM('delete','warn','mute') NOT NULL DEFAULT 'delete',
added_by VARCHAR(32) NULL,
added_by_tag VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_filter_words_guild_word (guild_id, word)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Scheduled/recurring messages (Phase 4). A row is EITHER recurring
-- (cron_expression set, run_at NULL — reposts on the node-cron schedule
-- forever until disabled/removed) OR one-off (run_at set, cron_expression
-- NULL — posted once, then sent_at is stamped so the scheduler's due-message
-- sweep never reposts it). content is plain text for now — the original spec
-- allows richer embed JSON here, deferred since authoring embed JSON through a
-- single slash-command string option isn't practical without a modal/admin UI.
CREATE TABLE IF NOT EXISTS scheduled_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
channel_id VARCHAR(32) NOT NULL,
content VARCHAR(2000) NOT NULL,
cron_expression VARCHAR(100) NULL,
run_at DATETIME NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
sent_at DATETIME NULL,
created_by VARCHAR(32) NULL,
created_by_tag VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_schedule_kind CHECK (
(cron_expression IS NOT NULL AND run_at IS NULL) OR
(cron_expression IS NULL AND run_at IS NOT NULL)
),
INDEX idx_scheduled_due (run_at, sent_at, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Self-assignable role menus (Phase 5). Button-based, not reaction-based —
-- avoids needing the messageReactionAdd/Remove events and their own intent.
-- `mapping` is a JSON array of {roleId, label}, validated against at click
-- time (see bot/src/discord/roleMenuHandler.js) so a stale/foreign button
-- customId can't toggle an untracked role. Auto-role-on-join is simpler and
-- reuses guild_config (key auto_role_id) rather than a table of its own.
CREATE TABLE IF NOT EXISTS role_menus (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
channel_id VARCHAR(32) NOT NULL,
message_id VARCHAR(32) NOT NULL,
mapping TEXT NOT NULL,
created_by VARCHAR(32) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_role_menus_message (message_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Timed role assignments (temp-mute-equivalent roles, timed event roles).
-- Swept once a minute (bot/src/roles/tempRoleSweeper.js) — expired rows have
-- their Discord role removed and the row deleted. UNIQUE(guild,user,role) so
-- re-granting the same temp role just refreshes its expiry via ON DUPLICATE
-- KEY UPDATE rather than stacking duplicate rows.
CREATE TABLE IF NOT EXISTS temp_roles (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
user_id VARCHAR(32) NOT NULL,
role_id VARCHAR(32) NOT NULL,
expires_at DATETIME NOT NULL,
created_by VARCHAR(32) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_temp_roles_user_role (guild_id, user_id, role_id),
INDEX idx_temp_roles_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Audit trail for the auto-rotating primary invite (Phase 6). triggered_by
-- NULL means the weekly scheduled rotation did it, not a staff member — see
-- bot/src/invites/inviteRotator.js, shared by both /invite rotate and the
-- cron job so both paths log identically. The channel invites are created in
-- is configured separately in guild_config (key invite_channel_id).
CREATE TABLE IF NOT EXISTS invite_log (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
channel_id VARCHAR(32) NOT NULL,
invite_code VARCHAR(20) NOT NULL,
triggered_by VARCHAR(32) NULL,
triggered_by_tag VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at DATETIME NULL,
INDEX idx_invite_log_guild (guild_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Guild member join/leave events (Phase 6b). Powers the dashboard's joins/leaves
-- feeds and the invite-usage view. Bot-owned (written by bot/src/discord/
-- guildMemberAdd.js + guildMemberRemove.js). For joins, invite_code/inviter_*
-- record which invite was used when the bot could attribute it (best-effort, see
-- bot/src/discord/inviteTracker.js) — NULL when undeterminable or for leaves.
-- These are member lifecycle events, not moderation actions, hence separate from
-- mod_actions.
CREATE TABLE IF NOT EXISTS member_events (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
event_type ENUM('join','leave') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
invite_code VARCHAR(20) NULL,
inviter_id VARCHAR(32) NULL,
inviter_tag VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_member_events_guild (guild_id, created_at),
INDEX idx_member_events_user (guild_id, discord_user_id, created_at),
INDEX idx_member_events_invite (guild_id, invite_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Automated content-filter hits (Phase 6b): one row per message the word filter
-- or the foreign-invite filter deleted. Separate from mod_actions (which still
-- records the resulting warn/mute) so the dashboard can show filter volume in
-- its own right. `matched` holds the offending word (word hits) or the blocked
-- invite code (invite hits); `action_taken` is what the pipeline did. Bot-owned
-- (bot/src/discord/messageFilter.js).
CREATE TABLE IF NOT EXISTS filter_hits (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
hit_type ENUM('word','invite') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
channel_id VARCHAR(32) NULL,
matched VARCHAR(200) NULL,
action_taken ENUM('delete','warn','mute') NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_filter_hits_guild (guild_id, created_at),
INDEX idx_filter_hits_user (guild_id, discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Automated spam-detection hits (Phase 6b): rate-limit / mass-mention /
-- mass-emoji triggers. As with filter_hits, mod_actions still logs the resulting
-- warn; this records the detection itself for the dashboard's spam feed.
-- Bot-owned (bot/src/discord/messageFilter.js via bot/src/filter/spamFilter.js).
CREATE TABLE IF NOT EXISTS spam_hits (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
spam_type ENUM('rate_limit','mass_mention','mass_emoji') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
channel_id VARCHAR(32) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_spam_hits_guild (guild_id, created_at),
INDEX idx_spam_hits_user (guild_id, discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Staff notes on a Discord user, surfaced in the admin moderation dashboard
-- (Phase 6). Unlike the tables above, this one is SERVER-owned — it is written
-- and read only by the main site (moderation.controller), never by the bot.
-- Keyed by discord_user_id (a snowflake, matching mod_actions.target_user_id) so
-- notes attach to a Discord identity even when it has no linked site account.
-- Notes are never user-visible; admin_only notes are further restricted to the
-- admin role (moderators see staff_only only) — enforced in the query layer.
CREATE TABLE IF NOT EXISTS mod_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
discord_user_id VARCHAR(32) NOT NULL,
author_user_id INT NULL,
author_tag VARCHAR(120) NULL,
body TEXT NOT NULL,
visibility ENUM('staff_only','admin_only') NOT NULL DEFAULT 'staff_only',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mod_notes_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_mod_notes_user (discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
-- (The category foreign key is only added on fresh installs; on upgraded databases
-- referential integrity for category_id is enforced in application code.)
-- Opt-in TOTP two-factor columns for databases created before login hardening.
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
-- Session-revocation cutoff for databases created before token revocation landed.
ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL;
-- Moderation dashboard (Phase 6): add the 'moderator' role to databases created
-- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is
-- an idempotent no-op, so it is safe to run on every boot.
-- Player accounts: widen the enum again to include 'player' (self-service public
-- accounts). Same idempotent-MODIFY pattern.
ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin';
-- Player accounts: make password_hash nullable (SSO-only players), pin the
-- username collation (case-insensitive uniqueness backstop), and add the player
-- columns to databases created before this. MODIFY is an idempotent no-op when
-- the column already matches; ADD COLUMN IF NOT EXISTS is safe to re-run.
ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(72) NULL;
ALTER TABLE users MODIFY COLUMN username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active';
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
-- Player self-registration mode: disabled | password | sso | both. Default off,
-- so the system behaves exactly as today until an admin opts in.
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL;
ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body);