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
+128
View File
@@ -0,0 +1,128 @@
import {
collection,
doc,
onSnapshot,
addDoc,
updateDoc,
query,
orderBy,
serverTimestamp,
Timestamp,
type DocumentData,
type FirestoreDataConverter,
type QueryDocumentSnapshot,
type SnapshotOptions,
type Unsubscribe,
} from "firebase/firestore";
import { firestoreDb } from "@/firebase";
import { ParticleSchema } from "@/api/types";
import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types";
// --- Converter ---
const particleConverter: FirestoreDataConverter<Particle> = {
toFirestore(particle: Particle): DocumentData {
const { id: _id, created_at, updated_at, ...rest } = particle;
return {
...rest,
created_at: Timestamp.fromDate(created_at),
...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }),
};
},
fromFirestore(
snap: QueryDocumentSnapshot,
options?: SnapshotOptions,
): Particle {
const raw = snap.data(options);
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_email: raw.created_by_email,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to,
});
},
};
// --- Typed reference helpers ---
function typedDoc(path: string) {
return doc(firestoreDb, path).withConverter(particleConverter);
}
function typedCollection(path: string) {
return collection(firestoreDb, path).withConverter(particleConverter);
}
// --- Exported operations ---
export function subscribeToParticle(
docPath: string,
onData: (particle: Particle | null) => void,
onError: (error: Error) => void,
): Unsubscribe {
return onSnapshot(
typedDoc(docPath),
(snap) => {
onData(snap.exists() ? snap.data() : null);
},
onError,
);
}
export function subscribeToParticleChildren(
collectionPath: string,
onData: (children: Particle[]) => void,
onError: (error: Error) => void,
): Unsubscribe {
const q = query(typedCollection(collectionPath), orderBy("created_at"));
return onSnapshot(
q,
(snap) => {
onData(snap.docs.map((d) => d.data()));
},
onError,
);
}
export async function createParticle<T extends ParticleType>(
collectionPath: string,
type: T,
properties: ParticlePropertiesMap[T],
createdByEmail: string,
visibleTo: string[],
): Promise<string> {
const particle: Particle = ParticleSchema.parse({
id: "", // ignored by toFirestore, but needed to satisfy the type
type,
properties,
created_at: new Date(),
created_by_email: createdByEmail,
updated_at: null,
visible_to: visibleTo,
});
const ref = await addDoc(typedCollection(collectionPath), particle);
return ref.id;
}
// This allows updating properties without overwriting the entire properties object
export async function updateParticle<T extends ParticleType>(
docPath: string,
properties: Partial<ParticlePropertiesMap[T]>,
visibleTo?: string[],
): Promise<void> {
const particleRef = typedDoc(docPath);
// Take the partial and create a new object with dot notation
// e.g. { title: "New Title" } becomes { "properties.title": "New Title" }
const updatedProperties: Record<string, any> = {};
for (const key in properties) {
updatedProperties[`properties.${key}`] = properties[key];
}
await updateDoc(particleRef, {
...updatedProperties,
updated_at: serverTimestamp(),
...(visibleTo ? { visible_to: visibleTo } : {}),
});
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Map URL segments to Firestore paths.
*
* Firestore structure:
* networks/{networkId}/particles/{particleId}
* networks/{networkId}/particles/{particleId}/children/{childId}
* ...and so on for arbitrary depth.
*
* Examples:
* segments = [] → "networks/{nid}/particles"
* segments = ["p1"] → "networks/{nid}/particles/p1"
* segments = ["p1", "p2"] → "networks/{nid}/particles/p1/children/p2"
*/
export function firestorePath(networkId: string, segments: string[]): string {
const base = `networks/${networkId}/particles`;
if (segments.length === 0) return base;
const parts: string[] = [base, segments[0]];
for (let i = 1; i < segments.length; i++) {
parts.push("children", segments[i]);
}
return parts.join("/");
}
-37
View File
@@ -1,37 +0,0 @@
import type { NetworkWithStreams, Stream } from "@/api/types";
export interface FlatStream extends Stream {
networkId: string;
networkName: string;
}
export function flattenStreams(
networks: NetworkWithStreams[],
selectedNetworkId: string | null,
): FlatStream[] {
const filtered = selectedNetworkId
? networks.filter((n) => n.id === selectedNetworkId)
: networks;
const streams: FlatStream[] = filtered.flatMap((n) =>
n.streams.map((s) => ({
...s,
networkId: n.id,
networkName: n.name,
})),
);
return streams.sort((a, b) => {
const aTime = getLatestParticleTime(a);
const bTime = getLatestParticleTime(b);
return bTime - aTime;
});
}
function getLatestParticleTime(stream: Stream): number {
if (stream.particles.length === 0) {
return new Date(stream.updated_at).getTime() || 0;
}
const last = stream.particles[stream.particles.length - 1];
return new Date(last.created_at).getTime();
}
+5
View File
@@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function getInitials(email: string): string {
const prefix = email.split("@")[0] ?? "";
return prefix.slice(0, 2).toUpperCase();
}