bmc_hub/migrations/025_ticket_module.sql
Christian 3806c7d011 feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs.
- Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups.
- Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
2025-12-15 23:40:23 +01:00

485 lines
19 KiB
PL/PgSQL

-- ============================================================================
-- 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 $$;
--
-- ============================================================================