- 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
335 lines
8.2 KiB
TypeScript
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]);
|
|
}
|