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"; type EnvMap = Record; type Summary = { cwTotal: number; apiTotal: number; eligibleForSync: number; missingSrServiceRecId: number; missingMemberRecId: number; missingParentTicketInApi: number; missingAuthorMappingInApi: number; eligibleButMissingInApi: number; sampleMissingParentTicketIds: number[]; sampleMissingAuthorMemberRecIds: number[]; sampleEligibleButMissingNoteIds: number[]; topMissingAuthorMemberRecIds: Array<{ memberRecId: number; count: number }>; topMissingParentTicketIds: Array<{ srServiceRecId: number; count: number }>; automateApi: { total: number; eligibleForSync: number; missingSrServiceRecId: number; missingMemberRecId: number; missingParentTicketInApi: number; missingAuthorMappingInApi: number; eligibleButMissingInApi: number; sampleMemberRecIds: number[]; sampleNoteIdsMissingInApi: number[]; }; }; const isAutomateApiAuthor = ( createdBy: string | null, originalAuthor: string | null ): boolean => { const normalizedCreatedBy = createdBy?.trim().toLowerCase() ?? ""; const normalizedOriginalAuthor = originalAuthor?.trim().toLowerCase() ?? ""; return ( normalizedCreatedBy.includes("automateapi") || normalizedOriginalAuthor.includes("automateapi") ); }; const parseEnvFile = (path: string): EnvMap => { const envData = readFileSync(path, "utf8"); const out: EnvMap = {}; 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 readApiEnv = (): EnvMap => { const candidates = [ resolve(import.meta.dir, "../api/.env"), resolve(process.cwd(), "../api/.env"), resolve(process.cwd(), "api/.env"), ]; for (const candidate of candidates) { try { return parseEnvFile(candidate); } catch { // Try next } } return {}; }; const main = async (): Promise => { const apiEnv = readApiEnv(); const cwDatabaseUrl = process.env.CW_DATABASE_URL || process.env.DATABASE_URL || apiEnv.CW_DATABASE_URL; const apiDatabaseUrl = process.env.API_DATABASE_URL || process.env.OPTIMA_API_DATABASE_URL || apiEnv.API_DATABASE_URL || apiEnv.OPTIMA_API_DATABASE_URL || apiEnv.DATABASE_URL; if (!cwDatabaseUrl) { throw new Error("Missing CW DB URL. Set CW_DATABASE_URL or DATABASE_URL."); } if (!apiDatabaseUrl) { throw new Error( "Missing API DB URL. Set API_DATABASE_URL/OPTIMA_API_DATABASE_URL or provide api/.env DATABASE_URL." ); } const cwPrisma = new CwPrismaClient({ adapter: new PrismaMssql(cwDatabaseUrl), }); const apiPrisma = new ApiPrismaClient({ adapter: new PrismaPg({ connectionString: apiDatabaseUrl }), }); try { console.log("[diag] Loading API reference sets..."); const [apiNotes, apiTickets, apiUsers] = await Promise.all([ apiPrisma.serviceTicketNote.findMany({ select: { id: true } }), apiPrisma.serviceTicket.findMany({ select: { id: true } }), apiPrisma.user.findMany({ select: { cwMemberId: true } }), ]); const apiNoteIds = new Set(apiNotes.map((r) => r.id)); const apiTicketIds = new Set(apiTickets.map((r) => r.id)); const apiUserMemberIds = new Set( apiUsers .map((r) => r.cwMemberId) .filter((v): v is number => Number.isInteger(v)) ); console.log( `[diag] API sets: notes=${apiNoteIds.size} tickets=${apiTicketIds.size} usersWithCwMemberId=${apiUserMemberIds.size}` ); const cwTotal = await cwPrisma.ticketNote.count(); const apiTotal = apiNoteIds.size; let missingSrServiceRecId = 0; let missingMemberRecId = 0; let missingParentTicketInApi = 0; let missingAuthorMappingInApi = 0; let eligibleForSync = 0; let eligibleButMissingInApi = 0; let automateTotal = 0; let automateEligibleForSync = 0; let automateMissingSrServiceRecId = 0; let automateMissingMemberRecId = 0; let automateMissingParentTicketInApi = 0; let automateMissingAuthorMappingInApi = 0; let automateEligibleButMissingInApi = 0; const sampleMissingParentTicketIds: number[] = []; const sampleMissingAuthorMemberRecIds: number[] = []; const sampleEligibleButMissingNoteIds: number[] = []; const automateSampleMemberRecIds: number[] = []; const automateSampleNoteIdsMissingInApi: number[] = []; const missingAuthorCounts = new Map(); const missingParentCounts = new Map(); let cursor = 0; const batchSize = 5000; console.log( `[diag] Scanning CW TicketNote rows in batches of ${batchSize}...` ); while (true) { const batch = await cwPrisma.ticketNote.findMany({ where: { ticketNoteRecId: { gt: cursor, }, }, orderBy: { ticketNoteRecId: "asc", }, select: { ticketNoteRecId: true, srServiceRecId: true, memberRecId: true, createdBy: true, originalAuthor: true, }, take: batchSize, }); if (batch.length === 0) break; for (const row of batch) { const noteId = row.ticketNoteRecId; const srServiceRecId = row.srServiceRecId; const memberRecId = row.memberRecId; const isAutomate = isAutomateApiAuthor( row.createdBy, row.originalAuthor ); if (isAutomate) { automateTotal++; if ( memberRecId && automateSampleMemberRecIds.length < 20 && !automateSampleMemberRecIds.includes(memberRecId) ) { automateSampleMemberRecIds.push(memberRecId); } } let blocked = false; if (!srServiceRecId) { missingSrServiceRecId++; if (isAutomate) { automateMissingSrServiceRecId++; } blocked = true; } else if (!apiTicketIds.has(srServiceRecId)) { missingParentTicketInApi++; if (isAutomate) { automateMissingParentTicketInApi++; } blocked = true; missingParentCounts.set( srServiceRecId, (missingParentCounts.get(srServiceRecId) ?? 0) + 1 ); if (sampleMissingParentTicketIds.length < 20) { sampleMissingParentTicketIds.push(srServiceRecId); } } if (!memberRecId) { missingMemberRecId++; if (isAutomate) { automateMissingMemberRecId++; } blocked = true; } else if (!apiUserMemberIds.has(memberRecId)) { missingAuthorMappingInApi++; if (isAutomate) { automateMissingAuthorMappingInApi++; } blocked = true; missingAuthorCounts.set( memberRecId, (missingAuthorCounts.get(memberRecId) ?? 0) + 1 ); if (sampleMissingAuthorMemberRecIds.length < 20) { sampleMissingAuthorMemberRecIds.push(memberRecId); } } if (!blocked) { eligibleForSync++; if (isAutomate) { automateEligibleForSync++; } if (!apiNoteIds.has(noteId)) { eligibleButMissingInApi++; if (isAutomate) { automateEligibleButMissingInApi++; if (automateSampleNoteIdsMissingInApi.length < 20) { automateSampleNoteIdsMissingInApi.push(noteId); } } if (sampleEligibleButMissingNoteIds.length < 50) { sampleEligibleButMissingNoteIds.push(noteId); } } } } cursor = batch[batch.length - 1]!.ticketNoteRecId; if (cursor % 50000 < batchSize) { console.log(`[diag] Progress cursor=${cursor}`); } } const summary: Summary = { cwTotal, apiTotal, eligibleForSync, missingSrServiceRecId, missingMemberRecId, missingParentTicketInApi, missingAuthorMappingInApi, eligibleButMissingInApi, sampleMissingParentTicketIds, sampleMissingAuthorMemberRecIds, sampleEligibleButMissingNoteIds, topMissingAuthorMemberRecIds: [...missingAuthorCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 20) .map(([memberRecId, count]) => ({ memberRecId, count })), topMissingParentTicketIds: [...missingParentCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 20) .map(([srServiceRecId, count]) => ({ srServiceRecId, count })), automateApi: { total: automateTotal, eligibleForSync: automateEligibleForSync, missingSrServiceRecId: automateMissingSrServiceRecId, missingMemberRecId: automateMissingMemberRecId, missingParentTicketInApi: automateMissingParentTicketInApi, missingAuthorMappingInApi: automateMissingAuthorMappingInApi, eligibleButMissingInApi: automateEligibleButMissingInApi, sampleMemberRecIds: automateSampleMemberRecIds, sampleNoteIdsMissingInApi: automateSampleNoteIdsMissingInApi, }, }; console.log("[diag] TicketNote sync gap summary:"); console.log(JSON.stringify(summary, null, 2)); } finally { await Promise.all([cwPrisma.$disconnect(), apiPrisma.$disconnect()]); } }; main().catch((error) => { console.error("[diag] Failed:", error); process.exit(1); });