import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { Prisma, PrismaClient } from '@prisma/client'; import { canonicalIranMobile, storedPhoneNumber } from './normalize'; const personSelect = { id: true, createdAt: true, firstName: true, lastName: true, gender: true, showGenderInName: true, previousFirstName: true, previousLastName: true, email: true, phone: true, mobile: true, position: true, website: true, instagram: true, birthDate: true, referrer: true, addresses: true, notes: true, tags: true, companyId: true, customFields: true, importSource: true, sourceRef: true, previousImportSource: true, previousSourceRef: true, loyaltyPoints: true, discLabel: true, discReliable: true, discAssessedAt: true, } satisfies Prisma.PersonSelect; type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>; function blank(value: string | null | undefined) { return !value?.trim(); } function asStrings(value: Prisma.JsonValue): string[] { if (!Array.isArray(value)) return []; return value.map((item) => String(item)).filter(Boolean); } function asObject(value: Prisma.JsonValue): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; return { ...(value as Record) }; } function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput { const notes = [keeper.notes?.trim() || '']; let email = keeper.email; let phone = storedPhoneNumber(keeper.phone); let position = keeper.position; let website = keeper.website; let instagram = keeper.instagram; let birthDate = keeper.birthDate; let referrer = keeper.referrer; let companyId = keeper.companyId; let gender = keeper.gender; let showGenderInName = keeper.showGenderInName; let previousFirstName = keeper.previousFirstName; let previousLastName = keeper.previousLastName; let previousImportSource = keeper.previousImportSource; let previousSourceRef = keeper.previousSourceRef; let discLabel = keeper.discLabel; let discReliable = keeper.discReliable; let discAssessedAt = keeper.discAssessedAt; let loyaltyPoints = keeper.loyaltyPoints; const addresses = asStrings(keeper.addresses); const tags = new Set(keeper.tags); const customFields = asObject(keeper.customFields); for (const extra of extras) { const name = `${extra.firstName} ${extra.lastName}`.trim(); notes.push( `ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`, ); if (blank(email) && extra.email) email = extra.email; if (blank(phone)) phone = storedPhoneNumber(extra.phone); if (blank(position) && extra.position) position = extra.position; if (blank(website) && extra.website) website = extra.website; if (blank(instagram) && extra.instagram) instagram = extra.instagram; if (!birthDate && extra.birthDate) birthDate = extra.birthDate; if (blank(referrer) && extra.referrer) referrer = extra.referrer; if (!companyId && extra.companyId) companyId = extra.companyId; if (gender === 'none' && extra.gender !== 'none') { gender = extra.gender; showGenderInName = extra.showGenderInName; } if (blank(previousFirstName) && extra.previousFirstName) { previousFirstName = extra.previousFirstName; } if (blank(previousLastName) && extra.previousLastName) { previousLastName = extra.previousLastName; } if ( blank(previousImportSource) && extra.importSource && extra.importSource !== keeper.importSource ) { previousImportSource = extra.importSource; previousSourceRef = extra.sourceRef; } if ( keeper.importSource === 'tahesab' && blank(previousFirstName) && blank(previousLastName) ) { const extraName = `${extra.firstName} ${extra.lastName}`.trim(); const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim(); if (extraName && extraName !== keeperName) { previousFirstName = extra.firstName || null; previousLastName = extra.lastName || null; } } if (!discLabel && extra.discLabel) { discLabel = extra.discLabel; discReliable = extra.discReliable; discAssessedAt = extra.discAssessedAt; } loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints); for (const address of asStrings(extra.addresses)) { if (!addresses.includes(address)) addresses.push(address); } for (const tag of extra.tags) tags.add(tag); for (const [key, value] of Object.entries(asObject(extra.customFields))) { if (customFields[key] == null || customFields[key] === '') customFields[key] = value; } } return { mobile: canon, email, phone, position, website, instagram, birthDate, referrer, companyId, gender, showGenderInName, previousFirstName, previousLastName, previousImportSource, previousSourceRef, discLabel, discReliable, discAssessedAt, loyaltyPoints, addresses, tags: [...tags], customFields: customFields as Prisma.InputJsonValue, notes: notes.filter(Boolean).join('\n') || null, }; } async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) { const rows = await prisma.$queryRaw<{ table_name: string }[]>` SELECT table_name FROM information_schema.columns WHERE table_schema = 'public' AND column_name = 'personId' `; return rows .map((row) => row.table_name) .filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person'); } async function moveLinks( tx: Prisma.TransactionClient, tables: string[], keeperId: string, dupId: string, ) { await tx.$executeRaw` DELETE FROM "PersonGroupLink" AS d USING "PersonGroupLink" AS k WHERE d."personId" = ${dupId} AND k."personId" = ${keeperId} AND d."groupId" = k."groupId" `; await tx.$executeRaw` DELETE FROM "PersonAccountingLink" AS d USING "PersonAccountingLink" AS k WHERE d."personId" = ${dupId} AND k."personId" = ${keeperId} AND d."connectionId" = k."connectionId" `; await tx.$executeRaw` DELETE FROM "PartyOpeningBalance" WHERE "personId" = ${dupId} AND EXISTS ( SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId} ) `; for (const table of tables) { await tx.$executeRawUnsafe( `UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`, keeperId, dupId, ); } await tx.$executeRaw` UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId} `; await tx.$executeRaw` UPDATE "MontifyLink" SET "entityId" = ${keeperId} WHERE "entityType" = 'customer' AND "entityId" = ${dupId} `; await tx.$executeRaw` UPDATE "Notification" SET "entityId" = ${keeperId} WHERE "entityType" = 'person' AND "entityId" = ${dupId} `; await tx.$executeRaw` UPDATE "GamificationTransaction" SET "entityId" = ${keeperId} WHERE "entityType" = 'person' AND "entityId" = ${dupId} `; await tx.$executeRaw` UPDATE "FinancialAuditLog" SET "entityId" = ${keeperId} WHERE "entityType" = 'person' AND "entityId" = ${dupId} `; } export async function mergeSameMobilePeople( prisma: PrismaClient, organizationId: string, apply: boolean, ) { const people = await prisma.person.findMany({ where: { organizationId }, select: personSelect, orderBy: { createdAt: 'asc' }, }); const groups = new Map(); for (const person of people) { const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone); if (!key) continue; const list = groups.get(key) ?? []; list.push(person); groups.set(key, list); } const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1); const preview = duplicateGroups.map(([mobile, list]) => ({ mobile, people: list.map((person) => ({ id: person.id, stored: person.mobile, name: `${person.firstName} ${person.lastName}`.trim(), })), })); if (!apply) { const needsRewrite = people.filter((person) => { const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone); return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone))); }).length; return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview }; } const tables = await personIdTables(prisma); let merged = 0; let rewritten = 0; let failed = 0; for (const [canon, list] of groups) { const ordered = [...list].sort((a, b) => { const aTahesab = a.importSource === 'tahesab' ? 0 : 1; const bTahesab = b.importSource === 'tahesab' ? 0 : 1; if (aTahesab !== bTahesab) return aTahesab - bTahesab; return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id); }); const [keeper, ...extras] = ordered; if (!keeper) continue; if (!extras.length) { const phoneCanon = canonicalIranMobile(keeper.phone); const data: Prisma.PersonUpdateInput = {}; if (keeper.mobile !== canon) data.mobile = canon; if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon; if (Object.keys(data).length) { await prisma.person.update({ where: { id: keeper.id }, data }); rewritten += 1; } continue; } try { await prisma.$transaction( async (tx) => { await tx.person.update({ where: { id: keeper.id }, data: mergedFields(keeper, extras, canon), }); for (const extra of extras) { await moveLinks(tx, tables, keeper.id, extra.id); await tx.person.delete({ where: { id: extra.id } }); merged += 1; } }, { timeout: 60_000 }, ); } catch (error) { failed += 1; console.error( `merge mobile ${canon} failed`, error instanceof Error ? error.message : error, ); } } return { merged, rewritten, needsRewrite: 0, failed, groups: preview }; } function loadEnv(file: string) { const text = readFileSync(file, 'utf8'); for (const line of text.split(/\r?\n/)) { const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/); if (!match || process.env[match[1]]) continue; let value = match[2].trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } process.env[match[1]] = value; } } const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile'); if (direct) { loadEnv(resolve(__dirname, '../../.env')); const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6); const apply = process.argv.includes('--apply'); if (!orgId) { console.error('org id required: --org='); process.exit(1); } const prisma = new PrismaClient(); mergeSameMobilePeople(prisma, orgId, apply) .then((result) => { console.log(JSON.stringify({ apply, merged: result.merged, rewritten: result.rewritten, needsRewrite: result.needsRewrite, groupCount: result.groups.length, groups: result.groups, }, null, 2)); }) .catch((error) => { console.error(error); process.exitCode = 1; }) .finally(() => prisma.$disconnect()); }