chore: integrate firestore for particles (#32)

* plumb for firestore

* chore: cleanup orion api to only include essentials

* setup boilerplate for data and rendering

This includes zod types creation for API response validation, and
exploration of path based resolution of rendering particles.

* chore: structure container particles for rendering children

* wire firestore crud for particles

* integrate visibility to particles

* docs: explain particle view resolver
This commit was merged in pull request #32.
This commit is contained in:
Arjun Patel
2026-03-17 19:52:31 -07:00
committed by GitHub
parent 377537b892
commit 51857bed63
25 changed files with 1585 additions and 2372 deletions
+90 -91
View File
@@ -1,17 +1,19 @@
import { useSessionStore } from "@/stores/session-store";
import type { z } from "zod";
import {
DepotObjectSchema,
HumanSchema,
ListNetworksResponseSchema,
NetworkSchema,
PrepareUploadResponseSchema,
SignInResponseSchema,
} from "./types";
import type {
CreateStreamParticleRequest,
CreateStreamRequest,
Human,
MarkSeenBatchRequest,
AddMembersRequest,
CreateNetworkRequest,
PrepareUploadRequest,
PrepareUploadResponse,
RequestCodeRequest,
SignInRequest,
SignInResponse,
StartupResponse,
Stream,
StreamParticle,
} from "./types";
export class ApiError extends Error {
@@ -37,11 +39,11 @@ class ApiClient {
this.config = config;
}
private async request<T>(
private async fetch(
method: string,
path: string,
body?: unknown,
): Promise<T> {
): Promise<Response> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
@@ -67,109 +69,106 @@ class ApiClient {
throw new ApiError(response.status, text);
}
if (response.status === 204) {
return undefined as T;
}
return response;
}
return response.json() as Promise<T>;
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.request<void>("POST", "/auth/request-code", data);
await this.requestVoid("POST", "/auth/request-code", data);
}
async signIn(data: SignInRequest): Promise<SignInResponse> {
return this.request<SignInResponse>("POST", "/auth/sign-in", data);
async signIn(data: SignInRequest) {
return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data);
}
async me(): Promise<Human> {
return this.request<Human>("GET", "/auth/me");
async me() {
return this.request(HumanSchema, "GET", "/auth/me");
}
async signOut(): Promise<void> {
await this.request<void>("POST", "/auth/sign-out");
await this.requestVoid("POST", "/auth/sign-out");
}
// --- Startup ---
async startup(): Promise<StartupResponse> {
return this.request<StartupResponse>("GET", "/startup");
}
// --- Streams ---
async createStream(
networkId: string,
data: CreateStreamRequest,
): Promise<Stream> {
return this.request<Stream>(
"POST",
`/networks/${networkId}/streams`,
data,
// 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`,
);
}
async createStreamParticle(
streamId: string,
data: CreateStreamParticleRequest,
): Promise<StreamParticle> {
return this.request<StreamParticle>(
"POST",
`/streams/${streamId}/particles`,
data,
);
}
// --- Particles ---
async markSeen(particleId: string): Promise<void> {
await this.request<void>("POST", `/particles/${particleId}/seen`);
}
async ackParticle(particleId: string): Promise<void> {
await this.request<void>("POST", `/particles/${particleId}/ack`);
}
async markSeenBatch(data: MarkSeenBatchRequest): Promise<void> {
await this.request<void>("POST", "/particles/seen", data);
}
async getParticleDownloadUrl(particleId: string): Promise<string> {
const token = this.config.getToken();
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${this.config.baseUrl}/particles/${particleId}/download`,
{ headers, redirect: "follow" },
);
if (response.status === 401) {
this.config.onUnauthorized();
throw new ApiError(401, "Unauthorized");
}
if (!response.ok) {
throw new ApiError(response.status, "Failed to get download URL");
}
return response.url;
}
// --- Depot ---
async prepareUpload(
data: PrepareUploadRequest,
): Promise<PrepareUploadResponse> {
return this.request<PrepareUploadResponse>("POST", "/depot/upload", data);
async prepareUpload(data: PrepareUploadRequest) {
return this.request(
PrepareUploadResponseSchema,
"POST",
"/depot/upload",
data,
);
}
async confirmUpload(objectId: string): Promise<void> {
await this.request<void>("POST", `/depot/objects/${objectId}/confirm`);
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, email: string): Promise<void> {
await this.requestVoid(
"DELETE",
`/networks/${networkId}/members/${email}`,
);
}
}
+151 -145
View File
@@ -1,168 +1,174 @@
// --- Core entities ---
import { z } from "zod";
export interface Human {
id: string | null;
email: string;
email_prefix: string;
created_at: string | null;
}
export const HumanSchema = z.object({
id: z.string().nullable(),
created_at: z.coerce.date().nullable(),
email: z.string().email(),
email_prefix: z.string(),
});
export type StreamStatus = "open" | "closed" | "unspecified";
export type Human = z.infer<typeof HumanSchema>;
export interface AckInfo {
email: string;
acked_at: string;
}
export const NetworkSchema = z.object({
id: z.string(),
name: z.string(),
admin_human: HumanSchema,
humans: z.array(HumanSchema),
created_at: z.coerce.date(),
});
// --- Particle types ---
export type Network = z.infer<typeof NetworkSchema>;
export type ParticleType =
| "media"
| "text"
| "quest"
| "paper"
| "file"
| "folder";
export const ListNetworksResponseSchema = z.array(NetworkSchema);
export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
export interface MediaParticleData {
object_id: string;
duration_ms: number;
mime_type: string;
}
// --- Network request/response types ---
export interface TextParticleData {
content: string;
}
const CreateNetworkRequestSchema = z.object({
name: z.string(),
});
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
export interface QuestParticleData {
title: string;
description: string;
status?: string;
assigned_to?: string;
due_date?: string;
}
export interface PaperParticleData {
title: string;
content: string;
}
export interface FileParticleData {
object_id: string;
filename: string;
mime_type: string;
size: number;
}
export interface FolderParticleData {
name: string;
color?: string;
}
export interface ParticleDataMap {
media: MediaParticleData;
text: TextParticleData;
quest: QuestParticleData;
paper: PaperParticleData;
file: FileParticleData;
folder: FolderParticleData;
}
export function getParticleData<T extends ParticleType>(
particle: StreamParticle,
type: T,
): ParticleDataMap[T] {
return particle.data as ParticleDataMap[T];
}
export interface StreamParticle {
id: string;
type: ParticleType;
data: unknown;
created_by_email: string;
seen: boolean;
acks: AckInfo[];
updated_at: string;
created_at: string;
}
export interface Stream {
id: string;
name: string;
description: string;
status: StreamStatus;
members?: string[];
particles: StreamParticle[];
unseen_count: number;
updated_at: string;
created_at: string;
}
export interface Network {
id: string;
name: string;
admin_human: Human;
humans: Human[];
open_stream_count: number;
open_stream_capacity: number;
created_at: string;
}
export interface NetworkWithStreams extends Network {
streams: Stream[];
}
const AddMembersRequestSchema = z.object({
email_addresses: z.array(z.string().email()),
});
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
// --- Depot types ---
export interface PrepareUploadRequest {
network_id: string;
name: string;
content_type: string;
content_length: number;
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(),
status: z.enum(["open", "closed"]),
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>;
export const MediaPropertiesSchema = z.object({
object_id: z.string(),
mime_type: z.string(),
duration_ms: z.number(),
size_bytes: z.number(),
});
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(),
});
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
export const QuestPropertiesSchema = z.object({
title: z.string(),
description: z.string(),
status: z.string().optional(),
assigned_to: z.string().email().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>;
export interface ParticlePropertiesMap {
stream: StreamProperties;
folder: FolderProperties;
media: MediaProperties;
file: FileProperties;
text: TextProperties;
quest: QuestProperties;
paper: PaperProperties;
}
export interface PrepareUploadResponse {
object_id: string;
upload_url: string;
upload_headers: Record<string, string>;
}
// --- Unified Particle types ---
// --- Stream mutation types ---
const ParticleBaseSchema = z.object({
id: z.string(),
created_at: z.coerce.date(),
created_by_email: z.string().email(),
updated_at: z.coerce.date().optional(),
// e.g. ["human:[email protected]", "human:[email protected]"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string())
});
export interface CreateStreamRequest {
name: string;
description: string;
visibility: "network_all" | "custom";
member_emails?: string[];
}
export const ParticleSchema = z.discriminatedUnion("type", [
ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema }),
]);
export interface CreateStreamParticleRequest {
type: ParticleType;
data: unknown;
}
export type Particle = z.infer<typeof ParticleSchema>;
export interface MarkSeenBatchRequest {
particle_ids: string[];
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);
}
// --- Auth types ---
export interface RequestCodeRequest {
email: string;
}
const RequestCodeRequestSchema = z.object({
email: z.string().email(),
});
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
export interface SignInRequest {
email: string;
code: string;
}
const SignInRequestSchema = z.object({
email: z.string().email(),
code: z.string(),
});
export type SignInRequest = z.infer<typeof SignInRequestSchema>;
export interface SignInResponse {
human: Human;
token: string;
}
// --- Startup ---
export interface StartupResponse {
networks: NetworkWithStreams[];
}
export const SignInResponseSchema = z.object({
human: HumanSchema,
token: z.string(),
});
export type SignInResponse = z.infer<typeof SignInResponseSchema>;