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:
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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("/");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user