This commit is contained in:
michael committed 2026-09-29 07:46:21 +02:00
1 parent 2105a0c5f7
commit c89d4825e2
30 files changed
+5435 -627

No files matched your search

+4 -1
View File
@@ -22,6 +22,7 @@ export type AuthUser = {
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number;
};
@@ -89,6 +90,7 @@ function publicUser(user: AuthUser) {
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),
zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true),
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6)
};
}
@@ -152,7 +154,8 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
u.zammad_session_note_default_enabled,
u.zammad_time_unit_rounding_enabled,
u.zammad_time_unit_rounding_minutes,
u.zammad_article_type_id
u.zammad_atoss_email_sync_enabled,
COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
+1
View File
@@ -22,6 +22,7 @@ export const databaseExportTables = [
"recurring_billing_slots",
"recurring_billing_exceptions",
"sessions",
"active_timers",
"ticket_month_closures",
"month_closures",
"ticket_day_closures",
+2382 -223
View File
File diff suppressed because it is too large. Load diff
+130 -6
View File
@@ -91,8 +91,11 @@ export async function migrate() {
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 ALTER COLUMN zammad_article_type_id DROP NOT NULL;");
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,
@@ -101,7 +104,7 @@ export async function migrate() {
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid,
ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IS NULL OR zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
`);
await query(`
@@ -237,6 +240,86 @@ export async function migrate() {
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,
@@ -263,6 +346,7 @@ export async function migrate() {
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,
@@ -272,8 +356,10 @@ export async function migrate() {
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 DEFAULT 6,
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,
@@ -285,12 +371,22 @@ export async function migrate() {
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("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id DROP NOT NULL;");
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 IS NULL OR zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
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
@@ -546,14 +642,18 @@ export async function migrate() {
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(),
PRIMARY KEY (ticket_id, user_id, day),
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(`
CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements (
@@ -610,6 +710,7 @@ export async function migrate() {
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,
@@ -622,9 +723,31 @@ 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("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(`
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;
END $$;
`);
await query(`
CREATE TABLE IF NOT EXISTS hidden_user_projects (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -786,6 +909,7 @@ export async function migrate() {
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);");
+36
View File
@@ -1,6 +1,7 @@
export const ticketPattern = /^Ticket#\d{6}$/;
export const trackableTicketPattern = /^(Ticket#\d{6}|Fix#\d+)$/;
export const monthPattern = /^\d{4}-\d{2}$/;
export const weekPattern = /^\d{4}-W\d{2}$/;
export const dayPattern = /^\d{4}-\d{2}-\d{2}$/;
export const usernamePattern = /^\S{3,40}$/u;
@@ -87,6 +88,41 @@ export function parseDay(value: string) {
};
}
export function parseWeek(value: string) {
if (!weekPattern.test(value)) {
throw badRequest("week must use YYYY-Www");
}
const year = Number(value.slice(0, 4));
const week = Number(value.slice(6, 8));
if (!Number.isInteger(year) || !Number.isInteger(week) || week < 1 || week > 53) {
throw badRequest("week is invalid");
}
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4Weekday = jan4.getUTCDay() || 7;
const weekStart = new Date(jan4);
weekStart.setUTCDate(jan4.getUTCDate() - jan4Weekday + 1 + (week - 1) * 7);
const checkThursday = new Date(weekStart);
checkThursday.setUTCDate(weekStart.getUTCDate() + 3);
if (checkThursday.getUTCFullYear() !== year) {
throw badRequest("week is invalid");
}
const next = new Date(weekStart);
next.setUTCDate(weekStart.getUTCDate() + 7);
return {
label: value,
start: weekStart.toISOString().slice(0, 10),
startIso: weekStart.toISOString(),
endIso: next.toISOString()
};
}
export function parsePositiveInteger(value: unknown, field: string) {
const number = Number(value);