Files
llink/js/desktop/src/api/client.ts
T
2026-05-26 14:37:28 -07:00

277 lines
6.5 KiB
TypeScript

import { appConfig } from "@/config/env";
import { useSessionStore } from "@/stores/session-store";
import { ApiError } from "@/lib/errors";
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";
import type { LinkMetadata } from "@/lib/link-metadata";
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<Response> {
const headers: Record<string, string> = {};
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<T>(
schema: z.ZodType<T>,
method: string,
path: string,
body?: unknown,
): Promise<T> {
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<void> {
await this.fetch(method, path, body);
}
// --- Auth ---
async requestCode(data: RequestCodeRequest): Promise<void> {
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<void> {
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<string> {
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<void> {
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<void> {
await this.requestVoid(
"POST",
`/networks/${networkId}/members`,
data,
);
}
async removeMember(networkId: string, humanId: string): Promise<void> {
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<void> {
await this.requestVoid("POST", "/invitations/accept", data);
}
async revokeInvitation(networkId: string, data: RevokeInvitationRequest): Promise<void> {
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`,
);
}
// --- Link metadata ---
async getLinkMetadata(url: string): Promise<LinkMetadata | null> {
const response = await this.fetch(
"GET",
`/metadata?url=${encodeURIComponent(url)}`,
);
return (await response.json()) as LinkMetadata | null;
}
}
export const apiClient = new ApiClient({
baseUrl: appConfig.orionUrl,
getToken: () => useSessionStore.getState().token,
onUnauthorized: () => useSessionStore.getState().clearToken(),
});