-- ============================================================================ -- Migration 025: Ticket System & Klippekort Modul (Isoleret) -- ============================================================================ -- Dette modul er 100% isoleret og kan slettes uden at påvirke eksisterende data. -- Alle tabeller har prefix 'tticket_' for at markere tilhørsforhold til modulet. -- Ved uninstall køres DROP-scriptet i bunden af denne fil. -- ============================================================================ -- Metadata tabel til at tracke modulets tilstand CREATE TABLE IF NOT EXISTS tticket_metadata ( id SERIAL PRIMARY KEY, module_version VARCHAR(20) NOT NULL DEFAULT '1.0.0', installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, installed_by INTEGER, -- Reference til users.user_id (read-only, ingen FK) last_sync_at TIMESTAMP, is_active BOOLEAN DEFAULT true, settings JSONB DEFAULT '{}'::jsonb ); -- Indsæt initial metadata INSERT INTO tticket_metadata (module_version, is_active) VALUES ('1.0.0', true) ON CONFLICT DO NOTHING; -- ============================================================================ -- TICKETS (hovedtabel) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_tickets ( id SERIAL PRIMARY KEY, ticket_number VARCHAR(50) UNIQUE NOT NULL, -- Format: TKT-YYYYMMDD-XXX -- Core felter subject VARCHAR(500) NOT NULL, description TEXT, status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'waiting_customer', 'waiting_internal', 'resolved', 'closed')), priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')), category VARCHAR(100), -- support, bug, feature_request, question, etc. -- Relationer (read-only references - INGEN FK til core tables) customer_id INTEGER, -- Reference til customers.id contact_id INTEGER, -- Reference til contacts.id assigned_to_user_id INTEGER, -- Reference til users.user_id created_by_user_id INTEGER, -- Reference til users.user_id -- Kilde source VARCHAR(50) DEFAULT 'manual' CHECK (source IN ('email', 'portal', 'phone', 'manual', 'api')), -- Metadata tags TEXT[], custom_fields JSONB, -- Timestamps created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, first_response_at TIMESTAMP, -- For SLA tracking (future) resolved_at TIMESTAMP, closed_at TIMESTAMP ); CREATE INDEX idx_tticket_tickets_number ON tticket_tickets(ticket_number); CREATE INDEX idx_tticket_tickets_customer ON tticket_tickets(customer_id); CREATE INDEX idx_tticket_tickets_assigned ON tticket_tickets(assigned_to_user_id); CREATE INDEX idx_tticket_tickets_status ON tticket_tickets(status); CREATE INDEX idx_tticket_tickets_priority ON tticket_tickets(priority); CREATE INDEX idx_tticket_tickets_created ON tticket_tickets(created_at DESC); -- ============================================================================ -- COMMENTS (beskeder på tickets) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_comments ( id SERIAL PRIMARY KEY, ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE, user_id INTEGER, -- Reference til users.user_id (read-only, ingen FK) comment_text TEXT NOT NULL, is_internal BOOLEAN DEFAULT false, -- Intern note vs. kunde-synlig created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP ); CREATE INDEX idx_tticket_comments_ticket ON tticket_comments(ticket_id); CREATE INDEX idx_tticket_comments_created ON tticket_comments(created_at); -- ============================================================================ -- ATTACHMENTS (filer på tickets) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_attachments ( id SERIAL PRIMARY KEY, ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE, file_name VARCHAR(255) NOT NULL, file_path VARCHAR(500) NOT NULL, file_size INTEGER, mime_type VARCHAR(100), uploaded_by_user_id INTEGER, -- Reference til users.user_id (read-only) created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_tticket_attachments_ticket ON tticket_attachments(ticket_id); -- ============================================================================ -- WORKLOG (tidsregistrering på tickets) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_worklog ( id SERIAL PRIMARY KEY, ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE, -- Arbejdsdata work_date DATE NOT NULL, hours DECIMAL(5,2) NOT NULL CHECK (hours > 0), work_type VARCHAR(50) DEFAULT 'support' CHECK (work_type IN ('support', 'development', 'troubleshooting', 'on_site', 'meeting', 'other')), description TEXT, -- Fakturering billing_method VARCHAR(20) DEFAULT 'invoice' CHECK (billing_method IN ('prepaid_card', 'invoice', 'internal', 'warranty')), status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'billable', 'billed', 'non_billable')), prepaid_card_id INTEGER, -- Reference til tticket_prepaid_cards (read-only) -- Metadata user_id INTEGER, -- Reference til users.user_id (read-only) created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, billed_at TIMESTAMP ); CREATE INDEX idx_tticket_worklog_ticket ON tticket_worklog(ticket_id); CREATE INDEX idx_tticket_worklog_status ON tticket_worklog(status); CREATE INDEX idx_tticket_worklog_date ON tticket_worklog(work_date); CREATE INDEX idx_tticket_worklog_card ON tticket_worklog(prepaid_card_id); -- ============================================================================ -- PREPAID CARDS (klippekort - kun 1 aktivt per virksomhed) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_prepaid_cards ( id SERIAL PRIMARY KEY, card_number VARCHAR(50) UNIQUE NOT NULL, -- Format: CARD-YYYYMMDD-XXX -- Tilknytning (kun 1 aktivt kort per customer) customer_id INTEGER NOT NULL, -- Reference til customers.id (read-only, ingen FK) -- Saldo purchased_hours DECIMAL(8,2) NOT NULL CHECK (purchased_hours > 0), used_hours DECIMAL(8,2) DEFAULT 0 CHECK (used_hours >= 0), remaining_hours DECIMAL(8,2) GENERATED ALWAYS AS (purchased_hours - used_hours) STORED, -- Priser price_per_hour DECIMAL(10,2) NOT NULL, total_amount DECIMAL(12,2) NOT NULL, -- Lifecycle status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'depleted', 'expired', 'cancelled')), purchased_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP, -- NULL = ingen udløb -- e-conomic integration economic_invoice_number VARCHAR(50), economic_product_number VARCHAR(50), -- Metadata notes TEXT, created_by_user_id INTEGER, -- Reference til users.user_id (read-only) created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP ); -- CRITICAL: Kun 1 aktivt kort per customer CREATE UNIQUE INDEX idx_tticket_prepaid_unique_active ON tticket_prepaid_cards(customer_id) WHERE status = 'active'; CREATE INDEX idx_tticket_prepaid_customer ON tticket_prepaid_cards(customer_id); CREATE INDEX idx_tticket_prepaid_status ON tticket_prepaid_cards(status); CREATE INDEX idx_tticket_prepaid_expires ON tticket_prepaid_cards(expires_at); -- ============================================================================ -- PREPAID TRANSACTIONS (immutable log af forbrug) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_prepaid_transactions ( id SERIAL PRIMARY KEY, card_id INTEGER NOT NULL REFERENCES tticket_prepaid_cards(id) ON DELETE CASCADE, worklog_id INTEGER, -- Reference til tticket_worklog (read-only, NULL for purchases/top-ups) -- Transaction type transaction_type VARCHAR(20) NOT NULL CHECK (transaction_type IN ('purchase', 'top_up', 'usage', 'refund', 'expiration', 'cancellation')), -- Beløb (positiv = tilføj, negativ = træk) hours DECIMAL(8,2) NOT NULL, balance_after DECIMAL(8,2) NOT NULL, -- Beskrivelse description TEXT, -- Metadata (immutable) created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by_user_id INTEGER -- Reference til users.user_id (read-only) ); CREATE INDEX idx_tticket_transactions_card ON tticket_prepaid_transactions(card_id); CREATE INDEX idx_tticket_transactions_worklog ON tticket_prepaid_transactions(worklog_id); CREATE INDEX idx_tticket_transactions_created ON tticket_prepaid_transactions(created_at DESC); -- ============================================================================ -- EMAIL INTEGRATION LOG (email → ticket mapping) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_email_log ( id SERIAL PRIMARY KEY, ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE CASCADE, email_id INTEGER, -- Reference til email_messages.id (read-only, ingen FK) email_message_id VARCHAR(500), -- Email Message-ID header for threading action VARCHAR(50) NOT NULL, -- created|comment_added|updated|linked created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_tticket_email_log_ticket ON tticket_email_log(ticket_id); CREATE INDEX idx_tticket_email_log_email ON tticket_email_log(email_id); CREATE INDEX idx_tticket_email_log_message_id ON tticket_email_log(email_message_id); -- ============================================================================ -- AUDIT LOG (alle ændringer) -- ============================================================================ CREATE TABLE IF NOT EXISTS tticket_audit_log ( id SERIAL PRIMARY KEY, ticket_id INTEGER, -- NULL for system-level events entity_type VARCHAR(50) NOT NULL, -- ticket, comment, worklog, prepaid_card, etc. entity_id INTEGER, user_id INTEGER, -- Reference til users.user_id (read-only) action VARCHAR(50) NOT NULL, -- created, updated, deleted, status_changed, assigned, etc. old_value TEXT, new_value TEXT, details JSONB, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id); CREATE INDEX idx_tticket_audit_entity ON tticket_audit_log(entity_type, entity_id); CREATE INDEX idx_tticket_audit_created ON tticket_audit_log(created_at DESC); -- ============================================================================ -- FUNCTIONS -- ============================================================================ -- Auto-update timestamp function CREATE OR REPLACE FUNCTION tticket_update_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; -- Generate ticket number function CREATE OR REPLACE FUNCTION tticket_generate_ticket_number() RETURNS VARCHAR AS $$ DECLARE today_str VARCHAR(8); last_number INTEGER; next_number INTEGER; new_ticket_number VARCHAR(50); BEGIN -- Format: TKT-YYYYMMDD-XXX today_str := TO_CHAR(CURRENT_DATE, 'YYYYMMDD'); -- Find last ticket number for today SELECT COALESCE( MAX(CAST(SPLIT_PART(ticket_number, '-', 3) AS INTEGER)), 0 ) INTO last_number FROM tticket_tickets WHERE ticket_number LIKE 'TKT-' || today_str || '-%'; next_number := last_number + 1; new_ticket_number := 'TKT-' || today_str || '-' || LPAD(next_number::TEXT, 3, '0'); RETURN new_ticket_number; END; $$ LANGUAGE plpgsql; -- Generate prepaid card number function CREATE OR REPLACE FUNCTION tticket_generate_card_number() RETURNS VARCHAR AS $$ DECLARE today_str VARCHAR(8); last_number INTEGER; next_number INTEGER; new_card_number VARCHAR(50); BEGIN -- Format: CARD-YYYYMMDD-XXX today_str := TO_CHAR(CURRENT_DATE, 'YYYYMMDD'); -- Find last card number for today SELECT COALESCE( MAX(CAST(SPLIT_PART(card_number, '-', 3) AS INTEGER)), 0 ) INTO last_number FROM tticket_prepaid_cards WHERE card_number LIKE 'CARD-' || today_str || '-%'; next_number := last_number + 1; new_card_number := 'CARD-' || today_str || '-' || LPAD(next_number::TEXT, 3, '0'); RETURN new_card_number; END; $$ LANGUAGE plpgsql; -- ============================================================================ -- TRIGGERS -- ============================================================================ -- Auto-update timestamps CREATE TRIGGER tticket_tickets_update BEFORE UPDATE ON tticket_tickets FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp(); CREATE TRIGGER tticket_comments_update BEFORE UPDATE ON tticket_comments FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp(); CREATE TRIGGER tticket_worklog_update BEFORE UPDATE ON tticket_worklog FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp(); CREATE TRIGGER tticket_prepaid_cards_update BEFORE UPDATE ON tticket_prepaid_cards FOR EACH ROW EXECUTE FUNCTION tticket_update_timestamp(); -- Auto-generate ticket number if not provided CREATE OR REPLACE FUNCTION tticket_auto_generate_ticket_number() RETURNS TRIGGER AS $$ BEGIN IF NEW.ticket_number IS NULL OR NEW.ticket_number = '' THEN NEW.ticket_number := tticket_generate_ticket_number(); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tticket_tickets_generate_number BEFORE INSERT ON tticket_tickets FOR EACH ROW EXECUTE FUNCTION tticket_auto_generate_ticket_number(); -- Auto-generate card number if not provided CREATE OR REPLACE FUNCTION tticket_auto_generate_card_number() RETURNS TRIGGER AS $$ BEGIN IF NEW.card_number IS NULL OR NEW.card_number = '' THEN NEW.card_number := tticket_generate_card_number(); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tticket_prepaid_cards_generate_number BEFORE INSERT ON tticket_prepaid_cards FOR EACH ROW EXECUTE FUNCTION tticket_auto_generate_card_number(); -- ============================================================================ -- VIEWS (common queries) -- ============================================================================ -- View: Open tickets with statistics CREATE OR REPLACE VIEW tticket_open_tickets AS SELECT t.*, COUNT(DISTINCT c.id) AS comment_count, COUNT(DISTINCT a.id) AS attachment_count, SUM(w.hours) FILTER (WHERE w.status IN ('draft', 'billable')) AS pending_hours, SUM(w.hours) FILTER (WHERE w.status = 'billed') AS billed_hours, MAX(c.created_at) AS last_comment_at, EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - t.created_at))/3600 AS age_hours FROM tticket_tickets t LEFT JOIN tticket_comments c ON t.id = c.ticket_id LEFT JOIN tticket_attachments a ON t.id = a.ticket_id LEFT JOIN tticket_worklog w ON t.id = w.ticket_id WHERE t.status IN ('open', 'in_progress', 'waiting_customer', 'waiting_internal') GROUP BY t.id; -- View: Worklog entries ready for billing CREATE OR REPLACE VIEW tticket_billable_worklog AS SELECT w.*, t.ticket_number, t.subject AS ticket_subject, t.customer_id, t.status AS ticket_status, CASE WHEN w.billing_method = 'prepaid_card' THEN pc.remaining_hours >= w.hours ELSE true END AS has_sufficient_balance FROM tticket_worklog w JOIN tticket_tickets t ON w.ticket_id = t.id LEFT JOIN tticket_prepaid_cards pc ON w.prepaid_card_id = pc.id WHERE w.status = 'billable'; -- View: Prepaid card balances CREATE OR REPLACE VIEW tticket_prepaid_balances AS SELECT pc.*, COUNT(w.id) AS usage_count, SUM(w.hours) AS total_hours_used, COUNT(w.id) FILTER (WHERE w.status = 'billed') AS billed_usage_count FROM tticket_prepaid_cards pc LEFT JOIN tticket_worklog w ON pc.id = w.prepaid_card_id GROUP BY pc.id; -- View: Ticket statistics by status CREATE OR REPLACE VIEW tticket_stats_by_status AS SELECT status, priority, COUNT(*) AS ticket_count, AVG(EXTRACT(EPOCH FROM (COALESCE(resolved_at, CURRENT_TIMESTAMP) - created_at))/3600) AS avg_age_hours FROM tticket_tickets GROUP BY status, priority; -- ============================================================================ -- INITIAL DATA -- ============================================================================ -- Log installation in audit log INSERT INTO tticket_audit_log (entity_type, action, details) VALUES ( 'system', 'module_installed', jsonb_build_object( 'version', '1.0.0', 'timestamp', CURRENT_TIMESTAMP, 'tables_created', ARRAY[ 'tticket_metadata', 'tticket_tickets', 'tticket_comments', 'tticket_attachments', 'tticket_worklog', 'tticket_prepaid_cards', 'tticket_prepaid_transactions', 'tticket_email_log', 'tticket_audit_log' ] ) ); -- ============================================================================ -- UNINSTALL SCRIPT (bruges ved modul-sletning) -- ============================================================================ -- ADVARSEL: Dette script sletter ALLE data i modulet! -- Kør kun hvis modulet skal fjernes fuldstændigt. -- -- For at uninstalle, kør følgende kommandoer i rækkefølge: -- -- -- Drop views -- DROP VIEW IF EXISTS tticket_stats_by_status CASCADE; -- DROP VIEW IF EXISTS tticket_prepaid_balances CASCADE; -- DROP VIEW IF EXISTS tticket_billable_worklog CASCADE; -- DROP VIEW IF EXISTS tticket_open_tickets CASCADE; -- -- -- Drop triggers -- DROP TRIGGER IF EXISTS tticket_prepaid_cards_generate_number ON tticket_prepaid_cards; -- DROP TRIGGER IF EXISTS tticket_tickets_generate_number ON tticket_tickets; -- DROP TRIGGER IF EXISTS tticket_prepaid_cards_update ON tticket_prepaid_cards; -- DROP TRIGGER IF EXISTS tticket_worklog_update ON tticket_worklog; -- DROP TRIGGER IF EXISTS tticket_comments_update ON tticket_comments; -- DROP TRIGGER IF EXISTS tticket_tickets_update ON tticket_tickets; -- -- -- Drop functions -- DROP FUNCTION IF EXISTS tticket_generate_card_number() CASCADE; -- DROP FUNCTION IF EXISTS tticket_generate_ticket_number() CASCADE; -- DROP FUNCTION IF EXISTS tticket_auto_generate_card_number() CASCADE; -- DROP FUNCTION IF EXISTS tticket_auto_generate_ticket_number() CASCADE; -- DROP FUNCTION IF EXISTS tticket_update_timestamp() CASCADE; -- -- -- Drop tables (reverse dependency order) -- DROP TABLE IF EXISTS tticket_audit_log CASCADE; -- DROP TABLE IF EXISTS tticket_email_log CASCADE; -- DROP TABLE IF EXISTS tticket_prepaid_transactions CASCADE; -- DROP TABLE IF EXISTS tticket_prepaid_cards CASCADE; -- DROP TABLE IF EXISTS tticket_worklog CASCADE; -- DROP TABLE IF EXISTS tticket_attachments CASCADE; -- DROP TABLE IF EXISTS tticket_comments CASCADE; -- DROP TABLE IF EXISTS tticket_tickets CASCADE; -- DROP TABLE IF EXISTS tticket_metadata CASCADE; -- -- -- Log uninstall -- -- (Dette vil fejle hvis tticket_audit_log er droppet, men det er OK) -- DO $$ -- BEGIN -- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tticket_audit_log') THEN -- INSERT INTO tticket_audit_log (entity_type, action, details) -- VALUES ('system', 'module_uninstalled', jsonb_build_object('timestamp', CURRENT_TIMESTAMP)); -- END IF; -- EXCEPTION WHEN OTHERS THEN -- NULL; -- Ignorer fejl -- END $$; -- -- ============================================================================