import { collection, doc, onSnapshot, addDoc, getDoc, getDocs, updateDoc, query, orderBy, limit, serverTimestamp, where, Timestamp, arrayUnion, arrayRemove, type DocumentData, type FirestoreDataConverter, type QueryDocumentSnapshot, type SnapshotOptions, type Unsubscribe, type QueryFieldFilterConstraint, } from "firebase/firestore"; import { firestoreDb } from "@/firebase"; import { isContainerType, ParticleSchema } from "@/api/types"; import type { Particle, ParticleType, ParticlePropertiesMap, Reactions, } from "@/api/types"; // --- Converter --- const particleConverter: FirestoreDataConverter = { toFirestore(particle: Particle): DocumentData { const { id: _id, created_at, updated_at, ...rest } = particle; const deletedAt = "deleted_at" in particle ? particle.deleted_at : undefined; return { ...rest, created_at: Timestamp.fromDate(created_at), ...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }), ...(deletedAt && { deleted_at: Timestamp.fromDate(deletedAt) }), }; }, 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, huddle_active_participants: raw.huddle_active_participants ?? undefined, status: raw.status ?? 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": { // Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text // particles carry `properties.edited_at`, so coerce it if present. const properties = type === "text" && raw.properties?.edited_at ? { ...raw.properties, edited_at: (raw.properties.edited_at as Timestamp).toDate(), } : raw.properties; return ParticleSchema.parse({ id: snap.id, type: raw.type, 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, reactions: raw.reactions ?? undefined, deleted_at: raw.deleted_at ? (raw.deleted_at as Timestamp).toDate() : undefined, deleted_by_human_id: raw.deleted_by_human_id ?? 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 docSnap = await getDoc(typedDoc(docPath)); if (!docSnap.exists()) { return null; } return docSnap.data(); } export interface GetParticleChildrenOptions { orderByField: string; orderDirection: "asc" | "desc"; } export async function getParticleChildren( collectionPath: string, { orderByField = "created_at", orderDirection = "asc", }: GetParticleChildrenOptions = { orderByField: "created_at", orderDirection: "asc", }, ): Promise { const q = query( typedCollection(collectionPath), orderBy(orderByField, orderDirection), ); const snap = await getDocs(q); return snap.docs.map((d) => d.data()); } export interface SubscribeToParticleChildrenOptions { onData: (children: Particle[]) => void; onError: (error: Error) => void; visibilityScopes?: string[]; orderByField?: string; orderDirection?: "asc" | "desc"; onAdded?: (child: Particle) => void; onRemoved?: (child: Particle, updatedChildren: Particle[]) => void; whereFilter?: QueryFieldFilterConstraint; /** Optional cap on results. Applied after order/where constraints. */ limit?: number; } export function subscribeToParticleChildren( collectionPath: string, { onData, onError, visibilityScopes = [], orderByField = "created_at", orderDirection = "desc", onAdded, onRemoved, whereFilter, limit: limitValue, }: SubscribeToParticleChildrenOptions, ): 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); } if (limitValue !== undefined) { q = query(q, limit(limitValue)); } 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; } export async function createStreamParticle( collectionPath: string, properties: ParticlePropertiesMap["stream"], createdByHumanId: string, visibleTo?: string[], ): Promise { if (!visibleTo || visibleTo.length === 0) { throw new Error("visibleTo is required for streams and cannot be empty"); } const particle: Particle = ParticleSchema.parse({ id: "", type: "stream", properties, created_at: new Date(), created_by_human_id: createdByHumanId, visible_to: visibleTo, status: "open", }); 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(), }); } // Edits the body of a text particle and stamps `properties.edited_at` so // readers can see that the message was edited (distinct from `updated_at`, // which is bumped by any write — visibility, reactions, etc.). export async function editTextParticleContent( docPath: string, content: string, ): Promise { const particleRef = typedDoc(docPath); await updateDoc(particleRef, { "properties.content": content, "properties.edited_at": serverTimestamp(), 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: unknown, ): Promise { const particleRef = typedDoc(docPath); await updateDoc(particleRef, { [fieldName]: value, updated_at: serverTimestamp(), }); } export async function updateStreamStatus( docPath: string, status: "open" | "closed", ): Promise { const particleRef = typedDoc(docPath); await updateDoc(particleRef, { status, updated_at: serverTimestamp() }); } /** * Soft-delete (tombstone) a non-container particle. The Firestore doc stays * in place so concurrent viewers see the deletion inline rather than being * bumped to an adjacent particle. Idempotent — re-calling on an already * tombstoned doc just refreshes the timestamp. */ export async function softDeleteParticle( docPath: string, humanId: string, ): Promise { const particleRef = typedDoc(docPath); await updateDoc(particleRef, { deleted_at: serverTimestamp(), deleted_by_human_id: humanId, 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(), }); } export async function toggleParticleReaction( docPath: string, emoji: string, humanId: string, currentReactions?: Reactions, ): Promise { const particleRef = typedDoc(docPath); const field = `reactions.${emoji}`; const alreadyReacted = currentReactions?.[emoji]?.includes(humanId) ?? false; await updateDoc(particleRef, { [field]: alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId), updated_at: serverTimestamp(), }); }