import { collection, doc, onSnapshot, addDoc, getDoc, getDocs, updateDoc, query, orderBy, limit, serverTimestamp, where, Timestamp, arrayUnion, arrayRemove, FieldPath, 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, Reactions, } from '@/api/types'; // --- Converter --- // Firestore stores timestamps as `Timestamp`; zod expects `Date`. Coerce the // per-type property fields that carry timestamps. function coerceLeafPropertyDates( type: ParticleType, properties: DocumentData | undefined, ): DocumentData | undefined { if (!properties) return properties; if (type === 'text' && properties.edited_at) { return { ...properties, edited_at: (properties.edited_at as Timestamp).toDate(), }; } if (type === 'event') { return { ...properties, start_at: (properties.start_at as Timestamp).toDate(), end_at: properties.end_at ? (properties.end_at as Timestamp).toDate() : undefined, }; } return properties; } 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, }); 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 'task': case 'event': case 'paper': { const properties = coerceLeafPropertyDates(type, 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 doc = await getDoc(typedDoc(docPath)); if (!doc.exists()) { return null; } return doc.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, }); 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 = {}; // eslint-disable-line @typescript-eslint/no-explicit-any 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: any, // eslint-disable-line @typescript-eslint/no-explicit-any ): Promise { const particleRef = typedDoc(docPath); await updateDoc(particleRef, { [fieldName]: value, 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(), }); } // Strip characters that Firestore's dotted field-path syntax treats specially. // Using FieldPath bypasses the parser, but we also forbid these in stored keys // so reaction maps stay portable across any future read path. const RESERVED_REACTION_CHARS = /[~*/[\]]/g; export function sanitizeReactionText(text: string): string { return text.replace(RESERVED_REACTION_CHARS, ''); } export async function toggleParticleReaction( docPath: string, emoji: string, humanId: string, currentReactions?: Reactions, ): Promise { const key = sanitizeReactionText(emoji); if (!key) return; const particleRef = typedDoc(docPath); const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false; await updateDoc( particleRef, new FieldPath('reactions', key), alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId), 'updated_at', serverTimestamp(), ); }