The owner sets them on the admin page; the bot polls the shared beta_settings row (30s) and moves the beta commands if the server changes. BETA_* env vars are gone. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
428 lines
16 KiB
PL/PgSQL
428 lines
16 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()
|
|
);
|
|
|
|
-- 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
|
|
);
|