import { buildNotificationFromType } from '@open-mercato/core/modules/notifications/lib/notificationBuilder' import { resolveNotificationService } from '@open-mercato/core/modules/notifications/lib/notificationService' import type { EntityManager } from '@mikro-orm/postgresql' import { ActionLog } from '@open-mercato/core/modules/audit_logs/data/entities' import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find' import { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService' import { isConflictNotificationEnabled, resolveRecordLockNotificationType, resolveRecordResourceLink, } from '../lib/notificationHelpers' import { createLogger } from '@open-mercato/shared/lib/logger' const logger = createLogger('record_locks').child({ subscriber: 'conflict-detected-notification' }) export const metadata = { event: 'record_locks.conflict.detected', persistent: true, id: 'record_locks:conflict-detected-notification', } type Payload = { conflictId: string resourceKind: string resourceId: string tenantId: string organizationId?: string | null conflictActorUserId: string incomingActorUserId?: string | null baseActionLogId?: string | null incomingActionLogId?: string | null } type ResolverContext = { resolve: (name: string) => T } function formatChangedFieldLabel(rawField: string): string { const trimmedField = rawField.trim() const withoutNamespace = trimmedField.includes('::') ? (trimmedField.split('::').pop() ?? trimmedField) : trimmedField const withoutPrefix = withoutNamespace.includes('.') ? (withoutNamespace.split('.').pop() ?? withoutNamespace) : withoutNamespace const words = withoutPrefix .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/[._-]+/g, ' ') .replace(/\s+/g, ' ') .trim() .split(' ') .filter(Boolean) if (!words.length) return trimmedField return words .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' ') } function readActionLogChanges(log: ActionLog | null): Record | null { const rawChanges = log?.changesJson as unknown if (rawChanges && typeof rawChanges === 'object' && !Array.isArray(rawChanges)) { return rawChanges as Record } if (typeof rawChanges !== 'string') return null const parsed = parseDecryptedFieldValue(rawChanges) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null } export default async function handle(payload: Payload, ctx: ResolverContext) { if (!payload.conflictActorUserId) return const notificationsEnabled = await isConflictNotificationEnabled(ctx, { tenantId: payload.tenantId, organizationId: payload.organizationId ?? null, }) if (!notificationsEnabled) return try { const em = (ctx.resolve('em') as EntityManager).fork() const incomingLog = payload.incomingActionLogId ? await findOneWithDecryption( em, ActionLog, { id: payload.incomingActionLogId, deletedAt: null }, undefined, { tenantId: payload.tenantId, organizationId: payload.organizationId ?? null }, ) : null const changesJson = readActionLogChanges(incomingLog) const changedFields = changesJson ? Object.keys(changesJson).slice(0, 12).map(formatChangedFieldLabel).join(', ') : '' const notificationService = resolveNotificationService(ctx) const typeDef = resolveRecordLockNotificationType('record_locks.conflict.detected') if (!typeDef) return const notificationInput = buildNotificationFromType(typeDef, { recipientUserId: payload.conflictActorUserId, bodyVariables: { resourceKind: payload.resourceKind, changedFields: changedFields || '-', }, sourceEntityType: 'record_locks:conflict', sourceEntityId: payload.conflictId, linkHref: resolveRecordResourceLink(payload.resourceKind, payload.resourceId), groupKey: `record_locks.conflict.detected:${payload.conflictId}`, }) await notificationService.create(notificationInput, { tenantId: payload.tenantId, organizationId: payload.organizationId ?? null, }) } catch (error) { logger.error('Failed to create notification', { err: error }) } }