Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 338 additions & 0 deletions docs/adex-handoff.md

Large diffs are not rendered by default.

114 changes: 0 additions & 114 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion src/app/api/agent/snapshot/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ export async function POST(req: NextRequest) {
for (const auth of auths) {
if (!isAdaptablePlatform(auth.platform)) continue
try {
const adapter = getAdapter(auth.platform, auth)
const linkedAccounts = await prisma.platformAccount.findMany({
where: { orgId: org.id, platform: auth.platform, isActive: true },
select: { accountId: true },
})
const linkedAccountIds = linkedAccounts.map(a => a.accountId).filter(Boolean)
const adapter = getAdapter(auth.platform, auth, linkedAccountIds)
const snap = await captureCampaignSnapshots({ adapter, orgId: org.id })
snapshots += snap.snapshotsTaken
orphans += snap.orphans
Expand Down
7 changes: 6 additions & 1 deletion src/app/api/cron/agent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,12 @@ export async function POST(req: NextRequest) {
for (const auth of auths) {
if (!isAdaptablePlatform(auth.platform)) continue
try {
const adapter = getAdapter(auth.platform, auth)
const linkedAccounts = await prisma.platformAccount.findMany({
where: { orgId: cfg.orgId, platform: auth.platform, isActive: true },
select: { accountId: true },
})
const linkedAccountIds = linkedAccounts.map(a => a.accountId).filter(Boolean)
const adapter = getAdapter(auth.platform, auth, linkedAccountIds)
const snap = await captureCampaignSnapshots({ adapter, orgId: cfg.orgId })
snapshotsTotal += snap.snapshotsTaken
orphansTotal += snap.orphans
Expand Down
10 changes: 9 additions & 1 deletion src/app/api/cron/daily/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,15 @@ async function syncOne(
): Promise<SyncMetrics | { error: string }> {
try {
if (isAdaptablePlatform(platform)) {
const adapter = getAdapter(platform, auth)
// Mirror /api/reports/sync: feed the adapter the workspace's linked
// PlatformAccount IDs so Google MCC iterates only the customer IDs
// the user explicitly linked (and skips the MCC itself as a leaf).
const linkedAccounts = await prisma.platformAccount.findMany({
where: { orgId: auth.orgId, platform, isActive: true },
select: { accountId: true },
})
const linkedAccountIds = linkedAccounts.map(a => a.accountId).filter(Boolean)
const adapter = getAdapter(platform, auth, linkedAccountIds)
const out = await runAdapterSync(adapter, {
orgId: auth.orgId,
userId: auth.userId,
Expand Down
11 changes: 10 additions & 1 deletion src/app/api/reports/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,16 @@ async function syncViaAdapter(
endDate: string,
today: Date
) {
const adapter = getAdapter(auth.platform, auth)
// Workspace may have N PlatformAccount rows under one PlatformAuth (Google
// MCC owning multiple customer IDs is the canonical case). Pass them to
// the adapter so it iterates exactly those accounts and uses auth.accountId
// purely as login-customer-id context.
const linkedAccounts = await prisma.platformAccount.findMany({
where: { orgId, platform: auth.platform, isActive: true },
select: { accountId: true },
})
const linkedAccountIds = linkedAccounts.map(a => a.accountId).filter(Boolean)
const adapter = getAdapter(auth.platform, auth, linkedAccountIds)
const out = await runAdapterSync(adapter, { orgId, userId, startDate, endDate, today })
return { success: true, ...out.account, campaignsWritten: out.campaignsWritten }
}
Expand Down
8 changes: 8 additions & 0 deletions src/lib/platforms/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,12 @@ export type AdapterFactoryInput = {
apiKey: string | null
// PlatformAuth.id — adapters use this to persist refreshed tokens
authId: string
// Optional: list of additional ad-account IDs scoped under the same auth.
// For Google Ads MCC this is the set of customer_client IDs the workspace
// has explicitly linked (PlatformAccount rows). When present, sync should
// iterate these instead of auto-discovering, and treat `accountId` (the
// MCC) purely as the login-customer-id context — not as a customer to pull
// reports from. Other multi-account platforms (TikTok, Meta business
// manager) can opt into the same convention later.
linkedAccountIds?: string[]
}
47 changes: 44 additions & 3 deletions src/lib/platforms/google-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ export class GoogleAdsAdapter extends BaseAdapter {
readonly platform = 'google' as const
readonly accountId: string
private client: GoogleAdsClient
// Normalized (dash-stripped) MCC id. Used purely as login-customer-id
// context — never iterated as a customer for report queries, because the
// MCC is a manager and has no campaigns of its own.
private mccId: string
// Customer IDs the workspace has explicitly linked (PlatformAccount rows).
// Empty array means "auto-discover" — we'll walk customer_client under the
// MCC, then fall back to listAccessibleCustomers.
private linkedAccountIds: string[]

constructor(input: AdapterFactoryInput) {
super(input.authId)
Expand All @@ -51,6 +59,10 @@ export class GoogleAdsAdapter extends BaseAdapter {
)
}
this.accountId = input.accountId
this.mccId = input.accountId.replace(/[-\s]/g, '')
this.linkedAccountIds = (input.linkedAccountIds || [])
.map(id => id.replace(/[-\s]/g, ''))
.filter(id => id && id !== this.mccId)
this.client = new GoogleAdsClient({
accessToken: input.accessToken || '',
refreshToken: input.refreshToken,
Expand All @@ -59,6 +71,35 @@ export class GoogleAdsAdapter extends BaseAdapter {
})
}

/**
* Pick the customer IDs to actually pull reports from.
*
* Order of preference:
* 1. Explicitly linked PlatformAccount rows (minus the MCC itself).
* 2. customer_client tree under the MCC (auto-discover, skip managers/hidden).
* 3. listAccessibleCustomers (legacy fallback — only the OAuth user's
* directly-linked accounts).
*
* We always filter out manager accounts (no campaigns) and skip the MCC
* even if a caller accidentally passes it in.
*/
private async resolveCustomerIds(): Promise<string[]> {
if (this.linkedAccountIds.length > 0) {
return this.linkedAccountIds
}
try {
const clients = await this.client.listMccClients(this.mccId)
const leaves = clients
.filter(c => !c.isManager && !c.hidden && c.status === 'ENABLED' && c.id && c.id !== this.mccId)
.map(c => c.id)
if (leaves.length > 0) return leaves
} catch {
// fall through to listAccessibleCustomers
}
const accessible = await this.client.listAccessibleCustomers()
return accessible.filter(id => id !== this.mccId)
}

async refreshAuth() {
const token = await safeCall(this.platform, () => this.client.refreshAccessToken())
await this.persistRefreshedToken(token)
Expand Down Expand Up @@ -219,7 +260,7 @@ export class GoogleAdsAdapter extends BaseAdapter {

async fetchCampaignList(): Promise<PlatformCampaignSnapshot[]> {
await this.refreshAuth()
const customerIds = await safeCall(this.platform, () => this.client.listAccessibleCustomers())
const customerIds = await safeCall(this.platform, () => this.resolveCustomerIds())
const out: PlatformCampaignSnapshot[] = []
for (const cid of customerIds) {
try {
Expand Down Expand Up @@ -250,7 +291,7 @@ export class GoogleAdsAdapter extends BaseAdapter {

async fetchAccountReport(range: DateRange): Promise<AccountReport> {
await this.refreshAuth()
const customerIds = await safeCall(this.platform, () => this.client.listAccessibleCustomers())
const customerIds = await safeCall(this.platform, () => this.resolveCustomerIds())
const acc: AccountReport = {
impressions: 0,
clicks: 0,
Expand Down Expand Up @@ -281,7 +322,7 @@ export class GoogleAdsAdapter extends BaseAdapter {

async fetchCampaignReport(range: DateRange): Promise<CampaignReport[]> {
await this.refreshAuth()
const customerIds = await safeCall(this.platform, () => this.client.listAccessibleCustomers())
const customerIds = await safeCall(this.platform, () => this.resolveCustomerIds())
const out: CampaignReport[] = []
for (const cid of customerIds) {
try {
Expand Down
68 changes: 66 additions & 2 deletions src/lib/platforms/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,76 @@ export class GoogleAdsClient {
}

/**
* Get customer client accounts under MCC
* List every customer_client under the configured MCC (login-customer-id).
* Returns the full tree (the MCC itself + every sub-account it manages),
* which is what the Google Ads UI shows when you click the account picker.
*
* Pure listAccessibleCustomers only returns accounts the OAuth user is
* *directly* linked to — it does NOT enumerate accounts owned through the
* MCC. So an MCC like 830-379-6268 will surface itself but hide its sub
* accounts (348-133-3068, 993-913-5964, ...). Querying customer_client
* against the MCC closes that gap.
*/
async listMccClients(
mccId?: string
): Promise<Array<{ id: string; name: string; isManager: boolean; hidden: boolean; status: string; level: number }>> {
const cid = (mccId || this.config.customerId || '').replace(/[-\s]/g, '')
if (!cid) throw new Error('listMccClients requires an MCC customer id')
const result = await this.search(
cid,
`SELECT customer_client.client_customer,
customer_client.id,
customer_client.descriptive_name,
customer_client.manager,
customer_client.hidden,
customer_client.level,
customer_client.status
FROM customer_client
WHERE customer_client.status != 'CLOSED'`
)
const rows = (result.results || []) as Array<Record<string, Record<string, unknown>>>
return rows.map(r => {
const cc = r.customer_client || r.customerClient || {}
return {
id: String(cc.id || ''),
name: String(cc.descriptiveName || cc.descriptive_name || 'Unnamed'),
isManager: Boolean(cc.manager),
hidden: Boolean(cc.hidden),
status: String(cc.status || 'UNKNOWN'),
level: Number(cc.level || 0),
}
}).filter(r => r.id)
}

/**
* Get customer client accounts under MCC.
*
* Strategy:
* 1. If the configured customerId is an MCC, walk its customer_client tree
* so sub-accounts surface even when they aren't directly OAuth-linked
* to the calling user.
* 2. Otherwise (or if the MCC query fails — e.g. wrong login-customer-id,
* no manager privilege), fall back to listAccessibleCustomers + a per
* account `customer` describe.
*/
async getClientAccounts(): Promise<Array<{ id: string; name: string; isManager: boolean }>> {
const mccId = (this.config.customerId || '').replace(/[-\s]/g, '')

if (mccId) {
try {
const clients = await this.listMccClients(mccId)
if (clients.length > 0) {
return clients
.filter(c => !c.hidden)
.map(c => ({ id: c.id, name: c.name, isManager: c.isManager }))
}
} catch {
// fall through to legacy listAccessibleCustomers
}
}

const customerIds = await this.listAccessibleCustomers()
const accounts: Array<{ id: string; name: string; isManager: boolean }> = []

for (const cid of customerIds) {
try {
const result = await this.search(cid, 'SELECT customer.id, customer.descriptive_name, customer.manager FROM customer LIMIT 1')
Expand Down
Loading
Loading