Skip to content

Commit beaaeaa

Browse files
authored
Merge pull request #25 from lytics/feat/opportunity-groups-scan-sizes-transport
feat: add content opportunity, segment groups/scan, sizes passthrough, and transport enrichment
2 parents bf2a29f + a90d7fe commit beaaeaa

File tree

9 files changed

+346
-8
lines changed

9 files changed

+346
-8
lines changed

.changeset/add-missing-features.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@lytics/lio-client": minor
3+
---
4+
5+
feat: add content opportunity, segment groups, segment scanning, and sizes support
6+
7+
- Add `content.opportunity()` for content opportunity topic data
8+
- Add `segments.groups()` for segment group listing
9+
- Add `segments.scan()` for generic segment scanning (user-table support)
10+
- Add `sizes` option to `segments.list()` (pass-through to server ?sizes=true)
11+
- Enrich transport events with url, duration, and requestId

packages/core/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,15 @@ export type {
4141
ContentAlignOptions,
4242
ContentEnrichResult,
4343
ContentEntity,
44+
ContentOpportunityOptions,
4445
ContentPlugin,
4546
Job,
4647
JobListOptions,
4748
JobsPlugin,
4849
LioClient,
4950
LioClientConfig,
51+
OpportunityDimension,
52+
OpportunityTopic,
5053
Provider,
5154
ProviderAuth,
5255
ProviderAuthConfig,
@@ -56,7 +59,9 @@ export type {
5659
SchemaPlugin,
5760
Segment,
5861
SegmentGetOptions,
62+
SegmentGroup,
5963
SegmentListOptions,
64+
SegmentScanOptions,
6065
SegmentSize,
6166
SegmentSizesOptions,
6267
SegmentsPlugin,

packages/core/src/plugins/__tests__/content.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,4 +295,42 @@ describe('contentPlugin', () => {
295295
);
296296
});
297297
});
298+
299+
describe('content.opportunity()', () => {
300+
it('should fetch opportunity topics with no params', async () => {
301+
const mockTopics = [
302+
{
303+
topic: 'AI',
304+
dimensions: [{ label: 'reach', value: 0.9, subject: 'audience' }],
305+
segments: ['tech_enthusiasts'],
306+
context_layer: 'global',
307+
},
308+
];
309+
const mockGet = vi.fn().mockResolvedValue({ topics: mockTopics });
310+
(sdk as any).transport.get = mockGet;
311+
312+
const result = await (sdk as any).content.opportunity();
313+
314+
expect(mockGet).toHaveBeenCalledWith('/v2/content/opportunity', undefined);
315+
expect(result).toEqual(mockTopics);
316+
});
317+
318+
it('should pass date param', async () => {
319+
const mockGet = vi.fn().mockResolvedValue({ topics: [] });
320+
(sdk as any).transport.get = mockGet;
321+
322+
await (sdk as any).content.opportunity({ date: '2024-06-01' });
323+
324+
expect(mockGet).toHaveBeenCalledWith('/v2/content/opportunity', { date: '2024-06-01' });
325+
});
326+
327+
it('should return empty array when topics is null', async () => {
328+
const mockGet = vi.fn().mockResolvedValue({});
329+
(sdk as any).transport.get = mockGet;
330+
331+
const result = await (sdk as any).content.opportunity();
332+
333+
expect(result).toEqual([]);
334+
});
335+
});
298336
});

