@bhooai/nexus-ads 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # @bhooai/nexus-ads
2
+
3
+ Google Ads API client: OAuth2, campaigns, and a GAQL (Google Ads Query Language)
4
+ reporting builder.
5
+
6
+ ## Exports
7
+
8
+ - `createGoogleAds(config, opts)` → `GoogleAdsClient`.
9
+ - **GaqlBuilder** — build reporting queries fluently.
10
+ - `fetchTransport` — the authenticated transport helper.
11
+ - Types for campaigns, reporting, and config.
12
+
13
+ OAuth2 credentials are configured via `NEXUS_ADS_*` / `nexus.config.ts`. Tests mock
14
+ the HTTP transport (no live Google Ads calls).
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@bhooai/nexus-ads",
3
+ "version": "0.1.0",
4
+ "publishConfig": { "access": "public" },
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "scripts": {
9
+ "build": "tsc -p tsconfig.json",
10
+ "test": "vitest run"
11
+ },
12
+ "dependencies": {
13
+ "@bhooai/nexus-core": "^0.1.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "^22.5.0",
17
+ "typescript": "^5.6.2",
18
+ "vitest": "^2.1.1"
19
+ }
20
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * GAQL (Google Ads Query Language) builder. Produces queries like:
3
+ * SELECT campaign.id, campaign.name FROM campaign WHERE campaign.status = 'ENABLED' ORDER BY campaign.id LIMIT 50
4
+ */
5
+ export class GaqlBuilder {
6
+ private fields: string[] = [];
7
+ private resource: string = '';
8
+ private conditions: string[] = [];
9
+ private order?: { field: string; dir: 'ASC' | 'DESC' };
10
+ private limitN?: number;
11
+
12
+ select(...fields: string[]): this {
13
+ this.fields.push(...fields);
14
+ return this;
15
+ }
16
+ from(resource: string): this {
17
+ this.resource = resource;
18
+ return this;
19
+ }
20
+ /** Add a condition, e.g. `where("campaign.status = 'ENABLED'")`. */
21
+ where(condition: string): this {
22
+ this.conditions.push(condition);
23
+ return this;
24
+ }
25
+ orderBy(field: string, dir: 'ASC' | 'DESC' = 'ASC'): this {
26
+ this.order = { field, dir };
27
+ return this;
28
+ }
29
+ limit(n: number): this {
30
+ this.limitN = n;
31
+ return this;
32
+ }
33
+
34
+ build(): string {
35
+ if (!this.resource) throw new Error('[nexus-ads] GaqlBuilder: FROM resource required');
36
+ if (this.fields.length === 0) throw new Error('[nexus-ads] GaqlBuilder: at least one field required');
37
+ let q = `SELECT ${this.fields.join(', ')} FROM ${this.resource}`;
38
+ if (this.conditions.length) q += ` WHERE ${this.conditions.join(' AND ')}`;
39
+ if (this.order) q += ` ORDER BY ${this.order.field} ${this.order.dir}`;
40
+ if (this.limitN != null) q += ` LIMIT ${this.limitN}`;
41
+ return q;
42
+ }
43
+ }
44
+
45
+ /** Convenience: standard campaign listing query. */
46
+ export function campaignsQuery(limit = 50): string {
47
+ return new GaqlBuilder()
48
+ .select('campaign.id', 'campaign.name', 'campaign.status', 'campaign.advertising_channel_type')
49
+ .from('campaign')
50
+ .orderBy('campaign.id')
51
+ .limit(limit)
52
+ .build();
53
+ }
54
+
55
+ /** Convenience: campaign performance metrics for a date range (YYYY-MM-DD). */
56
+ export function campaignMetricsQuery(fromDate: string, toDate: string, limit = 50): string {
57
+ return new GaqlBuilder()
58
+ .select(
59
+ 'campaign.id',
60
+ 'campaign.name',
61
+ 'metrics.impressions',
62
+ 'metrics.clicks',
63
+ 'metrics.cost_micros',
64
+ 'metrics.conversions',
65
+ )
66
+ .from('campaign')
67
+ .where(`segments.date >= '${fromDate}'`)
68
+ .where(`segments.date <= '${toDate}'`)
69
+ .orderBy('campaign.id')
70
+ .limit(limit)
71
+ .build();
72
+ }
@@ -0,0 +1,121 @@
1
+ import type { GoogleAdsConfig, HttpTransport, AdsRow, Campaign } from './types.js';
2
+
3
+ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
4
+
5
+ /**
6
+ * Google Ads REST client (no grpc/native deps). Uses the Google Ads API REST
7
+ * endpoint `googleAds:search` with GAQL. OAuth2 access tokens are acquired via
8
+ * the refresh-token grant and cached until near expiry. The HTTP transport is
9
+ * injectable so tests run without live credentials.
10
+ */
11
+ export class GoogleAdsClient {
12
+ private readonly cfg: GoogleAdsConfig;
13
+ private readonly transport: HttpTransport;
14
+ private readonly apiVersion: string;
15
+ private token: { value: string; expiresAt: number } | null = null;
16
+
17
+ constructor(cfg: GoogleAdsConfig, transport: HttpTransport, apiVersion = 'v17') {
18
+ this.cfg = cfg;
19
+ this.transport = transport;
20
+ this.apiVersion = apiVersion;
21
+ if (!cfg.developerToken || !cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
22
+ throw new Error('[nexus-ads] developerToken, clientId, clientSecret, refreshToken all required');
23
+ }
24
+ }
25
+
26
+ private baseUrl(): string {
27
+ return `https://googleads.googleapis.com/${this.apiVersion}`;
28
+ }
29
+
30
+ /** OAuth2 refresh-token → access token, cached with a 60s safety margin. */
31
+ private async accessToken(): Promise<string> {
32
+ if (this.token && Date.now() < this.token.expiresAt - 60_000) return this.token.value;
33
+ const body = new URLSearchParams({
34
+ client_id: this.cfg.clientId,
35
+ client_secret: this.cfg.clientSecret,
36
+ refresh_token: this.cfg.refreshToken,
37
+ grant_type: 'refresh_token',
38
+ }).toString();
39
+ const res = await this.transport({
40
+ method: 'POST',
41
+ url: TOKEN_URL,
42
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
43
+ body,
44
+ });
45
+ const parsed = safeJson(res.body);
46
+ if (res.status !== 200 || !parsed?.access_token) {
47
+ throw new Error(`[nexus-ads] token refresh failed: HTTP ${res.status} ${res.body}`);
48
+ }
49
+ this.token = { value: parsed.access_token, expiresAt: Date.now() + (parsed.expires_in ?? 3600) * 1000 };
50
+ return this.token.value;
51
+ }
52
+
53
+ private async searchHeaders(): Promise<Record<string, string>> {
54
+ return {
55
+ authorization: `Bearer ${await this.accessToken()}`,
56
+ 'developer-token': this.cfg.developerToken,
57
+ 'content-type': 'application/json',
58
+ ...(this.cfg.loginCustomerId ? { 'login-customer-id': normalizeId(this.cfg.loginCustomerId) } : {}),
59
+ };
60
+ }
61
+
62
+ /** Run a GAQL query against a customer and return all rows (handles pagination). */
63
+ async search(customerId: string, query: string): Promise<AdsRow[]> {
64
+ const headers = await this.searchHeaders();
65
+ let pageToken: string | undefined;
66
+ const rows: AdsRow[] = [];
67
+ do {
68
+ const body = JSON.stringify({ query, ...(pageToken ? { pageToken } : {}) });
69
+ const res = await this.transport({
70
+ method: 'POST',
71
+ url: `${this.baseUrl()}/customers/${normalizeId(customerId)}/googleAds:search`,
72
+ headers,
73
+ body,
74
+ });
75
+ const parsed = safeJson(res.body);
76
+ if (res.status !== 200) {
77
+ throw new Error(`[nexus-ads] search failed: HTTP ${res.status} ${res.body}`);
78
+ }
79
+ if (Array.isArray(parsed?.results)) rows.push(...parsed.results);
80
+ pageToken = parsed?.nextPageToken;
81
+ } while (pageToken);
82
+ return rows;
83
+ }
84
+
85
+ /** List campaigns on the default customer. */
86
+ async listCampaigns(customerId = this.cfg.customerId, limit = 50): Promise<Campaign[]> {
87
+ const rows = await this.search(customerId, listCampaignsGaql(limit));
88
+ return rows.map(rowToCampaign);
89
+ }
90
+
91
+ /** Campaign metrics for a date range (YYYY-MM-DD). */
92
+ async campaignMetrics(fromDate: string, toDate: string, customerId = this.cfg.customerId, limit = 50): Promise<AdsRow[]> {
93
+ const rows = await this.search(customerId, metricsGaql(fromDate, toDate, limit));
94
+ return rows;
95
+ }
96
+ }
97
+
98
+ function listCampaignsGaql(limit: number): string {
99
+ return `SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type FROM campaign ORDER BY campaign.id LIMIT ${limit}`;
100
+ }
101
+ function metricsGaql(from: string, to: string, limit: number): string {
102
+ return `SELECT campaign.id, campaign.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date >= '${from}' AND segments.date <= '${to}' ORDER BY campaign.id LIMIT ${limit}`;
103
+ }
104
+
105
+ function rowToCampaign(row: AdsRow): Campaign {
106
+ const c = (row.campaign ?? {}) as Record<string, unknown>;
107
+ return {
108
+ id: String(c.id ?? ''),
109
+ name: String(c.name ?? ''),
110
+ status: String(c.status ?? ''),
111
+ advertisingChannelType: c.advertisingChannelType != null ? String(c.advertisingChannelType) : undefined,
112
+ };
113
+ }
114
+
115
+ function normalizeId(id: string): string {
116
+ return id.replace(/-/g, '');
117
+ }
118
+
119
+ function safeJson(body: string): any {
120
+ try { return JSON.parse(body); } catch { return undefined; }
121
+ }
@@ -0,0 +1,10 @@
1
+ import type { HttpTransport, HttpRequest, HttpResponse } from './types.js';
2
+
3
+ /** Default HTTP transport built on the global `fetch` (Node >= 18). */
4
+ export const fetchTransport: HttpTransport = async (req: HttpRequest): Promise<HttpResponse> => {
5
+ const res = await fetch(req.url, { method: req.method, headers: req.headers, body: req.body });
6
+ const body = await res.text();
7
+ const headers: Record<string, string> = {};
8
+ res.headers.forEach((v, k) => { headers[k] = v; });
9
+ return { status: res.status, body, headers };
10
+ };
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export * from './types.js';
2
+ export * from './GaqlBuilder.js';
3
+ export * from './GoogleAdsClient.js';
4
+
5
+ import { GoogleAdsClient } from './GoogleAdsClient.js';
6
+ import { fetchTransport } from './fetchTransport.js';
7
+ import type { GoogleAdsConfig, HttpTransport } from './types.js';
8
+
9
+ /** Default fetch-based transport (Node >= 18). */
10
+ export { fetchTransport };
11
+
12
+ export interface CreateAdsOptions {
13
+ transport?: HttpTransport;
14
+ apiVersion?: string;
15
+ }
16
+
17
+ /** Build a Google Ads client. Throws at construction if required creds are missing. */
18
+ export function createGoogleAds(config: GoogleAdsConfig, opts: CreateAdsOptions = {}): GoogleAdsClient {
19
+ return new GoogleAdsClient(config, opts.transport ?? fetchTransport, opts.apiVersion);
20
+ }
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ /** Injectable HTTP transport so the client is testable without live Google creds. */
2
+ export interface HttpRequest {
3
+ method: 'GET' | 'POST';
4
+ url: string;
5
+ headers?: Record<string, string>;
6
+ body?: string;
7
+ }
8
+ export interface HttpResponse {
9
+ status: number;
10
+ body: string;
11
+ headers?: Record<string, string>;
12
+ }
13
+ export type HttpTransport = (req: HttpRequest) => Promise<HttpResponse>;
14
+
15
+ /** Google Ads config as it appears in nexus.config.ts `ads`. */
16
+ export interface GoogleAdsConfig {
17
+ enabled: boolean;
18
+ /** Google Ads API developer token. */
19
+ developerToken: string;
20
+ /** OAuth2 client credentials. */
21
+ clientId: string;
22
+ clientSecret: string;
23
+ /** OAuth2 refresh token (long-lived). */
24
+ refreshToken: string;
25
+ /** Login customer ID (MCC account, hyphenated or plain). Required for MCC. */
26
+ loginCustomerId?: string;
27
+ /** Default customer ID to query. */
28
+ customerId: string;
29
+ }
30
+
31
+ /** Minimal campaign shape from `campaign` resource. */
32
+ export interface Campaign {
33
+ id: string;
34
+ name: string;
35
+ status: string;
36
+ advertisingChannelType?: string;
37
+ budget?: { amountMicros?: string };
38
+ }
39
+
40
+ /** A row from a GAQL search: a record mapping resource name → fields. */
41
+ export type AdsRow = Record<string, unknown>;
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { GaqlBuilder, GoogleAdsClient, createGoogleAds, campaignsQuery, campaignMetricsQuery, type HttpTransport, type HttpRequest, type HttpResponse } from '../src/index.js';
3
+
4
+ describe('GaqlBuilder', () => {
5
+ it('builds a SELECT/FROM/WHERE/ORDER/LIMIT query', () => {
6
+ const q = new GaqlBuilder().select('campaign.id', 'campaign.name').from('campaign').where("campaign.status = 'ENABLED'").orderBy('campaign.id', 'DESC').limit(10).build();
7
+ expect(q).toBe("SELECT campaign.id, campaign.name FROM campaign WHERE campaign.status = 'ENABLED' ORDER BY campaign.id DESC LIMIT 10");
8
+ });
9
+ it('joins multiple WHERE with AND', () => {
10
+ const q = new GaqlBuilder().select('campaign.id').from('campaign').where('a = 1').where('b = 2').build();
11
+ expect(q).toBe('SELECT campaign.id FROM campaign WHERE a = 1 AND b = 2');
12
+ });
13
+ it('requires FROM and at least one field', () => {
14
+ expect(() => new GaqlBuilder().select('x').build()).toThrow(/FROM/);
15
+ expect(() => new GaqlBuilder().from('campaign').build()).toThrow(/field/);
16
+ });
17
+ it('campaignsQuery convenience builds the standard query', () => {
18
+ expect(campaignsQuery(5)).toBe('SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type FROM campaign ORDER BY campaign.id ASC LIMIT 5');
19
+ });
20
+ it('campaignMetricsQuery includes date range + metrics', () => {
21
+ const q = campaignMetricsQuery('2024-01-01', '2024-01-31', 10);
22
+ expect(q).toContain("segments.date >= '2024-01-01'");
23
+ expect(q).toContain("segments.date <= '2024-01-31'");
24
+ expect(q).toContain('metrics.clicks');
25
+ });
26
+ });
27
+
28
+ function mockTransport(routes: { match: string; respond: (req: HttpRequest) => HttpResponse }[]): HttpTransport {
29
+ return async (req) => {
30
+ for (const r of routes) if (req.url.includes(r.match)) return r.respond(req);
31
+ return { status: 404, body: JSON.stringify({ error: `no mock for ${req.url}` }) };
32
+ };
33
+ }
34
+
35
+ const cfg = {
36
+ enabled: true,
37
+ developerToken: 'dev-token',
38
+ clientId: 'cid',
39
+ clientSecret: 'csec',
40
+ refreshToken: 'rtok',
41
+ customerId: '123-456-7890',
42
+ };
43
+
44
+ describe('GoogleAdsClient', () => {
45
+ it('throws if required creds are missing', () => {
46
+ expect(() => new GoogleAdsClient({ ...cfg, developerToken: '' } as any, mockTransport([]))).toThrow(/developerToken/);
47
+ });
48
+
49
+ it('acquires an OAuth2 token (cached) and calls googleAds:search', async () => {
50
+ let tokenCalls = 0;
51
+ const t = mockTransport([
52
+ { match: 'oauth2.googleapis.com/token', respond: (req) => { tokenCalls++; void req; return { status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }; } },
53
+ { match: 'googleAds:search', respond: (req) => ({ status: 200, body: JSON.stringify({ results: [{ campaign: { id: '1', name: 'Camp A', status: 'ENABLED', advertisingChannelType: 'SEARCH' } }] }) }) },
54
+ ]);
55
+ const client = new GoogleAdsClient(cfg as any, t);
56
+ const rows = await client.search('1234567890', 'SELECT campaign.id FROM campaign LIMIT 1');
57
+ expect(tokenCalls).toBe(1);
58
+ expect(rows).toHaveLength(1);
59
+ expect((rows[0]!.campaign as any).name).toBe('Camp A');
60
+ // second call reuses the cached token
61
+ await client.search('1234567890', 'SELECT campaign.id FROM campaign LIMIT 1');
62
+ expect(tokenCalls).toBe(1);
63
+ });
64
+
65
+ it('listCampaigns maps rows to Campaign objects', async () => {
66
+ const t = mockTransport([
67
+ { match: 'oauth2.googleapis.com/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
68
+ { match: 'googleAds:search', respond: (req) => {
69
+ const body = JSON.parse(req.body as string);
70
+ expect(body.query).toContain('FROM campaign');
71
+ return { status: 200, body: JSON.stringify({ results: [{ campaign: { id: '1', name: 'A', status: 'ENABLED' } }, { campaign: { id: '2', name: 'B', status: 'PAUSED' } }] }) };
72
+ } },
73
+ ]);
74
+ const client = new GoogleAdsClient(cfg as any, t);
75
+ const camps = await client.listCampaigns();
76
+ expect(camps).toHaveLength(2);
77
+ expect(camps[0]!.name).toBe('A');
78
+ expect(camps[1]!.id).toBe('2');
79
+ });
80
+
81
+ it('normalizes customer ids (strips hyphens) and sends developer-token header', async () => {
82
+ let sentHeaders: Record<string, string> = {};
83
+ let sentUrl = '';
84
+ const t = mockTransport([
85
+ { match: 'oauth2.googleapis.com/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
86
+ { match: 'googleAds:search', respond: (req) => { sentHeaders = req.headers ?? {}; sentUrl = req.url; return { status: 200, body: JSON.stringify({ results: [] }) }; } },
87
+ ]);
88
+ const client = new GoogleAdsClient({ ...cfg, loginCustomerId: '11-22-33' } as any, t);
89
+ await client.search('1-2-3', 'SELECT campaign.id FROM campaign');
90
+ expect(sentUrl).toContain('/customers/123/googleAds:search');
91
+ expect(sentHeaders['developer-token']).toBe('dev-token');
92
+ expect(sentHeaders['login-customer-id']).toBe('112233');
93
+ expect(sentHeaders.authorization).toBe('Bearer tok');
94
+ });
95
+
96
+ it('propagates search errors', async () => {
97
+ const t = mockTransport([
98
+ { match: 'oauth2.googleapis.com/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
99
+ { match: 'googleAds:search', respond: () => ({ status: 400, body: JSON.stringify({ error: { message: 'bad query' } }) }) },
100
+ ]);
101
+ const client = new GoogleAdsClient(cfg as any, t);
102
+ await expect(client.search('123', 'bad')).rejects.toThrow(/search failed/);
103
+ });
104
+
105
+ it('handles pagination via nextPageToken', async () => {
106
+ let call = 0;
107
+ const t = mockTransport([
108
+ { match: 'oauth2.googleapis.com/token', respond: () => ({ status: 200, body: JSON.stringify({ access_token: 'tok', expires_in: 3600 }) }) },
109
+ { match: 'googleAds:search', respond: () => {
110
+ call++;
111
+ if (call === 1) return { status: 200, body: JSON.stringify({ results: [{ campaign: { id: '1' } }], nextPageToken: 'tok2' }) };
112
+ return { status: 200, body: JSON.stringify({ results: [{ campaign: { id: '2' } }] }) };
113
+ } },
114
+ ]);
115
+ const client = new GoogleAdsClient(cfg as any, t);
116
+ const rows = await client.search('123', 'SELECT campaign.id FROM campaign');
117
+ expect(call).toBe(2);
118
+ expect(rows).toHaveLength(2);
119
+ });
120
+ });
121
+
122
+ describe('createGoogleAds factory', () => {
123
+ it('builds a client with the default transport', () => {
124
+ const client = createGoogleAds(cfg as any);
125
+ expect(client).toBeInstanceOf(GoogleAdsClient);
126
+ });
127
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "references": [{ "path": "../nexus-core" }]
9
+ }
@@ -0,0 +1,10 @@
1
+ import { defineProject } from 'vitest/config';
2
+
3
+ export default defineProject({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['tests/**/*.test.ts'],
7
+ globals: false,
8
+ testTimeout: 15_000,
9
+ },
10
+ });