PSA/server/migrations/20251214120000_time_entry_work_date.cjs
Hermes 284313f908
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
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

227 lines
7.8 KiB
JavaScript

/**
* Time Entry work_date/work_timezone + normalize time_periods boundaries to DATE.
*
* Rationale:
* - Timesheets/period membership should be calendar-date based (user-local), not instant based.
* - Persist work_date (DATE) computed from start_time in the user's timezone.
* - Persist work_timezone (IANA tz) used for computation for audit/debug.
* - Store time_periods.start_date/end_date as DATE to avoid server timezone ambiguity.
*
* Note: CitusDB requires special handling for distributed tables:
* - Use run_command_on_shards for SET NOT NULL
* - Update pg_attribute for coordinator metadata
*/
// Disable transaction wrapping - CitusDB DDL needs to commit before DML can see new columns on shards
exports.config = { transaction: false };
/**
* Check if a table is distributed in Citus.
*/
async function isCitusDistributedTable(knex, tableName) {
try {
const result = await knex.raw(`
SELECT EXISTS (
SELECT 1 FROM pg_dist_partition WHERE logicalrelid = ?::regclass
) as is_distributed
`, [tableName]);
return result.rows[0]?.is_distributed === true;
} catch (error) {
// pg_dist_partition doesn't exist - standard PostgreSQL
return false;
}
}
/**
* Wait for Citus propagation.
*/
async function waitForCitusPropagation(ms, message) {
console.log(message);
await new Promise(resolve => setTimeout(resolve, ms));
}
exports.up = async function up(knex) {
const hasWorkDate = await knex.schema.hasColumn('time_entries', 'work_date');
const hasWorkTimezone = await knex.schema.hasColumn('time_entries', 'work_timezone');
if (!hasWorkDate || !hasWorkTimezone) {
await knex.schema.alterTable('time_entries', function (table) {
if (!hasWorkDate) table.date('work_date').nullable();
if (!hasWorkTimezone) table.text('work_timezone').nullable();
});
}
// Normalize time_periods boundaries to DATE (timezone-agnostic).
// Use an explicit UTC interpretation for safety (regardless of current column type).
await knex.raw(`
ALTER TABLE time_periods
ALTER COLUMN start_date TYPE date USING ((start_date AT TIME ZONE 'UTC')::date),
ALTER COLUMN end_date TYPE date USING ((end_date AT TIME ZONE 'UTC')::date)
`);
// Get all tenants with time entries that need updating
const tenantsResult = await knex.raw(`
SELECT DISTINCT tenant FROM time_entries
WHERE work_date IS NULL OR work_timezone IS NULL
`);
const tenants = tenantsResult.rows.map(r => r.tenant);
// Process each tenant separately to ensure CitusDB routes to correct shards.
// We use string interpolation with explicit UUID cast instead of ? parameters
// to avoid potential Knex/pg UUID binding issues.
for (const tenant of tenants) {
const tenantStr = String(tenant);
// Step 1: Update entries where user has a timezone set
await knex.raw(`
UPDATE time_entries te
SET
work_timezone = u.timezone,
work_date = (te.start_time AT TIME ZONE u.timezone)::date
FROM users u
WHERE te.tenant = '${tenantStr}'::uuid
AND te.tenant = u.tenant
AND te.user_id = u.user_id
AND u.timezone IS NOT NULL
AND te.start_time IS NOT NULL
AND (te.work_date IS NULL OR te.work_timezone IS NULL)
`);
// Step 2: Update entries where user has no timezone (use UTC)
await knex.raw(`
UPDATE time_entries te
SET
work_timezone = 'UTC',
work_date = (te.start_time AT TIME ZONE 'UTC')::date
FROM users u
WHERE te.tenant = '${tenantStr}'::uuid
AND te.tenant = u.tenant
AND te.user_id = u.user_id
AND u.timezone IS NULL
AND te.start_time IS NOT NULL
AND (te.work_date IS NULL OR te.work_timezone IS NULL)
`);
// Step 3: Fallback for entries with start_time but no matching user (use UTC)
await knex.raw(`
UPDATE time_entries
SET
work_timezone = 'UTC',
work_date = (start_time AT TIME ZONE 'UTC')::date
WHERE tenant = '${tenantStr}'::uuid
AND start_time IS NOT NULL
AND (work_date IS NULL OR work_timezone IS NULL)
`);
// Step 4: Final fallback - derive from created_at if start_time is somehow NULL
await knex.raw(`
UPDATE time_entries
SET
work_timezone = 'UTC',
work_date = (created_at AT TIME ZONE 'UTC')::date
WHERE tenant = '${tenantStr}'::uuid
AND start_time IS NULL
AND created_at IS NOT NULL
AND (work_date IS NULL OR work_timezone IS NULL)
`);
// Step 5: Ultimate fallback for this tenant - use current date
await knex.raw(`
UPDATE time_entries
SET
work_timezone = 'UTC',
work_date = CURRENT_DATE
WHERE tenant = '${tenantStr}'::uuid
AND (work_date IS NULL OR work_timezone IS NULL)
`);
}
// Wait for Citus to propagate changes across all shards
await waitForCitusPropagation(3000, 'Waiting for distributed changes to propagate...');
// Verify no NULLs remain before setting NOT NULL
const nullCheck = await knex.raw(`
SELECT COUNT(*) as cnt FROM time_entries
WHERE work_date IS NULL OR work_timezone IS NULL
`);
const nullCount = parseInt(nullCheck.rows[0].cnt, 10);
if (nullCount > 0) {
throw new Error(`Migration failed: ${nullCount} time_entries still have NULL work_date/work_timezone`);
}
// Check if time_entries is a Citus distributed table
const isCitusDistributed = await isCitusDistributedTable(knex, 'time_entries');
if (isCitusDistributed) {
console.log('Detected Citus distributed table, setting NOT NULL on all shards...');
// Set NOT NULL on all shards first
await knex.raw(`
SELECT * FROM run_command_on_shards(
'time_entries',
$$ALTER TABLE %s ALTER COLUMN work_date SET NOT NULL$$
)
`);
await knex.raw(`
SELECT * FROM run_command_on_shards(
'time_entries',
$$ALTER TABLE %s ALTER COLUMN work_timezone SET NOT NULL$$
)
`);
console.log('✅ Set NOT NULL on all shards');
// Update coordinator metadata
await knex.raw(`
UPDATE pg_attribute
SET attnotnull = true
WHERE attrelid = 'time_entries'::regclass
AND attname = 'work_date'
AND attnotnull = false
`);
await knex.raw(`
UPDATE pg_attribute
SET attnotnull = true
WHERE attrelid = 'time_entries'::regclass
AND attname = 'work_timezone'
AND attnotnull = false
`);
console.log('✅ Updated coordinator metadata');
} else {
// Standard PostgreSQL
await knex.raw(`ALTER TABLE time_entries ALTER COLUMN work_date SET NOT NULL`);
await knex.raw(`ALTER TABLE time_entries ALTER COLUMN work_timezone SET NOT NULL`);
}
// Indexes for common access patterns (include tenant first for shard pruning / multi-tenancy).
await knex.raw(`
CREATE INDEX IF NOT EXISTS time_entries_tenant_user_work_date_idx
ON time_entries (tenant, user_id, work_date)
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS time_entries_tenant_work_date_idx
ON time_entries (tenant, work_date)
`);
};
exports.down = async function down(knex) {
await knex.raw(`DROP INDEX IF EXISTS time_entries_tenant_user_work_date_idx`);
await knex.raw(`DROP INDEX IF EXISTS time_entries_tenant_work_date_idx`);
const hasWorkDate = await knex.schema.hasColumn('time_entries', 'work_date');
const hasWorkTimezone = await knex.schema.hasColumn('time_entries', 'work_timezone');
if (hasWorkDate || hasWorkTimezone) {
await knex.schema.alterTable('time_entries', function (table) {
if (hasWorkDate) table.dropColumn('work_date');
if (hasWorkTimezone) table.dropColumn('work_timezone');
});
}
// Revert time_periods boundaries back to timestamp (historically used).
await knex.raw(`
ALTER TABLE time_periods
ALTER COLUMN start_date TYPE timestamp USING (start_date::timestamp),
ALTER COLUMN end_date TYPE timestamp USING (end_date::timestamp)
`);
};