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:
ahp
2026-10-07 19:40:26 +03:30
parent 5b9b2a16e6
commit f76c7a110b
40 changed files with 2289 additions and 0 deletions
@@ -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);