feat(provider-api-client): ✨ Add TypeScript types and test coverage for analytics, blog, contact, roster, tour, and touring endpoints
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
b915344fe7
commit
cd836aa2ef
20 changed files with 500 additions and 0 deletions
37
codebase/@packages/@lilith/provider-api-client/README.md
Normal file
37
codebase/@packages/@lilith/provider-api-client/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# @lilith/provider-api-client
|
||||
|
||||
Type-safe HTTP client for the provider API public surface.
|
||||
|
||||
## Base URL resolution
|
||||
|
||||
1. `import.meta.env.VITE_QUINN_API_BASE_URL` (Vite env override)
|
||||
2. `localhost` / `127.0.0.1` → `http://localhost:3040`
|
||||
3. Otherwise → `https://api.quinn.apricot.local`
|
||||
|
||||
## Exports
|
||||
|
||||
### Types
|
||||
|
||||
`BlogPostSummary`, `BlogPost`, `ContactChannel`, `ContactPayload`, `ContactResult`, `TouringPayload`, `TouringResult`, `TrackAvailability`, `RosterApplicationPayload`, `RosterApplicationResult`, `AnalyticsEventPayload`
|
||||
|
||||
### Error classes
|
||||
|
||||
`ApiError`, `NotFoundError`, `RateLimitError`, `ValidationError`, `NetworkError`
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Function | Method | Path |
|
||||
|---|---|---|
|
||||
| `fetchBlogPosts()` | GET | `/www/blog` |
|
||||
| `fetchBlogPost(slug)` | GET | `/www/blog/:slug` |
|
||||
| `submitContact(payload)` | POST | `/public/contact` |
|
||||
| `subscribeToTouring(payload)` | POST | `/public/touring/subscribe` |
|
||||
| `fetchAvailability()` | GET | `/public/roster/availability` |
|
||||
| `fetchAvailabilityBySlug(slug)` | GET | `/public/roster/availability/:slug` |
|
||||
| `submitRosterApplication(payload)` | POST | `/public/roster/apply` |
|
||||
| `trackEvent(path, payload)` | POST | `/public/analytics/track/*` (fire-and-forget) |
|
||||
|
||||
### Low-level
|
||||
|
||||
`apiFetch<T>(url, init?)` — typed fetch with automatic error mapping
|
||||
`resolveBaseUrl()` — base URL resolution
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, it, expect, afterEach } from 'bun:test';
|
||||
import { resolveBaseUrl } from '../base-url';
|
||||
|
||||
describe('resolveBaseUrl', () => {
|
||||
afterEach(() => {
|
||||
(globalThis as Record<string, unknown>)['window'] = undefined;
|
||||
});
|
||||
|
||||
it('returns apricot.local when no window (server/test env)', () => {
|
||||
expect(resolveBaseUrl()).toBe('https://api.quinn.apricot.local');
|
||||
});
|
||||
|
||||
it('returns localhost:3040 when window.location.hostname is localhost', () => {
|
||||
(globalThis as Record<string, unknown>)['window'] = {
|
||||
location: { hostname: 'localhost' },
|
||||
};
|
||||
expect(resolveBaseUrl()).toBe('http://localhost:3040');
|
||||
});
|
||||
|
||||
it('returns localhost:3040 when window.location.hostname is 127.0.0.1', () => {
|
||||
(globalThis as Record<string, unknown>)['window'] = {
|
||||
location: { hostname: '127.0.0.1' },
|
||||
};
|
||||
expect(resolveBaseUrl()).toBe('http://localhost:3040');
|
||||
});
|
||||
|
||||
it('returns apricot.local for other hostnames', () => {
|
||||
(globalThis as Record<string, unknown>)['window'] = {
|
||||
location: { hostname: 'transquinnftw.com' },
|
||||
};
|
||||
expect(resolveBaseUrl()).toBe('https://api.quinn.apricot.local');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
||||
import { apiFetch, NotFoundError, RateLimitError, ValidationError, NetworkError, ApiError } from '../client';
|
||||
|
||||
function mockFetch(status: number, body: unknown): void {
|
||||
(globalThis as Record<string, unknown>)['fetch'] = mock(async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}));
|
||||
}
|
||||
|
||||
function mockFetchThrow(err: Error): void {
|
||||
(globalThis as Record<string, unknown>)['fetch'] = mock(async () => { throw err; });
|
||||
}
|
||||
|
||||
describe('apiFetch', () => {
|
||||
it('returns parsed JSON on 200', async () => {
|
||||
mockFetch(200, { id: 1 });
|
||||
const result = await apiFetch<{ id: number }>('http://test/');
|
||||
expect(result).toEqual({ id: 1 });
|
||||
});
|
||||
|
||||
it('throws NotFoundError on 404', async () => {
|
||||
mockFetch(404, {});
|
||||
await expect(apiFetch('http://test/')).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws RateLimitError on 429', async () => {
|
||||
mockFetch(429, {});
|
||||
await expect(apiFetch('http://test/')).rejects.toBeInstanceOf(RateLimitError);
|
||||
});
|
||||
|
||||
it('throws ValidationError on 422', async () => {
|
||||
mockFetch(422, { error: 'bad input' });
|
||||
await expect(apiFetch('http://test/')).rejects.toBeInstanceOf(ValidationError);
|
||||
});
|
||||
|
||||
it('throws ApiError on 500', async () => {
|
||||
mockFetch(500, { error: 'server error' });
|
||||
await expect(apiFetch('http://test/')).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
it('throws NetworkError when fetch throws', async () => {
|
||||
mockFetchThrow(new TypeError('Failed to fetch'));
|
||||
await expect(apiFetch('http://test/')).rejects.toBeInstanceOf(NetworkError);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
type ViteImportMeta = ImportMeta & {
|
||||
readonly env?: Readonly<{ VITE_QUINN_API_BASE_URL?: string }>;
|
||||
};
|
||||
|
||||
export function resolveBaseUrl(): string {
|
||||
const viteEnv = (import.meta as ViteImportMeta).env;
|
||||
if (viteEnv?.VITE_QUINN_API_BASE_URL) return viteEnv.VITE_QUINN_API_BASE_URL;
|
||||
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
||||
) {
|
||||
return 'http://localhost:3040';
|
||||
}
|
||||
|
||||
return 'https://api.quinn.apricot.local';
|
||||
}
|
||||
75
codebase/@packages/@lilith/provider-api-client/src/client.ts
Normal file
75
codebase/@packages/@lilith/provider-api-client/src/client.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends ApiError {
|
||||
constructor(message = 'Not found') {
|
||||
super(message, 404, 'not_found');
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends ApiError {
|
||||
constructor(message = 'Too many requests. Please wait a moment before trying again.') {
|
||||
super(message, 429, 'rate_limited');
|
||||
this.name = 'RateLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends ApiError {
|
||||
constructor(message: string) {
|
||||
super(message, 422, 'validation_error');
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends Error {
|
||||
constructor(message: string, public readonly cause?: unknown) {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, init);
|
||||
} catch (err) {
|
||||
throw new NetworkError(
|
||||
`Network error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
if (res.status === 404) throw new NotFoundError();
|
||||
if (res.status === 429) throw new RateLimitError();
|
||||
|
||||
if (res.status === 422) {
|
||||
let body: { error?: string; message?: string } = {};
|
||||
try {
|
||||
body = (await res.json()) as { error?: string; message?: string };
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new ValidationError(body.error ?? body.message ?? 'Validation failed');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let body: { error?: string; message?: string } = {};
|
||||
try {
|
||||
body = (await res.json()) as { error?: string; message?: string };
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new ApiError(body.error ?? body.message ?? `Request failed: ${res.status}`, res.status);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { resolveBaseUrl } from '../base-url';
|
||||
import type { AnalyticsEventPayload } from '../types/analytics';
|
||||
|
||||
export function trackEvent(eventPath: string, payload: AnalyticsEventPayload): void {
|
||||
const url = `${resolveBaseUrl()}/public/analytics/track/${eventPath.replace(/^\//, '')}`;
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {
|
||||
// fire-and-forget — analytics failures are non-fatal
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { apiFetch, NotFoundError } from '../client';
|
||||
import type { BlogPost, BlogPostSummary } from '../types/blog';
|
||||
import { resolveBaseUrl } from '../base-url';
|
||||
|
||||
export async function fetchBlogPosts(): Promise<readonly BlogPostSummary[]> {
|
||||
try {
|
||||
return await apiFetch<readonly BlogPostSummary[]>(`${resolveBaseUrl()}/www/blog`);
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) throw err;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBlogPost(slug: string): Promise<BlogPost> {
|
||||
try {
|
||||
return await apiFetch<BlogPost>(`${resolveBaseUrl()}/www/blog/${encodeURIComponent(slug)}`);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { apiFetch, RateLimitError, ApiError, NetworkError } from '../client';
|
||||
import type { ContactPayload, ContactResult } from '../types/contact';
|
||||
import { resolveBaseUrl } from '../base-url';
|
||||
|
||||
export async function submitContact(payload: ContactPayload): Promise<ContactResult> {
|
||||
try {
|
||||
const data = await apiFetch<{ id?: number; status?: string; error?: string }>(`${resolveBaseUrl()}/public/contact`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return { success: true, id: data.id ?? 0 };
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitError) throw err;
|
||||
if (err instanceof NetworkError) {
|
||||
return { success: false, error: 'Network error. Please check your connection and try again.' };
|
||||
}
|
||||
if (err instanceof ApiError) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
return { success: false, error: 'Something went wrong. Please try again.' };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { apiFetch, ApiError, ValidationError } from '../client';
|
||||
import type { TrackAvailability, RosterApplicationPayload, RosterApplicationResult } from '../types/roster';
|
||||
import { resolveBaseUrl } from '../base-url';
|
||||
|
||||
export async function fetchAvailability(): Promise<readonly TrackAvailability[]> {
|
||||
try {
|
||||
return await apiFetch<readonly TrackAvailability[]>(`${resolveBaseUrl()}/public/roster/availability`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAvailabilityBySlug(slug: string): Promise<TrackAvailability> {
|
||||
try {
|
||||
return await apiFetch<TrackAvailability>(`${resolveBaseUrl()}/public/roster/availability/${encodeURIComponent(slug)}`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitRosterApplication(
|
||||
payload: RosterApplicationPayload,
|
||||
): Promise<RosterApplicationResult> {
|
||||
try {
|
||||
return await apiFetch<RosterApplicationResult>(`${resolveBaseUrl()}/public/roster/apply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) throw err;
|
||||
if (err instanceof ApiError) throw err;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { apiFetch, ApiError, NetworkError } from '../client';
|
||||
import { resolveBaseUrl } from '../base-url';
|
||||
import type { TourStatus, TourStop } from '../types/tour';
|
||||
|
||||
export interface TourStatusOptions {
|
||||
readonly providerSlug?: string;
|
||||
readonly today?: string; // YYYY-MM-DD — optional override for testing
|
||||
}
|
||||
|
||||
function query(opts?: TourStatusOptions): string {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.providerSlug) params.set('provider', opts.providerSlug);
|
||||
if (opts?.today) params.set('today', opts.today);
|
||||
const q = params.toString();
|
||||
return q ? `?${q}` : '';
|
||||
}
|
||||
|
||||
export async function fetchTourStatus(opts?: TourStatusOptions): Promise<TourStatus> {
|
||||
try {
|
||||
return await apiFetch<TourStatus>(`${resolveBaseUrl()}/www/tour/status${query(opts)}`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError || err instanceof NetworkError) throw err;
|
||||
throw new NetworkError('Failed to fetch tour status', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTourStops(opts?: { providerSlug?: string }): Promise<readonly TourStop[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.providerSlug) params.set('provider', opts.providerSlug);
|
||||
const qs = params.toString();
|
||||
try {
|
||||
return await apiFetch<readonly TourStop[]>(
|
||||
`${resolveBaseUrl()}/www/tour/stops${qs ? '?' + qs : ''}`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError || err instanceof NetworkError) throw err;
|
||||
throw new NetworkError('Failed to fetch tour stops', err);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { apiFetch, RateLimitError } from '../client';
|
||||
import type { TouringPayload, TouringResult } from '../types/touring';
|
||||
import { resolveBaseUrl } from '../base-url';
|
||||
|
||||
export async function subscribeToTouring(payload: TouringPayload): Promise<TouringResult> {
|
||||
try {
|
||||
return await apiFetch<TouringResult>(`${resolveBaseUrl()}/public/touring/subscribe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitError) throw err;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
22
codebase/@packages/@lilith/provider-api-client/src/index.ts
Normal file
22
codebase/@packages/@lilith/provider-api-client/src/index.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export { resolveBaseUrl } from './base-url';
|
||||
export { apiFetch, ApiError, NotFoundError, RateLimitError, ValidationError, NetworkError } from './client';
|
||||
export type { BlogPostSummary, BlogPost } from './types/blog';
|
||||
export type { ContactChannel, ContactPayload, ContactResult } from './types/contact';
|
||||
export { CONTACT_CHANNELS } from './types/contact';
|
||||
export type { TouringPayload, TouringResult } from './types/touring';
|
||||
export type { TrackAvailability, RosterApplicationPayload, RosterApplicationResult } from './types/roster';
|
||||
export type { AnalyticsEventPayload } from './types/analytics';
|
||||
export type {
|
||||
TourStop,
|
||||
TourStopStatus,
|
||||
TourStopVisibility,
|
||||
CurrentLocation,
|
||||
TourStatus,
|
||||
} from './types/tour';
|
||||
export { fetchBlogPosts, fetchBlogPost } from './endpoints/blog';
|
||||
export { submitContact } from './endpoints/contact';
|
||||
export { subscribeToTouring } from './endpoints/touring';
|
||||
export { fetchAvailability, fetchAvailabilityBySlug, submitRosterApplication } from './endpoints/roster';
|
||||
export { trackEvent } from './endpoints/analytics';
|
||||
export { fetchTourStatus, fetchTourStops } from './endpoints/tour';
|
||||
export type { TourStatusOptions } from './endpoints/tour';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export type AnalyticsEventPayload = Record<string, unknown>;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export type BlogPostSummary = {
|
||||
readonly slug: string;
|
||||
readonly title: string;
|
||||
readonly excerpt: string;
|
||||
readonly heroImage: string | null;
|
||||
readonly publishedAt: string | null;
|
||||
readonly tags: readonly string[];
|
||||
};
|
||||
|
||||
export type BlogPost = BlogPostSummary & {
|
||||
readonly bodyHtml: string | null;
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export const CONTACT_CHANNELS = ['email', 'imessage', 'signal', 'telegram', 'other'] as const;
|
||||
export type ContactChannel = (typeof CONTACT_CHANNELS)[number];
|
||||
|
||||
export type ContactPayload = {
|
||||
readonly name: string;
|
||||
readonly email?: string;
|
||||
readonly handle?: string;
|
||||
readonly channel?: ContactChannel;
|
||||
readonly subject: string;
|
||||
readonly body: string;
|
||||
readonly hcaptchaToken?: string;
|
||||
};
|
||||
|
||||
export type ContactResult =
|
||||
| { readonly success: true; readonly id: number }
|
||||
| { readonly success: false; readonly error: string };
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export type { BlogPostSummary, BlogPost } from './blog';
|
||||
export type { ContactChannel, ContactPayload, ContactResult } from './contact';
|
||||
export { CONTACT_CHANNELS } from './contact';
|
||||
export type { TouringPayload, TouringResult } from './touring';
|
||||
export type { TrackAvailability, RosterApplicationPayload, RosterApplicationResult } from './roster';
|
||||
export type { AnalyticsEventPayload } from './analytics';
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
export type TrackAvailability = {
|
||||
readonly slug: string;
|
||||
readonly name: string;
|
||||
readonly tagline: string;
|
||||
readonly displayOpen: number;
|
||||
readonly displayMessage: string;
|
||||
readonly active: boolean;
|
||||
};
|
||||
|
||||
export type RosterApplicationPayload = {
|
||||
readonly track: string;
|
||||
readonly handle: string;
|
||||
readonly email: string;
|
||||
readonly phone: string;
|
||||
readonly countryCode?: string;
|
||||
readonly interests: readonly string[];
|
||||
readonly experience: string;
|
||||
readonly hardLimits: string;
|
||||
readonly availability: string;
|
||||
readonly tributeNote: string;
|
||||
readonly acknowledged: boolean;
|
||||
};
|
||||
|
||||
export type RosterApplicationResult = {
|
||||
readonly success: boolean;
|
||||
readonly id: number;
|
||||
readonly status: 'pending' | 'waitlisted';
|
||||
readonly track: string;
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
export type TourStopStatus = 'confirmed' | 'conditional' | 'sold-out';
|
||||
export type TourStopVisibility = 'public' | 'draft';
|
||||
|
||||
export interface TourStop {
|
||||
readonly id: number;
|
||||
readonly city: string;
|
||||
readonly state: string;
|
||||
readonly country: string;
|
||||
readonly startDate: string;
|
||||
readonly endDate: string;
|
||||
readonly status: TourStopStatus;
|
||||
readonly visibility: TourStopVisibility;
|
||||
readonly fmtyRate: number | null;
|
||||
readonly travelFee: number | null;
|
||||
readonly notes: string;
|
||||
readonly sortOrder: number;
|
||||
readonly providerSlug: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CurrentLocation {
|
||||
readonly city: string;
|
||||
readonly state: string | null;
|
||||
readonly country: string;
|
||||
readonly incallAvailable: boolean;
|
||||
readonly sinceDate: string;
|
||||
}
|
||||
|
||||
export interface TourStatus {
|
||||
readonly currentLocation: CurrentLocation | null;
|
||||
readonly activeStop: TourStop | null;
|
||||
readonly nextStop: TourStop | null;
|
||||
readonly confirmedStops: readonly TourStop[];
|
||||
readonly conditionalStops: readonly TourStop[];
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export type TouringPayload = {
|
||||
readonly email: string;
|
||||
readonly phone?: string;
|
||||
readonly countryCode?: string;
|
||||
readonly preferSms?: boolean;
|
||||
readonly source?: string;
|
||||
readonly sourceCity?: string;
|
||||
readonly citiesInterested?: readonly string[];
|
||||
};
|
||||
|
||||
export type TouringResult = {
|
||||
readonly id: number;
|
||||
readonly status: string;
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue