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