@startsimpli/api 0.5.13 → 0.5.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/api",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
4
4
  "description": "Type-safe Django REST API client for StartSimpli apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,7 +1,7 @@
1
1
  /**
2
- * EnrichmentApi unit tests — Apollo enrichment.
2
+ * EnrichmentApi unit tests — Apollo enrichment with client-side chunking.
3
3
  *
4
- * Phase 2 of user-facing Apollo enrichment (raise-simpli-iz2).
4
+ * Refs: raise-simpli-iz2 / raise-simpli-2a7.
5
5
  */
6
6
 
7
7
  import { describe, it, expect, vi } from 'vitest';
@@ -9,8 +9,11 @@ import { EnrichmentApi } from '../lib/enrichment-api';
9
9
  import type { ApiClient } from '../lib/api-client';
10
10
  import type { ApolloEnrichmentSummary } from '../types/enrichment';
11
11
 
12
- function makeClient(postReturn: unknown = {}): ApiClient {
13
- const post = vi.fn().mockResolvedValue(postReturn);
12
+ function makeClient(postBehavior: unknown = {}): ApiClient {
13
+ const post =
14
+ typeof postBehavior === 'function'
15
+ ? vi.fn(postBehavior as any)
16
+ : vi.fn().mockResolvedValue(postBehavior);
14
17
  return {
15
18
  get: vi.fn(),
16
19
  post,
@@ -23,18 +26,19 @@ function makeClient(postReturn: unknown = {}): ApiClient {
23
26
  } as unknown as ApiClient;
24
27
  }
25
28
 
26
- const mockSummary: ApolloEnrichmentSummary = {
27
- total: 2,
28
- enriched: ['c-1'],
29
- skipped: [{ contact_id: 'c-2', reason: 'insufficient query fields' }],
30
- errors: [],
31
- missing: [],
29
+ const emptySummary: ApolloEnrichmentSummary = {
30
+ total: 0, enriched: [], skipped: [], errors: [], missing: [],
32
31
  };
33
32
 
34
33
 
35
- describe('EnrichmentApi.enrichApollo', () => {
34
+ describe('EnrichmentApi.enrichApollo (single-chunk)', () => {
36
35
  it('POSTs to /contacts/enrich-apollo/ with contact_ids body', async () => {
37
- const client = makeClient(mockSummary);
36
+ const summary: ApolloEnrichmentSummary = {
37
+ total: 2, enriched: ['c-1'],
38
+ skipped: [{ contact_id: 'c-2', reason: 'insufficient query fields' }],
39
+ errors: [], missing: [],
40
+ };
41
+ const client = makeClient(summary);
38
42
  const api = new EnrichmentApi(client);
39
43
 
40
44
  const result = await api.enrichApollo(['c-1', 'c-2']);
@@ -43,46 +47,130 @@ describe('EnrichmentApi.enrichApollo', () => {
43
47
  const [endpoint, body] = (client.fetch.post as any).mock.calls[0];
44
48
  expect(endpoint).toContain('contacts/enrich-apollo');
45
49
  expect(body).toEqual({ contact_ids: ['c-1', 'c-2'] });
46
- expect(result).toEqual(mockSummary);
47
- });
48
-
49
- it('returns the structured summary unchanged', async () => {
50
- const summary: ApolloEnrichmentSummary = {
51
- total: 3,
52
- enriched: ['c-1', 'c-3'],
53
- skipped: [],
54
- errors: [{ contact_id: 'c-2', error: 'Apollo error: 401' }],
55
- missing: [],
56
- };
57
- const client = makeClient(summary);
58
- const api = new EnrichmentApi(client);
59
-
60
- const result = await api.enrichApollo(['c-1', 'c-2', 'c-3']);
61
50
  expect(result).toEqual(summary);
62
51
  });
63
52
 
64
53
  it('rejects empty contact_ids list before calling the API', async () => {
65
- const client = makeClient(mockSummary);
54
+ const client = makeClient(emptySummary);
66
55
  const api = new EnrichmentApi(client);
67
56
 
68
57
  await expect(api.enrichApollo([])).rejects.toThrow(/non-empty/);
69
58
  expect(client.fetch.post).not.toHaveBeenCalled();
70
59
  });
71
60
 
72
- it('rejects > 100 contact_ids before calling the API', async () => {
73
- const client = makeClient(mockSummary);
61
+ it('captures API errors as per-id error entries (does not throw)', async () => {
62
+ const client = makeClient(emptySummary);
63
+ (client.fetch.post as any).mockRejectedValueOnce(new Error('network'));
74
64
  const api = new EnrichmentApi(client);
75
65
 
76
- const ids = Array.from({ length: 101 }, (_, i) => `c-${i}`);
77
- await expect(api.enrichApollo(ids)).rejects.toThrow(/100/);
78
- expect(client.fetch.post).not.toHaveBeenCalled();
66
+ const result = await api.enrichApollo(['c-1']);
67
+ expect(result.errors).toEqual([{ contact_id: 'c-1', error: 'network' }]);
68
+ expect(result.enriched).toEqual([]);
79
69
  });
70
+ });
80
71
 
81
- it('passes through API errors', async () => {
82
- const client = makeClient(mockSummary);
83
- (client.fetch.post as any).mockRejectedValueOnce(new Error('network'));
72
+
73
+ describe('EnrichmentApi.enrichApollo (chunking >100)', () => {
74
+ function makeIds(n: number) {
75
+ return Array.from({ length: n }, (_, i) => `c-${i}`);
76
+ }
77
+
78
+ it('splits 250 ids into 3 sequential calls (100, 100, 50)', async () => {
79
+ const summaries: ApolloEnrichmentSummary[] = [];
80
+ const client = makeClient(async (_endpoint: string, body: any) => {
81
+ const ids = body.contact_ids as string[];
82
+ const summary: ApolloEnrichmentSummary = {
83
+ total: ids.length,
84
+ enriched: ids.slice(0, ids.length - 1),
85
+ skipped: ids.length > 0
86
+ ? [{ contact_id: ids[ids.length - 1], reason: 'no signal' }]
87
+ : [],
88
+ errors: [],
89
+ missing: [],
90
+ };
91
+ summaries.push(summary);
92
+ return summary;
93
+ });
94
+ const api = new EnrichmentApi(client);
95
+
96
+ const result = await api.enrichApollo(makeIds(250));
97
+
98
+ expect((client.fetch.post as any).mock.calls.length).toBe(3);
99
+ const calls = (client.fetch.post as any).mock.calls;
100
+ expect(calls[0][1].contact_ids).toHaveLength(100);
101
+ expect(calls[1][1].contact_ids).toHaveLength(100);
102
+ expect(calls[2][1].contact_ids).toHaveLength(50);
103
+
104
+ // Aggregate summary covers all 250
105
+ expect(result.total).toBe(250);
106
+ expect(result.enriched).toHaveLength(247);
107
+ expect(result.skipped).toHaveLength(3);
108
+ });
109
+
110
+ it('aggregates across chunks: enriched/skipped/errors/missing are concatenated', async () => {
111
+ const responses: ApolloEnrichmentSummary[] = [
112
+ { total: 100, enriched: ['a'], skipped: [{ contact_id: 'b', reason: 'r' }], errors: [], missing: [] },
113
+ { total: 100, enriched: [], skipped: [], errors: [{ contact_id: 'c', error: 'e' }], missing: ['d'] },
114
+ ];
115
+ let i = 0;
116
+ const client = makeClient(async () => responses[i++]);
117
+ const api = new EnrichmentApi(client);
118
+
119
+ const result = await api.enrichApollo(makeIds(150));
120
+
121
+ expect(result.total).toBe(200); // sum of per-chunk totals
122
+ expect(result.enriched).toEqual(['a']);
123
+ expect(result.skipped).toEqual([{ contact_id: 'b', reason: 'r' }]);
124
+ expect(result.errors).toEqual([{ contact_id: 'c', error: 'e' }]);
125
+ expect(result.missing).toEqual(['d']);
126
+ });
127
+
128
+ it('captures per-chunk failure as a synthetic error and continues to remaining chunks', async () => {
129
+ let i = 0;
130
+ const responses = [
131
+ { total: 100, enriched: ['ok-1'], skipped: [], errors: [], missing: [] },
132
+ // chunk 2 throws (network blip)
133
+ null,
134
+ { total: 50, enriched: ['ok-3'], skipped: [], errors: [], missing: [] },
135
+ ];
136
+ const client = makeClient(async () => {
137
+ const r = responses[i++];
138
+ if (r === null) throw new Error('boom');
139
+ return r;
140
+ });
84
141
  const api = new EnrichmentApi(client);
85
142
 
86
- await expect(api.enrichApollo(['c-1'])).rejects.toThrow(/network/);
143
+ const result = await api.enrichApollo(makeIds(250));
144
+
145
+ expect((client.fetch.post as any).mock.calls.length).toBe(3);
146
+ expect(result.enriched).toEqual(['ok-1', 'ok-3']);
147
+ // The chunk that threw becomes one error entry per id in that chunk so the
148
+ // caller can see which ids were impacted.
149
+ expect(result.errors.length).toBe(100);
150
+ expect(result.errors[0].error).toMatch(/boom/);
151
+ });
152
+
153
+ it('calls onProgress with cumulative count after each chunk', async () => {
154
+ const client = makeClient(async (_e: string, body: any) => ({
155
+ total: body.contact_ids.length,
156
+ enriched: body.contact_ids,
157
+ skipped: [], errors: [], missing: [],
158
+ }));
159
+ const api = new EnrichmentApi(client);
160
+ const onProgress = vi.fn();
161
+
162
+ await api.enrichApollo(makeIds(220), { onProgress });
163
+
164
+ expect(onProgress).toHaveBeenCalledTimes(3);
165
+ expect(onProgress).toHaveBeenNthCalledWith(1, 100, 220);
166
+ expect(onProgress).toHaveBeenNthCalledWith(2, 200, 220);
167
+ expect(onProgress).toHaveBeenNthCalledWith(3, 220, 220);
168
+ });
169
+
170
+ it('a single-chunk call (<=100 ids) makes exactly one request', async () => {
171
+ const client = makeClient(emptySummary);
172
+ const api = new EnrichmentApi(client);
173
+ await api.enrichApollo(Array.from({ length: 100 }, (_, i) => `c-${i}`));
174
+ expect((client.fetch.post as any).mock.calls.length).toBe(1);
87
175
  });
88
176
  });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * fetchAllPages — walks DRF `next` links until exhausted.
3
+ *
4
+ * Refs: raise-simpli-2a7.
5
+ */
6
+
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ import { fetchAllPages } from '../utils/fetch-all-pages';
9
+ import type { ApiClient } from '../lib/api-client';
10
+
11
+
12
+ function makeClient(getResponses: unknown[]): ApiClient {
13
+ let i = 0;
14
+ const get = vi.fn(async () => {
15
+ const r = getResponses[i++];
16
+ if (r === undefined) throw new Error('test ran out of mock responses');
17
+ return r;
18
+ });
19
+ return {
20
+ get,
21
+ post: vi.fn(),
22
+ patch: vi.fn(),
23
+ delete: vi.fn(),
24
+ baseUrl: 'http://localhost:8000',
25
+ fetch: { get } as any,
26
+ setTokenGetter: vi.fn(),
27
+ setUnauthorizedHandler: vi.fn(),
28
+ } as unknown as ApiClient;
29
+ }
30
+
31
+
32
+ describe('fetchAllPages', () => {
33
+ it('returns single-page DRF result as flat array', async () => {
34
+ const client = makeClient([
35
+ { count: 2, next: null, previous: null, results: [{ id: '1' }, { id: '2' }] },
36
+ ]);
37
+ const items = await fetchAllPages<{ id: string }>(client, 'api/v1/things/');
38
+ expect(items).toEqual([{ id: '1' }, { id: '2' }]);
39
+ expect(client.fetch.get).toHaveBeenCalledTimes(1);
40
+ });
41
+
42
+ it('walks next links until null', async () => {
43
+ const client = makeClient([
44
+ { count: 3, next: 'http://x/?page=2', previous: null, results: [{ id: '1' }] },
45
+ { count: 3, next: 'http://x/?page=3', previous: '1', results: [{ id: '2' }] },
46
+ { count: 3, next: null, previous: '2', results: [{ id: '3' }] },
47
+ ]);
48
+ const items = await fetchAllPages<{ id: string }>(client, 'api/v1/things/');
49
+ expect(items.map((i) => i.id)).toEqual(['1', '2', '3']);
50
+ expect(client.fetch.get).toHaveBeenCalledTimes(3);
51
+ });
52
+
53
+ it('passes pageSize as ?page_size= on first request', async () => {
54
+ const client = makeClient([
55
+ { count: 1, next: null, previous: null, results: [{ id: '1' }] },
56
+ ]);
57
+ await fetchAllPages(client, 'api/v1/things/', { pageSize: 250 });
58
+ const firstCall = (client.fetch.get as any).mock.calls[0];
59
+ expect(String(firstCall[0])).toMatch(/page_size=250/);
60
+ });
61
+
62
+ it('returns bare arrays unchanged (legacy non-paginated endpoints)', async () => {
63
+ const client = makeClient([[{ id: 'a' }, { id: 'b' }]]);
64
+ const items = await fetchAllPages<{ id: string }>(client, 'api/v1/things/');
65
+ expect(items).toEqual([{ id: 'a' }, { id: 'b' }]);
66
+ });
67
+
68
+ it('returns [] for null/undefined responses without throwing', async () => {
69
+ const client = makeClient([null]);
70
+ const items = await fetchAllPages(client, 'api/v1/things/');
71
+ expect(items).toEqual([]);
72
+ });
73
+
74
+ it('honors maxPages cap to prevent runaway loops', async () => {
75
+ const responses = [
76
+ { count: 999, next: 'http://x/?page=2', previous: null, results: [{ id: '1' }] },
77
+ { count: 999, next: 'http://x/?page=3', previous: '1', results: [{ id: '2' }] },
78
+ { count: 999, next: 'http://x/?page=4', previous: '2', results: [{ id: '3' }] },
79
+ { count: 999, next: 'http://x/?page=5', previous: '3', results: [{ id: '4' }] },
80
+ ];
81
+ const client = makeClient(responses);
82
+ const items = await fetchAllPages(client, 'api/v1/things/', { maxPages: 2 });
83
+ expect(items).toHaveLength(2);
84
+ expect(client.fetch.get).toHaveBeenCalledTimes(2);
85
+ });
86
+
87
+ it('calls onPage with each page batch', async () => {
88
+ const client = makeClient([
89
+ { count: 4, next: 'http://x/?page=2', previous: null, results: [{ id: '1' }, { id: '2' }] },
90
+ { count: 4, next: null, previous: '1', results: [{ id: '3' }, { id: '4' }] },
91
+ ]);
92
+ const onPage = vi.fn();
93
+ await fetchAllPages(client, 'api/v1/things/', { onPage });
94
+ expect(onPage).toHaveBeenCalledTimes(2);
95
+ expect(onPage).toHaveBeenNthCalledWith(1, [{ id: '1' }, { id: '2' }], 0);
96
+ expect(onPage).toHaveBeenNthCalledWith(2, [{ id: '3' }, { id: '4' }], 1);
97
+ });
98
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Wrapper short-circuits unsafe methods when getToken() returns null.
3
+ *
4
+ * Without this, a POST/PATCH/DELETE fired with no Authorization header
5
+ * gets a 403 from Django (CSRF or unauth path) rather than 401, so the
6
+ * wrapper's 401-refresh path never runs and the user is never redirected
7
+ * to /auth/signin. raise-simpli-lxv.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
11
+ import { FetchWrapper } from '../lib/fetch-wrapper';
12
+ import { ApiException } from '../lib/error-handler';
13
+
14
+ describe('Wrapper no-token unsafe-method short-circuit', () => {
15
+ let mockFetch: ReturnType<typeof vi.fn>;
16
+ let unauthorizedCallback: ReturnType<typeof vi.fn>;
17
+
18
+ beforeEach(() => {
19
+ mockFetch = vi.fn();
20
+ unauthorizedCallback = vi.fn();
21
+ global.fetch = mockFetch;
22
+ });
23
+
24
+ it('fires onUnauthorized and throws without sending the request when POST has no token', async () => {
25
+ const wrapper = new FetchWrapper({
26
+ baseUrl: 'http://localhost:8000',
27
+ getToken: () => null,
28
+ onUnauthorized: unauthorizedCallback,
29
+ });
30
+
31
+ await expect(
32
+ wrapper.post('/api/v1/calendar/meetings/', { title: 'x' }),
33
+ ).rejects.toBeInstanceOf(ApiException);
34
+
35
+ expect(mockFetch).not.toHaveBeenCalled();
36
+ expect(unauthorizedCallback).toHaveBeenCalledTimes(1);
37
+ });
38
+
39
+ it.each(['post', 'put', 'patch', 'delete'] as const)(
40
+ 'short-circuits %s with no token',
41
+ async (method) => {
42
+ const wrapper = new FetchWrapper({
43
+ baseUrl: 'http://localhost:8000',
44
+ getToken: () => null,
45
+ onUnauthorized: unauthorizedCallback,
46
+ });
47
+
48
+ await expect((wrapper as any)[method]('/x')).rejects.toBeInstanceOf(ApiException);
49
+ expect(mockFetch).not.toHaveBeenCalled();
50
+ expect(unauthorizedCallback).toHaveBeenCalledTimes(1);
51
+ },
52
+ );
53
+
54
+ it('does NOT short-circuit GET when there is no token (anonymous reads are allowed)', async () => {
55
+ mockFetch.mockResolvedValueOnce({
56
+ status: 200,
57
+ ok: true,
58
+ headers: { get: () => 'application/json' },
59
+ json: async () => ({}),
60
+ });
61
+
62
+ const wrapper = new FetchWrapper({
63
+ baseUrl: 'http://localhost:8000',
64
+ getToken: () => null,
65
+ onUnauthorized: unauthorizedCallback,
66
+ });
67
+
68
+ await wrapper.get('/api/v1/public/health/');
69
+ expect(mockFetch).toHaveBeenCalledTimes(1);
70
+ expect(unauthorizedCallback).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it('does NOT short-circuit when getToken is unconfigured (consumer opted out of auth)', async () => {
74
+ mockFetch.mockResolvedValueOnce({
75
+ status: 200,
76
+ ok: true,
77
+ headers: { get: () => 'application/json' },
78
+ json: async () => ({}),
79
+ });
80
+
81
+ const wrapper = new FetchWrapper({
82
+ baseUrl: 'http://localhost:8000',
83
+ onUnauthorized: unauthorizedCallback,
84
+ });
85
+
86
+ await wrapper.post('/api/v1/public/feedback/', { msg: 'hi' });
87
+ expect(mockFetch).toHaveBeenCalledTimes(1);
88
+ expect(unauthorizedCallback).not.toHaveBeenCalled();
89
+ });
90
+ });
@@ -65,4 +65,16 @@ export const ENDPOINTS = {
65
65
 
66
66
  // Companies / Feature flags
67
67
  FEATURE_FLAGS: 'api/v1/companies/feature-flags',
68
+
69
+ // Markets (instruments, prices, analytics, news, health)
70
+ INSTRUMENTS: 'api/v1/markets/instruments',
71
+ INSTRUMENT: (symbol: string) => `api/v1/markets/instruments/${symbol}`,
72
+ INSTRUMENT_BARS: (symbol: string) => `api/v1/markets/instruments/${symbol}/bars`,
73
+ INSTRUMENT_LATEST: (symbol: string) => `api/v1/markets/instruments/${symbol}/latest`,
74
+ INSTRUMENT_ANALYTICS: (symbol: string) => `api/v1/markets/instruments/${symbol}/analytics`,
75
+ INSTRUMENT_NEWS: (symbol: string) => `api/v1/markets/instruments/${symbol}/news`,
76
+ MARKETS_HEALTH: 'api/v1/markets/health',
77
+
78
+ // Sources ops
79
+ SOURCES_HEALTH: 'api/v1/sources/ops/health',
68
80
  } as const;
package/src/index.ts CHANGED
@@ -162,6 +162,7 @@ import { UsersApi } from './lib/users-api';
162
162
  import { EnrichmentApi } from './lib/enrichment-api';
163
163
  import { TargetListsApi } from './lib/target-lists-api';
164
164
  import { MessageTemplatesApi } from './lib/message-templates-api';
165
+ import { MarketsApi } from './lib/markets-api';
165
166
 
166
167
  import type { ApiClientConfig } from './lib/api-client';
167
168
 
@@ -185,5 +186,30 @@ export function createStartSimpliApi(config: ApiClientConfig = {}) {
185
186
  enrichment: new EnrichmentApi(client),
186
187
  targetLists: new TargetListsApi(client),
187
188
  messageTemplates: new MessageTemplatesApi(client),
189
+ markets: new MarketsApi(client),
188
190
  };
189
191
  }
192
+
193
+ // Markets API
194
+ export { MarketsApi } from './lib/markets-api';
195
+ export type {
196
+ BarInterval,
197
+ Instrument,
198
+ PriceBar,
199
+ InstrumentFilters,
200
+ BarsParams,
201
+ AnalyticsMetric,
202
+ AnalyticsParams,
203
+ AnalyticsResponse,
204
+ ReturnsAnalytics,
205
+ VolatilityPoint,
206
+ BetaAnalytics,
207
+ PairSpreadPoint,
208
+ InstrumentNewsItem,
209
+ InstrumentNewsResponse,
210
+ NewsParams,
211
+ InstrumentHealth,
212
+ InstrumentHealthInterval,
213
+ SourceInstanceHealth,
214
+ MarketsHealth,
215
+ } from './lib/markets-api';
@@ -6,29 +6,77 @@ import type { EnrichmentResult, QueueStatus, ApolloEnrichmentSummary } from '../
6
6
  import { ENDPOINTS } from '../constants/endpoints';
7
7
  import type { ApiClient } from './api-client';
8
8
 
9
+ /**
10
+ * Backend `POST /api/v1/contacts/enrich-apollo/` enforces a 100-id cap
11
+ * per request. Callers can pass any number — the client chunks here.
12
+ */
9
13
  const APOLLO_BATCH_CAP = 100;
10
14
 
15
+
16
+ export interface EnrichApolloOptions {
17
+ /**
18
+ * Called after each chunk completes. `done` and `total` count
19
+ * contacts (not chunks), so UIs can show "120 of 250 enriched…".
20
+ */
21
+ onProgress?: (done: number, total: number) => void;
22
+ }
23
+
24
+
11
25
  export class EnrichmentApi {
12
26
  constructor(private client: ApiClient) {}
13
27
 
14
28
  /**
15
- * Trigger Apollo enrichment for one or more contacts.
29
+ * Trigger Apollo enrichment for any number of contacts.
16
30
  *
17
- * Backend: POST /api/v1/contacts/enrich-apollo/. Team-scoped contact
18
- * ids the user can't see come back under `missing`. Calls fail fast on
19
- * empty list or > 100 ids without round-tripping the server.
31
+ * Backend: POST /api/v1/contacts/enrich-apollo/ (capped at 100 per
32
+ * request). Caller supplies the full id list; this method chunks
33
+ * into batches of 100, calls the backend sequentially, and returns
34
+ * a single aggregated summary.
35
+ *
36
+ * Per-chunk failures are recorded as one error entry per contact_id
37
+ * in the failed chunk so the caller can see which ids were impacted,
38
+ * but the remaining chunks still run (partial-success bias).
20
39
  */
21
- async enrichApollo(contactIds: string[]): Promise<ApolloEnrichmentSummary> {
40
+ async enrichApollo(
41
+ contactIds: string[],
42
+ options: EnrichApolloOptions = {},
43
+ ): Promise<ApolloEnrichmentSummary> {
22
44
  if (!Array.isArray(contactIds) || contactIds.length === 0) {
23
45
  throw new Error('enrichApollo requires a non-empty list of contact_ids');
24
46
  }
25
- if (contactIds.length > APOLLO_BATCH_CAP) {
26
- throw new Error(`enrichApollo accepts at most ${APOLLO_BATCH_CAP} contact_ids per call`);
47
+
48
+ const { onProgress } = options;
49
+ const chunks = chunk(contactIds, APOLLO_BATCH_CAP);
50
+ const aggregate: ApolloEnrichmentSummary = {
51
+ total: 0, enriched: [], skipped: [], errors: [], missing: [],
52
+ };
53
+
54
+ let done = 0;
55
+ for (const ids of chunks) {
56
+ try {
57
+ const summary = await this.client.fetch.post<ApolloEnrichmentSummary>(
58
+ ENDPOINTS.CONTACTS_ENRICH_APOLLO,
59
+ { contact_ids: ids },
60
+ );
61
+ aggregate.total += summary.total ?? 0;
62
+ if (summary.enriched) aggregate.enriched.push(...summary.enriched);
63
+ if (summary.skipped) aggregate.skipped.push(...summary.skipped);
64
+ if (summary.errors) aggregate.errors.push(...summary.errors);
65
+ if (summary.missing) aggregate.missing.push(...summary.missing);
66
+ } catch (err) {
67
+ // Don't bail the whole batch on a network blip in chunk N — record
68
+ // a per-id error entry so the caller knows what was impacted, then
69
+ // continue with the next chunk.
70
+ const message = err instanceof Error ? err.message : 'enrichment failed';
71
+ for (const id of ids) {
72
+ aggregate.errors.push({ contact_id: id, error: message });
73
+ }
74
+ }
75
+ done += ids.length;
76
+ onProgress?.(done, contactIds.length);
27
77
  }
28
- return this.client.fetch.post<ApolloEnrichmentSummary>(
29
- ENDPOINTS.CONTACTS_ENRICH_APOLLO,
30
- { contact_ids: contactIds },
31
- );
78
+
79
+ return aggregate;
32
80
  }
33
81
 
34
82
  /**
@@ -77,3 +125,13 @@ export class EnrichmentApi {
77
125
  return this.client.fetch.get<QueueStatus>(ENDPOINTS.ENRICHMENT_QUEUE);
78
126
  }
79
127
  }
128
+
129
+
130
+ function chunk<T>(items: T[], size: number): T[][] {
131
+ if (size <= 0) return [items];
132
+ const out: T[][] = [];
133
+ for (let i = 0; i < items.length; i += size) {
134
+ out.push(items.slice(i, i + size));
135
+ }
136
+ return out;
137
+ }
@@ -92,6 +92,24 @@ export class FetchWrapper {
92
92
 
93
93
  // Execute request
94
94
  const hasAuth = headers.has('Authorization');
95
+
96
+ // Short-circuit unsafe methods (POST/PATCH/PUT/DELETE) when the
97
+ // token is missing — Django would respond 403 (or 401) and we'd
98
+ // never trigger the redirect because the wrapper's recovery path
99
+ // only fires on 401-with-refresh. Surface the session loss
100
+ // immediately instead so the registered onUnauthorized sink (e.g.
101
+ // AuthProvider redirect to /auth/signin) fires before the user
102
+ // assumes their action succeeded. raise-simpli-lxv.
103
+ const isUnsafe = method !== 'GET';
104
+ if (!hasAuth && isUnsafe && this.config.getToken) {
105
+ console.log(`[FetchWrapper] ${method} ${url} (auth=false) — refusing unsafe method, firing onUnauthorized`);
106
+ this.fireUnauthorizedOnce();
107
+ throw new ApiException('Session expired — please sign in again', {
108
+ status: 401,
109
+ statusText: 'Unauthorized',
110
+ });
111
+ }
112
+
95
113
  console.log(`[FetchWrapper] ${method} ${url} (auth=${hasAuth})`);
96
114
  let response = await fetch(url, {
97
115
  method,
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Markets API wrapper for /api/v1/markets/*
3
+ *
4
+ * Backs the trade-simpli app: instruments, OHLCV bars, analytics
5
+ * (returns / volatility / beta / pair_spread), per-instrument news,
6
+ * and operational health rollup.
7
+ */
8
+
9
+ import type { PaginatedResponse } from '../types';
10
+ import { ENDPOINTS } from '../constants/endpoints';
11
+ import type { ApiClient } from './api-client';
12
+
13
+ export type BarInterval = '1d' | '1h' | '5m' | '1m';
14
+
15
+ export interface Instrument {
16
+ symbol: string;
17
+ name?: string | null;
18
+ instrumentType?: string | null;
19
+ exchange?: string | null;
20
+ isActive?: boolean;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface PriceBar {
25
+ barDate: string;
26
+ open: number;
27
+ high: number;
28
+ low: number;
29
+ close: number;
30
+ volume?: number | null;
31
+ interval?: BarInterval;
32
+ }
33
+
34
+ export interface InstrumentFilters {
35
+ q?: string;
36
+ instrumentType?: string;
37
+ exchange?: string;
38
+ isActive?: boolean;
39
+ page?: number;
40
+ pageSize?: number;
41
+ }
42
+
43
+ export interface BarsParams {
44
+ interval?: BarInterval;
45
+ since?: string;
46
+ limit?: number;
47
+ }
48
+
49
+ export type AnalyticsMetric = 'returns' | 'volatility' | 'beta' | 'pair_spread';
50
+
51
+ export interface AnalyticsParams {
52
+ metric: AnalyticsMetric;
53
+ windowDays?: number;
54
+ window?: number;
55
+ interval?: BarInterval;
56
+ benchmark?: string;
57
+ vs?: string;
58
+ }
59
+
60
+ export interface ReturnsAnalytics {
61
+ symbol: string;
62
+ windowDays: number;
63
+ startPrice: number;
64
+ endPrice: number;
65
+ simpleReturn: number;
66
+ logReturn: number;
67
+ barCount: number;
68
+ }
69
+
70
+ export interface VolatilityPoint {
71
+ barDate: string;
72
+ volatility: number;
73
+ }
74
+
75
+ export interface BetaAnalytics {
76
+ symbol: string;
77
+ benchmark: string;
78
+ beta: number;
79
+ }
80
+
81
+ export interface PairSpreadPoint {
82
+ barDate: string;
83
+ longClose: number;
84
+ shortClose: number;
85
+ spread: number;
86
+ logSpread: number;
87
+ }
88
+
89
+ export type AnalyticsResponse =
90
+ | ReturnsAnalytics
91
+ | VolatilityPoint[]
92
+ | BetaAnalytics
93
+ | PairSpreadPoint[];
94
+
95
+ export interface InstrumentNewsItem {
96
+ id: string;
97
+ title: string;
98
+ summary?: string | null;
99
+ url: string;
100
+ publisherName?: string | null;
101
+ publisherDomain?: string | null;
102
+ publishedAt?: string | null;
103
+ queryTag?: string | null;
104
+ confidence?: number | null;
105
+ ingestedAt?: string | null;
106
+ }
107
+
108
+ export interface InstrumentNewsResponse {
109
+ symbol: string;
110
+ count: number;
111
+ results: InstrumentNewsItem[];
112
+ }
113
+
114
+ export interface NewsParams {
115
+ limit?: number;
116
+ minConfidence?: number;
117
+ since?: string;
118
+ }
119
+
120
+ export interface InstrumentHealthInterval {
121
+ interval: BarInterval;
122
+ barCount: number;
123
+ latestBarAt?: string | null;
124
+ ageSeconds?: number | null;
125
+ isStale: boolean;
126
+ gaps30d: number;
127
+ }
128
+
129
+ export interface InstrumentHealth {
130
+ symbol: string;
131
+ name?: string | null;
132
+ intervals: InstrumentHealthInterval[];
133
+ }
134
+
135
+ export interface SourceInstanceHealth {
136
+ sourceKey: string;
137
+ label?: string | null;
138
+ healthStatus: string;
139
+ consecutiveFailureCount: number;
140
+ runs24h: { total: number; completed: number; successRatio: number };
141
+ lastRunAt?: string | null;
142
+ lastSuccessAt?: string | null;
143
+ openDeadLetters: number;
144
+ }
145
+
146
+ export interface MarketsHealth {
147
+ generatedAt: string;
148
+ instruments: InstrumentHealth[];
149
+ sourceInstances: SourceInstanceHealth[];
150
+ }
151
+
152
+ export class MarketsApi {
153
+ constructor(private client: ApiClient) {}
154
+
155
+ async listInstruments(filters?: InstrumentFilters): Promise<PaginatedResponse<Instrument>> {
156
+ return this.client.fetch.get<PaginatedResponse<Instrument>>(ENDPOINTS.INSTRUMENTS, {
157
+ params: filters as Record<string, unknown> | undefined,
158
+ });
159
+ }
160
+
161
+ async getInstrument(symbol: string): Promise<Instrument> {
162
+ return this.client.fetch.get<Instrument>(ENDPOINTS.INSTRUMENT(symbol));
163
+ }
164
+
165
+ async getBars(symbol: string, params?: BarsParams): Promise<PriceBar[] | PaginatedResponse<PriceBar>> {
166
+ return this.client.fetch.get<PriceBar[] | PaginatedResponse<PriceBar>>(
167
+ ENDPOINTS.INSTRUMENT_BARS(symbol),
168
+ { params: params as Record<string, unknown> | undefined },
169
+ );
170
+ }
171
+
172
+ async getLatestBar(symbol: string, interval: BarInterval = '1d'): Promise<PriceBar> {
173
+ return this.client.fetch.get<PriceBar>(ENDPOINTS.INSTRUMENT_LATEST(symbol), {
174
+ params: { interval },
175
+ });
176
+ }
177
+
178
+ async getAnalytics(symbol: string, params: AnalyticsParams): Promise<AnalyticsResponse> {
179
+ return this.client.fetch.get<AnalyticsResponse>(ENDPOINTS.INSTRUMENT_ANALYTICS(symbol), {
180
+ params: params as unknown as Record<string, unknown>,
181
+ });
182
+ }
183
+
184
+ async getNews(symbol: string, params?: NewsParams): Promise<InstrumentNewsResponse> {
185
+ return this.client.fetch.get<InstrumentNewsResponse>(ENDPOINTS.INSTRUMENT_NEWS(symbol), {
186
+ params: params as Record<string, unknown> | undefined,
187
+ });
188
+ }
189
+
190
+ async getHealth(): Promise<MarketsHealth> {
191
+ return this.client.fetch.get<MarketsHealth>(ENDPOINTS.MARKETS_HEALTH);
192
+ }
193
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Fetch every page of a paginated DRF endpoint and return a flat array.
3
+ *
4
+ * Walks `next` URLs until exhausted. Bounded by `maxPages` to prevent
5
+ * runaway loops on misbehaving APIs. Gracefully handles legacy endpoints
6
+ * that return a bare array instead of a paginated envelope.
7
+ *
8
+ * Refs: raise-simpli-2a7.
9
+ */
10
+
11
+ import type { ApiClient } from '../lib/api-client';
12
+ import { isDRFPaginatedResponse } from './drf-transforms';
13
+ import type { DRFPaginatedResponse } from './drf-transforms';
14
+
15
+
16
+ export interface FetchAllPagesOptions<T> {
17
+ /** Hint for first-page size; appended as ?page_size= when set. */
18
+ pageSize?: number;
19
+ /** Hard cap on pages walked (default 1000). */
20
+ maxPages?: number;
21
+ /** Called with each batch of items + the zero-indexed page number. */
22
+ onPage?: (items: T[], pageIndex: number) => void;
23
+ }
24
+
25
+
26
+ export async function fetchAllPages<T>(
27
+ client: ApiClient,
28
+ endpoint: string,
29
+ options: FetchAllPagesOptions<T> = {},
30
+ ): Promise<T[]> {
31
+ const { pageSize, maxPages = 1000, onPage } = options;
32
+
33
+ let url: string = pageSize
34
+ ? appendQuery(endpoint, 'page_size', String(pageSize))
35
+ : endpoint;
36
+
37
+ const all: T[] = [];
38
+
39
+ for (let page = 0; page < maxPages; page++) {
40
+ const response = await client.fetch.get<
41
+ DRFPaginatedResponse<T> | T[] | null | undefined
42
+ >(url);
43
+
44
+ if (response == null) {
45
+ break;
46
+ }
47
+
48
+ if (Array.isArray(response)) {
49
+ // Legacy non-paginated endpoint — return as-is.
50
+ onPage?.(response, page);
51
+ return [...all, ...response];
52
+ }
53
+
54
+ if (!isDRFPaginatedResponse<T>(response)) {
55
+ // Unknown shape — bail out without throwing.
56
+ break;
57
+ }
58
+
59
+ const items = response.results ?? [];
60
+ all.push(...items);
61
+ onPage?.(items, page);
62
+
63
+ if (!response.next) {
64
+ break;
65
+ }
66
+ url = response.next;
67
+ }
68
+
69
+ return all;
70
+ }
71
+
72
+
73
+ function appendQuery(url: string, key: string, value: string): string {
74
+ const sep = url.includes('?') ? '&' : '?';
75
+ return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
76
+ }
@@ -18,6 +18,9 @@ export { EntityQueryBuilder } from './entity-query-builder';
18
18
 
19
19
  export { normalizePaginated, isDRFPaginatedResponse } from './drf-transforms';
20
20
 
21
+ export { fetchAllPages } from './fetch-all-pages';
22
+ export type { FetchAllPagesOptions } from './fetch-all-pages';
23
+
21
24
  export { snakeToCamel, camelToSnake } from './case-transform';
22
25
  export type {
23
26
  DRFPaginatedResponse,