fix: add required filters for billing items and validate task project references

- Add buildBillingItemsFilter() to provide required itemDate filter
  BillingItems API requires a filter parameter and returns 500 error without it
- Add validation for task project_id foreign key references
  Tasks with invalid project_id now have the field nullified instead of failing
- Add getValidProjectIds() method to fetch valid project IDs from database

Fixes:
- Billing Items: 'Value cannot be null. Parameter name: filters' error
- Tasks: 'violates foreign key constraint tasks_project_id_fkey' error

Both entities should now sync successfully.
This commit is contained in:
root 2026-01-24 08:09:10 -05:00
parent f85741c993
commit f1037d7964
2 changed files with 72 additions and 0 deletions

View file

@ -18,6 +18,7 @@ import {
buildContractsFilter,
buildProjectsFilter,
buildTimeEntriesFilter,
buildBillingItemsFilter,
getTableName
} from '../utils/sync-helpers';
import { createSyncLogger, SyncPhase, categorizeError } from '../utils/sync-logger';
@ -112,6 +113,9 @@ export class EntitySyncService {
} else if (entity === EntityType.TIME_ENTRIES) {
filters.push(...buildTimeEntriesFilter(yearsBack));
entityLogger.info(`Full sync with dateWorked filter for last ${yearsBack} years`);
} else if (entity === EntityType.BILLING_ITEMS) {
filters.push(...buildBillingItemsFilter(yearsBack));
entityLogger.info(`Full sync with itemDate filter for last ${yearsBack} years`);
} else {
// Add active filter if applicable
const activeFilter = buildActiveFilter(entity);
@ -270,6 +274,32 @@ export class EntitySyncService {
}
}
// Validate project foreign keys for tasks
if (entity === EntityType.TASKS) {
const initialCount = mappedRecords.length;
// Get all valid project IDs from database
const validProjectIds = await this.getValidProjectIds();
// Filter tasks with invalid project references
mappedRecords = mappedRecords.map(task => {
// Set invalid project IDs to null instead of filtering out the entire task
if (task.project_id && !validProjectIds.has(task.project_id)) {
entityLogger.debug(`Invalid project_id, setting to null`, {
taskId: task.id,
invalidProjectId: task.project_id,
});
task.project_id = null;
}
return task;
});
const nullifiedCount = initialCount - mappedRecords.filter(t => t.project_id).length;
if (nullifiedCount > 0) {
entityLogger.warn('Nullified invalid project references', { nullifiedCount });
}
}
entityLogger.info('Successfully mapped records', { mappedCount: mappedRecords.length });
// Bulk upsert to PostgreSQL
@ -547,6 +577,24 @@ export class EntitySyncService {
}
}
/**
* Get all valid project IDs from the database
* Used to validate foreign key references before insert
* @returns Set of valid project IDs
*/
private async getValidProjectIds(): Promise<Set<number>> {
try {
const query = 'SELECT id FROM projects WHERE is_deleted = false';
const result = await postgresClient.query<{ id: number }>(query);
return new Set(result.rows.map(row => row.id));
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('Failed to fetch valid project IDs', {}, err);
// Return empty set on error - will cause all project IDs to be nullified
return new Set();
}
}
/**
* Calculate monthly date chunks for a given time period
* @param yearsBack Number of years to look back

View file

@ -332,6 +332,30 @@ export function buildTimeEntriesFilter(yearsBack: number = 2): Array<{ field: st
];
}
/**
* Build special filter for billing items (requires filter)
* @param yearsBack Number of years to look back (default: 2)
* @returns Query filter array for billing items
*/
export function buildBillingItemsFilter(yearsBack: number = 2): Array<{ field: string; op: string; value: any }> {
// BillingItems API requires a filter. Use itemDate to limit the range
// Calculate date from X years ago
const cutoffDate = new Date();
const millisecondsPerYear = 365.25 * 24 * 60 * 60 * 1000;
const millisecondsBack = yearsBack * millisecondsPerYear;
cutoffDate.setTime(cutoffDate.getTime() - millisecondsBack);
console.log(`BillingItems filter: itemDate >= ${cutoffDate.toISOString()} (${yearsBack} years back)`);
return [
{
field: 'itemDate',
op: 'gte',
value: cutoffDate.toISOString(),
},
];
}
/**
* Calculate estimated sync duration based on record count
* @param recordCount Number of records to sync