diff --git a/.changeset/add-missing-features.md b/.changeset/add-missing-features.md new file mode 100644 index 0000000..afee88b --- /dev/null +++ b/.changeset/add-missing-features.md @@ -0,0 +1,11 @@ +--- +"@lytics/lio-client": minor +--- + +feat: add content opportunity, segment groups, segment scanning, and sizes support + +- Add `content.opportunity()` for content opportunity topic data +- Add `segments.groups()` for segment group listing +- Add `segments.scan()` for generic segment scanning (user-table support) +- Add `sizes` option to `segments.list()` (pass-through to server ?sizes=true) +- Enrich transport events with url, duration, and requestId diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3829808..74d2096 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,12 +41,15 @@ export type { ContentAlignOptions, ContentEnrichResult, ContentEntity, + ContentOpportunityOptions, ContentPlugin, Job, JobListOptions, JobsPlugin, LioClient, LioClientConfig, + OpportunityDimension, + OpportunityTopic, Provider, ProviderAuth, ProviderAuthConfig, @@ -56,7 +59,9 @@ export type { SchemaPlugin, Segment, SegmentGetOptions, + SegmentGroup, SegmentListOptions, + SegmentScanOptions, SegmentSize, SegmentSizesOptions, SegmentsPlugin, diff --git a/packages/core/src/plugins/__tests__/content.test.ts b/packages/core/src/plugins/__tests__/content.test.ts index af2a3f1..8907e14 100644 --- a/packages/core/src/plugins/__tests__/content.test.ts +++ b/packages/core/src/plugins/__tests__/content.test.ts @@ -295,4 +295,42 @@ describe('contentPlugin', () => { ); }); }); + + describe('content.opportunity()', () => { + it('should fetch opportunity topics with no params', async () => { + const mockTopics = [ + { + topic: 'AI', + dimensions: [{ label: 'reach', value: 0.9, subject: 'audience' }], + segments: ['tech_enthusiasts'], + context_layer: 'global', + }, + ]; + const mockGet = vi.fn().mockResolvedValue({ topics: mockTopics }); + (sdk as any).transport.get = mockGet; + + const result = await (sdk as any).content.opportunity(); + + expect(mockGet).toHaveBeenCalledWith('/v2/content/opportunity', undefined); + expect(result).toEqual(mockTopics); + }); + + it('should pass date param', async () => { + const mockGet = vi.fn().mockResolvedValue({ topics: [] }); + (sdk as any).transport.get = mockGet; + + await (sdk as any).content.opportunity({ date: '2024-06-01' }); + + expect(mockGet).toHaveBeenCalledWith('/v2/content/opportunity', { date: '2024-06-01' }); + }); + + it('should return empty array when topics is null', async () => { + const mockGet = vi.fn().mockResolvedValue({}); + (sdk as any).transport.get = mockGet; + + const result = await (sdk as any).content.opportunity(); + + expect(result).toEqual([]); + }); + }); }); diff --git a/packages/core/src/plugins/__tests__/segments.test.ts b/packages/core/src/plugins/__tests__/segments.test.ts index fa80c28..7c37088 100644 --- a/packages/core/src/plugins/__tests__/segments.test.ts +++ b/packages/core/src/plugins/__tests__/segments.test.ts @@ -232,4 +232,136 @@ describe('segmentsPlugin', () => { expect(result).toEqual([]); }); }); + + describe('segments.list() with sizes', () => { + it('should pass sizes param when true', async () => { + const mockGet = vi.fn().mockResolvedValue([]); + (sdk as any).transport.get = mockGet; + + await (sdk as any).segments.list({ sizes: true }); + + expect(mockGet).toHaveBeenCalledWith('/v2/segment', { sizes: true }); + }); + + it('should not pass sizes param when false or omitted', async () => { + const mockGet = vi.fn().mockResolvedValue([]); + (sdk as any).transport.get = mockGet; + + await (sdk as any).segments.list({ sizes: false }); + + expect(mockGet).toHaveBeenCalledWith('/v2/segment', undefined); + }); + + it('should combine sizes with other params', async () => { + const mockGet = vi.fn().mockResolvedValue([]); + (sdk as any).transport.get = mockGet; + + await (sdk as any).segments.list({ table: 'user', kind: 'segment', sizes: true }); + + expect(mockGet).toHaveBeenCalledWith('/v2/segment', { + table: 'user', + kind: 'segment', + sizes: true, + }); + }); + }); + + describe('segments.groups()', () => { + it('should fetch segment groups', async () => { + const mockGroups = [ + { + id: 'g1', + aid: 123, + account_id: 'acc-1', + created: '2024-01-01', + updated: '2024-01-01', + author: 'test@example.com', + name: 'VIP Segments', + description: 'High-value audience groups', + segment_ids: ['seg-1', 'seg-2'], + }, + ]; + const mockGet = vi.fn().mockResolvedValue(mockGroups); + (sdk as any).transport.get = mockGet; + + const result = await (sdk as any).segments.groups(); + + expect(mockGet).toHaveBeenCalledWith('/v2/segment/group'); + expect(result).toEqual(mockGroups); + }); + + it('should return empty array when API returns null', async () => { + const mockGet = vi.fn().mockResolvedValue(null); + (sdk as any).transport.get = mockGet; + + const result = await (sdk as any).segments.groups(); + + expect(result).toEqual([]); + }); + }); + + describe('segments.scan()', () => { + it('should require segment ID', async () => { + await expect((sdk as any).segments.scan('')).rejects.toThrow('Segment ID is required'); + }); + + it('should scan segment with no options', async () => { + const mockData = [{ _uid: 'user-1', lytics_content_ai: 0.9 }]; + const mockGet = vi.fn().mockResolvedValue({ data: mockData }); + (sdk as any).transport.get = mockGet; + + const result = await (sdk as any).segments.scan('seg-123'); + + expect(mockGet).toHaveBeenCalledWith('/api/segment/seg-123/scan', undefined); + expect(result).toEqual(mockData); + }); + + it('should pass limit, table, and fields params', async () => { + const mockGet = vi.fn().mockResolvedValue({ data: [] }); + (sdk as any).transport.get = mockGet; + + await (sdk as any).segments.scan('seg-123', { + limit: 50, + table: 'user', + fields: ['_uid', 'lytics_content_ai'], + }); + + expect(mockGet).toHaveBeenCalledWith('/api/segment/seg-123/scan', { + limit: 50, + table: 'user', + fields: '_uid,lytics_content_ai', + }); + }); + + it('should pass sort params', async () => { + const mockGet = vi.fn().mockResolvedValue({ data: [] }); + (sdk as any).transport.get = mockGet; + + await (sdk as any).segments.scan('seg-123', { + sortfield: 'created', + sortorder: 'desc', + }); + + expect(mockGet).toHaveBeenCalledWith('/api/segment/seg-123/scan', { + sortfield: 'created', + sortorder: 'desc', + }); + }); + + it('should return empty array when data is null', async () => { + const mockGet = vi.fn().mockResolvedValue({}); + (sdk as any).transport.get = mockGet; + + const result = await (sdk as any).segments.scan('seg-123'); + + expect(result).toEqual([]); + }); + + it('should propagate transport errors', async () => { + const mockGet = vi.fn().mockRejectedValue(new Error('Not found')); + (sdk as any).transport.get = mockGet; + + await expect((sdk as any).segments.scan('bad-id')).rejects.toThrow('Not found'); + }); + }); }); diff --git a/packages/core/src/plugins/__tests__/transport.test.ts b/packages/core/src/plugins/__tests__/transport.test.ts index 22bba6f..ddb0740 100644 --- a/packages/core/src/plugins/__tests__/transport.test.ts +++ b/packages/core/src/plugins/__tests__/transport.test.ts @@ -46,4 +46,13 @@ describe('lyticsTransportPlugin', () => { const baseUrl = sdk.get('transport.baseUrl'); expect(baseUrl).toBe('https://api.test.lytics.io'); }); + + describe('enriched events', () => { + it('should expose get and post methods that emit enriched events', () => { + sdk.use(lyticsTransportPlugin); + + expect(typeof (sdk as any).transport.get).toBe('function'); + expect(typeof (sdk as any).transport.post).toBe('function'); + }); + }); }); diff --git a/packages/core/src/plugins/content.ts b/packages/core/src/plugins/content.ts index 65adb29..7ac46d7 100644 --- a/packages/core/src/plugins/content.ts +++ b/packages/core/src/plugins/content.ts @@ -13,7 +13,9 @@ import type { ContentAlignOptions, ContentEnrichResult, ContentEntity, + ContentOpportunityOptions, ContentPlugin, + OpportunityTopic, } from '../types'; import type { LyticsTransportPlugin } from './transport'; @@ -387,6 +389,34 @@ export const contentPlugin: PluginFunction = (plugin, instance) => { return response; }, + /** + * Fetch content opportunity topics + * + * @param options - Options (date filter) + * @returns Array of opportunity topics with dimensions and segments + */ + async opportunity(options?: ContentOpportunityOptions): Promise { + plugin.emit('content:opportunity', { options }); + + const transport = (instance as SDK & { transport: LyticsTransportPlugin }).transport; + if (!transport) { + throw new Error('Transport plugin not registered. Use lyticsTransportPlugin.'); + } + + const params: Record = {}; + if (options?.date) params.date = options.date; + + const response = await transport.get<{ topics: OpportunityTopic[] }>( + '/v2/content/opportunity', + Object.keys(params).length > 0 ? params : undefined + ); + + const topics = response.topics ?? []; + + plugin.emit('content:opportunity-received', { count: topics.length }); + + return topics; + }, } as ContentPlugin, }); }; diff --git a/packages/core/src/plugins/segments.ts b/packages/core/src/plugins/segments.ts index 7b9c9f9..fcf5125 100644 --- a/packages/core/src/plugins/segments.ts +++ b/packages/core/src/plugins/segments.ts @@ -10,7 +10,9 @@ import type { PluginFunction, SDK } from '@lytics/sdk-kit'; import type { Segment, SegmentGetOptions, + SegmentGroup, SegmentListOptions, + SegmentScanOptions, SegmentSize, SegmentSizesOptions, SegmentsPlugin, @@ -35,6 +37,7 @@ export const segmentsPlugin: PluginFunction = (plugin, instance) => { if (options?.valid) params.valid = options.valid; if (options?.kind) params.kind = options.kind; if (options?.filterPredefined != null) params.filterpredefined = options.filterPredefined; + if (options?.sizes) params.sizes = true; const response = await transport.get( '/v2/segment', @@ -72,10 +75,6 @@ export const segmentsPlugin: PluginFunction = (plugin, instance) => { return segment; }, - // NOTE: Uses the v1 /api/segment/sizes endpoint which returns pre-computed - // sizes from a bulk KV blob (sub-100ms). Waiting on lio PR to add ?sizes=true - // support to GET /v2/segment (list), at which point this can be replaced by - // passing sizes: true to list(). async sizes(options?: SegmentSizesOptions): Promise { plugin.emit('segments:sizes', { options }); @@ -99,6 +98,55 @@ export const segmentsPlugin: PluginFunction = (plugin, instance) => { return sizes; }, + async groups(): Promise { + plugin.emit('segments:groups', {}); + + const transport = (instance as SDK & { transport: LyticsTransportPlugin }).transport; + if (!transport) { + throw new Error('Transport plugin not registered. Use lyticsTransportPlugin.'); + } + + const response = await transport.get('/v2/segment/group'); + const groups = response ?? []; + + plugin.emit('segments:grouped', { count: groups.length }); + + return groups; + }, + + async scan( + segmentId: string, + options?: SegmentScanOptions + ): Promise[]> { + if (!segmentId) { + throw new Error('Segment ID is required'); + } + + plugin.emit('segments:scan', { segmentId, options }); + + const transport = (instance as SDK & { transport: LyticsTransportPlugin }).transport; + if (!transport) { + throw new Error('Transport plugin not registered. Use lyticsTransportPlugin.'); + } + + const params: Record = {}; + if (options?.limit) params.limit = options.limit; + if (options?.table) params.table = options.table; + if (options?.fields) params.fields = options.fields.join(','); + if (options?.sortfield) params.sortfield = options.sortfield; + if (options?.sortorder) params.sortorder = options.sortorder; + + const response = await transport.get<{ data: Record[] }>( + `/api/segment/${segmentId}/scan`, + Object.keys(params).length > 0 ? params : undefined + ); + + const entities = response.data ?? []; + + plugin.emit('segments:scanned', { segmentId, count: entities.length }); + + return entities; + }, } as SegmentsPlugin, }); }; diff --git a/packages/core/src/plugins/transport.ts b/packages/core/src/plugins/transport.ts index ebd2421..eeb67fd 100644 --- a/packages/core/src/plugins/transport.ts +++ b/packages/core/src/plugins/transport.ts @@ -151,8 +151,9 @@ export const lyticsTransportPlugin: PluginFunction = (plugin, instance, config) */ async get(path: string, params?: Record): Promise { const url = buildUrl(path, params); + const startTime = Date.now(); - plugin.emit('lytics:request', { method: 'GET', path, params }); + plugin.emit('lytics:request', { method: 'GET', path, url, params }); const request: TransportRequest = { url, @@ -163,9 +164,17 @@ export const lyticsTransportPlugin: PluginFunction = (plugin, instance, config) }; const response = await sdkTransport.send(request); + const requestId = (response.data as ApiResponse)?.request_id; const data = unwrapResponse(response); - plugin.emit('lytics:response', { method: 'GET', path, status: response.status }); + plugin.emit('lytics:response', { + method: 'GET', + path, + url, + status: response.status, + duration: Date.now() - startTime, + requestId, + }); return data; }, @@ -183,8 +192,9 @@ export const lyticsTransportPlugin: PluginFunction = (plugin, instance, config) const shouldUnwrap = options?.unwrap !== false; // Default to true const url = buildUrl(path, params); + const startTime = Date.now(); - plugin.emit('lytics:request', { method: 'POST', path, params, body }); + plugin.emit('lytics:request', { method: 'POST', path, url, params, body }); // For form-urlencoded, pass the string body directly via text/plain // serialization path and override Content-Type in headers. @@ -201,11 +211,19 @@ export const lyticsTransportPlugin: PluginFunction = (plugin, instance, config) }; const response = await sdkTransport.send(request); + const requestId = (response.data as ApiResponse)?.request_id; // Unwrap response if requested (default behavior for JSON endpoints) const data = shouldUnwrap ? unwrapResponse(response) : (response.data as T); - plugin.emit('lytics:response', { method: 'POST', path, status: response.status }); + plugin.emit('lytics:response', { + method: 'POST', + path, + url, + status: response.status, + duration: Date.now() - startTime, + requestId, + }); return data; }, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 22ae527..5cd60ea 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -124,6 +124,24 @@ export interface ContentAlignment { segment_topics: Record; } +export interface OpportunityDimension { + label: string; + value: number; + subject: string; +} + +export interface OpportunityTopic { + topic: string; + dimensions: OpportunityDimension[]; + segments: string[]; + context_layer: string; +} + +export interface ContentOpportunityOptions { + /** ISO 8601 date, defaults to latest */ + date?: string; +} + export interface ContentPlugin { getByUrl(url: string): Promise; scan(options?: { @@ -140,6 +158,8 @@ export interface ContentPlugin { ): AsyncGenerator; enrich(input: { text?: string; url?: string }): Promise; align(topics: Record, options?: ContentAlignOptions): Promise; + /** Fetch content opportunity topics */ + opportunity(options?: ContentOpportunityOptions): Promise; } export interface SchemaField { @@ -204,6 +224,8 @@ export interface SegmentListOptions { kind?: string; /** Exclude predefined segments (default: false) */ filterPredefined?: boolean; + /** Include cached segment sizes (requires server commit d168baa) */ + sizes?: boolean; } export interface SegmentGetOptions { @@ -226,11 +248,36 @@ export interface SegmentSize { timestamp: string; } +export interface SegmentGroup { + id: string; + aid: number; + account_id: string; + created: string; + updated: string; + author: string; + name: string; + description: string; + segment_ids: string[]; +} + +export interface SegmentScanOptions { + limit?: number; + fields?: string[]; + /** Entity table: "user" (default), "content", "campaign", etc. */ + table?: string; + sortfield?: string; + sortorder?: 'asc' | 'desc'; +} + export interface SegmentsPlugin { list(options?: SegmentListOptions): Promise; get(slugOrId: string, options?: SegmentGetOptions): Promise; /** Fetch pre-computed segment sizes (v1 bulk endpoint). */ sizes(options?: SegmentSizesOptions): Promise; + /** Fetch segment groups */ + groups(): Promise; + /** Scan a segment's entities (generic, supports any table) */ + scan(segmentId: string, options?: SegmentScanOptions): Promise[]>; } export interface AiPlugin {