1.1.0-rc1

This commit is contained in:
michael committed 2026-10-13 10:43:53 +02:00
1 parent 649dd6f028
commit 45ff46b7eb
26 files changed
+4752 -1108

No files matched your search

+5
View File
@@ -15,7 +15,12 @@ export const databaseExportTables = [
"organizations",
"tickets",
"projects",
"teamspace_project_resources",
"project_teamspace_entries",
"teamspace_project_exclusions",
"teamspace_monthly_user_actuals",
"project_teamspace_monthly_actuals",
"project_teamspace_subentries",
"hidden_user_projects",
"ticket_todos",
"recurring_billings",
+2341 -51
View File
File diff suppressed because it is too large. Load diff
+564
View File
@@ -182,6 +182,41 @@ export async function migrate() {
);
`);
await query(`
CREATE TABLE IF NOT EXISTS teamspace_project_exclusions (
teamspace_project TEXT PRIMARY KEY,
teamspace_name TEXT,
customer_name TEXT,
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_name TEXT;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS customer_name TEXT;");
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_project_resources (
id BIGSERIAL PRIMARY KEY,
teamspace_project TEXT NOT NULL,
teamspace_api_project_id BIGINT,
teamspace_api_project_url TEXT,
hour_budget_minutes INTEGER,
budget_sync_disabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT teamspace_project_resources_project_not_blank CHECK (btrim(teamspace_project) <> ''),
CONSTRAINT teamspace_project_resources_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0)
);
`);
await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS teamspace_api_project_id BIGINT;");
await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS teamspace_api_project_url TEXT;");
await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS hour_budget_minutes INTEGER;");
await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_teamspace_project_resources_lower ON teamspace_project_resources (lower(btrim(teamspace_project)));");
await encryptStoredApiSecrets();
await query(`
@@ -864,9 +899,154 @@ export async function migrate() {
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("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_project_resource_id BIGINT REFERENCES teamspace_project_resources(id) ON DELETE SET NULL;");
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 INDEX IF NOT EXISTS idx_project_teamspace_entries_resource ON project_teamspace_entries(teamspace_project_resource_id) WHERE teamspace_project_resource_id IS NOT NULL;");
await query(`
INSERT INTO teamspace_project_resources (
teamspace_project,
teamspace_api_project_id,
teamspace_api_project_url,
hour_budget_minutes,
budget_sync_disabled,
updated_at
)
SELECT
canonical.teamspace_project,
canonical.teamspace_api_project_id,
canonical.teamspace_api_project_url,
canonical.hour_budget_minutes,
canonical.budget_sync_disabled,
now()
FROM (
SELECT DISTINCT ON (lower(btrim(teamspace_project)))
btrim(teamspace_project) AS teamspace_project,
teamspace_api_project_id,
teamspace_api_project_url,
hour_budget_minutes,
COALESCE(budget_sync_disabled, false) AS budget_sync_disabled
FROM project_teamspace_entries
WHERE teamspace_project IS NOT NULL
AND btrim(teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project))
)
ORDER BY
lower(btrim(teamspace_project)),
(teamspace_api_project_id IS NOT NULL) DESC,
(teamspace_api_project_url IS NOT NULL) DESC,
(hour_budget_minutes IS NOT NULL) DESC,
updated_at DESC,
id DESC
) canonical
WHERE NOT EXISTS (
SELECT 1
FROM teamspace_project_resources existing
WHERE lower(btrim(existing.teamspace_project)) = lower(btrim(canonical.teamspace_project))
);
`);
await query(`
UPDATE project_teamspace_entries pte
SET teamspace_project_resource_id = resource.id,
updated_at = CASE
WHEN pte.teamspace_project_resource_id IS DISTINCT FROM resource.id THEN now()
ELSE pte.updated_at
END
FROM teamspace_project_resources resource
WHERE pte.teamspace_project IS NOT NULL
AND btrim(pte.teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project))
)
AND lower(btrim(resource.teamspace_project)) = lower(btrim(pte.teamspace_project))
AND pte.teamspace_project_resource_id IS DISTINCT FROM resource.id;
`);
await query(`
UPDATE project_teamspace_entries pte
SET teamspace_project_resource_id = NULL,
updated_at = now()
WHERE EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project))
)
AND pte.teamspace_project_resource_id IS NOT NULL;
`);
await query(`
DELETE FROM teamspace_project_resources resource
WHERE EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(resource.teamspace_project))
);
`);
await query(`
UPDATE project_teamspace_entries pte
SET teamspace_api_project_id = COALESCE(resource.teamspace_api_project_id, pte.teamspace_api_project_id),
teamspace_api_project_url = COALESCE(resource.teamspace_api_project_url, pte.teamspace_api_project_url),
hour_budget_minutes = COALESCE(resource.hour_budget_minutes, pte.hour_budget_minutes),
budget_sync_disabled = resource.budget_sync_disabled,
updated_at = CASE
WHEN pte.teamspace_api_project_id IS DISTINCT FROM COALESCE(resource.teamspace_api_project_id, pte.teamspace_api_project_id)
OR pte.teamspace_api_project_url IS DISTINCT FROM COALESCE(resource.teamspace_api_project_url, pte.teamspace_api_project_url)
OR pte.hour_budget_minutes IS DISTINCT FROM COALESCE(resource.hour_budget_minutes, pte.hour_budget_minutes)
OR pte.budget_sync_disabled IS DISTINCT FROM resource.budget_sync_disabled
THEN now()
ELSE pte.updated_at
END
FROM teamspace_project_resources resource
WHERE pte.teamspace_project_resource_id = resource.id
AND pte.teamspace_project IS NOT NULL
AND btrim(pte.teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project))
);
`);
await query(`
CREATE TABLE IF NOT EXISTS project_teamspace_monthly_actuals (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_teamspace_entry_id BIGINT NOT NULL REFERENCES project_teamspace_entries(id) ON DELETE CASCADE,
month DATE NOT NULL,
teamspace_minutes INTEGER NOT NULL DEFAULT 0,
teamspace_billable_minutes INTEGER NOT NULL DEFAULT 0,
teamspace_time_entry_count INTEGER NOT NULL DEFAULT 0,
positions JSONB NOT NULL DEFAULT '[]'::jsonb,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, project_teamspace_entry_id, month),
CONSTRAINT project_teamspace_monthly_actuals_minutes_nonnegative CHECK (teamspace_minutes >= 0),
CONSTRAINT project_teamspace_monthly_actuals_billable_nonnegative CHECK (teamspace_billable_minutes >= 0),
CONSTRAINT project_teamspace_monthly_actuals_count_nonnegative CHECK (teamspace_time_entry_count >= 0)
);
`);
await query("ALTER TABLE project_teamspace_monthly_actuals ADD COLUMN IF NOT EXISTS positions JSONB NOT NULL DEFAULT '[]'::jsonb;");
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_monthly_actuals_entry_month ON project_teamspace_monthly_actuals(project_teamspace_entry_id, month);");
await query(`
CREATE TABLE IF NOT EXISTS teamspace_monthly_user_actuals (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
month DATE NOT NULL,
teamspace_minutes INTEGER NOT NULL DEFAULT 0,
teamspace_billable_minutes INTEGER NOT NULL DEFAULT 0,
teamspace_time_entry_count INTEGER NOT NULL DEFAULT 0,
positions JSONB NOT NULL DEFAULT '[]'::jsonb,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, month),
CONSTRAINT teamspace_monthly_user_actuals_minutes_nonnegative CHECK (teamspace_minutes >= 0),
CONSTRAINT teamspace_monthly_user_actuals_billable_nonnegative CHECK (teamspace_billable_minutes >= 0),
CONSTRAINT teamspace_monthly_user_actuals_count_nonnegative CHECK (teamspace_time_entry_count >= 0)
);
`);
await query(`
CREATE TABLE IF NOT EXISTS project_teamspace_subentries (
id BIGSERIAL PRIMARY KEY,
@@ -940,6 +1120,390 @@ 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 duplicate_keys AS (
SELECT lower(btrim(teamspace_project)) AS teamspace_key
FROM project_teamspace_entries
WHERE teamspace_project IS NOT NULL
AND btrim(teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project))
)
GROUP BY lower(btrim(teamspace_project))
HAVING COUNT(*) > 1
),
canonical AS (
SELECT DISTINCT ON (lower(btrim(pte.teamspace_project)))
lower(btrim(pte.teamspace_project)) AS teamspace_key,
pte.teamspace_api_project_id,
pte.teamspace_api_project_url,
pte.hour_budget_minutes,
COALESCE(pte.budget_sync_disabled, false) AS budget_sync_disabled
FROM project_teamspace_entries pte
JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project))
ORDER BY
lower(btrim(pte.teamspace_project)),
(pte.teamspace_api_project_id IS NOT NULL) DESC,
(pte.teamspace_api_project_url IS NOT NULL) DESC,
(pte.hour_budget_minutes IS NOT NULL) DESC,
pte.updated_at DESC,
pte.id DESC
)
UPDATE project_teamspace_entries target
SET teamspace_api_project_id = COALESCE(canonical.teamspace_api_project_id, target.teamspace_api_project_id),
teamspace_api_project_url = COALESCE(canonical.teamspace_api_project_url, target.teamspace_api_project_url),
hour_budget_minutes = COALESCE(canonical.hour_budget_minutes, target.hour_budget_minutes),
budget_sync_disabled = canonical.budget_sync_disabled,
updated_at = CASE
WHEN target.teamspace_api_project_id IS DISTINCT FROM COALESCE(canonical.teamspace_api_project_id, target.teamspace_api_project_id)
OR target.teamspace_api_project_url IS DISTINCT FROM COALESCE(canonical.teamspace_api_project_url, target.teamspace_api_project_url)
OR target.hour_budget_minutes IS DISTINCT FROM COALESCE(canonical.hour_budget_minutes, target.hour_budget_minutes)
OR target.budget_sync_disabled IS DISTINCT FROM canonical.budget_sync_disabled
THEN now()
ELSE target.updated_at
END
FROM canonical
WHERE target.teamspace_project IS NOT NULL
AND btrim(target.teamspace_project) <> ''
AND lower(btrim(target.teamspace_project)) = canonical.teamspace_key;
`);
await query(`
WITH duplicate_keys AS (
SELECT lower(btrim(teamspace_project)) AS teamspace_key
FROM project_teamspace_entries
WHERE teamspace_project IS NOT NULL
AND btrim(teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project))
)
GROUP BY lower(btrim(teamspace_project))
HAVING COUNT(*) > 1
),
entry_keys AS (
SELECT
pte.id AS entry_id,
p.user_id,
lower(btrim(pte.teamspace_project)) AS teamspace_key,
pte.teamspace_api_project_id,
pte.updated_at
FROM project_teamspace_entries pte
JOIN projects p ON p.id = pte.project_id
JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project))
WHERE p.user_id IS NOT NULL
),
canonical_entries AS (
SELECT DISTINCT ON (entry_keys.user_id, entry_keys.teamspace_key)
entry_keys.user_id,
entry_keys.teamspace_key,
entry_keys.entry_id
FROM entry_keys
ORDER BY
entry_keys.user_id,
entry_keys.teamspace_key,
EXISTS (
SELECT 1
FROM project_teamspace_subentries pts
WHERE pts.user_id = entry_keys.user_id
AND pts.project_teamspace_entry_id = entry_keys.entry_id
) DESC,
(entry_keys.teamspace_api_project_id IS NOT NULL) DESC,
entry_keys.updated_at DESC,
entry_keys.entry_id DESC
),
targets AS (
SELECT
entry_keys.entry_id AS target_entry_id,
entry_keys.user_id,
canonical_entries.entry_id AS source_entry_id
FROM entry_keys
JOIN canonical_entries
ON canonical_entries.user_id = entry_keys.user_id
AND canonical_entries.teamspace_key = entry_keys.teamspace_key
WHERE entry_keys.entry_id <> canonical_entries.entry_id
),
deleted AS (
DELETE FROM project_teamspace_subentries pts
USING targets
WHERE pts.user_id = targets.user_id
AND pts.project_teamspace_entry_id = targets.target_entry_id
)
INSERT INTO project_teamspace_subentries (
user_id,
project_teamspace_entry_id,
teamspace_api_project_id,
teamspace_api_project_url,
teamspace_project_id,
billing_type,
name,
alias,
description,
progress,
planned_minutes,
sum_minutes,
planned_minutes_billable,
sum_minutes_billable,
planned_income,
sum_income,
display_order,
synced_at,
updated_at
)
SELECT
targets.user_id,
targets.target_entry_id,
source.teamspace_api_project_id,
source.teamspace_api_project_url,
source.teamspace_project_id,
source.billing_type,
source.name,
source.alias,
source.description,
source.progress,
source.planned_minutes,
source.sum_minutes,
source.planned_minutes_billable,
source.sum_minutes_billable,
source.planned_income,
source.sum_income,
source.display_order,
source.synced_at,
now()
FROM targets
JOIN project_teamspace_subentries source
ON source.user_id = targets.user_id
AND source.project_teamspace_entry_id = targets.source_entry_id
ON CONFLICT (user_id, project_teamspace_entry_id, teamspace_api_project_id)
DO UPDATE SET
teamspace_api_project_url = EXCLUDED.teamspace_api_project_url,
teamspace_project_id = EXCLUDED.teamspace_project_id,
billing_type = EXCLUDED.billing_type,
name = EXCLUDED.name,
alias = EXCLUDED.alias,
description = EXCLUDED.description,
progress = EXCLUDED.progress,
planned_minutes = EXCLUDED.planned_minutes,
sum_minutes = EXCLUDED.sum_minutes,
planned_minutes_billable = EXCLUDED.planned_minutes_billable,
sum_minutes_billable = EXCLUDED.sum_minutes_billable,
planned_income = EXCLUDED.planned_income,
sum_income = EXCLUDED.sum_income,
display_order = EXCLUDED.display_order,
synced_at = EXCLUDED.synced_at,
updated_at = now();
`);
await query(`
WITH shared_resources AS (
SELECT pte.teamspace_project_resource_id AS resource_id
FROM project_teamspace_entries pte
WHERE pte.teamspace_project_resource_id IS NOT NULL
AND pte.teamspace_project IS NOT NULL
AND btrim(pte.teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project))
)
GROUP BY pte.teamspace_project_resource_id
HAVING COUNT(*) > 1
),
resource_entries AS (
SELECT
pte.id AS entry_id,
pte.teamspace_project_resource_id AS resource_id,
p.user_id,
p.ticket_id,
pte.is_default,
pte.updated_at
FROM project_teamspace_entries pte
JOIN projects p ON p.id = pte.project_id
JOIN shared_resources sr ON sr.resource_id = pte.teamspace_project_resource_id
WHERE p.user_id IS NOT NULL
),
canonical_entries AS (
SELECT DISTINCT ON (resource_id, user_id)
resource_id,
user_id,
entry_id
FROM resource_entries
ORDER BY
resource_id,
user_id,
EXISTS (
SELECT 1
FROM project_teamspace_subentries pts
WHERE pts.user_id = resource_entries.user_id
AND pts.project_teamspace_entry_id = resource_entries.entry_id
) DESC,
updated_at DESC,
entry_id DESC
),
resource_consumption AS (
SELECT
ce.resource_id,
ce.user_id,
COALESCE(SUM(
CASE
WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX'
THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0)
ELSE 0
END
), 0)::int AS shared_consumption_minutes
FROM canonical_entries ce
JOIN project_teamspace_subentries pts
ON pts.project_teamspace_entry_id = ce.entry_id
AND pts.user_id = ce.user_id
GROUP BY ce.resource_id, ce.user_id
),
local_day_values AS (
SELECT
re.entry_id,
COALESCE(SUM(tdb.billed_minutes), 0)::int AS local_day_minutes
FROM resource_entries re
LEFT JOIN ticket_day_billings tdb
ON tdb.ticket_id = re.ticket_id
AND tdb.user_id = re.user_id
AND (
tdb.project_teamspace_entry_id = re.entry_id
OR (re.is_default AND tdb.project_teamspace_entry_id IS NULL)
)
GROUP BY re.entry_id
)
UPDATE project_teamspace_entries pte
SET manual_consumption_minutes = GREATEST(resource_consumption.shared_consumption_minutes - local_day_values.local_day_minutes, 0),
updated_at = CASE
WHEN pte.manual_consumption_minutes IS DISTINCT FROM GREATEST(resource_consumption.shared_consumption_minutes - local_day_values.local_day_minutes, 0)
THEN now()
ELSE pte.updated_at
END
FROM resource_entries re
JOIN resource_consumption
ON resource_consumption.resource_id = re.resource_id
AND resource_consumption.user_id = re.user_id
JOIN local_day_values ON local_day_values.entry_id = re.entry_id
WHERE pte.id = re.entry_id
AND resource_consumption.shared_consumption_minutes > 0;
`);
await query(`
WITH duplicate_keys AS (
SELECT lower(btrim(teamspace_project)) AS teamspace_key
FROM project_teamspace_entries
WHERE teamspace_project IS NOT NULL
AND btrim(teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project))
)
GROUP BY lower(btrim(teamspace_project))
HAVING COUNT(*) > 1
),
entry_keys AS (
SELECT
pte.id AS entry_id,
p.user_id,
lower(btrim(pte.teamspace_project)) AS teamspace_key
FROM project_teamspace_entries pte
JOIN projects p ON p.id = pte.project_id
JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project))
WHERE p.user_id IS NOT NULL
),
canonical_actuals AS (
SELECT DISTINCT ON (ptma.user_id, entry_keys.teamspace_key, ptma.month)
ptma.user_id,
entry_keys.teamspace_key,
ptma.month,
ptma.teamspace_minutes,
ptma.teamspace_billable_minutes,
ptma.teamspace_time_entry_count,
ptma.positions,
ptma.synced_at
FROM project_teamspace_monthly_actuals ptma
JOIN entry_keys ON entry_keys.entry_id = ptma.project_teamspace_entry_id
ORDER BY
ptma.user_id,
entry_keys.teamspace_key,
ptma.month,
ptma.synced_at DESC,
ptma.updated_at DESC,
ptma.project_teamspace_entry_id DESC
)
INSERT INTO project_teamspace_monthly_actuals (
user_id,
project_teamspace_entry_id,
month,
teamspace_minutes,
teamspace_billable_minutes,
teamspace_time_entry_count,
positions,
synced_at,
updated_at
)
SELECT
canonical_actuals.user_id,
entry_keys.entry_id,
canonical_actuals.month,
canonical_actuals.teamspace_minutes,
canonical_actuals.teamspace_billable_minutes,
canonical_actuals.teamspace_time_entry_count,
canonical_actuals.positions,
canonical_actuals.synced_at,
now()
FROM canonical_actuals
JOIN entry_keys
ON entry_keys.user_id = canonical_actuals.user_id
AND entry_keys.teamspace_key = canonical_actuals.teamspace_key
ON CONFLICT (user_id, project_teamspace_entry_id, month)
DO UPDATE SET
teamspace_minutes = EXCLUDED.teamspace_minutes,
teamspace_billable_minutes = EXCLUDED.teamspace_billable_minutes,
teamspace_time_entry_count = EXCLUDED.teamspace_time_entry_count,
positions = EXCLUDED.positions,
synced_at = EXCLUDED.synced_at,
updated_at = now();
`);
await query(`
WITH duplicate_keys AS (
SELECT lower(btrim(teamspace_project)) AS teamspace_key
FROM project_teamspace_entries
WHERE teamspace_project IS NOT NULL
AND btrim(teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project))
)
GROUP BY lower(btrim(teamspace_project))
HAVING COUNT(*) > 1
),
affected_projects AS (
SELECT DISTINCT pte.project_id
FROM project_teamspace_entries pte
JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project))
),
entry_budget AS (
SELECT
pte.project_id,
COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes,
COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS manual_consumption_minutes
FROM project_teamspace_entries pte
JOIN affected_projects affected ON affected.project_id = pte.project_id
GROUP BY pte.project_id
)
UPDATE projects p
SET hour_budget_minutes = entry_budget.hour_budget_minutes,
manual_consumption_minutes = entry_budget.manual_consumption_minutes,
updated_at = now()
FROM entry_budget
WHERE p.id = entry_budget.project_id
AND (
p.hour_budget_minutes IS DISTINCT FROM entry_budget.hour_budget_minutes
OR p.manual_consumption_minutes IS DISTINCT FROM entry_budget.manual_consumption_minutes
);
`);
await query(`
DO $$
BEGIN