-- Runic Gateway 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)
  device_name VARCHAR(100) NULL,              -- friendly label the app may send (M9)
  user_agent  VARCHAR(255) NULL,
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  last_used_at DATETIME NULL,                 -- last time this session token was issued/used (M9)
  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;

-- Mobile SSO authorization bridge (M9). Two short-lived, self-pruning tables that
-- bridge a browser SSO redirect flow to the native app. They carry the app↔website
-- PKCE + CSRF state (a SECOND PKCE layer, distinct from the website↔IdP PKCE the
-- sso_tx cookie already carries) and the one-time code the app trades for bearer
-- tokens. No secret is stored in the clear: code_challenge is a hash by construction
-- and the authorization code is stored as a sha256 hash only (same pattern as
-- mobile_refresh_tokens / user_invites / password_resets). See docs BACKEND_DESIGN §3/§4.
CREATE TABLE IF NOT EXISTS mobile_auth_sessions (
  id             INT AUTO_INCREMENT PRIMARY KEY,
  session_id     CHAR(36)     NOT NULL UNIQUE,        -- uuid; carried inside the signed sso_tx (mode 'mobile')
  provider       VARCHAR(40)  NOT NULL,               -- provider id, validated enabled at /start
  code_challenge VARCHAR(255) NOT NULL,               -- app-supplied PKCE S256 challenge (base64url)
  redirect_uri   VARCHAR(255) NOT NULL,               -- app callback; EXACT-match against the allowlist
  state          VARCHAR(255) NOT NULL,               -- app-generated opaque CSRF value, echoed to the app
  status         ENUM('pending','completed','consumed') NOT NULL DEFAULT 'pending',
  user_id        INT          NULL,                   -- set once SSO resolves the account
  trust_device   TINYINT(1)   NOT NULL DEFAULT 0,     -- user ticked "trust this device" on the Custom Tab TOTP form;
                                                      -- a BOOLEAN only — the trust token itself is minted at /exchange
                                                      -- and returned over that app→server call, never stored here
  created_at     DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  expires_at     DATETIME NOT NULL,                   -- ~10 min (one redirect round-trip incl. TOTP)
  used_at        DATETIME NULL,                       -- stamped at exchange
  CONSTRAINT fk_mas_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_mas_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS mobile_auth_codes (
  id          INT AUTO_INCREMENT PRIMARY KEY,
  code_hash   CHAR(64)     NOT NULL UNIQUE,           -- sha256 hex of the opaque >=128-bit code
  user_id     INT          NOT NULL,
  session_id  CHAR(36)     NOT NULL,                  -- owning mobile_auth_sessions.session_id (ties code→PKCE challenge)
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  expires_at  DATETIME NOT NULL,                      -- very short (~5 min)
  used_at     DATETIME NULL,                          -- set on first successful exchange (single use)
  CONSTRAINT fk_mac_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_mac_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;

-- Trusted devices for MFA (opt-in "Trust this device"). A trusted device lets a
-- browser/app SKIP the TOTP step at login — never the password. Pattern-identical
-- to mobile_refresh_tokens: the opaque trust token lives client-side (the rg_trust
-- cookie on web, EncryptedSharedPreferences on mobile) and only its sha256 hash is
-- stored here (token_hash UNIQUE, so the login path can look a device up in O(1)).
-- sha256 (not bcrypt) because the token is a 256-bit random value looked up BY its
-- hash — a per-row salt would break the index lookup. Trust is consulted only at
-- the login/password step, never at token refresh, and is revoked on untrust /
-- password change/reset / TOTP disable. Capped at 10 rows per user (enforced in
-- application code — no silent pruning). See docs/website/TRUSTED_DEVICES_MFA.md.
CREATE TABLE IF NOT EXISTS trusted_devices (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  user_id      INT NOT NULL,
  token_hash   CHAR(64)     NOT NULL UNIQUE,          -- sha256 hex of the opaque trust token
  platform     ENUM('web','mobile') NOT NULL DEFAULT 'web',
  device_name  VARCHAR(100) NULL,                     -- friendly label for the Trusted Devices list
  device_hash  VARCHAR(32)  NULL,                     -- best-effort UA+IP (sessionMeta) — display only
  user_agent   VARCHAR(255) NULL,
  created_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  last_used_at DATETIME NULL,                         -- stamped when trust is honored at login
  expires_at   DATETIME NOT NULL,                     -- created_at + 30d
  revoked_at   DATETIME NULL,
  CONSTRAINT fk_td_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_td_user (user_id),
  INDEX idx_td_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Single-use recovery (backup) codes for MFA. Generated at TOTP enrollment (10 at a
-- time, shown to the user ONCE) so a user who loses their authenticator can complete
-- login without an admin reset. code_hash is a BCRYPT hash (not sha256): a recovery
-- code is a human-typed, lower-entropy fallback credential — the closest analogue to
-- a password — and there is no hash-lookup constraint (we fetch the user's <=10 rows
-- and bcrypt.compare each, exactly like password verification). Cleared wholesale on
-- TOTP disable / password change/reset. See docs/website/TRUSTED_DEVICES_MFA.md.
CREATE TABLE IF NOT EXISTS recovery_codes (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  user_id    INT NOT NULL,
  code_hash  VARCHAR(72)  NOT NULL,                   -- bcrypt hash of one recovery code
  used_at    DATETIME NULL,                           -- single-use marker
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fk_rc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_rc_user (user_id)
) 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;

-- Outbound email configuration (Gmail over OAuth2 / SMTP XOAUTH2). Singleton row
-- (id = 1), mirroring bot_config: the DB only ever holds the AES-256-GCM-encrypted
-- refresh token, never plaintext, and the client id/secret are NOT stored here —
-- they are read live from the `google` auth_providers row. The refresh token is
-- captured by the in-app "Connect Gmail" consent flow and is write-only over the
-- admin API (never returned; responses expose only hasRefreshToken).
CREATE TABLE IF NOT EXISTS email_config (
  id                 INT PRIMARY KEY DEFAULT 1,
  provider           VARCHAR(20)  NOT NULL DEFAULT 'gmail_oauth2',
  enabled            TINYINT(1)   NOT NULL DEFAULT 0,
  sender_email       VARCHAR(255) NULL,        -- connected Gmail address (from userinfo)
  sender_name        VARCHAR(120) NULL,        -- optional From display name
  refresh_token_enc  TEXT         NULL,        -- AES-256-GCM ciphertext, never exposed
  status             VARCHAR(20)  NOT NULL DEFAULT 'unconfigured',
  status_detail      VARCHAR(500) NULL,
  last_verified_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_email_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
  CONSTRAINT chk_email_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ── uo-link sidecar ────────────────────────────────────────────────────────
-- Connection config for the uo-link sidecar (the HTTP + WebSocket bridge to the
-- ServUO shard). Singleton row (id = 1), mirroring bot_config/email_config: the
-- DB only ever holds the AES-256-GCM-encrypted shared-secret auth token, never
-- plaintext, and it is only decrypted server-side (to call the sidecar). It is
-- never returned to the admin UI — responses expose only `hasToken`. base_url is
-- the REST endpoint, ws_url the live-feed endpoint; both are configurable because
-- in production the sidecar runs on a different host from the website. `status`/
-- `plugin_connected`/`last_event_at`/`boot_id` mirror the sidecar's last-known
-- state for the admin panel between polls; `boot_id` tracks server.hello.bootId
-- so a shard restart can be detected (and caches dropped).
CREATE TABLE IF NOT EXISTS uo_link_config (
  id                INT PRIMARY KEY DEFAULT 1,
  base_url          VARCHAR(255) NULL,
  ws_url            VARCHAR(255) NULL,
  auth_token_enc    TEXT         NULL,
  protocol          INT          NOT NULL DEFAULT 3,
  enabled           TINYINT(1)   NOT NULL DEFAULT 0,
  status            VARCHAR(20)  NOT NULL DEFAULT 'disconnected',
  status_detail     VARCHAR(500) NULL,
  plugin_connected  TINYINT(1)   NOT NULL DEFAULT 0,
  last_event_at     DATETIME     NULL,
  boot_id           VARCHAR(64)  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_uo_link_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
  CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Append-only log of notable shard events ingested from the uo-link WebSocket
-- feed. The site OWNS this data (it does not query the sidecar's SQLite): the WS
-- client writes here, and the public/admin read endpoints + live feeds read from
-- here. Only "notable" kinds are logged (sales, deaths, murders, mob.killed,
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
-- here — they update shard_online / shard_economy instead, keeping the log lean.
-- dedupe_key = sha256(kind + t + stable-json(payload)) truncated to 40 hex chars
-- (fits CHAR(40)); with the UNIQUE index it makes INSERT IGNORE idempotent so
-- WS-reconnect backfill never double-inserts.
CREATE TABLE IF NOT EXISTS shard_events (
  id          BIGINT AUTO_INCREMENT PRIMARY KEY,
  kind        VARCHAR(48)  NOT NULL,
  t           BIGINT       NOT NULL,               -- event time, epoch ms (from the sidecar)
  boot_id     VARCHAR(64)  NULL,                   -- shard boot id at ingest (server.hello.bootId)
  payload     JSON         NOT NULL,               -- the full event object
  dedupe_key  CHAR(40)     NOT NULL UNIQUE,
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_shard_events_kind_t (kind, t),
  INDEX idx_shard_events_t (t)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Current online players. Upserted on mob.login, refreshed on char.vitals, and
-- removed on mob.logout. Cleared wholesale when the shard restarts (a new
-- server.hello.bootId). web_id is the linked website user id (present when the
-- account is linked), so the roster can be correlated to site accounts.
CREATE TABLE IF NOT EXISTS shard_online (
  serial      VARCHAR(20)  NOT NULL PRIMARY KEY,   -- mobile serial (opaque hex key)
  name        VARCHAR(120) NULL,
  acct        VARCHAR(120) NULL,
  web_id      INT          NULL,
  map         VARCHAR(40)  NULL,
  x           INT          NULL,
  y           INT          NULL,
  z           INT          NULL,
  hits        INT          NULL,
  hits_max    INT          NULL,
  mana        INT          NULL,
  mana_max    INT          NULL,
  stam        INT          NULL,
  stam_max    INT          NULL,
  str         INT          NULL,
  dex         INT          NULL,
  `int`       INT          NULL,
  updated_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_shard_online_acct (acct)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Total-gold-supply time series (from the periodic economy.supply event). Kept
-- append-only so the public status page can render a supply-over-time sparkline.
CREATE TABLE IF NOT EXISTS shard_economy (
  id          BIGINT AUTO_INCREMENT PRIMARY KEY,
  accounts    INT          NULL,                   -- number of accounts included in the total
  gold        BIGINT       NULL,                   -- total gold supply across all accounts
  t           BIGINT       NOT NULL,               -- sample time, epoch ms
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_shard_economy_t (t)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Current decay stage per house, upserted on house.decay. is_idoc is a derived
-- flag (stage == 'IDOC') so the public "houses in danger" list is a cheap
-- indexed lookup rather than a scan.
CREATE TABLE IF NOT EXISTS shard_houses (
  serial         VARCHAR(20)  NOT NULL PRIMARY KEY,
  stage          VARCHAR(24)  NULL,                -- Somewhat | Fairly | Greatly | IDOC | Collapsed | ...
  map            VARCHAR(40)  NULL,
  x              INT          NULL,
  y              INT          NULL,
  z              INT          NULL,
  region         VARCHAR(120) NULL,
  name           VARCHAR(160) NULL,
  owner_serial   VARCHAR(20)  NULL,
  owner_acct     VARCHAR(120) NULL,
  built_on       DATETIME     NULL,
  last_refreshed DATETIME     NULL,
  is_idoc        TINYINT(1)   NOT NULL DEFAULT 0,
  updated_at     DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_shard_houses_idoc (is_idoc)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Site-side mirror of in-game-account → website-user links. The sidecar is the
-- source of truth (it tags the game account with the websiteUserId on
-- /link/confirm); this table mirrors it so the player portal can list a user's
-- linked accounts and enforce ownership on roster/vendor reads without a shard
-- round-trip. account is unique (one game account maps to at most one site user);
-- a single user may link several game accounts.
CREATE TABLE IF NOT EXISTS shard_account_links (
  account    VARCHAR(120) NOT NULL PRIMARY KEY,
  user_id    INT          NOT NULL,
  char_name  VARCHAR(120) NULL,
  linked_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fk_shard_links_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_shard_links_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Current champion-spawn board, upserted on champ.update and removed on
-- champ.remove. Mirrors the sidecar's /champs projection into our own store so
-- the public Champions page (and its live deltas) survive a shard outage, the
-- same way shard_online / shard_houses do. Three families share one table, told
-- apart by `category` (champion | mini | sea); category-specific fields (level,
-- kills, boss, restartAt, hits, …) live in the JSON `payload` so the schema does
-- not have to model every variant.
CREATE TABLE IF NOT EXISTS shard_champs (
  serial      VARCHAR(20)  NOT NULL PRIMARY KEY,   -- controller/mobile serial (opaque hex)
  category    VARCHAR(16)  NULL,                   -- champion | mini | sea
  type        VARCHAR(80)  NULL,
  name        VARCHAR(120) NULL,
  status      VARCHAR(16)  NULL,                   -- active | cooldown | dormant
  active      TINYINT(1)   NOT NULL DEFAULT 0,
  map         VARCHAR(40)  NULL,
  x           INT          NULL,
  y           INT          NULL,
  z           INT          NULL,
  boss_up     TINYINT(1)   NOT NULL DEFAULT 0,
  payload     JSON         NOT NULL,               -- the full champ.update object
  t           BIGINT       NULL,                   -- event time, epoch ms
  updated_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_shard_champs_category (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Current open help-page (support ticket) queue, upserted on page.new/page.updated
-- and removed on page.closed. Snapshotted authoritatively from the sidecar's
-- GET /pages on every (re)connect. page_id is the sender's serial (one page per
-- player). Staff-only data — served on the admin channel, never public.
CREATE TABLE IF NOT EXISTS shard_pages (
  page_id      VARCHAR(20)  NOT NULL PRIMARY KEY,  -- sender serial (one page per player)
  type         VARCHAR(40)  NULL,                  -- Bug | Stuck | Account | Question | ...
  sender_name  VARCHAR(120) NULL,
  sender_acct  VARCHAR(120) NULL,
  web_id       INT          NULL,                  -- linked website user id, if any
  message      TEXT         NULL,
  map          VARCHAR(40)  NULL,
  x            INT          NULL,
  y            INT          NULL,
  z            INT          NULL,
  sent_ms      BIGINT       NULL,                  -- when the page was opened, epoch ms
  handled      TINYINT(1)   NOT NULL DEFAULT 0,    -- a staffer claimed it in game
  handler      VARCHAR(120) NULL,
  payload      JSON         NOT NULL,              -- the full page.new/updated object
  updated_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_shard_pages_handled (handled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state
-- snapshot emitted only on change) and removed on guild.remove. The leader is an
-- actor object flattened into leader_* columns; the full event is kept in
-- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds
-- projection into our store so the public Guilds page survives a shard outage.
CREATE TABLE IF NOT EXISTS shard_guilds (
  id            INT          NOT NULL PRIMARY KEY,   -- in-game guild id
  name          VARCHAR(120) NULL,
  abbr          VARCHAR(24)  NULL,
  members       INT          NULL,
  online        INT          NULL,
  alliance      VARCHAR(120) NULL,
  leader_serial VARCHAR(20)  NULL,
  leader_name   VARCHAR(120) NULL,
  leader_acct   VARCHAR(120) NULL,
  leader_web_id INT          NULL,
  payload       JSON         NOT NULL,               -- the full guild.update object
  t             BIGINT       NULL,                   -- event time, epoch ms
  updated_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_shard_guilds_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
-- city.update (full-state, emitted only on change; there is no remove event since
-- the set of cities is fixed). governor / governorElect are actor objects
-- flattened into columns; the full event is kept in `payload`. Empty on shards
-- that do not run the City Loyalty system.
CREATE TABLE IF NOT EXISTS shard_governors (
  city             VARCHAR(40)  NOT NULL PRIMARY KEY, -- Britain | Moonglow | ...
  governor_serial  VARCHAR(20)  NULL,
  governor_name    VARCHAR(120) NULL,
  governor_acct    VARCHAR(120) NULL,
  governor_web_id  INT          NULL,
  elect_serial     VARCHAR(20)  NULL,
  elect_name       VARCHAR(120) NULL,
  elect_acct       VARCHAR(120) NULL,
  election_phase   VARCHAR(16)  NULL,                 -- none | nominate | vote | pending
  candidates       INT          NULL,
  auto_pick_at     DATETIME     NULL,
  payload          JSON         NOT NULL,             -- the full city.update object
  t                BIGINT       NULL,                 -- event time, epoch ms
  updated_at       DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Governor term history — the "who governed when" ledger behind the Governors
-- board. Captured from day one (history cannot be backfilled) on every observed
-- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one
-- opened. `votes` stays NULL — the city.update feed exposes only the candidate
-- COUNT and election phase, not per-candidate tallies, so we record who governed
-- and when (reliable) and never fabricate vote numbers. The look-back UI ("who
-- were all the governors of Britain?") reads this table.
CREATE TABLE IF NOT EXISTS shard_governor_terms (
  id               BIGINT AUTO_INCREMENT PRIMARY KEY,
  city             VARCHAR(40)  NOT NULL,
  governor_serial  VARCHAR(20)  NULL,
  governor_name    VARCHAR(120) NULL,
  governor_acct    VARCHAR(120) NULL,
  governor_web_id  INT          NULL,
  started_at       BIGINT       NOT NULL,             -- term start, epoch ms
  ended_at         BIGINT       NULL,                 -- term end epoch ms (NULL = current)
  votes            INT          NULL,                 -- not in the feed (reserved)
  created_at       DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_shard_gov_terms_city (city, started_at),
  INDEX idx_shard_gov_terms_open (city, ended_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the
-- latest presence.online aggregate: total count plus per-facet and per-region
-- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this
-- is the rolled-up headcount the public "Players Online" widget renders. The
-- time series, if ever needed, is available from GET /history?kind=presence.online.
CREATE TABLE IF NOT EXISTS shard_presence (
  id          INT PRIMARY KEY DEFAULT 1,
  count       INT          NOT NULL DEFAULT 0,
  by_facet    JSON         NULL,                      -- { "Felucca": 12, "Trammel": 30 }
  by_region   JSON         NULL,                      -- { "Britain": 18, "Wilderness": 9 }
  t           BIGINT       NULL,                      -- snapshot time, epoch ms
  updated_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- The shard's published ruleset (Protocol 3.0 world.ruleset). Singleton row
-- (id = 1) holding the latest frame: expansion, which optional systems are on,
-- skill/stat caps, account and house limits, champion scroll rules, the
-- save/restart schedule. The shard re-emits it on every sidecar connect, so this
-- row is simply overwritten; `rev` is the shard's own FNV-1a of the body, which
-- distinguishes "same ruleset, re-sent on reconnect" from "an operator changed a
-- .cfg". No row at all means the shard has never published one — served as null,
-- which the rules page renders differently from a published ruleset.
CREATE TABLE IF NOT EXISTS shard_ruleset (
  id          INT PRIMARY KEY DEFAULT 1,
  rev         VARCHAR(32)  NULL,
  expansion   VARCHAR(16)  NULL,                     -- hoisted for cheap display
  payload     JSON         NOT NULL,                 -- the whole world.ruleset frame
  t           BIGINT       NULL,                     -- frame time, epoch ms
  updated_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Points/loyalty leaderboards (Protocol 3.0 points.board). One row per point
-- system, keyed by the shard's own PointsType name. The shard publishes ~25 of
-- these (Queen's Loyalty, Void Pool, the nine city loyalties, …), each a standing
-- players accumulate over months.
--
-- The top-N list stays inside `payload` rather than being normalized into a
-- shard_points_entries table. It is a fixed-size list (10 by default) that is only
-- ever read whole, exactly like shard_governors.candidates — normalizing it would
-- buy nothing until something needs a per-character reverse lookup, and a
-- character's own standings already ride inside char.profile instead.
--
-- No delete path: the shard's set of systems is fixed at startup, so there is no
-- points.remove to mirror.
CREATE TABLE IF NOT EXISTS shard_points_boards (
  system        VARCHAR(48) PRIMARY KEY,          -- PointsType name, e.g. QueensLoyalty
  name          VARCHAR(128) NULL,                -- resolved display name, if the shard sent a literal
  name_cliloc   INT          NULL,                -- cliloc id when the name is a TextDefinition number
  max_points    BIGINT       NULL,
  players       INT          NULL,                -- players actually holding points in this system
  show_on_gump  TINYINT(1)   NOT NULL DEFAULT 1,  -- the shard's own "is this player-facing?" flag
  payload       JSON         NOT NULL,            -- the whole points.board frame, incl. `top`
  t             BIGINT       NULL,                -- frame time, epoch ms
  updated_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Player-vendor market index (Protocol 3.0 vendor.listing). One row per player
-- vendor and one per priced listing, so the site can offer the search the in-game
-- Vendor Search gump offers — from outside the game.
--
-- The shard sweeps vendors round-robin and emits one AUTHORITATIVE frame per
-- vendor, so ingest is delete-then-insert of that vendor's items inside one
-- transaction (see shardMarket.db.js). No foreign key from items to vendors, in
-- keeping with every other shard_* table: the ingest transaction is what keeps
-- them consistent, and an FK would turn a malformed frame into a failed write
-- rather than a dropped row.
--
-- Only vendors whose owner left the in-game Vendor Search flag ON are ever sent,
-- so a player who hid their shop in game is hidden here too — see BridgeMarket.cs.
CREATE TABLE IF NOT EXISTS shard_vendors (
  serial       VARCHAR(20)  NOT NULL PRIMARY KEY,   -- "0x40001234"
  shop_name    VARCHAR(160) NULL,
  owner_serial VARCHAR(20)  NULL,
  owner_name   VARCHAR(64)  NULL,
  map          VARCHAR(40)  NULL,
  x            INT          NULL,
  y            INT          NULL,
  z            INT          NULL,
  region       VARCHAR(80)  NULL,
  house        VARCHAR(160) NULL,                   -- the house SIGN's name, not the house type
  item_count   INT          NOT NULL DEFAULT 0,     -- listings published in the frame
  item_total   INT          NOT NULL DEFAULT 0,     -- listings the shop actually holds
  truncated    TINYINT(1)   NOT NULL DEFAULT 0,     -- item_total > item_count
  t            BIGINT       NULL,                   -- frame time, epoch ms
  updated_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_shard_vendors_owner (owner_name),
  INDEX idx_shard_vendors_map (map),
  INDEX idx_shard_vendors_region (region),
  -- The market page's staleness banner is MIN(updated_at) over this column: the
  -- round-robin sweep means the oldest row is how far behind the index can be.
  INDEX idx_shard_vendors_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- One priced listing. Unlike the points board's top-N — a fixed-size list read
-- whole — these are the searchable rows the whole feature exists for, so they are
-- normalized rather than left inside a payload column, and there is no payload
-- column on shard_vendors at all.
--
-- `display_name` is DENORMALIZED at ingest: the shard sends `cliloc` (the item's
-- LabelNumber) and, rarely, a literal `name`, and resolving 50 clilocs per page
-- at query time would make the cliloc table a join on the hot path AND make
-- search-by-name impossible. Resolving once on write buys the index. It is
-- re-resolved in bulk after a cliloc import, because the diff sweep will not
-- re-send an unchanged shop just because the site learned what its items are
-- called.
CREATE TABLE IF NOT EXISTS shard_vendor_items (
  id            BIGINT       NOT NULL AUTO_INCREMENT PRIMARY KEY,
  vendor_serial VARCHAR(20)  NOT NULL,
  serial        VARCHAR(20)  NOT NULL,
  item_id       INT          NOT NULL DEFAULT 0,    -- ItemID (the art/graphic id)
  hue           INT          NOT NULL DEFAULT 0,
  amount        INT          NOT NULL DEFAULT 1,
  price         BIGINT       NOT NULL DEFAULT 0,
  name          VARCHAR(160) NULL,                  -- the item's literal Name, null for most
  cliloc        INT          NULL,                  -- LabelNumber, resolved against shard_clilocs
  display_name  VARCHAR(160) NULL,                  -- resolved at ingest; what search matches
  child         TINYINT(1)   NOT NULL DEFAULT 0,    -- priced by an enclosing container, not itself
  INDEX idx_shard_vendor_items_vendor (vendor_serial),
  INDEX idx_shard_vendor_items_price (price),
  INDEX idx_shard_vendor_items_item (item_id),
  INDEX idx_shard_vendor_items_name (display_name),
  -- Search filters on name and sorts on price; the composite covers the common
  -- "cheapest matching X" without a filesort over the whole table.
  INDEX idx_shard_vendor_items_name_price (display_name, price)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
-- per feature; an absent row means "use the compiled default", and the compiled
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
-- a no-op. See utils/shardVisibility.js for the catalog and the ladder, and
-- docs/link/v3.md §3 for the contract.
--
--   audience     the minimum rung on anonymous < logged_in < player < staff < admin
--   stream       whether this feature's kinds fan out over SSE at all (the market
--                index ships with this off: no page needs a live firehose of
--                whole vendor inventories)
--   field_rules  {"<field>": "<rung>"} for SENSITIVE fields only. `acct` and
--                `webId` are admin-only always and are rejected here — they are
--                not in-game visible and are deliberately not configurable.
CREATE TABLE IF NOT EXISTS shard_feature_visibility (
  feature     VARCHAR(48) NOT NULL PRIMARY KEY,
  enabled     TINYINT(1)  NOT NULL DEFAULT 1,
  audience    VARCHAR(20) NOT NULL DEFAULT 'anonymous',
  stream      TINYINT(1)  NOT NULL DEFAULT 1,
  field_rules JSON        NULL,
  updated_by  INT         NULL,
  updated_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
-- which creates their website user at that role (and optionally a linked game
-- account). Only the sha256 hash of the opaque token is stored — a DB read never
-- yields a usable invite link, same as mobile_refresh_tokens. status tracks the
-- lifecycle; accepted_user_id back-points at the created user. Single-use +
-- expiring (enforced in the model on top of expires_at).
CREATE TABLE IF NOT EXISTS user_invites (
  id                INT AUTO_INCREMENT PRIMARY KEY,
  token_hash        CHAR(64)     NOT NULL UNIQUE,     -- sha256 hex of the opaque token
  email             VARCHAR(255) NOT NULL,
  role              ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'player',
  status            ENUM('pending','accepted','revoked') NOT NULL DEFAULT 'pending',
  invited_by        INT          NULL,                -- staff user who sent it
  accepted_user_id  INT          NULL,                -- the user created on accept
  expires_at        DATETIME     NOT NULL,
  created_at        DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  accepted_at       DATETIME     NULL,
  CONSTRAINT fk_user_invites_inviter FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE SET NULL,
  CONSTRAINT fk_user_invites_user FOREIGN KEY (accepted_user_id) REFERENCES users(id) ON DELETE SET NULL,
  INDEX idx_user_invites_email (email),
  INDEX idx_user_invites_status (status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Self-service password resets. A user requests a reset by email; a tokened link
-- is emailed to every active account on that address. Opening the link and setting
-- a new password rotates the hash and revokes all sessions (web + mobile). Only the
-- sha256 hash of the opaque token is stored — a DB read never yields a usable link,
-- same as user_invites / mobile_refresh_tokens. Single-use + short-lived (1h,
-- enforced in the model on top of expires_at). Also serves SSO-only accounts (null
-- password_hash) as their "set an initial password" path.
CREATE TABLE IF NOT EXISTS password_resets (
  id                INT AUTO_INCREMENT PRIMARY KEY,
  token_hash        CHAR(64)     NOT NULL UNIQUE,     -- sha256 hex of the opaque token
  user_id           INT          NOT NULL,            -- the account this reset targets
  status            ENUM('pending','used') NOT NULL DEFAULT 'pending',
  requested_ip      VARCHAR(64)  NULL,                -- who asked (audit only)
  expires_at        DATETIME     NOT NULL,
  created_at        DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  used_at           DATETIME     NULL,
  CONSTRAINT fk_password_resets_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_password_resets_user (user_id),
  INDEX idx_password_resets_status (status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ── Push notifications (opt-in) ─────────────────────────────────────────────
-- One row per registered push endpoint (Android/UnifiedPush v1; FCM later). The
-- `endpoint` is the UnifiedPush distributor URL the app's ntfy topic was handed —
-- unguessable but NOT a secret (the security model treats ntfy as an untrusted
-- relay and only ever pushes content-free tickles), so it is stored in the clear,
-- unlike mobile_refresh_tokens. A device belongs to one user; re-registering the
-- same endpoint for the same user is an idempotent upsert (UNIQUE user_id+endpoint).
CREATE TABLE IF NOT EXISTS push_devices (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  user_id      INT          NOT NULL,
  transport    ENUM('unifiedpush','fcm') NOT NULL DEFAULT 'unifiedpush',
  endpoint     VARCHAR(512) NOT NULL,             -- distributor URL (or FCM token)
  platform     VARCHAR(40)  NULL,                 -- e.g. 'android' (free-form label)
  created_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  CONSTRAINT fk_push_devices_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  UNIQUE KEY uq_push_devices_user_endpoint (user_id, endpoint),
  INDEX idx_push_devices_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Which notification streams a user has opted into. Subscriptions are per-user
-- (applied to every device the user has registered), not per-device. stream_id is
-- an id from the notification catalog (config/notificationStreams.js), validated
-- in the model on write. One row per (user, stream); PUT replaces the whole set.
CREATE TABLE IF NOT EXISTS notification_subscriptions (
  user_id    INT          NOT NULL,
  stream_id  VARCHAR(64)  NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (user_id, stream_id),
  CONSTRAINT fk_notif_subs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_notif_subs_stream (stream_id)
) 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;

-- Player-submitted moderation appeals (Phase 6c). Unlike mod_actions above, this
-- table is SERVER-owned — written and read only by the main site (the player
-- appeals controller and the admin moderation queue), never by the bot. A player
-- appeals one of their own ban/mute mod_actions; staff triage the queue, and an
-- approval optionally triggers an automatic Discord reversal (Phase 6d) whose
-- outcome is recorded in reversal_status. mod_action_id is a plain column with NO
-- hard FK to the bot-owned mod_actions table (cross-owner FK avoided on purpose,
-- matching posts.announce_job_id) — existence is validated in app code. user_id
-- is the appealing site account; discord_user_id is the snowflake the appeal is
-- for (snapshotted from mod_actions.target_user_id at submit time).
CREATE TABLE IF NOT EXISTS appeals (
  id                 INT AUTO_INCREMENT PRIMARY KEY,
  mod_action_id      INT NOT NULL,
  discord_user_id    VARCHAR(32)  NOT NULL,
  action_type        ENUM('ban','mute') NOT NULL,
  user_id            INT NULL,
  status             ENUM('pending','under_review','approved','denied','withdrawn') NOT NULL DEFAULT 'pending',
  submitted_text     TEXT NOT NULL,
  staff_response     TEXT NULL,
  handled_by_user_id INT NULL,
  handled_by_tag     VARCHAR(120) NULL,
  reversal_status    ENUM('none','done','failed') NOT NULL DEFAULT 'none',
  submitted_at       DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  resolved_at        DATETIME NULL,
  CONSTRAINT fk_appeal_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
  INDEX idx_appeals_status (status, submitted_at),
  INDEX idx_appeals_action (mod_action_id)
) 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;

-- Generic CMS pages composed from a fixed palette of blocks (the page builder).
-- `blocks` is a JSON array of block-envelope objects ({ id, type, version,
-- visible, props }); it is stored as text and parsed/validated in app code
-- against the block registry (server/src/blocks) on every save — the same
-- pattern role_menus.mapping uses, since MariaDB's JSON type is just LONGTEXT and
-- the driver hands it back as a string anyway. The seo_*/og_image/canonical_url/
-- robots and layout/nav_* columns are metadata/settings surfaced grouped in the
-- API response; several have no consumer yet but are cheap to add now and painful
-- to retrofit once real pages exist. published_at mirrors posts: stamped the first
-- time a page goes to 'published'.
CREATE TABLE IF NOT EXISTS pages (
  id               INT AUTO_INCREMENT PRIMARY KEY,
  slug             VARCHAR(160) NOT NULL UNIQUE,
  title            VARCHAR(200) NOT NULL,
  blocks           MEDIUMTEXT   NOT NULL,      -- JSON array of block objects
  status           ENUM('draft','published') NOT NULL DEFAULT 'draft',
  protected        TINYINT(1)   NOT NULL DEFAULT 0,
  author_id        INT          NULL,
  -- SEO / social metadata (grouped under `metadata` in the API response).
  seo_title        VARCHAR(200) NULL,
  meta_description VARCHAR(400) NULL,
  og_image         VARCHAR(500) NULL,
  canonical_url    VARCHAR(500) NULL,
  robots           VARCHAR(100) NULL,
  -- Presentation / navigation (grouped under `settings` in the API response).
  layout           ENUM('default','full_width','landing') NOT NULL DEFAULT 'default',
  show_in_nav      TINYINT(1)   NOT NULL DEFAULT 0,
  nav_group        ENUM('main','footer','account','hidden') NULL,
  nav_order        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_pages_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL,
  INDEX idx_pages_status (status),
  INDEX idx_pages_nav (show_in_nav, nav_group, nav_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Announcement pipeline. One row per publish event of a news post; the table
-- doubles as the job queue (a light in-process poller — utils/announceWorker.js
-- — sweeps it for due legs). Two INDEPENDENT delivery legs so a Discord outage
-- never blocks or retries the in-game town-crier leg and vice versa. `status` is
-- a derived rollup of the two legs (see announceJobs.logic.js): done when both
-- legs done, failed when both exhausted, partial in between. Each leg tracks its
-- own attempt count, last error, and next-due time for exponential backoff.
-- post_id is INT (matches posts.id) and cascades so deleting a post reaps its
-- jobs. posts.announce_job_id points back at the latest row for admin lookups.
CREATE TABLE IF NOT EXISTS announce_jobs (
  id                        INT AUTO_INCREMENT PRIMARY KEY,
  post_id                   INT NOT NULL,
  status                    ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending',

  towncrier_status          ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
  towncrier_attempts        SMALLINT NOT NULL DEFAULT 0,
  towncrier_last_error      TEXT NULL,
  towncrier_next_attempt_at DATETIME NULL,

  discord_status            ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
  discord_attempts          SMALLINT NOT NULL DEFAULT 0,
  discord_last_error        TEXT NULL,
  discord_next_attempt_at   DATETIME NULL,

  created_at                DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at                DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
  INDEX idx_announce_due (towncrier_status, towncrier_next_attempt_at),
  INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
-- Static shard CONTENT, not live shard state: what spawns where, which regions
-- and landmarks exist, and which champion altars are configured. Nothing here
-- comes from the sidecar — it is imported from a committed artifact built off a
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
-- these tables stay populated whether the shard is up or not.
--
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
-- in one transaction. Nothing else may write here, and nothing else may hold a
-- foreign key to them. No FKs at all, consistent with every other shard_* table.

-- One row per spawnable type, aggregated across the world. `total` is the sum of
-- each type's own MX across every point that spawns it (how many exist at once);
-- `facets` is a per-facet point count, so the facet filter and "where does this
-- live" both answer without touching shard_spawn_points.
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
  slug        VARCHAR(120) NOT NULL PRIMARY KEY,  -- slugified class name; the /atlas/:slug key
  name        VARCHAR(120) NOT NULL,              -- display spelling chosen by the build
  total       INT          NOT NULL DEFAULT 0,
  points      INT          NOT NULL DEFAULT 0,
  facets      JSON         NULL,                  -- { "Felucca": 171, "Trammel": 160, ... }
  -- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
  -- creature art: sprites live in the operator's own client .mul/.uop files and
  -- are theirs to extract and place under uploads/atlas/. The UI renders without
  -- art when this is NULL, which is the normal case.
  art         VARCHAR(255) NULL,
  -- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
  -- and FULLTEXT's min-token-length would break searches for names like "orc".
  INDEX idx_shard_spawn_creatures_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- One row per spawner. `region`/`landmark` are the resolved place name — the
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
-- the resolved display string (region, else landmark, else 'Wilderness').
CREATE TABLE IF NOT EXISTS shard_spawn_points (
  id          INT AUTO_INCREMENT PRIMARY KEY,
  facet       VARCHAR(40)  NOT NULL,
  name        VARCHAR(120) NULL,                  -- the ServUO spawner's own name
  x           INT          NOT NULL,
  y           INT          NOT NULL,
  width       INT          NOT NULL DEFAULT 0,
  height      INT          NOT NULL DEFAULT 0,
  spawn_range INT          NOT NULL DEFAULT 0,    -- `range` is reserved in MariaDB
  max_count   INT          NOT NULL DEFAULT 0,
  min_delay   INT          NOT NULL DEFAULT 0,
  max_delay   INT          NOT NULL DEFAULT 0,
  tod_start   INT          NOT NULL DEFAULT 0,    -- meaningless unless tod_mode <> 0
  tod_end     INT          NOT NULL DEFAULT 0,
  tod_mode    INT          NOT NULL DEFAULT 0,
  region      VARCHAR(120) NULL,
  landmark    VARCHAR(120) NULL,
  label       VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
  INDEX idx_shard_spawn_points_facet (facet),
  INDEX idx_shard_spawn_points_label (label)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- The many-to-many between the two above: one spawner commonly carries several
-- types (a single Trammel point spawns six), each with its own max. This is how
-- /atlas/creatures/:slug finds the places a creature appears.
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
  point_id    INT          NOT NULL,
  slug        VARCHAR(120) NOT NULL,              -- → shard_spawn_creatures.slug (no FK)
  max_count   INT          NOT NULL DEFAULT 1,
  PRIMARY KEY (point_id, slug),
  INDEX idx_shard_spawn_point_types_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
-- holds the region's rectangles; `priority` and rect area are what resolved each
-- spawn point at build time, kept here so the admin drift check can re-derive.
CREATE TABLE IF NOT EXISTS shard_regions (
  id          INT AUTO_INCREMENT PRIMARY KEY,
  facet       VARCHAR(40)  NOT NULL,
  name        VARCHAR(120) NOT NULL,
  type        VARCHAR(80)  NULL,                  -- ServUO region class
  priority    INT          NOT NULL DEFAULT 0,
  parent      VARCHAR(120) NULL,                  -- enclosing named region, if any
  rects       JSON         NULL,
  INDEX idx_shard_regions_facet (facet),
  INDEX idx_shard_regions_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
CREATE TABLE IF NOT EXISTS shard_landmarks (
  id          INT AUTO_INCREMENT PRIMARY KEY,
  facet       VARCHAR(40)  NOT NULL,
  name        VARCHAR(120) NOT NULL,
  grp         VARCHAR(120) NULL,
  x           INT          NOT NULL,
  y           INT          NOT NULL,
  z           INT          NOT NULL DEFAULT 0,
  INDEX idx_shard_landmarks_facet (facet),
  INDEX idx_shard_landmarks_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Configured champion altars from Config/ChampionSpawns.xml. This is static
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
  slug        VARCHAR(160) NOT NULL PRIMARY KEY,  -- facet-name, e.g. "felucca-deceit"
  name        VARCHAR(120) NOT NULL,
  grp         VARCHAR(80)  NULL,                  -- spawn group; one active per group
  type        VARCHAR(80)  NULL,                  -- '' when randomised per activation
  random_type TINYINT(1)   NOT NULL DEFAULT 0,
  facet       VARCHAR(40)  NOT NULL,
  x           INT          NOT NULL,
  y           INT          NOT NULL,
  z           INT          NOT NULL DEFAULT 0,
  radius      INT          NOT NULL DEFAULT 0,
  label       VARCHAR(120) NULL,                  -- resolved place name
  INDEX idx_shard_champion_spawns_facet (facet)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- UO's localization table: cliloc id -> display string. Items carry a
-- `LabelNumber` rather than a name, so without this the site can only render
-- `id 1023721` where the game shows "quarter staff". The shard has always sent
-- the id (char.profile's `cliloc`, and one per marketplace listing) — the number
-- was never the missing piece, the table was.
--
-- Sourced from a file the OPERATOR converts once from their own UO client and
-- points the site at (docs/website/CLILOCS.md); nothing derived from the client
-- is committed, the same rule the spawn atlas and the creature art map follow.
-- A shard with no cliloc file configured simply renders item ids, which is what
-- it did before this table existed.
--
-- `text` is TEXT, not VARCHAR: real tables top out around 12 KB for the long
-- property descriptions, and truncating them silently would be worse than
-- storing them. Item NAMES are all short — the index that matters for search is
-- on the denormalized `shard_vendor_items.display_name`, not here.
CREATE TABLE IF NOT EXISTS shard_clilocs (
  number      INT          NOT NULL PRIMARY KEY,
  flag        SMALLINT     NOT NULL DEFAULT 0,
  text        TEXT         NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Singleton (id = 1) describing the cliloc table currently loaded: the source
-- file, its sha256, the entry count and the parser version. The boot path
-- compares the stored hash against the file on disk and skips the parse when
-- they match, which is every restart that did not follow a client patch.
CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
  id          TINYINT      NOT NULL PRIMARY KEY DEFAULT 1,
  payload     JSON         NOT NULL,
  imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Singleton (id = 1) describing the artifact currently loaded: when it was
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
-- compares this against db/data/spawnAtlas.meta.json to report when the database
-- is behind the committed artifact.
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
  id          TINYINT      NOT NULL PRIMARY KEY DEFAULT 1,
  payload     JSON         NOT NULL,
  imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
-- NOT applied, because it would remove a facet the site currently serves.
--
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
-- much as of a real map change, and boot cannot tell the two apart — so the
-- refresh is staged here for a human instead of being applied. Startup is never
-- blocked by it: the site comes up serving the atlas it already had.
--
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
-- keeps a multi-megabyte blob out of the database and guarantees the applied
-- atlas matches the tree as it is at approval time, not as it was at boot.
--
-- `rejected` is remembered against those exact source hashes so a declined
-- refresh does not re-prompt on every restart; changing the tree changes the
-- hashes and asks again.
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
  id          TINYINT      NOT NULL PRIMARY KEY DEFAULT 1,
  status      ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
  payload     JSON         NOT NULL,               -- source hashes + facet diff
  detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
) 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');
-- Game-account signup (Protocol 2.0 hybrid mode): whether a signed-in website user
-- may provision a linked game account from the site. Default off; the shard's own
-- signup mode still has the final say (a 'game'-mode shard refuses regardless).
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', '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);

-- News → town-crier + Discord announcement pipeline. Add the announcement-state
-- columns to posts on databases created before the pipeline landed. announced_at
-- is stamped once both legs deliver; announce_job_id points at the announce_jobs
-- row for the post's admin status panel. Kept as a plain column (not a hard FK)
-- so the idempotent boot migration never trips over a re-added constraint — the
-- pointer is resolved in application code and the CASCADE on announce_jobs.post_id
-- already keeps the two tables consistent.
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL;
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL;

-- House registry (Protocol 2.0). The house.update full-state feed carries richer
-- fields than the house.decay transition feed shard_houses was built for. Rather
-- than a second table for one entity, extend shard_houses: house.update writes the
-- registry columns below (owner display name, co-owner/friend counts, placement
-- price, decay level name) while house.decay keeps owning `stage`/`is_idoc`. Each
-- upsert only touches its own columns, so the two feeds never clobber each other.
-- `price` is the placement value, NOT a "for sale" flag (stock ServUO has none).
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS owner_name VARCHAR(120) NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS co_owners INT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS friends INT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS price BIGINT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
-- Distinguishes a full registry row (seen via house.update) from a decay-only row,
-- so the public Houses browser can list registered houses without pulling in rows
-- we only ever saw an IDOC transition for.
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;

-- Mobile device sessions (M9): a friendly label the app may send at login, and
-- the last time this session token was issued/used, for the "Active Devices"
-- self-service list. Both nullable and additive; existing rows get them here.
ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS device_name VARCHAR(100) NULL;
ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME NULL;

-- SSO trusted devices: records that the user ticked "trust this device" on the
-- Custom Tab TOTP form, so /auth/mobile/sso/exchange knows to mint the app's own
-- trust token. A boolean only — the token is returned over that app→server call
-- and never persisted here (only its sha256 lands in trusted_devices).
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;

-- Protocol 3.0 cutover: this build speaks wire protocol 3 (world.ruleset,
-- points.board, vendor.listing), so the pinned version an existing install
-- carries has to move with it — a 2 against a v3 sidecar 409s every REST call
-- and closes the WS on ws.hello. MODIFY fixes the column default for installs
-- created before the bump (idempotent, like the other MODIFYs here).
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
-- The row itself is admin-editable, and schema.sql runs on EVERY boot, so this
-- must be one-shot: an operator who deliberately pins an older sidecar in
-- Admin → Shard has to stay pinned. The marker row in `settings` is what makes
-- it fire once — written after the UPDATE, and on a fresh install (no
-- uo_link_config row yet) it is simply written with nothing to update.
UPDATE uo_link_config SET protocol = 3
  WHERE id = 1 AND protocol < 3
    AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
