feat: auto-discover Meta and Google Ads accounts for tenants
Agency panel lists accessible ad accounts and matches them to clients by name, same pattern as Drive folders. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
136
src/lib/match.ts
Normal file
136
src/lib/match.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/** Shared fuzzy match: account/folder name ↔ tenant name/slug. */
|
||||
|
||||
export function normalizeName(raw: string): string {
|
||||
return raw
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function scoreNameToTenant(
|
||||
accountName: string,
|
||||
tenantName: string,
|
||||
tenantSlug: string
|
||||
): number {
|
||||
const f = normalizeName(accountName);
|
||||
const n = normalizeName(tenantName);
|
||||
const s = normalizeName(tenantSlug.replace(/-/g, ' '));
|
||||
if (!f || (!n && !s)) return 0;
|
||||
if (f === n || f === s) return 100;
|
||||
if (n && (f.includes(n) || n.includes(f))) return 85;
|
||||
if (s && (f.includes(s) || s.includes(f))) return 80;
|
||||
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
|
||||
const nt = new Set(
|
||||
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
|
||||
);
|
||||
if (!ft.size || !nt.size) return 0;
|
||||
let hit = 0;
|
||||
for (const t of nt) if (ft.has(t)) hit += 1;
|
||||
const ratio = hit / nt.size;
|
||||
if (ratio >= 0.8) return 70;
|
||||
if (ratio >= 0.5) return 55;
|
||||
return Math.round(ratio * 50);
|
||||
}
|
||||
|
||||
export type NamedAccount = { id: string; name: string };
|
||||
|
||||
export type AccountMatch = {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
tenantSlug: string;
|
||||
accountId: string | null;
|
||||
accountName: string | null;
|
||||
score: number;
|
||||
status: 'matched' | 'ambiguous' | 'unmatched';
|
||||
candidates?: { id: string; name: string; score: number }[];
|
||||
};
|
||||
|
||||
export function matchAccountsToTenants(
|
||||
tenants: { id: string; name: string; slug: string }[],
|
||||
accounts: NamedAccount[],
|
||||
opts?: { threshold?: number; ambiguousGap?: number }
|
||||
): AccountMatch[] {
|
||||
const THRESHOLD = opts?.threshold ?? 55;
|
||||
const AMBIGUOUS_GAP = opts?.ambiguousGap ?? 8;
|
||||
|
||||
type Scored = { account: NamedAccount; tenantId: string; score: number };
|
||||
const all: Scored[] = [];
|
||||
for (const t of tenants) {
|
||||
for (const a of accounts) {
|
||||
const score = scoreNameToTenant(a.name, t.name, t.slug);
|
||||
if (score >= THRESHOLD) {
|
||||
all.push({ account: a, tenantId: t.id, score });
|
||||
}
|
||||
}
|
||||
}
|
||||
all.sort((a, b) => b.score - a.score);
|
||||
|
||||
const usedAccounts = new Set<string>();
|
||||
const usedTenants = new Set<string>();
|
||||
const assigned = new Map<string, { account: NamedAccount; score: number }>();
|
||||
|
||||
for (const row of all) {
|
||||
if (usedAccounts.has(row.account.id) || usedTenants.has(row.tenantId)) {
|
||||
continue;
|
||||
}
|
||||
const rivals = all.filter(
|
||||
(x) =>
|
||||
x.tenantId === row.tenantId &&
|
||||
x.account.id !== row.account.id &&
|
||||
!usedAccounts.has(x.account.id) &&
|
||||
row.score - x.score < AMBIGUOUS_GAP &&
|
||||
x.score >= THRESHOLD
|
||||
);
|
||||
if (rivals.length) continue;
|
||||
assigned.set(row.tenantId, { account: row.account, score: row.score });
|
||||
usedAccounts.add(row.account.id);
|
||||
usedTenants.add(row.tenantId);
|
||||
}
|
||||
|
||||
return tenants.map((t) => {
|
||||
const hit = assigned.get(t.id);
|
||||
if (hit) {
|
||||
return {
|
||||
tenantId: t.id,
|
||||
tenantName: t.name,
|
||||
tenantSlug: t.slug,
|
||||
accountId: hit.account.id,
|
||||
accountName: hit.account.name,
|
||||
score: hit.score,
|
||||
status: 'matched' as const,
|
||||
};
|
||||
}
|
||||
const cands = all
|
||||
.filter((x) => x.tenantId === t.id)
|
||||
.slice(0, 5)
|
||||
.map((x) => ({
|
||||
id: x.account.id,
|
||||
name: x.account.name,
|
||||
score: x.score,
|
||||
}));
|
||||
if (cands.length) {
|
||||
return {
|
||||
tenantId: t.id,
|
||||
tenantName: t.name,
|
||||
tenantSlug: t.slug,
|
||||
accountId: null,
|
||||
accountName: null,
|
||||
score: cands[0].score,
|
||||
status: 'ambiguous' as const,
|
||||
candidates: cands,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tenantId: t.id,
|
||||
tenantName: t.name,
|
||||
tenantSlug: t.slug,
|
||||
accountId: null,
|
||||
accountName: null,
|
||||
score: 0,
|
||||
status: 'unmatched' as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user