"""Idempotent DDL for the bot's own tables (mirrors the api's init_db.py convention — the ORM never creates tables; this script does, IF NOT EXISTS). """ import logging from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.models import Base logger = logging.getLogger("bot.db_init") DDL = [ """ CREATE TABLE IF NOT EXISTS bot_links ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), chat_id BIGINT UNIQUE NOT NULL, user_id UUID, casdoor_username TEXT, display_name TEXT, role VARCHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """, "CREATE INDEX IF NOT EXISTS ix_bot_links_chat_id ON bot_links (chat_id)", """ CREATE TABLE IF NOT EXISTS bot_drafts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), chat_id BIGINT NOT NULL, kind VARCHAR(16) NOT NULL, payload JSONB NOT NULL DEFAULT '{}'::jsonb, missing JSONB, status VARCHAR(16) NOT NULL DEFAULT 'active', source VARCHAR(16), source_message_id BIGINT, review_message_id BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """, "CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts (chat_id)", "CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts (chat_id, status)", "ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ", "ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS source_message_id BIGINT", "CREATE INDEX IF NOT EXISTS ix_bot_drafts_review_msg ON bot_drafts (review_message_id)", "CREATE INDEX IF NOT EXISTS ix_bot_drafts_source_msg ON bot_drafts (source_message_id)", """ CREATE TABLE IF NOT EXISTS bot_messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), chat_id BIGINT NOT NULL, direction VARCHAR(4) NOT NULL, kind VARCHAR(16) NOT NULL DEFAULT 'text', body TEXT, payload JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """, "CREATE INDEX IF NOT EXISTS ix_bot_messages_chat_id ON bot_messages (chat_id)", """ CREATE TABLE IF NOT EXISTS bot_meta ( key VARCHAR(64) PRIMARY KEY, value TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """, # ---- Observability (phase 1) — silent pipeline recorder, DB-only ------- """ CREATE TABLE IF NOT EXISTS bot_traces ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), chat_id BIGINT NOT NULL, message_id BIGINT, kind VARCHAR(8) NOT NULL, source VARCHAR(16), draft_id UUID, draft_kind VARCHAR(16), committed_quote_id UUID, status VARCHAR(16) NOT NULL DEFAULT 'open', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """, "CREATE INDEX IF NOT EXISTS ix_bot_traces_chat ON bot_traces (chat_id, created_at)", "CREATE INDEX IF NOT EXISTS ix_bot_traces_draft ON bot_traces (draft_id)", """ CREATE TABLE IF NOT EXISTS bot_trace_events ( id BIGSERIAL PRIMARY KEY, trace_id UUID NOT NULL REFERENCES bot_traces(id) ON DELETE CASCADE, stage VARCHAR(32) NOT NULL, ok BOOLEAN NOT NULL DEFAULT TRUE, latency_ms INTEGER, model VARCHAR(64), field VARCHAR(32), payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """, "CREATE INDEX IF NOT EXISTS ix_bot_trace_events_trace ON bot_trace_events (trace_id, created_at)", "CREATE INDEX IF NOT EXISTS ix_bot_trace_events_stage ON bot_trace_events (stage)", # dashboard queries filter stage + time range together (senior-engineer review # 2026-10-24; column is `stage` — the review's `event_type` doesn't exist here) "CREATE INDEX IF NOT EXISTS ix_bot_trace_events_stage_time ON bot_trace_events (stage, created_at DESC)", "CREATE INDEX IF NOT EXISTS ix_bot_trace_events_payload ON bot_trace_events USING GIN (payload jsonb_path_ops)", ] async def ensure_obs_reader(session: AsyncSession) -> None: """Read-only role for the Grafana Postgres datasource (dashboard plan phase 1). Idempotent; entirely skipped when PROCUREMENT_BOT_OBS_READER_PASSWORD is empty (local dev may keep using the postgres superuser for the datasource instead). Password comes from env so rotation never needs a code change. """ from app.config import settings pwd = (settings.OBS_READER_PASSWORD or "").strip() if not pwd: logger.info("obs_reader role skipped (no OBS_READER_PASSWORD configured)") return # CREATE ROLE's PASSWORD clause takes a string literal only (no function # expressions), so the env value is embedded with standard '' escaping — # bound parameters cannot cross into a DO block. lit = pwd.replace("'", "''") await session.execute(text(f""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'obs_reader') THEN CREATE ROLE obs_reader LOGIN PASSWORD '{lit}'; ELSE ALTER ROLE obs_reader WITH LOGIN PASSWORD '{lit}'; END IF; END $$ """)) await session.execute(text("GRANT USAGE ON SCHEMA public TO obs_reader")) await session.execute(text( "GRANT SELECT ON TABLE public.bot_traces, public.bot_trace_events TO obs_reader")) await session.commit() logger.info("obs_reader role ready (USAGE on public + SELECT on bot_traces/bot_trace_events)") async def ensure_bot_tables(session: AsyncSession) -> None: for stmt in DDL: await session.execute(text(stmt)) await session.commit() logger.info("bot tables ready") async def ensure_schema() -> None: from app.db import SessionLocal async with SessionLocal() as session: await ensure_bot_tables(session) await ensure_obs_reader(session)