@slotchain/sdk 1.1.3 → 1.2.0
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/dist/index.cjs.js +257 -16
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.mts +324 -28
- package/dist/index.d.ts +324 -28
- package/dist/index.esm.js +257 -16
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -2
- package/src/clients/__tests__/service-client.spec.ts +450 -0
- package/src/clients/customer-client.ts +129 -29
- package/src/clients/customer-client.ts.bak +165 -0
- package/src/clients/document-client.ts +148 -0
- package/src/clients/service-client.ts +87 -1
- package/src/clients/slot-client.ts +8 -5
- package/src/index.ts +17 -0
- package/src/types/api.ts +172 -7
- package/src/types/next-ambient.d.ts +29 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for ServiceClient.getServicesByTenant() and getServicesBySlot()
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* - getServicesByTenant() with various parameter combinations
|
|
6
|
+
* - getServicesBySlot() with valid/invalid slot IDs
|
|
7
|
+
* - Type safety for ServiceWithItems vs SlotServiceWithItems
|
|
8
|
+
* - Error handling for all error codes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import axios from 'axios';
|
|
12
|
+
import axiosMockAdapter from 'axios-mock-adapter';
|
|
13
|
+
import { ServiceClient } from '../service-client';
|
|
14
|
+
import type { Service, ServiceWithItems, SlotServiceWithItems } from '../../types/api';
|
|
15
|
+
|
|
16
|
+
describe('ServiceClient', () => {
|
|
17
|
+
let mockAdapter: axiosMockAdapter;
|
|
18
|
+
let client: axios.AxiosInstance;
|
|
19
|
+
let serviceClient: ServiceClient;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
// Create a new axios instance for each test
|
|
23
|
+
client = axios.create({
|
|
24
|
+
baseURL: 'https://api.slotly.dev',
|
|
25
|
+
});
|
|
26
|
+
mockAdapter = new axiosMockAdapter(client, { delayResponse: 0 });
|
|
27
|
+
serviceClient = new ServiceClient(client);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
mockAdapter.restore();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('getServicesByTenant()', () => {
|
|
35
|
+
describe('With tenantId', () => {
|
|
36
|
+
it('should return services without items when includeItems is false', async () => {
|
|
37
|
+
const mockServices: Service[] = [
|
|
38
|
+
{
|
|
39
|
+
id: 'service-1',
|
|
40
|
+
tenant_id: 'tenant-123',
|
|
41
|
+
name: 'Consultation',
|
|
42
|
+
description: 'Initial consultation',
|
|
43
|
+
is_active: true,
|
|
44
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
45
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
mockAdapter
|
|
50
|
+
.onGet('/api/v1/services', { params: { tenant_id: 'tenant-123' } })
|
|
51
|
+
.reply(200, {
|
|
52
|
+
success: true,
|
|
53
|
+
data: mockServices,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = await serviceClient.getServicesByTenant({
|
|
57
|
+
tenantId: 'tenant-123',
|
|
58
|
+
includeItems: false,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(result.success).toBe(true);
|
|
62
|
+
expect(result.data).toBeDefined();
|
|
63
|
+
expect(Array.isArray(result.data)).toBe(true);
|
|
64
|
+
expect(result.data?.length).toBe(1);
|
|
65
|
+
expect(result.data?.[0].id).toBe('service-1');
|
|
66
|
+
expect('serviceItems' in (result.data?.[0] || {})).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should return services with items when includeItems is true', async () => {
|
|
70
|
+
const mockServices: ServiceWithItems[] = [
|
|
71
|
+
{
|
|
72
|
+
id: 'service-1',
|
|
73
|
+
tenant_id: 'tenant-123',
|
|
74
|
+
name: 'Consultation',
|
|
75
|
+
description: 'Initial consultation',
|
|
76
|
+
is_active: true,
|
|
77
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
78
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
79
|
+
serviceItems: [
|
|
80
|
+
{
|
|
81
|
+
id: 'item-1',
|
|
82
|
+
service_id: 'service-1',
|
|
83
|
+
name: 'Standard Consultation',
|
|
84
|
+
price: 100,
|
|
85
|
+
is_available: true,
|
|
86
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
87
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
mockAdapter
|
|
94
|
+
.onGet('/api/v1/services', { params: { tenant_id: 'tenant-123', includeItems: true } })
|
|
95
|
+
.reply(200, {
|
|
96
|
+
success: true,
|
|
97
|
+
data: mockServices,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const result = await serviceClient.getServicesByTenant({
|
|
101
|
+
tenantId: 'tenant-123',
|
|
102
|
+
includeItems: true,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.success).toBe(true);
|
|
106
|
+
expect(result.data).toBeDefined();
|
|
107
|
+
expect(Array.isArray(result.data)).toBe(true);
|
|
108
|
+
const firstService = result.data?.[0] as ServiceWithItems;
|
|
109
|
+
expect(firstService.serviceItems).toBeDefined();
|
|
110
|
+
expect(firstService.serviceItems?.length).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should include pagination metadata when provided', async () => {
|
|
114
|
+
const mockServices: Service[] = [
|
|
115
|
+
{
|
|
116
|
+
id: 'service-1',
|
|
117
|
+
tenant_id: 'tenant-123',
|
|
118
|
+
name: 'Consultation',
|
|
119
|
+
is_active: true,
|
|
120
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
121
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
mockAdapter
|
|
126
|
+
.onGet('/api/v1/services', { params: { tenant_id: 'tenant-123', page: 1, limit: 20 } })
|
|
127
|
+
.reply(200, {
|
|
128
|
+
success: true,
|
|
129
|
+
data: mockServices,
|
|
130
|
+
pagination: {
|
|
131
|
+
page: 1,
|
|
132
|
+
limit: 20,
|
|
133
|
+
total: 50,
|
|
134
|
+
totalPages: 3,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const result = await serviceClient.getServicesByTenant({
|
|
139
|
+
tenantId: 'tenant-123',
|
|
140
|
+
page: 1,
|
|
141
|
+
limit: 20,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.success).toBe(true);
|
|
145
|
+
expect(result.pagination).toBeDefined();
|
|
146
|
+
expect(result.pagination?.page).toBe(1);
|
|
147
|
+
expect(result.pagination?.total).toBe(50);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should filter by isActive when provided', async () => {
|
|
151
|
+
const mockServices: Service[] = [
|
|
152
|
+
{
|
|
153
|
+
id: 'service-1',
|
|
154
|
+
tenant_id: 'tenant-123',
|
|
155
|
+
name: 'Active Service',
|
|
156
|
+
is_active: true,
|
|
157
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
158
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
mockAdapter
|
|
163
|
+
.onGet('/api/v1/services', { params: { tenant_id: 'tenant-123', is_active: true } })
|
|
164
|
+
.reply(200, {
|
|
165
|
+
success: true,
|
|
166
|
+
data: mockServices,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const result = await serviceClient.getServicesByTenant({
|
|
170
|
+
tenantId: 'tenant-123',
|
|
171
|
+
isActive: true,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(result.success).toBe(true);
|
|
175
|
+
expect(result.data?.length).toBe(1);
|
|
176
|
+
expect(result.data?.[0].is_active).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('With tenantSlug', () => {
|
|
181
|
+
it('should return services when using tenantSlug', async () => {
|
|
182
|
+
const mockServices: Service[] = [
|
|
183
|
+
{
|
|
184
|
+
id: 'service-1',
|
|
185
|
+
tenant_id: 'tenant-123',
|
|
186
|
+
name: 'Consultation',
|
|
187
|
+
is_active: true,
|
|
188
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
189
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
mockAdapter
|
|
194
|
+
.onGet('/api/v1/services', { params: { tenantSlug: 'sn-cleaning-co' } })
|
|
195
|
+
.reply(200, {
|
|
196
|
+
success: true,
|
|
197
|
+
data: mockServices,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const result = await serviceClient.getServicesByTenant({
|
|
201
|
+
tenantSlug: 'sn-cleaning-co',
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(result.success).toBe(true);
|
|
205
|
+
expect(result.data).toBeDefined();
|
|
206
|
+
expect(result.data?.length).toBe(1);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('Error handling', () => {
|
|
211
|
+
it('should return MISSING_TENANT_ID error when neither tenantId nor tenantSlug provided', async () => {
|
|
212
|
+
const result = await serviceClient.getServicesByTenant({});
|
|
213
|
+
|
|
214
|
+
expect(result.success).toBe(false);
|
|
215
|
+
expect(result.error).toBeDefined();
|
|
216
|
+
expect(result.error?.code).toBe('MISSING_TENANT_ID');
|
|
217
|
+
expect(result.error?.message).toContain('tenantId or tenantSlug is required');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should handle TENANT_NOT_FOUND error', async () => {
|
|
221
|
+
mockAdapter
|
|
222
|
+
.onGet('/api/v1/services', { params: { tenantSlug: 'invalid-tenant' } })
|
|
223
|
+
.reply(404, {
|
|
224
|
+
success: false,
|
|
225
|
+
error: {
|
|
226
|
+
code: 'TENANT_NOT_FOUND',
|
|
227
|
+
message: 'Tenant with slug "invalid-tenant" not found',
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const result = await serviceClient.getServicesByTenant({
|
|
232
|
+
tenantSlug: 'invalid-tenant',
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(result.success).toBe(false);
|
|
236
|
+
expect(result.error?.code).toBe('TENANT_NOT_FOUND');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should handle INTERNAL_ERROR', async () => {
|
|
240
|
+
mockAdapter
|
|
241
|
+
.onGet('/api/v1/services', { params: { tenant_id: 'tenant-123' } })
|
|
242
|
+
.reply(500, {
|
|
243
|
+
success: false,
|
|
244
|
+
error: {
|
|
245
|
+
code: 'INTERNAL_ERROR',
|
|
246
|
+
message: 'Internal server error',
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const result = await serviceClient.getServicesByTenant({
|
|
251
|
+
tenantId: 'tenant-123',
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(result.success).toBe(false);
|
|
255
|
+
expect(result.error?.code).toBe('INTERNAL_ERROR');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should enforce max limit of 100', async () => {
|
|
259
|
+
const mockServices: Service[] = [];
|
|
260
|
+
|
|
261
|
+
mockAdapter
|
|
262
|
+
.onGet('/api/v1/services', { params: { tenant_id: 'tenant-123', limit: 100 } })
|
|
263
|
+
.reply(200, {
|
|
264
|
+
success: true,
|
|
265
|
+
data: mockServices,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Request with limit > 100 should be capped at 100
|
|
269
|
+
await serviceClient.getServicesByTenant({
|
|
270
|
+
tenantId: 'tenant-123',
|
|
271
|
+
limit: 150,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Verify the request was made with limit: 100
|
|
275
|
+
expect(mockAdapter.history.get.length).toBe(1);
|
|
276
|
+
const request = mockAdapter.history.get[0];
|
|
277
|
+
expect(request.params?.limit).toBe(100);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('Deprecated slotId parameter', () => {
|
|
282
|
+
it('should accept slotId parameter for compatibility', async () => {
|
|
283
|
+
const mockServices: Service[] = [
|
|
284
|
+
{
|
|
285
|
+
id: 'service-1',
|
|
286
|
+
tenant_id: 'tenant-123',
|
|
287
|
+
name: 'Consultation',
|
|
288
|
+
is_active: true,
|
|
289
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
290
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
291
|
+
},
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
mockAdapter
|
|
295
|
+
.onGet('/api/v1/services', { params: { tenant_id: 'tenant-123', slot_id: 'slot-123' } })
|
|
296
|
+
.reply(200, {
|
|
297
|
+
success: true,
|
|
298
|
+
data: mockServices,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const result = await serviceClient.getServicesByTenant({
|
|
302
|
+
tenantId: 'tenant-123',
|
|
303
|
+
slotId: 'slot-123', // Deprecated but accepted
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(result.success).toBe(true);
|
|
307
|
+
// Verify slot_id was included in request (even though API ignores it)
|
|
308
|
+
expect(mockAdapter.history.get[0].params?.slot_id).toBe('slot-123');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('getServicesBySlot()', () => {
|
|
314
|
+
describe('Valid slot ID', () => {
|
|
315
|
+
it('should return services with items property (not serviceItems)', async () => {
|
|
316
|
+
const mockServices: SlotServiceWithItems[] = [
|
|
317
|
+
{
|
|
318
|
+
id: 'service-1',
|
|
319
|
+
tenant_id: 'tenant-123',
|
|
320
|
+
name: 'Consultation',
|
|
321
|
+
is_active: true,
|
|
322
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
323
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
324
|
+
items: [
|
|
325
|
+
{
|
|
326
|
+
id: 'item-1',
|
|
327
|
+
service_id: 'service-1',
|
|
328
|
+
name: 'Standard Consultation',
|
|
329
|
+
price: 100,
|
|
330
|
+
is_available: true,
|
|
331
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
332
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
mockAdapter
|
|
339
|
+
.onGet('/api/v1/slots/slot-123/services')
|
|
340
|
+
.reply(200, {
|
|
341
|
+
success: true,
|
|
342
|
+
data: mockServices,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const result = await serviceClient.getServicesBySlot('slot-123');
|
|
346
|
+
|
|
347
|
+
expect(result.success).toBe(true);
|
|
348
|
+
expect(result.data).toBeDefined();
|
|
349
|
+
expect(Array.isArray(result.data)).toBe(true);
|
|
350
|
+
expect(result.data?.length).toBe(1);
|
|
351
|
+
|
|
352
|
+
const firstService = result.data?.[0];
|
|
353
|
+
expect(firstService?.items).toBeDefined();
|
|
354
|
+
expect(firstService?.items.length).toBe(1);
|
|
355
|
+
// Verify it uses 'items' not 'serviceItems'
|
|
356
|
+
expect('serviceItems' in (firstService || {})).toBe(false);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should only return active services', async () => {
|
|
360
|
+
const mockServices: SlotServiceWithItems[] = [
|
|
361
|
+
{
|
|
362
|
+
id: 'service-1',
|
|
363
|
+
tenant_id: 'tenant-123',
|
|
364
|
+
name: 'Active Service',
|
|
365
|
+
is_active: true,
|
|
366
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
367
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
368
|
+
items: [],
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
|
|
372
|
+
mockAdapter
|
|
373
|
+
.onGet('/api/v1/slots/slot-123/services')
|
|
374
|
+
.reply(200, {
|
|
375
|
+
success: true,
|
|
376
|
+
data: mockServices,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const result = await serviceClient.getServicesBySlot('slot-123');
|
|
380
|
+
|
|
381
|
+
expect(result.success).toBe(true);
|
|
382
|
+
expect(result.data?.every(s => s.is_active === true)).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('should always include items (even if empty array)', async () => {
|
|
386
|
+
const mockServices: SlotServiceWithItems[] = [
|
|
387
|
+
{
|
|
388
|
+
id: 'service-1',
|
|
389
|
+
tenant_id: 'tenant-123',
|
|
390
|
+
name: 'Service without items',
|
|
391
|
+
is_active: true,
|
|
392
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
393
|
+
updated_at: '2024-01-01T00:00:00Z',
|
|
394
|
+
items: [],
|
|
395
|
+
},
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
mockAdapter
|
|
399
|
+
.onGet('/api/v1/slots/slot-123/services')
|
|
400
|
+
.reply(200, {
|
|
401
|
+
success: true,
|
|
402
|
+
data: mockServices,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const result = await serviceClient.getServicesBySlot('slot-123');
|
|
406
|
+
|
|
407
|
+
expect(result.success).toBe(true);
|
|
408
|
+
expect(result.data?.[0].items).toBeDefined();
|
|
409
|
+
expect(Array.isArray(result.data?.[0].items)).toBe(true);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe('Error handling', () => {
|
|
414
|
+
it('should handle SLOT_NOT_FOUND error', async () => {
|
|
415
|
+
mockAdapter
|
|
416
|
+
.onGet('/api/v1/slots/invalid-slot/services')
|
|
417
|
+
.reply(404, {
|
|
418
|
+
success: false,
|
|
419
|
+
error: {
|
|
420
|
+
code: 'SLOT_NOT_FOUND',
|
|
421
|
+
message: 'Slot with id "invalid-slot" not found',
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const result = await serviceClient.getServicesBySlot('invalid-slot');
|
|
426
|
+
|
|
427
|
+
expect(result.success).toBe(false);
|
|
428
|
+
expect(result.error?.code).toBe('SLOT_NOT_FOUND');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('should handle INTERNAL_ERROR', async () => {
|
|
432
|
+
mockAdapter
|
|
433
|
+
.onGet('/api/v1/slots/slot-123/services')
|
|
434
|
+
.reply(500, {
|
|
435
|
+
success: false,
|
|
436
|
+
error: {
|
|
437
|
+
code: 'INTERNAL_ERROR',
|
|
438
|
+
message: 'Internal server error',
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const result = await serviceClient.getServicesBySlot('slot-123');
|
|
443
|
+
|
|
444
|
+
expect(result.success).toBe(false);
|
|
445
|
+
expect(result.error?.code).toBe('INTERNAL_ERROR');
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { AxiosInstance } from 'axios';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ApiResponse,
|
|
4
|
+
Customer,
|
|
5
|
+
CustomerIdentity,
|
|
6
|
+
CustomerWithIdentities,
|
|
7
|
+
CustomerWithBookings,
|
|
8
|
+
NotificationPreferences,
|
|
9
|
+
FindOrCreateCustomerOptions,
|
|
10
|
+
ResolveCustomerOptions,
|
|
11
|
+
} from '../types/api';
|
|
3
12
|
|
|
4
13
|
export class CustomerClient {
|
|
5
14
|
constructor(private client: AxiosInstance) {}
|
|
@@ -58,11 +67,8 @@ export class CustomerClient {
|
|
|
58
67
|
/**
|
|
59
68
|
* Create a new customer
|
|
60
69
|
* POST /api/v1/customers
|
|
61
|
-
*
|
|
62
|
-
* @param data - Customer creation data (tenant_id, name, email required)
|
|
63
|
-
* @returns Created customer
|
|
64
70
|
*/
|
|
65
|
-
async create(data: Partial<Customer>): Promise<ApiResponse<Customer>> {
|
|
71
|
+
async create(data: Partial<Customer> & { tenant_id: string; name: string; email: string }): Promise<ApiResponse<Customer>> {
|
|
66
72
|
const response = await this.client.post<ApiResponse<Customer>>(
|
|
67
73
|
'/api/v1/customers',
|
|
68
74
|
data
|
|
@@ -119,14 +125,25 @@ export class CustomerClient {
|
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
/**
|
|
122
|
-
* Get autocomplete data from existing customers and bookings
|
|
123
|
-
* GET /api/v1/customers/autocomplete
|
|
124
|
-
*
|
|
125
|
-
* @param tenantId - Tenant ID (required)
|
|
126
|
-
* @returns Autocomplete data
|
|
128
|
+
* Get autocomplete data from existing customers and bookings.
|
|
129
|
+
* GET /api/v1/customers/autocomplete?tenantId=:tenantId
|
|
127
130
|
*/
|
|
128
|
-
async autocomplete(tenantId: string): Promise<ApiResponse<
|
|
129
|
-
|
|
131
|
+
async autocomplete(tenantId: string): Promise<ApiResponse<{
|
|
132
|
+
companies: string[];
|
|
133
|
+
jobTitles: string[];
|
|
134
|
+
experienceLevels: string[];
|
|
135
|
+
cities: string[];
|
|
136
|
+
states: string[];
|
|
137
|
+
zipCodes: string[];
|
|
138
|
+
}>> {
|
|
139
|
+
const response = await this.client.get<ApiResponse<{
|
|
140
|
+
companies: string[];
|
|
141
|
+
jobTitles: string[];
|
|
142
|
+
experienceLevels: string[];
|
|
143
|
+
cities: string[];
|
|
144
|
+
states: string[];
|
|
145
|
+
zipCodes: string[];
|
|
146
|
+
}>>(
|
|
130
147
|
'/api/v1/customers/autocomplete',
|
|
131
148
|
{ params: { tenantId } }
|
|
132
149
|
);
|
|
@@ -134,32 +151,115 @@ export class CustomerClient {
|
|
|
134
151
|
}
|
|
135
152
|
|
|
136
153
|
/**
|
|
137
|
-
* Get all bookings for a customer
|
|
138
|
-
* GET /api/v1/customers/bookings
|
|
139
|
-
*
|
|
140
|
-
* @param params - Query parameters (email optional, userId optional, tenantId required)
|
|
141
|
-
* @returns Customer bookings data
|
|
154
|
+
* Get all bookings for a customer, grouped by slot.
|
|
155
|
+
* GET /api/v1/customers/bookings?tenantId=:id
|
|
142
156
|
*/
|
|
143
157
|
async getBookings(params: {
|
|
144
158
|
email?: string;
|
|
145
159
|
userId?: string;
|
|
146
160
|
tenantId: string;
|
|
147
|
-
}): Promise<ApiResponse<{
|
|
148
|
-
|
|
149
|
-
bookings: Booking[];
|
|
150
|
-
groupedBySlot: Record<string, Booking[]>;
|
|
151
|
-
total: number;
|
|
152
|
-
}>> {
|
|
153
|
-
const response = await this.client.get<ApiResponse<{
|
|
154
|
-
customer: Customer | null;
|
|
155
|
-
bookings: Booking[];
|
|
156
|
-
groupedBySlot: Record<string, Booking[]>;
|
|
157
|
-
total: number;
|
|
158
|
-
}>>(
|
|
161
|
+
}): Promise<ApiResponse<CustomerWithBookings>> {
|
|
162
|
+
const response = await this.client.get<ApiResponse<CustomerWithBookings>>(
|
|
159
163
|
'/api/v1/customers/bookings',
|
|
160
164
|
{ params }
|
|
161
165
|
);
|
|
162
166
|
return response.data;
|
|
163
167
|
}
|
|
168
|
+
|
|
169
|
+
// ─── Atomic upsert ────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Atomic upsert: find an existing customer by email within a tenant, or create one.
|
|
173
|
+
* POST /api/v1/customers/find-or-create
|
|
174
|
+
*/
|
|
175
|
+
async findOrCreate(options: FindOrCreateCustomerOptions): Promise<ApiResponse<Customer & { created: boolean }>> {
|
|
176
|
+
const response = await this.client.post<ApiResponse<Customer & { created: boolean }>>(
|
|
177
|
+
'/api/v1/customers/find-or-create',
|
|
178
|
+
options
|
|
179
|
+
);
|
|
180
|
+
return response.data;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve a customer from an IDP identity or email.
|
|
185
|
+
* Tries idpId lookup (customer_users) first, then falls back to email.
|
|
186
|
+
* POST /api/v1/customers/resolve
|
|
187
|
+
*/
|
|
188
|
+
async resolve(options: ResolveCustomerOptions): Promise<ApiResponse<Customer | null>> {
|
|
189
|
+
const response = await this.client.post<ApiResponse<Customer | null>>(
|
|
190
|
+
'/api/v1/customers/resolve',
|
|
191
|
+
options
|
|
192
|
+
);
|
|
193
|
+
return response.data;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── IDP Identity Management ─────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get a customer with their linked IDP identities (customer_users rows).
|
|
200
|
+
* GET /api/v1/customers/:id?include_identities=true
|
|
201
|
+
*/
|
|
202
|
+
async getWithIdentities(id: string): Promise<ApiResponse<CustomerWithIdentities>> {
|
|
203
|
+
const response = await this.client.get<ApiResponse<CustomerWithIdentities>>(
|
|
204
|
+
`/api/v1/customers/${id}`,
|
|
205
|
+
{ params: { include_identities: true } }
|
|
206
|
+
);
|
|
207
|
+
return response.data;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Link an IDP user ID to a customer (creates a customer_users row).
|
|
212
|
+
* POST /api/v1/customers/:id/users
|
|
213
|
+
*/
|
|
214
|
+
async linkUser(
|
|
215
|
+
customerId: string,
|
|
216
|
+
idpId: string,
|
|
217
|
+
options?: { role?: CustomerIdentity['role']; is_primary?: boolean }
|
|
218
|
+
): Promise<ApiResponse<CustomerIdentity>> {
|
|
219
|
+
const response = await this.client.post<ApiResponse<CustomerIdentity>>(
|
|
220
|
+
`/api/v1/customers/${customerId}/users`,
|
|
221
|
+
{ idp_id: idpId, role: options?.role ?? 'member', is_primary: options?.is_primary ?? false }
|
|
222
|
+
);
|
|
223
|
+
return response.data;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Unlink an IDP user from a customer (deletes the customer_users row).
|
|
228
|
+
* DELETE /api/v1/customers/:id/users/:idpId
|
|
229
|
+
*/
|
|
230
|
+
async unlinkUser(customerId: string, idpId: string): Promise<ApiResponse<void>> {
|
|
231
|
+
const response = await this.client.delete<ApiResponse<void>>(
|
|
232
|
+
`/api/v1/customers/${customerId}/users/${encodeURIComponent(idpId)}`
|
|
233
|
+
);
|
|
234
|
+
return response.data;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Notification Preferences ────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get notification preferences for a customer.
|
|
241
|
+
* GET /api/v1/customers/:id/preferences/notifications
|
|
242
|
+
*/
|
|
243
|
+
async getNotificationPreferences(customerId: string): Promise<ApiResponse<NotificationPreferences>> {
|
|
244
|
+
const response = await this.client.get<ApiResponse<NotificationPreferences>>(
|
|
245
|
+
`/api/v1/customers/${customerId}/preferences/notifications`
|
|
246
|
+
);
|
|
247
|
+
return response.data;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Update notification preferences for a customer (partial merge).
|
|
252
|
+
* PATCH /api/v1/customers/:id/preferences/notifications
|
|
253
|
+
*/
|
|
254
|
+
async updateNotificationPreferences(
|
|
255
|
+
customerId: string,
|
|
256
|
+
preferences: Partial<NotificationPreferences>
|
|
257
|
+
): Promise<ApiResponse<NotificationPreferences>> {
|
|
258
|
+
const response = await this.client.patch<ApiResponse<NotificationPreferences>>(
|
|
259
|
+
`/api/v1/customers/${customerId}/preferences/notifications`,
|
|
260
|
+
preferences
|
|
261
|
+
);
|
|
262
|
+
return response.data;
|
|
263
|
+
}
|
|
164
264
|
}
|
|
165
265
|
|