@startsimpli/api 0.5.7 → 0.5.9

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.7",
3
+ "version": "0.5.9",
4
4
  "description": "Type-safe Django REST API client for StartSimpli apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -23,14 +23,13 @@
23
23
  "clean": "rm -rf dist"
24
24
  },
25
25
  "dependencies": {
26
- "@types/dompurify": "^3.0.5",
27
- "isomorphic-dompurify": "^2.36.0",
28
- "zod": "^3.22.4"
26
+ "isomorphic-dompurify": "^3.10.0",
27
+ "zod": "^4.3.6"
29
28
  },
30
29
  "peerDependencies": {
30
+ "@tanstack/react-query": ">=5.0.0",
31
31
  "next": ">=14.0.0",
32
- "react": ">=18.0.0",
33
- "@tanstack/react-query": ">=5.0.0"
32
+ "react": ">=18.0.0"
34
33
  },
35
34
  "peerDependenciesMeta": {
36
35
  "next": {
@@ -44,13 +43,13 @@
44
43
  }
45
44
  },
46
45
  "devDependencies": {
47
- "@tanstack/react-query": "^5.0.0",
48
- "@types/node": "^20.11.0",
49
- "@types/react": "^18.2.0",
50
- "next": "^15.5.12",
51
- "react": "^18.2.0",
46
+ "@tanstack/react-query": "^5.99.2",
47
+ "@types/node": "^20.19.39",
48
+ "@types/react": "^19.2.14",
49
+ "next": "^15.5.15",
50
+ "react": "^19.2.5",
52
51
  "tsup": "^8.5.1",
53
- "typescript": "^5.3.3",
54
- "vitest": "^1.2.0"
52
+ "typescript": "^6.0.3",
53
+ "vitest": "^4.1.5"
55
54
  }
56
55
  }
