diff --git a/.env.example b/.env.example index d686a27..7787b88 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,14 @@ GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= # Optional override; default: {APP_URL}/api/admin/drive/callback # GOOGLE_OAUTH_REDIRECT_URI=https://app.4ventures.es/api/admin/drive/callback + +# Meta Ads discovery (long-lived / system user token) +META_ACCESS_TOKEN= + +# Google Ads discovery (MCC via marketing@) +GOOGLE_ADS_DEVELOPER_TOKEN= +GOOGLE_ADS_REFRESH_TOKEN= +# Optional: reuse GOOGLE_OAUTH_CLIENT_* or set dedicated: +# GOOGLE_ADS_CLIENT_ID= +# GOOGLE_ADS_CLIENT_SECRET= +# GOOGLE_ADS_LOGIN_CUSTOMER_ID= diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index abe8c8a..69e43d1 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -10,6 +10,7 @@ - Biblioteca de activos (archivos): `sql/asset_files.sql` + bucket privado `tenant-assets` (máx. 80 MB/archivo). UI: `/dashboard/activos`. Apply: `python3 scripts/dokploy_apply_asset_files.py` - Google Drive (agencia): `sql/google_drive.sql`. OAuth marketing@ → auto-match carpetas por nombre de tenant. Env Dokploy: `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `NEXT_PUBLIC_APP_URL`. Redirect: `https://app.4ventures.es/api/admin/drive/callback`. UI: Agencia → Conectar Drive. - Panel control clientes: `sql/agency_control.sql` (`agency_followed` + `integrations.enabled`). Matriz en Agencia + hub por cliente. +- Meta/Google Ads discovery: env `META_ACCESS_TOKEN`, `GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_REFRESH_TOKEN`, `GOOGLE_ADS_CLIENT_ID/SECRET`. UI: Agencia → Descubrir y emparejar. ## Login diff --git a/src/app/api/admin/ads/discover/route.ts b/src/app/api/admin/ads/discover/route.ts new file mode 100644 index 0000000..a084daf --- /dev/null +++ b/src/app/api/admin/ads/discover/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + isAgencyRole, + serviceHeaders, + userRole, +} from '@/lib/agency'; +import { discoverAndMatch, applyProviderMatches } from '@/lib/ads-discovery'; +import type { AccountMatch } from '@/lib/match'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 120; + +async function requireAgency() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user || !isAgencyRole(userRole(user))) { + return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }; + } + if (!serviceHeaders()) { + return { + error: NextResponse.json({ error: 'no service role' }, { status: 503 }), + }; + } + return { user }; +} + +/** POST — discover Meta/Google accounts and auto-match to tenants. + * Body: { apply?: boolean, providers?: ['meta_ads'|'google_ads'], + * assign?: { provider, tenantId, accountId, accountName } } + */ +export async function POST(req: NextRequest) { + const gate = await requireAgency(); + if ('error' in gate && gate.error) return gate.error; + + const body = await req.json().catch(() => ({})); + + if (body.assign?.tenantId && body.assign?.accountId && body.assign?.provider) { + const provider = body.assign.provider as 'meta_ads' | 'google_ads'; + if (provider !== 'meta_ads' && provider !== 'google_ads') { + return NextResponse.json({ error: 'provider inválido' }, { status: 400 }); + } + const fake: AccountMatch[] = [ + { + tenantId: body.assign.tenantId, + tenantName: '', + tenantSlug: '', + accountId: String(body.assign.accountId), + accountName: String(body.assign.accountName || body.assign.accountId), + score: 100, + status: 'matched', + }, + ]; + const { linked } = await applyProviderMatches( + provider, + fake, + provider === 'meta_ads' ? 'Meta Ads' : 'Google Ads' + ); + return NextResponse.json({ linked, manual: true }); + } + + try { + const result = await discoverAndMatch({ + apply: body.apply !== false, + providers: body.providers, + }); + return NextResponse.json(result); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'discover failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/ads/status/route.ts b/src/app/api/admin/ads/status/route.ts new file mode 100644 index 0000000..9a324ac --- /dev/null +++ b/src/app/api/admin/ads/status/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { isAgencyRole, userRole } from '@/lib/agency'; +import { + googleAdsConfigured, + metaConfigured, +} from '@/lib/ads-discovery'; + +export const dynamic = 'force-dynamic'; + +/** GET — whether Meta / Google Ads discovery credentials are ready. */ +export async function GET() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user || !isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json({ + meta: { configured: metaConfigured() }, + googleAds: { configured: googleAdsConfigured() }, + }); +} diff --git a/src/components/AdsDiscoverPanel.tsx b/src/components/AdsDiscoverPanel.tsx new file mode 100644 index 0000000..6f45b72 --- /dev/null +++ b/src/components/AdsDiscoverPanel.tsx @@ -0,0 +1,197 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { Loader2, Megaphone, RefreshCw } from 'lucide-react'; + +type MatchRow = { + tenantId: string; + tenantName: string; + tenantSlug: string; + accountId: string | null; + accountName: string | null; + score: number; + status: string; + candidates?: { id: string; name: string; score: number }[]; +}; + +type ProviderResult = { + accounts: number; + linked: number; + matches: MatchRow[]; + error?: string; +}; + +export default function AdsDiscoverPanel() { + const [metaOk, setMetaOk] = useState(false); + const [gadsOk, setGadsOk] = useState(false); + const [loading, setLoading] = useState(true); + const [running, setRunning] = useState(false); + const [meta, setMeta] = useState(null); + const [google, setGoogle] = useState(null); + const [msg, setMsg] = useState(null); + const [err, setErr] = useState(null); + + async function loadStatus() { + setLoading(true); + const res = await fetch('/api/admin/ads/status'); + const data = await res.json(); + setMetaOk(Boolean(data.meta?.configured)); + setGadsOk(Boolean(data.googleAds?.configured)); + setLoading(false); + } + + useEffect(() => { + void loadStatus(); + }, []); + + async function discover() { + setRunning(true); + setErr(null); + setMsg(null); + const res = await fetch('/api/admin/ads/discover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apply: true }), + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'Discover falló'); + } else { + setMeta(data.meta || null); + setGoogle(data.google || null); + const parts = []; + if (data.meta) { + parts.push( + `Meta: ${data.meta.linked}/${data.meta.accounts} enlazadas` + + (data.meta.error ? ` (${data.meta.error})` : '') + ); + } + if (data.google) { + parts.push( + `Google Ads: ${data.google.linked}/${data.google.accounts} enlazadas` + + (data.google.error ? ` (${data.google.error})` : '') + ); + } + setMsg(parts.join(' · ')); + } + setRunning(false); + } + + async function assign( + provider: 'meta_ads' | 'google_ads', + tenantId: string, + accountId: string, + accountName: string + ) { + await fetch('/api/admin/ads/discover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + assign: { provider, tenantId, accountId, accountName }, + }), + }); + await discover(); + } + + if (loading) { + return ( +

