fix(contacts): merge the same mobile stored on phone or mobile

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-05 11:27:05 +03:30
co-authored by Cursor
parent 57a0b379f6
commit c976c0bcd6
2 changed files with 60 additions and 40 deletions
+34 -28
View File
@@ -229,7 +229,7 @@ export async function mergeSameMobilePeople(
}); });
const groups = new Map<string, PersonRow[]>(); const groups = new Map<string, PersonRow[]>();
for (const person of people) { for (const person of people) {
const key = canonicalIranMobile(person.mobile); const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
if (!key) continue; if (!key) continue;
const list = groups.get(key) ?? []; const list = groups.get(key) ?? [];
list.push(person); list.push(person);
@@ -248,15 +248,16 @@ export async function mergeSameMobilePeople(
if (!apply) { if (!apply) {
const needsRewrite = people.filter((person) => { const needsRewrite = people.filter((person) => {
const canon = canonicalIranMobile(person.mobile); const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
return Boolean(canon && person.mobile !== canon); return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
}).length; }).length;
return { merged: 0, rewritten: 0, needsRewrite, groups: preview }; return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
} }
const tables = await personIdTables(prisma); const tables = await personIdTables(prisma);
let merged = 0; let merged = 0;
let rewritten = 0; let rewritten = 0;
let failed = 0;
for (const [canon, list] of groups) { for (const [canon, list] of groups) {
const ordered = [...list].sort((a, b) => { const ordered = [...list].sort((a, b) => {
@@ -268,37 +269,42 @@ export async function mergeSameMobilePeople(
const [keeper, ...extras] = ordered; const [keeper, ...extras] = ordered;
if (!keeper) continue; if (!keeper) continue;
if (!extras.length) { if (!extras.length) {
const phone = storedPhoneNumber(keeper.phone); const phoneCanon = canonicalIranMobile(keeper.phone);
if (keeper.mobile !== canon || (phone && keeper.phone !== phone)) { const data: Prisma.PersonUpdateInput = {};
await prisma.person.update({ if (keeper.mobile !== canon) data.mobile = canon;
where: { id: keeper.id }, if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
data: { if (Object.keys(data).length) {
mobile: canon, await prisma.person.update({ where: { id: keeper.id }, data });
...(phone && keeper.phone !== phone ? { phone } : {}),
},
});
rewritten += 1; rewritten += 1;
} }
continue; continue;
} }
await prisma.$transaction( try {
async (tx) => { await prisma.$transaction(
await tx.person.update({ async (tx) => {
where: { id: keeper.id }, await tx.person.update({
data: mergedFields(keeper, extras, canon), where: { id: keeper.id },
}); data: mergedFields(keeper, extras, canon),
for (const extra of extras) { });
await moveLinks(tx, tables, keeper.id, extra.id); for (const extra of extras) {
await tx.person.delete({ where: { id: extra.id } }); await moveLinks(tx, tables, keeper.id, extra.id);
merged += 1; await tx.person.delete({ where: { id: extra.id } });
} merged += 1;
}, }
{ timeout: 60_000 }, },
); { timeout: 60_000 },
);
} catch (error) {
failed += 1;
console.error(
`merge mobile ${canon} failed`,
error instanceof Error ? error.message : error,
);
}
} }
return { merged, rewritten, needsRewrite: 0, groups: preview }; return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
} }
function loadEnv(file: string) { function loadEnv(file: string) {
@@ -2,7 +2,7 @@ import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { mergeSameMobilePeople } from './merge-same-mobile'; import { mergeSameMobilePeople } from './merge-same-mobile';
const FIX_KEY = 'person-mobile-canonical-v1'; const FIX_KEY = 'person-mobile-canonical-v2';
@Injectable() @Injectable()
export class SameMobileMergeService implements OnApplicationBootstrap { export class SameMobileMergeService implements OnApplicationBootstrap {
@@ -21,17 +21,20 @@ export class SameMobileMergeService implements OnApplicationBootstrap {
SELECT 1 AS ok SELECT 1 AS ok
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'public' WHERE table_schema = 'public'
AND table_name = 'Person' AND lower(table_name) = 'person'
AND column_name = 'gender' AND lower(column_name) = 'gender'
`; `;
if (!ready.length) return; if (!ready.length) {
this.logger.warn('یکسان‌سازی موبایل رد شد: ستون gender هنوز نیست');
return;
}
const table = await this.prisma.$queryRaw<{ ok: number }[]>` await this.prisma.$executeRaw`
SELECT 1 AS ok CREATE TABLE IF NOT EXISTS "DataFix" (
FROM information_schema.tables "key" TEXT PRIMARY KEY,
WHERE table_schema = 'public' AND table_name = 'DataFix' "doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`; `;
if (!table.length) return;
const done = await this.prisma.$queryRaw<{ key: string }[]>` const done = await this.prisma.$queryRaw<{ key: string }[]>`
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY} SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
@@ -48,11 +51,22 @@ export class SameMobileMergeService implements OnApplicationBootstrap {
const orgs = await this.prisma.organization.findMany({ select: { id: true } }); const orgs = await this.prisma.organization.findMany({ select: { id: true } });
let merged = 0; let merged = 0;
let rewritten = 0; let rewritten = 0;
let failed = 0;
for (const org of orgs) { for (const org of orgs) {
const result = await mergeSameMobilePeople(this.prisma, org.id, true); try {
merged += result.merged; const result = await mergeSameMobilePeople(this.prisma, org.id, true);
rewritten += result.rewritten; merged += result.merged;
rewritten += result.rewritten;
failed += result.failed;
} catch (error) {
failed += 1;
this.logger.error(
`یکسان‌سازی موبایل برای محیط ${org.id} انجام نشد`,
error instanceof Error ? error.message : String(error),
);
}
} }
if (failed) return;
await this.prisma.$executeRaw` await this.prisma.$executeRaw`
INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY}) INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY})
ON CONFLICT ("key") DO NOTHING ON CONFLICT ("key") DO NOTHING