-- Runtime auth/session tables used by the BFF boundary. -- This runs during DB initialization so the API can use a non-owner app role. ALTER TABLE app.app_user ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS legal_name TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS department TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS grade_level TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS contact_address TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS nickname TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS self_introduction TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS avatar_url TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS profile_completed_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS terms_agreed_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS privacy_agreed_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS terms_version TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS privacy_version TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), ADD COLUMN IF NOT EXISTS account_status TEXT NOT NULL DEFAULT 'approved', ADD COLUMN IF NOT EXISTS admin_access BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); ALTER TABLE app.app_user ALTER COLUMN affiliation SET DEFAULT ''; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'app_user_account_status_check' AND conrelid = 'app.app_user'::regclass ) THEN ALTER TABLE app.app_user ADD CONSTRAINT app_user_account_status_check CHECK (account_status IN ('pending','approved','suspended')); END IF; END $$; CREATE INDEX IF NOT EXISTS idx_app_user_account_status ON app.app_user(account_status, last_seen_at DESC); CREATE TABLE IF NOT EXISTS app.auth_session ( sid_hash TEXT PRIMARY KEY, user_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE CASCADE, role TEXT NOT NULL, display_name TEXT NOT NULL, cohort_ids JSONB NOT NULL DEFAULT '[]'::jsonb, expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_auth_session_user_active ON app.auth_session(user_id, expires_at) WHERE revoked_at IS NULL; CREATE TABLE IF NOT EXISTS app.user_preferences ( user_id UUID PRIMARY KEY REFERENCES app.app_user(user_id) ON DELETE CASCADE, theme TEXT NOT NULL DEFAULT 'system', voice_preset_id TEXT NOT NULL DEFAULT 'soft-young-fem', voice_rate REAL NOT NULL DEFAULT 1.0, notifications JSONB NOT NULL DEFAULT '{}'::jsonb, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS app.admin_engine_config ( id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id), engine_mode TEXT NOT NULL, engine_url TEXT NOT NULL, model TEXT NOT NULL, updated_by TEXT, updated_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS app.admin_health_event ( id BIGSERIAL PRIMARY KEY, observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), overall_status TEXT NOT NULL CHECK (overall_status IN ('ok','degraded','down')), environment TEXT NOT NULL, engine_mode TEXT NOT NULL, service_key TEXT NOT NULL, service_name TEXT NOT NULL, service_status TEXT NOT NULL CHECK (service_status IN ('ok','degraded','down')), detail TEXT NOT NULL DEFAULT '', metric TEXT NOT NULL DEFAULT '', load REAL NOT NULL DEFAULT 0.0 CHECK (load >= 0.0 AND load <= 1.0), captured_by UUID REFERENCES app.app_user(user_id) ); CREATE INDEX IF NOT EXISTS idx_admin_health_event_observed ON app.admin_health_event(observed_at DESC, service_key); CREATE TABLE IF NOT EXISTS app.admin_health_daily_rollup ( rollup_date DATE NOT NULL, environment TEXT NOT NULL, engine_mode TEXT NOT NULL, service_key TEXT NOT NULL, service_name TEXT NOT NULL, sample_count INTEGER NOT NULL DEFAULT 0 CHECK (sample_count >= 0), ok_samples INTEGER NOT NULL DEFAULT 0 CHECK (ok_samples >= 0), degraded_samples INTEGER NOT NULL DEFAULT 0 CHECK (degraded_samples >= 0), down_samples INTEGER NOT NULL DEFAULT 0 CHECK (down_samples >= 0), first_observed_at TIMESTAMPTZ NOT NULL, last_observed_at TIMESTAMPTZ NOT NULL, latest_status TEXT NOT NULL CHECK (latest_status IN ('ok','degraded','down')), last_down_at TIMESTAMPTZ, max_load REAL NOT NULL DEFAULT 0.0 CHECK (max_load >= 0.0 AND max_load <= 1.0), generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (rollup_date, environment, engine_mode, service_key) ); CREATE INDEX IF NOT EXISTS idx_admin_health_daily_rollup_latest ON app.admin_health_daily_rollup(last_observed_at DESC, service_key); CREATE TABLE IF NOT EXISTS app.support_ticket ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), reporter_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL, reporter_email TEXT NOT NULL, reporter_name TEXT NOT NULL, reporter_role TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'other' CHECK ( category IN ( 'account_access', 'session_review', 'voice_browser', 'content_scenario', 'safety', 'other' ) ), priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high','urgent')), status TEXT NOT NULL DEFAULT 'open' CHECK ( status IN ('open','triaged','in_progress','resolved','closed') ), subject TEXT NOT NULL, body TEXT NOT NULL, source_path TEXT NOT NULL DEFAULT '', fingerprint TEXT NOT NULL DEFAULT '', parent_ticket_id UUID REFERENCES app.support_ticket(id) ON DELETE SET NULL, assigned_group TEXT NOT NULL DEFAULT '', resolution_note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), resolved_at TIMESTAMPTZ, last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_support_ticket_status_priority ON app.support_ticket(status, priority, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_support_ticket_reporter ON app.support_ticket(reporter_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_support_ticket_fingerprint ON app.support_ticket(fingerprint, created_at DESC) WHERE fingerprint <> ''; CREATE INDEX IF NOT EXISTS idx_support_ticket_parent ON app.support_ticket(parent_ticket_id) WHERE parent_ticket_id IS NOT NULL; ALTER TABLE app.session_review_status ADD COLUMN IF NOT EXISTS worksheet_status TEXT NOT NULL DEFAULT 'pending'; ALTER TABLE app.session_review_status ADD COLUMN IF NOT EXISTS worksheet_note TEXT NOT NULL DEFAULT ''; ALTER TABLE app.session_review_status ADD COLUMN IF NOT EXISTS worksheet_reviewed_at TIMESTAMPTZ; CREATE TABLE IF NOT EXISTS app.notification_event ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), kind TEXT NOT NULL CHECK ( kind IN ('account_pending_approval','session_review_ready','admin_test_email') ), actor_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL, subject_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL, session_id UUID REFERENCES app.sessions(id) ON DELETE CASCADE, idempotency_key TEXT NOT NULL UNIQUE, payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS app.notification_delivery ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_id UUID NOT NULL REFERENCES app.notification_event(id) ON DELETE CASCADE, channel TEXT NOT NULL DEFAULT 'email' CHECK (channel IN ('email')), recipient_user_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL, recipient_email TEXT NOT NULL, recipient_name TEXT NOT NULL DEFAULT '', recipient_role TEXT NOT NULL DEFAULT '', subject TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued' CHECK ( status IN ('queued','sending','sent','failed','skipped') ), attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), sent_at TIMESTAMPTZ, last_error TEXT, provider_message_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (event_id, channel, recipient_email) ); CREATE INDEX IF NOT EXISTS idx_notification_event_created ON app.notification_event(created_at DESC); CREATE INDEX IF NOT EXISTS idx_notification_delivery_queue ON app.notification_delivery(status, next_attempt_at, created_at) WHERE status IN ('queued','failed'); CREATE INDEX IF NOT EXISTS idx_notification_delivery_event ON app.notification_delivery(event_id, created_at); CREATE TABLE IF NOT EXISTS app.learner_prepost_measure ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE CASCADE, pilot_id TEXT NOT NULL DEFAULT 'phase3-pilot-draft', measure_name TEXT NOT NULL CHECK ( measure_name IN ('self_efficacy','skill_proficiency','training_satisfaction') ), timepoint TEXT NOT NULL CHECK (timepoint IN ('pre','post')), raw_score NUMERIC(8,3) NOT NULL, min_score NUMERIC(8,3) NOT NULL DEFAULT 1.0, max_score NUMERIC(8,3) NOT NULL DEFAULT 5.0, instrument_version TEXT NOT NULL DEFAULT 'pilot-prepost-scaffold-2026-06-28', item_count INTEGER NOT NULL DEFAULT 1 CHECK (item_count >= 1 AND item_count <= 80), collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (max_score > min_score), CHECK (raw_score >= min_score AND raw_score <= max_score), UNIQUE (learner_id, pilot_id, measure_name, timepoint, instrument_version) ); CREATE INDEX IF NOT EXISTS idx_learner_prepost_measure_pilot ON app.learner_prepost_measure(pilot_id, measure_name, timepoint); CREATE INDEX IF NOT EXISTS idx_learner_prepost_measure_learner ON app.learner_prepost_measure(learner_id, pilot_id); ALTER TABLE app.admin_health_event ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_admin_health_event_select ON app.admin_health_event; DROP POLICY IF EXISTS p_admin_health_event_insert ON app.admin_health_event; DROP POLICY IF EXISTS p_admin_health_event_delete ON app.admin_health_event; CREATE POLICY p_admin_health_event_select ON app.admin_health_event FOR SELECT USING (app.current_role_name() = 'admin'); CREATE POLICY p_admin_health_event_insert ON app.admin_health_event FOR INSERT WITH CHECK (app.current_role_name() = 'admin'); CREATE POLICY p_admin_health_event_delete ON app.admin_health_event FOR DELETE USING (app.current_role_name() = 'admin'); ALTER TABLE app.admin_health_daily_rollup ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_admin_health_daily_rollup_select ON app.admin_health_daily_rollup; DROP POLICY IF EXISTS p_admin_health_daily_rollup_insert ON app.admin_health_daily_rollup; DROP POLICY IF EXISTS p_admin_health_daily_rollup_update ON app.admin_health_daily_rollup; CREATE POLICY p_admin_health_daily_rollup_select ON app.admin_health_daily_rollup FOR SELECT USING (app.current_role_name() = 'admin'); CREATE POLICY p_admin_health_daily_rollup_insert ON app.admin_health_daily_rollup FOR INSERT WITH CHECK (app.current_role_name() = 'admin'); CREATE POLICY p_admin_health_daily_rollup_update ON app.admin_health_daily_rollup FOR UPDATE USING (app.current_role_name() = 'admin') WITH CHECK (app.current_role_name() = 'admin'); ALTER TABLE app.support_ticket ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_support_ticket_select ON app.support_ticket; DROP POLICY IF EXISTS p_support_ticket_insert ON app.support_ticket; DROP POLICY IF EXISTS p_support_ticket_update ON app.support_ticket; DROP POLICY IF EXISTS p_support_ticket_delete ON app.support_ticket; CREATE POLICY p_support_ticket_select ON app.support_ticket FOR SELECT USING ( app.current_role_name() = 'admin' OR reporter_id = app.current_uid() ); CREATE POLICY p_support_ticket_insert ON app.support_ticket FOR INSERT WITH CHECK ( app.current_role_name() = 'admin' OR reporter_id = app.current_uid() ); CREATE POLICY p_support_ticket_update ON app.support_ticket FOR UPDATE USING (app.current_role_name() = 'admin') WITH CHECK (app.current_role_name() = 'admin'); CREATE POLICY p_support_ticket_delete ON app.support_ticket FOR DELETE USING (app.current_role_name() = 'admin'); ALTER TABLE app.notification_event ENABLE ROW LEVEL SECURITY; ALTER TABLE app.notification_delivery ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_notification_event_admin_all ON app.notification_event; DROP POLICY IF EXISTS p_notification_delivery_admin_all ON app.notification_delivery; CREATE POLICY p_notification_event_admin_all ON app.notification_event FOR ALL USING (app.current_role_name() = 'admin') WITH CHECK (app.current_role_name() = 'admin'); CREATE POLICY p_notification_delivery_admin_all ON app.notification_delivery FOR ALL USING (app.current_role_name() = 'admin') WITH CHECK (app.current_role_name() = 'admin'); ALTER TABLE app.learner_prepost_measure ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_learner_prepost_measure_select ON app.learner_prepost_measure; DROP POLICY IF EXISTS p_learner_prepost_measure_insert ON app.learner_prepost_measure; DROP POLICY IF EXISTS p_learner_prepost_measure_update ON app.learner_prepost_measure; CREATE POLICY p_learner_prepost_measure_select ON app.learner_prepost_measure FOR SELECT USING ( app.current_role_name() = 'admin' OR learner_id = app.current_uid() OR ( app.current_role_name() = 'instructor' AND EXISTS ( SELECT 1 FROM app.app_user u WHERE u.user_id = app.learner_prepost_measure.learner_id AND u.cohort = current_setting('app.current_cohort', true) ) ) ); CREATE POLICY p_learner_prepost_measure_insert ON app.learner_prepost_measure FOR INSERT WITH CHECK ( app.current_role_name() = 'admin' OR learner_id = app.current_uid() ); CREATE POLICY p_learner_prepost_measure_update ON app.learner_prepost_measure FOR UPDATE USING ( app.current_role_name() = 'admin' OR learner_id = app.current_uid() ) WITH CHECK ( app.current_role_name() = 'admin' OR learner_id = app.current_uid() ); ALTER TABLE app.sessions ADD COLUMN IF NOT EXISTS runtime_case_id UUID, ADD COLUMN IF NOT EXISTS persona_code TEXT, ADD COLUMN IF NOT EXISTS persona_display_name TEXT, ADD COLUMN IF NOT EXISTS persona_difficulty TEXT, ADD COLUMN IF NOT EXISTS prev_rapport_credit REAL NOT NULL DEFAULT 0.0; ALTER TABLE app.session_state ADD COLUMN IF NOT EXISTS turns_in_stage INT NOT NULL DEFAULT 0; INSERT INTO app.stage_def (stage_code, display_name, seq, base_openness) VALUES ('라포', '라포', 1, 0.15), ('탐색', '탐색', 2, 0.35), ('개입', '개입', 3, 0.55), ('정리', '정리', 4, 0.45) ON CONFLICT (stage_code) DO UPDATE SET display_name = EXCLUDED.display_name, seq = EXCLUDED.seq, base_openness = EXCLUDED.base_openness; DROP POLICY IF EXISTS p_sessions_modify ON app.sessions; DROP POLICY IF EXISTS p_sessions_insert ON app.sessions; DROP POLICY IF EXISTS p_sessions_update ON app.sessions; DROP POLICY IF EXISTS p_sessions_delete ON app.sessions; CREATE POLICY p_sessions_insert ON app.sessions FOR INSERT WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR learner_id = app.current_uid() ); CREATE POLICY p_sessions_update ON app.sessions FOR UPDATE USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR learner_id = app.current_uid() ) WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR learner_id = app.current_uid() ); CREATE POLICY p_sessions_delete ON app.sessions FOR DELETE USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR learner_id = app.current_uid() ); DROP POLICY IF EXISTS p_turns_modify ON app.turns; DROP POLICY IF EXISTS p_turns_insert ON app.turns; DROP POLICY IF EXISTS p_turns_update ON app.turns; DROP POLICY IF EXISTS p_turns_delete ON app.turns; CREATE POLICY p_turns_insert ON app.turns FOR INSERT WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.turns.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_turns_update ON app.turns FOR UPDATE USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.turns.session_id AND s.learner_id = app.current_uid() ) ) WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.turns.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_turns_delete ON app.turns FOR DELETE USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.turns.session_id AND s.learner_id = app.current_uid() ) );