@startsimpli/api 0.5.6 → 0.5.8
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 +1 -1
- package/src/__tests__/jwt-refresh.test.ts +25 -0
- package/src/constants/endpoints.ts +9 -0
- package/src/index.ts +38 -2
- package/src/lib/__tests__/entity-adapter.test.ts +223 -0
- package/src/lib/enrichment-api.ts +57 -0
- package/src/lib/entity-adapter.ts +66 -0
- package/src/lib/fetch-wrapper.ts +17 -6
- package/src/lib/funnels-api.ts +82 -14
- package/src/lib/message-stats.ts +46 -0
- package/src/lib/message-templates-api.ts +95 -0
- package/src/lib/messages-api.ts +2 -0
- package/src/lib/target-lists-api.ts +190 -0
- package/src/types/enrichment.ts +28 -0
- package/src/types/index.ts +8 -0
- package/src/types/user.ts +13 -12
package/package.json
CHANGED
|
@@ -123,6 +123,31 @@ describe('JWT Refresh Flow', () => {
|
|
|
123
123
|
expect(refreshCallback).toHaveBeenCalledTimes(1);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
it('should fire onUnauthorized at most once when many parallel 401s fail refresh', async () => {
|
|
127
|
+
const wrapper = new FetchWrapper({
|
|
128
|
+
baseUrl: 'http://localhost:8000',
|
|
129
|
+
getToken: () => 'old-token',
|
|
130
|
+
onTokenRefresh: refreshCallback,
|
|
131
|
+
onUnauthorized: unauthorizedCallback,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const mock401 = { status: 401, ok: false, headers: new Headers(), text: async () => '' };
|
|
135
|
+
|
|
136
|
+
// All five requests hit 401 and the retry is also 401 (refresh returned null)
|
|
137
|
+
mockFetch.mockResolvedValue(mock401);
|
|
138
|
+
refreshCallback.mockResolvedValue(null);
|
|
139
|
+
|
|
140
|
+
await Promise.allSettled([
|
|
141
|
+
wrapper.get('/api/v1/a/'),
|
|
142
|
+
wrapper.get('/api/v1/b/'),
|
|
143
|
+
wrapper.get('/api/v1/c/'),
|
|
144
|
+
wrapper.get('/api/v1/d/'),
|
|
145
|
+
wrapper.get('/api/v1/e/'),
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
expect(unauthorizedCallback).toHaveBeenCalledTimes(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
126
151
|
it('should work without refresh callback (backward compatible)', async () => {
|
|
127
152
|
const wrapper = new FetchWrapper({
|
|
128
153
|
baseUrl: 'http://localhost:8000',
|
|
@@ -40,15 +40,24 @@ export const ENDPOINTS = {
|
|
|
40
40
|
// Funnels
|
|
41
41
|
FUNNELS: 'api/v1/funnels',
|
|
42
42
|
FUNNEL: (id: string) => `api/v1/funnels/${id}`,
|
|
43
|
+
FUNNEL_PIPELINE: (id: string) => `api/v1/funnels/${id}/pipeline`,
|
|
44
|
+
FUNNEL_ADD_ENTITY: (id: string) => `api/v1/funnels/${id}/add_entity`,
|
|
45
|
+
FUNNEL_MOVE: (id: string) => `api/v1/funnels/${id}/move`,
|
|
46
|
+
FUNNEL_STATS: (id: string) => `api/v1/funnels/${id}/stats`,
|
|
43
47
|
FUNNEL_RUN: (id: string) => `api/v1/funnels/${id}/run`,
|
|
44
48
|
FUNNEL_RUNS: (id: string) => `api/v1/funnels/${id}/runs`,
|
|
45
49
|
FUNNEL_RUN_ITEM: (funnelId: string, runId: string) => `api/v1/funnels/${funnelId}/runs/${runId}`,
|
|
46
50
|
FUNNEL_PREVIEW: (id: string) => `api/v1/funnels/${id}/preview`,
|
|
47
51
|
FUNNEL_RESULTS: (id: string) => `api/v1/funnels/${id}/results`,
|
|
48
52
|
FUNNEL_TEMPLATES: 'api/v1/funnels/templates',
|
|
53
|
+
FUNNEL_FIELDS: 'api/v1/funnels/fields',
|
|
49
54
|
FUNNEL_RUN_BY_ID: (runId: string) => `api/v1/funnel-runs/${runId}`,
|
|
50
55
|
FUNNEL_RUN_CANCEL: (runId: string) => `api/v1/funnel-runs/${runId}/cancel`,
|
|
51
56
|
FUNNEL_RUNS_GLOBAL: 'api/v1/funnel-runs',
|
|
57
|
+
// Enrichment
|
|
58
|
+
ENRICHMENT_QUEUE: 'api/v1/enrichment/start-processing',
|
|
59
|
+
CONTACT_ENRICH: (id: string) => `api/v1/contacts/${id}/enrich`,
|
|
60
|
+
|
|
52
61
|
// Users
|
|
53
62
|
USER_ME: 'api/v1/users/me',
|
|
54
63
|
USER_CHANGE_PASSWORD: 'api/v1/users/me/change-password',
|
package/src/index.ts
CHANGED
|
@@ -9,17 +9,47 @@
|
|
|
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';
|
|
15
19
|
export { EntitiesApi } from './lib/entities-api';
|
|
16
20
|
export { WorkflowsApi } from './lib/workflows-api';
|
|
17
21
|
export { MessagesApi } from './lib/messages-api';
|
|
18
|
-
export type { Message, MessageStatus as MessageApiStatus, MessageRecipient, MessagingChannel as MessagingChannelType } from './lib/messages-api';
|
|
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';
|
|
25
|
+
export { MessageTemplatesApi } from './lib/message-templates-api';
|
|
26
|
+
export type { MessageTemplate, MessageTemplateFilters, CreateMessageTemplateInput } from './lib/message-templates-api';
|
|
19
27
|
export { UsersApi } from './lib/users-api';
|
|
20
28
|
export type { UserProfile, UpdateProfileRequest, ChangePasswordRequest, ChangePasswordResponse } from './types/user';
|
|
21
29
|
export { FunnelsApi, isFunnelRunConflict, isFunnelValidationError } from './lib/funnels-api';
|
|
22
|
-
export type {
|
|
30
|
+
export type {
|
|
31
|
+
FunnelPreviewResult,
|
|
32
|
+
FunnelRunFilters,
|
|
33
|
+
FunnelTemplate,
|
|
34
|
+
FunnelFieldDefinition,
|
|
35
|
+
FunnelFieldsResponse,
|
|
36
|
+
} from './lib/funnels-api';
|
|
37
|
+
export { EnrichmentApi } from './lib/enrichment-api';
|
|
38
|
+
export { TargetListsApi } from './lib/target-lists-api';
|
|
39
|
+
export type {
|
|
40
|
+
TargetList,
|
|
41
|
+
TargetListMember,
|
|
42
|
+
CreateTargetListInput,
|
|
43
|
+
UpdateTargetListInput,
|
|
44
|
+
TargetListFilters,
|
|
45
|
+
TargetListMemberFilters,
|
|
46
|
+
TargetType,
|
|
47
|
+
ListSourceType,
|
|
48
|
+
RefreshStrategy,
|
|
49
|
+
AddMembersResult,
|
|
50
|
+
RemoveMembersResult,
|
|
51
|
+
RefreshResult,
|
|
52
|
+
} from './lib/target-lists-api';
|
|
23
53
|
|
|
24
54
|
// Feature flags
|
|
25
55
|
export { FeatureFlagsApi, FeatureFlagProvider, useFeatureFlags } from './lib/feature-flags';
|
|
@@ -129,6 +159,9 @@ import { MessagesApi } from './lib/messages-api';
|
|
|
129
159
|
import { FunnelsApi } from './lib/funnels-api';
|
|
130
160
|
import { FeatureFlagsApi } from './lib/feature-flags';
|
|
131
161
|
import { UsersApi } from './lib/users-api';
|
|
162
|
+
import { EnrichmentApi } from './lib/enrichment-api';
|
|
163
|
+
import { TargetListsApi } from './lib/target-lists-api';
|
|
164
|
+
import { MessageTemplatesApi } from './lib/message-templates-api';
|
|
132
165
|
|
|
133
166
|
import type { ApiClientConfig } from './lib/api-client';
|
|
134
167
|
|
|
@@ -149,5 +182,8 @@ export function createStartSimpliApi(config: ApiClientConfig = {}) {
|
|
|
149
182
|
funnels: new FunnelsApi(client),
|
|
150
183
|
featureFlags: new FeatureFlagsApi(client),
|
|
151
184
|
users: new UsersApi(client),
|
|
185
|
+
enrichment: new EnrichmentApi(client),
|
|
186
|
+
targetLists: new TargetListsApi(client),
|
|
187
|
+
messageTemplates: new MessageTemplatesApi(client),
|
|
152
188
|
};
|
|
153
189
|
}
|
|
@@ -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,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enrichment API wrapper for /api/v1/enrichment/ and /api/v1/contacts/:id/enrich/
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { EnrichmentResult, QueueStatus } from '../types';
|
|
6
|
+
import { ENDPOINTS } from '../constants/endpoints';
|
|
7
|
+
import type { ApiClient } from './api-client';
|
|
8
|
+
|
|
9
|
+
export class EnrichmentApi {
|
|
10
|
+
constructor(private client: ApiClient) {}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Enrich a single contact by ID
|
|
14
|
+
*/
|
|
15
|
+
async enrich(contactId: string): Promise<EnrichmentResult> {
|
|
16
|
+
return this.client.fetch.post<EnrichmentResult>(
|
|
17
|
+
ENDPOINTS.CONTACT_ENRICH(contactId),
|
|
18
|
+
{}
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Enrich multiple contacts. Calls the single-contact endpoint for each ID.
|
|
24
|
+
* Returns results in order, including failures.
|
|
25
|
+
*/
|
|
26
|
+
async bulkEnrich(
|
|
27
|
+
contactIds: string[],
|
|
28
|
+
onProgress?: (completed: number, total: number) => void
|
|
29
|
+
): Promise<EnrichmentResult[]> {
|
|
30
|
+
const results: EnrichmentResult[] = [];
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < contactIds.length; i++) {
|
|
33
|
+
try {
|
|
34
|
+
const result = await this.enrich(contactIds[i]);
|
|
35
|
+
results.push(result);
|
|
36
|
+
} catch (err: unknown) {
|
|
37
|
+
results.push({
|
|
38
|
+
contactId: contactIds[i],
|
|
39
|
+
contactName: '',
|
|
40
|
+
success: false,
|
|
41
|
+
fieldsUpdated: [],
|
|
42
|
+
error: err instanceof Error ? err.message : 'Enrichment failed',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
onProgress?.(i + 1, contactIds.length);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the current enrichment queue status
|
|
53
|
+
*/
|
|
54
|
+
async getQueueStatus(): Promise<QueueStatus> {
|
|
55
|
+
return this.client.fetch.get<QueueStatus>(ENDPOINTS.ENRICHMENT_QUEUE);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -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
|
*/
|
|
@@ -117,13 +126,15 @@ export class FetchWrapper {
|
|
|
117
126
|
credentials: 'include',
|
|
118
127
|
...fetchOptions,
|
|
119
128
|
});
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
129
|
+
|
|
130
|
+
if (response.status === 401) {
|
|
131
|
+
this.fireUnauthorizedOnce();
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
this.fireUnauthorizedOnce();
|
|
123
135
|
}
|
|
124
|
-
} else
|
|
125
|
-
|
|
126
|
-
this.config.onUnauthorized();
|
|
136
|
+
} else {
|
|
137
|
+
this.fireUnauthorizedOnce();
|
|
127
138
|
}
|
|
128
139
|
}
|
|
129
140
|
|
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
|
|
|
@@ -123,6 +138,44 @@ export class FunnelsApi {
|
|
|
123
138
|
return this.client.delete<void>(ENDPOINTS.FUNNEL(id));
|
|
124
139
|
}
|
|
125
140
|
|
|
141
|
+
// ── Generic pipeline actions (work with any funnel/profile) ──────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get entities grouped by stage (kanban view).
|
|
145
|
+
* Works for any funnel that has a membership run with entities in stages.
|
|
146
|
+
*/
|
|
147
|
+
async pipeline(id: string): Promise<Record<string, FunnelResult[]>> {
|
|
148
|
+
return this.client.get<Record<string, FunnelResult[]>>(ENDPOINTS.FUNNEL_PIPELINE(id));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Add an entity to a funnel's membership run.
|
|
153
|
+
* Creates a FunnelResult with the entity + optional context (deal amount, notes, etc.)
|
|
154
|
+
*/
|
|
155
|
+
async addEntity(
|
|
156
|
+
funnelId: string,
|
|
157
|
+
data: { entityId: string; stage?: string; context?: Record<string, unknown> }
|
|
158
|
+
): Promise<FunnelResult> {
|
|
159
|
+
return this.client.post<FunnelResult>(ENDPOINTS.FUNNEL_ADD_ENTITY(funnelId), data);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Move an entity to a different stage within a funnel.
|
|
164
|
+
*/
|
|
165
|
+
async move(
|
|
166
|
+
funnelId: string,
|
|
167
|
+
data: { entityId: string; newStage: string; context?: Record<string, unknown> }
|
|
168
|
+
): Promise<FunnelResult> {
|
|
169
|
+
return this.client.post<FunnelResult>(ENDPOINTS.FUNNEL_MOVE(funnelId), data);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get funnel statistics (stage counts, conversion rates, etc.)
|
|
174
|
+
*/
|
|
175
|
+
async stats(funnelId: string): Promise<Record<string, unknown>> {
|
|
176
|
+
return this.client.get<Record<string, unknown>>(ENDPOINTS.FUNNEL_STATS(funnelId));
|
|
177
|
+
}
|
|
178
|
+
|
|
126
179
|
/**
|
|
127
180
|
* Execute a funnel run
|
|
128
181
|
*/
|
|
@@ -157,24 +210,26 @@ export class FunnelsApi {
|
|
|
157
210
|
return this.client.get<FunnelRun>(ENDPOINTS.FUNNEL_RUN_BY_ID(runId));
|
|
158
211
|
}
|
|
159
212
|
|
|
160
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Get results (entities) from a funnel's latest completed run.
|
|
215
|
+
*/
|
|
161
216
|
async getResults(
|
|
162
|
-
|
|
163
|
-
|
|
217
|
+
funnelId: string,
|
|
218
|
+
pagination?: PaginationParams
|
|
164
219
|
): Promise<PaginatedResponse<FunnelResult>> {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// return this.client.get<PaginatedResponse<FunnelResult>>(endpoint);
|
|
220
|
+
const params = new URLSearchParams();
|
|
221
|
+
if (pagination?.page) params.append('page', String(pagination.page));
|
|
222
|
+
if (pagination?.pageSize) params.append('page_size', String(pagination.pageSize));
|
|
223
|
+
const query = params.toString();
|
|
224
|
+
const endpoint = query ? `${ENDPOINTS.FUNNEL_RESULTS(funnelId)}?${query}` : ENDPOINTS.FUNNEL_RESULTS(funnelId);
|
|
225
|
+
return this.client.get<PaginatedResponse<FunnelResult>>(endpoint);
|
|
172
226
|
}
|
|
173
227
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Preview funnel execution without persisting results.
|
|
230
|
+
*/
|
|
231
|
+
async preview(funnelId: string, stages?: Funnel['stages']): Promise<FunnelPreviewResult> {
|
|
232
|
+
return this.client.post<FunnelPreviewResult>(ENDPOINTS.FUNNEL_PREVIEW(funnelId), { stages });
|
|
178
233
|
}
|
|
179
234
|
|
|
180
235
|
/**
|
|
@@ -218,4 +273,17 @@ export class FunnelsApi {
|
|
|
218
273
|
: ENDPOINTS.FUNNEL_TEMPLATES;
|
|
219
274
|
return this.client.get<PaginatedResponse<FunnelTemplate>>(endpoint);
|
|
220
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
|
+
}
|
|
221
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
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Templates API wrapper
|
|
3
|
+
*
|
|
4
|
+
* Provides type-safe access to Django Message Templates API
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ApiClient } from './api-client';
|
|
8
|
+
|
|
9
|
+
export interface MessageTemplate {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description: string | null;
|
|
13
|
+
subjectTemplate: string;
|
|
14
|
+
bodyTemplate: string;
|
|
15
|
+
channel: string | null;
|
|
16
|
+
entityType: string | null;
|
|
17
|
+
category: string | null;
|
|
18
|
+
isDefault: boolean;
|
|
19
|
+
useCount: number;
|
|
20
|
+
blocks: string | null;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MessageTemplateFilters {
|
|
26
|
+
channel?: string;
|
|
27
|
+
entityType?: string;
|
|
28
|
+
category?: string;
|
|
29
|
+
search?: string;
|
|
30
|
+
page?: number;
|
|
31
|
+
pageSize?: number;
|
|
32
|
+
ordering?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CreateMessageTemplateInput {
|
|
36
|
+
name: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
subjectTemplate: string;
|
|
39
|
+
bodyTemplate: string;
|
|
40
|
+
channel?: string;
|
|
41
|
+
entityType?: string;
|
|
42
|
+
category?: string;
|
|
43
|
+
blocks?: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class MessageTemplatesApi {
|
|
47
|
+
constructor(private client: ApiClient) {}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* List message templates with optional filters
|
|
51
|
+
*/
|
|
52
|
+
async list(filters?: MessageTemplateFilters) {
|
|
53
|
+
const params = new URLSearchParams();
|
|
54
|
+
|
|
55
|
+
if (filters?.channel) params.append('channel', filters.channel);
|
|
56
|
+
if (filters?.entityType) params.append('entityType', filters.entityType);
|
|
57
|
+
if (filters?.category) params.append('category', filters.category);
|
|
58
|
+
if (filters?.search) params.append('search', filters.search);
|
|
59
|
+
if (filters?.page) params.append('page', String(filters.page));
|
|
60
|
+
if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
|
|
61
|
+
if (filters?.ordering) params.append('ordering', filters.ordering);
|
|
62
|
+
|
|
63
|
+
return this.client.get<{ results: MessageTemplate[]; count: number; next: string | null; previous: string | null }>(
|
|
64
|
+
`/api/v1/message-templates/?${params.toString()}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get message template by ID
|
|
70
|
+
*/
|
|
71
|
+
async get(id: string) {
|
|
72
|
+
return this.client.get<MessageTemplate>(`/api/v1/message-templates/${id}/`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a new message template
|
|
77
|
+
*/
|
|
78
|
+
async create(data: CreateMessageTemplateInput) {
|
|
79
|
+
return this.client.post<MessageTemplate>('/api/v1/message-templates/', data);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Update message template
|
|
84
|
+
*/
|
|
85
|
+
async update(id: string, data: Partial<CreateMessageTemplateInput>) {
|
|
86
|
+
return this.client.patch<MessageTemplate>(`/api/v1/message-templates/${id}/`, data);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Delete message template
|
|
91
|
+
*/
|
|
92
|
+
async delete(id: string) {
|
|
93
|
+
return this.client.delete(`/api/v1/message-templates/${id}/`);
|
|
94
|
+
}
|
|
95
|
+
}
|
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;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target Lists API wrapper for /api/v1/targets/lists/
|
|
3
|
+
*
|
|
4
|
+
* Shared across all StartSimpli apps for managing contact/organization lists.
|
|
5
|
+
* All types use camelCase — FetchWrapper auto-converts snake_case↔camelCase.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ApiClient } from './api-client';
|
|
9
|
+
import type { PaginatedResponse } from '../types';
|
|
10
|
+
|
|
11
|
+
// ── Types (camelCase — auto-converted from Django snake_case by FetchWrapper) ──
|
|
12
|
+
|
|
13
|
+
export type TargetType = 'contact' | 'organization';
|
|
14
|
+
export type ListSourceType = 'static' | 'funnel' | 'query';
|
|
15
|
+
export type RefreshStrategy = 'manual' | 'on_funnel_run' | 'scheduled';
|
|
16
|
+
|
|
17
|
+
export interface TargetList {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
targetType: TargetType;
|
|
22
|
+
sourceType: ListSourceType;
|
|
23
|
+
memberCount: number;
|
|
24
|
+
refreshStrategy: RefreshStrategy;
|
|
25
|
+
lastRefreshedAt: string | null;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TargetListMember {
|
|
31
|
+
id: string;
|
|
32
|
+
objectId: string;
|
|
33
|
+
contentType: string;
|
|
34
|
+
addedAt: string;
|
|
35
|
+
metadata: Record<string, unknown>;
|
|
36
|
+
name?: string;
|
|
37
|
+
email?: string;
|
|
38
|
+
firm?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CreateTargetListInput {
|
|
42
|
+
name: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
targetType?: TargetType;
|
|
45
|
+
sourceType?: ListSourceType;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UpdateTargetListInput {
|
|
49
|
+
name?: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TargetListFilters {
|
|
54
|
+
targetType?: TargetType;
|
|
55
|
+
search?: string;
|
|
56
|
+
page?: number;
|
|
57
|
+
pageSize?: number;
|
|
58
|
+
ordering?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TargetListMemberFilters {
|
|
62
|
+
search?: string;
|
|
63
|
+
page?: number;
|
|
64
|
+
pageSize?: number;
|
|
65
|
+
ordering?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AddMembersResult {
|
|
69
|
+
added: number;
|
|
70
|
+
alreadyExists: number;
|
|
71
|
+
total: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RemoveMembersResult {
|
|
75
|
+
removed: number;
|
|
76
|
+
total: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RefreshResult {
|
|
80
|
+
added: number;
|
|
81
|
+
removed: number;
|
|
82
|
+
total: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Endpoints ────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const LISTS_BASE = 'api/v1/targets/lists';
|
|
88
|
+
const LIST = (id: string) => `${LISTS_BASE}/${id}`;
|
|
89
|
+
const LIST_MEMBERS = (id: string) => `${LISTS_BASE}/${id}/members`;
|
|
90
|
+
const LIST_ADD_MEMBERS = (id: string) => `${LISTS_BASE}/${id}/add_members`;
|
|
91
|
+
const LIST_REMOVE_MEMBERS = (id: string) => `${LISTS_BASE}/${id}/remove_members`;
|
|
92
|
+
const LIST_REFRESH = (id: string) => `${LISTS_BASE}/${id}/refresh`;
|
|
93
|
+
|
|
94
|
+
// ── API Class ────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export class TargetListsApi {
|
|
97
|
+
constructor(private client: ApiClient) {}
|
|
98
|
+
|
|
99
|
+
/** List all target lists with pagination and filters */
|
|
100
|
+
async list(filters?: TargetListFilters): Promise<PaginatedResponse<TargetList>> {
|
|
101
|
+
// Query params use snake_case (sent directly to Django as URL params)
|
|
102
|
+
const params: Record<string, string> = {};
|
|
103
|
+
if (filters?.targetType) params.target_type = filters.targetType;
|
|
104
|
+
if (filters?.search) params.search = filters.search;
|
|
105
|
+
if (filters?.page) params.page = String(filters.page);
|
|
106
|
+
if (filters?.pageSize) params.page_size = String(filters.pageSize);
|
|
107
|
+
if (filters?.ordering) params.ordering = filters.ordering;
|
|
108
|
+
|
|
109
|
+
const response = await this.client.fetch.get<PaginatedResponse<TargetList> | TargetList[]>(
|
|
110
|
+
LISTS_BASE,
|
|
111
|
+
{ params }
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (Array.isArray(response)) {
|
|
115
|
+
return { count: response.length, results: response, next: null, previous: null };
|
|
116
|
+
}
|
|
117
|
+
return response as PaginatedResponse<TargetList>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Get a single target list by ID */
|
|
121
|
+
async get(id: string): Promise<TargetList> {
|
|
122
|
+
return this.client.fetch.get<TargetList>(LIST(id));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Create a new target list (camelCase body → auto-converted to snake_case by serializeBody) */
|
|
126
|
+
async create(data: CreateTargetListInput): Promise<TargetList> {
|
|
127
|
+
return this.client.fetch.post<TargetList>(LISTS_BASE, {
|
|
128
|
+
name: data.name,
|
|
129
|
+
description: data.description || '',
|
|
130
|
+
targetType: data.targetType || 'contact',
|
|
131
|
+
sourceType: data.sourceType || 'static',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Update an existing target list */
|
|
136
|
+
async update(id: string, data: UpdateTargetListInput): Promise<TargetList> {
|
|
137
|
+
return this.client.fetch.patch<TargetList>(LIST(id), data);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Delete a target list */
|
|
141
|
+
async delete(id: string): Promise<void> {
|
|
142
|
+
return this.client.fetch.delete(LIST(id));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Get members of a target list with pagination */
|
|
146
|
+
async getMembers(
|
|
147
|
+
listId: string,
|
|
148
|
+
filters?: TargetListMemberFilters
|
|
149
|
+
): Promise<PaginatedResponse<TargetListMember>> {
|
|
150
|
+
const params: Record<string, string> = {};
|
|
151
|
+
if (filters?.search) params.search = filters.search;
|
|
152
|
+
if (filters?.page) params.page = String(filters.page);
|
|
153
|
+
if (filters?.pageSize) params.page_size = String(filters.pageSize);
|
|
154
|
+
if (filters?.ordering) params.ordering = filters.ordering;
|
|
155
|
+
|
|
156
|
+
const response = await this.client.fetch.get<PaginatedResponse<TargetListMember> | TargetListMember[]>(
|
|
157
|
+
LIST_MEMBERS(listId),
|
|
158
|
+
{ params }
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (Array.isArray(response)) {
|
|
162
|
+
return { count: response.length, results: response, next: null, previous: null };
|
|
163
|
+
}
|
|
164
|
+
return response as PaginatedResponse<TargetListMember>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Add members (camelCase body → auto-converted to snake_case) */
|
|
168
|
+
async addMembers(
|
|
169
|
+
listId: string,
|
|
170
|
+
objectIds: string[],
|
|
171
|
+
metadata?: Record<string, unknown>
|
|
172
|
+
): Promise<AddMembersResult> {
|
|
173
|
+
return this.client.fetch.post<AddMembersResult>(LIST_ADD_MEMBERS(listId), {
|
|
174
|
+
objectIds,
|
|
175
|
+
...(metadata ? { metadata } : {}),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Remove members */
|
|
180
|
+
async removeMembers(listId: string, objectIds: string[]): Promise<RemoveMembersResult> {
|
|
181
|
+
return this.client.fetch.post<RemoveMembersResult>(LIST_REMOVE_MEMBERS(listId), {
|
|
182
|
+
objectIds,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Refresh a funnel/query-based list */
|
|
187
|
+
async refresh(listId: string): Promise<RefreshResult> {
|
|
188
|
+
return this.client.fetch.post<RefreshResult>(LIST_REFRESH(listId));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enrichment types — shared across all StartSimpli apps
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface EnrichmentResult {
|
|
6
|
+
contactId: string;
|
|
7
|
+
contactName: string;
|
|
8
|
+
success: boolean;
|
|
9
|
+
fieldsUpdated: string[];
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface QueueStatus {
|
|
14
|
+
pending: number;
|
|
15
|
+
processing: number;
|
|
16
|
+
completed: number;
|
|
17
|
+
failed: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type EnrichmentProvider = 'apollo' | 'hunter';
|
|
21
|
+
|
|
22
|
+
export interface IntegrationStatus {
|
|
23
|
+
name: string;
|
|
24
|
+
provider: EnrichmentProvider;
|
|
25
|
+
connected: boolean;
|
|
26
|
+
description: string;
|
|
27
|
+
lastSync?: string;
|
|
28
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -89,6 +89,14 @@ export type {
|
|
|
89
89
|
ChangePasswordResponse,
|
|
90
90
|
} from './user';
|
|
91
91
|
|
|
92
|
+
// Enrichment types
|
|
93
|
+
export type {
|
|
94
|
+
EnrichmentResult,
|
|
95
|
+
QueueStatus,
|
|
96
|
+
EnrichmentProvider,
|
|
97
|
+
IntegrationStatus,
|
|
98
|
+
} from './enrichment';
|
|
99
|
+
|
|
92
100
|
// Error types
|
|
93
101
|
export type {
|
|
94
102
|
FieldError,
|
package/src/types/user.ts
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* User types for /api/v1/users/ endpoints
|
|
3
|
+
* camelCase — FetchWrapper auto-converts snake_case↔camelCase at boundary
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
export interface UserProfile {
|
|
6
7
|
id: string;
|
|
7
8
|
email: string;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
firstName: string;
|
|
10
|
+
lastName: string;
|
|
11
|
+
fullName: string;
|
|
12
|
+
isEmailVerified: boolean;
|
|
12
13
|
company: string | null;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
isActive: boolean;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
updatedAt: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export interface UpdateProfileRequest {
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
firstName?: string;
|
|
21
|
+
lastName?: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface ChangePasswordRequest {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
oldPassword: string;
|
|
26
|
+
newPassword: string;
|
|
27
|
+
newPasswordConfirm: string;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface ChangePasswordResponse {
|