@@ -72,7 +72,7 @@ describe('FunnelsApi', () => {
72
72
  it('calls GET funnels with no params when no filters given', async () => {
73
73
  vi.mocked(client.get).mockResolvedValue(mockPaginated([mockFunnel]));
74
74
  await api.list();
75
- expect(client.get).toHaveBeenCalledWith('funnels');
75
+ expect(client.get).toHaveBeenCalledWith('api/v1/funnels');
76
76
  });
77
77
 
78
78
  it('passes status filter as query param', async () => {
@@ -102,7 +102,7 @@ describe('FunnelsApi', () => {
102
102
  it('calls GET funnels/:id', async () => {
103
103
  vi.mocked(client.get).mockResolvedValue(mockFunnel);
104
104
  const result = await api.get('funnel-1');
105
- expect(client.get).toHaveBeenCalledWith('funnels/funnel-1');
105
+ expect(client.get).toHaveBeenCalledWith('api/v1/funnels/funnel-1');
106
106
  expect(result).toEqual(mockFunnel);
107
107
  });
108
108
  });
@@ -112,7 +112,7 @@ describe('FunnelsApi', () => {
112
112
  vi.mocked(client.post).mockResolvedValue(mockFunnel);
113
113
  const input = { name: 'New Funnel', entityType: 'contact' as const, tags: ['campaign:abc'] };
114
114
  await api.create(input);
115
- expect(client.post).toHaveBeenCalledWith('funnels', input);
115
+ expect(client.post).toHaveBeenCalledWith('api/v1/funnels', input);
116
116
  });
117
117
  });
118
118
 
@@ -120,7 +120,7 @@ describe('FunnelsApi', () => {
120
120
  it('calls PATCH funnels/:id with data', async () => {
121
121
  vi.mocked(client.patch).mockResolvedValue(mockFunnel);
122
122
  await api.update('funnel-1', { name: 'Renamed' });
123
- expect(client.patch).toHaveBeenCalledWith('funnels/funnel-1', { name: 'Renamed' });
123
+ expect(client.patch).toHaveBeenCalledWith('api/v1/funnels/funnel-1', { name: 'Renamed' });
124
124
  });
125
125
  });
126
126
 
@@ -128,7 +128,7 @@ describe('FunnelsApi', () => {
128
128
  it('calls DELETE funnels/:id', async () => {
129
129
  vi.mocked(client.delete).mockResolvedValue(undefined);
130
130
  await api.delete('funnel-1');
131
- expect(client.delete).toHaveBeenCalledWith('funnels/funnel-1');
131
+ expect(client.delete).toHaveBeenCalledWith('api/v1/funnels/funnel-1');
132
132
  });
133
133
  });
134
134
 
@@ -136,13 +136,13 @@ describe('FunnelsApi', () => {
136
136
  it('calls POST funnels/:id/run with empty body by default', async () => {
137
137
  vi.mocked(client.post).mockResolvedValue(mockRun);
138
138
  await api.run('funnel-1');
139
- expect(client.post).toHaveBeenCalledWith('funnels/funnel-1/run', {});
139
+ expect(client.post).toHaveBeenCalledWith('api/v1/funnels/funnel-1/run', {});
140
140
  });
141
141
 
142
142
  it('passes entityIds in body when provided', async () => {
143
143
  vi.mocked(client.post).mockResolvedValue(mockRun);
144
144
  await api.run('funnel-1', { entityIds: ['e1', 'e2'] });
145
- expect(client.post).toHaveBeenCalledWith('funnels/funnel-1/run', { entityIds: ['e1', 'e2'] });
145
+ expect(client.post).toHaveBeenCalledWith('api/v1/funnels/funnel-1/run', { entityIds: ['e1', 'e2'] });
146
146
  });
147
147
  });
148
148
 
@@ -150,7 +150,7 @@ describe('FunnelsApi', () => {
150
150
  it('calls GET funnels/:id/runs', async () => {
151
151
  vi.mocked(client.get).mockResolvedValue(mockPaginated([mockRun]));
152
152
  await api.getRuns('funnel-1');
153
- expect(client.get).toHaveBeenCalledWith('funnels/funnel-1/runs');
153
+ expect(client.get).toHaveBeenCalledWith('api/v1/funnels/funnel-1/runs');
154
154
  });
155
155
 
156
156
  it('passes page and pageSize filters', async () => {
@@ -166,14 +166,15 @@ describe('FunnelsApi', () => {
166
166
  it('calls GET funnel-runs/:runId (global endpoint, funnelId ignored)', async () => {
167
167
  vi.mocked(client.get).mockResolvedValue(mockRun);
168
168
  await api.getRun('funnel-1', 'run-1');
169
- expect(client.get).toHaveBeenCalledWith('funnel-runs/run-1');
169
+ expect(client.get).toHaveBeenCalledWith('api/v1/funnel-runs/run-1');
170
170
  });
171
171
  });
172
172
 
173
173
  describe('preview', () => {
174
- it('throws Not implemented (endpoint missing in Django)', async () => {
175
- // BEAD: fund-your-startup-rgi4 - funnels/{id}/preview does not exist in Django
176
- await expect(api.preview('funnel-1', [])).rejects.toThrow('Not implemented');
174
+ it('calls POST funnels/:id/preview with stages', async () => {
175
+ vi.mocked(client.post).mockResolvedValue({} as never);
176
+ await api.preview('funnel-1', []);
177
+ expect(client.post).toHaveBeenCalledWith('api/v1/funnels/funnel-1/preview', { stages: [] });
177
178
  });
178
179
  });
179
180
 
@@ -181,7 +182,7 @@ describe('FunnelsApi', () => {
181
182
  it('calls POST funnel-runs/:runId/cancel', async () => {
182
183
  vi.mocked(client.post).mockResolvedValue(mockRun);
183
184
  await api.cancelRun('run-1');
184
- expect(client.post).toHaveBeenCalledWith('funnel-runs/run-1/cancel', {});
185
+ expect(client.post).toHaveBeenCalledWith('api/v1/funnel-runs/run-1/cancel', {});
185
186
  });
186
187
  });
187
188
 
@@ -189,7 +190,7 @@ describe('FunnelsApi', () => {
189
190
  it('calls GET funnel-runs with no params when no filters given', async () => {
190
191
  vi.mocked(client.get).mockResolvedValue(mockPaginated([mockRun]));
191
192
  await api.listRunsGlobal();
192
- expect(client.get).toHaveBeenCalledWith('funnel-runs');
193
+ expect(client.get).toHaveBeenCalledWith('api/v1/funnel-runs');
193
194
  });
194
195
 
195
196
  it('passes funnel filter as query param', async () => {
@@ -203,7 +204,7 @@ describe('FunnelsApi', () => {
203
204
  it('calls GET funnels/templates', async () => {
204
205
  vi.mocked(client.get).mockResolvedValue(mockPaginated([]));
205
206
  await api.listTemplates();
206
- expect(client.get).toHaveBeenCalledWith('funnels/templates');
207
+ expect(client.get).toHaveBeenCalledWith('api/v1/funnels/templates');
207
208
  });
208
209
 
209
210
  it('passes category filter', async () => {
@@ -123,6 +123,58 @@ describe('JWT Refresh Flow', () => {
123
123
  expect(refreshCallback).toHaveBeenCalledTimes(1);
124
124
  });
125
125
 
126
+ it('does NOT fire onUnauthorized when onTokenRefresh THROWS (transient 5xx / network)', async () => {
127
+ // This is the "backend is down, not session expired" case. If refresh
128
+ // throws (TransientRefreshError), we must surface the original 401 as an
129
+ // API error WITHOUT logging the user out.
130
+ const wrapper = new FetchWrapper({
131
+ baseUrl: 'http://localhost:8000',
132
+ getToken: () => 'stale-token',
133
+ onTokenRefresh: refreshCallback,
134
+ onUnauthorized: unauthorizedCallback,
135
+ });
136
+
137
+ mockFetch.mockResolvedValueOnce({
138
+ status: 401,
139
+ ok: false,
140
+ headers: new Headers(),
141
+ text: async () => '',
142
+ });
143
+ refreshCallback.mockRejectedValueOnce(new Error('transient 5xx'));
144
+
145
+ await expect(wrapper.get('/api/v1/messages/')).rejects.toBeTruthy();
146
+
147
+ expect(refreshCallback).toHaveBeenCalledTimes(1);
148
+ // CRITICAL: a transient refresh failure must NOT call onUnauthorized.
149
+ // Backend being down should not log users out.
150
+ expect(unauthorizedCallback).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('should fire onUnauthorized at most once when many parallel 401s fail refresh', async () => {
154
+ const wrapper = new FetchWrapper({
155
+ baseUrl: 'http://localhost:8000',
156
+ getToken: () => 'old-token',
157
+ onTokenRefresh: refreshCallback,
158
+ onUnauthorized: unauthorizedCallback,
159
+ });
160
+
161
+ const mock401 = { status: 401, ok: false, headers: new Headers(), text: async () => '' };
162
+
163
+ // All five requests hit 401 and the retry is also 401 (refresh returned null)
164
+ mockFetch.mockResolvedValue(mock401);
165
+ refreshCallback.mockResolvedValue(null);
166
+
167
+ await Promise.allSettled([
168
+ wrapper.get('/api/v1/a/'),
169
+ wrapper.get('/api/v1/b/'),
170
+ wrapper.get('/api/v1/c/'),
171
+ wrapper.get('/api/v1/d/'),
172
+ wrapper.get('/api/v1/e/'),
173
+ ]);
174
+
175
+ expect(unauthorizedCallback).toHaveBeenCalledTimes(1);
176
+ });
177
+
126
178
  it('should work without refresh callback (backward compatible)', async () => {
127
179
  const wrapper = new FetchWrapper({
128
180
  baseUrl: 'http://localhost:8000',
@@ -50,6 +50,7 @@ export const ENDPOINTS = {
50
50
  FUNNEL_PREVIEW: (id: string) => `api/v1/funnels/${id}/preview`,
51
51
  FUNNEL_RESULTS: (id: string) => `api/v1/funnels/${id}/results`,
52
52
  FUNNEL_TEMPLATES: 'api/v1/funnels/templates',
53
+ FUNNEL_FIELDS: 'api/v1/funnels/fields',
53
54
  FUNNEL_RUN_BY_ID: (runId: string) => `api/v1/funnel-runs/${runId}`,
54
55
  FUNNEL_RUN_CANCEL: (runId: string) => `api/v1/funnel-runs/${runId}/cancel`,
55
56
  FUNNEL_RUNS_GLOBAL: 'api/v1/funnel-runs',
package/src/index.ts CHANGED
@@ -9,6 +9,10 @@
9
9
  export { ApiClient, createApiClient } from './lib/api-client';
10
10
  export type { ApiClientConfig } from './lib/api-client';
11
11
 
12
+ // Entity adapter factory
13
+ export { createEntityAdapter } from './lib/entity-adapter';
14
+ export type { EntityAdapterConfig, EntityAdapter } from './lib/entity-adapter';
15
+
12
16
  // API wrappers
13
17
  export { ContactsApi } from './lib/contacts-api';
14
18
  export { OrganizationsApi } from './lib/organizations-api';
@@ -16,12 +20,20 @@ export { EntitiesApi } from './lib/entities-api';
16
20
  export { WorkflowsApi } from './lib/workflows-api';
17
21
  export { MessagesApi } from './lib/messages-api';
18
22
  export type { Message, MessageStatus as MessageApiStatus, MessageRecipient, MessagingChannel as MessagingChannelType, MessageFilters, CreateMessageInput, ScheduleMessageInput, SendTestInput } from './lib/messages-api';
23
+ export { calculateStatsFromMessages } from './lib/message-stats';
24
+ export type { MessageStats } from './lib/message-stats';
19
25
  export { MessageTemplatesApi } from './lib/message-templates-api';
20
26
  export type { MessageTemplate, MessageTemplateFilters, CreateMessageTemplateInput } from './lib/message-templates-api';
21
27
  export { UsersApi } from './lib/users-api';
22
28
  export type { UserProfile, UpdateProfileRequest, ChangePasswordRequest, ChangePasswordResponse } from './types/user';
23
29
  export { FunnelsApi, isFunnelRunConflict, isFunnelValidationError } from './lib/funnels-api';
24
- export type { FunnelPreviewResult, FunnelRunFilters, FunnelTemplate } from './lib/funnels-api';
30
+ export type {
31
+ FunnelPreviewResult,
32
+ FunnelRunFilters,
33
+ FunnelTemplate,
34
+ FunnelFieldDefinition,
35
+ FunnelFieldsResponse,
36
+ } from './lib/funnels-api';
25
37
  export { EnrichmentApi } from './lib/enrichment-api';
26
38
  export { TargetListsApi } from './lib/target-lists-api';
27
39
  export type {
@@ -0,0 +1,223 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createEntityAdapter } from '../entity-adapter';
3
+ import type { EntityAdapterConfig } from '../entity-adapter';
4
+
5
+ // --- Test types ---
6
+
7
+ interface DomainUser {
8
+ id: string;
9
+ fullName: string;
10
+ email: string;
11
+ }
12
+
13
+ interface ApiUser {
14
+ id: string;
15
+ first_name: string;
16
+ last_name: string;
17
+ email_address: string;
18
+ }
19
+
20
+ function makeConfig(overrides: Partial<EntityAdapterConfig<DomainUser, ApiUser>> = {}) {
21
+ return {
22
+ list: vi.fn().mockResolvedValue({ results: [], count: 0 }),
23
+ get: vi.fn().mockResolvedValue({ id: '1', first_name: 'Jane', last_name: 'Doe', email_address: 'jane@example.com' }),
24
+ create: vi.fn().mockImplementation(async (data: Partial<ApiUser>) => ({
25
+ id: '2',
26
+ first_name: data.first_name ?? '',
27
+ last_name: data.last_name ?? '',
28
+ email_address: data.email_address ?? '',
29
+ })),
30
+ update: vi.fn().mockImplementation(async (_id: string, data: Partial<ApiUser>) => ({
31
+ id: _id,
32
+ first_name: data.first_name ?? 'Jane',
33
+ last_name: data.last_name ?? 'Doe',
34
+ email_address: data.email_address ?? 'jane@example.com',
35
+ })),
36
+ delete: vi.fn().mockResolvedValue(undefined),
37
+ toEntity: (api: ApiUser): DomainUser => ({
38
+ id: api.id,
39
+ fullName: `${api.first_name} ${api.last_name}`,
40
+ email: api.email_address,
41
+ }),
42
+ toApi: (input: Record<string, unknown>): Partial<ApiUser> => {
43
+ const result: Partial<ApiUser> = {};
44
+ if (input.fullName) {
45
+ const parts = (input.fullName as string).split(' ');
46
+ result.first_name = parts[0];
47
+ result.last_name = parts.slice(1).join(' ');
48
+ }
49
+ if (input.email) {
50
+ result.email_address = input.email as string;
51
+ }
52
+ return result;
53
+ },
54
+ ...overrides,
55
+ } satisfies EntityAdapterConfig<DomainUser, ApiUser>;
56
+ }
57
+
58
+ describe('createEntityAdapter', () => {
59
+ describe('list', () => {
60
+ it('maps API results through toEntity and preserves count', async () => {
61
+ const apiUsers: ApiUser[] = [
62
+ { id: '1', first_name: 'Jane', last_name: 'Doe', email_address: 'jane@example.com' },
63
+ { id: '2', first_name: 'John', last_name: 'Smith', email_address: 'john@example.com' },
64
+ ];
65
+ const config = makeConfig({
66
+ list: vi.fn().mockResolvedValue({ results: apiUsers, count: 42 }),
67
+ });
68
+ const adapter = createEntityAdapter(config);
69
+
70
+ const result = await adapter.list({ page: 1 });
71
+
72
+ expect(config.list).toHaveBeenCalledWith({ page: 1 });
73
+ expect(result.count).toBe(42);
74
+ expect(result.results).toEqual([
75
+ { id: '1', fullName: 'Jane Doe', email: 'jane@example.com' },
76
+ { id: '2', fullName: 'John Smith', email: 'john@example.com' },
77
+ ]);
78
+ });
79
+
80
+ it('passes undefined filters when called without arguments', async () => {
81
+ const config = makeConfig();
82
+ const adapter = createEntityAdapter(config);
83
+
84
+ await adapter.list();
85
+
86
+ expect(config.list).toHaveBeenCalledWith(undefined);
87
+ });
88
+
89
+ it('returns empty results for empty API response', async () => {
90
+ const config = makeConfig({
91
+ list: vi.fn().mockResolvedValue({ results: [], count: 0 }),
92
+ });
93
+ const adapter = createEntityAdapter(config);
94
+
95
+ const result = await adapter.list();
96
+
97
+ expect(result).toEqual({ results: [], count: 0 });
98
+ });
99
+ });
100
+
101
+ describe('get', () => {
102
+ it('maps a single API resource through toEntity', async () => {
103
+ const config = makeConfig();
104
+ const adapter = createEntityAdapter(config);
105
+
106
+ const result = await adapter.get('1');
107
+
108
+ expect(config.get).toHaveBeenCalledWith('1');
109
+ expect(result).toEqual({
110
+ id: '1',
111
+ fullName: 'Jane Doe',
112
+ email: 'jane@example.com',
113
+ });
114
+ });
115
+ });
116
+
117
+ describe('create', () => {
118
+ it('transforms input via toApi, calls config.create, maps result via toEntity', async () => {
119
+ const config = makeConfig();
120
+ const adapter = createEntityAdapter(config);
121
+
122
+ const result = await adapter.create({ fullName: 'Alice Wonder', email: 'alice@example.com' });
123
+
124
+ expect(config.create).toHaveBeenCalledWith({
125
+ first_name: 'Alice',
126
+ last_name: 'Wonder',
127
+ email_address: 'alice@example.com',
128
+ });
129
+ expect(result).toEqual({
130
+ id: '2',
131
+ fullName: 'Alice Wonder',
132
+ email: 'alice@example.com',
133
+ });
134
+ });
135
+ });
136
+
137
+ describe('update', () => {
138
+ it('transforms input via toApi, calls config.update with id, maps result via toEntity', async () => {
139
+ const config = makeConfig();
140
+ const adapter = createEntityAdapter(config);
141
+
142
+ const result = await adapter.update('1', { fullName: 'Jane Updated', email: 'new@example.com' });
143
+
144
+ expect(config.update).toHaveBeenCalledWith('1', {
145
+ first_name: 'Jane',
146
+ last_name: 'Updated',
147
+ email_address: 'new@example.com',
148
+ });
149
+ expect(result).toEqual({
150
+ id: '1',
151
+ fullName: 'Jane Updated',
152
+ email: 'new@example.com',
153
+ });
154
+ });
155
+ });
156
+
157
+ describe('delete', () => {
158
+ it('passes through to config.delete', async () => {
159
+ const config = makeConfig();
160
+ const adapter = createEntityAdapter(config);
161
+
162
+ await adapter.delete('1');
163
+
164
+ expect(config.delete).toHaveBeenCalledWith('1');
165
+ });
166
+
167
+ it('returns void', async () => {
168
+ const config = makeConfig();
169
+ const adapter = createEntityAdapter(config);
170
+
171
+ const result = await adapter.delete('1');
172
+
173
+ expect(result).toBeUndefined();
174
+ });
175
+ });
176
+
177
+ describe('error propagation', () => {
178
+ it('propagates list errors', async () => {
179
+ const config = makeConfig({
180
+ list: vi.fn().mockRejectedValue(new Error('Network error')),
181
+ });
182
+ const adapter = createEntityAdapter(config);
183
+
184
+ await expect(adapter.list()).rejects.toThrow('Network error');
185
+ });
186
+
187
+ it('propagates get errors', async () => {
188
+ const config = makeConfig({
189
+ get: vi.fn().mockRejectedValue(new Error('Not found')),
190
+ });
191
+ const adapter = createEntityAdapter(config);
192
+
193
+ await expect(adapter.get('999')).rejects.toThrow('Not found');
194
+ });
195
+
196
+ it('propagates create errors', async () => {
197
+ const config = makeConfig({
198
+ create: vi.fn().mockRejectedValue(new Error('Validation failed')),
199
+ });
200
+ const adapter = createEntityAdapter(config);
201
+
202
+ await expect(adapter.create({ fullName: 'X' })).rejects.toThrow('Validation failed');
203
+ });
204
+
205
+ it('propagates update errors', async () => {
206
+ const config = makeConfig({
207
+ update: vi.fn().mockRejectedValue(new Error('Conflict')),
208
+ });
209
+ const adapter = createEntityAdapter(config);
210
+
211
+ await expect(adapter.update('1', { fullName: 'X' })).rejects.toThrow('Conflict');
212
+ });
213
+
214
+ it('propagates delete errors', async () => {
215
+ const config = makeConfig({
216
+ delete: vi.fn().mockRejectedValue(new Error('Forbidden')),
217
+ });
218
+ const adapter = createEntityAdapter(config);
219
+
220
+ await expect(adapter.delete('1')).rejects.toThrow('Forbidden');
221
+ });
222
+ });
223
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Entity Adapter — thin mapping layer between domain entities and API resources.
3
+ *
4
+ * Creates a typed CRUD client that translates between a domain entity shape
5
+ * (used by the UI) and the raw API resource shape (returned by Django).
6
+ */
7
+
8
+ export interface EntityAdapterConfig<TEntity, TApi> {
9
+ /** List API resources with optional filters. Returns paginated response. */
10
+ list: (filters?: Record<string, unknown>) => Promise<{ results: TApi[]; count: number }>;
11
+ /** Get a single API resource by ID. */
12
+ get: (id: string) => Promise<TApi>;
13
+ /** Create an API resource. */
14
+ create: (data: Partial<TApi>) => Promise<TApi>;
15
+ /** Update an API resource by ID. */
16
+ update: (id: string, data: Partial<TApi>) => Promise<TApi>;
17
+ /** Delete an API resource by ID. */
18
+ delete: (id: string) => Promise<void>;
19
+ /** Transform an API resource into the domain entity. */
20
+ toEntity: (api: TApi) => TEntity;
21
+ /** Transform domain input into the API shape for create/update. */
22
+ toApi: (input: Record<string, unknown>) => Partial<TApi>;
23
+ }
24
+
25
+ export interface EntityAdapter<TEntity> {
26
+ list: (filters?: Record<string, unknown>) => Promise<{ results: TEntity[]; count: number }>;
27
+ get: (id: string) => Promise<TEntity>;
28
+ create: (input: Record<string, unknown>) => Promise<TEntity>;
29
+ update: (id: string, input: Record<string, unknown>) => Promise<TEntity>;
30
+ delete: (id: string) => Promise<void>;
31
+ }
32
+
33
+ export function createEntityAdapter<TEntity, TApi>(
34
+ config: EntityAdapterConfig<TEntity, TApi>,
35
+ ): EntityAdapter<TEntity> {
36
+ return {
37
+ async list(filters?: Record<string, unknown>) {
38
+ const response = await config.list(filters);
39
+ return {
40
+ results: response.results.map(config.toEntity),
41
+ count: response.count,
42
+ };
43
+ },
44
+
45
+ async get(id: string) {
46
+ const raw = await config.get(id);
47
+ return config.toEntity(raw);
48
+ },
49
+
50
+ async create(input: Record<string, unknown>) {
51
+ const apiData = config.toApi(input);
52
+ const raw = await config.create(apiData);
53
+ return config.toEntity(raw);
54
+ },
55
+
56
+ async update(id: string, input: Record<string, unknown>) {
57
+ const apiData = config.toApi(input);
58
+ const raw = await config.update(id, apiData);
59
+ return config.toEntity(raw);
60
+ },
61
+
62
+ async delete(id: string) {
63
+ await config.delete(id);
64
+ },
65
+ };
66
+ }
@@ -25,11 +25,20 @@ export class FetchWrapper {
25
25
  private config: FetchWrapperConfig;
26
26
  private isRefreshing = false;
27
27
  private refreshPromise: Promise<string | null> | null = null;
28
+ private unauthorizedFiredAt = 0;
28
29
 
29
30
  constructor(config: FetchWrapperConfig) {
30
31
  this.config = config;
31
32
  }
32
33
 
34
+ private fireUnauthorizedOnce(): void {
35
+ if (!this.config.onUnauthorized) return;
36
+ const now = Date.now();
37
+ if (now - this.unauthorizedFiredAt < 5000) return;
38
+ this.unauthorizedFiredAt = now;
39
+ this.config.onUnauthorized();
40
+ }
41
+
33
42
  /**
34
43
  * Build headers with auth token
35
44
  */
@@ -104,26 +113,42 @@ export class FetchWrapper {
104
113
  });
105
114
  }
106
115
 
107
- const newToken = await this.refreshPromise;
108
-
109
- if (newToken) {
110
- // Retry original request with new token
111
- const retryHeaders = new Headers(headers);
112
- retryHeaders.set('Authorization', `Bearer ${newToken}`);
116
+ // A throw from onTokenRefresh means transient (5xx/network) — surface
117
+ // the original 401 as a normal API error rather than treating it as
118
+ // session-dead. Callers (components doing a fetch) will see an
119
+ // ApiException they can retry; the user stays logged in.
120
+ let newToken: string | null = null;
121
+ let refreshThrew = false;
122
+ try {
123
+ newToken = await this.refreshPromise;
124
+ } catch {
125
+ refreshThrew = true;
126
+ }
113
127
 
114
- response = await fetch(url, {
115
- method,
116
- headers: retryHeaders,
117
- credentials: 'include',
118
- ...fetchOptions,
119
- });
120
- } else if (this.config.onUnauthorized) {
121
- // Refresh failed - call unauthorized callback
122
- this.config.onUnauthorized();
128
+ if (!refreshThrew) {
129
+ if (newToken) {
130
+ // Retry original request with new token
131
+ const retryHeaders = new Headers(headers);
132
+ retryHeaders.set('Authorization', `Bearer ${newToken}`);
133
+
134
+ response = await fetch(url, {
135
+ method,
136
+ headers: retryHeaders,
137
+ credentials: 'include',
138
+ ...fetchOptions,
139
+ });
140
+
141
+ if (response.status === 401) {
142
+ this.fireUnauthorizedOnce();
143
+ }
144
+ } else {
145
+ // newToken=null is the "session is dead" signal from the refresh
146
+ // implementation. Only this path should log the user out.
147
+ this.fireUnauthorizedOnce();
148
+ }
123
149
  }
124
- } else if (this.config.onUnauthorized) {
125
- // No refresh callback - call unauthorized directly
126
- this.config.onUnauthorized();
150
+ } else {
151
+ this.fireUnauthorizedOnce();
127
152
  }
128
153
  }
129
154
 
@@ -65,6 +65,21 @@ export interface FunnelTemplate {
65
65
  usageCount: number;
66
66
  }
67
67
 
68
+ /** One entry in the field registry served by GET /api/v1/funnels/fields/ */
69
+ export interface FunnelFieldDefinition {
70
+ key: string;
71
+ label: string;
72
+ category: string;
73
+ valueType: 'string' | 'number' | 'boolean' | 'enum' | 'date';
74
+ enumValues: string[];
75
+ allowedOperators: string[];
76
+ }
77
+
78
+ export interface FunnelFieldsResponse {
79
+ entityType: string | null;
80
+ fields: FunnelFieldDefinition[];
81
+ }
82
+
68
83
  export class FunnelsApi {
69
84
  constructor(private client: ApiClient) {}
70
85
 
@@ -258,4 +273,17 @@ export class FunnelsApi {
258
273
  : ENDPOINTS.FUNNEL_TEMPLATES;
259
274
  return this.client.get<PaginatedResponse<FunnelTemplate>>(endpoint);
260
275
  }
276
+
277
+ /**
278
+ * Fetch the funnel rule field registry. Drives the generic rule editor UI.
279
+ */
280
+ async getFields(entityType?: string): Promise<FunnelFieldsResponse> {
281
+ // Trailing slash before the query string — Django's APPEND_SLASH does not
282
+ // redirect when a query string is present, so the URL must already end
283
+ // with a slash before '?entity_type='.
284
+ const endpoint = entityType
285
+ ? `${ENDPOINTS.FUNNEL_FIELDS}/?entity_type=${encodeURIComponent(entityType)}`
286
+ : `${ENDPOINTS.FUNNEL_FIELDS}/`;
287
+ return this.client.get<FunnelFieldsResponse>(endpoint);
288
+ }
261
289
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Message statistics utilities
3
+ *
4
+ * Calculates aggregate stats from an array of Message objects.
5
+ */
6
+
7
+ import type { Message } from './messages-api';
8
+
9
+ export interface MessageStats {
10
+ sent: number;
11
+ opened: number;
12
+ clicked: number;
13
+ replied: number;
14
+ bounced: number;
15
+ unsubscribed: number;
16
+ openRate: number;
17
+ clickRate: number;
18
+ replyRate: number;
19
+ bounceRate: number;
20
+ }
21
+
22
+ /**
23
+ * Calculate aggregate stats from an array of messages.
24
+ */
25
+ export function calculateStatsFromMessages(messages: Message[]): MessageStats {
26
+ const sent = messages.length;
27
+ const opened = messages.filter((m) => (m.recipientsOpened ?? 0) > 0).length;
28
+ const clicked = messages.filter((m) => (m.recipientsClicked ?? 0) > 0).length;
29
+ const replied = messages.filter((m) => (m.recipientsReplied ?? 0) > 0).length;
30
+ const bounced = messages.filter((m) => (m.recipientsBounced ?? 0) > 0).length;
31
+ // recipientsUnsubscribed is not included in list endpoint responses; fall back to 0
32
+ const unsubscribed = messages.filter((m) => (m.recipientsUnsubscribed ?? 0) > 0).length;
33
+
34
+ return {
35
+ sent,
36
+ opened,
37
+ clicked,
38
+ replied,
39
+ bounced,
40
+ unsubscribed,
41
+ openRate: sent > 0 ? (opened / sent) * 100 : 0,
42
+ clickRate: sent > 0 ? (clicked / sent) * 100 : 0,
43
+ replyRate: sent > 0 ? (replied / sent) * 100 : 0,
44
+ bounceRate: sent > 0 ? (bounced / sent) * 100 : 0,
45
+ };
46
+ }
@@ -35,10 +35,12 @@ export interface Message {
35
35
  recipientsDelivered: number;
36
36
  recipientsOpened: number;
37
37
  recipientsClicked: number;
38
+ recipientsReplied: number;
38
39
  recipientsBounced: number;
39
40
  recipientsUnsubscribed: number;
40
41
  openRate: number;
41
42
  clickRate: number;
43
+ replyRate: number;
42
44
  bounceRate: number;
43
45
  errorMessage: string | null;
44
46
  createdAt: string;
@@ -55,7 +55,7 @@ export const StandardErrorResponseSchema = z.object({
55
55
  code: z.string(),
56
56
  statusCode: z.number().int().min(400).max(599),
57
57
  fieldErrors: z.array(FieldErrorSchema).optional(),
58
- details: z.record(z.unknown()).optional(),
58
+ details: z.record(z.string(), z.unknown()).optional(),
59
59
  requestId: z.string().optional(),
60
60
  timestamp: z.string().datetime().optional(),
61
61
  });