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:
+90
-91
@@ -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
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user