feat(backend): bootstrap Go module, config, and data plane
Introduce configuration validation, Postgres migrations, Redis/storage adapters, and the seed sample video.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
-- 0001_init.sql
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TYPE user_role AS ENUM ('student', 'mentor', 'admin');
|
||||
CREATE TYPE notif_category AS ENUM ('progress', 'gamification', 'mentor', 'event');
|
||||
CREATE TYPE quiz_status AS ENUM ('in_progress', 'passed', 'failed');
|
||||
CREATE TYPE event_type AS ENUM ('meet', 'zoom', 'workshop');
|
||||
|
||||
CREATE TABLE users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
role user_role NOT NULL DEFAULT 'student',
|
||||
xp INT NOT NULL DEFAULT 0,
|
||||
hearts INT NOT NULL DEFAULT 3,
|
||||
hearts_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
streak_current INT NOT NULL DEFAULT 0,
|
||||
streak_longest INT NOT NULL DEFAULT 0,
|
||||
last_activity_date DATE,
|
||||
is_locked BOOLEAN NOT NULL DEFAULT false,
|
||||
locked_at TIMESTAMPTZ,
|
||||
unlocked_by BIGINT REFERENCES users(id),
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE chapters (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT NOT NULL DEFAULT 'leaf',
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE lessons (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
chapter_id BIGINT NOT NULL REFERENCES chapters(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
video_key TEXT NOT NULL DEFAULT '',
|
||||
duration_seconds INT NOT NULL DEFAULT 0,
|
||||
completion_threshold_pct INT NOT NULL DEFAULT 85,
|
||||
requires_lesson_id BIGINT REFERENCES lessons(id),
|
||||
x INT NOT NULL DEFAULT 0,
|
||||
y INT NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
xp_reward INT NOT NULL DEFAULT 50,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
CREATE TABLE mcq_questions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
lesson_id BIGINT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
question TEXT NOT NULL,
|
||||
options JSONB NOT NULL,
|
||||
answer_index INT NOT NULL,
|
||||
explanation TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE watch_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
lesson_id BIGINT NOT NULL REFERENCES lessons(id),
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_position FLOAT NOT NULL DEFAULT 0,
|
||||
UNIQUE (user_id, lesson_id)
|
||||
);
|
||||
|
||||
CREATE TABLE watch_heartbeats (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
lesson_id BIGINT NOT NULL REFERENCES lessons(id),
|
||||
session_id BIGINT NOT NULL REFERENCES watch_sessions(id),
|
||||
position FLOAT NOT NULL,
|
||||
delta FLOAT NOT NULL,
|
||||
seq INT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, lesson_id, seq)
|
||||
);
|
||||
|
||||
CREATE TABLE lesson_progress (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
lesson_id BIGINT NOT NULL REFERENCES lessons(id),
|
||||
watched_seconds FLOAT NOT NULL DEFAULT 0,
|
||||
watched_pct FLOAT NOT NULL DEFAULT 0,
|
||||
last_position FLOAT NOT NULL DEFAULT 0,
|
||||
quiz_unlocked BOOLEAN NOT NULL DEFAULT false,
|
||||
passed_quiz BOOLEAN NOT NULL DEFAULT false,
|
||||
quiz_completed_at TIMESTAMPTZ,
|
||||
UNIQUE (user_id, lesson_id)
|
||||
);
|
||||
|
||||
CREATE TABLE quiz_attempts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
lesson_id BIGINT NOT NULL REFERENCES lessons(id),
|
||||
answers JSONB NOT NULL DEFAULT '[]',
|
||||
correct_count INT NOT NULL DEFAULT 0,
|
||||
total INT NOT NULL DEFAULT 0,
|
||||
score_pct FLOAT NOT NULL DEFAULT 0,
|
||||
status quiz_status NOT NULL DEFAULT 'in_progress',
|
||||
hearts_lost INT NOT NULL DEFAULT 0,
|
||||
passed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE xp_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
amount INT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
ref_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE weekly_leaderboard (
|
||||
week TEXT NOT NULL,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
xp INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (week, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
event_type event_type NOT NULL DEFAULT 'meet',
|
||||
external_url TEXT NOT NULL DEFAULT '',
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
ics_uuid UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE event_rsvps (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (event_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE chat_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
mentor_id BIGINT NOT NULL REFERENCES users(id),
|
||||
sender_role user_role NOT NULL DEFAULT 'student',
|
||||
body TEXT NOT NULL,
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE notifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
category notif_category NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
route TEXT NOT NULL DEFAULT '',
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notifications_user ON notifications (user_id, created_at DESC);
|
||||
|
||||
CREATE TABLE notification_prefs (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(id),
|
||||
progress BOOLEAN NOT NULL DEFAULT true,
|
||||
gamification BOOLEAN NOT NULL DEFAULT true,
|
||||
mentor BOOLEAN NOT NULL DEFAULT true,
|
||||
event BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
CREATE TABLE video_assets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
mime TEXT NOT NULL DEFAULT 'video/mp4',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE refresh_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE reminders_sent (
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
kind TEXT NOT NULL,
|
||||
ref TEXT NOT NULL,
|
||||
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, kind, ref)
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE chat_messages
|
||||
ADD COLUMN IF NOT EXISTS attachment_type TEXT,
|
||||
ADD COLUMN IF NOT EXISTS attachment_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS attachment_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS attachment_size BIGINT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS reply_to BIGINT;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS chat_reactions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
message_id BIGINT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(message_id, user_id, emoji)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_reactions_msg ON chat_reactions(message_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_variant TEXT NOT NULL DEFAULT 'beam';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_palette TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_photo TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,19 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT;
|
||||
|
||||
UPDATE users
|
||||
SET username = lower(split_part(email, '@', 1))
|
||||
WHERE username IS NULL OR username = '';
|
||||
|
||||
UPDATE users u
|
||||
SET username = u.username || '-' || u.id::text
|
||||
WHERE u.id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY id) AS rn
|
||||
FROM users
|
||||
) ranked
|
||||
WHERE ranked.rn > 1
|
||||
);
|
||||
|
||||
ALTER TABLE users ALTER COLUMN username SET NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_uidx ON users (lower(username));
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Keep the database invariant aligned with the application-level hearts guard.
|
||||
UPDATE users
|
||||
SET hearts = 3,
|
||||
is_locked = true,
|
||||
locked_at = COALESCE(locked_at, now()),
|
||||
hearts_updated_at = now()
|
||||
WHERE hearts > 3;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_hearts_max CHECK (hearts BETWEEN 0 AND 3);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Hot-path indexes and heartbeat retention helpers.
|
||||
CREATE INDEX IF NOT EXISTS idx_quiz_attempts_user_lesson ON quiz_attempts (user_id, lesson_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_pair ON chat_messages (user_id, mentor_id, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_progress_user ON lesson_progress (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_watch_heartbeats_created ON watch_heartbeats (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_starts ON events (starts_at) WHERE is_active;
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens (user_id) WHERE revoked_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_xp_events_user ON xp_events (user_id, created_at DESC);
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Identity, Telegram, security Q/A, certificates, physical orders
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_id BIGINT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_username TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS security_question TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS security_answer_hash TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_phone_uidx
|
||||
ON users (phone) WHERE phone <> '';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_telegram_id_uidx
|
||||
ON users (telegram_id) WHERE telegram_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
full_name TEXT NOT NULL,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
UNIQUE (user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS certificates_public_id_idx ON certificates (public_id);
|
||||
|
||||
CREATE TYPE physical_order_status AS ENUM ('requested', 'paid', 'shipped', 'cancelled');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_physical_orders (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
certificate_id BIGINT NOT NULL REFERENCES certificates(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status physical_order_status NOT NULL DEFAULT 'requested',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
window_ends_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cert_physical_orders_user_idx
|
||||
ON certificate_physical_orders (user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS cert_physical_orders_status_idx
|
||||
ON certificate_physical_orders (status, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
INSERT INTO app_settings (key, value) VALUES
|
||||
('physical_cert_enabled', 'true'),
|
||||
('physical_cert_price_irr', '2500000'),
|
||||
('physical_cert_window_days', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_pinned
|
||||
ON chat_messages (user_id, mentor_id, pinned_at DESC)
|
||||
WHERE pinned_at IS NOT NULL;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Shipping details + tracking for physical certificates
|
||||
|
||||
ALTER TABLE certificate_physical_orders
|
||||
ADD COLUMN IF NOT EXISTS recipient_name TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS address TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS city TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS postal_code TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS tracking_code TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS unlock_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS unlock_requests_user_created_idx
|
||||
ON unlock_requests (user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS unlock_requests_open_idx
|
||||
ON unlock_requests (created_at DESC)
|
||||
WHERE resolved_at IS NULL;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Public/community settings defaults + sponsors (admin-editable)
|
||||
|
||||
INSERT INTO app_settings (key, value) VALUES
|
||||
('telegram_channel_username', ''),
|
||||
('telegram_channel_title', 'کانال پرگار'),
|
||||
('donation_enabled', 'true'),
|
||||
('donation_note', 'اگر مایلید از مسیر حمایت کنید، وارد شوید و در گفتگو با تیم هماهنگ کنید — جزئیات فقط خصوصی رد و بدل میشود.'),
|
||||
('sponsors', '[{"name":"نمونه اسپانسر","url":"https://example.com","blurb":"حامی یادگیری واقعی در پرگار"}]')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Gated public registration: IP one-shot + admin toggle
|
||||
|
||||
CREATE TABLE IF NOT EXISTS registration_ips (
|
||||
ip TEXT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS registration_ips_user_idx ON registration_ips (user_id);
|
||||
|
||||
INSERT INTO app_settings (key, value) VALUES
|
||||
('registration_enabled', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Capacity-limited registration invite / membership links
|
||||
|
||||
CREATE TABLE IF NOT EXISTS registration_invites (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_enc TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
max_uses INT NOT NULL CHECK (max_uses > 0),
|
||||
used_count INT NOT NULL DEFAULT 0 CHECK (used_count >= 0),
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'revoked', 'exhausted')),
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT registration_invites_used_lte_max CHECK (used_count <= max_uses)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS registration_invites_status_idx
|
||||
ON registration_invites (status, created_at DESC);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- One-shot phone whitelist for public registration
|
||||
|
||||
CREATE TABLE IF NOT EXISTS registration_phone_whitelist (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
phone TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'available'
|
||||
CHECK (status IN ('available', 'consumed')),
|
||||
consumed_at TIMESTAMPTZ,
|
||||
consumed_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT registration_phone_whitelist_consumed_consistency CHECK (
|
||||
(status = 'available' AND consumed_at IS NULL AND consumed_user_id IS NULL)
|
||||
OR (status = 'consumed' AND consumed_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS registration_phone_whitelist_status_idx
|
||||
ON registration_phone_whitelist (status, created_at DESC);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Allow temporarily pausing registration invites without revoking them
|
||||
|
||||
ALTER TABLE registration_invites
|
||||
DROP CONSTRAINT IF EXISTS registration_invites_status_check;
|
||||
|
||||
ALTER TABLE registration_invites
|
||||
ADD CONSTRAINT registration_invites_status_check
|
||||
CHECK (status IN ('active', 'paused', 'revoked', 'exhausted'));
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Phone blacklist: blocks all registration (public and invite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS registration_phone_blacklist (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
phone TEXT NOT NULL UNIQUE,
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS registration_phone_blacklist_created_at_idx
|
||||
ON registration_phone_blacklist (created_at DESC);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Audit log when a blacklisted phone tries to register (public or invite).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS registration_blacklist_attempts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
phone TEXT NOT NULL,
|
||||
path TEXT NOT NULL CHECK (path IN ('public', 'invite')),
|
||||
invite_id BIGINT REFERENCES registration_invites(id) ON DELETE SET NULL,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
attempted_username TEXT NOT NULL DEFAULT '',
|
||||
attempted_email TEXT NOT NULL DEFAULT '',
|
||||
attempted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS registration_blacklist_attempts_phone_idx
|
||||
ON registration_blacklist_attempts (phone);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS registration_blacklist_attempts_attempted_at_idx
|
||||
ON registration_blacklist_attempts (attempted_at DESC);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Time-limited, revocable download tokens for project-wide chat export links.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_export_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_enc TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS chat_export_tokens_active_idx
|
||||
ON chat_export_tokens (expires_at DESC)
|
||||
WHERE revoked_at IS NULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Chat export download links are single-use after a successful download.
|
||||
|
||||
ALTER TABLE chat_export_tokens
|
||||
ADD COLUMN IF NOT EXISTS used_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS chat_export_tokens_used_idx
|
||||
ON chat_export_tokens (used_at)
|
||||
WHERE used_at IS NOT NULL;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS challenges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
target_xp INT NOT NULL CHECK (target_xp > 0),
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (ends_at >= starts_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_challenges_active_window
|
||||
ON challenges (is_active, starts_at, ends_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS challenge_progress (
|
||||
challenge_id BIGINT NOT NULL REFERENCES challenges(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
xp INT NOT NULL DEFAULT 0 CHECK (xp >= 0),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (challenge_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_challenge_progress_user ON challenge_progress (user_id);
|
||||
Reference in New Issue
Block a user