Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
103 lines
3.9 KiB
JavaScript
103 lines
3.9 KiB
JavaScript
/**
|
|
* Deduplicate task_resources rows that share (tenant, task_id, additional_user_id),
|
|
* then enforce uniqueness with an index.
|
|
*
|
|
* Background: assignTeamToProjectTask inserted team members with role='team_member'
|
|
* while TaskForm also pushed those same members through addTaskResourceAction,
|
|
* producing duplicate rows (one with role='team_member', one with NULL role) and
|
|
* double notifications. This cleans existing duplicates and prevents recurrence.
|
|
*
|
|
* Citus notes:
|
|
* - task_resources is distributed by `tenant`. The DELETE is written as DELETE USING
|
|
* with `tenant` on both sides so Citus can co-locate per shard.
|
|
* - The unique index leads with `tenant`, satisfying Citus's distributed-uniqueness
|
|
* requirement.
|
|
* - Migration is idempotent: re-running after a partial completion produces the same
|
|
* final state.
|
|
*
|
|
* @param { import("knex").Knex } knex
|
|
* @returns { Promise<void> }
|
|
*/
|
|
exports.up = async function up(knex) {
|
|
// 1. Audit: how many duplicate groups exist before we touch anything.
|
|
const beforeResult = await knex.raw(`
|
|
SELECT COUNT(*)::int AS dup_groups,
|
|
COALESCE(SUM(extras), 0)::int AS extra_rows
|
|
FROM (
|
|
SELECT COUNT(*) - 1 AS extras
|
|
FROM task_resources
|
|
WHERE additional_user_id IS NOT NULL
|
|
GROUP BY tenant, task_id, additional_user_id
|
|
HAVING COUNT(*) > 1
|
|
) g;
|
|
`);
|
|
const beforeRow = beforeResult.rows[0] || { dup_groups: 0, extra_rows: 0 };
|
|
console.log(
|
|
`[dedupe_task_resources] Found ${beforeRow.dup_groups} duplicate group(s) ` +
|
|
`comprising ${beforeRow.extra_rows} extra row(s) to delete.`
|
|
);
|
|
|
|
// 2. Delete extra rows. Per-(tenant, task_id, additional_user_id) keep the row
|
|
// with a non-null role (so 'team_member' wins over NULL), then earliest
|
|
// assigned_at as the tiebreaker. The USING subquery includes `tenant` so the
|
|
// join is co-located on Citus shards.
|
|
if (beforeRow.extra_rows > 0) {
|
|
const deleteResult = await knex.raw(`
|
|
DELETE FROM task_resources tr
|
|
USING (
|
|
SELECT tenant, assignment_id,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY tenant, task_id, additional_user_id
|
|
ORDER BY
|
|
CASE WHEN role IS NOT NULL THEN 0 ELSE 1 END,
|
|
assigned_at ASC
|
|
) AS rn
|
|
FROM task_resources
|
|
WHERE additional_user_id IS NOT NULL
|
|
) ranked
|
|
WHERE tr.tenant = ranked.tenant
|
|
AND tr.assignment_id = ranked.assignment_id
|
|
AND ranked.rn > 1;
|
|
`);
|
|
console.log(
|
|
`[dedupe_task_resources] Deleted ${deleteResult.rowCount ?? 'unknown'} duplicate row(s).`
|
|
);
|
|
}
|
|
|
|
// 3. Verify no duplicates remain before adding the constraint.
|
|
const afterResult = await knex.raw(`
|
|
SELECT COUNT(*)::int AS dup_groups
|
|
FROM (
|
|
SELECT 1
|
|
FROM task_resources
|
|
WHERE additional_user_id IS NOT NULL
|
|
GROUP BY tenant, task_id, additional_user_id
|
|
HAVING COUNT(*) > 1
|
|
) g;
|
|
`);
|
|
const remaining = afterResult.rows[0]?.dup_groups ?? 0;
|
|
if (remaining > 0) {
|
|
throw new Error(
|
|
`[dedupe_task_resources] ${remaining} duplicate group(s) remain after dedupe; aborting before index creation.`
|
|
);
|
|
}
|
|
|
|
// 4. Add the unique index. Partial WHERE excludes any legacy NULL additional_user_id
|
|
// rows so they don't collide. CREATE INDEX (non-concurrent) is fine for a small
|
|
// table per tenant; Knex's transaction also forbids CONCURRENTLY here.
|
|
await knex.raw(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS task_resources_tenant_task_user_unique
|
|
ON task_resources (tenant, task_id, additional_user_id)
|
|
WHERE additional_user_id IS NOT NULL;
|
|
`);
|
|
console.log('[dedupe_task_resources] Unique index task_resources_tenant_task_user_unique created.');
|
|
};
|
|
|
|
/**
|
|
* @param { import("knex").Knex } knex
|
|
* @returns { Promise<void> }
|
|
*/
|
|
exports.down = async function down(knex) {
|
|
await knex.raw(`DROP INDEX IF EXISTS task_resources_tenant_task_user_unique;`);
|
|
};
|