-- ============================================================================ -- Migration 026: Ticket System Enhancements - Kravspecifikation Implementation -- ============================================================================ -- Implementerer: -- 1. Ticket relations (merge, split, parent/child hierarchy) -- 2. Calendar events og deadlines -- 3. Templates system -- 4. AI suggestions (metadata only - ingen automatik) -- 5. Enhanced contact identification -- ============================================================================ -- ============================================================================ -- TICKET RELATIONS (flette, splitte, hierarki) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_relations ( id SERIAL PRIMARY KEY, ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE, related_ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE, relation_type VARCHAR(20) NOT NULL CHECK (relation_type IN ('merged_into', 'split_from', 'parent_of', 'child_of', 'related_to')), -- Metadata om relationen created_by_user_id INTEGER, -- Reference til users.user_id (read-only) created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, reason TEXT, -- Hvorfor blev relationen oprettet CONSTRAINT unique_relation UNIQUE (ticket_id, related_ticket_id, relation_type), CONSTRAINT no_self_reference CHECK (ticket_id != related_ticket_id) ); CREATE INDEX IF NOT EXISTS idx_tticket_relations_ticket ON tticket_relations(ticket_id); CREATE INDEX IF NOT EXISTS idx_tticket_relations_related ON tticket_relations(related_ticket_id); CREATE INDEX IF NOT EXISTS idx_tticket_relations_type ON tticket_relations(relation_type); -- View for at finde alle relationer for en ticket (begge retninger) CREATE OR REPLACE VIEW tticket_all_relations AS SELECT ticket_id, related_ticket_id, relation_type, created_by_user_id, created_at, reason FROM tticket_relations UNION ALL SELECT related_ticket_id as ticket_id, ticket_id as related_ticket_id, CASE WHEN relation_type = 'parent_of' THEN 'child_of' WHEN relation_type = 'child_of' THEN 'parent_of' WHEN relation_type = 'merged_into' THEN 'merged_from' WHEN relation_type = 'split_from' THEN 'split_into' ELSE relation_type END as relation_type, created_by_user_id, created_at, reason FROM tticket_relations; -- ============================================================================ -- CALENDAR EVENTS (aftaler, deadlines, milepæle) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_calendar_events ( id SERIAL PRIMARY KEY, ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE, -- Event data title VARCHAR(200) NOT NULL, description TEXT, event_type VARCHAR(20) DEFAULT 'appointment' CHECK (event_type IN ('appointment', 'deadline', 'milestone', 'reminder', 'follow_up')), -- Tidspunkt event_date DATE NOT NULL, event_time TIME, duration_minutes INTEGER, -- Varighed i minutter all_day BOOLEAN DEFAULT false, -- AI forslag suggested_by_ai BOOLEAN DEFAULT false, -- Blev denne foreslået af AI? ai_confidence DECIMAL(3,2), -- AI confidence score 0-1 ai_source_text TEXT, -- Tekst som AI fandt datoen i -- Status status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'completed', 'cancelled')), -- Metadata created_by_user_id INTEGER, -- Reference til users.user_id created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, completed_at TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id); CREATE INDEX IF NOT EXISTS idx_tticket_calendar_date ON tticket_calendar_events(event_date); CREATE INDEX IF NOT EXISTS idx_tticket_calendar_type ON tticket_calendar_events(event_type); CREATE INDEX IF NOT EXISTS idx_tticket_calendar_status ON tticket_calendar_events(status); -- ============================================================================ -- TEMPLATES (svarskabeloner, guides, standardbreve) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_templates ( id SERIAL PRIMARY KEY, -- Template metadata name VARCHAR(200) NOT NULL, description TEXT, category VARCHAR(100), -- guide, standard_letter, technical, billing, etc. -- Template indhold subject_template VARCHAR(500), -- Emne med placeholders body_template TEXT NOT NULL, -- Indhold med placeholders -- Placeholders dokumentation available_placeholders TEXT[], -- fx ['{{customer_name}}', '{{ticket_number}}'] -- Attachments (optional) default_attachments JSONB, -- Array af fil-paths/URLs -- Settings is_active BOOLEAN DEFAULT true, requires_approval BOOLEAN DEFAULT false, -- Kræver godkendelse før afsendelse -- Metadata created_by_user_id INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, last_used_at TIMESTAMP, usage_count INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_tticket_templates_category ON tticket_templates(category); CREATE INDEX IF NOT EXISTS idx_tticket_templates_active ON tticket_templates(is_active); -- ============================================================================ -- TEMPLATE USAGE LOG (hvornår blev skabeloner brugt) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_template_usage ( id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL REFERENCES tticket_templates(id) ON DELETE CASCADE, ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE, user_id INTEGER, -- Reference til users.user_id used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, was_modified BOOLEAN DEFAULT false -- Blev template redigeret før afsendelse? ); CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_template ON tticket_template_usage(template_id); CREATE INDEX IF NOT EXISTS idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id); -- ============================================================================ -- AI SUGGESTIONS (forslag til actions - aldrig automatisk) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_ai_suggestions ( id SERIAL PRIMARY KEY, ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE, -- Suggestion data suggestion_type VARCHAR(50) NOT NULL CHECK (suggestion_type IN ( 'contact_update', -- Opdater kontakt oplysninger 'new_contact', -- Ny kontakt opdaget 'category', -- Foreslå kategori 'tag', -- Foreslå tag 'priority', -- Foreslå prioritet 'deadline', -- Foreslå deadline 'calendar_event', -- Foreslå kalender event 'template', -- Foreslå skabelon 'merge', -- Foreslå flet med anden ticket 'related_ticket' -- Foreslå relation til anden ticket )), -- Suggestion content suggestion_data JSONB NOT NULL, -- Struktureret data om forslaget confidence DECIMAL(3,2), -- AI confidence 0-1 reasoning TEXT, -- Hvorfor blev dette foreslået -- Source source_text TEXT, -- Tekst som AI analyserede source_comment_id INTEGER REFERENCES tticket_comments(id) ON DELETE CASCADE, -- Status status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'auto_expired')), reviewed_by_user_id INTEGER, -- Hvem behandlede forslaget reviewed_at TIMESTAMP, -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP -- Forslag udløber efter X dage ); CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id); CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_type ON tticket_ai_suggestions(suggestion_type); CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status); CREATE INDEX IF NOT EXISTS idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at); -- ============================================================================ -- EMAIL METADATA (udvidet til contact identification) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_email_metadata ( id SERIAL PRIMARY KEY, ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE, -- Email headers message_id VARCHAR(500) UNIQUE, -- Email Message-ID for threading in_reply_to VARCHAR(500), -- In-Reply-To header email_references TEXT, -- References header (renamed to avoid SQL keyword conflict) -- Sender info (fra email) from_email VARCHAR(255) NOT NULL, from_name VARCHAR(255), from_signature TEXT, -- Udtræk af signatur -- Matched contact (hvis fundet) matched_contact_id INTEGER, -- Reference til contacts.id match_confidence DECIMAL(3,2), -- Hvor sikker er vi på match match_method VARCHAR(50), -- email_exact, email_domain, name_similarity, etc. -- Suggested contacts (hvis tvivl) suggested_contacts JSONB, -- Array af {contact_id, confidence, reason} -- Extracted data (AI analysis) extracted_phone VARCHAR(50), extracted_address TEXT, extracted_company VARCHAR(255), extracted_title VARCHAR(100), -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_tticket_email_ticket ON tticket_email_metadata(ticket_id); CREATE INDEX IF NOT EXISTS idx_tticket_email_message_id ON tticket_email_metadata(message_id); CREATE INDEX IF NOT EXISTS idx_tticket_email_from ON tticket_email_metadata(from_email); -- ============================================================================ -- Tilføj manglende kolonner til existing tticket_tickets -- ============================================================================ ALTER TABLE tticket_tickets ADD COLUMN IF NOT EXISTS deadline TIMESTAMP, ADD COLUMN IF NOT EXISTS parent_ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS is_merged BOOLEAN DEFAULT false, ADD COLUMN IF NOT EXISTS merged_into_ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_tticket_tickets_deadline ON tticket_tickets(deadline); CREATE INDEX IF NOT EXISTS idx_tticket_tickets_parent ON tticket_tickets(parent_ticket_id); -- ============================================================================ -- AUDIT LOG for ticket changes (sporbarhed) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_audit_log ( id SERIAL PRIMARY KEY, ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE, -- What changed action VARCHAR(50) NOT NULL, -- created, updated, merged, split, status_change, etc. field_name VARCHAR(100), -- Hvilket felt blev ændret old_value TEXT, new_value TEXT, -- Who and when user_id INTEGER, -- Reference til users.user_id performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Context reason TEXT, metadata JSONB -- Additional context ); ALTER TABLE tticket_audit_log ADD COLUMN IF NOT EXISTS field_name VARCHAR(100), ADD COLUMN IF NOT EXISTS performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, ADD COLUMN IF NOT EXISTS reason TEXT, ADD COLUMN IF NOT EXISTS metadata JSONB; CREATE INDEX IF NOT EXISTS idx_tticket_audit_ticket ON tticket_audit_log(ticket_id); CREATE INDEX IF NOT EXISTS idx_tticket_audit_action ON tticket_audit_log(action); CREATE INDEX IF NOT EXISTS idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC); -- ============================================================================ -- TRIGGERS for audit logging -- ============================================================================ CREATE OR REPLACE FUNCTION tticket_log_ticket_changes() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'UPDATE' THEN -- Log status changes IF OLD.status != NEW.status THEN INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value) VALUES (NEW.id, 'status_change', 'status', OLD.status, NEW.status); END IF; -- Log priority changes IF OLD.priority != NEW.priority THEN INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value) VALUES (NEW.id, 'priority_change', 'priority', OLD.priority, NEW.priority); END IF; -- Log assignment changes IF OLD.assigned_to_user_id IS DISTINCT FROM NEW.assigned_to_user_id THEN INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value) VALUES (NEW.id, 'assignment_change', 'assigned_to_user_id', OLD.assigned_to_user_id::TEXT, NEW.assigned_to_user_id::TEXT); END IF; -- Log deadline changes IF OLD.deadline IS DISTINCT FROM NEW.deadline THEN INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value) VALUES (NEW.id, 'deadline_change', 'deadline', OLD.deadline::TEXT, NEW.deadline::TEXT); END IF; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tticket_audit_changes AFTER UPDATE ON tticket_tickets FOR EACH ROW EXECUTE FUNCTION tticket_log_ticket_changes(); -- ============================================================================ -- VIEWS for enhanced queries -- ============================================================================ -- View for tickets with hierarchy info CREATE OR REPLACE VIEW tticket_tickets_with_hierarchy AS SELECT t.*, parent.ticket_number as parent_ticket_number, parent.subject as parent_subject, (SELECT COUNT(*) FROM tticket_tickets WHERE parent_ticket_id = t.id) as child_count, (SELECT COUNT(*) FROM tticket_relations WHERE ticket_id = t.id) as relation_count FROM tticket_tickets t LEFT JOIN tticket_tickets parent ON t.parent_ticket_id = parent.id; -- View for tickets with pending AI suggestions CREATE OR REPLACE VIEW tticket_tickets_with_suggestions AS SELECT t.id, t.ticket_number, t.subject, t.status, COUNT(DISTINCT s.id) FILTER (WHERE s.status = 'pending') as pending_suggestions, COUNT(DISTINCT ce.id) FILTER (WHERE ce.suggested_by_ai = true AND ce.status = 'pending') as pending_calendar_suggestions FROM tticket_tickets t LEFT JOIN tticket_ai_suggestions s ON t.id = s.ticket_id LEFT JOIN tticket_calendar_events ce ON t.id = ce.ticket_id GROUP BY t.id, t.ticket_number, t.subject, t.status; -- View for overdue tickets CREATE OR REPLACE VIEW tticket_overdue_tickets AS SELECT t.id, t.ticket_number, t.subject, t.status, t.priority, t.deadline, t.assigned_to_user_id, (CURRENT_TIMESTAMP - t.deadline) as overdue_duration, c.name as customer_name FROM tticket_tickets t LEFT JOIN customers c ON t.customer_id = c.id WHERE t.deadline < CURRENT_TIMESTAMP AND t.status NOT IN ('resolved', 'closed') ORDER BY t.deadline ASC; -- ============================================================================ -- Seed data: Default templates -- ============================================================================ INSERT INTO tticket_templates (name, description, category, subject_template, body_template, available_placeholders, is_active) VALUES ( 'Tak for henvendelse', 'Standard svar ved modtagelse af ticket', 'standard_letter', 'Re: {{ticket_subject}}', 'Hej {{contact_name}}, Tak for din henvendelse. Vi har modtaget din sag og den er nu registreret som sag nr. {{ticket_number}}. Vi vender tilbage hurtigst muligt med svar. Med venlig hilsen, BMC Networks', ARRAY['{{contact_name}}', '{{customer_name}}', '{{ticket_number}}', '{{ticket_subject}}'], true ), ( 'Løsning: Genstart router', 'Guide til genstart af router', 'guide', 'Re: {{ticket_subject}} - Løsning: Genstart router', 'Hej {{contact_name}}, Her er en guide til at genstarte din router: 1. Træk strømkablet ud af routeren 2. Vent 30 sekunder 3. Sæt strømkablet i igen 4. Vent 2-3 minutter mens routeren starter op 5. Test forbindelsen Hvis problemet fortsætter, er du velkommen til at svare på denne mail. Med venlig hilsen, BMC Networks Sag: {{ticket_number}}', ARRAY['{{contact_name}}', '{{customer_name}}', '{{ticket_number}}', '{{ticket_subject}}'], true ), ( 'Afslutning af sag', 'Besked ved lukning af sag', 'standard_letter', 'Re: {{ticket_subject}} - Sag lukket', 'Hej {{contact_name}}, Vi betragter nu denne sag som løst og lukker den. Hvis du har yderligere spørgsmål, er du velkommen til at kontakte os. Med venlig hilsen, BMC Networks Sag: {{ticket_number}}', ARRAY['{{contact_name}}', '{{customer_name}}', '{{ticket_number}}', '{{ticket_subject}}'], true ); -- ============================================================================ -- Comments -- ============================================================================ COMMENT ON TABLE tticket_relations IS 'Ticket relationer: merge, split, parent/child hierarki'; COMMENT ON TABLE tticket_calendar_events IS 'Kalender events, deadlines og milepæle på tickets'; COMMENT ON TABLE tticket_templates IS 'Svarskabeloner med placeholders'; COMMENT ON TABLE tticket_ai_suggestions IS 'AI forslag der kræver manuel godkendelse'; COMMENT ON TABLE tticket_email_metadata IS 'Email metadata og contact identification data'; COMMENT ON TABLE tticket_audit_log IS 'Audit trail for alle ticket ændringer'; -- ============================================================================ -- Migration complete -- ============================================================================ -- Dette modul tilføjer: -- ✅ Ticket relations (merge, split, hierarchy) -- ✅ Calendar events med AI forslag -- ✅ Templates system -- ✅ AI suggestions (kun forslag) -- ✅ Enhanced email/contact matching -- ✅ Full audit trail -- ============================================================================