+ Ads… +

+ ); + } + + const ambiguous = [ + ...(meta?.matches || []) + .filter((m) => m.status === 'ambiguous') + .map((m) => ({ ...m, provider: 'meta_ads' as const })), + ...(google?.matches || []) + .filter((m) => m.status === 'ambiguous') + .map((m) => ({ ...m, provider: 'google_ads' as const })), + ]; + + return ( +
+
+
+ +
+

+ Meta Ads + Google Ads (agencia) +

+

+ Lista las cuentas accesibles con marketing@ / token de agencia y + las enlaza a cada cliente por nombre. +

+
+
+ +
+ +
+ + Meta {metaOk ? 'listo' : 'falta META_ACCESS_TOKEN'} + + + Google Ads{' '} + {gadsOk ? 'listo' : 'falta GOOGLE_ADS_DEVELOPER_TOKEN + refresh'} + +
+ + {msg &&

{msg}

} + {err &&

{err}

} + + {ambiguous.length > 0 && ( +
    + {ambiguous.map((m) => ( +
  • +
    +

    + {m.tenantName}{' '} + + · {m.provider === 'meta_ads' ? 'Meta' : 'GAds'} + +

    +

    Ambiguo — elige cuenta

    +
    +
    + {(m.candidates || []).map((c) => ( + + ))} +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/AgencyPortfolioBoard.tsx b/src/components/AgencyPortfolioBoard.tsx index 20baeb3..8191605 100644 --- a/src/components/AgencyPortfolioBoard.tsx +++ b/src/components/AgencyPortfolioBoard.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import DriveConnectPanel from '@/components/DriveConnectPanel'; +import AdsDiscoverPanel from '@/components/AdsDiscoverPanel'; import { AlertTriangle, Bell, @@ -179,6 +180,7 @@ export default function AgencyPortfolioBoard() { return (
+ {warning && (

diff --git a/src/lib/ads-discovery.ts b/src/lib/ads-discovery.ts new file mode 100644 index 0000000..80386ea --- /dev/null +++ b/src/lib/ads-discovery.ts @@ -0,0 +1,350 @@ +/** Discover Meta Ads + Google Ads accounts for agency and match to tenants. */ + +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; +import { + type AccountMatch, + type NamedAccount, + matchAccountsToTenants, +} from '@/lib/match'; + +const META_GRAPH = 'https://graph.facebook.com/v21.0'; +const GADS_API = 'https://googleads.googleapis.com/v18'; + +export function metaConfigured(): boolean { + return Boolean(process.env.META_ACCESS_TOKEN?.trim()); +} + +export function googleAdsConfigured(): boolean { + const hasRefresh = Boolean( + process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() || + process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim() + ); + const hasClient = Boolean( + (process.env.GOOGLE_ADS_CLIENT_ID?.trim() || + process.env.GOOGLE_OAUTH_CLIENT_ID?.trim()) && + (process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() || + process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim()) + ); + return Boolean( + process.env.GOOGLE_ADS_DEVELOPER_TOKEN?.trim() && hasRefresh && hasClient + ); +} + +function metaToken(): string { + const t = process.env.META_ACCESS_TOKEN?.trim(); + if (!t) throw new Error('Falta META_ACCESS_TOKEN'); + return t; +} + +async function googleAdsAccessToken(): Promise { + const refresh = + process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() || + process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim(); + const clientId = + process.env.GOOGLE_ADS_CLIENT_ID?.trim() || + process.env.GOOGLE_OAUTH_CLIENT_ID?.trim(); + const clientSecret = + process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() || + process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim(); + if (!refresh || !clientId || !clientSecret) { + throw new Error( + 'Faltan GOOGLE_ADS_REFRESH_TOKEN + CLIENT_ID/SECRET (o GOOGLE_OAUTH_*)' + ); + } + const body = new URLSearchParams({ + refresh_token: refresh, + client_id: clientId, + client_secret: clientSecret, + grant_type: 'refresh_token', + }); + const r = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!r.ok) { + throw new Error(`Google Ads token: ${(await r.text()).slice(0, 200)}`); + } + const j = (await r.json()) as { access_token: string }; + return j.access_token; +} + +export async function listMetaAdAccounts(): Promise { + const token = metaToken(); + const out: NamedAccount[] = []; + let url: string | null = + `${META_GRAPH}/me/adaccounts?fields=id,account_id,name,account_status&limit=200&access_token=${encodeURIComponent(token)}`; + while (url) { + const r = await fetch(url, { cache: 'no-store' }); + if (!r.ok) { + throw new Error(`Meta adaccounts: ${(await r.text()).slice(0, 300)}`); + } + const j = (await r.json()) as { + data?: { id: string; account_id?: string; name?: string }[]; + paging?: { next?: string }; + }; + for (const row of j.data || []) { + const id = row.account_id + ? `act_${row.account_id}` + : row.id.startsWith('act_') + ? row.id + : `act_${row.id}`; + out.push({ id, name: row.name || id }); + } + url = j.paging?.next || null; + } + return out; +} + +async function gadsHeaders( + accessToken: string, + loginCustomerId?: string +): Promise> { + const h: Record = { + Authorization: `Bearer ${accessToken}`, + 'developer-token': process.env.GOOGLE_ADS_DEVELOPER_TOKEN!.trim(), + 'Content-Type': 'application/json', + }; + const login = + loginCustomerId || + process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID?.trim()?.replace(/-/g, ''); + if (login) h['login-customer-id'] = login; + return h; +} + +export async function listGoogleAdsCustomers(): Promise { + const accessToken = await googleAdsAccessToken(); + const headers = await gadsHeaders(accessToken); + + const listRes = await fetch(`${GADS_API}/customers:listAccessibleCustomers`, { + method: 'GET', + headers, + cache: 'no-store', + }); + if (!listRes.ok) { + throw new Error( + `Google Ads accessible: ${(await listRes.text()).slice(0, 300)}` + ); + } + const listed = (await listRes.json()) as { resourceNames?: string[] }; + const ids = (listed.resourceNames || []).map((rn) => + rn.replace('customers/', '') + ); + + const out: NamedAccount[] = []; + for (const id of ids) { + try { + const h = await gadsHeaders(accessToken, id); + const r = await fetch( + `${GADS_API}/customers/${id}/googleAds:search`, + { + method: 'POST', + headers: h, + body: JSON.stringify({ + query: + 'SELECT customer.id, customer.descriptive_name, customer.manager FROM customer LIMIT 1', + }), + } + ); + if (!r.ok) continue; + const j = (await r.json()) as { + results?: { + customer?: { + id?: string; + descriptiveName?: string; + manager?: boolean; + }; + }[]; + }; + const c = j.results?.[0]?.customer; + if (!c) continue; + // Prefer leaf accounts for client match; still include managers with name + const name = c.descriptiveName || id; + out.push({ + id: String(c.id || id), + name: c.manager ? `${name} (MCC)` : name, + }); + + // If manager, also pull client accounts + if (c.manager) { + const clients = await fetch( + `${GADS_API}/customers/${id}/googleAds:search`, + { + method: 'POST', + headers: h, + body: JSON.stringify({ + query: `SELECT customer_client.id, customer_client.descriptive_name, customer_client.manager, customer_client.status + FROM customer_client + WHERE customer_client.status = 'ENABLED' + AND customer_client.manager = FALSE`, + }), + } + ); + if (clients.ok) { + const cj = (await clients.json()) as { + results?: { + customerClient?: { + id?: string; + descriptiveName?: string; + }; + }[]; + }; + for (const row of cj.results || []) { + const cc = row.customerClient; + if (!cc?.id) continue; + out.push({ + id: String(cc.id), + name: cc.descriptiveName || String(cc.id), + }); + } + } + } + } catch { + // skip inaccessible + } + } + + // Dedupe by id + const map = new Map(); + for (const a of out) map.set(a.id, a); + return [...map.values()]; +} + +export async function applyProviderMatches( + provider: 'meta_ads' | 'google_ads', + matches: AccountMatch[], + labelFallback: string +): Promise<{ linked: number }> { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const base = supabaseUrl(); + let linked = 0; + for (const m of matches) { + if (m.status !== 'matched' || !m.accountId) continue; + const patch = { + status: 'connected', + external_account_id: m.accountId, + label: m.accountName || labelFallback, + last_sync_at: new Date().toISOString(), + error_message: null, + enabled: true, + meta: { + account_name: m.accountName, + match_score: m.score, + matched_at: new Date().toISOString(), + source: 'auto_discover', + }, + updated_at: new Date().toISOString(), + }; + const url = `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(m.tenantId)}&provider=eq.${provider}`; + let r = await fetch(url, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify(patch), + }); + if (!r.ok) { + r = await fetch(`${base}/rest/v1/integrations`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'resolution=merge-duplicates', + }, + body: JSON.stringify({ + tenant_id: m.tenantId, + provider, + ...patch, + }), + }); + } + if (r.ok) linked += 1; + } + return { linked }; +} + +export async function discoverAndMatch(opts?: { + apply?: boolean; + providers?: Array<'meta_ads' | 'google_ads'>; +}): Promise<{ + meta?: { accounts: number; linked: number; matches: AccountMatch[]; error?: string }; + google?: { accounts: number; linked: number; matches: AccountMatch[]; error?: string }; +}> { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const tRes = await fetch( + `${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`, + { headers, cache: 'no-store' } + ); + if (!tRes.ok) throw new Error(await tRes.text()); + const tenants = (await tRes.json()) as { + id: string; + name: string; + slug: string; + }[]; + + const want = opts?.providers || ['meta_ads', 'google_ads']; + const apply = opts?.apply !== false; + const result: Awaited> = {}; + + if (want.includes('meta_ads')) { + try { + if (!metaConfigured()) { + result.meta = { + accounts: 0, + linked: 0, + matches: [], + error: 'META_ACCESS_TOKEN no configurado', + }; + } else { + const accounts = await listMetaAdAccounts(); + const matches = matchAccountsToTenants(tenants, accounts); + const linked = apply + ? (await applyProviderMatches('meta_ads', matches, 'Meta Ads')).linked + : 0; + result.meta = { accounts: accounts.length, linked, matches }; + } + } catch (e) { + result.meta = { + accounts: 0, + linked: 0, + matches: [], + error: e instanceof Error ? e.message : 'meta error', + }; + } + } + + if (want.includes('google_ads')) { + try { + if (!googleAdsConfigured()) { + result.google = { + accounts: 0, + linked: 0, + matches: [], + error: 'GOOGLE_ADS_* no configurado', + }; + } else { + const accounts = await listGoogleAdsCustomers(); + const matches = matchAccountsToTenants(tenants, accounts); + const linked = apply + ? ( + await applyProviderMatches('google_ads', matches, 'Google Ads') + ).linked + : 0; + result.google = { accounts: accounts.length, linked, matches }; + } + } catch (e) { + result.google = { + accounts: 0, + linked: 0, + matches: [], + error: e instanceof Error ? e.message : 'google error', + }; + } + } + + return result; +} diff --git a/src/lib/match.ts b/src/lib/match.ts new file mode 100644 index 0000000..3166f50 --- /dev/null +++ b/src/lib/match.ts @@ -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(); + const usedTenants = new Set(); + const assigned = new Map(); + + 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, + }; + }); +}