1356 lines
59 KiB
TypeScript
1356 lines
59 KiB
TypeScript
import { query } from "./db.js";
|
|
import { encryptSecret, isEncryptedSecret } from "./secrets.js";
|
|
|
|
const zammadApiKeySettingKey = "zammad.api_key";
|
|
|
|
async function findMigrationAdmin() {
|
|
const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;");
|
|
return adminResult.rows[0]?.id ?? null;
|
|
}
|
|
|
|
async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string | null) {
|
|
await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`);
|
|
|
|
if (adminId) {
|
|
await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]);
|
|
}
|
|
|
|
const duplicateMatchers = [...primaryKeyColumns, "user_id"]
|
|
.map((key) => `older.${key} IS NOT DISTINCT FROM newer.${key}`)
|
|
.join(" AND ");
|
|
|
|
await query(`
|
|
DELETE FROM ${table} older
|
|
USING ${table} newer
|
|
WHERE ${duplicateMatchers}
|
|
AND (
|
|
older.closed_at < newer.closed_at
|
|
OR (older.closed_at = newer.closed_at AND older.ctid < newer.ctid)
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM ${table} WHERE user_id IS NULL) THEN
|
|
ALTER TABLE ${table} ALTER COLUMN user_id SET NOT NULL;
|
|
ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_pkey;
|
|
ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id);
|
|
END IF;
|
|
END $$;
|
|
`);
|
|
await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`);
|
|
}
|
|
|
|
async function encryptStoredApiSecrets() {
|
|
const userTokenResult = await query<{ id: string; zammad_api_token: string | null }>(`
|
|
SELECT id::text AS id, zammad_api_token
|
|
FROM users
|
|
WHERE zammad_api_token IS NOT NULL;
|
|
`);
|
|
|
|
for (const row of userTokenResult.rows) {
|
|
if (row.zammad_api_token && !isEncryptedSecret(row.zammad_api_token)) {
|
|
await query("UPDATE users SET zammad_api_token = $1, updated_at = now() WHERE id = $2;", [encryptSecret(row.zammad_api_token), row.id]);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
if (zammadApiKey && !isEncryptedSecret(zammadApiKey)) {
|
|
await query("UPDATE app_settings SET value = $1, updated_at = now() WHERE key = $2;", [encryptSecret(zammadApiKey), zammadApiKeySettingKey]);
|
|
}
|
|
}
|
|
|
|
export async function migrate() {
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
display_name TEXT NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'user',
|
|
active BOOLEAN NOT NULL DEFAULT true,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT users_role_valid CHECK (role IN ('admin', 'user')),
|
|
CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s')
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
ALTER TABLE users
|
|
DROP CONSTRAINT IF EXISTS users_username_format,
|
|
ADD CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s');
|
|
`);
|
|
await query("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;");
|
|
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 ADD COLUMN IF NOT EXISTS project_followup_enabled BOOLEAN NOT NULL DEFAULT true;");
|
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_weeks INTEGER NOT NULL DEFAULT 4;");
|
|
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;");
|
|
await query(`
|
|
ALTER TABLE users
|
|
DROP CONSTRAINT IF EXISTS users_zammad_time_unit_rounding_minutes_valid,
|
|
ADD CONSTRAINT users_zammad_time_unit_rounding_minutes_valid CHECK (zammad_time_unit_rounding_minutes IN (5, 10, 15, 30));
|
|
`);
|
|
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 IN (1, 2, 3, 4, 5, 6));
|
|
`);
|
|
await query(`
|
|
ALTER TABLE users
|
|
DROP CONSTRAINT IF EXISTS users_project_followup_weeks_valid,
|
|
ADD CONSTRAINT users_project_followup_weeks_valid CHECK (project_followup_weeks BETWEEN 1 AND 52);
|
|
`);
|
|
await query(`
|
|
ALTER TABLE users
|
|
DROP CONSTRAINT IF EXISTS users_project_followup_second_weeks_valid,
|
|
ADD CONSTRAINT users_project_followup_second_weeks_valid CHECK (project_followup_second_weeks BETWEEN 1 AND 52);
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
token_hash TEXT PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`);
|
|
|
|
await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);");
|
|
|
|
const adminId = await findMigrationAdmin();
|
|
|
|
if (adminId) {
|
|
await query("UPDATE users SET role = 'user', updated_at = now() WHERE role = 'admin' AND id <> $1;", [adminId]);
|
|
}
|
|
|
|
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users ((role)) WHERE role = 'admin';");
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS app_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`);
|
|
|
|
await encryptStoredApiSecrets();
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
key TEXT NOT NULL,
|
|
value JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (user_id, key)
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS organizations (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
zammad_id BIGINT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL UNIQUE,
|
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`);
|
|
|
|
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS active;");
|
|
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS shared;");
|
|
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS vip;");
|
|
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS domain;");
|
|
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS note;");
|
|
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS zammad_updated_at;");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_organizations_name_lower ON organizations(lower(name));");
|
|
await query("DROP INDEX IF EXISTS idx_organizations_active_name;");
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS tickets (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
ticket_number TEXT NOT NULL UNIQUE,
|
|
title TEXT,
|
|
organization_id BIGINT REFERENCES organizations(zammad_id),
|
|
customer_name TEXT,
|
|
work_type TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'),
|
|
CONSTRAINT ticket_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting'))
|
|
);
|
|
`);
|
|
|
|
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS customer_name TEXT;");
|
|
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS 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 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;");
|
|
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_id BIGINT;");
|
|
await query("UPDATE tickets SET zammad_state_id = 2 WHERE zammad_state_id = 1;");
|
|
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;");
|
|
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id) ON DELETE SET NULL;");
|
|
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
|
|
DROP CONSTRAINT IF EXISTS ticket_number_format,
|
|
ADD CONSTRAINT ticket_number_format CHECK (ticket_number ~ '^(Ticket#[0-9]{6}|Fix#[0-9]+)$');
|
|
`);
|
|
await query(`
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'ticket_work_type_valid'
|
|
) THEN
|
|
ALTER TABLE tickets
|
|
ADD CONSTRAINT ticket_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting'));
|
|
END IF;
|
|
END $$;
|
|
`);
|
|
await query(`
|
|
WITH unique_zammad_users AS (
|
|
SELECT zammad_user_id, MIN(id) AS user_id
|
|
FROM users
|
|
WHERE role = 'user'
|
|
AND active
|
|
AND zammad_user_id IS NOT NULL
|
|
GROUP BY zammad_user_id
|
|
HAVING COUNT(*) = 1
|
|
),
|
|
zammad_ticket_owners AS (
|
|
SELECT t.id, uzu.user_id
|
|
FROM tickets t
|
|
LEFT JOIN unique_zammad_users uzu ON uzu.zammad_user_id = t.zammad_owner_id
|
|
WHERE t.zammad_owner_id IS NOT NULL
|
|
)
|
|
UPDATE tickets t
|
|
SET user_id = zto.user_id
|
|
FROM zammad_ticket_owners zto
|
|
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);");
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
organization_id BIGINT REFERENCES organizations(zammad_id),
|
|
customer_name TEXT NOT NULL,
|
|
activity TEXT NOT NULL,
|
|
work_type TEXT NOT NULL,
|
|
user_id BIGINT REFERENCES users(id),
|
|
started_at TIMESTAMPTZ NOT NULL,
|
|
ended_at TIMESTAMPTZ NOT NULL,
|
|
duration_seconds INTEGER NOT NULL,
|
|
rounded_minutes INTEGER NOT NULL,
|
|
billing_status TEXT,
|
|
billing_updated_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT work_type_valid CHECK (work_type IN ('support', 'consulting')),
|
|
CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')),
|
|
CONSTRAINT duration_non_negative CHECK (duration_seconds >= 0),
|
|
CONSTRAINT rounded_minutes_positive CHECK (rounded_minutes >= 1),
|
|
CONSTRAINT ended_after_started CHECK (ended_at >= started_at)
|
|
);
|
|
`);
|
|
|
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);");
|
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);");
|
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_id BIGINT;");
|
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;");
|
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;");
|
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;");
|
|
await query("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,
|
|
ADD CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable'));
|
|
`);
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS zammad_email_session_events (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
zammad_article_id BIGINT,
|
|
delivery_id TEXT,
|
|
session_id BIGINT REFERENCES sessions(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT zammad_email_session_event_source CHECK (zammad_article_id IS NOT NULL OR delivery_id IS NOT NULL)
|
|
);
|
|
`);
|
|
await query(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_email_session_events_user_article
|
|
ON zammad_email_session_events(user_id, zammad_article_id)
|
|
WHERE zammad_article_id IS NOT NULL;
|
|
`);
|
|
await query(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_email_session_events_delivery
|
|
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,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
session_id BIGINT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
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 NOT NULL DEFAULT 6,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
last_error TEXT,
|
|
last_attempt_at TIMESTAMPTZ,
|
|
sent_at TIMESTAMPTZ,
|
|
zammad_article_id BIGINT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
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("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 IN (1, 2, 3, 4, 5, 6));
|
|
`);
|
|
await query(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session
|
|
ON zammad_session_article_queue(session_id)
|
|
WHERE status = 'pending';
|
|
`);
|
|
await query(`
|
|
CREATE INDEX IF NOT EXISTS idx_zammad_session_article_queue_user_pending
|
|
ON zammad_session_article_queue(user_id, created_at)
|
|
WHERE status = 'pending';
|
|
`);
|
|
if (adminId) {
|
|
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
|
|
}
|
|
await query(`
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM sessions WHERE user_id IS NULL) THEN
|
|
ALTER TABLE sessions ALTER COLUMN user_id SET NOT NULL;
|
|
END IF;
|
|
END $$;
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS recurring_billings (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
ticket_number TEXT,
|
|
organization_id BIGINT NOT NULL REFERENCES organizations(zammad_id),
|
|
activity TEXT NOT NULL,
|
|
work_type TEXT NOT NULL,
|
|
recurrence_type TEXT NOT NULL,
|
|
interval_value INTEGER NOT NULL DEFAULT 1,
|
|
valid_from DATE NOT NULL,
|
|
valid_until DATE,
|
|
start_time TIME NOT NULL DEFAULT '09:00',
|
|
active BOOLEAN NOT NULL DEFAULT true,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT recurring_billings_work_type_valid CHECK (work_type IN ('support', 'consulting')),
|
|
CONSTRAINT recurring_billings_type_valid CHECK (recurrence_type IN ('weekly', 'every_n_weeks')),
|
|
CONSTRAINT recurring_billings_interval_positive CHECK (interval_value >= 1),
|
|
CONSTRAINT recurring_billings_valid_range CHECK (valid_until IS NULL OR valid_until >= valid_from)
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_organization_id_fkey;
|
|
ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_organization_id_fkey;
|
|
ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_organization_id_fkey;
|
|
`);
|
|
|
|
await query(`
|
|
UPDATE tickets t
|
|
SET organization_id = o.zammad_id
|
|
FROM organizations o
|
|
WHERE t.organization_id = o.id
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM organizations current_org
|
|
WHERE current_org.zammad_id = t.organization_id
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
UPDATE sessions s
|
|
SET organization_id = o.zammad_id
|
|
FROM organizations o
|
|
WHERE s.organization_id = o.id
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM organizations current_org
|
|
WHERE current_org.zammad_id = s.organization_id
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
UPDATE recurring_billings rb
|
|
SET organization_id = o.zammad_id
|
|
FROM organizations o
|
|
WHERE rb.organization_id = o.id
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM organizations current_org
|
|
WHERE current_org.zammad_id = rb.organization_id
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
UPDATE tickets t
|
|
SET organization_id = old_org.zammad_id
|
|
FROM organizations old_org
|
|
WHERE t.organization_id = old_org.id
|
|
AND t.organization_id <> old_org.zammad_id
|
|
AND COALESCE(t.customer_name, '') = old_org.name;
|
|
`);
|
|
|
|
await query(`
|
|
UPDATE sessions s
|
|
SET organization_id = old_org.zammad_id
|
|
FROM organizations old_org
|
|
WHERE s.organization_id = old_org.id
|
|
AND s.organization_id <> old_org.zammad_id
|
|
AND COALESCE(s.customer_name, '') = old_org.name;
|
|
`);
|
|
|
|
await query(`
|
|
UPDATE recurring_billings rb
|
|
SET organization_id = old_org.zammad_id
|
|
FROM organizations old_org
|
|
WHERE rb.organization_id = old_org.id
|
|
AND rb.organization_id <> old_org.zammad_id
|
|
AND (
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM sessions s
|
|
WHERE s.recurring_billing_id = rb.id
|
|
AND COALESCE(s.customer_name, '') = old_org.name
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM tickets t
|
|
WHERE t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id)
|
|
AND COALESCE(t.customer_name, '') = old_org.name
|
|
)
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
UPDATE tickets
|
|
SET organization_id = NULL
|
|
WHERE organization_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM organizations o
|
|
WHERE o.zammad_id = tickets.organization_id
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
UPDATE sessions
|
|
SET organization_id = NULL
|
|
WHERE organization_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM organizations o
|
|
WHERE o.zammad_id = sessions.organization_id
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
ALTER TABLE tickets
|
|
ADD CONSTRAINT tickets_organization_id_fkey
|
|
FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id);
|
|
ALTER TABLE sessions
|
|
ADD CONSTRAINT sessions_organization_id_fkey
|
|
FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id);
|
|
ALTER TABLE recurring_billings
|
|
ADD CONSTRAINT recurring_billings_organization_id_fkey
|
|
FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id);
|
|
`);
|
|
|
|
await query("ALTER TABLE recurring_billings ALTER COLUMN ticket_number DROP NOT NULL;");
|
|
await query("ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_ticket_number_format;");
|
|
await query("UPDATE recurring_billings SET recurrence_type = 'every_n_weeks' WHERE recurrence_type = 'every_n_days';");
|
|
await query(`
|
|
ALTER TABLE recurring_billings
|
|
DROP CONSTRAINT IF EXISTS recurring_billings_type_valid,
|
|
ADD CONSTRAINT recurring_billings_type_valid CHECK (recurrence_type IN ('weekly', 'every_n_weeks'));
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS recurring_billing_slots (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
recurring_billing_id BIGINT NOT NULL REFERENCES recurring_billings(id) ON DELETE CASCADE,
|
|
weekday INTEGER,
|
|
start_time TIME,
|
|
duration_minutes INTEGER NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT recurring_billing_slots_weekday_valid CHECK (weekday IS NULL OR weekday BETWEEN 0 AND 6),
|
|
CONSTRAINT recurring_billing_slots_duration_positive CHECK (duration_minutes >= 1)
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS recurring_billing_exceptions (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
recurring_billing_id BIGINT NOT NULL REFERENCES recurring_billings(id) ON DELETE CASCADE,
|
|
recurring_billing_slot_id BIGINT REFERENCES recurring_billing_slots(id) ON DELETE CASCADE,
|
|
occurrence_date DATE NOT NULL,
|
|
action TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT recurring_billing_exceptions_action_valid CHECK (action IN ('cancelled')),
|
|
UNIQUE (recurring_billing_id, recurring_billing_slot_id, occurrence_date)
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'sessions_recurring_billing_fkey'
|
|
) THEN
|
|
ALTER TABLE sessions
|
|
ADD CONSTRAINT sessions_recurring_billing_fkey
|
|
FOREIGN KEY (recurring_billing_id) REFERENCES recurring_billings(id) ON DELETE SET NULL;
|
|
END IF;
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'sessions_recurring_billing_slot_fkey'
|
|
) THEN
|
|
ALTER TABLE sessions
|
|
ADD CONSTRAINT sessions_recurring_billing_slot_fkey
|
|
FOREIGN KEY (recurring_billing_slot_id) REFERENCES recurring_billing_slots(id) ON DELETE SET NULL;
|
|
END IF;
|
|
END $$;
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS ticket_month_closures (
|
|
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
user_id BIGINT REFERENCES users(id),
|
|
month DATE NOT NULL,
|
|
closed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS month_closures (
|
|
user_id BIGINT REFERENCES users(id),
|
|
month DATE NOT NULL,
|
|
closed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS ticket_day_closures (
|
|
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
user_id BIGINT REFERENCES users(id),
|
|
day DATE NOT NULL,
|
|
closed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS day_closures (
|
|
user_id BIGINT REFERENCES users(id),
|
|
day DATE NOT NULL,
|
|
closed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS ticket_day_billings (
|
|
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
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(),
|
|
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("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 (
|
|
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
day DATE NOT NULL,
|
|
acknowledged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (ticket_id, user_id, day)
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS projects (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
teamspace_project TEXT,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
goals TEXT NOT NULL DEFAULT '',
|
|
starts_on DATE,
|
|
ends_on DATE,
|
|
hour_budget_minutes INTEGER,
|
|
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
|
|
closed_at TIMESTAMPTZ,
|
|
close_review_required BOOLEAN NOT NULL DEFAULT false,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (user_id, ticket_id),
|
|
CONSTRAINT projects_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
|
|
CONSTRAINT projects_manual_consumption_non_negative CHECK (manual_consumption_minutes >= 0),
|
|
CONSTRAINT projects_valid_range CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on)
|
|
);
|
|
`);
|
|
|
|
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;");
|
|
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS close_review_required BOOLEAN NOT NULL DEFAULT false;");
|
|
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;");
|
|
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;");
|
|
await query("ALTER TABLE projects ALTER COLUMN user_id DROP NOT NULL;");
|
|
await query(`
|
|
UPDATE projects p
|
|
SET zammad_owner_id = t.zammad_owner_id
|
|
FROM tickets t
|
|
WHERE p.ticket_id = t.id
|
|
AND p.zammad_owner_id IS NULL
|
|
AND t.zammad_owner_id IS NOT NULL;
|
|
`);
|
|
await query(`
|
|
UPDATE projects p
|
|
SET user_id = t.user_id,
|
|
zammad_owner_id = t.zammad_owner_id
|
|
FROM tickets t
|
|
WHERE p.ticket_id = t.id
|
|
AND t.zammad_owner_id IS NOT NULL
|
|
AND (
|
|
p.user_id IS DISTINCT FROM t.user_id
|
|
OR p.zammad_owner_id IS DISTINCT FROM t.zammad_owner_id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM projects existing
|
|
WHERE existing.ticket_id = p.ticket_id
|
|
AND existing.id <> p.id
|
|
AND (
|
|
(t.user_id IS NOT NULL AND existing.user_id = t.user_id)
|
|
OR (
|
|
t.user_id IS NULL
|
|
AND existing.user_id IS NULL
|
|
AND existing.zammad_owner_id IS NOT DISTINCT FROM t.zammad_owner_id
|
|
)
|
|
)
|
|
);
|
|
`);
|
|
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_zammad_owner_ticket ON projects(zammad_owner_id, ticket_id) WHERE user_id IS NULL AND zammad_owner_id IS NOT NULL;");
|
|
await query(`
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1
|
|
FROM pg_constraint
|
|
WHERE conname = 'projects_manual_consumption_non_negative'
|
|
) THEN
|
|
ALTER TABLE projects
|
|
ADD CONSTRAINT projects_manual_consumption_non_negative CHECK (manual_consumption_minutes >= 0);
|
|
END IF;
|
|
END $$;
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS project_teamspace_entries (
|
|
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,
|
|
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
|
|
display_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT project_teamspace_entries_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
|
|
CONSTRAINT project_teamspace_entries_manual_non_negative CHECK (manual_consumption_minutes >= 0)
|
|
);
|
|
`);
|
|
|
|
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
|
|
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;
|
|
|
|
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,
|
|
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
hidden_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (user_id, project_id)
|
|
);
|
|
`);
|
|
await query("CREATE INDEX IF NOT EXISTS idx_hidden_user_projects_project ON hidden_user_projects(project_id, user_id);");
|
|
await query(`
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'sessions_project_teamspace_entry_fkey'
|
|
) THEN
|
|
ALTER TABLE sessions
|
|
ADD CONSTRAINT sessions_project_teamspace_entry_fkey
|
|
FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL;
|
|
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
|
|
SET work_type = 'consulting'
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM sessions s
|
|
WHERE s.ticket_id = t.id
|
|
AND s.work_type = 'consulting'
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
INSERT INTO projects (user_id, zammad_owner_id, ticket_id, teamspace_project, description, goals)
|
|
SELECT DISTINCT
|
|
CASE
|
|
WHEN t.zammad_owner_id IS NOT NULL THEN zammad_owner_user.id
|
|
ELSE s.user_id
|
|
END,
|
|
t.zammad_owner_id,
|
|
s.ticket_id,
|
|
COALESCE(t.title, ''),
|
|
'',
|
|
''
|
|
FROM sessions s
|
|
JOIN tickets t ON t.id = s.ticket_id
|
|
LEFT JOIN users zammad_owner_user
|
|
ON zammad_owner_user.role = 'user'
|
|
AND zammad_owner_user.active
|
|
AND zammad_owner_user.zammad_user_id = t.zammad_owner_id
|
|
WHERE s.work_type = 'consulting'
|
|
AND s.user_id IS NOT NULL
|
|
ON CONFLICT DO NOTHING;
|
|
`);
|
|
|
|
await query(`
|
|
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
|
|
SELECT p.id, p.teamspace_project, true, p.hour_budget_minutes, p.manual_consumption_minutes, 0
|
|
FROM projects p
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM project_teamspace_entries pte
|
|
WHERE pte.project_id = p.id
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
WITH single_entry_projects AS (
|
|
SELECT
|
|
p.id AS project_id,
|
|
p.user_id,
|
|
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
|
|
GROUP BY p.id, p.user_id, p.ticket_id
|
|
HAVING COUNT(pte.id) = 1
|
|
)
|
|
UPDATE sessions s
|
|
SET project_teamspace_entry_id = sep.project_teamspace_entry_id
|
|
FROM single_entry_projects sep
|
|
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;
|
|
`);
|
|
|
|
await query(`
|
|
WITH single_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
|
|
GROUP BY p.ticket_id
|
|
HAVING COUNT(pte.id) = 1
|
|
)
|
|
UPDATE sessions s
|
|
SET project_teamspace_entry_id = single_ticket.project_teamspace_entry_id
|
|
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;
|
|
`);
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS ticket_todos (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
title TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
priority TEXT NOT NULL DEFAULT 'medium',
|
|
status TEXT NOT NULL DEFAULT 'open',
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
starts_on DATE,
|
|
ends_on DATE,
|
|
completed_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT ticket_todos_priority_valid CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
|
|
CONSTRAINT ticket_todos_status_valid CHECK (status IN ('open', 'done')),
|
|
CONSTRAINT ticket_todos_valid_period CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on)
|
|
);
|
|
`);
|
|
|
|
await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS starts_on DATE;");
|
|
await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS ends_on DATE;");
|
|
await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS sort_order INTEGER;");
|
|
await query(`
|
|
WITH ordered_todos AS (
|
|
SELECT
|
|
id,
|
|
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS row_number
|
|
FROM ticket_todos
|
|
WHERE sort_order IS NULL
|
|
)
|
|
UPDATE ticket_todos tt
|
|
SET sort_order = ordered_todos.row_number * 1024
|
|
FROM ordered_todos
|
|
WHERE tt.id = ordered_todos.id;
|
|
`);
|
|
await query("ALTER TABLE ticket_todos ALTER COLUMN sort_order SET DEFAULT 0;");
|
|
await query("ALTER TABLE ticket_todos ALTER COLUMN sort_order SET NOT NULL;");
|
|
await query(`
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1
|
|
FROM pg_constraint
|
|
WHERE conname = 'ticket_todos_valid_period'
|
|
) THEN
|
|
ALTER TABLE ticket_todos
|
|
ADD CONSTRAINT ticket_todos_valid_period CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on);
|
|
END IF;
|
|
END $$;
|
|
`);
|
|
|
|
await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
|
|
await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
|
|
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId);
|
|
await ensureClosureOwnership("day_closures", "day", ["day"], adminId);
|
|
|
|
await query("CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_sessions_ticket_started ON sessions(ticket_id, started_at);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_started ON sessions(user_id, started_at);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_ticket_started ON sessions(user_id, ticket_id, started_at);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_organization_started ON sessions(user_id, organization_id, started_at);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_sessions_project_teamspace_entry ON sessions(project_teamspace_entry_id);");
|
|
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_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);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_closed_updated ON projects(user_id, closed_at, updated_at DESC);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_status_priority ON ticket_todos(user_id, status, priority);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_period ON ticket_todos(user_id, starts_on, ends_on);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_sort_order ON ticket_todos(user_id, sort_order, id);");
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS project_followup_reminders (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
todo_id BIGINT REFERENCES ticket_todos(id) ON DELETE SET NULL,
|
|
stage INTEGER NOT NULL DEFAULT 1,
|
|
latest_tt_update_at TIMESTAMPTZ NOT NULL,
|
|
due_on DATE NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2))
|
|
);
|
|
`);
|
|
await query("ALTER TABLE project_followup_reminders ADD COLUMN IF NOT EXISTS stage INTEGER NOT NULL DEFAULT 1;");
|
|
await query(`
|
|
ALTER TABLE project_followup_reminders
|
|
DROP CONSTRAINT IF EXISTS project_followup_reminders_stage_valid,
|
|
ADD CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2));
|
|
`);
|
|
await query(`
|
|
DO $$
|
|
DECLARE
|
|
constraint_name text;
|
|
BEGIN
|
|
SELECT c.conname
|
|
INTO constraint_name
|
|
FROM pg_constraint c
|
|
WHERE c.conrelid = 'project_followup_reminders'::regclass
|
|
AND c.contype = 'u'
|
|
AND c.conkey = ARRAY[
|
|
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'user_id'),
|
|
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'project_id'),
|
|
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'latest_tt_update_at')
|
|
]::smallint[];
|
|
|
|
IF constraint_name IS NOT NULL THEN
|
|
EXECUTE format('ALTER TABLE project_followup_reminders DROP CONSTRAINT %I', constraint_name);
|
|
END IF;
|
|
END $$;
|
|
`);
|
|
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_followup_reminders_unique_stage ON project_followup_reminders(user_id, project_id, latest_tt_update_at, stage);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_user_project ON project_followup_reminders(user_id, project_id, latest_tt_update_at DESC);");
|
|
await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_todo ON project_followup_reminders(todo_id) WHERE todo_id IS NOT NULL;");
|
|
|
|
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
|
|
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
|
|
await query(`
|
|
WITH deleted_orphan_sessions AS (
|
|
DELETE FROM sessions s
|
|
WHERE s.recurring_occurrence_key IS NOT NULL
|
|
AND split_part(s.recurring_occurrence_key, ':', 1) ~ '^[0-9]+$'
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM recurring_billings rb
|
|
WHERE rb.id = split_part(s.recurring_occurrence_key, ':', 1)::bigint
|
|
)
|
|
RETURNING
|
|
ticket_id,
|
|
user_id,
|
|
(started_at AT TIME ZONE 'UTC')::date AS day,
|
|
date_trunc('month', started_at AT TIME ZONE 'UTC')::date AS month
|
|
),
|
|
deleted_ticket_month_closures AS (
|
|
DELETE FROM ticket_month_closures tmc
|
|
USING deleted_orphan_sessions dos
|
|
WHERE tmc.ticket_id = dos.ticket_id
|
|
AND tmc.user_id = dos.user_id
|
|
AND tmc.month = dos.month
|
|
RETURNING tmc.ticket_id
|
|
),
|
|
deleted_month_closures AS (
|
|
DELETE FROM month_closures mc
|
|
USING deleted_orphan_sessions dos
|
|
WHERE mc.user_id = dos.user_id
|
|
AND mc.month = dos.month
|
|
RETURNING mc.user_id
|
|
),
|
|
deleted_ticket_day_closures AS (
|
|
DELETE FROM ticket_day_closures tdc
|
|
USING deleted_orphan_sessions dos
|
|
WHERE tdc.ticket_id = dos.ticket_id
|
|
AND tdc.user_id = dos.user_id
|
|
AND tdc.day = dos.day
|
|
RETURNING tdc.ticket_id
|
|
),
|
|
deleted_day_closures AS (
|
|
DELETE FROM day_closures dc
|
|
USING deleted_orphan_sessions dos
|
|
WHERE dc.user_id = dos.user_id
|
|
AND dc.day = dos.day
|
|
RETURNING dc.user_id
|
|
)
|
|
DELETE FROM tickets t
|
|
WHERE t.id IN (SELECT ticket_id FROM deleted_orphan_sessions)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM sessions s
|
|
WHERE s.ticket_id = t.id
|
|
);
|
|
`);
|
|
await query(`
|
|
DELETE FROM tickets t
|
|
WHERE t.ticket_number ~ '^Fix#[0-9]+$'
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM sessions s
|
|
WHERE s.ticket_id = t.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM recurring_billings rb
|
|
WHERE rb.id = substring(t.ticket_number FROM 5)::bigint
|
|
);
|
|
`);
|
|
|
|
await query(`
|
|
UPDATE tickets t
|
|
SET customer_name = latest.customer_name,
|
|
work_type = latest.work_type
|
|
FROM (
|
|
SELECT DISTINCT ON (ticket_id)
|
|
ticket_id,
|
|
customer_name,
|
|
work_type
|
|
FROM sessions
|
|
ORDER BY ticket_id, started_at DESC
|
|
) latest
|
|
WHERE t.id = latest.ticket_id
|
|
AND (t.customer_name IS NULL OR t.work_type IS NULL);
|
|
`);
|
|
}
|