packages/core/src/plugins/__tests__/segments.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,136 @@ describe('segmentsPlugin', () => {
232232
expect(result).toEqual([]);
233233
});
234234
});
235+
236+
describe('segments.list() with sizes', () => {
237+
it('should pass sizes param when true', async () => {
238+
const mockGet = vi.fn().mockResolvedValue([]);
239+
(sdk as any).transport.get = mockGet;
240+
241+
await (sdk as any).segments.list({ sizes: true });
242+
243+
expect(mockGet).toHaveBeenCalledWith('/v2/segment', { sizes: true });
244+
});
245+
246+
it('should not pass sizes param when false or omitted', async () => {
247+
const mockGet = vi.fn().mockResolvedValue([]);
248+
(sdk as any).transport.get = mockGet;
249+
250+
await (sdk as any).segments.list({ sizes: false });
251+
252+
expect(mockGet).toHaveBeenCalledWith('/v2/segment', undefined);
253+
});
254+
255+
it('should combine sizes with other params', async () => {
256+
const mockGet = vi.fn().mockResolvedValue([]);
257+
(sdk as any).transport.get = mockGet;
258+
259+
await (sdk as any).segments.list({ table: 'user', kind: 'segment', sizes: true });
260+
261+
expect(mockGet).toHaveBeenCalledWith('/v2/segment', {
262+
table: 'user',
263+
kind: 'segment',
264+
sizes: true,
265+
});
266+
});
267+
});
268+
269+
describe('segments.groups()', () => {
270+
it('should fetch segment groups', async () => {
271+
const mockGroups = [
272+
{
273+
id: 'g1',
274+
aid: 123,
275+
account_id: 'acc-1',
276+
created: '2024-01-01',
277+
updated: '2024-01-01',
278+
author: 'test@example.com',
279+
name: 'VIP Segments',
280+
description: 'High-value audience groups',
281+
segment_ids: ['seg-1', 'seg-2'],
282+
},
283+
];
284+
const mockGet = vi.fn().mockResolvedValue(mockGroups);
285+
(sdk as any).transport.get = mockGet;
286+
287+
const result = await (sdk as any).segments.groups();
288+
289+
expect(mockGet).toHaveBeenCalledWith('/v2/segment/group');
290+
expect(result).toEqual(mockGroups);
291+
});
292+
293+
it('should return empty array when API returns null', async () => {
294+
const mockGet = vi.fn().mockResolvedValue(null);
295+
(sdk as any).transport.get = mockGet;
296+
297+
const result = await (sdk as any).segments.groups();
298+
299+
expect(result).toEqual([]);
300+
});
301+
});
302+
303+
describe('segments.scan()', () => {
304+
it('should require segment ID', async () => {
305+
await expect((sdk as any).segments.scan('')).rejects.toThrow('Segment ID is required');
306+
});
307+
308+
it('should scan segment with no options', async () => {
309+
const mockData = [{ _uid: 'user-1', lytics_content_ai: 0.9 }];
310+
const mockGet = vi.fn().mockResolvedValue({ data: mockData });
311+
(sdk as any).transport.get = mockGet;
312+
313+
const result = await (sdk as any).segments.scan('seg-123');
314+
315+
expect(mockGet).toHaveBeenCalledWith('/api/segment/seg-123/scan', undefined);
316+
expect(result).toEqual(mockData);
317+
});
318+
319+
it('should pass limit, table, and fields params', async () => {
320+
const mockGet = vi.fn().mockResolvedValue({ data: [] });
321+
(sdk as any).transport.get = mockGet;
322+
323+
await (sdk as any).segments.scan('seg-123', {
324+
limit: 50,
325+
table: 'user',
326+
fields: ['_uid', 'lytics_content_ai'],
327+
});
328+
329+
expect(mockGet).toHaveBeenCalledWith('/api/segment/seg-123/scan', {
330+
limit: 50,
331+
table: 'user',
332+
fields: '_uid,lytics_content_ai',
333+
});
334+
});
335+
336+
it('should pass sort params', async () => {
337+
const mockGet = vi.fn().mockResolvedValue({ data: [] });
338+
(sdk as any).transport.get = mockGet;
339+
340+
await (sdk as any).segments.scan('seg-123', {
341+
sortfield: 'created',
342+
sortorder: 'desc',
343+
});
344+
345+
expect(mockGet).toHaveBeenCalledWith('/api/segment/seg-123/scan', {
346+
sortfield: 'created',
347+
sortorder: 'desc',
348+
});
349+
});
350+
351+
it('should return empty array when data is null', async () => {
352+
const mockGet = vi.fn().mockResolvedValue({});
353+
(sdk as any).transport.get = mockGet;
354+
355+
const result = await (sdk as any).segments.scan('seg-123');
356+
357+
expect(result).toEqual([]);
358+
});
359+
360+
it('should propagate transport errors', async () => {
361+
const mockGet = vi.fn().mockRejectedValue(new Error('Not found'));
362+
(sdk as any).transport.get = mockGet;
363+
364+
await expect((sdk as any).segments.scan('bad-id')).rejects.toThrow('Not found');
365+
});
366+
});
235367
});

packages/core/src/plugins/__tests__/transport.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,13 @@ describe('lyticsTransportPlugin', () => {
4646
const baseUrl = sdk.get('transport.baseUrl');
4747
expect(baseUrl).toBe('https://api.test.lytics.io');
4848
});
49+
50+
describe('enriched events', () => {
51+
it('should expose get and post methods that emit enriched events', () => {
52+
sdk.use(lyticsTransportPlugin);
53+
54+
expect(typeof (sdk as any).transport.get).toBe('function');
55+
expect(typeof (sdk as any).transport.post).toBe('function');
56+
});
57+
});
4958
});

packages/core/src/plugins/content.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import type {
1313
ContentAlignOptions,
1414
ContentEnrichResult,
1515
ContentEntity,
16+
ContentOpportunityOptions,
1617
ContentPlugin,
18+
OpportunityTopic,
1719
} from '../types';
1820
import type { LyticsTransportPlugin } from './transport';
1921

