@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 +12 -13
- package/src/__tests__/funnels-api.test.ts +16 -15
- package/src/__tests__/jwt-refresh.test.ts +52 -0
- package/src/constants/endpoints.ts +1 -0
- package/src/index.ts +13 -1
- package/src/lib/__tests__/entity-adapter.test.ts +223 -0
- package/src/lib/entity-adapter.ts +66 -0
- package/src/lib/fetch-wrapper.ts +43 -18
- package/src/lib/funnels-api.ts +28 -0
- package/src/lib/message-stats.ts +46 -0
- package/src/lib/messages-api.ts +2 -0
- package/src/types/error.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/api",
|
|
3
|
-
"version": "0.5.
|
|
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
|
-
"
|
|
27
|
-
"
|
|
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.
|
|
48
|
-
"@types/node": "^20.
|
|
49
|
-
"@types/react": "^
|
|
50
|
-
"next": "^15.5.
|
|
51
|
-
"react": "^
|
|
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": "^
|
|
54
|
-
"vitest": "^1.
|
|
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('
|
|
175
|
-
|
|
176
|
-
await
|
|
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 {
|
|
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
|
+
}
|
package/src/lib/fetch-wrapper.ts
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
this.config.onUnauthorized();
|
|
150
|
+
} else {
|
|
151
|
+
this.fireUnauthorizedOnce();
|
|
127
152
|
}
|
|
128
153
|
}
|
|
129
154
|
|
package/src/lib/funnels-api.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/messages-api.ts
CHANGED
|
@@ -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;
|
package/src/types/error.ts
CHANGED
|
@@ -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
|
});
|