fix: prevent sync from nullifying assigned_resource_id on tickets
- bulkUpsert now accepts preserveExistingOnNull column list, using COALESCE(EXCLUDED.col, table.col) so null incoming values never overwrite existing non-null DB values - bulkUpsertRecords passes resource ID columns as preserve-on-null for TICKETS and TASKS entities - getValidResourceIds now throws on DB error instead of returning empty set (which would nullify every resource reference) - Fix mimecast mailbox-remediate fetch handlers to check res.ok and content-type before calling res.json(), preventing JSON parse crash on 502 Bad Gateway responses
This commit is contained in:
parent
04a720f0d0
commit
0e8eb4871e
4 changed files with 40 additions and 26 deletions
|
|
@ -1144,11 +1144,13 @@ function DeliveredAnalysisDialog({ message, onClose, onFindSimilar, allMessages
|
|||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'search', userEmail: message.to, fromAddress: message.from }),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!res.ok) {
|
||||
if (d.permissionRequired) { setPermError(d.detail); setRemedStep('idle'); return; }
|
||||
throw new Error(d.error ?? `HTTP ${res.status}`);
|
||||
const isJson = res.headers.get('content-type')?.includes('application/json');
|
||||
const d = isJson ? await res.json() : null;
|
||||
if (d?.permissionRequired) { setPermError(d.detail); setRemedStep('idle'); return; }
|
||||
throw new Error(d?.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const d = await res.json();
|
||||
const matches = d.messages ?? [];
|
||||
setRemedMatches(matches);
|
||||
setRemedSelected(new Set(matches.map((m: any) => m.id)));
|
||||
|
|
@ -1168,8 +1170,12 @@ function DeliveredAnalysisDialog({ message, onClose, onFindSimilar, allMessages
|
|||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'move', userEmail: message.to, messageIds: [...remedSelected] }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const isJson = res.headers.get('content-type')?.includes('application/json');
|
||||
const d = isJson ? await res.json() : null;
|
||||
throw new Error(d?.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const d = await res.json();
|
||||
if (!res.ok) throw new Error(d.error ?? `HTTP ${res.status}`);
|
||||
setRemedResults({ succeeded: d.succeeded, failed: d.failed });
|
||||
setRemedStep('done');
|
||||
} catch (e: any) {
|
||||
|
|
|
|||
|
|
@ -253,14 +253,16 @@ export class EntitySyncService {
|
|||
const validResourceIds = await this.getValidResourceIds();
|
||||
|
||||
// Filter tickets with invalid resource references
|
||||
let skippedResourceCount = 0;
|
||||
mappedRecords = mappedRecords.map(ticket => {
|
||||
// Set invalid resource IDs to null instead of filtering out the entire ticket
|
||||
// Set to null — bulkUpsert uses COALESCE for these columns so existing DB value is preserved
|
||||
if (ticket.assigned_resource_id && !validResourceIds.has(ticket.assigned_resource_id)) {
|
||||
entityLogger.debug(`Invalid assigned_resource_id, setting to null`, {
|
||||
entityLogger.debug(`Unknown assigned_resource_id, will preserve existing DB value via COALESCE`, {
|
||||
ticketId: ticket.id,
|
||||
invalidResourceId: ticket.assigned_resource_id,
|
||||
unknownResourceId: ticket.assigned_resource_id,
|
||||
});
|
||||
ticket.assigned_resource_id = null;
|
||||
skippedResourceCount++;
|
||||
}
|
||||
if (ticket.first_response_assigned_resource_id && !validResourceIds.has(ticket.first_response_assigned_resource_id)) {
|
||||
ticket.first_response_assigned_resource_id = null;
|
||||
|
|
@ -271,9 +273,8 @@ export class EntitySyncService {
|
|||
return ticket;
|
||||
});
|
||||
|
||||
const nullifiedCount = initialCount - mappedRecords.filter(t => t.assigned_resource_id).length;
|
||||
if (nullifiedCount > 0) {
|
||||
entityLogger.warn('Nullified invalid resource references', { nullifiedCount });
|
||||
if (skippedResourceCount > 0) {
|
||||
entityLogger.warn('Skipped unknown resource references (existing DB values preserved via COALESCE)', { skippedResourceCount });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -595,7 +596,7 @@ export class EntitySyncService {
|
|||
chunkLogger.info('Cached valid resource IDs', { cacheSize: this.cachedValidResourceIds.size });
|
||||
}
|
||||
|
||||
// Nullify invalid resource references
|
||||
// Null out unknown resource references — bulkUpsert uses COALESCE so existing DB value is preserved
|
||||
mappedRecords = mappedRecords.map(ticket => {
|
||||
if (ticket.assigned_resource_id && !this.cachedValidResourceIds!.has(ticket.assigned_resource_id)) {
|
||||
ticket.assigned_resource_id = null;
|
||||
|
|
@ -670,16 +671,9 @@ export class EntitySyncService {
|
|||
* @returns Set of valid resource IDs
|
||||
*/
|
||||
private async getValidResourceIds(): Promise<Set<number>> {
|
||||
try {
|
||||
const query = 'SELECT id FROM resources 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 resource IDs', {}, err);
|
||||
// Return empty set on error - will cause all resource IDs to be nullified
|
||||
return new Set();
|
||||
}
|
||||
const query = 'SELECT id FROM resources WHERE is_deleted = false';
|
||||
const result = await postgresClient.query<{ id: number }>(query);
|
||||
return new Set(result.rows.map(row => Number(row.id)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -690,7 +684,7 @@ export class EntitySyncService {
|
|||
try {
|
||||
const query = 'SELECT id FROM contacts WHERE is_deleted = false';
|
||||
const result = await postgresClient.query<{ id: number }>(query);
|
||||
return new Set(result.rows.map(row => row.id));
|
||||
return new Set(result.rows.map(row => Number(row.id)));
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error('Failed to fetch valid contact IDs', {}, err);
|
||||
|
|
@ -708,7 +702,7 @@ export class EntitySyncService {
|
|||
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));
|
||||
return new Set(result.rows.map(row => Number(row.id)));
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error('Failed to fetch valid project IDs', {}, err);
|
||||
|
|
|
|||
|
|
@ -217,11 +217,14 @@ class PostgresClient {
|
|||
|
||||
/**
|
||||
* Bulk upsert records
|
||||
* For nullable FK columns (assigned_resource_id etc.), uses COALESCE so that
|
||||
* a null incoming value does not overwrite an existing non-null DB value.
|
||||
*/
|
||||
async bulkUpsert(
|
||||
table: string,
|
||||
records: Record<string, any>[],
|
||||
conflictColumns: string[] = ['id']
|
||||
conflictColumns: string[] = ['id'],
|
||||
preserveExistingOnNull: string[] = []
|
||||
): Promise<number> {
|
||||
if (records.length === 0) return 0;
|
||||
|
||||
|
|
@ -240,7 +243,11 @@ class PostgresClient {
|
|||
// Build UPDATE clause for conflict resolution
|
||||
const updateKeys = keys.filter(k => !conflictColumns.includes(k));
|
||||
const updateClause = updateKeys
|
||||
.map(key => `${key} = EXCLUDED.${key}`)
|
||||
.map(key =>
|
||||
preserveExistingOnNull.includes(key)
|
||||
? `${key} = COALESCE(EXCLUDED.${key}, ${table}.${key})`
|
||||
: `${key} = EXCLUDED.${key}`
|
||||
)
|
||||
.join(', ');
|
||||
|
||||
const query = `
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ export async function upsertRecord(
|
|||
* @param batchSize Number of records per batch (default: 100)
|
||||
* @returns Total number of records upserted
|
||||
*/
|
||||
// Columns where a null incoming value should preserve the existing DB value rather than overwrite it
|
||||
const PRESERVE_EXISTING_ON_NULL: Partial<Record<EntityType, string[]>> = {
|
||||
[EntityType.TICKETS]: ['assigned_resource_id', 'first_response_assigned_resource_id', 'first_response_initiating_resource_id'],
|
||||
[EntityType.TASKS]: ['assigned_resource_id'],
|
||||
};
|
||||
|
||||
export async function bulkUpsertRecords(
|
||||
entity: EntityType,
|
||||
records: Record<string, any>[],
|
||||
|
|
@ -36,12 +42,13 @@ export async function bulkUpsertRecords(
|
|||
if (records.length === 0) return 0;
|
||||
|
||||
const tableName = getTableName(entity);
|
||||
const preserveOnNull = PRESERVE_EXISTING_ON_NULL[entity] ?? [];
|
||||
let totalUpserted = 0;
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < records.length; i += batchSize) {
|
||||
const batch = records.slice(i, i + batchSize);
|
||||
const count = await postgresClient.bulkUpsert(tableName, batch);
|
||||
const count = await postgresClient.bulkUpsert(tableName, batch, ['id'], preserveOnNull);
|
||||
totalUpserted += count;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue