-- 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.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 '', 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); 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; 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'); 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.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() ) );