diff --git a/internal/adapters/store/migrations/017_uploaded_transcripts.down.sql b/internal/adapters/store/migrations/017_uploaded_transcripts.down.sql new file mode 100644 index 0000000..8040680 --- /dev/null +++ b/internal/adapters/store/migrations/017_uploaded_transcripts.down.sql @@ -0,0 +1,3 @@ +-- Drops the private uploaded-transcript store and every transcript in it. There +-- is no revert of the data: the original audio was never kept (ADR-033). +DROP TABLE uploaded_transcripts; diff --git a/internal/adapters/store/migrations/017_uploaded_transcripts.up.sql b/internal/adapters/store/migrations/017_uploaded_transcripts.up.sql new file mode 100644 index 0000000..bdd311a --- /dev/null +++ b/internal/adapters/store/migrations/017_uploaded_transcripts.up.sql @@ -0,0 +1,48 @@ +-- Migration 017: uploaded_transcripts — PRIVATE transcripts of audio a user +-- uploaded themselves (ADR-033, tapir#33). +-- +-- This is deliberately NOT the shared `transcripts` table (migration 015, +-- ADR-021). That table is non-RLS on the explicit grounds that it holds only +-- public caption content and nothing user-identifying. A recorded meeting is the +-- opposite: private third-party content owned by exactly one user. Reusing +-- `transcripts` — or adding a user_id to it with a partial policy — would put a +-- private row in the one table the isolation test asserts is shared, and would +-- pass CI green. A separate table keeps ADR-021's exception singular. +-- +-- Row-level security follows migration 003 exactly: ENABLE + FORCE (the app +-- connects as the table owner, which bypasses RLS unless forced), and a policy +-- keyed off the per-request GUC tapir.current_user_id. With no GUC set the +-- predicate is NULL and the table is deny-all. +-- +-- The original audio is never stored (ADR-033): this row is all that is kept. +-- segments holds the timestamped cues; re-transcribing 90 minutes costs ~7 +-- minutes, so discarding the timestamps would be irreversible. +CREATE TABLE uploaded_transcripts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'transcribing', 'done', 'failed')), + content TEXT, + segments JSONB, + endpoint TEXT, -- which backend served it (iguana | berget); a quality fact + audio_seconds DOUBLE PRECISION, + error_kind TEXT, -- typed, never a provider's error string (ADR-033) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ, + CONSTRAINT uploaded_transcripts_done_has_content + CHECK (status <> 'done' OR content IS NOT NULL), + CONSTRAINT uploaded_transcripts_failed_has_error_kind + CHECK (status <> 'failed' OR error_kind IS NOT NULL) +); + +CREATE INDEX idx_uploaded_transcripts_user ON uploaded_transcripts (user_id, created_at DESC); + +ALTER TABLE uploaded_transcripts ENABLE ROW LEVEL SECURITY; +ALTER TABLE uploaded_transcripts FORCE ROW LEVEL SECURITY; +CREATE POLICY uploaded_transcripts_isolation ON uploaded_transcripts + FOR ALL + USING (user_id = current_setting('tapir.current_user_id', true)::uuid); + +COMMENT ON TABLE uploaded_transcripts IS + 'PRIVATE per-user transcripts of user-supplied audio. RLS-scoped (ADR-012). Never merge into the shared transcripts table (ADR-021, ADR-033).';