import type { Badge, LeaderboardEntry, Player, PlayerRank, PointBalance, Quest, RedemptionResult, Reward, TrackEventInput, } from '../types'; // ───────────────────────────────────────────────────────────────────────────── // Configuration // ───────────────────────────────────────────────────────────────────────────── interface ClientConfig { apiKey: string; tenantId: string; baseUrl?: string; fetchImpl?: typeof fetch; timeoutMs?: number; retries?: number; } const DEFAULT_BASE_URL = 'https://apim-pb-staging.azure-api.net/playbasis/v1'; // ───────────────────────────────────────────────────────────────────────────── // Client // ───────────────────────────────────────────────────────────────────────────── export class PlaybasisClient { private readonly baseUrl: string; private readonly fetchImpl: typeof fetch; private readonly apiKey: string; private readonly tenantId: string; private readonly timeoutMs: number; private readonly retries: number; constructor(config: ClientConfig) { this.tenantId = config.tenantId; this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''); this.fetchImpl = config.fetchImpl ?? fetch; this.apiKey = config.apiKey; this.timeoutMs = config.timeoutMs ?? 10000; this.retries = config.retries ?? 2; } /** * Generate a unique ID for idempotency keys. * Uses crypto.randomUUID when available, falls back to timestamp + random. */ private generateId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } // Fallback for environments without crypto.randomUUID return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; } private async request( method: 'GET' | 'POST' | 'PATCH', path: string, options?: { body?: unknown; headers?: Record }, ): Promise { const url = `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`; const attemptRequest = async (): Promise => { const headers: Record = { 'Ocp-Apim-Subscription-Key': this.apiKey, 'X-Tenant-ID': this.tenantId, ...options?.headers, }; if (options?.body !== undefined) { headers['Content-Type'] = headers['Content-Type'] ?? 'application/json'; } const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; const timeoutId = this.timeoutMs ? setTimeout(() => controller?.abort(), this.timeoutMs) : null; try { const init: RequestInit = { method, headers, body: options?.body !== undefined ? JSON.stringify(options.body) : undefined, signal: controller?.signal, }; const response = await this.fetchImpl(url, init); const text = await response.text(); let json: unknown; if (text) { try { json = JSON.parse(text); } catch { json = undefined; } } const getErrorMessage = (value: unknown): string | undefined => { if (!value || typeof value !== 'object') return undefined; if (!('message' in value)) return undefined; const messageValue = (value as { message?: unknown }).message; return typeof messageValue === 'string' ? messageValue : undefined; }; if (!response.ok) { const errorMessage = getErrorMessage(json) || (text && !json ? text.slice(0, 200) : null) || `Request failed: ${response.status} ${response.statusText}`; const error = new Error(String(errorMessage)) as Error & { status?: number }; error.status = response.status; throw error; } return json as T; } finally { if (timeoutId) clearTimeout(timeoutId); } }; for (let attempt = 0; attempt <= this.retries; attempt += 1) { try { return await attemptRequest(); } catch (error) { const status = (error as { status?: number }).status; const shouldRetry = attempt < this.retries && (status === undefined || status === 429 || (status >= 500 && status <= 599)); if (!shouldRetry) throw error; await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))); } } throw new Error('Request failed after retries'); } // ───────────────────────────────────────────────────────────────────────── // Players // ───────────────────────────────────────────────────────────────────────── async createPlayer(input: { displayName: string; email?: string; metadata?: Record; }): Promise { const data = await this.request<{ player: Player }>('POST', '/players', { body: input }); return data.player; } async getPlayer(playerId: string): Promise { const data = await this.request<{ player: Player }>( 'GET', `/players/${encodeURIComponent(playerId)}`, ); return data.player; } async updatePlayer( playerId: string, updates: Partial<{ displayName: string; metadata: Record }>, ): Promise { const data = await this.request<{ player: Player }>( 'PATCH', `/players/${encodeURIComponent(playerId)}`, { body: updates, }, ); return data.player; } // ───────────────────────────────────────────────────────────────────────── // Points // ───────────────────────────────────────────────────────────────────────── async getBalances(playerId: string): Promise { const data = await this.request<{ balances: PointBalance[] }>( 'GET', `/players/${encodeURIComponent(playerId)}/points`, ); return data.balances; } async earnPoints(input: { playerId: string; currency: string; amount: number; reason?: string; }): Promise { const idempotencyKey = `earn-${input.playerId}-${this.generateId()}`; const data = await this.request<{ balance: PointBalance }>('POST', '/points/earn', { body: input, headers: { 'Idempotency-Key': idempotencyKey }, }); return data.balance; } async redeemPoints(input: { playerId: string; currency: string; amount: number; reason?: string; }): Promise { const idempotencyKey = `redeem-${input.playerId}-${this.generateId()}`; const data = await this.request<{ balance: PointBalance }>('POST', '/points/redeem', { body: input, headers: { 'Idempotency-Key': idempotencyKey }, }); return data.balance; } // ───────────────────────────────────────────────────────────────────────── // Events // ───────────────────────────────────────────────────────────────────────── async trackEvent(input: TrackEventInput): Promise<{ eventId: string }> { const idempotencyKey = input.referenceId || `evt-${input.playerId}-${this.generateId()}`; const event = { id: idempotencyKey, source: 'qwikcard-app', type: input.type, specversion: '1.0', time: new Date().toISOString(), subject: input.playerId, data: input.data || {}, }; const data = await this.request<{ event?: { id?: string } }>('POST', '/events', { body: event, headers: { 'Idempotency-Key': idempotencyKey }, }); return { eventId: data.event?.id || idempotencyKey }; } // ───────────────────────────────────────────────────────────────────────── // Quests // ───────────────────────────────────────────────────────────────────────── async getQuests(): Promise { const data = await this.request<{ quests?: Quest[]; items?: Quest[] }>('GET', '/quests'); return data.quests || data.items || []; } async getPlayerQuests(playerId: string): Promise { const data = await this.request( 'GET', `/players/${encodeURIComponent(playerId)}/quests`, ); if (Array.isArray(data)) return data; return data.quests || data.items || []; } async getQuestProgress(playerId: string, questId: string): Promise { const data = await this.request<{ quest: Quest }>( 'GET', `/players/${encodeURIComponent(playerId)}/quests/${encodeURIComponent(questId)}`, ); return data.quest; } // ───────────────────────────────────────────────────────────────────────── // Badges // ───────────────────────────────────────────────────────────────────────── async getBadges(): Promise { const data = await this.request<{ badges?: Badge[]; items?: Badge[] }>('GET', '/badges'); return data.badges || data.items || []; } async getPlayerBadges(playerId: string): Promise { const data = await this.request( 'GET', `/players/${encodeURIComponent(playerId)}/badges`, ); if (Array.isArray(data)) return data; return data.badges || data.items || []; } // ───────────────────────────────────────────────────────────────────────── // Rewards // ───────────────────────────────────────────────────────────────────────── async getRewards(): Promise { const data = await this.request<{ rewards?: Reward[]; items?: Reward[] }>('GET', '/rewards'); return data.rewards || data.items || []; } async redeemReward(playerId: string, rewardId: string): Promise { const idempotencyKey = `reward-${playerId}-${rewardId}-${this.generateId()}`; const data = await this.request('POST', '/rewards/redeem', { body: { playerId, rewardId }, headers: { 'Idempotency-Key': idempotencyKey }, }); return data; } // ───────────────────────────────────────────────────────────────────────── // Leaderboards // ───────────────────────────────────────────────────────────────────────── async getLeaderboard( leaderboardId: string, options?: { limit?: number; cursor?: string }, ): Promise<{ entries: LeaderboardEntry[]; total: number }> { const params = new URLSearchParams(); if (options?.limit) params.set('limit', String(options.limit)); if (options?.cursor) params.set('cursor', options.cursor); const data = await this.request< | { entries?: LeaderboardEntry[]; total?: number } | { items?: LeaderboardEntry[]; total?: number } | { leaderboard?: { entries?: LeaderboardEntry[]; total?: number } } >('GET', `/leaderboards/${encodeURIComponent(leaderboardId)}?${params}`); const leaderboardData = data as { leaderboard?: { entries?: LeaderboardEntry[]; total?: number }; }; const entries = 'entries' in data ? data.entries : 'items' in data ? data.items : leaderboardData.leaderboard?.entries; const total = 'total' in data && typeof data.total === 'number' ? data.total : (leaderboardData.leaderboard?.total ?? entries?.length ?? 0); const normalizedEntries = (entries || []).map((entry, index) => { const raw = entry as LeaderboardEntry & { balance?: number; points?: number; value?: number; name?: string; id?: string; }; const score = typeof raw.score === 'number' ? raw.score : typeof raw.balance === 'number' ? raw.balance : typeof raw.points === 'number' ? raw.points : typeof raw.value === 'number' ? raw.value : 0; const rank = typeof raw.rank === 'number' ? raw.rank : index + 1; const displayName = raw.displayName || raw.name || raw.playerId || 'Player'; const playerId = raw.playerId || raw.id || displayName; return { playerId, displayName, rank, score, metadata: raw.metadata, }; }); return { entries: normalizedEntries, total, }; } async getPlayerRank(leaderboardId: string, playerId: string): Promise { const data = await this.request<{ player: PlayerRank }>( 'GET', `/leaderboards/${encodeURIComponent(leaderboardId)}/players/${encodeURIComponent(playerId)}`, ); return data.player; } // ───────────────────────────────────────────────────────────────────────── // Health Check // ───────────────────────────────────────────────────────────────────────── async health(): Promise<{ status: string }> { return this.request<{ status: string }>('GET', '/health'); } } export default PlaybasisClient;