@startsimpli/api 0.5.7 → 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.7",
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',
@@ -50,6 +50,7 @@ export const ENDPOINTS = {
50
50
  FUNNEL_PREVIEW: (id: string) => `api/v1/funnels/${id}/preview`,
51
51
  FUNNEL_RESULTS: (id: string) => `api/v1/funnels/${id}/results`,
52
52
  FUNNEL_TEMPLATES: 'api/v1/funnels/templates',
53
+ FUNNEL_FIELDS: 'api/v1/funnels/fields',
53
54
  FUNNEL_RUN_BY_ID: (runId: string) => `api/v1/funnel-runs/${runId}`,
54
55
  FUNNEL_RUN_CANCEL: (runId: string) => `api/v1/funnel-runs/${runId}/cancel`,
55
56
  FUNNEL_RUNS_GLOBAL: 'api/v1/funnel-runs',
package/src/index.ts CHANGED
@@ -9,6 +9,10 @@
9
9
  export { ApiClient, createApiClient } from './lib/api-client';
10
10
  export type { ApiClientConfig } from './lib/api-client';
11
11
 
12
+ // Entity adapter factory
13
+ export { createEntityAdapter } from './lib/entity-adapter';
14
+ export type { EntityAdapterConfig, EntityAdapter } from './lib/entity-adapter';
15
+
12
16
  // API wrappers
13
17
  export { ContactsApi } from './lib/contacts-api';
14
18
  export { OrganizationsApi } from './lib/organizations-api';
@@ -16,12 +20,20 @@ export { EntitiesApi } from './lib/entities-api';
16
20
  export { WorkflowsApi } from './lib/workflows-api';
17
21
  export { MessagesApi } from './lib/messages-api';
18
22
  export type { Message, MessageStatus as MessageApiStatus, MessageRecipient, MessagingChannel as MessagingChannelType, MessageFilters, CreateMessageInput, ScheduleMessageInput, SendTestInput } from './lib/messages-api';
23
+ export { calculateStatsFromMessages } from './lib/message-stats';
24
+ export type { MessageStats } from './lib/message-stats';
19
25
  export { MessageTemplatesApi } from './lib/message-templates-api';
20
26
  export type { MessageTemplate, MessageTemplateFilters, CreateMessageTemplateInput } from './lib/message-templates-api';
21
27
  export { UsersApi } from './lib/users-api';
22
28
  export type { UserProfile, UpdateProfileRequest, ChangePasswordRequest, ChangePasswordResponse } from './types/user';
23
29
  export { FunnelsApi, isFunnelRunConflict, isFunnelValidationError } from './lib/funnels-api';
24
- export type { FunnelPreviewResult, FunnelRunFilters, FunnelTemplate } from './lib/funnels-api';
30
+ export type {
31
+ FunnelPreviewResult,
32
+ FunnelRunFilters,
33
+ FunnelTemplate,
34
+ FunnelFieldDefinition,
35
+ FunnelFieldsResponse,
36
+ } from './lib/funnels-api';
25
37
  export { EnrichmentApi } from './lib/enrichment-api';
26
38
  export { TargetListsApi } from './lib/target-lists-api';
27
39
  export type {
@@ -0,0 +1,223 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createEntityAdapter } from '../entity-adapter';
3
+ import type { EntityAdapterConfig } from '../entity-adapter';
4
+
5
+ // --- Test types ---
6
+
7
+ interface DomainUser {
8
+ id: string;
9
+ fullName: string;
10
+ email: string;
11
+ }
12
+
13
+ interface ApiUser {
14
+ id: string;
15
+ first_name: string;
16
+ last_name: string;
17
+ email_address: string;
18
+ }
19
+
20
+ function makeConfig(overrides: Partial<EntityAdapterConfig<DomainUser, ApiUser>> = {}) {
21
+ return {
22
+ list: vi.fn().mockResolvedValue({ results: [], count: 0 }),
23
+ get: vi.fn().mockResolvedValue({ id: '1', first_name: 'Jane', last_name: 'Doe', email_address: 'jane@example.com' }),
24
+ create: vi.fn().mockImplementation(async (data: Partial<ApiUser>) => ({
25
+ id: '2',
26
+ first_name: data.first_name ?? '',
27
+ last_name: data.last_name ?? '',
28
+ email_address: data.email_address ?? '',
29
+ })),
30
+ update: vi.fn().mockImplementation(async (_id: string, data: Partial<ApiUser>) => ({
31
+ id: _id,
32
+ first_name: data.first_name ?? 'Jane',
33
+ last_name: data.last_name ?? 'Doe',
34
+ email_address: data.email_address ?? 'jane@example.com',
35
+ })),
36
+ delete: vi.fn().mockResolvedValue(undefined),
37
+ toEntity: (api: ApiUser): DomainUser => ({
38
+ id: api.id,
39
+ fullName: `${api.first_name} ${api.last_name}`,
40
+ email: api.email_address,
41
+ }),
42
+ toApi: (input: Record<string, unknown>): Partial<ApiUser> => {
43
+ const result: Partial<ApiUser> = {};
44
+ if (input.fullName) {
45
+ const parts = (input.fullName as string).split(' ');
46
+ result.first_name = parts[0];
47
+ result.last_name = parts.slice(1).join(' ');
48
+ }
49
+ if (input.email) {
50
+ result.email_address = input.email as string;
51
+ }
52
+ return result;
53
+ },
54
+ ...overrides,
55
+ } satisfies EntityAdapterConfig<DomainUser, ApiUser>;
56
+ }
57
+
58
+ describe('createEntityAdapter', () => {
59
+ describe('list', () => {
60
+ it('maps API results through toEntity and preserves count', async () => {
61
+ const apiUsers: ApiUser[] = [
62
+ { id: '1', first_name: 'Jane', last_name: 'Doe', email_address: 'jane@example.com' },
63
+ { id: '2', first_name: 'John', last_name: 'Smith', email_address: 'john@example.com' },
64
+ ];
65
+ const config = makeConfig({
66
+ list: vi.fn().mockResolvedValue({ results: apiUsers, count: 42 }),
67
+ });
68
+ const adapter = createEntityAdapter(config);
69
+
70
+ const result = await adapter.list({ page: 1 });
71
+
72
+ expect(config.list).toHaveBeenCalledWith({ page: 1 });
73
+ expect(result.count).toBe(42);
74
+ expect(result.results).toEqual([
75
+ { id: '1', fullName: 'Jane Doe', email: 'jane@example.com' },
76
+ { id: '2', fullName: 'John Smith', email: 'john@example.com' },
77
+ ]);
78
+ });
79
+
80
+ it('passes undefined filters when called without arguments', async () => {
81
+ const config = makeConfig();
82
+ const adapter = createEntityAdapter(config);
83
+
84
+ await adapter.list();
85
+
86
+ expect(config.list).toHaveBeenCalledWith(undefined);
87
+ });
88
+
89
+ it('returns empty results for empty API response', async () => {
90
+ const config = makeConfig({
91
+ list: vi.fn().mockResolvedValue({ results: [], count: 0 }),
92
+ });
93
+ const adapter = createEntityAdapter(config);
94
+
95
+ const result = await adapter.list();
96
+
97
+ expect(result).toEqual({ results: [], count: 0 });
98
+ });
99
+ });
100
+
101
+ describe('get', () => {
102
+ it('maps a single API resource through toEntity', async () => {
103
+ const config = makeConfig();
104
+ const adapter = createEntityAdapter(config);
105
+
106
+ const result = await adapter.get('1');
107
+
108
+ expect(config.get).toHaveBeenCalledWith('1');
109
+ expect(result).toEqual({
110
+ id: '1',
111
+ fullName: 'Jane Doe',
112
+ email: 'jane@example.com',
113
+ });
114
+ });
115
+ });
116
+
117
+ describe('create', () => {
118
+ it('transforms input via toApi, calls config.create, maps result via toEntity', async () => {
119
+ const config = makeConfig();
120
+ const adapter = createEntityAdapter(config);
121
+
122
+ const result = await adapter.create({ fullName: 'Alice Wonder', email: 'alice@example.com' });
123
+
124
+ expect(config.create).toHaveBeenCalledWith({
125
+ first_name: 'Alice',
126
+ last_name: 'Wonder',
127
+ email_address: 'alice@example.com',
128
+ });
129
+ expect(result).toEqual({
130
+ id: '2',
131
+ fullName: 'Alice Wonder',
132
+ email: 'alice@example.com',
133
+ });
134
+ });
135
+ });
136
+
137
+ describe('update', () => {
138
+ it('transforms input via toApi, calls config.update with id, maps result via toEntity', async () => {
139
+ const config = makeConfig();
140
+ const adapter = createEntityAdapter(config);
141
+
142
+ const result = await adapter.update('1', { fullName: 'Jane Updated', email: 'new@example.com' });
143
+
144
+ expect(config.update).toHaveBeenCalledWith('1', {
145
+ first_name: 'Jane',
146
+ last_name: 'Updated',
147
+ email_address: 'new@example.com',
148
+ });
149
+ expect(result).toEqual({
150
+ id: '1',
151
+ fullName: 'Jane Updated',
152
+ email: 'new@example.com',
153
+ });
154
+ });
155
+ });
156
+
157
+ describe('delete', () => {
158
+ it('passes through to config.delete', async () => {
159
+ const config = makeConfig();
160
+ const adapter = createEntityAdapter(config);
161
+
162
+ await adapter.delete('1');
163
+
164
+ expect(config.delete).toHaveBeenCalledWith('1');
165
+ });
166
+
167
+ it('returns void', async () => {
168
+ const config = makeConfig();
169
+ const adapter = createEntityAdapter(config);
170
+
171
+ const result = await adapter.delete('1');
172
+
173
+ expect(result).toBeUndefined();
174
+ });
175
+ });
176
+
177
+ describe('error propagation', () => {
178
+ it('propagates list errors', async () => {
179
+ const config = makeConfig({
180
+ list: vi.fn().mockRejectedValue(new Error('Network error')),
181
+ });
182
+ const adapter = createEntityAdapter(config);
183
+
184
+ await expect(adapter.list()).rejects.toThrow('Network error');
185
+ });
186
+
187
+ it('propagates get errors', async () => {
188
+ const config = makeConfig({
189
+ get: vi.fn().mockRejectedValue(new Error('Not found')),
190
+ });
191
+ const adapter = createEntityAdapter(config);
192
+
193
+ await expect(adapter.get('999')).rejects.toThrow('Not found');
194
+ });
195
+
196
+ it('propagates create errors', async () => {
197
+ const config = makeConfig({
198
+ create: vi.fn().mockRejectedValue(new Error('Validation failed')),
199
+ });
200
+ const adapter = createEntityAdapter(config);
201
+
202
+ await expect(adapter.create({ fullName: 'X' })).rejects.toThrow('Validation failed');
203
+ });
204
+
205
+ it('propagates update errors', async () => {
206
+ const config = makeConfig({
207
+ update: vi.fn().mockRejectedValue(new Error('Conflict')),
208
+ });
209
+ const adapter = createEntityAdapter(config);
210
+
211
+ await expect(adapter.update('1', { fullName: 'X' })).rejects.toThrow('Conflict');
212
+ });
213
+
214
+ it('propagates delete errors', async () => {
215
+ const config = makeConfig({
216
+ delete: vi.fn().mockRejectedValue(new Error('Forbidden')),
217
+ });
218
+ const adapter = createEntityAdapter(config);
219
+
220
+ await expect(adapter.delete('1')).rejects.toThrow('Forbidden');
221
+ });
222
+ });
223
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Entity Adapter — thin mapping layer between domain entities and API resources.
3
+ *
4
+ * Creates a typed CRUD client that translates between a domain entity shape
5
+ * (used by the UI) and the raw API resource shape (returned by Django).
6
+ */
7
+
8
+ export interface EntityAdapterConfig<TEntity, TApi> {
9
+ /** List API resources with optional filters. Returns paginated response. */
10
+ list: (filters?: Record<string, unknown>) => Promise<{ results: TApi[]; count: number }>;
11
+ /** Get a single API resource by ID. */
12
+ get: (id: string) => Promise<TApi>;
13
+ /** Create an API resource. */
14
+ create: (data: Partial<TApi>) => Promise<TApi>;
15
+ /** Update an API resource by ID. */
16
+ update: (id: string, data: Partial<TApi>) => Promise<TApi>;
17
+ /** Delete an API resource by ID. */
18
+ delete: (id: string) => Promise<void>;
19
+ /** Transform an API resource into the domain entity. */
20
+ toEntity: (api: TApi) => TEntity;
21
+ /** Transform domain input into the API shape for create/update. */
22
+ toApi: (input: Record<string, unknown>) => Partial<TApi>;
23
+ }
24
+
25
+ export interface EntityAdapter<TEntity> {
26
+ list: (filters?: Record<string, unknown>) => Promise<{ results: TEntity[]; count: number }>;
27
+ get: (id: string) => Promise<TEntity>;
28
+ create: (input: Record<string, unknown>) => Promise<TEntity>;
29
+ update: (id: string, input: Record<string, unknown>) => Promise<TEntity>;
30
+ delete: (id: string) => Promise<void>;
31
+ }
32
+
33
+ export function createEntityAdapter<TEntity, TApi>(
34
+ config: EntityAdapterConfig<TEntity, TApi>,
35
+ ): EntityAdapter<TEntity> {
36
+ return {
37
+ async list(filters?: Record<string, unknown>) {
38
+ const response = await config.list(filters);
39
+ return {
40
+ results: response.results.map(config.toEntity),
41
+ count: response.count,
42
+ };
43
+ },
44
+
45
+ async get(id: string) {
46
+ const raw = await config.get(id);
47
+ return config.toEntity(raw);
48
+ },
49
+
50
+ async create(input: Record<string, unknown>) {
51
+ const apiData = config.toApi(input);
52
+ const raw = await config.create(apiData);
53
+ return config.toEntity(raw);
54
+ },
55
+
56
+ async update(id: string, input: Record<string, unknown>) {
57
+ const apiData = config.toApi(input);
58
+ const raw = await config.update(id, apiData);
59
+ return config.toEntity(raw);
60
+ },
61
+
62
+ async delete(id: string) {
63
+ await config.delete(id);
64
+ },
65
+ };
66
+ }
@@ -25,11 +25,20 @@ export class FetchWrapper {
25
25
  private config: FetchWrapperConfig;
26
26
  private isRefreshing = false;
27
27
  private refreshPromise: Promise<string | null> | null = null;
28
+ private unauthorizedFiredAt = 0;
28
29
 
29
30
  constructor(config: FetchWrapperConfig) {
30
31
  this.config = config;
31
32
  }
32
33
 
34
+ private fireUnauthorizedOnce(): void {
35
+ if (!this.config.onUnauthorized) return;
36
+ const now = Date.now();
37
+ if (now - this.unauthorizedFiredAt < 5000) return;
38
+ this.unauthorizedFiredAt = now;
39
+ this.config.onUnauthorized();
40
+ }
41
+
33
42
  /**
34
43
  * Build headers with auth token
35
44
  */
@@ -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
 
@@ -258,4 +273,17 @@ export class FunnelsApi {
258
273
  : ENDPOINTS.FUNNEL_TEMPLATES;
259
274
  return this.client.get<PaginatedResponse<FunnelTemplate>>(endpoint);
260
275
  }
276
+
277
+ /**
278
+ * Fetch the funnel rule field registry. Drives the generic rule editor UI.
279
+ */
280
+ async getFields(entityType?: string): Promise<FunnelFieldsResponse> {
281
+ // Trailing slash before the query string — Django's APPEND_SLASH does not
282
+ // redirect when a query string is present, so the URL must already end
283
+ // with a slash before '?entity_type='.
284
+ const endpoint = entityType
285
+ ? `${ENDPOINTS.FUNNEL_FIELDS}/?entity_type=${encodeURIComponent(entityType)}`
286
+ : `${ENDPOINTS.FUNNEL_FIELDS}/`;
287
+ return this.client.get<FunnelFieldsResponse>(endpoint);
288
+ }
261
289
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Message statistics utilities
3
+ *
4
+ * Calculates aggregate stats from an array of Message objects.
5
+ */
6
+
7
+ import type { Message } from './messages-api';
8
+
9
+ export interface MessageStats {
10
+ sent: number;
11
+ opened: number;
12
+ clicked: number;
13
+ replied: number;
14
+ bounced: number;
15
+ unsubscribed: number;
16
+ openRate: number;
17
+ clickRate: number;
18
+ replyRate: number;
19
+ bounceRate: number;
20
+ }
21
+
22
+ /**
23
+ * Calculate aggregate stats from an array of messages.
24
+ */
25
+ export function calculateStatsFromMessages(messages: Message[]): MessageStats {
26
+ const sent = messages.length;
27
+ const opened = messages.filter((m) => (m.recipientsOpened ?? 0) > 0).length;
28
+ const clicked = messages.filter((m) => (m.recipientsClicked ?? 0) > 0).length;
29
+ const replied = messages.filter((m) => (m.recipientsReplied ?? 0) > 0).length;
30
+ const bounced = messages.filter((m) => (m.recipientsBounced ?? 0) > 0).length;
31
+ // recipientsUnsubscribed is not included in list endpoint responses; fall back to 0
32
+ const unsubscribed = messages.filter((m) => (m.recipientsUnsubscribed ?? 0) > 0).length;
33
+
34
+ return {
35
+ sent,
36
+ opened,
37
+ clicked,
38
+ replied,
39
+ bounced,
40
+ unsubscribed,
41
+ openRate: sent > 0 ? (opened / sent) * 100 : 0,
42
+ clickRate: sent > 0 ? (clicked / sent) * 100 : 0,
43
+ replyRate: sent > 0 ? (replied / sent) * 100 : 0,
44
+ bounceRate: sent > 0 ? (bounced / sent) * 100 : 0,
45
+ };
46
+ }
@@ -35,10 +35,12 @@ export interface Message {
35
35
  recipientsDelivered: number;
36
36
  recipientsOpened: number;
37
37
  recipientsClicked: number;
38
+ recipientsReplied: number;
38
39
  recipientsBounced: number;
39
40
  recipientsUnsubscribed: number;
40
41
  openRate: number;
41
42
  clickRate: number;
43
+ replyRate: number;
42
44
  bounceRate: number;
43
45
  errorMessage: string | null;
44
46
  createdAt: string;