refactor: organize desktop vs. mobile into separate folders
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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";
|
||||
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient({
|
||||
baseUrl: appConfig.orionUrl,
|
||||
getToken: () => useSessionStore.getState().token,
|
||||
onUnauthorized: () => useSessionStore.getState().clearToken(),
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const HumanSchema = z.object({
|
||||
id: z.string(),
|
||||
created_at: z.coerce.date(),
|
||||
email: z.string().email(),
|
||||
email_prefix: z.string(),
|
||||
email_notifications_enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type Human = z.infer<typeof HumanSchema>;
|
||||
|
||||
export const NetworkSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
admin_human: HumanSchema,
|
||||
humans: z.array(HumanSchema),
|
||||
created_at: z.coerce.date(),
|
||||
});
|
||||
|
||||
export type Network = z.infer<typeof NetworkSchema>;
|
||||
|
||||
export const ListNetworksResponseSchema = z.array(NetworkSchema);
|
||||
export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
|
||||
|
||||
// --- Network request/response types ---
|
||||
|
||||
const CreateNetworkRequestSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
|
||||
|
||||
const AddMembersRequestSchema = z.object({
|
||||
email_addresses: z.array(z.string().email()),
|
||||
});
|
||||
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
|
||||
|
||||
// --- Invitation types ---
|
||||
|
||||
export const InvitationSchema = z.object({
|
||||
network_id: z.string(),
|
||||
network_name: z.string(),
|
||||
email: z.string(),
|
||||
created_at: z.coerce.date(),
|
||||
});
|
||||
export type Invitation = z.infer<typeof InvitationSchema>;
|
||||
|
||||
export const ListInvitationsResponseSchema = z.array(InvitationSchema);
|
||||
|
||||
export type AcceptInvitationRequest = { network_id: string };
|
||||
export type RevokeInvitationRequest = { email: string };
|
||||
|
||||
// --- Depot types ---
|
||||
|
||||
const PrepareUploadRequestSchema = z.object({
|
||||
network_id: z.string(),
|
||||
name: z.string(),
|
||||
content_type: z.string(),
|
||||
content_length: z.number(),
|
||||
});
|
||||
export type PrepareUploadRequest = z.infer<typeof PrepareUploadRequestSchema>;
|
||||
|
||||
export const PrepareUploadResponseSchema = z.object({
|
||||
object_id: z.string(),
|
||||
upload_url: z.string(),
|
||||
upload_headers: z.record(z.string(), z.string()),
|
||||
});
|
||||
export type PrepareUploadResponse = z.infer<typeof PrepareUploadResponseSchema>;
|
||||
|
||||
export const DepotObjectSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
content_type: z.string(),
|
||||
content_length: z.number(),
|
||||
contains_content: z.boolean(),
|
||||
created_at: z.coerce.date(),
|
||||
});
|
||||
export type DepotObject = z.infer<typeof DepotObjectSchema>;
|
||||
|
||||
// --- Particle property schemas ---
|
||||
|
||||
export const StreamPropertiesSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
export type StreamProperties = z.infer<typeof StreamPropertiesSchema>;
|
||||
|
||||
export const FolderPropertiesSchema = z.object({
|
||||
name: z.string(),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
export type FolderProperties = z.infer<typeof FolderPropertiesSchema>;
|
||||
|
||||
const TranscriptWordSchema = z.object({
|
||||
word: z.string(),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
});
|
||||
|
||||
const TranscriptSentenceSchema = z.object({
|
||||
text: z.string(),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
});
|
||||
|
||||
const TranscriptParagraphSchema = z.object({
|
||||
sentences: z.array(TranscriptSentenceSchema),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
});
|
||||
|
||||
export const TranscriptSchema = z.object({
|
||||
transcript: z.string(),
|
||||
words: z.array(TranscriptWordSchema),
|
||||
paragraphs: z.array(TranscriptParagraphSchema),
|
||||
});
|
||||
export type Transcript = z.infer<typeof TranscriptSchema>;
|
||||
|
||||
export const MediaPropertiesSchema = z.object({
|
||||
object_id: z.string(),
|
||||
mime_type: z.string(),
|
||||
duration_ms: z.number(),
|
||||
size_bytes: z.number(),
|
||||
transcript: TranscriptSchema.optional(),
|
||||
source: z.enum(["camera", "screen"]).optional(),
|
||||
});
|
||||
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
|
||||
|
||||
export const FilePropertiesSchema = z.object({
|
||||
object_id: z.string(),
|
||||
filename: z.string(),
|
||||
mime_type: z.string(),
|
||||
size_bytes: z.number(),
|
||||
});
|
||||
export type FileProperties = z.infer<typeof FilePropertiesSchema>;
|
||||
|
||||
export const TextPropertiesSchema = z.object({
|
||||
content: z.string(),
|
||||
edited_at: z.coerce.date().optional(),
|
||||
});
|
||||
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
||||
|
||||
export const QuestPropertiesSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
status: z.string().optional(),
|
||||
// humanId
|
||||
assigned_to: z.string().optional(),
|
||||
});
|
||||
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
|
||||
|
||||
export const PaperPropertiesSchema = z.object({
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
|
||||
|
||||
// --- Reactions ---
|
||||
|
||||
export const ReactionsSchema = z.record(z.string(), z.array(z.string())).optional();
|
||||
export type Reactions = z.infer<typeof ReactionsSchema>;
|
||||
|
||||
// --- Tombstone (soft-delete) ---
|
||||
|
||||
// Fields added to non-container particles when their creator deletes them.
|
||||
// We keep the doc around so concurrent viewers can see a "This particle was
|
||||
// deleted" message in place, rather than being jumped to the next particle.
|
||||
const TombstoneFields = {
|
||||
deleted_at: z.coerce.date().optional(),
|
||||
deleted_by_human_id: z.string().optional(),
|
||||
};
|
||||
|
||||
export const REACTION_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F525}", "\u{1F440}", "\u{2705}", "\u{2753}", "\u{1F602}"] as const;
|
||||
|
||||
export interface ParticlePropertiesMap {
|
||||
stream: StreamProperties;
|
||||
folder: FolderProperties;
|
||||
media: MediaProperties;
|
||||
file: FileProperties;
|
||||
text: TextProperties;
|
||||
quest: QuestProperties;
|
||||
paper: PaperProperties;
|
||||
}
|
||||
|
||||
// --- Unified Particle types ---
|
||||
|
||||
const ParticleBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
created_at: z.coerce.date(),
|
||||
created_by_human_id: z.string(),
|
||||
updated_at: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const ParticleSchema = z.discriminatedUnion("type", [
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal("stream"),
|
||||
properties: StreamPropertiesSchema,
|
||||
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||
// e.g. ["network:xywx"] - visible to everyone in the network
|
||||
visible_to: z.array(z.string()),
|
||||
// Marks human_id to their `playback_position_at`: where they left off in a conversation
|
||||
playback_markers: z.record(z.string(), z.coerce.date()).optional(),
|
||||
// Timestamp of the most recent child particle
|
||||
// used for sorting streams by recent activity without needing to query subcollections
|
||||
last_child_created_at: z.coerce.date().optional(),
|
||||
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
|
||||
huddle_active_participants: z.array(z.string()).optional(),
|
||||
status: z.enum(["open", "closed"]).optional(),
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal("folder"), properties: FolderPropertiesSchema,
|
||||
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||
// e.g. ["network:123"] - visible to everyone in the network
|
||||
visible_to: z.array(z.string()),
|
||||
}),
|
||||
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema, ...TombstoneFields }),
|
||||
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema, ...TombstoneFields }),
|
||||
]);
|
||||
|
||||
export type Particle = z.infer<typeof ParticleSchema>;
|
||||
|
||||
export type ParticleType = Particle["type"];
|
||||
|
||||
/** Container types can have children subcollections */
|
||||
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set(["stream", "folder"]);
|
||||
|
||||
export function isContainerType(type: ParticleType): boolean {
|
||||
return CONTAINER_TYPES.has(type);
|
||||
}
|
||||
|
||||
/** True when a non-container particle has been soft-deleted (tombstoned). */
|
||||
export function isParticleDeleted(particle: Particle): boolean {
|
||||
return "deleted_at" in particle && particle.deleted_at != null;
|
||||
}
|
||||
|
||||
// --- LiveKit types ---
|
||||
|
||||
export const GetLivekitTokenResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
server_url: z.string(),
|
||||
});
|
||||
export type GetLivekitTokenResponse = z.infer<typeof GetLivekitTokenResponseSchema>;
|
||||
|
||||
// --- Auth types ---
|
||||
|
||||
const RequestCodeRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
|
||||
|
||||
const SignInRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
code: z.string(),
|
||||
});
|
||||
export type SignInRequest = z.infer<typeof SignInRequestSchema>;
|
||||
|
||||
export const SignInResponseSchema = z.object({
|
||||
human: HumanSchema,
|
||||
token: z.string(),
|
||||
});
|
||||
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
|
||||
|
||||
export const FirebaseTokenResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
export type FirebaseTokenResponse = z.infer<typeof FirebaseTokenResponseSchema>;
|
||||
|
||||
// --- Billing types ---
|
||||
|
||||
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
|
||||
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
|
||||
|
||||
export const NetworkPlanSchema = z.enum(["free", "pro"]);
|
||||
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
|
||||
|
||||
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
|
||||
export const BillingPlanStatusSchema = z.enum([
|
||||
"active",
|
||||
"trialing",
|
||||
"past_due",
|
||||
"canceled",
|
||||
"incomplete",
|
||||
"incomplete_expired",
|
||||
"unpaid",
|
||||
]);
|
||||
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
|
||||
|
||||
export const BillingStatusSchema = z.object({
|
||||
plan: NetworkPlanSchema,
|
||||
plan_status: BillingPlanStatusSchema,
|
||||
cadence: BillingCadenceSchema.nullable(),
|
||||
seats: z.number().int(),
|
||||
current_period_end: z.coerce.date().nullable(),
|
||||
cancel_at_period_end: z.boolean(),
|
||||
price_monthly_cents: z.number().int(),
|
||||
price_annual_cents: z.number().int(),
|
||||
});
|
||||
export type BillingStatus = z.infer<typeof BillingStatusSchema>;
|
||||
|
||||
export const CheckoutSessionResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
});
|
||||
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>;
|
||||
|
||||
export const PortalSessionResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
});
|
||||
export type PortalSessionResponse = z.infer<typeof PortalSessionResponseSchema>;
|
||||
|
||||
export const NetworkUsageSchema = z.object({
|
||||
plan: NetworkPlanSchema,
|
||||
used: z.number().int().nonnegative(),
|
||||
limit: z.number().int().nonnegative().nullable(),
|
||||
reset_at: z.coerce.date(),
|
||||
});
|
||||
export type NetworkUsage = z.infer<typeof NetworkUsageSchema>;
|
||||
Reference in New Issue
Block a user