=== UPDATES === - fieldedge: Added apps, tools, main server entry, full rebuild - lightspeed: Added complete src/ directory with tools + apps - squarespace: Full rebuild — new apps, clients, tools, types modules - toast: Full rebuild — api-client, apps, tools, types - touchbistro: Full rebuild — api-client, tools, types, gitignore - servicetitan: Added 4 React UI apps (call-tracking, lead-source-analytics, performance-metrics, schedule-calendar) All servers restructured from single-file to modular architecture.
236 lines
5.4 KiB
TypeScript
236 lines
5.4 KiB
TypeScript
/**
|
|
* TouchBistro API Client
|
|
* Handles authentication, pagination, error handling, and rate limiting
|
|
*/
|
|
|
|
import type { TouchBistroConfig, PaginatedResponse, PaginationParams } from './types.js';
|
|
|
|
export class TouchBistroAPIError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public statusCode?: number,
|
|
public responseBody?: unknown
|
|
) {
|
|
super(message);
|
|
this.name = 'TouchBistroAPIError';
|
|
}
|
|
}
|
|
|
|
export class TouchBistroAPIClient {
|
|
private apiKey: string;
|
|
private baseUrl: string;
|
|
private venueId?: string;
|
|
|
|
constructor(config: TouchBistroConfig) {
|
|
this.apiKey = config.apiKey;
|
|
this.baseUrl = config.baseUrl || 'https://api.touchbistro.com/v1';
|
|
this.venueId = config.venueId;
|
|
}
|
|
|
|
/**
|
|
* Make an authenticated request to the TouchBistro API
|
|
*/
|
|
private async request<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {}
|
|
): Promise<T> {
|
|
const url = `${this.baseUrl}${endpoint}`;
|
|
|
|
const headers: HeadersInit = {
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
...options.headers,
|
|
};
|
|
|
|
if (this.venueId) {
|
|
headers['X-Venue-ID'] = this.venueId;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers,
|
|
});
|
|
|
|
// Handle rate limiting
|
|
if (response.status === 429) {
|
|
const retryAfter = response.headers.get('Retry-After');
|
|
throw new TouchBistroAPIError(
|
|
`Rate limited. Retry after ${retryAfter} seconds`,
|
|
429,
|
|
{ retryAfter }
|
|
);
|
|
}
|
|
|
|
// Parse response body
|
|
const contentType = response.headers.get('content-type');
|
|
let data: unknown;
|
|
|
|
if (contentType?.includes('application/json')) {
|
|
data = await response.json();
|
|
} else {
|
|
data = await response.text();
|
|
}
|
|
|
|
// Handle errors
|
|
if (!response.ok) {
|
|
throw new TouchBistroAPIError(
|
|
`TouchBistro API error: ${response.statusText}`,
|
|
response.status,
|
|
data
|
|
);
|
|
}
|
|
|
|
return data as T;
|
|
} catch (error) {
|
|
if (error instanceof TouchBistroAPIError) {
|
|
throw error;
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
throw new TouchBistroAPIError(
|
|
`Network error: ${error.message}`,
|
|
undefined,
|
|
error
|
|
);
|
|
}
|
|
|
|
throw new TouchBistroAPIError('Unknown error occurred');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET request
|
|
*/
|
|
async get<T>(endpoint: string, params?: Record<string, unknown>): Promise<T> {
|
|
const queryString = params
|
|
? '?' + new URLSearchParams(
|
|
Object.entries(params)
|
|
.filter(([_, v]) => v !== undefined && v !== null)
|
|
.map(([k, v]) => [k, String(v)])
|
|
).toString()
|
|
: '';
|
|
|
|
return this.request<T>(`${endpoint}${queryString}`, {
|
|
method: 'GET',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* POST request
|
|
*/
|
|
async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'POST',
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* PUT request
|
|
*/
|
|
async put<T>(endpoint: string, body?: unknown): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'PUT',
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* PATCH request
|
|
*/
|
|
async patch<T>(endpoint: string, body?: unknown): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'PATCH',
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* DELETE request
|
|
*/
|
|
async delete<T>(endpoint: string): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Paginated GET request - automatically handles pagination
|
|
*/
|
|
async getPaginated<T>(
|
|
endpoint: string,
|
|
params: PaginationParams & Record<string, unknown> = {}
|
|
): Promise<PaginatedResponse<T>> {
|
|
const { page = 1, limit = 50, ...otherParams } = params;
|
|
|
|
const response = await this.get<{
|
|
data: T[];
|
|
pagination?: {
|
|
total?: number;
|
|
page?: number;
|
|
limit?: number;
|
|
hasMore?: boolean;
|
|
nextPage?: string;
|
|
};
|
|
}>(endpoint, {
|
|
page,
|
|
limit,
|
|
...otherParams,
|
|
});
|
|
|
|
// Normalize pagination response
|
|
return {
|
|
data: response.data || [],
|
|
pagination: {
|
|
total: response.pagination?.total || response.data?.length || 0,
|
|
page: response.pagination?.page || page,
|
|
limit: response.pagination?.limit || limit,
|
|
hasMore: response.pagination?.hasMore || false,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch all pages automatically
|
|
*/
|
|
async getAllPages<T>(
|
|
endpoint: string,
|
|
params: Record<string, unknown> = {},
|
|
maxPages = 10
|
|
): Promise<T[]> {
|
|
const allData: T[] = [];
|
|
let page = 1;
|
|
let hasMore = true;
|
|
|
|
while (hasMore && page <= maxPages) {
|
|
const response = await this.getPaginated<T>(endpoint, {
|
|
...params,
|
|
page,
|
|
limit: 100,
|
|
});
|
|
|
|
allData.push(...response.data);
|
|
hasMore = response.pagination.hasMore;
|
|
page++;
|
|
}
|
|
|
|
return allData;
|
|
}
|
|
|
|
/**
|
|
* Health check
|
|
*/
|
|
async healthCheck(): Promise<{ status: string; timestamp: string }> {
|
|
try {
|
|
return await this.get('/health');
|
|
} catch (error) {
|
|
return {
|
|
status: 'error',
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
}
|
|
}
|