import { query } from "./db.js"; import { hashPassword } from "./auth.js"; async function ensureMigrationAdmin() { const username = process.env.ADMIN_USERNAME?.trim() || "admin"; const password = process.env.ADMIN_PASSWORD ?? "admin"; const displayName = process.env.ADMIN_DISPLAY_NAME?.trim() || "Admin"; const existingUsers = await query<{ count: string }>("SELECT COUNT(*) AS count FROM users;"); if (Number(existingUsers.rows[0]?.count ?? 0) === 0) { await query( ` INSERT INTO users (username, display_name, password_hash, role) VALUES ($1, $2, $3, 'admin'); `, [username, displayName, await hashPassword(password)] ); if (!process.env.ADMIN_PASSWORD) { console.warn("Initialer Admin wurde mit admin/admin angelegt. Bitte Passwort über ADMIN_PASSWORD setzen oder im Adminbereich ändern."); } } const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;"); if (adminResult.rows.length === 0) { throw new Error("At least one admin user is required"); } return adminResult.rows[0].id; } async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string) { await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`); await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]); await query(`ALTER TABLE ${table} ALTER COLUMN user_id SET NOT NULL;`); await query(`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_pkey;`); await query(`ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id);`); await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`); } 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(` 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 ensureMigrationAdmin(); 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 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, organization_id BIGINT REFERENCES organizations(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 organization_id BIGINT REFERENCES organizations(id);"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS work_type TEXT;"); 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(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 ('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(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("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]); await query("ALTER TABLE sessions ALTER COLUMN user_id SET NOT NULL;"); 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(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 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 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 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_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); `); }