import { collection, doc, onSnapshot, addDoc, getDoc, getDocs, updateDoc, query, orderBy, limit, serverTimestamp, where, Timestamp, type DocumentData, type FirestoreDataConverter, type QueryDocumentSnapshot, type SnapshotOptions, type Unsubscribe, QueryFieldFilterConstraint, } from "firebase/firestore"; import { firestoreDb } from "@/firebase"; import { isContainerType, ParticleSchema } from "@/api/types"; import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types"; // --- Converter --- const particleConverter: FirestoreDataConverter = { 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); if (typeof raw.type !== "string") { throw new Error(`Invalid particle type: ${raw.type}`); } const type = raw.type as ParticleType; switch (type) { case "stream": return ParticleSchema.parse({ id: snap.id, type: raw.type, properties: raw.properties, created_at: (raw.created_at as Timestamp).toDate(), created_by_human_id: raw.created_by_human_id, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, visible_to: raw.visible_to, playback_markers: raw.playback_markers ? Object.fromEntries( Object.entries(raw.playback_markers).map(([key, value]) => [ key, (value as Timestamp).toDate(), ]), ) : undefined, last_child_created_at: raw.last_child_created_at ? (raw.last_child_created_at as Timestamp).toDate() : undefined, }); case "folder": return ParticleSchema.parse({ id: snap.id, type: raw.type, properties: raw.properties, created_at: (raw.created_at as Timestamp).toDate(), created_by_human_id: raw.created_by_human_id, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, visible_to: raw.visible_to, }); case "media": case "file": case "text": case "quest": case "paper": return ParticleSchema.parse({ id: snap.id, type: raw.type, properties: raw.properties, created_at: (raw.created_at as Timestamp).toDate(), created_by_human_id: raw.created_by_human_id, updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined, }); default: throw new Error(`Unknown particle type: ${type}`); } }, }; // --- 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 async function getParticle(docPath: string): Promise { const doc = await getDoc(typedDoc(docPath)); if (!doc.exists()) { return null; } return doc.data(); } export async function getParticleChildren( collectionPath: string, orderByField: string = "created_at", orderDirection: "asc" | "desc" = "asc", ): Promise { const q = query( typedCollection(collectionPath), orderBy(orderByField, orderDirection), ); const snap = await getDocs(q); return snap.docs.map((d) => d.data()); } export function subscribeToParticleChildren( collectionPath: string, onData: (children: Particle[]) => void, onError: (error: Error) => void, visibilityScopes: string[] = [], orderByField: string = "created_at", orderDirection: "asc" | "desc" = "desc", onAdded?: (child: Particle) => void, onRemoved?: (child: Particle, updatedChildren: Particle[]) => void, whereFilter?: QueryFieldFilterConstraint, ): Unsubscribe { let q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection)); if (visibilityScopes.length > 0) { q = query( q, where("visible_to", "array-contains-any", visibilityScopes), ); } if (whereFilter) { q = query(q, whereFilter); } return onSnapshot( q, (snap) => { const updatedChildren = snap.docs.map((d) => d.data()); onData(updatedChildren); if (onAdded || onRemoved) { for (const change of snap.docChanges()) { if (change.type === "added" && onAdded) onAdded(change.doc.data()); if (change.type === "removed" && onRemoved) onRemoved(change.doc.data(), updatedChildren); } } }, onError, ); } export function subscribeToLatestChild( collectionPath: string, onData: (child: Particle | null) => void, onError: (error: Error) => void, ): Unsubscribe { const q = query( typedCollection(collectionPath), orderBy("created_at", "desc"), limit(1), ); return onSnapshot( q, (snap) => { onData(snap.empty ? null : snap.docs[0].data()); }, onError, ); } // This creates a new particle document with the given properties and returns its ID. export async function createParticle( collectionPath: string, type: T, properties: ParticlePropertiesMap[T], createdByHumanId: string, // Must be passed for container types visibleTo?: string[], ): Promise { if (isContainerType(type) && (!visibleTo || visibleTo.length === 0)) { throw new Error( `visibleTo is required for container type ${type} and cannot be empty`, ); } const particle: Particle = ParticleSchema.parse({ id: "", // ignored by toFirestore, but needed to satisfy the type type, properties, created_at: new Date(), created_by_human_id: createdByHumanId, ...(visibleTo ? { 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 updateParticleProperties( docPath: string, properties: Partial, ): Promise { 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 = {}; for (const key in properties) { updatedProperties[`properties.${key}`] = properties[key]; } await updateDoc(particleRef, { ...updatedProperties, updated_at: serverTimestamp(), }); } export async function updateParticleVisibleTo( docPath: string, visibleTo: string[], ): Promise { const particleRef = typedDoc(docPath); const particle = await getParticle(docPath); if (!particle) { throw new Error(`Particle not found at path: ${docPath}`); } if (!isContainerType(particle.type)) { throw new Error( `Only container particles can have visible_to field. Particle at ${docPath} is of type ${particle.type}`, ); } await updateDoc(particleRef, { visible_to: visibleTo, updated_at: serverTimestamp(), }); } // CAUTION: use the other type safe update functions in most cases // There is no checking whether this field actually exists on the particle type, so it can lead to inconsistent data if used incorrectly export async function updateParticle( docPath: string, fieldName: string, value: any, ): Promise { const particleRef = typedDoc(docPath); await updateDoc(particleRef, { [fieldName]: value, updated_at: serverTimestamp(), }); } export async function updateStreamPlaybackMarker( docPath: string, humanId: string, playbackPositionAt: Date, ): Promise { const particleRef = typedDoc(docPath); const markerField = `playback_markers.${humanId}`; await updateDoc(particleRef, { [markerField]: Timestamp.fromDate(playbackPositionAt), updated_at: serverTimestamp(), }); }