import { query } from "./db.js"; import { encryptSecret, isEncryptedSecret } from "./secrets.js"; const zammadApiKeySettingKey = "zammad.api_key"; async function findMigrationAdmin() { const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;"); return adminResult.rows[0]?.id ?? null; } async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string | null) { await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`); if (adminId) { await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]); } const duplicateMatchers = [...primaryKeyColumns, "user_id"] .map((key) => `older.${key} IS NOT DISTINCT FROM newer.${key}`) .join(" AND "); await query(` DELETE FROM ${table} older USING ${table} newer WHERE ${duplicateMatchers} AND ( older.closed_at < newer.closed_at OR (older.closed_at = newer.closed_at AND older.ctid < newer.ctid) ); `); await query(` DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM ${table} WHERE user_id IS NULL) THEN ALTER TABLE ${table} ALTER COLUMN user_id SET NOT NULL; ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_pkey; ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id); END IF; END $$; `); await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`); } async function encryptStoredApiSecrets() { const userTokenResult = await query<{ id: string; zammad_api_token: string | null }>(` SELECT id::text AS id, zammad_api_token FROM users WHERE zammad_api_token IS NOT NULL; `); for (const row of userTokenResult.rows) { if (row.zammad_api_token && !isEncryptedSecret(row.zammad_api_token)) { await query("UPDATE users SET zammad_api_token = $1, updated_at = now() WHERE id = $2;", [encryptSecret(row.zammad_api_token), row.id]); } } const zammadApiKeyResult = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [zammadApiKeySettingKey]); const zammadApiKey = zammadApiKeyResult.rows[0]?.value ?? null; if (zammadApiKey && !isEncryptedSecret(zammadApiKey)) { await query("UPDATE app_settings SET value = $1, updated_at = now() WHERE key = $2;", [encryptSecret(zammadApiKey), zammadApiKeySettingKey]); } } export async function migrate() { await query(` CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT users_role_valid CHECK (role IN ('admin', 'user')), CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s') ); `); await query(` ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_format, ADD CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s'); `); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_api_token TEXT;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_user_id BIGINT;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_email_session_webhook_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS session_billing_prompt_enabled BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_default_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id DROP NOT NULL;"); await query(` ALTER TABLE users DROP CONSTRAINT IF EXISTS users_zammad_time_unit_rounding_minutes_valid, ADD CONSTRAINT users_zammad_time_unit_rounding_minutes_valid CHECK (zammad_time_unit_rounding_minutes IN (5, 10, 15, 30)); `); await query(` ALTER TABLE users DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid, ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IS NULL OR zammad_article_type_id IN (1, 2, 3, 4, 5, 6)); `); await query(` CREATE TABLE IF NOT EXISTS auth_sessions ( token_hash TEXT PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);"); await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);"); const adminId = await findMigrationAdmin(); if (adminId) { await query("UPDATE users SET role = 'user', updated_at = now() WHERE role = 'admin' AND id <> $1;", [adminId]); } await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users ((role)) WHERE role = 'admin';"); await query(` CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); await encryptStoredApiSecrets(); await query(` CREATE TABLE IF NOT EXISTS user_preferences ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, key TEXT NOT NULL, value JSONB NOT NULL DEFAULT '{}'::jsonb, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (user_id, key) ); `); await query(` CREATE TABLE IF NOT EXISTS organizations ( id BIGSERIAL PRIMARY KEY, zammad_id BIGINT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE, synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); await query("ALTER TABLE organizations DROP COLUMN IF EXISTS active;"); await query("ALTER TABLE organizations DROP COLUMN IF EXISTS shared;"); await query("ALTER TABLE organizations DROP COLUMN IF EXISTS vip;"); await query("ALTER TABLE organizations DROP COLUMN IF EXISTS domain;"); await query("ALTER TABLE organizations DROP COLUMN IF EXISTS note;"); await query("ALTER TABLE organizations DROP COLUMN IF EXISTS zammad_updated_at;"); await query("CREATE INDEX IF NOT EXISTS idx_organizations_name_lower ON organizations(lower(name));"); await query("DROP INDEX IF EXISTS idx_organizations_active_name;"); await query(` CREATE TABLE IF NOT EXISTS tickets ( id BIGSERIAL PRIMARY KEY, ticket_number TEXT NOT NULL UNIQUE, title TEXT, organization_id BIGINT REFERENCES organizations(zammad_id), customer_name TEXT, work_type TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'), CONSTRAINT ticket_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting')) ); `); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS customer_name TEXT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS title TEXT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS work_type TEXT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_ticket_id BIGINT UNIQUE;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_group_id BIGINT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_priority_id BIGINT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_id BIGINT;"); await query("UPDATE tickets SET zammad_state_id = 2 WHERE zammad_state_id = 1;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;"); await query(` ALTER TABLE tickets DROP CONSTRAINT IF EXISTS ticket_number_format, ADD CONSTRAINT ticket_number_format CHECK (ticket_number ~ '^(Ticket#[0-9]{6}|Fix#[0-9]+)$'); `); await query(` DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'ticket_work_type_valid' ) THEN ALTER TABLE tickets ADD CONSTRAINT ticket_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting')); END IF; END $$; `); await query(` CREATE TABLE IF NOT EXISTS sessions ( id BIGSERIAL PRIMARY KEY, ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, organization_id BIGINT REFERENCES organizations(zammad_id), customer_name TEXT NOT NULL, activity TEXT NOT NULL, work_type TEXT NOT NULL, user_id BIGINT REFERENCES users(id), started_at TIMESTAMPTZ NOT NULL, ended_at TIMESTAMPTZ NOT NULL, duration_seconds INTEGER NOT NULL, rounded_minutes INTEGER NOT NULL, billing_status TEXT, billing_updated_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT work_type_valid CHECK (work_type IN ('support', 'consulting')), CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')), CONSTRAINT duration_non_negative CHECK (duration_seconds >= 0), CONSTRAINT rounded_minutes_positive CHECK (rounded_minutes >= 1), CONSTRAINT ended_after_started CHECK (ended_at >= started_at) ); `); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_id BIGINT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;"); await query(` ALTER TABLE sessions DROP CONSTRAINT IF EXISTS billing_status_valid, ADD CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')); `); await query(` CREATE TABLE IF NOT EXISTS zammad_email_session_events ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, zammad_article_id BIGINT, delivery_id TEXT, session_id BIGINT REFERENCES sessions(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT zammad_email_session_event_source CHECK (zammad_article_id IS NOT NULL OR delivery_id IS NOT NULL) ); `); await query(` CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_email_session_events_user_article ON zammad_email_session_events(user_id, zammad_article_id) WHERE zammad_article_id IS NOT NULL; `); await query(` CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_email_session_events_delivery ON zammad_email_session_events(delivery_id) WHERE delivery_id IS NOT NULL; `); await query(` CREATE TABLE IF NOT EXISTS zammad_session_article_queue ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, session_id BIGINT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, ticket_number TEXT NOT NULL, zammad_ticket_id BIGINT, activity TEXT NOT NULL, time_unit NUMERIC(10, 2) NOT NULL, zammad_article_type_id INTEGER DEFAULT 6, status TEXT NOT NULL DEFAULT 'pending', attempt_count INTEGER NOT NULL DEFAULT 0, last_error TEXT, last_attempt_at TIMESTAMPTZ, sent_at TIMESTAMPTZ, zammad_article_id BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT zammad_session_article_queue_status_valid CHECK (status IN ('pending', 'sent')) ); `); await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;"); await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id DROP NOT NULL;"); await query(` ALTER TABLE zammad_session_article_queue DROP CONSTRAINT IF EXISTS zammad_session_article_queue_type_id_valid, ADD CONSTRAINT zammad_session_article_queue_type_id_valid CHECK (zammad_article_type_id IS NULL OR zammad_article_type_id IN (1, 2, 3, 4, 5, 6)); `); await query(` CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session ON zammad_session_article_queue(session_id) WHERE status = 'pending'; `); await query(` CREATE INDEX IF NOT EXISTS idx_zammad_session_article_queue_user_pending ON zammad_session_article_queue(user_id, created_at) WHERE status = 'pending'; `); if (adminId) { await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]); } await query(` DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM sessions WHERE user_id IS NULL) THEN ALTER TABLE sessions ALTER COLUMN user_id SET NOT NULL; END IF; END $$; `); await query(` CREATE TABLE IF NOT EXISTS recurring_billings ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, ticket_number TEXT, organization_id BIGINT NOT NULL REFERENCES organizations(zammad_id), activity TEXT NOT NULL, work_type TEXT NOT NULL, recurrence_type TEXT NOT NULL, interval_value INTEGER NOT NULL DEFAULT 1, valid_from DATE NOT NULL, valid_until DATE, start_time TIME NOT NULL DEFAULT '09:00', active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT recurring_billings_work_type_valid CHECK (work_type IN ('support', 'consulting')), CONSTRAINT recurring_billings_type_valid CHECK (recurrence_type IN ('weekly', 'every_n_weeks')), CONSTRAINT recurring_billings_interval_positive CHECK (interval_value >= 1), CONSTRAINT recurring_billings_valid_range CHECK (valid_until IS NULL OR valid_until >= valid_from) ); `); await query(` ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_organization_id_fkey; ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_organization_id_fkey; ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_organization_id_fkey; `); await query(` UPDATE tickets t SET organization_id = o.zammad_id FROM organizations o WHERE t.organization_id = o.id AND NOT EXISTS ( SELECT 1 FROM organizations current_org WHERE current_org.zammad_id = t.organization_id ); `); await query(` UPDATE sessions s SET organization_id = o.zammad_id FROM organizations o WHERE s.organization_id = o.id AND NOT EXISTS ( SELECT 1 FROM organizations current_org WHERE current_org.zammad_id = s.organization_id ); `); await query(` UPDATE recurring_billings rb SET organization_id = o.zammad_id FROM organizations o WHERE rb.organization_id = o.id AND NOT EXISTS ( SELECT 1 FROM organizations current_org WHERE current_org.zammad_id = rb.organization_id ); `); await query(` UPDATE tickets t SET organization_id = old_org.zammad_id FROM organizations old_org WHERE t.organization_id = old_org.id AND t.organization_id <> old_org.zammad_id AND COALESCE(t.customer_name, '') = old_org.name; `); await query(` UPDATE sessions s SET organization_id = old_org.zammad_id FROM organizations old_org WHERE s.organization_id = old_org.id AND s.organization_id <> old_org.zammad_id AND COALESCE(s.customer_name, '') = old_org.name; `); await query(` UPDATE recurring_billings rb SET organization_id = old_org.zammad_id FROM organizations old_org WHERE rb.organization_id = old_org.id AND rb.organization_id <> old_org.zammad_id AND ( EXISTS ( SELECT 1 FROM sessions s WHERE s.recurring_billing_id = rb.id AND COALESCE(s.customer_name, '') = old_org.name ) OR EXISTS ( SELECT 1 FROM tickets t WHERE t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id) AND COALESCE(t.customer_name, '') = old_org.name ) ); `); await query(` UPDATE tickets SET organization_id = NULL WHERE organization_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM organizations o WHERE o.zammad_id = tickets.organization_id ); `); await query(` UPDATE sessions SET organization_id = NULL WHERE organization_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM organizations o WHERE o.zammad_id = sessions.organization_id ); `); await query(` ALTER TABLE tickets ADD CONSTRAINT tickets_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id); ALTER TABLE sessions ADD CONSTRAINT sessions_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id); ALTER TABLE recurring_billings ADD CONSTRAINT recurring_billings_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id); `); await query("ALTER TABLE recurring_billings ALTER COLUMN ticket_number DROP NOT NULL;"); await query("ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_ticket_number_format;"); await query("UPDATE recurring_billings SET recurrence_type = 'every_n_weeks' WHERE recurrence_type = 'every_n_days';"); await query(` ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_type_valid, ADD CONSTRAINT recurring_billings_type_valid CHECK (recurrence_type IN ('weekly', 'every_n_weeks')); `); await query(` CREATE TABLE IF NOT EXISTS recurring_billing_slots ( id BIGSERIAL PRIMARY KEY, recurring_billing_id BIGINT NOT NULL REFERENCES recurring_billings(id) ON DELETE CASCADE, weekday INTEGER, start_time TIME, duration_minutes INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT recurring_billing_slots_weekday_valid CHECK (weekday IS NULL OR weekday BETWEEN 0 AND 6), CONSTRAINT recurring_billing_slots_duration_positive CHECK (duration_minutes >= 1) ); `); await query(` CREATE TABLE IF NOT EXISTS recurring_billing_exceptions ( id BIGSERIAL PRIMARY KEY, recurring_billing_id BIGINT NOT NULL REFERENCES recurring_billings(id) ON DELETE CASCADE, recurring_billing_slot_id BIGINT REFERENCES recurring_billing_slots(id) ON DELETE CASCADE, occurrence_date DATE NOT NULL, action TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT recurring_billing_exceptions_action_valid CHECK (action IN ('cancelled')), UNIQUE (recurring_billing_id, recurring_billing_slot_id, occurrence_date) ); `); await query(` DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'sessions_recurring_billing_fkey' ) THEN ALTER TABLE sessions ADD CONSTRAINT sessions_recurring_billing_fkey FOREIGN KEY (recurring_billing_id) REFERENCES recurring_billings(id) ON DELETE SET NULL; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'sessions_recurring_billing_slot_fkey' ) THEN ALTER TABLE sessions ADD CONSTRAINT sessions_recurring_billing_slot_fkey FOREIGN KEY (recurring_billing_slot_id) REFERENCES recurring_billing_slots(id) ON DELETE SET NULL; END IF; END $$; `); await query(` CREATE TABLE IF NOT EXISTS ticket_month_closures ( ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, user_id BIGINT REFERENCES users(id), month DATE NOT NULL, closed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); await query(` CREATE TABLE IF NOT EXISTS month_closures ( user_id BIGINT REFERENCES users(id), month DATE NOT NULL, closed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); await query(` CREATE TABLE IF NOT EXISTS ticket_day_closures ( ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, user_id BIGINT REFERENCES users(id), day DATE NOT NULL, closed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); await query(` CREATE TABLE IF NOT EXISTS day_closures ( user_id BIGINT REFERENCES users(id), day DATE NOT NULL, closed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); await query(` CREATE TABLE IF NOT EXISTS ticket_day_billings ( ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, day DATE NOT NULL, billed_minutes INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (ticket_id, user_id, day), CONSTRAINT ticket_day_billings_minutes_non_negative CHECK (billed_minutes >= 0) ); `); await query(` CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements ( ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, day DATE NOT NULL, acknowledged_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (ticket_id, user_id, day) ); `); await query(` CREATE TABLE IF NOT EXISTS projects ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, teamspace_project TEXT, description TEXT NOT NULL DEFAULT '', goals TEXT NOT NULL DEFAULT '', starts_on DATE, ends_on DATE, hour_budget_minutes INTEGER, manual_consumption_minutes INTEGER NOT NULL DEFAULT 0, closed_at TIMESTAMPTZ, close_review_required BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (user_id, ticket_id), CONSTRAINT projects_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0), CONSTRAINT projects_manual_consumption_non_negative CHECK (manual_consumption_minutes >= 0), CONSTRAINT projects_valid_range CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on) ); `); await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;"); await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS close_review_required BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;"); await query(` DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'projects_manual_consumption_non_negative' ) THEN ALTER TABLE projects ADD CONSTRAINT projects_manual_consumption_non_negative CHECK (manual_consumption_minutes >= 0); END IF; END $$; `); await query(` CREATE TABLE IF NOT EXISTS project_teamspace_entries ( id BIGSERIAL PRIMARY KEY, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, teamspace_project TEXT, is_default BOOLEAN NOT NULL DEFAULT false, hour_budget_minutes INTEGER, budget_sync_disabled BOOLEAN NOT NULL DEFAULT false, manual_consumption_minutes INTEGER NOT NULL DEFAULT 0, display_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT project_teamspace_entries_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0), CONSTRAINT project_teamspace_entries_manual_non_negative CHECK (manual_consumption_minutes >= 0) ); `); await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;"); await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);"); await query(` CREATE TABLE IF NOT EXISTS hidden_user_projects ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, hidden_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (user_id, project_id) ); `); await query("CREATE INDEX IF NOT EXISTS idx_hidden_user_projects_project ON hidden_user_projects(project_id, user_id);"); await query(` DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'sessions_project_teamspace_entry_fkey' ) THEN ALTER TABLE sessions ADD CONSTRAINT sessions_project_teamspace_entry_fkey FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL; END IF; END $$; `); await query(` UPDATE tickets t SET work_type = 'consulting' WHERE EXISTS ( SELECT 1 FROM sessions s WHERE s.ticket_id = t.id AND s.work_type = 'consulting' ); `); await query(` INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals) SELECT DISTINCT s.user_id, s.ticket_id, COALESCE(t.title, ''), '', '' FROM sessions s JOIN tickets t ON t.id = s.ticket_id WHERE s.work_type = 'consulting' AND s.user_id IS NOT NULL ON CONFLICT (user_id, ticket_id) DO NOTHING; `); await query(` INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order) SELECT p.id, p.teamspace_project, true, p.hour_budget_minutes, p.manual_consumption_minutes, 0 FROM projects p WHERE NOT EXISTS ( SELECT 1 FROM project_teamspace_entries pte WHERE pte.project_id = p.id ); `); await query(` WITH single_entry_projects AS ( SELECT p.id AS project_id, p.user_id, p.ticket_id, MIN(pte.id) AS project_teamspace_entry_id FROM projects p JOIN project_teamspace_entries pte ON pte.project_id = p.id GROUP BY p.id, p.user_id, p.ticket_id HAVING COUNT(pte.id) = 1 ) UPDATE sessions s SET project_teamspace_entry_id = sep.project_teamspace_entry_id FROM single_entry_projects sep WHERE s.ticket_id = sep.ticket_id AND s.user_id = sep.user_id AND s.work_type = 'consulting' AND s.project_teamspace_entry_id IS NULL; `); await query(` WITH single_entry_tickets AS ( SELECT p.ticket_id, MIN(pte.id) AS project_teamspace_entry_id FROM projects p JOIN project_teamspace_entries pte ON pte.project_id = p.id GROUP BY p.ticket_id HAVING COUNT(pte.id) = 1 ) UPDATE sessions s SET project_teamspace_entry_id = single_ticket.project_teamspace_entry_id FROM single_entry_tickets single_ticket WHERE s.ticket_id = single_ticket.ticket_id AND s.work_type = 'consulting' AND s.project_teamspace_entry_id IS NULL; `); await query(` CREATE TABLE IF NOT EXISTS ticket_todos ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', priority TEXT NOT NULL DEFAULT 'medium', status TEXT NOT NULL DEFAULT 'open', sort_order INTEGER NOT NULL DEFAULT 0, starts_on DATE, ends_on DATE, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT ticket_todos_priority_valid CHECK (priority IN ('low', 'medium', 'high', 'urgent')), CONSTRAINT ticket_todos_status_valid CHECK (status IN ('open', 'done')), CONSTRAINT ticket_todos_valid_period CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on) ); `); await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS starts_on DATE;"); await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS ends_on DATE;"); await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS sort_order INTEGER;"); await query(` WITH ordered_todos AS ( SELECT id, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS row_number FROM ticket_todos WHERE sort_order IS NULL ) UPDATE ticket_todos tt SET sort_order = ordered_todos.row_number * 1024 FROM ordered_todos WHERE tt.id = ordered_todos.id; `); await query("ALTER TABLE ticket_todos ALTER COLUMN sort_order SET DEFAULT 0;"); await query("ALTER TABLE ticket_todos ALTER COLUMN sort_order SET NOT NULL;"); await query(` DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'ticket_todos_valid_period' ) THEN ALTER TABLE ticket_todos ADD CONSTRAINT ticket_todos_valid_period CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on); END IF; END $$; `); await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId); await ensureClosureOwnership("month_closures", "month", ["month"], adminId); await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId); await ensureClosureOwnership("day_closures", "day", ["day"], adminId); await query("CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);"); await query("CREATE INDEX IF NOT EXISTS idx_sessions_ticket_started ON sessions(ticket_id, started_at);"); await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_started ON sessions(user_id, started_at);"); await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_ticket_started ON sessions(user_id, ticket_id, started_at);"); await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_organization_started ON sessions(user_id, organization_id, started_at);"); await query("CREATE INDEX IF NOT EXISTS idx_sessions_project_teamspace_entry ON sessions(project_teamspace_entry_id);"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_recurring_occurrence_key ON sessions(recurring_occurrence_key) WHERE recurring_occurrence_key IS NOT NULL;"); await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);"); await query("CREATE INDEX IF NOT EXISTS idx_projects_user_ticket ON projects(user_id, ticket_id);"); await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);"); await query("CREATE INDEX IF NOT EXISTS idx_projects_user_closed_updated ON projects(user_id, closed_at, updated_at DESC);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_status_priority ON ticket_todos(user_id, status, priority);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_period ON ticket_todos(user_id, starts_on, ends_on);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_sort_order ON ticket_todos(user_id, sort_order, id);"); await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);"); await query("UPDATE recurring_billings SET active = true WHERE active = false;"); await query(` WITH deleted_orphan_sessions AS ( DELETE FROM sessions s WHERE s.recurring_occurrence_key IS NOT NULL AND split_part(s.recurring_occurrence_key, ':', 1) ~ '^[0-9]+$' AND NOT EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.id = split_part(s.recurring_occurrence_key, ':', 1)::bigint ) RETURNING ticket_id, user_id, (started_at AT TIME ZONE 'UTC')::date AS day, date_trunc('month', started_at AT TIME ZONE 'UTC')::date AS month ), deleted_ticket_month_closures AS ( DELETE FROM ticket_month_closures tmc USING deleted_orphan_sessions dos WHERE tmc.ticket_id = dos.ticket_id AND tmc.user_id = dos.user_id AND tmc.month = dos.month RETURNING tmc.ticket_id ), deleted_month_closures AS ( DELETE FROM month_closures mc USING deleted_orphan_sessions dos WHERE mc.user_id = dos.user_id AND mc.month = dos.month RETURNING mc.user_id ), deleted_ticket_day_closures AS ( DELETE FROM ticket_day_closures tdc USING deleted_orphan_sessions dos WHERE tdc.ticket_id = dos.ticket_id AND tdc.user_id = dos.user_id AND tdc.day = dos.day RETURNING tdc.ticket_id ), deleted_day_closures AS ( DELETE FROM day_closures dc USING deleted_orphan_sessions dos WHERE dc.user_id = dos.user_id AND dc.day = dos.day RETURNING dc.user_id ) DELETE FROM tickets t WHERE t.id IN (SELECT ticket_id FROM deleted_orphan_sessions) AND NOT EXISTS ( SELECT 1 FROM sessions s WHERE s.ticket_id = t.id ); `); await query(` DELETE FROM tickets t WHERE t.ticket_number ~ '^Fix#[0-9]+$' AND NOT EXISTS ( SELECT 1 FROM sessions s WHERE s.ticket_id = t.id ) AND NOT EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.id = substring(t.ticket_number FROM 5)::bigint ); `); await query(` UPDATE tickets t SET customer_name = latest.customer_name, work_type = latest.work_type FROM ( SELECT DISTINCT ON (ticket_id) ticket_id, customer_name, work_type FROM sessions ORDER BY ticket_id, started_at DESC ) latest WHERE t.id = latest.ticket_id AND (t.customer_name IS NULL OR t.work_type IS NULL); `); }