1.2.0-rc1
This commit is contained in:
1 parent
45ff46b7eb
commit
fa9c122250
30 files changed
+6354
-989
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+336
-28
@@ -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;
|
||||
}
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user