This commit is contained in:
michael committed 2026-10-08 09:03:15 +02:00
1 parent fc0dfd0556
commit 649dd6f028
20 files changed
+3725 -988

No files matched your search

+9
View File
@@ -17,8 +17,11 @@ export type AuthUser = {
active: boolean;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
teamspace_user_id: string | null;
has_teamspace_credentials: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
teamspace_time_tracking_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
@@ -90,8 +93,11 @@ function publicUser(user: AuthUser) {
active: user.active,
zammad_user_id: user.zammad_user_id ?? null,
has_zammad_api_token: Boolean(user.has_zammad_api_token),
teamspace_user_id: user.teamspace_user_id ?? null,
has_teamspace_credentials: Boolean(user.has_teamspace_credentials),
zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled),
session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled),
teamspace_time_tracking_enabled: Boolean(user.teamspace_time_tracking_enabled ?? true),
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled),
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
@@ -159,8 +165,11 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
u.active,
u.zammad_user_id::text AS zammad_user_id,
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token,
u.teamspace_user_id::text AS teamspace_user_id,
(u.teamspace_device_id IS NOT NULL AND u.teamspace_device_password IS NOT NULL) AS has_teamspace_credentials,
u.zammad_email_session_webhook_enabled,
u.session_billing_prompt_enabled,
u.teamspace_time_tracking_enabled,
u.zammad_session_note_default_enabled,
u.zammad_time_unit_rounding_enabled,
u.zammad_time_unit_rounding_minutes,
+2082 -383
View File
File diff suppressed because it is too large. Load diff
+199 -15
View File
@@ -55,6 +55,33 @@ async function encryptStoredApiSecrets() {
}
}
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;
@@ -86,8 +113,12 @@ export async function migrate() {
`);
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;");
@@ -98,6 +129,7 @@ export async function migrate() {
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;");
@@ -717,10 +749,11 @@ export async function migrate() {
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("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("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 (
@@ -829,8 +862,84 @@ export async function migrate() {
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
@@ -843,15 +952,18 @@ export async function migrate() {
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;
END $$;
`);
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,
@@ -873,6 +985,78 @@ export async function migrate() {
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
@@ -1025,8 +1209,8 @@ export async function migrate() {
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_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);");