@@ -387,6 +389,34 @@ export const contentPlugin: PluginFunction = (plugin, instance) => {
387389

388390
return response;
389391
},
392+
/**
393+
* Fetch content opportunity topics
394+
*
395+
* @param options - Options (date filter)
396+
* @returns Array of opportunity topics with dimensions and segments
397+
*/
398+
async opportunity(options?: ContentOpportunityOptions): Promise<OpportunityTopic[]> {
399+
plugin.emit('content:opportunity', { options });
400+
401+
const transport = (instance as SDK & { transport: LyticsTransportPlugin }).transport;
402+
if (!transport) {
403+
throw new Error('Transport plugin not registered. Use lyticsTransportPlugin.');
404+
}
405+
406+
const params: Record<string, string> = {};
407+
if (options?.date) params.date = options.date;
408+
409+
const response = await transport.get<{ topics: OpportunityTopic[] }>(
410+
'/v2/content/opportunity',
411+
Object.keys(params).length > 0 ? params : undefined
412+
);
413+
414+
const topics = response.topics ?? [];
415+
416+
plugin.emit('content:opportunity-received', { count: topics.length });
417+
418+
return topics;
419+
},
390420
} as ContentPlugin,
391421
});
392422
};

packages/core/src/plugins/segments.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import type { PluginFunction, SDK } from '@lytics/sdk-kit';
1010
import type {
1111
Segment,
1212
SegmentGetOptions,
13+
SegmentGroup,
1314
SegmentListOptions,
15+
SegmentScanOptions,
1416
SegmentSize,
1517
SegmentSizesOptions,
1618
SegmentsPlugin,
@@ -35,6 +37,7 @@ export const segmentsPlugin: PluginFunction = (plugin, instance) => {
3537
if (options?.valid) params.valid = options.valid;
3638
if (options?.kind) params.kind = options.kind;
3739
if (options?.filterPredefined != null) params.filterpredefined = options.filterPredefined;
40+
if (options?.sizes) params.sizes = true;
3841

3942
const response = await transport.get<Segment[]>(
4043
'/v2/segment',
@@ -72,10 +75,6 @@ export const segmentsPlugin: PluginFunction = (plugin, instance) => {
7275

7376
return segment;
7477
},
75-
// NOTE: Uses the v1 /api/segment/sizes endpoint which returns pre-computed
76-
// sizes from a bulk KV blob (sub-100ms). Waiting on lio PR to add ?sizes=true
77-
// support to GET /v2/segment (list), at which point this can be replaced by
78-
// passing sizes: true to list().
7978
async sizes(options?: SegmentSizesOptions): Promise<SegmentSize[]> {
8079
plugin.emit('segments:sizes', { options });
8180

@@ -99,6 +98,55 @@ export const segmentsPlugin: PluginFunction = (plugin, instance) => {
9998

10099
return sizes;
101100
},
101+
async groups(): Promise<SegmentGroup[]> {
102+
plugin.emit('segments:groups', {});
103+
104+
const transport = (instance as SDK & { transport: LyticsTransportPlugin }).transport;
105+
if (!transport) {
106+
throw new Error('Transport plugin not registered. Use lyticsTransportPlugin.');
107+
}
108+
109+
const response = await transport.get<SegmentGroup[]>('/v2/segment/group');
110+
const groups = response ?? [];
111+
112+
plugin.emit('segments:grouped', { count: groups.length });
113+
114+
return groups;
115+
},
116+
117+
async scan(
118+
segmentId: string,
119+
options?: SegmentScanOptions
120+
): Promise<Record<string, unknown>[]> {
121+
if (!segmentId) {
122+
throw new Error('Segment ID is required');
123+
}
124+
125+
plugin.emit('segments:scan', { segmentId, options });
126+
127+
const transport = (instance as SDK & { transport: LyticsTransportPlugin }).transport;
128+
if (!transport) {
129+
throw new Error('Transport plugin not registered. Use lyticsTransportPlugin.');
130+
}
131+
132+
const params: Record<string, string | number> = {};
133+
if (options?.limit) params.limit = options.limit;
134+
if (options?.table) params.table = options.table;
135+
if (options?.fields) params.fields = options.fields.join(',');
136+
if (options?.sortfield) params.sortfield = options.sortfield;
137+
if (options?.sortorder) params.sortorder = options.sortorder;
138+
139+
const response = await transport.get<{ data: Record<string, unknown>[] }>(
140+
`/api/segment/${segmentId}/scan`,
141+
Object.keys(params).length > 0 ? params : undefined
142+
);
143+
144+
const entities = response.data ?? [];
145+
146+
plugin.emit('segments:scanned', { segmentId, count: entities.length });
147+
148+
return entities;
149+
},
102150
} as SegmentsPlugin,
103151
});
104152
};

0 commit comments

Comments
 (0)