1.2.0-rc1

This commit is contained in:
michael committed 2026-10-15 08:09:47 +02:00
1 parent 45ff46b7eb
commit fa9c122250
30 files changed
+6354 -989

No files matched your search

+6
View File
@@ -21,8 +21,10 @@ export type AuthUser = {
has_teamspace_credentials: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
session_work_segments_enabled: boolean;
teamspace_time_tracking_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_session_note_date_header_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
@@ -97,8 +99,10 @@ function publicUser(user: AuthUser) {
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),
session_work_segments_enabled: Boolean(user.session_work_segments_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_session_note_date_header_enabled: Boolean(user.zammad_session_note_date_header_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),
@@ -169,8 +173,10 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
(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.session_work_segments_enabled,
u.teamspace_time_tracking_enabled,
u.zammad_session_note_default_enabled,
u.zammad_session_note_date_header_enabled,
u.zammad_time_unit_rounding_enabled,
u.zammad_time_unit_rounding_minutes,
u.zammad_atoss_email_sync_enabled,
+28 -1
View File
@@ -12,12 +12,14 @@ export const databaseExportTables = [
"auth_sessions",
"app_settings",
"user_preferences",
"organization_attribute_options",
"teamspace_project_exclusions",
"teamspace_assignment_rules",
"organizations",
"tickets",
"projects",
"teamspace_project_resources",
"project_teamspace_entries",
"teamspace_project_exclusions",
"teamspace_monthly_user_actuals",
"project_teamspace_monthly_actuals",
"project_teamspace_subentries",
@@ -45,6 +47,17 @@ type ColumnInfo = {
};
const jsonDataTypes = new Set(["json", "jsonb"]);
const arrayElementTypes = new Map<string, string>([
["_bool", "boolean"],
["_date", "date"],
["_int2", "smallint"],
["_int4", "integer"],
["_int8", "bigint"],
["_numeric", "numeric"],
["_text", "text"],
["_timestamptz", "timestamptz"],
["_uuid", "uuid"]
]);
const insertBatchSize = 500;
export function quoteIdentifier(value: string) {
@@ -81,6 +94,20 @@ export function sqlLiteral(value: unknown, column?: Pick<ColumnInfo, "dataType"
return quoteSqlString(jsonValueForSql(value));
}
if (column?.dataType === "ARRAY") {
if (!Array.isArray(value)) {
throw new Error(`Expected PostgreSQL array value for ${column.udtName}`);
}
const elementType = arrayElementTypes.get(column.udtName);
if (!elementType) {
throw new Error(`Unsupported PostgreSQL array type: ${column.udtName}`);
}
return `ARRAY[${value.map((entry) => sqlLiteral(entry)).join(", ")}]::${elementType}[]`;
}
if (value instanceof Date) {
return quoteSqlString(value.toISOString());
}
+3408 -345
View File
File diff suppressed because it is too large. Load diff
+336 -28
View File
@@ -118,8 +118,10 @@ export async function migrate() {
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 session_work_segments_enabled BOOLEAN NOT NULL DEFAULT false;");
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_session_note_date_header_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;");
@@ -129,6 +131,10 @@ 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 ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_synced_at TIMESTAMPTZ;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_synced_by BIGINT REFERENCES users(id) ON DELETE SET NULL;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_unlocked_at TIMESTAMPTZ;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_unlocked_by BIGINT REFERENCES users(id) ON DELETE SET NULL;");
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;");
@@ -182,21 +188,79 @@ export async function migrate() {
);
`);
await query(`
CREATE TABLE IF NOT EXISTS organization_attribute_options (
id BIGSERIAL PRIMARY KEY,
zammad_attribute_id BIGINT NOT NULL,
attribute_name TEXT NOT NULL,
option_key TEXT NOT NULL,
display_name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT organization_attribute_options_attribute_valid
CHECK (attribute_name IN ('customer_owner', 'hotline_contract')),
CONSTRAINT organization_attribute_options_key_not_blank CHECK (btrim(option_key) <> ''),
CONSTRAINT organization_attribute_options_display_not_blank CHECK (btrim(display_name) <> ''),
UNIQUE (attribute_name, option_key)
);
`);
await query("CREATE INDEX IF NOT EXISTS idx_organization_attribute_options_attribute_active ON organization_attribute_options(attribute_name, active, display_name);");
await query(`
CREATE TABLE IF NOT EXISTS teamspace_project_exclusions (
teamspace_project TEXT PRIMARY KEY,
teamspace_api_project_id BIGINT,
teamspace_api_project_ids BIGINT[] NOT NULL DEFAULT '{}'::bigint[],
teamspace_name TEXT,
customer_name TEXT,
atoss_support_default BOOLEAN NOT NULL DEFAULT false,
atoss_support_valid_from DATE,
atoss_support_valid_to DATE,
customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT teamspace_project_exclusions_project_not_blank CHECK (btrim(teamspace_project) <> '')
);
`);
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS teamspace_api_project_id BIGINT;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS teamspace_api_project_ids BIGINT[] NOT NULL DEFAULT '{}'::bigint[];");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS teamspace_name TEXT;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS customer_name TEXT;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS atoss_support_default BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS atoss_support_valid_from DATE;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS atoss_support_valid_to DATE;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_teamspace_project_exclusions_lower ON teamspace_project_exclusions (lower(btrim(teamspace_project)));");
await query(`
CREATE TABLE IF NOT EXISTS teamspace_assignment_rules (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
priority INTEGER NOT NULL DEFAULT 100,
customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
support_enabled BOOLEAN NOT NULL DEFAULT true,
consulting_enabled BOOLEAN NOT NULL DEFAULT false,
teamspace_project TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT teamspace_assignment_rules_name_not_blank CHECK (btrim(name) <> ''),
CONSTRAINT teamspace_assignment_rules_project_not_blank CHECK (btrim(teamspace_project) <> ''),
CONSTRAINT teamspace_assignment_rules_priority_valid CHECK (priority BETWEEN 0 AND 100000),
CONSTRAINT teamspace_assignment_rules_condition_required CHECK (
customer_owner_option_id IS NOT NULL OR hotline_contract_option_id IS NOT NULL
),
CONSTRAINT teamspace_assignment_rules_work_type_required CHECK (support_enabled OR consulting_enabled)
);
`);
await query("CREATE INDEX IF NOT EXISTS idx_teamspace_assignment_rules_matching ON teamspace_assignment_rules(active, priority, id);");
await query(`
CREATE TABLE IF NOT EXISTS teamspace_project_resources (
id BIGSERIAL PRIMARY KEY,
@@ -234,6 +298,8 @@ export async function migrate() {
id BIGSERIAL PRIMARY KEY,
zammad_id BIGINT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -246,6 +312,58 @@ export async function migrate() {
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("ALTER TABLE organizations ADD COLUMN IF NOT EXISTS customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;");
await query("ALTER TABLE organizations ADD COLUMN IF NOT EXISTS hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;");
await query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'organizations'
AND column_name = 'customer_owner'
) THEN
INSERT INTO organization_attribute_options (zammad_attribute_id, attribute_name, option_key, display_name, active)
SELECT 0, 'customer_owner', btrim(customer_owner), btrim(customer_owner), false
FROM organizations
WHERE customer_owner IS NOT NULL AND btrim(customer_owner) <> ''
ON CONFLICT (attribute_name, option_key) DO NOTHING;
UPDATE organizations organization
SET customer_owner_option_id = option.id
FROM organization_attribute_options option
WHERE option.attribute_name = 'customer_owner'
AND option.option_key = btrim(organization.customer_owner)
AND organization.customer_owner_option_id IS NULL;
ALTER TABLE organizations DROP COLUMN customer_owner;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'organizations'
AND column_name = 'hotline_contract'
) THEN
INSERT INTO organization_attribute_options (zammad_attribute_id, attribute_name, option_key, display_name, active)
SELECT 0, 'hotline_contract', btrim(hotline_contract), btrim(hotline_contract), false
FROM organizations
WHERE hotline_contract IS NOT NULL AND btrim(hotline_contract) <> ''
ON CONFLICT (attribute_name, option_key) DO NOTHING;
UPDATE organizations organization
SET hotline_contract_option_id = option.id
FROM organization_attribute_options option
WHERE option.attribute_name = 'hotline_contract'
AND option.option_key = btrim(organization.hotline_contract)
AND organization.hotline_contract_option_id IS NULL;
ALTER TABLE organizations DROP COLUMN hotline_contract;
END IF;
END $$;
`);
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;");
@@ -257,6 +375,10 @@ export async function migrate() {
organization_id BIGINT REFERENCES organizations(zammad_id),
customer_name TEXT,
work_type TEXT,
atoss_support BOOLEAN NOT NULL DEFAULT false,
teamspace_assignment_rule_id BIGINT REFERENCES teamspace_assignment_rules(id) ON DELETE SET NULL,
teamspace_assignment_project TEXT,
teamspace_assignment_synced_at TIMESTAMPTZ,
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'))
@@ -267,6 +389,7 @@ export async function migrate() {
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 atoss_support BOOLEAN NOT NULL DEFAULT false;");
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;");
@@ -277,6 +400,9 @@ export async function migrate() {
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 ADD COLUMN IF NOT EXISTS teamspace_assignment_rule_id BIGINT REFERENCES teamspace_assignment_rules(id) ON DELETE SET NULL;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS teamspace_assignment_project TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS teamspace_assignment_synced_at TIMESTAMPTZ;");
await query(`
ALTER TABLE tickets
DROP CONSTRAINT IF EXISTS ticket_number_format,
@@ -315,31 +441,6 @@ export async function migrate() {
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);");
@@ -375,6 +476,7 @@ export async function migrate() {
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("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS work_segments JSONB NOT NULL DEFAULT '[]'::jsonb;");
await query(`
CREATE TABLE IF NOT EXISTS active_timers (
id BIGSERIAL PRIMARY KEY,
@@ -416,6 +518,7 @@ export async function migrate() {
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 work_segments JSONB NOT NULL DEFAULT '[]'::jsonb;");
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(`
@@ -877,6 +980,32 @@ export async function migrate() {
END $$;
`);
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 TABLE IF NOT EXISTS project_teamspace_entries (
id BIGSERIAL PRIMARY KEY,
@@ -1120,6 +1249,37 @@ export async function migrate() {
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(`
WITH exclusion_api_ids AS (
SELECT
tpe.teamspace_project,
MIN(api_id) AS main_api_id,
array_agg(DISTINCT api_id ORDER BY api_id) AS api_ids
FROM teamspace_project_exclusions tpe
CROSS JOIN LATERAL (
SELECT pte.teamspace_api_project_id AS api_id
FROM project_teamspace_entries pte
WHERE lower(btrim(pte.teamspace_project)) = lower(btrim(tpe.teamspace_project))
AND pte.teamspace_api_project_id IS NOT NULL
UNION
SELECT pts.teamspace_api_project_id AS api_id
FROM project_teamspace_entries pte
JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte.id
WHERE lower(btrim(pte.teamspace_project)) = lower(btrim(tpe.teamspace_project))
) api_source
GROUP BY tpe.teamspace_project
)
UPDATE teamspace_project_exclusions tpe
SET teamspace_api_project_id = COALESCE(tpe.teamspace_api_project_id, exclusion_api_ids.main_api_id),
teamspace_api_project_ids = CASE
WHEN cardinality(tpe.teamspace_api_project_ids) = 0 THEN exclusion_api_ids.api_ids
ELSE tpe.teamspace_api_project_ids
END
FROM exclusion_api_ids
WHERE tpe.teamspace_project = exclusion_api_ids.teamspace_project;
`);
await query(`
WITH duplicate_keys AS (
SELECT lower(btrim(teamspace_project)) AS teamspace_key
@@ -1561,7 +1721,7 @@ export async function migrate() {
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)
SET project_teamspace_entry_id = pts.project_teamspace_entry_id
FROM project_teamspace_subentries pts
WHERE s.project_teamspace_subentry_id = pts.id;
END IF;
@@ -1685,7 +1845,15 @@ export async function migrate() {
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;
AND (
s.project_teamspace_entry_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM project_teamspace_entries current_pte
WHERE current_pte.id = s.project_teamspace_entry_id
AND current_pte.project_id = sep.project_id
)
);
`);
await query(`
@@ -1703,7 +1871,44 @@ export async function migrate() {
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;
AND (
s.project_teamspace_entry_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM project_teamspace_entries current_pte
JOIN projects current_project ON current_project.id = current_pte.project_id
WHERE current_pte.id = s.project_teamspace_entry_id
AND current_project.ticket_id = single_ticket.ticket_id
)
);
`);
await query(`
WITH default_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
WHERE pte.is_default
GROUP BY p.ticket_id
HAVING COUNT(pte.id) = 1
)
UPDATE sessions s
SET project_teamspace_entry_id = default_ticket.project_teamspace_entry_id
FROM default_entry_tickets default_ticket
WHERE s.ticket_id = default_ticket.ticket_id
AND s.work_type = 'consulting'
AND (
s.project_teamspace_entry_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM project_teamspace_entries current_pte
JOIN projects current_project ON current_project.id = current_pte.project_id
WHERE current_pte.id = s.project_teamspace_entry_id
AND current_project.ticket_id = default_ticket.ticket_id
)
);
`);
await query(`
@@ -1832,6 +2037,63 @@ export async function migrate() {
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("ALTER TABLE recurring_billings ADD COLUMN IF NOT EXISTS teamspace_project TEXT;");
await query("ALTER TABLE recurring_billings ADD COLUMN IF NOT EXISTS hour_budget_minutes INTEGER;");
await query("ALTER TABLE recurring_billings ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;");
await query(`
WITH existing_configuration AS (
SELECT DISTINCT ON (rb.id)
rb.id AS recurring_billing_id,
COALESCE(NULLIF(btrim(pte.teamspace_project), ''), NULLIF(btrim(p.teamspace_project), '')) AS teamspace_project,
COALESCE(pte.hour_budget_minutes, p.hour_budget_minutes) AS hour_budget_minutes,
GREATEST(
COALESCE(pte.manual_consumption_minutes, 0),
COALESCE(p.manual_consumption_minutes, 0)
)::int AS manual_consumption_minutes
FROM recurring_billings rb
JOIN tickets t ON t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id)
JOIN projects p
ON p.ticket_id = t.id
AND p.user_id = rb.user_id
LEFT JOIN project_teamspace_entries pte
ON pte.project_id = p.id
AND pte.is_default
ORDER BY rb.id, p.id ASC, pte.id ASC NULLS LAST
)
UPDATE recurring_billings rb
SET teamspace_project = COALESCE(rb.teamspace_project, existing.teamspace_project),
hour_budget_minutes = COALESCE(rb.hour_budget_minutes, existing.hour_budget_minutes),
manual_consumption_minutes = GREATEST(
rb.manual_consumption_minutes,
existing.manual_consumption_minutes
),
updated_at = CASE
WHEN rb.teamspace_project IS NULL AND existing.teamspace_project IS NOT NULL
OR rb.hour_budget_minutes IS NULL AND existing.hour_budget_minutes IS NOT NULL
OR existing.manual_consumption_minutes > rb.manual_consumption_minutes
THEN now()
ELSE rb.updated_at
END
FROM existing_configuration existing
WHERE existing.recurring_billing_id = rb.id;
`);
await query(`
ALTER TABLE recurring_billings
DROP CONSTRAINT IF EXISTS recurring_billings_budget_non_negative,
ADD CONSTRAINT recurring_billings_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
DROP CONSTRAINT IF EXISTS recurring_billings_manual_consumption_non_negative,
ADD CONSTRAINT recurring_billings_manual_consumption_non_negative CHECK (manual_consumption_minutes >= 0);
`);
await query(`
UPDATE tickets t
SET organization_id = rb.organization_id,
customer_name = o.name,
work_type = rb.work_type
FROM recurring_billings rb
JOIN organizations o ON o.zammad_id = rb.organization_id
WHERE t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id)
AND (t.user_id IS NULL OR t.user_id = rb.user_id);
`);
await query(`
WITH deleted_orphan_sessions AS (
DELETE FROM sessions s
@@ -1889,6 +2151,7 @@ export async function migrate() {
await query(`
DELETE FROM tickets t
WHERE t.ticket_number ~ '^Fix#[0-9]+$'
AND t.ticket_number <> 'Fix#0'
AND NOT EXISTS (
SELECT 1
FROM sessions s
@@ -1916,4 +2179,49 @@ export async function migrate() {
WHERE t.id = latest.ticket_id
AND (t.customer_name IS NULL OR t.work_type IS NULL);
`);
// Preserve an Atoss fallback that was already applied while the choice still
// lived on individual sessions. Rule-based assignments use the same
// technical Fix#0 container, so only sessions without a stored rule marker
// and on the configured, date-valid Atoss target qualify for this backfill.
await query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM app_settings
WHERE key = 'migration.atoss_support_ticket_backfill_v1'
) THEN
UPDATE tickets ticket
SET atoss_support = true
WHERE ticket.work_type = 'support'
AND ticket.atoss_support = false
AND ticket.teamspace_assignment_rule_id IS NULL
AND EXISTS (
SELECT 1
FROM sessions session
JOIN project_teamspace_entries entry ON entry.id = session.project_teamspace_entry_id
JOIN projects target_project ON target_project.id = entry.project_id
JOIN tickets target_ticket ON target_ticket.id = target_project.ticket_id
JOIN teamspace_project_exclusions exclusion
ON lower(btrim(exclusion.teamspace_project)) = lower(btrim(entry.teamspace_project))
WHERE session.ticket_id = ticket.id
AND target_ticket.ticket_number = 'Fix#0'
AND exclusion.atoss_support_default = true
AND (
exclusion.atoss_support_valid_from IS NULL
OR exclusion.atoss_support_valid_from <= (session.started_at AT TIME ZONE 'Europe/Berlin')::date
)
AND (
exclusion.atoss_support_valid_to IS NULL
OR exclusion.atoss_support_valid_to >= (session.started_at AT TIME ZONE 'Europe/Berlin')::date
)
);
INSERT INTO app_settings (key, value, updated_at)
VALUES ('migration.atoss_support_ticket_backfill_v1', 'done', now());
END IF;
END $$;
`);
}
@@ -0,0 +1,71 @@
export const organizationAttributeNames = ["customer_owner", "hotline_contract"] as const;
export type OrganizationAttributeName = (typeof organizationAttributeNames)[number];
export type ZammadOrganizationAttributeOption = {
zammadAttributeId: number;
attributeName: OrganizationAttributeName;
optionKey: string;
displayName: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isOrganizationAttributeName(value: unknown): value is OrganizationAttributeName {
return typeof value === "string" && organizationAttributeNames.includes(value as OrganizationAttributeName);
}
export function parseZammadOrganizationAttributeOptions(payload: unknown): ZammadOrganizationAttributeOption[] {
if (!Array.isArray(payload)) {
throw new Error("Zammad response must be an object-manager attribute array");
}
const options: ZammadOrganizationAttributeOption[] = [];
const foundAttributes = new Set<OrganizationAttributeName>();
for (const item of payload) {
if (!isRecord(item) || !isOrganizationAttributeName(item.name)) {
continue;
}
const zammadAttributeId = Number(item.id);
const dataOption = isRecord(item.data_option)
? item.data_option
: isRecord(item.data_options)
? item.data_options
: null;
const rawOptions = dataOption && isRecord(dataOption.options) ? dataOption.options : null;
if (!Number.isInteger(zammadAttributeId) || zammadAttributeId <= 0 || !rawOptions) {
throw new Error(`Zammad object-manager attribute ${item.name} is invalid`);
}
foundAttributes.add(item.name);
for (const [rawKey, rawDisplayName] of Object.entries(rawOptions)) {
const optionKey = rawKey.trim();
const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : "";
if (!optionKey || !displayName) {
continue;
}
options.push({
zammadAttributeId,
attributeName: item.name,
optionKey,
displayName
});
}
}
const missingAttributes = organizationAttributeNames.filter((name) => !foundAttributes.has(name));
if (missingAttributes.length > 0) {
throw new Error(`Zammad object-manager attributes missing: ${missingAttributes.join(", ")}`);
}
return options;
}
+24 -2
View File
@@ -1,9 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import { nextSequenceIdAfterRestore, restoreSqlText, sequenceSetvalSql, sqlLiteral, type QueryExecutor } from "../src/databaseExport.ts";
import { exportTableSql } from "../src/databaseExport.ts";
import {
databaseExportTables,
exportTableSql,
nextSequenceIdAfterRestore,
restoreSqlText,
sequenceSetvalSql,
sqlLiteral,
type QueryExecutor
} from "../src/databaseExport.ts";
const jsonbColumn = { dataType: "jsonb", udtName: "jsonb" };
const bigintArrayColumn = { dataType: "ARRAY", udtName: "_int8" };
test("sqlLiteral serializes primitive values", () => {
assert.equal(sqlLiteral(null), "NULL");
@@ -36,6 +44,20 @@ test("sqlLiteral serializes JSON columns as valid JSON values", () => {
assert.equal(sqlLiteral(true, jsonbColumn), "'true'");
});
test("sqlLiteral serializes PostgreSQL arrays with an explicit element type", () => {
assert.equal(sqlLiteral([], bigintArrayColumn), "ARRAY[]::bigint[]");
assert.equal(sqlLiteral(["32349965", "32349982"], bigintArrayColumn), "ARRAY['32349965', '32349982']::bigint[]");
assert.throws(
() => sqlLiteral(["value"], { dataType: "ARRAY", udtName: "_unsupported" }),
/Unsupported PostgreSQL array type/
);
});
test("database export inserts assignment rules before tickets that reference them", () => {
assert.ok(databaseExportTables.indexOf("organization_attribute_options") < databaseExportTables.indexOf("teamspace_assignment_rules"));
assert.ok(databaseExportTables.indexOf("teamspace_assignment_rules") < databaseExportTables.indexOf("tickets"));
});
test("generated table export never contains implicit object stringification", async () => {
const client: QueryExecutor = {
async query<T extends Record<string, unknown>>(text: string): Promise<{ rows: T[]; rowCount: number | null }> {
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseZammadOrganizationAttributeOptions } from "../src/zammadOrganizationAttributes.ts";
test("parses the two organization select attributes and ignores unrelated attributes", () => {
const options = parseZammadOrganizationAttributeOptions([
{
id: 85,
name: "customer_owner",
data_option: { options: { ATOSS: "ATOSS", sdw: "SD Worx" } }
},
{
id: 12,
name: "unrelated",
data_option: { options: { no: "No" } }
},
{
id: 86,
name: "hotline_contract",
data_option: { options: { none: "(kein Hotlinevertrag)" } }
}
]);
assert.deepEqual(options, [
{ zammadAttributeId: 85, attributeName: "customer_owner", optionKey: "ATOSS", displayName: "ATOSS" },
{ zammadAttributeId: 85, attributeName: "customer_owner", optionKey: "sdw", displayName: "SD Worx" },
{ zammadAttributeId: 86, attributeName: "hotline_contract", optionKey: "none", displayName: "(kein Hotlinevertrag)" }
]);
});
test("rejects responses without both required attributes", () => {
assert.throws(
() => parseZammadOrganizationAttributeOptions([
{ id: 85, name: "customer_owner", data_option: { options: { ATOSS: "ATOSS" } } }
]),
/hotline_contract/
);
});