Initial TicketTracker release

This commit is contained in:
2026-08-05 10:55:30 +02:00
commit 125b1d22ff
128 changed files with 26208 additions and 0 deletions
+398
View File
@@ -0,0 +1,398 @@
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 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_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);
`);
}