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
227 lines
7.8 KiB
JavaScript
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)
|
|
`);
|
|
};
|
|
|