Files
velox-bot/schema.sql
T
FernandoJVideiraandClaude Sonnet 5.5 acdb5ead35 Tickets: settings, category edit and ticket list from Discord; NOTIFY triggers in schema.sql
- /tickets settings (on or off, panel title and text, open tickets per member), /tickets category edit, /tickets list
- schema.sql now creates the NOTIFY triggers, so the integration tests run on a database built from it, and two stale expectations (tickets start off) are fixed

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 02:05:45 +01:00

482 lines
18 KiB
PL/PgSQL

-- PostgreSQL schema for Velox
-- Use BIGINT for Discord snowflakes, INT for smaller counters, BOOLEAN properly.
CREATE TABLE IF NOT EXISTS levels (
guild BIGINT NOT NULL,
user_id BIGINT NOT NULL,
level INT NOT NULL DEFAULT 0,
xp BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (guild, user_id)
);
CREATE TABLE IF NOT EXISTS twitch (
twitch_user TEXT NOT NULL,
guild_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'not live',
PRIMARY KEY (twitch_user, guild_id)
);
CREATE TABLE IF NOT EXISTS levelsettings (
guild_id BIGINT PRIMARY KEY,
levelsys BOOLEAN NOT NULL DEFAULT FALSE,
message TEXT
);
-- Role rewards per level (supports multiple rewards per guild)
CREATE TABLE IF NOT EXISTS levelrewards (
guild_id BIGINT NOT NULL,
levelreq INT NOT NULL,
role BIGINT NOT NULL,
PRIMARY KEY (guild_id, levelreq)
);
CREATE TABLE IF NOT EXISTS welcome (
guild_id BIGINT PRIMARY KEY,
welcome_channel_id BIGINT,
welcome_message TEXT,
welcome_dm TEXT,
welcome_gif_url TEXT
);
CREATE TABLE IF NOT EXISTS levelup (
guild_id BIGINT PRIMARY KEY,
levelup_channel_id BIGINT
);
CREATE TABLE IF NOT EXISTS twitch_config (
guild_id BIGINT PRIMARY KEY,
twitch_channel_id BIGINT,
enabled BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS rps (
guild_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
score INT NOT NULL DEFAULT 0,
PRIMARY KEY (guild_id, user_id)
);
CREATE TABLE IF NOT EXISTS defaultrole (
guild_id BIGINT PRIMARY KEY,
role_id BIGINT
);
CREATE TABLE IF NOT EXISTS logsettings (
guild_id BIGINT PRIMARY KEY,
log_channel_id BIGINT,
is_enabled BOOLEAN NOT NULL DEFAULT FALSE
);
-- Meeting rooms (voice lobby -> auto-created temporary voice channels)
CREATE TABLE IF NOT EXISTS meeting_settings (
guild_id BIGINT PRIMARY KEY,
lobby_channel_id BIGINT
);
-- Who counts as a beta tester: holders of role_id in guild_id. One row, set by
-- the owner on the dashboard's admin page (the API creates and writes it, see
-- its migration 000021); the bot only reads it.
CREATE TABLE IF NOT EXISTS beta_settings (
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id),
guild_id BIGINT,
role_id BIGINT,
updated_by BIGINT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Servers whose owner opted in to beta features (API migration 000022). The
-- beta server itself is always eligible and isn't listed. The bot only reads it.
CREATE TABLE IF NOT EXISTS beta_guilds (
guild_id BIGINT PRIMARY KEY,
enabled_by BIGINT NOT NULL,
enabled_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Servers where the bot registered beta commands, so it can remove them even
-- if the server opted out while the bot was offline. Bot-owned (the API's
-- migration 000022 creates it for a combined database).
CREATE TABLE IF NOT EXISTS beta_registered (
guild_id BIGINT PRIMARY KEY
);
-- A server can have several lobbies (at most 10; the trigger enforces it, and
-- is the same as the API's migration 000020). meeting_settings.lobby_channel_id
-- is the old single-lobby column, no longer read.
CREATE TABLE IF NOT EXISTS meeting_lobbies (
guild_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, channel_id)
);
CREATE OR REPLACE FUNCTION meeting_lobbies_limit() RETURNS trigger AS $$
BEGIN
PERFORM pg_advisory_xact_lock(NEW.guild_id);
IF EXISTS (SELECT 1 FROM meeting_lobbies WHERE guild_id = NEW.guild_id AND channel_id = NEW.channel_id) THEN
RETURN NEW;
END IF;
IF (SELECT COUNT(*) FROM meeting_lobbies WHERE guild_id = NEW.guild_id) >= 10 THEN
RAISE EXCEPTION 'a server can have at most 10 lobby channels' USING ERRCODE = '54000';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS meeting_lobbies_limit ON meeting_lobbies;
CREATE TRIGGER meeting_lobbies_limit BEFORE INSERT ON meeting_lobbies
FOR EACH ROW EXECUTE FUNCTION meeting_lobbies_limit();
CREATE TABLE IF NOT EXISTS meeting_temp_channels (
guild_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, channel_id)
);
-- Projects & members
CREATE TABLE IF NOT EXISTS projects (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
name TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS project_members (
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL,
is_creator BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (project_id, user_id)
);
--
-- Scheduling (one-to-one sessions)
--
CREATE TABLE IF NOT EXISTS schedules (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
requester_id BIGINT NOT NULL,
invitee_id BIGINT NOT NULL,
scheduled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested
description TEXT,
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Reschedule negotiation: a proposed new time awaiting the counterpart's
-- response, kept separate from scheduled_at until accepted.
proposed_at TIMESTAMPTZ,
proposed_by BIGINT,
-- Snapshot of status before entering reschedule_requested, so a decline
-- knows whether to revert to 'pending' or 'accepted'.
pre_reschedule_status TEXT
);
CREATE TABLE IF NOT EXISTS schedule_messages (
schedule_id BIGINT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE,
message_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
is_dm BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (schedule_id, message_id)
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id BIGINT PRIMARY KEY,
timezone TEXT NOT NULL
);
--
-- Music (dashboard queue view/control - see velox-dashboard-api's
-- 000010_music migration, this file mirrors it)
--
CREATE TABLE IF NOT EXISTS music_settings (
guild_id BIGINT PRIMARY KEY,
is_enabled BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS music_now_playing (
guild_id BIGINT PRIMARY KEY,
track_title TEXT NOT NULL,
track_url TEXT NOT NULL,
duration_seconds INT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
paused BOOLEAN NOT NULL DEFAULT FALSE,
volume INT NOT NULL DEFAULT 100,
repeat_song BOOLEAN NOT NULL DEFAULT FALSE,
repeat_queue BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS music_queue (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
position INT NOT NULL,
track_title TEXT NOT NULL,
track_url TEXT NOT NULL,
requested_by BIGINT NOT NULL,
added_at TIMESTAMPTZ DEFAULT NOW(),
client_id UUID NOT NULL DEFAULT gen_random_uuid(),
UNIQUE (guild_id, client_id)
);
CREATE TABLE IF NOT EXISTS music_commands (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
command_type TEXT NOT NULL CHECK (command_type IN ('skip', 'pause', 'resume', 'set_volume', 'remove_track', 'reorder', 'repeat_song', 'repeat_queue', 'add_song', 'stop')),
payload JSONB,
requested_by BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
-- Tickets (mirror of velox-dashboard-api migration 000016_tickets)
CREATE TABLE IF NOT EXISTS ticket_settings (
guild_id BIGINT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
channel_id BIGINT,
log_channel_id BIGINT,
panel_message_id BIGINT,
panel_title TEXT NOT NULL DEFAULT 'Need help?',
panel_description TEXT NOT NULL DEFAULT 'Pick a category below to open a private ticket with the staff team.',
max_open_per_user INT NOT NULL DEFAULT 1 CHECK (max_open_per_user BETWEEN 1 AND 10),
next_number INT NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS ticket_categories (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 40),
emoji TEXT,
description TEXT NOT NULL DEFAULT '',
staff_role_id BIGINT,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tickets (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
number INT NOT NULL,
category_id BIGINT REFERENCES ticket_categories (id) ON DELETE SET NULL,
category_name TEXT NOT NULL,
thread_id BIGINT NOT NULL UNIQUE,
intro_message_id BIGINT,
opener_id BIGINT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
claimed_by BIGINT,
closed_by BIGINT,
close_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
closed_at TIMESTAMPTZ,
UNIQUE (guild_id, number)
);
CREATE TABLE IF NOT EXISTS ticket_commands (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
command_type TEXT NOT NULL CHECK (command_type IN ('post_panel')),
requested_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
-- Security: anti-raid and anti-nuke (mirror of velox-dashboard-api migration 000017_security)
CREATE TABLE IF NOT EXISTS security_settings (
guild_id BIGINT PRIMARY KEY,
-- Where alerts go, and who gets pinged for raids and nukes.
alert_channel_id BIGINT,
alert_role_id BIGINT,
-- Also DM critical alerts to the owner and trusted users.
alert_dm BOOLEAN NOT NULL DEFAULT TRUE,
-- Join Gate: checks run on every join, each with its own action.
joingate_enabled BOOLEAN NOT NULL DEFAULT FALSE,
joingate_no_avatar TEXT NOT NULL DEFAULT 'off' CHECK (joingate_no_avatar IN ('off', 'timeout', 'kick', 'ban')),
joingate_new_account TEXT NOT NULL DEFAULT 'off' CHECK (joingate_new_account IN ('off', 'timeout', 'kick', 'ban')),
joingate_min_account_age_days INT NOT NULL DEFAULT 7 CHECK (joingate_min_account_age_days BETWEEN 1 AND 365),
joingate_advertising_name TEXT NOT NULL DEFAULT 'off' CHECK (joingate_advertising_name IN ('off', 'timeout', 'kick', 'ban')),
joingate_username_filter TEXT NOT NULL DEFAULT 'off' CHECK (joingate_username_filter IN ('off', 'timeout', 'kick', 'ban')),
joingate_username_patterns TEXT[] NOT NULL DEFAULT '{}',
joingate_unverified_bot TEXT NOT NULL DEFAULT 'off' CHECK (joingate_unverified_bot IN ('off', 'kick', 'ban')),
-- A bot added by someone who isn't the owner or trusted.
joingate_unauthorized_bot TEXT NOT NULL DEFAULT 'kick' CHECK (joingate_unauthorized_bot IN ('off', 'kick', 'ban')),
-- Raid mode: raid_join_count joins within raid_join_seconds starts it; it
-- ends after raid_quiet_minutes without a join, or when staff end it.
antiraid_enabled BOOLEAN NOT NULL DEFAULT FALSE,
raid_join_count INT NOT NULL DEFAULT 10 CHECK (raid_join_count BETWEEN 3 AND 100),
raid_join_seconds INT NOT NULL DEFAULT 10 CHECK (raid_join_seconds BETWEEN 5 AND 300),
raid_action TEXT NOT NULL DEFAULT 'kick' CHECK (raid_action IN ('alert', 'timeout', 'kick', 'ban')),
raid_pause_invites BOOLEAN NOT NULL DEFAULT TRUE,
raid_quiet_minutes INT NOT NULL DEFAULT 10 CHECK (raid_quiet_minutes BETWEEN 1 AND 120),
-- Anti-nuke: anyone who isn't the owner or trusted and goes over a limit
-- within antinuke_window_seconds is quarantined. Limits are per action
-- (channel_delete, channel_create, role_delete, role_create, ban, kick,
-- webhook_create); a limit of 0 means the action is never allowed.
antinuke_enabled BOOLEAN NOT NULL DEFAULT FALSE,
antinuke_window_seconds INT NOT NULL DEFAULT 10 CHECK (antinuke_window_seconds BETWEEN 5 AND 300),
antinuke_limits JSONB NOT NULL DEFAULT '{"channel_delete": 2, "channel_create": 5, "role_delete": 2, "role_create": 5, "ban": 3, "kick": 5, "webhook_create": 3}',
-- Also lock every channel when a nuke is stopped.
antinuke_lock_server BOOLEAN NOT NULL DEFAULT FALSE,
quarantine_role_id BIGINT,
-- Restore: snapshots of roles and channels are taken every few hours; a
-- restore recreates what's missing. Automatic after a stopped nuke when
-- antinuke_auto_restore is on, otherwise by the owner or trusted.
antinuke_auto_restore BOOLEAN NOT NULL DEFAULT FALSE,
-- Heat: every message adds heat to its author (more for repeats,
-- mentions, emoji walls, links, blocked words; an invite link maxes it),
-- and heat cools down over time. Reaching the maximum deletes the message
-- and applies heat_action. heat_strictness scales how fast heat builds.
heat_enabled BOOLEAN NOT NULL DEFAULT FALSE,
heat_strictness TEXT NOT NULL DEFAULT 'medium' CHECK (heat_strictness IN ('low', 'medium', 'high')),
heat_action TEXT NOT NULL DEFAULT 'timeout' CHECK (heat_action IN ('timeout', 'kick', 'ban')),
heat_timeout_minutes INT NOT NULL DEFAULT 10 CHECK (heat_timeout_minutes BETWEEN 1 AND 10080),
-- Each new offence within a day doubles the timeout (capped at 28 days).
heat_escalate BOOLEAN NOT NULL DEFAULT TRUE,
heat_blocked_words TEXT[] NOT NULL DEFAULT '{}',
-- Heat panic: this many members maxing out within a minute times out
-- anyone who joined in the last day as soon as they speak, for 10
-- minutes. 0 turns it off.
heat_panic_members INT NOT NULL DEFAULT 3 CHECK (heat_panic_members BETWEEN 0 AND 50),
-- Mention flood: this many mentions by non-exempt members within 30
-- seconds locks every channel. 0 turns it off.
heat_lockdown_mentions INT NOT NULL DEFAULT 0 CHECK (heat_lockdown_mentions BETWEEN 0 AND 1000),
-- Join lockdown (/lockdown joins): every new member is kicked or banned.
join_lock TEXT NOT NULL DEFAULT 'off' CHECK (join_lock IN ('off', 'kick', 'ban'))
);
-- Servers set up before alert_dm existed get it here (CREATE TABLE IF NOT EXISTS
-- doesn't change an existing table).
ALTER TABLE security_settings ADD COLUMN IF NOT EXISTS alert_dm BOOLEAN NOT NULL DEFAULT TRUE;
-- Users and roles exempt from anti-nuke, who may also manage its settings.
-- The server owner is always trusted and never listed here.
CREATE TABLE IF NOT EXISTS security_trusted (
guild_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('user', 'role')),
added_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, target_id)
);
-- Members currently quarantined, with the roles they had, so releasing them
-- gives every role back.
CREATE TABLE IF NOT EXISTS security_quarantines (
guild_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
previous_role_ids BIGINT[] NOT NULL DEFAULT '{}',
reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, user_id)
);
-- Channels the bot locked, with @everyone's permission overwrite from before,
-- so unlocking puts back exactly what was there.
CREATE TABLE IF NOT EXISTS security_lockdowns (
guild_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
had_overwrite BOOLEAN NOT NULL,
previous_allow BIGINT NOT NULL DEFAULT 0,
previous_deny BIGINT NOT NULL DEFAULT 0,
server_wide BOOLEAN NOT NULL DEFAULT FALSE,
locked_by BIGINT NOT NULL,
locked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, channel_id)
);
-- A raid in progress, so raid mode survives a bot restart: the quiet timer
-- carries on and the summary still lists everyone caught.
CREATE TABLE IF NOT EXISTS security_raids (
guild_id BIGINT PRIMARY KEY,
started_at TIMESTAMPTZ NOT NULL,
last_join_at TIMESTAMPTZ NOT NULL,
invites_paused BOOLEAN NOT NULL DEFAULT FALSE,
caught_total INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS security_raid_caught (
guild_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
action TEXT NOT NULL,
name TEXT NOT NULL,
caught_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, user_id)
);
-- Snapshots of a guild's roles and channels for restoring after a nuke. The
-- bot keeps the last three per guild.
CREATE TABLE IF NOT EXISTS security_snapshots (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
data JSONB NOT NULL,
roles INT NOT NULL,
channels INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS security_snapshots_guild_idx ON security_snapshots (guild_id, created_at DESC);
-- What the system did, for the dashboard's history.
CREATE TABLE IF NOT EXISTS security_events (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
kind TEXT NOT NULL,
actor_id BIGINT,
target_id BIGINT,
detail TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS security_events_guild_idx ON security_events (guild_id, created_at DESC);
-- Dashboard -> bot requests, same pattern as music_commands.
CREATE TABLE IF NOT EXISTS security_commands (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
command_type TEXT NOT NULL CHECK (command_type IN ('end_raid', 'release_quarantine', 'unlock_server', 'unlock_joins', 'snapshot', 'restore')),
payload JSONB,
requested_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
-- Wake the bot when a request is added. The bot also processes everything
-- waiting when it starts, so a request is never lost; this only makes it
-- immediate. Whatever writes to these tables (a dashboard, a script) needs no
-- NOTIFY of its own.
CREATE OR REPLACE FUNCTION notify_music_command() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('music_commands', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS music_commands_notify ON music_commands;
CREATE TRIGGER music_commands_notify AFTER INSERT ON music_commands
FOR EACH ROW EXECUTE FUNCTION notify_music_command();
CREATE OR REPLACE FUNCTION notify_ticket_command() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('ticket_commands', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ticket_commands_notify ON ticket_commands;
CREATE TRIGGER ticket_commands_notify AFTER INSERT ON ticket_commands
FOR EACH ROW EXECUTE FUNCTION notify_ticket_command();
CREATE OR REPLACE FUNCTION notify_security_command() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('security_commands', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS security_commands_notify ON security_commands;
CREATE TRIGGER security_commands_notify AFTER INSERT ON security_commands
FOR EACH ROW EXECUTE FUNCTION notify_security_command();