Files
dumpsterChat/internal/db/db.go
T

466 lines
17 KiB
Go

package db
import (
"context"
"database/sql"
"fmt"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
_ "github.com/jackc/pgx/v5/stdlib"
)
type DB struct {
*sql.DB
}
func New(cfg *config.Config) (*DB, error) {
db, err := sql.Open("pgx", cfg.DatabaseDSN())
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return &DB{db}, nil
}
func (d *DB) RunMigrations() error {
_, err := d.ExecContext(context.Background(), migrationsSQL)
return err
}
const migrationsSQL = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(32) NOT NULL UNIQUE,
display_name VARCHAR(32),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255),
avatar TEXT,
bio VARCHAR(250) DEFAULT '',
accent_color VARCHAR(7) DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'offline',
status_text VARCHAR(128) DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS servers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
icon TEXT,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
type VARCHAR(16) NOT NULL DEFAULT 'text',
category VARCHAR(100) NOT NULL DEFAULT 'general',
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (server_id, name)
);
CREATE TABLE IF NOT EXISTS members (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, server_id)
);
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
color VARCHAR(7),
permissions BIGINT NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS member_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id);
CREATE TABLE IF NOT EXISTS reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (message_id, user_id, emoji)
);
CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id);
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(16) NOT NULL UNIQUE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ,
max_uses INTEGER,
use_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code);
CREATE TABLE IF NOT EXISTS bots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
avatar TEXT,
description TEXT DEFAULT '',
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS bot_servers (
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
added_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (bot_id, server_id)
);
CREATE TABLE IF NOT EXISTS slash_commands (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name VARCHAR(32) NOT NULL,
description TEXT DEFAULT '',
options JSONB DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (server_id, name)
);
CREATE TABLE IF NOT EXISTS webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
token VARCHAR(64) NOT NULL,
avatar TEXT,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bots_owner ON bots(owner_id);
CREATE INDEX IF NOT EXISTS idx_bots_token ON bots(token);
CREATE INDEX IF NOT EXISTS idx_bot_servers_bot ON bot_servers(bot_id);
CREATE INDEX IF NOT EXISTS idx_slash_commands_server ON slash_commands(server_id, name);
CREATE INDEX IF NOT EXISTS idx_webhooks_channel ON webhooks(channel_id);
CREATE INDEX IF NOT EXISTS idx_webhooks_token ON webhooks(token);
-- Add token_hash column for secure webhook token storage
ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS token_hash VARCHAR(64);
CREATE INDEX IF NOT EXISTS idx_webhooks_token_hash ON webhooks(token_hash);
-- Ensure reply_to column exists on messages (added after initial table creation)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS reply_to UUID REFERENCES messages(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, endpoint)
);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
aaguid UUID,
sign_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
-- Direct message conversations
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(16) NOT NULL DEFAULT 'dm',
name VARCHAR(100),
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS conversation_members (
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (conversation_id, user_id)
);
CREATE TABLE IF NOT EXISTS conversation_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content VARCHAR(4000) NOT NULL,
edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id);
CREATE INDEX IF NOT EXISTS idx_conversation_messages_conv_created ON conversation_messages(conversation_id, created_at DESC);
-- Moderation
CREATE TABLE IF NOT EXISTS bans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
banned_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (server_id, user_id)
);
CREATE TABLE IF NOT EXISTS server_mutes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
muted_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reason TEXT,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (server_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_bans_server ON bans(server_id);
CREATE INDEX IF NOT EXISTS idx_server_mutes_server ON server_mutes(server_id);
-- Link embeds
CREATE TABLE IF NOT EXISTS embeds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
url TEXT NOT NULL,
title TEXT,
description TEXT,
image_url TEXT,
site_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_embeds_message ON embeds(message_id);
-- Channel slowmode
ALTER TABLE channels ADD COLUMN IF NOT EXISTS slowmode_seconds INTEGER NOT NULL DEFAULT 0;
-- Audit log
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action_type VARCHAR(32) NOT NULL,
target_type VARCHAR(32),
target_id VARCHAR(64),
reason TEXT,
changes JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_server_created ON audit_log(server_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(server_id, action_type);
-- Channel permission overrides
CREATE TABLE IF NOT EXISTS channel_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
target_type VARCHAR(8) NOT NULL CHECK (target_type IN ('role', 'user')),
target_id UUID NOT NULL,
allow_bitflags BIGINT NOT NULL DEFAULT 0,
deny_bitflags BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (channel_id, target_type, target_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel ON channel_overrides(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_overrides_target ON channel_overrides(channel_id, target_type, target_id);
-- Forum tags
CREATE TABLE IF NOT EXISTS forum_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
name VARCHAR(64) NOT NULL,
emoji VARCHAR(32),
color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (channel_id, name)
);
CREATE INDEX IF NOT EXISTS idx_forum_tags_channel ON forum_tags(channel_id);
-- Thread tag assignments (for forum posts)
CREATE TABLE IF NOT EXISTS channel_tags (
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES forum_tags(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, tag_id)
);
-- Threads: reuse channels table by adding parent_channel_id; thread messages are still messages scoped to the thread channel.
ALTER TABLE channels ADD COLUMN IF NOT EXISTS parent_channel_id UUID REFERENCES channels(id) ON DELETE CASCADE;
ALTER TABLE channels ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
ALTER TABLE channels ADD COLUMN IF NOT EXISTS auto_archive_duration INTEGER NOT NULL DEFAULT 1440;
ALTER TABLE channels ADD COLUMN IF NOT EXISTS message_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE channels ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_channels_parent ON channels(parent_channel_id);
ALTER TABLE users ADD COLUMN IF NOT EXISTS banner_url TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS tagline VARCHAR(128);
ALTER TABLE users ADD COLUMN IF NOT EXISTS social_links JSONB DEFAULT '[]';
ALTER TABLE users ADD COLUMN IF NOT EXISTS pronouns VARCHAR(40);
-- Badges
CREATE TABLE IF NOT EXISTS badges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID REFERENCES servers(id) ON DELETE CASCADE,
name VARCHAR(64) NOT NULL,
icon TEXT,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_badges_server ON badges(server_id);
CREATE TABLE IF NOT EXISTS user_badges (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
badge_id UUID NOT NULL REFERENCES badges(id) ON DELETE CASCADE,
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
granted_by UUID REFERENCES users(id) ON DELETE SET NULL,
PRIMARY KEY (user_id, badge_id)
);
-- Block list
CREATE TABLE IF NOT EXISTS blocks (
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (blocker_id, blocked_id)
);
CREATE INDEX IF NOT EXISTS idx_blocks_blocker ON blocks(blocker_id);
CREATE INDEX IF NOT EXISTS idx_blocks_blocked ON blocks(blocked_id);
-- Calendar events
CREATE TABLE IF NOT EXISTS events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
recurrence_rule VARCHAR(255),
location VARCHAR(255),
color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_events_channel_time ON events(channel_id, start_time);
CREATE TABLE IF NOT EXISTS event_rsvps (
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(16) NOT NULL CHECK (status IN ('going', 'maybe', 'no')),
responded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (event_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_event_rsvps_event ON event_rsvps(event_id);
-- Documents (wiki/docs channels)
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_documents_channel ON documents(channel_id, updated_at DESC);
-- List items (task lists)
CREATE TABLE IF NOT EXISTS list_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(511) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'todo',
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_list_items_channel ON list_items(channel_id, position);
-- Weekly availability
CREATE TABLE IF NOT EXISTS availability (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
day_of_week VARCHAR(9) NOT NULL CHECK (day_of_week IN ('monday','tuesday','wednesday','thursday','friday','saturday','sunday')),
start_time TIME NOT NULL,
end_time TIME NOT NULL,
PRIMARY KEY (user_id, server_id, day_of_week, start_time)
);
CREATE INDEX IF NOT EXISTS idx_availability_server ON availability(server_id, day_of_week);
-- Message full-text search
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
CREATE OR REPLACE FUNCTION messages_search_update() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('english', COALESCE(NEW.content, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS messages_search_trigger ON messages;
CREATE TRIGGER messages_search_trigger
BEFORE INSERT OR UPDATE ON messages
FOR EACH ROW EXECUTE FUNCTION messages_search_update();
`