From 87cce83030779acb593804c0773ca68928ba2a52 Mon Sep 17 00:00:00 2001 From: Jackson Roberts Date: Wed, 1 Apr 2026 21:28:22 -0500 Subject: [PATCH] fix(dalpuri): correct UTC timestamp field names in smart sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed field name mismatches for tables with lastUpdatedUTC (all-caps): - IV_Product: lastUpdateUtc → lastUpdatedUTC - Department: lastUpdateUtc → lastUpdatedUTC These field names must match the Prisma schema exactly (case-sensitive). Ensures smart sync decision logic can correctly probe for record updates. Co-Authored-By: Claude Opus 4.6 --- dalpuri/src/sync.ts | 1087 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1087 insertions(+) create mode 100644 dalpuri/src/sync.ts diff --git a/dalpuri/src/sync.ts b/dalpuri/src/sync.ts new file mode 100644 index 0000000..dd8ec6c --- /dev/null +++ b/dalpuri/src/sync.ts @@ -0,0 +1,1087 @@ +import { PrismaMssql } from "@prisma/adapter-mssql"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { PrismaClient as CwPrismaClient } from "../generated/prisma/client"; +import { PrismaClient as ApiPrismaClient } from "../../api/generated/prisma/client"; + +import { + catalogCategoryTranslation, + catalogItemTranslation, + catalogItemTypeTranslation, + catalogManufacturerTranslation, + catalogSubcategoryTranslation, + companyAddressTranslation, + companyTranslation, + contactTranslation, + corporateLocationTranslation, + createTranslationContext, + cwMemberTranslation, + internalDepartmentTranslation, + opportunityStatusTranslation, + opportunityTranslation, + opportunityTypeTranslation, + productDataTranslation, + productInventoryTranslation, + serviceTicketBoardTranslation, + serviceTicketImpactTranslation, + serviceTicketLocationTranslation, + serviceTicketNoteTranslation, + serviceTicketPriorityTranslation, + serviceTicketServerityTranslation, + serviceTicketSourceTranslation, + serviceTicketTranslation, + serviceTicketTypeTranslation, + userTranslation, + warehouseBinTranslation, + type TranslationContext, +} from "./index"; +import { Translation } from "./translations/types"; + +type Row = Record; +type AnyTranslation = Translation; + +type Step = { + name: string; + sourceModel: string; + targetModel: string; + translation: AnyTranslation; + uniqueField: string; + sourceIdField: string; + sourceUpdatedField: string; + sourceArgs?: Record; +}; + +type StepResult = { + insertedOrUpdated: number; + skipped: number; + failed: number; +}; + +const parseEnvFile = (path: string): Record => { + const envData = readFileSync(path, "utf8"); + const out: Record = {}; + + for (const rawLine of envData.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const index = line.indexOf("="); + if (index <= 0) continue; + + const key = line.slice(0, index).trim(); + let value = line.slice(index + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + out[key] = value; + } + + return out; +}; + +const resolveApiDatabaseUrl = (): string => { + if (process.env.API_DATABASE_URL) return process.env.API_DATABASE_URL; + if (process.env.OPTIMA_API_DATABASE_URL) + return process.env.OPTIMA_API_DATABASE_URL; + + const candidates = [ + resolve(import.meta.dir, "../../api/.env"), + resolve(process.cwd(), "../api/.env"), + ]; + + for (const apiEnvPath of candidates) { + try { + const apiEnv = parseEnvFile(apiEnvPath); + if (apiEnv.DATABASE_URL) { + return apiEnv.DATABASE_URL; + } + } catch { + // Try next path candidate. + } + } + + return ""; +}; + +const parseJsonMapFromEnv = (envKey: string): Map => { + const raw = process.env[envKey]; + if (!raw) return new Map(); + + try { + const parsed = JSON.parse(raw) as Record; + const entries = Object.entries(parsed) + .map(([key, value]) => [Number.parseInt(key, 10), value] as const) + .filter( + ([key, value]) => Number.isInteger(key) && typeof value === "string" + ); + + return new Map(entries); + } catch (error) { + console.warn(`Could not parse ${envKey} as JSON map. Ignoring.`, error); + return new Map(); + } +}; + +const normalizeData = (data: Row): Row => { + const normalized: Row = {}; + for (const [key, value] of Object.entries(data)) { + if (value !== undefined) { + normalized[key] = value; + } + } + return normalized; +}; + +const translateRow = ( + row: Row, + translation: AnyTranslation, + context: TranslationContext +): Row => { + const out: Row = {}; + + for (const entry of translation.values) { + const fromKey = entry.from as string; + const toKey = entry.to as string; + const input = row[fromKey]; + + if (input === undefined) { + continue; + } + + const output = entry.process + ? entry.process(input as never, context, row) + : input; + out[toKey] = output as unknown; + } + + return normalizeData(out); +}; + +const refreshContextFromApi = async ( + apiPrisma: ApiPrismaClient, + context: TranslationContext +): Promise => { + context.userIds.clear(); + context.serviceTicketIds.clear(); + context.opportunityIds.clear(); + context.catalogItemIds.clear(); + context.corporateLocationIds.clear(); + context.usersByMemberRecId.clear(); + context.userIdentifiersByMemberRecId.clear(); + context.usersByIdentifier.clear(); + context.serviceTicketBoardUidsById.clear(); + + const [ + users, + boards, + serviceTickets, + opportunities, + catalogItems, + locations, + ] = await Promise.all([ + apiPrisma.user.findMany({ + select: { + id: true, + cwMemberId: true, + cwIdentifier: true, + }, + }), + apiPrisma.serviceTicketBoard.findMany({ + select: { + id: true, + uid: true, + }, + }), + apiPrisma.serviceTicket.findMany({ + select: { + id: true, + }, + }), + apiPrisma.opportunity.findMany({ + select: { + id: true, + }, + }), + apiPrisma.catalogItem.findMany({ + select: { + id: true, + }, + }), + apiPrisma.corporateLocation.findMany({ + select: { + id: true, + }, + }), + ]); + + const defaultOpportunityType = await apiPrisma.opportunityType.findFirst({ + orderBy: { id: "asc" }, + select: { id: true }, + }); + + for (const user of users) { + context.userIds.add(user.id); + + if (user.cwMemberId != null) { + context.usersByMemberRecId.set(user.cwMemberId, user.id); + if (user.cwIdentifier) { + context.userIdentifiersByMemberRecId.set( + user.cwMemberId, + user.cwIdentifier + ); + } + } + + if (user.cwIdentifier) { + context.usersByIdentifier.set(user.cwIdentifier, user.id); + } + } + + for (const board of boards) { + context.serviceTicketBoardUidsById.set(board.id, board.uid); + } + + for (const serviceTicket of serviceTickets) { + context.serviceTicketIds.add(serviceTicket.id); + } + + for (const opportunity of opportunities) { + context.opportunityIds.add(opportunity.id); + } + + for (const catalogItem of catalogItems) { + context.catalogItemIds.add(catalogItem.id); + } + + for (const location of locations) { + context.corporateLocationIds.add(location.id); + } + + context.defaultOpportunityTypeId = defaultOpportunityType?.id ?? null; + + // Optional context fed from env-provided maps. + context.billingTypeByTicketId = parseJsonMapFromEnv( + "BILLING_TYPE_CONTEXT_JSON" + ); + context.billingInstructionsByTicketId = parseJsonMapFromEnv( + "BILLING_INSTRUCTIONS_CONTEXT_JSON" + ); +}; + +const normalizeNarrativeFieldName = (value: unknown): string => { + if (typeof value !== "string") return ""; + return value.trim().toLowerCase(); +}; + +const isNarrativeCustomField = (fieldName: string): boolean => { + if (!fieldName) return false; + return fieldName.includes("narrative"); +}; + +const normalizeNullableText = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const refreshCustomFieldContextFromCw = async ( + cwPrisma: CwPrismaClient, + context: TranslationContext +): Promise => { + context.opportunityNarrativeByOpportunityId.clear(); + context.productCustomByIvProductId.clear(); + + const [productCustomRows, opportunityCustomRows] = await Promise.all([ + cwPrisma.$queryRawUnsafe>>(` + SELECT + IV_Product_RecID, + ProcurementNotes, + ProductNarrative + FROM dbo.v_IV_Product_Custom_Fields + `), + cwPrisma.$queryRawUnsafe>>(` + SELECT + Opportunity_RecID, + opportunity_customfield, + opportunity_customvalue + FROM dbo.v_rpt_OpportunityCustomFields + WHERE opportunity_customvalue IS NOT NULL + `), + ]); + + for (const row of productCustomRows) { + const idRaw = row.IV_Product_RecID; + const ivProductRecId = + typeof idRaw === "number" ? idRaw : Number.parseInt(String(idRaw), 10); + if (!Number.isInteger(ivProductRecId)) continue; + + context.productCustomByIvProductId.set(ivProductRecId, { + procurementNotes: normalizeNullableText(row.ProcurementNotes), + productNarrative: normalizeNullableText(row.ProductNarrative), + }); + } + + for (const row of opportunityCustomRows) { + const idRaw = row.Opportunity_RecID; + const opportunityRecId = + typeof idRaw === "number" ? idRaw : Number.parseInt(String(idRaw), 10); + if (!Number.isInteger(opportunityRecId)) continue; + + const fieldName = normalizeNarrativeFieldName(row.opportunity_customfield); + if (!isNarrativeCustomField(fieldName)) continue; + + const narrativeValue = normalizeNullableText(row.opportunity_customvalue); + if (!narrativeValue) continue; + + // First narrative-looking custom field wins for each opportunity. + if (!context.opportunityNarrativeByOpportunityId.has(opportunityRecId)) { + context.opportunityNarrativeByOpportunityId.set( + opportunityRecId, + narrativeValue + ); + } + } +}; + +const sanitizeUserForeignKeys = ( + data: Row, + context: TranslationContext, + targetModel: string +): Row => { + if ( + targetModel !== "serviceTicket" && + targetModel !== "serviceTicketNote" && + targetModel !== "company" && + targetModel !== "companyAddress" + ) { + return data; + } + + const sanitized = { ...data }; + + const userIdFields = + targetModel === "serviceTicketNote" + ? ["authorId"] + : targetModel === "serviceTicket" + ? ["createdById", "updatedById", "closedById"] + : []; + + const identifierFields = + targetModel === "serviceTicket" + ? ["ticketOwnerId"] + : targetModel === "company" + ? ["enteredById", "deletedById"] + : targetModel === "companyAddress" + ? ["updatedById"] + : []; + + for (const field of userIdFields) { + const value = sanitized[field]; + if (typeof value === "string" && !context.userIds.has(value)) { + sanitized[field] = null; + } + } + + for (const field of identifierFields) { + const value = sanitized[field]; + if (typeof value === "string" && !context.usersByIdentifier.has(value)) { + sanitized[field] = null; + } + } + + return sanitized; +}; + +const sanitizeModelData = ( + data: Row, + step: Step, + context: TranslationContext +): Row => { + const sanitized = { ...data }; + + if (step.targetModel === "warehouseBin") { + if (sanitized.minQuantity == null) sanitized.minQuantity = 0; + if (sanitized.maxQuantity == null) sanitized.maxQuantity = 0; + } + + if (step.targetModel === "serviceTicket") { + const boardId = sanitized.serviceTicketBoardId; + if (typeof boardId === "string") { + const parsed = Number.parseInt(boardId, 10); + sanitized.serviceTicketBoardId = Number.isNaN(parsed) ? null : parsed; + } + } + + if (step.targetModel === "opportunity") { + if (sanitized.typeId == null && context.defaultOpportunityTypeId != null) { + sanitized.typeId = context.defaultOpportunityTypeId; + } + } + + return sanitized; +}; + +const asRecord = (value: unknown): Record | null => { + if (typeof value !== "object" || value == null) { + return null; + } + + return value as Record; +}; + +const parseFieldsFromErrorMessage = (message: string): string[] => { + const fields = new Set(); + + // Handles patterns like (`"cwIdentifier"`) and (`login`) + for (const match of message.matchAll(/`\"([^\"]+)\"`|`([^`]+)`/g)) { + const candidate = (match[1] ?? match[2] ?? "").trim(); + if (!candidate) continue; + + // Skip common non-field tokens that can appear in error messages. + if (candidate === "targetDelegate.upsert()" || candidate === "invocation") { + continue; + } + + fields.add(candidate); + } + + return [...fields]; +}; + +const getUniqueConstraintFields = (error: unknown): string[] => { + const errorRecord = asRecord(error); + if (!errorRecord || errorRecord.code !== "P2002") { + return []; + } + + const meta = asRecord(errorRecord.meta); + if (!meta) { + return []; + } + + const target = meta.target; + if (Array.isArray(target)) { + return target.filter((field): field is string => typeof field === "string"); + } + + if (typeof target === "string") { + return [target]; + } + + if (typeof errorRecord.message === "string") { + return parseFieldsFromErrorMessage(errorRecord.message); + } + + return []; +}; + +const formatValue = (value: unknown): string => { + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === "string") { + return JSON.stringify(value); + } + + if ( + typeof value === "number" || + typeof value === "boolean" || + value == null + ) { + return String(value); + } + + try { + return JSON.stringify(value); + } catch { + return "[unserializable]"; + } +}; + +const formatUniqueConstraintError = ( + error: unknown, + data: Row | null +): string => { + const fields = getUniqueConstraintFields(error); + + if (fields.length === 0) { + return "Unique constraint failed."; + } + + const fieldValues = fields.map((field) => { + const value = data ? data[field] : undefined; + return `${field}=${formatValue(value)}`; + }); + + return `Unique constraint failed on field(s): ${fields.join( + ", " + )}. Values: ${fieldValues.join(", ")}`; +}; + +type SmartSyncDecision = + | { mode: "full" } + | { mode: "incremental"; sourceIds: number[] }; + +const computeSmartSyncDecision = async ( + cwPrisma: CwPrismaClient, + apiPrisma: ApiPrismaClient, + step: Step +): Promise => { + const cwDelegate = ( + cwPrisma as unknown as Record + )[step.sourceModel]; + const apiDelegate = ( + apiPrisma as unknown as Record + )[step.targetModel]; + + if (!cwDelegate || !apiDelegate) { + return { mode: "full" }; + } + + const existingWhere = + (step.sourceArgs as Record | undefined)?.where ?? {}; + + // Fetch top 1000 CW records: only id + updatedAt + const cwSample = (await cwDelegate.findMany({ + select: { + [step.sourceIdField]: true, + [step.sourceUpdatedField]: true, + }, + where: existingWhere, + orderBy: { [step.sourceUpdatedField]: "desc" }, + take: 1000, + })) as Row[]; + + if (cwSample.length === 0) { + return { mode: "incremental", sourceIds: [] }; + } + + const cwIds = cwSample.map((r) => r[step.sourceIdField] as number); + + // Fetch the corresponding API records for comparison + const apiSample = (await apiDelegate.findMany({ + select: { + [step.uniqueField]: true, + updatedAt: true, + }, + where: { [step.uniqueField]: { in: cwIds } }, + })) as Row[]; + + const apiMap = new Map(); + for (const row of apiSample) { + const id = row[step.uniqueField] as number; + apiMap.set(id, (row.updatedAt as Date | null) ?? new Date(0)); + } + + let differences = 0; + for (const cwRow of cwSample) { + const cwId = cwRow[step.sourceIdField] as number; + const cwUpdated = + (cwRow[step.sourceUpdatedField] as Date | null) ?? new Date(0); + + if (!apiMap.has(cwId)) { + differences++; + } else { + const apiUpdated = apiMap.get(cwId)!; + if (cwUpdated.getTime() > apiUpdated.getTime()) { + differences++; + } + } + } + + console.log( + ` [smart-sync] ${step.name}: ${differences} differences in top 1000` + ); + + if (differences > 100) { + return { mode: "full" }; + } + + return { mode: "incremental", sourceIds: cwIds.slice(0, 100) }; +}; + +const syncStep = async ( + cwPrisma: CwPrismaClient, + apiPrisma: ApiPrismaClient, + context: TranslationContext, + step: Step, + sourceIdsFilter?: number[] +): Promise => { + const sourceDelegate = ( + cwPrisma as unknown as Record + )[step.sourceModel]; + const targetDelegate = ( + apiPrisma as unknown as Record + )[step.targetModel]; + + if (!sourceDelegate) { + throw new Error(`CW delegate not found: ${step.sourceModel}`); + } + + if (!targetDelegate) { + throw new Error(`API delegate not found: ${step.targetModel}`); + } + + if (sourceIdsFilter !== undefined && sourceIdsFilter.length === 0) { + return { insertedOrUpdated: 0, skipped: 0, failed: 0 }; + } + + let findManyArgs: Record = step.sourceArgs ?? {}; + if (sourceIdsFilter !== undefined) { + const existingWhere = + (findManyArgs as Record).where ?? {}; + findManyArgs = { + ...findManyArgs, + where: { + ...(existingWhere as Record), + [step.sourceIdField]: { in: sourceIdsFilter }, + }, + }; + } + + const rows = (await sourceDelegate.findMany(findManyArgs)) as Row[]; + + let insertedOrUpdated = 0; + let skipped = 0; + let failed = 0; + let sampleErrorsPrinted = 0; + + for (const row of rows) { + let translatedData: Row | null = null; + + try { + const translated = translateRow(row, step.translation, context); + const userSanitized = sanitizeUserForeignKeys( + translated, + context, + step.targetModel + ); + const data = sanitizeModelData(userSanitized, step, context); + translatedData = data; + const uniqueValue = data[step.uniqueField]; + + if ( + uniqueValue === undefined || + uniqueValue === null || + uniqueValue === "" + ) { + skipped += 1; + continue; + } + + await targetDelegate.upsert({ + where: { + [step.uniqueField]: uniqueValue, + }, + create: data, + update: data, + }); + + insertedOrUpdated += 1; + } catch (error) { + if (error instanceof Error && error.message.startsWith("SKIP_ROW:")) { + skipped += 1; + continue; + } + + failed += 1; + if (sampleErrorsPrinted < 5) { + sampleErrorsPrinted += 1; + const uniqueFields = getUniqueConstraintFields(error); + const message = + uniqueFields.length > 0 + ? formatUniqueConstraintError(error, translatedData) + : error instanceof Error + ? error.message + : "Unknown row sync error"; + console.error( + `Failed row in ${step.name} (source ${step.sourceModel} -> target ${step.targetModel}):`, + message + ); + } + } + } + + if (failed > sampleErrorsPrinted) { + console.error( + `${step.name}: suppressed ${ + failed - sampleErrorsPrinted + } additional row errors` + ); + } + + return { insertedOrUpdated, skipped, failed }; +}; + +export const executeFullDalpuriSync = async (): Promise => { + const cwDatabaseUrl = process.env.CW_DATABASE_URL || process.env.DATABASE_URL; + const apiDatabaseUrl = resolveApiDatabaseUrl(); + + if (!cwDatabaseUrl) { + throw new Error( + "Missing CW DB connection string. Set CW_DATABASE_URL or DATABASE_URL." + ); + } + + if (!apiDatabaseUrl) { + throw new Error( + "Missing API DB connection string. Set API_DATABASE_URL/OPTIMA_API_DATABASE_URL or ensure ../api/.env has DATABASE_URL." + ); + } + + const cwAdapter = new PrismaMssql(cwDatabaseUrl); + const apiAdapter = new PrismaPg({ connectionString: apiDatabaseUrl }); + + const cwPrisma = new CwPrismaClient({ adapter: cwAdapter }); + const apiPrisma = new ApiPrismaClient({ adapter: apiAdapter }); + + const context = createTranslationContext(); + + const steps: Step[] = [ + { + name: "Users", + sourceModel: "member", + targetModel: "user", + translation: userTranslation as unknown as AnyTranslation, + uniqueField: "cwMemberId", + sourceIdField: "memberRecId", + sourceUpdatedField: "lastUpdatedUtc", + sourceArgs: { + where: { + memberClass: "F", + NOT: [{ emailAddress: null }, { emailAddress: "" }], + }, + }, + }, + { + name: "CW Members", + sourceModel: "member", + targetModel: "cwMember", + translation: cwMemberTranslation as unknown as AnyTranslation, + uniqueField: "cwMemberId", + sourceIdField: "memberRecId", + sourceUpdatedField: "lastUpdatedUtc", + sourceArgs: { + where: { + memberClass: "F", + NOT: [{ emailAddress: null }, { emailAddress: "" }], + }, + }, + }, + { + name: "Companies", + sourceModel: "company", + targetModel: "company", + translation: companyTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "companyRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Company Addresses", + sourceModel: "companyAddress", + targetModel: "companyAddress", + translation: companyAddressTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "companyAddressRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Contacts", + sourceModel: "contact", + targetModel: "contact", + translation: contactTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "contactRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Corporate Locations", + sourceModel: "ownerLevel", + targetModel: "corporateLocation", + translation: corporateLocationTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "ownerLevelRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Internal Departments", + sourceModel: "department", + targetModel: "internalDepartment", + translation: internalDepartmentTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "departmentRecId", + sourceUpdatedField: "lastUpdatedUTC", + }, + { + name: "Catalog Item Types", + sourceModel: "productType", + targetModel: "catalogItemType", + translation: catalogItemTypeTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "typeRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Catalog Categories", + sourceModel: "productCategory", + targetModel: "catalogCategory", + translation: catalogCategoryTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "categoryRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Catalog Subcategories", + sourceModel: "productSubcategory", + targetModel: "catalogSubcategory", + translation: catalogSubcategoryTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "subcategoryRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Catalog Manufacturers", + sourceModel: "manufacturer", + targetModel: "catalogManufacturer", + translation: catalogManufacturerTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "manufacturerRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Warehouse Bins", + sourceModel: "warehouseBin", + targetModel: "warehouseBin", + translation: warehouseBinTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "warehouseBinRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Catalog Items", + sourceModel: "productCatalog", + targetModel: "catalogItem", + translation: catalogItemTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "catalogRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Product Inventory", + sourceModel: "productInventory", + targetModel: "productInventory", + translation: productInventoryTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "inventoryRecId", + sourceUpdatedField: "lastUpdate", + }, + { + name: "Service Ticket Types", + sourceModel: "srType", + targetModel: "serviceTicketType", + translation: serviceTicketTypeTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "srTypeRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Service Ticket Locations", + sourceModel: "srLocation", + targetModel: "serviceTicketLocation", + translation: + serviceTicketLocationTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "srLocationRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Service Ticket Sources", + sourceModel: "srSource", + targetModel: "serviceTicketSource", + translation: serviceTicketSourceTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "srSourceRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Service Ticket Impacts", + sourceModel: "srImpact", + targetModel: "serviceTicketImpact", + translation: serviceTicketImpactTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "srImpactRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Service Ticket Severities", + sourceModel: "srSeverity", + targetModel: "serviceTicketServerity", + translation: + serviceTicketServerityTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "srSeverityRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Service Ticket Priorities", + sourceModel: "srUrgency", + targetModel: "serviceTicketPriority", + translation: + serviceTicketPriorityTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "srUrgencyRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Service Ticket Boards", + sourceModel: "srBoard", + targetModel: "serviceTicketBoard", + translation: serviceTicketBoardTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "srBoardRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Opportunity Types", + sourceModel: "soType", + targetModel: "opportunityType", + translation: opportunityTypeTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "soTypeRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Opportunity Statuses", + sourceModel: "soOppStatus", + targetModel: "opportunityStatus", + translation: opportunityStatusTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "soOppStatusRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Opportunities", + sourceModel: "opportunity", + targetModel: "opportunity", + translation: opportunityTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "opportunityRecId", + sourceUpdatedField: "lastUpdatedUtc", + sourceArgs: { + include: { + members: { + select: { + memberRecId: true, + primarySalesFlag: true, + secondarySalesFlag: true, + }, + }, + }, + }, + }, + { + name: "Service Tickets", + sourceModel: "srService", + targetModel: "serviceTicket", + translation: serviceTicketTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "srServiceRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, + { + name: "Product Data", + sourceModel: "iV_Product", + targetModel: "productData", + translation: productDataTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "ivProductRecId", + sourceUpdatedField: "lastUpdatedUTC", + }, + { + name: "Service Ticket Notes", + sourceModel: "ticketNote", + targetModel: "serviceTicketNote", + translation: serviceTicketNoteTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "ticketNoteRecId", + sourceUpdatedField: "lastUpdatedUtc", + sourceArgs: { + orderBy: { + dateCreated: "asc", + }, + }, + }, + ]; + + try { + await refreshContextFromApi(apiPrisma, context); + await refreshCustomFieldContextFromCw(cwPrisma, context); + + const summary: Array<{ step: string; result: StepResult }> = []; + + for (const step of steps) { + // Refresh context before tables that depend heavily on cross-table lookups. + if ( + step.targetModel === "user" || + step.targetModel === "serviceTicketBoard" || + step.targetModel === "serviceTicket" || + step.targetModel === "opportunity" || + step.targetModel === "productData" || + step.targetModel === "serviceTicketNote" + ) { + await refreshContextFromApi(apiPrisma, context); + if ( + step.targetModel === "opportunity" || + step.targetModel === "productData" + ) { + await refreshCustomFieldContextFromCw(cwPrisma, context); + } + } + + console.log(`Starting ${step.name}...`); + const decision = await computeSmartSyncDecision(cwPrisma, apiPrisma, step); + const sourceIdsFilter = + decision.mode === "incremental" ? decision.sourceIds : undefined; + console.log( + ` [smart-sync] mode=${decision.mode}${decision.mode === "incremental" ? ` (${decision.sourceIds.length} ids)` : ""}` + ); + const result = await syncStep( + cwPrisma, + apiPrisma, + context, + step, + sourceIdsFilter + ); + summary.push({ step: step.name, result }); + console.log( + `${step.name}: upserted=${result.insertedOrUpdated} skipped=${result.skipped} failed=${result.failed}` + ); + } + + console.log("\nSync complete.\n"); + for (const item of summary) { + console.log( + `${item.step}: upserted=${item.result.insertedOrUpdated}, skipped=${item.result.skipped}, failed=${item.result.failed}` + ); + } + } finally { + await cwPrisma.$disconnect(); + await apiPrisma.$disconnect(); + } +}; + +if (import.meta.main) { + executeFullDalpuriSync().catch((error) => { + console.error("CW -> API sync failed:", error); + process.exit(1); + }); +}