Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/pr-170-review-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'scope3': patch
---

Remove placeholder test stubs for deleted resources, reject storefront persona at runtime, remove unsafe index signatures from campaign input types, add response validation to campaign create/update, and type campaign sub-endpoint responses.
3 changes: 0 additions & 3 deletions src/__tests__/cli/commands/storefront.test.ts

This file was deleted.

6 changes: 6 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ describe('Scope3Client', () => {
expect(() => new Scope3Client({ apiKey: 'test-key' } as any)).toThrow('persona is required');
});

it('should reject storefront persona', () => {
expect(() => new Scope3Client({ apiKey: 'test-key', persona: 'storefront' })).toThrow(
'Scope3Client only supports the buyer persona'
);
});

it('should default to v2 version', () => {
const client = new Scope3Client({ apiKey: 'test-key', persona: 'buyer' });
expect(client.version).toBe('v2');
Expand Down
3 changes: 0 additions & 3 deletions src/__tests__/resources/agents.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/__tests__/resources/bundles.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/__tests__/resources/conversion-events.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/__tests__/resources/creative-sets.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/__tests__/resources/inventory-sources.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/__tests__/resources/notifications.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/__tests__/resources/products.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/__tests__/resources/readiness.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/__tests__/resources/sales-agents.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/__tests__/resources/signals.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions src/__tests__/resources/storefront.test.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/cli/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ configCommand
}

// Validate persona
if (key === 'persona' && !['buyer', 'storefront'].includes(value)) {
if (key === 'persona' && value !== 'buyer') {
console.error(chalk.red(`Invalid persona: ${value}`));
console.error('Valid personas: buyer, storefront');
console.error('Valid personas: buyer');
process.exit(1);
}

Expand Down
2 changes: 1 addition & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ program
.option('--base-url <url>', 'Custom API base URL')
.option('--format <format>', 'Output format: json, table, or yaml (default: table)')
.option('--debug', 'Enable debug mode')
.option('--persona <persona>', 'API persona: buyer or storefront (default: buyer)');
.option('--persona <persona>', 'API persona (default: buyer)');

program.hook('preAction', (_thisCommand, actionCommand) => {
const skipCommands = ['login', 'logout', 'config', 'commands'];
Expand Down
5 changes: 4 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ export class Scope3Client {
throw new Error('apiKey is required');
}
if (!config.persona) {
throw new Error('persona is required (buyer or storefront)');
throw new Error('persona is required');
}
if (config.persona !== 'buyer') {
throw new Error('Scope3Client only supports the buyer persona');
}

this.version = config.version ?? 'v2';
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ export type {
CreateCampaignInput,
UpdateCampaignInput,
ListCampaignsParams,
RefinementItem,
AutoSelectProductsResult,
CampaignProductEntry,
CampaignProductsResult,
CampaignMediaBuyStatus,
MediaBuyStatusValue,
// Test Cohorts
TestCohort,
CreateTestCohortInput,
Expand Down
42 changes: 29 additions & 13 deletions src/resources/campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import type {
ListCampaignsParams,
PaginatedApiResponse,
ApiResponse,
AutoSelectProductsResult,
CampaignProductsResult,
CampaignMediaBuyStatus,
RefinementItem,
} from '../types';
import { campaignSchemas } from '../schemas/registry';
import { shouldValidateResponse, validateResponse } from '../validation';
Expand Down Expand Up @@ -60,7 +64,11 @@ export class CampaignsResource {
* @returns Created campaign
*/
async create(data: CreateCampaignInput): Promise<ApiResponse<Campaign>> {
return this.adapter.request<ApiResponse<Campaign>>('POST', '/campaigns', data);
const result = await this.adapter.request<ApiResponse<Campaign>>('POST', '/campaigns', data);
if (shouldValidateResponse(this.adapter.validate)) {
result.data = validateResponse(campaignSchemas.response, result.data) as unknown as Campaign;
}
return result;
}

/**
Expand All @@ -70,11 +78,15 @@ export class CampaignsResource {
* @returns Updated campaign
*/
async update(id: string, data: UpdateCampaignInput): Promise<ApiResponse<Campaign>> {
return this.adapter.request<ApiResponse<Campaign>>(
const result = await this.adapter.request<ApiResponse<Campaign>>(
'PUT',
`/campaigns/${validateResourceId(id)}`,
data
);
if (shouldValidateResponse(this.adapter.validate)) {
result.data = validateResponse(campaignSchemas.response, result.data) as unknown as Campaign;
}
return result;
}

/**
Expand All @@ -88,14 +100,18 @@ export class CampaignsResource {
/**
* Auto-select products for a campaign
* @param id Campaign ID
* @param data Optional configuration for product selection
* @returns Auto-selection result
* @param data Optional refinement and configuration for product selection
* @returns Auto-selection result with selected products
*/
async autoSelectProducts(
id: string,
data?: Record<string, unknown>
): Promise<ApiResponse<Record<string, unknown>>> {
return this.adapter.request<ApiResponse<Record<string, unknown>>>(
data?: {
refine?: RefinementItem[];
maxProducts?: number;
minBudgetPerProduct?: number;
}
): Promise<ApiResponse<AutoSelectProductsResult>> {
return this.adapter.request<ApiResponse<AutoSelectProductsResult>>(
'POST',
`/campaigns/${validateResourceId(id)}/auto-select-products`,
data
Expand All @@ -105,10 +121,10 @@ export class CampaignsResource {
/**
* Get media buy status for a campaign
* @param id Campaign ID
* @returns Media buy status
* @returns Media buy statuses
*/
async getMediaBuyStatus(id: string): Promise<ApiResponse<Record<string, unknown>>> {
return this.adapter.request<ApiResponse<Record<string, unknown>>>(
async getMediaBuyStatus(id: string): Promise<ApiResponse<CampaignMediaBuyStatus>> {
return this.adapter.request<ApiResponse<CampaignMediaBuyStatus>>(
'GET',
`/campaigns/${validateResourceId(id)}/media-buy-status`
);
Expand All @@ -117,10 +133,10 @@ export class CampaignsResource {
/**
* Get products associated with a campaign
* @param id Campaign ID
* @returns Campaign products
* @returns Campaign products with summary
*/
async getProducts(id: string): Promise<ApiResponse<Record<string, unknown>>> {
return this.adapter.request<ApiResponse<Record<string, unknown>>>(
async getProducts(id: string): Promise<ApiResponse<CampaignProductsResult>> {
return this.adapter.request<ApiResponse<CampaignProductsResult>>(
'GET',
`/campaigns/${validateResourceId(id)}/products`
);
Expand Down
91 changes: 89 additions & 2 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,6 @@ export interface CreateCampaignInput {
brief?: string;
constraints?: CampaignConstraints;
performanceConfig?: PerformanceConfig;
[key: string]: unknown;
}

export interface UpdateCampaignInput {
Expand All @@ -258,7 +257,6 @@ export interface UpdateCampaignInput {
brief?: string;
constraints?: CampaignConstraints;
performanceConfig?: PerformanceConfig;
[key: string]: unknown;
}

export interface ListCampaignsParams extends PaginationParams {
Expand All @@ -267,6 +265,95 @@ export interface ListCampaignsParams extends PaginationParams {
status?: CampaignStatus;
}

export type RefinementItem =
| { scope: 'request'; ask: string }
| { scope: 'product'; id: string; action: 'include' | 'omit' | 'moreLikeThis'; ask?: string };

export interface AutoSelectProductsResult {
campaignId: string;
discoveryId: string;
selectedProducts: Array<{
productId: string;
name: string;
salesAgentId: string;
groupId: string;
groupName: string;
cpm?: number;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this type is missing fields from AutoSelectProductsResponse in buyer.ts. selectedProducts items need budget: number (required) and pricingOptionId?: string. top level is also missing budgetContext: { campaignBudget, totalAllocated, remainingBudget, currency }, productCount: number, and previouslySelectedCount?: number

budget: number;
pricingOptionId?: string;
}>;
budgetContext: {
campaignBudget: number;
totalAllocated: number;
remainingBudget: number;
currency: string;
};
productCount: number;
previouslySelectedCount?: number;
}

export interface CampaignProductEntry {
productId: string;
productName?: string;
salesAgentId: string;
salesAgentName?: string;
publisherDomain?: string;
publisherName?: string;
bidPrice?: number;
budget?: number;
pricingOptionId?: string;
pricingModel?: string;
selectedAt: string;
searchContext?: { id: string; brief: string };
mediaBuys: Array<{ MediaBuyId: string; Status: string; name: string }>;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing fields vs CampaignProductEntry in the schema: selectedAt: string (required), searchContext?: { id: string; brief: string }, and mediaBuys: Array<{ MediaBuyId: string; Status: string; name: string }> (also required)


export interface CampaignProductsResult {
campaignId: string;
discoveryId: string | null;
products: CampaignProductEntry[];
searchContexts: Array<{
id: string;
brief: string;
channels: string[];
countries: string[];
createdAt: string;
productCount: number;
}>;
summary: {
totalProducts: number;
productsOnMediaBuys: number;
productsPending: number;
};
}

export type MediaBuyStatusValue =
| 'DRAFT'
| 'PENDING_APPROVAL'
| 'INPUT_REQUIRED'
| 'ACTIVE'
| 'PAUSED'
| 'COMPLETED'
| 'CANCELED'
| 'FAILED'
| 'REJECTED'
| 'ARCHIVED';

export interface CampaignMediaBuyStatus {
campaign_id: string;
media_buys: Array<{
media_buy_id: string;
adcp_media_buy_id: string;
internal_status: string;
adcp_status: string | null;
previous_internal_status: string;
previous_adcp_status: string | null;
updated: boolean;
}>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no corresponding exported schema for this in buyer.ts - looks hand-rolled. if it's based on the campaign response's mediaBuys field, it should reuse that shape rather than being a standalone interface

agents_queried: number;
errors: Array<{ media_buy_id: string; error: string }>;
}

// ============================================================================
// Test Cohort Types (Buyer Persona)
// ============================================================================
Expand Down