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 teamspaceCredentialResult = await query<{ id: string; teamspace_device_id: string | null; teamspace_device_password: string | null }>(` SELECT id::text AS id, teamspace_device_id, teamspace_device_password FROM users WHERE teamspace_device_id IS NOT NULL OR teamspace_device_password IS NOT NULL; `); for (const row of teamspaceCredentialResult.rows) { const updates: string[] = []; const values: unknown[] = []; if (row.teamspace_device_id && !isEncryptedSecret(row.teamspace_device_id)) { values.push(encryptSecret(row.teamspace_device_id)); updates.push(`teamspace_device_id = $${values.length}`); } if (row.teamspace_device_password && !isEncryptedSecret(row.teamspace_device_password)) { values.push(encryptSecret(row.teamspace_device_password)); updates.push(`teamspace_device_password = $${values.length}`); } if (updates.length > 0) { values.push(row.id); await query(`UPDATE users SET ${updates.join(", ")}, updated_at = now() WHERE id = $${values.length};`, values); } } 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 teamspace_device_id TEXT;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_device_password TEXT;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_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 teamspace_time_tracking_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_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_enabled BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_weeks INTEGER NOT NULL DEFAULT 4;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_enabled BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_weeks INTEGER NOT NULL DEFAULT 2;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_complete_on_session BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users DROP COLUMN IF EXISTS teamspace_subproject_assignment_enabled;"); await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET 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 IN (1, 2, 3, 4, 5, 6)); `); await query(` ALTER TABLE users DROP CONSTRAINT IF EXISTS users_project_followup_weeks_valid, ADD CONSTRAINT users_project_followup_weeks_valid CHECK (project_followup_weeks BETWEEN 1 AND 52); `); await query(` ALTER TABLE users DROP CONSTRAINT IF EXISTS users_project_followup_second_weeks_valid, ADD CONSTRAINT users_project_followup_second_weeks_valid CHECK (project_followup_second_weeks BETWEEN 1 AND 52); `); 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 user_id BIGINT REFERENCES users(id) ON DELETE SET NULL;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_id BIGINT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_number TEXT;"); 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(` WITH unique_zammad_users AS ( SELECT zammad_user_id, MIN(id) AS user_id FROM users WHERE role = 'user' AND active AND zammad_user_id IS NOT NULL GROUP BY zammad_user_id HAVING COUNT(*) = 1 ), zammad_ticket_owners AS ( SELECT t.id, uzu.user_id FROM tickets t LEFT JOIN unique_zammad_users uzu ON uzu.zammad_user_id = t.zammad_owner_id WHERE t.zammad_owner_id IS NOT NULL ) UPDATE tickets t SET user_id = zto.user_id FROM zammad_ticket_owners zto WHERE t.id = zto.id AND t.user_id IS DISTINCT FROM zto.user_id; `); await query(` UPDATE tickets t SET user_id = owner.user_id FROM ( SELECT ticket_id, MIN(user_id) AS user_id FROM projects GROUP BY ticket_id ) owner WHERE t.user_id IS NULL AND t.zammad_owner_id IS NULL AND t.id = owner.ticket_id; `); await query(` UPDATE tickets t SET user_id = owner.user_id FROM ( SELECT ticket_id, MIN(user_id) AS user_id FROM sessions WHERE user_id IS NOT NULL GROUP BY ticket_id ) owner WHERE t.user_id IS NULL AND t.zammad_owner_id IS NULL AND t.id = owner.ticket_id; `); await query("CREATE INDEX IF NOT EXISTS idx_tickets_user ON tickets(user_id);"); await query("CREATE INDEX IF NOT EXISTS idx_tickets_zammad_owner ON tickets(zammad_owner_id);"); 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 ADD COLUMN IF NOT EXISTS activity_html TEXT;"); await query(` CREATE TABLE IF NOT EXISTS active_timers ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, ticket_number TEXT NOT NULL, organization_name TEXT, work_type TEXT, is_project BOOLEAN NOT NULL DEFAULT false, project_name TEXT, ticket_lookup_done BOOLEAN NOT NULL DEFAULT false, activity TEXT NOT NULL DEFAULT '', activity_html TEXT, billing_status TEXT, started_at TIMESTAMPTZ NOT NULL, paused_total_ms BIGINT NOT NULL DEFAULT 0, paused_at TIMESTAMPTZ, phase TEXT NOT NULL DEFAULT 'running', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT active_timers_ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'), CONSTRAINT active_timers_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting')), CONSTRAINT active_timers_billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')), CONSTRAINT active_timers_phase_valid CHECK (phase IN ('running', 'paused')), CONSTRAINT active_timers_paused_total_non_negative CHECK (paused_total_ms >= 0), CONSTRAINT active_timers_paused_state_valid CHECK ( (phase = 'running' AND paused_at IS NULL) OR (phase = 'paused' AND paused_at IS NOT NULL) ) ); `); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS organization_name TEXT;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS work_type TEXT;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS is_project BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS project_name TEXT;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS ticket_lookup_done BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS activity TEXT NOT NULL DEFAULT '';"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS activity_html TEXT;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS billing_status TEXT;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_total_ms BIGINT NOT NULL DEFAULT 0;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ;"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS phase TEXT NOT NULL DEFAULT 'running';"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now();"); await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();"); await query(` ALTER TABLE active_timers DROP CONSTRAINT IF EXISTS active_timers_ticket_number_format, ADD CONSTRAINT active_timers_ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'); `); await query(` ALTER TABLE active_timers DROP CONSTRAINT IF EXISTS active_timers_work_type_valid, ADD CONSTRAINT active_timers_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting')); `); await query(` ALTER TABLE active_timers DROP CONSTRAINT IF EXISTS active_timers_billing_status_valid, ADD CONSTRAINT active_timers_billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')); `); await query(` ALTER TABLE active_timers DROP CONSTRAINT IF EXISTS active_timers_phase_valid, ADD CONSTRAINT active_timers_phase_valid CHECK (phase IN ('running', 'paused')); `); await query(` ALTER TABLE active_timers DROP CONSTRAINT IF EXISTS active_timers_paused_total_non_negative, ADD CONSTRAINT active_timers_paused_total_non_negative CHECK (paused_total_ms >= 0); `); await query(` ALTER TABLE active_timers DROP CONSTRAINT IF EXISTS active_timers_paused_state_valid, ADD CONSTRAINT active_timers_paused_state_valid CHECK ( (phase = 'running' AND paused_at IS NULL) OR (phase = 'paused' AND paused_at IS NOT NULL) ); `); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_active_timers_user_ticket ON active_timers(user_id, ticket_number);"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_active_timers_single_running ON active_timers(user_id) WHERE phase = 'running';"); await query("CREATE INDEX IF NOT EXISTS idx_active_timers_user_updated ON active_timers(user_id, updated_at);"); 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 INDEX IF NOT EXISTS idx_zammad_email_session_events_session ON zammad_email_session_events(session_id);"); 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, article_body TEXT, article_content_type TEXT NOT NULL DEFAULT 'text/plain', time_unit NUMERIC(10, 2) NOT NULL, zammad_article_type_id INTEGER NOT NULL 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 article_body TEXT;"); await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS article_content_type TEXT NOT NULL DEFAULT 'text/plain';"); await query("UPDATE zammad_session_article_queue SET article_body = activity WHERE article_body IS NULL;"); await query(` ALTER TABLE zammad_session_article_queue DROP CONSTRAINT IF EXISTS zammad_session_article_queue_content_type_valid, ADD CONSTRAINT zammad_session_article_queue_content_type_valid CHECK (article_content_type IN ('text/plain', 'text/html')); `); await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;"); await query("UPDATE zammad_session_article_queue SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;"); await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id SET DEFAULT 6;"); await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id SET 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 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, project_teamspace_entry_id BIGINT, day DATE NOT NULL, billed_minutes INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT ticket_day_billings_minutes_non_negative CHECK (billed_minutes >= 0) ); `); await query("ALTER TABLE ticket_day_billings ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;"); await query("ALTER TABLE ticket_day_billings DROP CONSTRAINT IF EXISTS ticket_day_billings_pkey;"); await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_default;"); await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_entry;"); await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_subentry;"); 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("ALTER TABLE projects ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;"); await query("ALTER TABLE projects ALTER COLUMN user_id DROP NOT NULL;"); await query(` UPDATE projects p SET zammad_owner_id = t.zammad_owner_id FROM tickets t WHERE p.ticket_id = t.id AND p.zammad_owner_id IS NULL AND t.zammad_owner_id IS NOT NULL; `); await query(` UPDATE projects p SET user_id = t.user_id, zammad_owner_id = t.zammad_owner_id FROM tickets t WHERE p.ticket_id = t.id AND t.zammad_owner_id IS NOT NULL AND ( p.user_id IS DISTINCT FROM t.user_id OR p.zammad_owner_id IS DISTINCT FROM t.zammad_owner_id ) AND NOT EXISTS ( SELECT 1 FROM projects existing WHERE existing.ticket_id = p.ticket_id AND existing.id <> p.id AND ( (t.user_id IS NOT NULL AND existing.user_id = t.user_id) OR ( t.user_id IS NULL AND existing.user_id IS NULL AND existing.zammad_owner_id IS NOT DISTINCT FROM t.zammad_owner_id ) ) ); `); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_zammad_owner_ticket ON projects(zammad_owner_id, ticket_id) WHERE user_id IS NULL AND zammad_owner_id IS NOT NULL;"); 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, teamspace_description TEXT NOT NULL DEFAULT '', 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 teamspace_description TEXT NOT NULL DEFAULT '';"); await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_api_project_id BIGINT;"); await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_api_project_url TEXT;"); 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 INDEX IF NOT EXISTS idx_project_teamspace_entries_ts_api_id ON project_teamspace_entries(teamspace_api_project_id) WHERE teamspace_api_project_id IS NOT NULL;"); await query(` CREATE TABLE IF NOT EXISTS project_teamspace_subentries ( id BIGSERIAL PRIMARY KEY, user_id BIGINT REFERENCES users(id) ON DELETE CASCADE, project_teamspace_entry_id BIGINT NOT NULL REFERENCES project_teamspace_entries(id) ON DELETE CASCADE, teamspace_api_project_id BIGINT NOT NULL, teamspace_api_project_url TEXT, teamspace_project_id TEXT, billing_type TEXT, name TEXT, alias TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', progress INTEGER, planned_minutes INTEGER, sum_minutes INTEGER, planned_minutes_billable INTEGER, sum_minutes_billable INTEGER, planned_income NUMERIC, sum_income NUMERIC, display_order INTEGER NOT NULL DEFAULT 0, 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 project_teamspace_subentries ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id) ON DELETE CASCADE;"); await query(` UPDATE project_teamspace_subentries pts SET user_id = p.user_id FROM project_teamspace_entries pte JOIN projects p ON p.id = pte.project_id WHERE pte.id = pts.project_teamspace_entry_id AND pts.user_id IS NULL AND p.user_id IS NOT NULL; `); await query(` DO $$ DECLARE constraint_name text; BEGIN SELECT c.conname INTO constraint_name FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace WHERE n.nspname = current_schema() AND t.relname = 'project_teamspace_subentries' AND c.contype = 'u' AND pg_get_constraintdef(c.oid) LIKE '%project_teamspace_entry_id%' AND pg_get_constraintdef(c.oid) LIKE '%teamspace_api_project_id%' AND pg_get_constraintdef(c.oid) NOT LIKE '%user_id%' LIMIT 1; IF constraint_name IS NOT NULL THEN EXECUTE format('ALTER TABLE project_teamspace_subentries DROP CONSTRAINT %I', constraint_name); END IF; END $$; `); await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS alias TEXT NOT NULL DEFAULT '';"); await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '';"); await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS billing_type TEXT;"); await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS planned_income NUMERIC;"); await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS sum_income NUMERIC;"); await query("ALTER TABLE project_teamspace_subentries DROP CONSTRAINT IF EXISTS project_teamspace_subentries_hour_budget_nonnegative;"); await query("ALTER TABLE project_teamspace_subentries DROP CONSTRAINT IF EXISTS project_teamspace_subentries_manual_consumption_nonnegative;"); await query("ALTER TABLE project_teamspace_subentries DROP CONSTRAINT IF EXISTS project_teamspace_subentries_manual_unit_quantity_nonnegative;"); await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS hour_budget_minutes;"); await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS manual_consumption_minutes;"); await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS manual_unit_quantity;"); await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS is_fulfilled;"); await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_entry_order ON project_teamspace_subentries(project_teamspace_entry_id, display_order, id);"); await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_order ON project_teamspace_subentries(user_id, project_teamspace_entry_id, display_order, id);"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_api ON project_teamspace_subentries(user_id, project_teamspace_entry_id, teamspace_api_project_id);"); await query(` DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey' AND confdeltype <> 'n' ) THEN ALTER TABLE ticket_day_billings DROP CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey' ) THEN ALTER TABLE ticket_day_billings ADD CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL; END IF; ALTER TABLE ticket_day_billings DROP CONSTRAINT IF EXISTS ticket_day_billings_project_teamspace_subentry_fkey; END $$; `); 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("ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_project_teamspace_subentry_fkey;"); await query(` DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'sessions' AND column_name = 'project_teamspace_subentry_id' ) THEN UPDATE sessions s SET project_teamspace_entry_id = COALESCE(s.project_teamspace_entry_id, pts.project_teamspace_entry_id) FROM project_teamspace_subentries pts WHERE s.project_teamspace_subentry_id = pts.id; END IF; END $$; `); await query(` DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'ticket_day_billings' AND column_name = 'project_teamspace_subentry_id' ) THEN CREATE TEMP TABLE migrated_subentry_day_billings ON COMMIT DROP AS SELECT tdb.ticket_id, tdb.user_id, tdb.day, pts.project_teamspace_entry_id, SUM(tdb.billed_minutes)::int AS billed_minutes FROM ticket_day_billings tdb JOIN project_teamspace_subentries pts ON pts.id = tdb.project_teamspace_subentry_id GROUP BY tdb.ticket_id, tdb.user_id, tdb.day, pts.project_teamspace_entry_id; DELETE FROM ticket_day_billings tdb WHERE tdb.project_teamspace_subentry_id IS NOT NULL; UPDATE ticket_day_billings target SET billed_minutes = target.billed_minutes + source.billed_minutes, updated_at = now() FROM migrated_subentry_day_billings source WHERE target.ticket_id = source.ticket_id AND target.user_id = source.user_id AND target.day = source.day AND target.project_teamspace_entry_id = source.project_teamspace_entry_id; INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes) SELECT source.ticket_id, source.user_id, source.project_teamspace_entry_id, source.day, source.billed_minutes FROM migrated_subentry_day_billings source WHERE NOT EXISTS ( SELECT 1 FROM ticket_day_billings target WHERE target.ticket_id = source.ticket_id AND target.user_id = source.user_id AND target.day = source.day AND target.project_teamspace_entry_id = source.project_teamspace_entry_id ); END IF; END $$; `); await query("DROP INDEX IF EXISTS idx_sessions_project_teamspace_subentry;"); await query("DROP INDEX IF EXISTS idx_ticket_day_billings_teamspace_subentry;"); await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_subentry;"); await query("ALTER TABLE sessions DROP COLUMN IF EXISTS project_teamspace_subentry_id;"); await query("ALTER TABLE ticket_day_billings DROP COLUMN IF EXISTS project_teamspace_subentry_id;"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_default ON ticket_day_billings(ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL;"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_entry ON ticket_day_billings(ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL;"); 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, zammad_owner_id, ticket_id, teamspace_project, description, goals) SELECT DISTINCT CASE WHEN t.zammad_owner_id IS NOT NULL THEN zammad_owner_user.id ELSE s.user_id END, t.zammad_owner_id, s.ticket_id, COALESCE(t.title, ''), '', '' FROM sessions s JOIN tickets t ON t.id = s.ticket_id LEFT JOIN users zammad_owner_user ON zammad_owner_user.role = 'user' AND zammad_owner_user.active AND zammad_owner_user.zammad_user_id = t.zammad_owner_id WHERE s.work_type = 'consulting' AND s.user_id IS NOT NULL ON CONFLICT 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_billings_teamspace_entry ON ticket_day_billings(project_teamspace_entry_id);"); 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 TABLE IF NOT EXISTS project_followup_reminders ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, todo_id BIGINT REFERENCES ticket_todos(id) ON DELETE SET NULL, stage INTEGER NOT NULL DEFAULT 1, latest_tt_update_at TIMESTAMPTZ NOT NULL, due_on DATE NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2)) ); `); await query("ALTER TABLE project_followup_reminders ADD COLUMN IF NOT EXISTS stage INTEGER NOT NULL DEFAULT 1;"); await query(` ALTER TABLE project_followup_reminders DROP CONSTRAINT IF EXISTS project_followup_reminders_stage_valid, ADD CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2)); `); await query(` DO $$ DECLARE constraint_name text; BEGIN SELECT c.conname INTO constraint_name FROM pg_constraint c WHERE c.conrelid = 'project_followup_reminders'::regclass AND c.contype = 'u' AND c.conkey = ARRAY[ (SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'user_id'), (SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'project_id'), (SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'latest_tt_update_at') ]::smallint[]; IF constraint_name IS NOT NULL THEN EXECUTE format('ALTER TABLE project_followup_reminders DROP CONSTRAINT %I', constraint_name); END IF; END $$; `); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_followup_reminders_unique_stage ON project_followup_reminders(user_id, project_id, latest_tt_update_at, stage);"); await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_user_project ON project_followup_reminders(user_id, project_id, latest_tt_update_at DESC);"); await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_todo ON project_followup_reminders(todo_id) WHERE todo_id IS NOT NULL;"); 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); `); }