@slotchain/sdk 1.0.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.
@@ -0,0 +1,148 @@
1
+ import { AxiosInstance } from 'axios';
2
+ import { ApiResponse, Tenant, TenantFullConfig } from '../types/api';
3
+
4
+ export class TenantClient {
5
+ constructor(private client: AxiosInstance) {}
6
+
7
+ /**
8
+ * Get tenant configuration by slug
9
+ * GET /api/v1/tenant-config/:slug
10
+ */
11
+ async getBySlug(slug: string): Promise<ApiResponse<Tenant>> {
12
+ // TODO: Replace with actual endpoint when available
13
+ const response = await this.client.get<ApiResponse<Tenant>>(
14
+ `/api/v1/tenant-config/${slug}`
15
+ );
16
+ return response.data;
17
+ }
18
+
19
+ /**
20
+ * Get tenant by ID
21
+ * GET /api/v1/tenants/:id
22
+ */
23
+ async getById(id: string): Promise<ApiResponse<Tenant>> {
24
+ // TODO: Replace with actual endpoint when available
25
+ const response = await this.client.get<ApiResponse<Tenant>>(
26
+ `/api/v1/tenants/${id}`
27
+ );
28
+ return response.data;
29
+ }
30
+
31
+ /**
32
+ * List all tenants
33
+ * GET /api/v1/tenants
34
+ */
35
+ async list(params?: {
36
+ page?: number;
37
+ limit?: number;
38
+ }): Promise<ApiResponse<Tenant[]>> {
39
+ // TODO: Replace with actual endpoint when available
40
+ const response = await this.client.get<ApiResponse<Tenant[]>>(
41
+ '/api/v1/tenants',
42
+ { params }
43
+ );
44
+ return response.data;
45
+ }
46
+
47
+ /**
48
+ * Create a new tenant
49
+ * POST /api/v1/tenants
50
+ */
51
+ async create(data: Partial<Tenant>): Promise<ApiResponse<Tenant>> {
52
+ // TODO: Replace with actual endpoint when available
53
+ const response = await this.client.post<ApiResponse<Tenant>>(
54
+ '/api/v1/tenants',
55
+ data
56
+ );
57
+ return response.data;
58
+ }
59
+
60
+ /**
61
+ * Update tenant by ID
62
+ * PATCH /api/v1/tenants/:id
63
+ */
64
+ async update(id: string, data: Partial<Tenant>): Promise<ApiResponse<Tenant>> {
65
+ // TODO: Replace with actual endpoint when available
66
+ const response = await this.client.patch<ApiResponse<Tenant>>(
67
+ `/api/v1/tenants/${id}`,
68
+ data
69
+ );
70
+ return response.data;
71
+ }
72
+
73
+ /**
74
+ * Delete tenant by ID
75
+ * DELETE /api/v1/tenants/:id
76
+ */
77
+ async delete(id: string): Promise<ApiResponse<void>> {
78
+ // TODO: Replace with actual endpoint when available
79
+ const response = await this.client.delete<ApiResponse<void>>(
80
+ `/api/v1/tenants/${id}`
81
+ );
82
+ return response.data;
83
+ }
84
+
85
+ /**
86
+ * Get full tenant configuration including branding, services, and service items
87
+ * GET /api/v1/tenants/:slug
88
+ *
89
+ * This method returns the complete tenant configuration including:
90
+ * - Tenant basic info (id, slug, name)
91
+ * - Branding configuration (logo, colors, etc.)
92
+ * - All services associated with the tenant (with nested service items)
93
+ * - Feature flags and subscription level
94
+ *
95
+ * Note: Service items are nested within each service object (not as a separate top-level array).
96
+ * The endpoint returns full config by default (includes services with nested items).
97
+ *
98
+ * @param slug - Tenant slug identifier (required, non-nullable)
99
+ * @param includeServices - Include services with nested items (default: true)
100
+ * @returns Promise resolving to ApiResponse<TenantFullConfig>
101
+ *
102
+ * @example
103
+ * ```typescript
104
+ * const result = await slotly.tenant.getFullConfig('bright-accountants');
105
+ * if (result.success && result.data) {
106
+ * const config = result.data;
107
+ * console.log('Tenant:', config.name);
108
+ * console.log('Logo:', config.branding?.logoUrl);
109
+ * console.log('Services:', config.services.length);
110
+ *
111
+ * // Service items are nested within each service
112
+ * config.services.forEach(service => {
113
+ * console.log(`Service: ${service.name}`);
114
+ * if (service.serviceItems) {
115
+ * console.log(` Items: ${service.serviceItems.length}`);
116
+ * }
117
+ * });
118
+ * }
119
+ * ```
120
+ *
121
+ * **Guaranteed fields (non-nullable):**
122
+ * - `id`: string
123
+ * - `slug`: string
124
+ * - `name`: string
125
+ * - `services`: ServiceWithItems[] (may be empty array, each service may have nested `serviceItems`)
126
+ *
127
+ * **Optional fields:**
128
+ * - `branding`: TenantBranding | undefined
129
+ * - `services[].serviceItems`: ServiceItem[] | undefined (nested within each service)
130
+ * - `featuresEnabled`: string[] | undefined
131
+ * - `subscriptionLevel`: string | undefined
132
+ * - `config`: Record<string, any> | undefined
133
+ *
134
+ * **Error Codes:**
135
+ * - `TENANT_NOT_FOUND` - Tenant with slug not found
136
+ * - `AUTH_ERROR` - Authentication failed
137
+ * - `PERMISSION_DENIED` - Insufficient permissions
138
+ * - `NETWORK_ERROR` - Network request failed
139
+ */
140
+ async getFullConfig(slug: string, includeServices: boolean = true): Promise<ApiResponse<TenantFullConfig>> {
141
+ const response = await this.client.get<ApiResponse<TenantFullConfig>>(
142
+ `/api/v1/tenants/${slug}`,
143
+ { params: { includeServices } }
144
+ );
145
+ return response.data;
146
+ }
147
+ }
148
+
package/src/errors.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Custom error classes for Slotly SDK
3
+ */
4
+
5
+ export class SlotlyApiError extends Error {
6
+ constructor(
7
+ public code: string,
8
+ message: string,
9
+ public statusCode?: number,
10
+ public details?: any
11
+ ) {
12
+ super(message);
13
+ this.name = 'SlotlyApiError';
14
+ Object.setPrototypeOf(this, SlotlyApiError.prototype);
15
+ }
16
+ }
17
+
18
+ export class SlotlyAuthError extends SlotlyApiError {
19
+ constructor(message: string, details?: any) {
20
+ super('AUTH_ERROR', message, 401, details);
21
+ this.name = 'SlotlyAuthError';
22
+ Object.setPrototypeOf(this, SlotlyAuthError.prototype);
23
+ }
24
+ }
25
+
26
+ export class SlotlyNetworkError extends Error {
27
+ constructor(message: string, public originalError?: any) {
28
+ super(message);
29
+ this.name = 'SlotlyNetworkError';
30
+ Object.setPrototypeOf(this, SlotlyNetworkError.prototype);
31
+ }
32
+ }
33
+
34
+ export class SlotlyConfigurationError extends Error {
35
+ constructor(message: string, public details?: any) {
36
+ super(message);
37
+ this.name = 'SlotlyConfigurationError';
38
+ Object.setPrototypeOf(this, SlotlyConfigurationError.prototype);
39
+ }
40
+ }
41
+
package/src/index.ts ADDED
@@ -0,0 +1,296 @@
1
+ import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
2
+ import { TenantClient } from './clients/tenant-client';
3
+ import { BookingClient } from './clients/booking-client';
4
+ import { ServiceClient } from './clients/service-client';
5
+ import { ServiceItemClient } from './clients/service-item-client';
6
+ import { SlotClient } from './clients/slot-client';
7
+ import { CustomerClient } from './clients/customer-client';
8
+ import { FlowClient } from './clients/flow-client';
9
+ import { StudioClient } from './clients/studio-client';
10
+ import { NotificationClient } from './clients/notification-client';
11
+ import { DataQualityClient } from './clients/data-quality-client';
12
+ import { setupRequestInterceptor, setupResponseInterceptor, setupErrorInterceptor } from './interceptors';
13
+ import { setupRetryInterceptor } from './retry';
14
+ import { SlotlyConfigurationError } from './errors';
15
+
16
+ // Re-export types
17
+ export type {
18
+ ApiResponse,
19
+ Tenant,
20
+ TenantFullConfig,
21
+ TenantBranding,
22
+ Booking,
23
+ Service,
24
+ ServiceItem,
25
+ ServiceWithItems,
26
+ ServiceItemWithService,
27
+ Category,
28
+ Slot,
29
+ Customer,
30
+ Flow,
31
+ FlowStep,
32
+ Studio,
33
+ Notification,
34
+ DataQualityIssue,
35
+ } from './types/api';
36
+
37
+ // Re-export errors
38
+ export {
39
+ SlotlyApiError,
40
+ SlotlyAuthError,
41
+ SlotlyNetworkError,
42
+ SlotlyConfigurationError,
43
+ } from './errors';
44
+
45
+ // Re-export middleware
46
+ export {
47
+ validateSlotlyRequest,
48
+ getSlotlyContext,
49
+ type SlotlyAuthContext,
50
+ type SlotlyRequest,
51
+ } from './middleware/validateSlotlyRequest';
52
+
53
+ export interface SlotlyClientOptions {
54
+ /**
55
+ * Required: Function that returns the API key or service token.
56
+ * This must return a non-empty string.
57
+ */
58
+ getClientKey: () => Promise<string>;
59
+ /**
60
+ * Optional: Function that returns a Clerk JWT for user traceability.
61
+ * If provided and returns a token, it will be included in the Authorization header.
62
+ */
63
+ getUserToken?: () => Promise<string | null>;
64
+ /**
65
+ * Optional: Custom base URL for the API.
66
+ * Defaults to process.env.SLOTLY_API_URL or 'https://api.slotly.dev'
67
+ */
68
+ baseURL?: string;
69
+ /**
70
+ * Optional: Additional headers to include with every request.
71
+ */
72
+ headers?: Record<string, string>;
73
+ /**
74
+ * Optional: Retry configuration.
75
+ */
76
+ retry?: {
77
+ maxRetries?: number;
78
+ retryDelay?: number;
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Unified Slotly API client
84
+ * Provides typed access to all Slotly API domains
85
+ *
86
+ * Note: RegisterClient has been moved to @slotly/studio-sdk
87
+ * for studio-specific booking register operations.
88
+ */
89
+ export interface SlotlyApi {
90
+ tenant: TenantClient;
91
+ booking: BookingClient;
92
+ service: ServiceClient;
93
+ serviceItem: ServiceItemClient;
94
+ slot: SlotClient;
95
+ customer: CustomerClient;
96
+ flow: FlowClient;
97
+ studio: StudioClient;
98
+ notification: NotificationClient;
99
+ dataQuality: DataQualityClient;
100
+ }
101
+
102
+ const defaultBaseURL = typeof process !== 'undefined' && process.env?.SLOTLY_API_URL
103
+ ? process.env.SLOTLY_API_URL
104
+ : 'https://api.slotly.dev';
105
+
106
+ /**
107
+ * Creates a new isolated Axios instance with authentication interceptors.
108
+ * Client isolation per instantiation ensures:
109
+ * - No cross-contamination between different auth contexts
110
+ * - SSR safety (each request gets its own client instance)
111
+ * - Proper isolation for scoped keys/roles in the future
112
+ */
113
+ function createAuthenticatedClient(options: SlotlyClientOptions): AxiosInstance {
114
+ const client = axios.create({
115
+ baseURL: options.baseURL || defaultBaseURL,
116
+ headers: {
117
+ 'Content-Type': 'application/json',
118
+ ...options.headers,
119
+ },
120
+ timeout: 30000, // 30 seconds
121
+ });
122
+
123
+ // Request interceptor: Add authentication headers
124
+ // This interceptor runs once per request and fetches fresh tokens
125
+ client.interceptors.request.use(
126
+ async (config: InternalAxiosRequestConfig) => {
127
+ try {
128
+ // Fetch both credentials in parallel for performance
129
+ const [clientKeyPromise, userTokenPromise] = [
130
+ options.getClientKey(),
131
+ options.getUserToken?.() ?? Promise.resolve(null),
132
+ ];
133
+
134
+ const [clientKey, userToken] = await Promise.all([
135
+ clientKeyPromise,
136
+ userTokenPromise,
137
+ ]);
138
+
139
+ // Validate that clientKey is provided and non-empty
140
+ if (!clientKey || typeof clientKey !== 'string' || clientKey.trim().length === 0) {
141
+ throw new SlotlyConfigurationError(
142
+ 'getClientKey() must return a non-empty string. API key is required for all requests.',
143
+ { received: clientKey }
144
+ );
145
+ }
146
+
147
+ // Always set the API key header
148
+ config.headers['x-slotly-api-key'] = clientKey;
149
+
150
+ // Conditionally set Authorization header if user token is provided
151
+ if (userToken && typeof userToken === 'string' && userToken.trim().length > 0) {
152
+ config.headers['Authorization'] = `Bearer ${userToken}`;
153
+ }
154
+
155
+ return config;
156
+ } catch (error) {
157
+ // If it's already a SlotlyConfigurationError, re-throw it
158
+ if (error instanceof SlotlyConfigurationError) {
159
+ throw error;
160
+ }
161
+
162
+ // Handle errors from getClientKey() or getUserToken()
163
+ // If getClientKey fails, this is critical
164
+ if (error && typeof error === 'object' && 'message' in error) {
165
+ // eslint-disable-next-line no-console
166
+ console.error('[Slotly SDK] Failed to retrieve authentication credentials:', error);
167
+
168
+ throw new SlotlyConfigurationError(
169
+ `Failed to retrieve authentication credentials: ${error.message}`,
170
+ { originalError: error }
171
+ );
172
+ }
173
+
174
+ throw error;
175
+ }
176
+ },
177
+ (error: Error) => Promise.reject(error)
178
+ );
179
+
180
+ // Request interceptor: Logging
181
+ client.interceptors.request.use(setupRequestInterceptor());
182
+
183
+ // Response interceptor: Logging and error shaping
184
+ client.interceptors.response.use(
185
+ (response: any) => setupResponseInterceptor()(response),
186
+ (error: any) => setupErrorInterceptor()(error)
187
+ );
188
+
189
+ // Error interceptor: Retry logic
190
+ client.interceptors.response.use(
191
+ (response: any) => response,
192
+ setupRetryInterceptor({
193
+ maxRetries: options.retry?.maxRetries ?? 3,
194
+ retryDelay: options.retry?.retryDelay ?? 1000,
195
+ })
196
+ );
197
+
198
+ return client;
199
+ }
200
+
201
+ /**
202
+ * Initialize and configure a new Slotly API client with dual authentication.
203
+ *
204
+ * This function creates an isolated Axios instance per call, ensuring:
205
+ * - SSR safety: Each request/context gets its own client instance
206
+ * - No cross-contamination: Different auth contexts don't interfere
207
+ * - Scalable: Supports future expansion for scoped keys, roles, or per-request tokens
208
+ *
209
+ * Authentication model:
210
+ * - **getClientKey()**: Required. Always sets `x-slotly-api-key` header.
211
+ * Identifies the SDK/application making the request.
212
+ * - **getUserToken()**: Optional. Sets `Authorization: Bearer <token>` header if provided.
213
+ * Provides user traceability and authorization scoping.
214
+ *
215
+ * @example Basic usage with API key (public SDK client):
216
+ * ```ts
217
+ * import { useSlotly } from '@slotly/sdk';
218
+ *
219
+ * const slotly = useSlotly({
220
+ * getClientKey: async () => process.env.SLOTLY_SDK_KEY!,
221
+ * });
222
+ * ```
223
+ *
224
+ * @example Server-side with Clerk (authenticated, user-scoped):
225
+ * ```ts
226
+ * import { getToken } from '@clerk/clerk-sdk-node';
227
+ * import { useSlotly } from '@slotly/sdk';
228
+ *
229
+ * // In an API route or server action
230
+ * const slotly = useSlotly({
231
+ * getClientKey: async () => process.env.SLOTLY_SDK_KEY!,
232
+ * getUserToken: async () => await getToken(req),
233
+ * });
234
+ * ```
235
+ *
236
+ * @example Client-side with Clerk (Next.js):
237
+ * ```ts
238
+ * import { useAuth } from '@clerk/nextjs';
239
+ * import { useSlotly } from '@slotly/sdk';
240
+ *
241
+ * function MyComponent() {
242
+ * const { getToken } = useAuth();
243
+ *
244
+ * const slotly = useSlotly({
245
+ * getClientKey: async () => process.env.NEXT_PUBLIC_SLOTLY_SDK_KEY!,
246
+ * getUserToken: getToken,
247
+ * });
248
+ * }
249
+ * ```
250
+ *
251
+ * @example Future: Scoped keys or role-based access
252
+ * ```ts
253
+ * // Future enhancement: Per-request scoping
254
+ * const slotly = useSlotly({
255
+ * getClientKey: async () => getScopedKey(role, tenantId),
256
+ * getUserToken: async () => getToken(),
257
+ * });
258
+ * ```
259
+ *
260
+ * @throws {SlotlyConfigurationError} If getClientKey is missing, returns null, undefined, or empty string.
261
+ */
262
+ export const useSlotly = (options: SlotlyClientOptions): SlotlyApi => {
263
+ // Validate required options
264
+ if (!options || typeof options !== 'object') {
265
+ throw new SlotlyConfigurationError(
266
+ 'useSlotly() requires an options object with getClientKey function'
267
+ );
268
+ }
269
+
270
+ if (!options.getClientKey || typeof options.getClientKey !== 'function') {
271
+ throw new SlotlyConfigurationError(
272
+ 'getClientKey must be a function that returns Promise<string>'
273
+ );
274
+ }
275
+
276
+ // Create a new isolated client instance for this configuration
277
+ // This ensures SSR safety and prevents cross-contamination
278
+ const client = createAuthenticatedClient(options);
279
+
280
+ // Return the unified API object with all domain clients
281
+ return {
282
+ tenant: new TenantClient(client),
283
+ booking: new BookingClient(client),
284
+ service: new ServiceClient(client),
285
+ serviceItem: new ServiceItemClient(client),
286
+ slot: new SlotClient(client),
287
+ customer: new CustomerClient(client),
288
+ flow: new FlowClient(client),
289
+ studio: new StudioClient(client),
290
+ notification: new NotificationClient(client),
291
+ dataQuality: new DataQualityClient(client),
292
+ };
293
+ };
294
+
295
+ // Default export for convenience
296
+ export default useSlotly;
@@ -0,0 +1,81 @@
1
+ import { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
2
+ import { SlotlyApiError, SlotlyAuthError, SlotlyNetworkError } from './errors';
3
+ import { ApiResponse } from './types/api';
4
+
5
+ /**
6
+ * Request interceptor for logging
7
+ */
8
+ export function setupRequestInterceptor() {
9
+ return (config: InternalAxiosRequestConfig) => {
10
+ // TODO: Add request logging if needed
11
+ // console.log(`[Slotly SDK] ${config.method?.toUpperCase()} ${config.url}`);
12
+ return config;
13
+ };
14
+ }
15
+
16
+ /**
17
+ * Response interceptor for error shaping and logging
18
+ */
19
+ export function setupResponseInterceptor() {
20
+ return (response: AxiosResponse) => {
21
+ // TODO: Add response logging if needed
22
+ // console.log(`[Slotly SDK] Response: ${response.status} ${response.config.url}`);
23
+ return response;
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Error interceptor for shaping errors into SlotlyApiError
29
+ */
30
+ export function setupErrorInterceptor() {
31
+ return (error: AxiosError<ApiResponse<any>>) => {
32
+ // Network errors (no response)
33
+ if (!error.response) {
34
+ const networkError = new SlotlyNetworkError(
35
+ error.message || 'Network error occurred',
36
+ error
37
+ );
38
+ // TODO: Add error logging
39
+ // console.error('[Slotly SDK] Network error:', networkError);
40
+ return Promise.reject(networkError);
41
+ }
42
+
43
+ const { status, data } = error.response;
44
+
45
+ // Handle authentication errors
46
+ if (status === 401 || status === 403) {
47
+ const authError = new SlotlyAuthError(
48
+ data?.error?.message || 'Authentication failed',
49
+ data?.error?.details
50
+ );
51
+ // TODO: Add error logging
52
+ // console.error('[Slotly SDK] Auth error:', authError);
53
+ return Promise.reject(authError);
54
+ }
55
+
56
+ // Handle API errors with structured error format
57
+ if (data?.error) {
58
+ const apiError = new SlotlyApiError(
59
+ data.error.code || 'API_ERROR',
60
+ data.error.message || 'An error occurred',
61
+ status,
62
+ data.error.details
63
+ );
64
+ // TODO: Add error logging
65
+ // console.error('[Slotly SDK] API error:', apiError);
66
+ return Promise.reject(apiError);
67
+ }
68
+
69
+ // Handle other HTTP errors
70
+ const apiError = new SlotlyApiError(
71
+ 'HTTP_ERROR',
72
+ error.message || `Request failed with status ${status}`,
73
+ status,
74
+ data
75
+ );
76
+ // TODO: Add error logging
77
+ // console.error('[Slotly SDK] HTTP error:', apiError);
78
+ return Promise.reject(apiError);
79
+ };
80
+ }
81
+