@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/api",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Type-safe Django REST API client for StartSimpli apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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 { 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';
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
+ }
@@ -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
- } else if (this.config.onUnauthorized) {
121
- // Refresh failed - call unauthorized callback
122
- this.config.onUnauthorized();
129
+
130
+ if (response.status === 401) {
131
+ this.fireUnauthorizedOnce();
132
+ }
133
+ } else {
134
+ this.fireUnauthorizedOnce();
123
135
  }
124
- } else if (this.config.onUnauthorized) {
125
- // No refresh callback - call unauthorized directly
126
- this.config.onUnauthorized();
136
+ } else {
137
+ this.fireUnauthorizedOnce();
127
138
  }
128
139
  }
129
140
 
@@ -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
- // BEAD: fund-your-startup-rgi4 - funnels/{id}/results endpoint missing from Django FunnelViewSet
213
+ /**
214
+ * Get results (entities) from a funnel's latest completed run.
215
+ */
161
216
  async getResults(
162
- _funnelId: string,
163
- _pagination?: PaginationParams
217
+ funnelId: string,
218
+ pagination?: PaginationParams
164
219
  ): Promise<PaginatedResponse<FunnelResult>> {
165
- throw new Error('Not implemented - BEAD: fund-your-startup-rgi4. funnels/{id}/results action does not exist in Django.');
166
- // const params = new URLSearchParams();
167
- // if (_pagination?.page) params.append('page', String(_pagination.page));
168
- // if (_pagination?.pageSize) params.append('pageSize', String(_pagination.pageSize));
169
- // const query = params.toString();
170
- // const endpoint = query ? `${ENDPOINTS.FUNNEL_RESULTS(_funnelId)}?${query}` : ENDPOINTS.FUNNEL_RESULTS(_funnelId);
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
- // BEAD: fund-your-startup-rgi4 - funnels/{id}/preview endpoint missing. Django has /funnels/preview-icp/ (different signature).
175
- async preview(_funnelId: string, _stages?: Funnel['stages']): Promise<FunnelPreviewResult> {
176
- throw new Error('Not implemented - BEAD: fund-your-startup-rgi4. funnels/{id}/preview action does not exist in Django.');
177
- // return this.client.post<FunnelPreviewResult>(ENDPOINTS.FUNNEL_PREVIEW(_funnelId), { stages: _stages });
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
+ }
@@ -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
+ }
@@ -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
- first_name: string;
9
- last_name: string;
10
- full_name: string;
11
- is_email_verified: boolean;
9
+ firstName: string;
10
+ lastName: string;
11
+ fullName: string;
12
+ isEmailVerified: boolean;
12
13
  company: string | null;
13
- is_active: boolean;
14
- created_at: string;
15
- updated_at: string;
14
+ isActive: boolean;
15
+ createdAt: string;
16
+ updatedAt: string;
16
17
  }
17
18
 
18
19
  export interface UpdateProfileRequest {
19
- first_name?: string;
20
- last_name?: string;
20
+ firstName?: string;
21
+ lastName?: string;
21
22
  }
22
23
 
23
24
  export interface ChangePasswordRequest {
24
- old_password: string;
25
- new_password: string;
26
- new_password_confirm: string;
25
+ oldPassword: string;
26
+ newPassword: string;
27
+ newPasswordConfirm: string;
27
28
  }
28
29
 
29
30
  export interface ChangePasswordResponse {