BEGIN; CREATE TABLE memoir_chat_import ( id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenant(id), subject_id uuid NOT NULL REFERENCES subject_profile(id), created_by uuid NOT NULL REFERENCES user_account(id), title text NOT NULL, transcript_digest text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), status text NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','READY','FAILED')) ); CREATE TABLE memoir_import_turn ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), import_id uuid NOT NULL REFERENCES memoir_chat_import(id), sequence_no integer NOT NULL, speaker text NOT NULL CHECK(speaker IN ('subject','agent')), content text NOT NULL, UNIQUE(import_id,sequence_no) ); -- One call may contain several independent events. Keep the original story -- origin for navigation, and carry every contributing source on revisions. ALTER TABLE story_draft ADD COLUMN event_index integer NOT NULL DEFAULT 0 CHECK(event_index>=0); ALTER TABLE story_draft ADD COLUMN event_time text NOT NULL DEFAULT ''; ALTER TABLE story_draft ADD COLUMN import_id uuid REFERENCES memoir_chat_import(id); ALTER TABLE story_draft DROP CONSTRAINT story_origin_check; ALTER TABLE story_draft ADD CONSTRAINT story_origin_check CHECK( num_nonnulls(conversation_id,call_session_id,import_id)=1 AND ((call_session_id IS NOT NULL AND call_privacy_epoch IS NOT NULL) OR (call_session_id IS NULL AND call_privacy_epoch IS NULL))); ALTER TABLE story_source ADD COLUMN import_turn_id uuid REFERENCES memoir_import_turn(id); ALTER TABLE story_source DROP CONSTRAINT story_source_origin_check; ALTER TABLE story_source ADD CONSTRAINT story_source_origin_check CHECK( num_nonnulls(conversation_turn_id,call_session_id,import_turn_id)=1 AND ((call_session_id IS NOT NULL AND call_utterance_id IS NOT NULL AND call_source_revision IS NOT NULL) OR (call_session_id IS NULL AND call_utterance_id IS NULL AND call_source_revision IS NULL))); ALTER TABLE story_source ADD CONSTRAINT story_import_source_key UNIQUE(story_revision_id,import_turn_id); ALTER TABLE story_draft DROP CONSTRAINT story_draft_generation_key; ALTER TABLE story_draft DROP CONSTRAINT story_call_generation_key; ALTER TABLE story_draft ADD CONSTRAINT story_draft_generation_key UNIQUE(conversation_id,generation_version,event_index); ALTER TABLE story_draft ADD CONSTRAINT story_call_generation_key UNIQUE(call_session_id,generation_version,event_index); ALTER TABLE story_draft ADD CONSTRAINT story_import_generation_key UNIQUE(import_id,generation_version,event_index); CREATE TABLE chat_daily_event ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id uuid NOT NULL REFERENCES tenant(id), subject_id uuid NOT NULL REFERENCES subject_profile(id), conversation_id uuid REFERENCES conversation(id), call_session_id uuid REFERENCES call_session(id), import_id uuid REFERENCES memoir_chat_import(id), generation_version bigint NOT NULL, event_index integer NOT NULL, event_date date NOT NULL, title text NOT NULL, content text NOT NULL, source_refs jsonb NOT NULL, model_ref text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), CHECK(num_nonnulls(conversation_id,call_session_id,import_id)=1), UNIQUE(conversation_id,generation_version,event_index), UNIQUE(call_session_id,generation_version,event_index), UNIQUE(import_id,generation_version,event_index) ); CREATE TABLE story_generation_event ( origin_id uuid NOT NULL, generation_version bigint NOT NULL, event_index integer NOT NULL, story_id uuid NOT NULL REFERENCES story_draft(id), PRIMARY KEY(origin_id,generation_version,event_index) ); CREATE TABLE memoir_document ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id uuid NOT NULL REFERENCES tenant(id), subject_id uuid NOT NULL REFERENCES subject_profile(id), version bigint NOT NULL DEFAULT 1, source_digest text NOT NULL, document jsonb NOT NULL, model_ref text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(subject_id,version) ); CREATE INDEX memoir_document_subject_idx ON memoir_document(subject_id,version DESC); CREATE INDEX chat_daily_event_subject_idx ON chat_daily_event(subject_id,event_date DESC,created_at DESC); CREATE TABLE memoir_generation_job ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), subject_id uuid NOT NULL REFERENCES subject_profile(id), tenant_id uuid NOT NULL REFERENCES tenant(id), actor_id uuid NOT NULL REFERENCES user_account(id), source_digest text NOT NULL, state text NOT NULL DEFAULT 'PENDING' CHECK(state IN ('PENDING','RUNNING','READY','FAILED')), error_code text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX memoir_generation_active ON memoir_generation_job(subject_id) WHERE state IN ('PENDING','RUNNING'); CREATE TABLE memoir_illustration ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), document_id uuid NOT NULL REFERENCES memoir_document(id) ON DELETE CASCADE, chapter_index integer NOT NULL, content bytea NOT NULL, content_type text NOT NULL DEFAULT 'image/jpeg', UNIQUE(document_id,chapter_index) ); -- Every source in a merged story must remain valid, including other calls. CREATE OR REPLACE FUNCTION call_story_available(story uuid) RETURNS boolean LANGUAGE sql STABLE AS $$ SELECT EXISTS(SELECT 1 FROM story_draft sd JOIN subject_profile sp ON sp.id=sd.subject_id WHERE sd.id=story AND sp.status='active' AND NOT EXISTS(SELECT 1 FROM privacy_request pr WHERE pr.subject_id=sd.subject_id AND pr.request_type='DELETE') AND EXISTS(SELECT 1 FROM story_source ss WHERE ss.story_revision_id=sd.current_revision_id) AND NOT EXISTS(SELECT 1 FROM story_source ss LEFT JOIN call_utterance u ON u.session_id=ss.call_session_id AND u.utterance_id=ss.call_utterance_id LEFT JOIN call_session c ON c.id=ss.call_session_id LEFT JOIN conversation_turn ct ON ct.id=ss.conversation_turn_id LEFT JOIN conversation cv ON cv.id=ct.conversation_id LEFT JOIN memoir_import_turn it ON it.id=ss.import_turn_id LEFT JOIN memoir_chat_import ci ON ci.id=it.import_id WHERE ss.story_revision_id=sd.current_revision_id AND ( (ss.call_session_id IS NOT NULL AND (c.tenant_id IS DISTINCT FROM sd.tenant_id OR c.subject_id IS DISTINCT FROM sd.subject_id OR NOT call_story_authorized(c.id) OR u.source_revision IS DISTINCT FROM ss.call_source_revision OR u.speaker<>'USER' OR u.text_content='[deleted]' OR length(trim(u.text_content))=0)) OR (ss.conversation_turn_id IS NOT NULL AND (cv.tenant_id IS DISTINCT FROM sd.tenant_id OR cv.subject_id IS DISTINCT FROM sd.subject_id OR ct.speaker<>'subject' OR ct.final_text='[deleted]' OR length(trim(ct.final_text))=0 OR NOT EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=sd.subject_id AND cr.consent_type='story_extraction' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now())))) OR (ss.import_turn_id IS NOT NULL AND (ci.tenant_id IS DISTINCT FROM sd.tenant_id OR ci.subject_id IS DISTINCT FROM sd.subject_id OR it.speaker<>'subject' OR it.content='[deleted]' OR length(trim(it.content))=0 OR NOT EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=sd.subject_id AND cr.consent_type='story_extraction' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now())))) OR EXISTS(SELECT 1 FROM memory_source_suppression ms CROSS JOIN LATERAL jsonb_array_elements(ms.source_refs) r WHERE ms.subject_id=sd.subject_id AND (r->>'story_id'=sd.public_id::text OR (ss.call_session_id IS NOT NULL AND r->>'session_id'=ss.call_session_id::text AND (r->>'utterance_id' IS NULL OR r->>'utterance_id'=ss.call_utterance_id)) OR (ss.conversation_turn_id IS NOT NULL AND r->>'turn_id'=ct.public_id::text) OR (ss.import_turn_id IS NOT NULL AND r->>'import_id'=ci.id::text AND (r->>'turn_id' IS NULL OR r->>'turn_id'=it.id::text)))))) ) $$; COMMIT;