* upload & view attachments to particles * allow download of attachments
292 lines
8.4 KiB
TypeScript
292 lines
8.4 KiB
TypeScript
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<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);
|
|
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<Particle | null> {
|
|
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<Particle[]> {
|
|
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<T extends ParticleType>(
|
|
collectionPath: string,
|
|
type: T,
|
|
properties: ParticlePropertiesMap[T],
|
|
createdByHumanId: string,
|
|
// Must be passed for container types
|
|
visibleTo?: string[],
|
|
): Promise<string> {
|
|
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<T extends ParticleType>(
|
|
docPath: string,
|
|
properties: Partial<ParticlePropertiesMap[T]>,
|
|
): 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(),
|
|
});
|
|
}
|
|
|
|
export async function updateParticleVisibleTo(
|
|
docPath: string,
|
|
visibleTo: string[],
|
|
): Promise<void> {
|
|
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<void> {
|
|
const particleRef = typedDoc(docPath);
|
|
await updateDoc(particleRef, {
|
|
[fieldName]: value,
|
|
updated_at: serverTimestamp(),
|
|
});
|
|
}
|
|
|
|
export async function updateStreamPlaybackMarker(
|
|
docPath: string,
|
|
humanId: string,
|
|
playbackPositionAt: Date,
|
|
): Promise<void> {
|
|
const particleRef = typedDoc(docPath);
|
|
const markerField = `playback_markers.${humanId}`;
|
|
await updateDoc(particleRef, {
|
|
[markerField]: Timestamp.fromDate(playbackPositionAt),
|
|
updated_at: serverTimestamp(),
|
|
});
|
|
}
|