-- 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,
  username      VARCHAR(32)  NOT NULL UNIQUE,
  password_hash VARCHAR(72)  NOT NULL,
  role          ENUM('admin','editor') NOT NULL DEFAULT 'admin',
  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
) 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;

-- 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;

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);
