import { appConfig } from "@/config/env"; import { useSessionStore } from "@/stores/session-store"; import type { z } from "zod"; import { BillingStatusSchema, CheckoutSessionResponseSchema, DepotObjectSchema, FirebaseTokenResponseSchema, GetLivekitTokenResponseSchema, HumanSchema, ListInvitationsResponseSchema, ListNetworksResponseSchema, NetworkSchema, NetworkUsageSchema, PortalSessionResponseSchema, PrepareUploadResponseSchema, SignInResponseSchema, } from "./types"; import type { AcceptInvitationRequest, AddMembersRequest, BillingCadence, CreateNetworkRequest, PrepareUploadRequest, RequestCodeRequest, RevokeInvitationRequest, SignInRequest, } from "./types"; export class ApiError extends Error { constructor( public status: number, message: string, ) { super(message); this.name = "ApiError"; } } interface ApiClientConfig { baseUrl: string; getToken: () => string | null; onUnauthorized: () => void; } class ApiClient { private config: ApiClientConfig; constructor(config: ApiClientConfig) { this.config = config; } private async fetch( method: string, path: string, body?: unknown, ): Promise { const headers: Record = {}; if (body) { headers["Content-Type"] = "application/json"; } const token = this.config.getToken(); if (token) { headers["Authorization"] = `Bearer ${token}`; } const response = await fetch(`${this.config.baseUrl}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, }); if (response.status === 401) { this.config.onUnauthorized(); throw new ApiError(401, "Unauthorized"); } if (!response.ok) { const text = await response.text().catch(() => "Unknown error"); throw new ApiError(response.status, text); } return response; } private async request( schema: z.ZodType, method: string, path: string, body?: unknown, ): Promise { const response = await this.fetch(method, path, body); const json = await response.json(); return schema.parse(json); } private async requestVoid( method: string, path: string, body?: unknown, ): Promise { await this.fetch(method, path, body); } // --- Auth --- async requestCode(data: RequestCodeRequest): Promise { await this.requestVoid("POST", "/auth/request-code", data); } async signIn(data: SignInRequest) { return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data); } async me() { return this.request(HumanSchema, "GET", "/auth/me"); } async signOut(): Promise { await this.requestVoid("POST", "/auth/sign-out"); } async getFirebaseToken() { return this.request( FirebaseTokenResponseSchema, "POST", "/auth/firebase-token", ); } // TODO: security: require passing in the particle id once api deprecates this async getParticleDownloadUrl(objectId: string): Promise { const response = await this.fetch( "GET", `/particles/${objectId}/download`, ); const data = await response.json(); return data.url; } // --- Settings --- async updateSettings(data: { email_notifications_enabled?: boolean }): Promise { await this.requestVoid("PATCH", "/humans/me/settings", data); } // --- Depot --- async prepareUpload(data: PrepareUploadRequest) { return this.request( PrepareUploadResponseSchema, "POST", "/depot/upload", data, ); } async confirmUpload(objectId: string) { return this.request( DepotObjectSchema, "POST", `/depot/objects/${objectId}/confirm`, ); } // --- Networks --- async listNetworks() { return this.request( ListNetworksResponseSchema, "GET", "/networks", ); } async createNetwork(data: CreateNetworkRequest) { return this.request(NetworkSchema, "POST", "/networks", data); } async getNetwork(id: string) { return this.request(NetworkSchema, "GET", `/networks/${id}`); } async addMembers(networkId: string, data: AddMembersRequest): Promise { await this.requestVoid( "POST", `/networks/${networkId}/members`, data, ); } async removeMember(networkId: string, humanId: string): Promise { await this.requestVoid( "DELETE", `/networks/${networkId}/members/${humanId}`, ); } // --- Invitations --- async listNetworkInvitations(networkId: string) { return this.request( ListInvitationsResponseSchema, "GET", `/networks/${networkId}/invitations`, ); } async listMyInvitations() { return this.request( ListInvitationsResponseSchema, "GET", "/invitations", ); } async acceptInvitation(data: AcceptInvitationRequest): Promise { await this.requestVoid("POST", "/invitations/accept", data); } async revokeInvitation(networkId: string, data: RevokeInvitationRequest): Promise { await this.requestVoid("DELETE", `/networks/${networkId}/invitations`, data); } // --- LiveKit --- async getLivekitToken(networkId: string, streamId: string) { return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId }); } // --- Billing (network admin only) --- async getNetworkBilling(networkId: string) { return this.request( BillingStatusSchema, "GET", `/networks/${networkId}/billing`, ); } async createCheckoutSession(networkId: string, cadence: BillingCadence) { return this.request( CheckoutSessionResponseSchema, "POST", `/networks/${networkId}/billing/checkout-session`, { cadence }, ); } async createPortalSession(networkId: string) { return this.request( PortalSessionResponseSchema, "POST", `/networks/${networkId}/billing/portal-session`, ); } async getNetworkUsage(networkId: string) { return this.request( NetworkUsageSchema, "GET", `/networks/${networkId}/usage`, ); } } export const apiClient = new ApiClient({ baseUrl: appConfig.orionUrl, getToken: () => useSessionStore.getState().token, onUnauthorized: () => useSessionStore.getState().clearToken(), });