wulf-pulse/lib/utils/db-helpers.ts
lorentz 0e8eb4871e 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
2026-04-05 08:55:41 -04:00

335 lines
8.2 KiB
TypeScript

/**
* Database Helper Functions
* Additional utility functions for database operations
*/
import postgresClient from '../services/postgres-client';
import { EntityType } from '../types/sync';
import { getTableName } from './sync-helpers';
/**
* Upsert a single record
* @param entity Entity type
* @param data Record data
* @returns Upserted record
*/
export async function upsertRecord(
entity: EntityType,
data: Record<string, any>
): Promise<any> {
const tableName = getTableName(entity);
return await postgresClient.upsert(tableName, data);
}
/**
* Bulk upsert records with batching
* @param entity Entity type
* @param records Array of records
* @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>[],
batchSize: number = 100
): Promise<number> {
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, ['id'], preserveOnNull);
totalUpserted += count;
}
return totalUpserted;
}
/**
* Soft delete records not in the provided ID list
* @param entity Entity type
* @param activeIds Array of IDs that should remain active
* @returns Number of records soft-deleted
*/
export async function softDeleteMissingRecords(
entity: EntityType,
activeIds: (number | string)[]
): Promise<number> {
if (activeIds.length === 0) return 0;
const tableName = getTableName(entity);
const query = `
UPDATE ${tableName}
SET is_deleted = true, deleted_at = CURRENT_TIMESTAMP
WHERE id NOT IN (${activeIds.map((_, i) => `$${i + 1}`).join(', ')})
AND is_deleted = false
`;
const result = await postgresClient.query(query, activeIds);
return result.rowCount || 0;
}
/**
* Get last sync time for an entity
* @param entity Entity type
* @returns Last sync timestamp or null
*/
export async function getLastSyncTime(
entity: EntityType
): Promise<Date | null> {
const query = `
SELECT completed_at
FROM sync_history
WHERE entity_type = $1
AND status = 'completed'
ORDER BY completed_at DESC
LIMIT 1
`;
const result = await postgresClient.query<{ completed_at: Date }>(
query,
[entity]
);
return result.rows[0]?.completed_at || null;
}
/**
* Get record count for an entity
* @param entity Entity type
* @param includeDeleted Include soft-deleted records
* @returns Record count
*/
export async function getRecordCount(
entity: EntityType,
includeDeleted: boolean = false
): Promise<number> {
const tableName = getTableName(entity);
return await postgresClient.count(tableName, {}, includeDeleted);
}
/**
* Get records modified since a specific date
* @param entity Entity type
* @param since Date to filter from
* @param limit Maximum number of records
* @returns Array of records
*/
export async function getRecordsModifiedSince(
entity: EntityType,
since: Date,
limit?: number
): Promise<any[]> {
const tableName = getTableName(entity);
const query = `
SELECT *
FROM ${tableName}
WHERE synced_at >= $1
AND is_deleted = false
ORDER BY synced_at DESC
${limit ? `LIMIT ${limit}` : ''}
`;
const result = await postgresClient.query(query, [since]);
return result.rows;
}
/**
* Get all active IDs for an entity
* @param entity Entity type
* @returns Array of active record IDs
*/
export async function getActiveIds(entity: EntityType): Promise<number[]> {
const tableName = getTableName(entity);
const query = `
SELECT id
FROM ${tableName}
WHERE is_deleted = false
`;
const result = await postgresClient.query<{ id: number }>(query);
return result.rows.map((row: { id: number }) => row.id);
}
/**
* Restore soft-deleted record
* @param entity Entity type
* @param id Record ID
*/
export async function restoreRecord(
entity: EntityType,
id: number | string
): Promise<void> {
const tableName = getTableName(entity);
const query = `
UPDATE ${tableName}
SET is_deleted = false, deleted_at = NULL
WHERE id = $1
`;
await postgresClient.query(query, [id]);
}
/**
* Hard delete soft-deleted records older than specified days
* @param entity Entity type
* @param daysOld Number of days
* @returns Number of records deleted
*/
export async function purgeOldDeletedRecords(
entity: EntityType,
daysOld: number = 90
): Promise<number> {
const tableName = getTableName(entity);
const query = `
DELETE FROM ${tableName}
WHERE is_deleted = true
AND deleted_at < NOW() - INTERVAL '${daysOld} days'
`;
const result = await postgresClient.query(query);
return result.rowCount || 0;
}
/**
* Get sync statistics for an entity
* @param entity Entity type
* @returns Sync statistics
*/
export async function getSyncStatistics(entity: EntityType): Promise<{
totalRecords: number;
activeRecords: number;
deletedRecords: number;
lastSyncTime: Date | null;
lastSyncStatus: string | null;
}> {
const tableName = getTableName(entity);
// Get record counts
const countQuery = `
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE is_deleted = false) as active,
COUNT(*) FILTER (WHERE is_deleted = true) as deleted
FROM ${tableName}
`;
const countResult = await postgresClient.query<{
total: string;
active: string;
deleted: string;
}>(countQuery);
// Get last sync info
const syncQuery = `
SELECT completed_at, status
FROM sync_history
WHERE entity_type = $1
ORDER BY started_at DESC
LIMIT 1
`;
const syncResult = await postgresClient.query<{
completed_at: Date;
status: string;
}>(syncQuery, [entity]);
return {
totalRecords: parseInt(countResult.rows[0]?.total || '0'),
activeRecords: parseInt(countResult.rows[0]?.active || '0'),
deletedRecords: parseInt(countResult.rows[0]?.deleted || '0'),
lastSyncTime: syncResult.rows[0]?.completed_at || null,
lastSyncStatus: syncResult.rows[0]?.status || null,
};
}
/**
* Vacuum analyze table to optimize performance
* @param entity Entity type
*/
export async function optimizeTable(entity: EntityType): Promise<void> {
const tableName = getTableName(entity);
await postgresClient.query(`VACUUM ANALYZE ${tableName}`);
}
/**
* Check if record exists
* @param entity Entity type
* @param id Record ID
* @returns True if record exists
*/
export async function recordExists(
entity: EntityType,
id: number | string
): Promise<boolean> {
const tableName = getTableName(entity);
const query = `
SELECT EXISTS(SELECT 1 FROM ${tableName} WHERE id = $1) as exists
`;
const result = await postgresClient.query<{ exists: boolean }>(query, [id]);
return result.rows[0]?.exists || false;
}
/**
* Get records by IDs
* @param entity Entity type
* @param ids Array of record IDs
* @returns Array of records
*/
export async function getRecordsByIds(
entity: EntityType,
ids: (number | string)[]
): Promise<any[]> {
if (ids.length === 0) return [];
const tableName = getTableName(entity);
const query = `
SELECT *
FROM ${tableName}
WHERE id = ANY($1::bigint[])
AND is_deleted = false
`;
const result = await postgresClient.query(query, [ids]);
return result.rows;
}
/**
* Update sync timestamp for records
* @param entity Entity type
* @param ids Array of record IDs
*/
export async function updateSyncTimestamp(
entity: EntityType,
ids: (number | string)[]
): Promise<void> {
if (ids.length === 0) return;
const tableName = getTableName(entity);
const query = `
UPDATE ${tableName}
SET synced_at = CURRENT_TIMESTAMP
WHERE id = ANY($1::bigint[])
`;
await postgresClient.query(query, [ids]);
}