diff --git a/functions/src/config.ts b/functions/src/config.ts index 3d13420c..34888262 100644 --- a/functions/src/config.ts +++ b/functions/src/config.ts @@ -1,4 +1,27 @@ export class TanamConfig { + static get projectId(): string { + const projectId = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || process.env.GCP_PROJECT; + + if (!projectId) { + throw new Error("Could not find project ID in any variable"); + } + + return projectId; + } + + static get cloudFunctionRegion(): string { + return "us-central1"; + } + + /** + * Get flag for whether the functions are running in emulator or not. + * This can be derived by checking the existence of any emulator provided + * variables. + */ + static get isEmulated(): boolean { + return !!process.env.FIREBASE_EMULATOR_HUB; + } + static get databaseName(): string { return process.env.database || "(default)"; } diff --git a/functions/src/document-publish.ts b/functions/src/document-publish.ts new file mode 100644 index 00000000..f1afbd4c --- /dev/null +++ b/functions/src/document-publish.ts @@ -0,0 +1,173 @@ +import * as admin from "firebase-admin"; +import {Timestamp} from "firebase-admin/firestore"; +import {getFunctions} from "firebase-admin/functions"; +import {getStorage} from "firebase-admin/storage"; +import {logger} from "firebase-functions/v2"; +import {onDocumentWritten} from "firebase-functions/v2/firestore"; +import {onTaskDispatched} from "firebase-functions/v2/tasks"; +import {ITanamDocument} from "./models/TanamDocument"; +import {TanamDocumentAdmin} from "./models/TanamDocumentAdmin"; + +const db = admin.firestore(); +const storage = getStorage().bucket(); + +// Document publish change handler +// This function is handling updates when a document is published or unpublished. +// It will ignore updates that does not change the publish status of the document. +export const onPublishChange = onDocumentWritten("tanam-documents/{documentId}", async (event) => { + const documentId = event.params.documentId; + const unpublishQueue = getFunctions().taskQueue("taskUnpublishDocument"); + const publishQueue = getFunctions().taskQueue("taskPublishDocument"); + if (!event.data || !event.data.after.exists) { + logger.info("Document was deleted. Unpublishing document."); + return unpublishQueue.enqueue({documentId}); + } + + const documentBeforeData = (event.data.before.data() || {}) as ITanamDocument; + const documentBefore = new TanamDocumentAdmin(documentId, documentBeforeData); + + const documentAfterData = (event.data.after.data() || {}) as ITanamDocument; + const documentAfter = new TanamDocumentAdmin(documentId, documentAfterData); + + if (documentBefore.status === documentAfter.status) { + logger.info("Document status did not change. Skipping."); + return; + } + + if (documentAfter.status === "published") { + logger.info("Document was published."); + return publishQueue.enqueue({documentId}); + } + + if (documentBefore.status === "published") { + logger.info("Document was unpublished", documentAfter.toJson()); + await unpublishQueue.enqueue({documentId}); + } + + const publishedAt = documentAfter.publishedAt?.toDate(); + if (documentAfter.status === "scheduled" && !!publishedAt) { + logger.info("Document has been scheduled", documentAfter.toJson()); + if (publishedAt !== normalizeDateToMaxOffset(publishedAt)) { + logger.error("Scheduled date is too far in the future", documentAfter.toJson()); + throw new Error("Scheduled date is too far in the future"); + } + + logger.info(`Enqueueing document for publishing at ${publishedAt}`, documentAfter.toJson()); + await publishQueue.enqueue( + {documentId}, + { + scheduleTime: publishedAt, + }, + ); + } +}); + +// Task to publish a document +// This task is responsible for copying the document data to the public collection +// and copying associated files to the cloud storage public directory. +export const taskPublishDocument = onTaskDispatched( + { + retryConfig: { + maxAttempts: 3, + minBackoffSeconds: 60, + }, + rateLimits: { + // Try to give room for concurrency of documents in firestore + maxDispatchesPerSecond: 1, + }, + }, + async (req) => { + const documentId = req.data.documentId; + const documentRef = db.collection("tanam-documents").doc(documentId); + const publicDocumentRef = db.collection("tanam-public").doc(documentId); + const snap = await documentRef.get(); + + if (!snap.exists) { + logger.error(`Document does not exist anymore: ${documentId}`); + return; + } + + const documentData = snap.data(); + if (!documentData) { + logger.error(`Document data is empty: ${documentId}`); + return; + } + const document = new TanamDocumentAdmin(documentId, documentData as ITanamDocument); + + if (document.status !== "published") { + // This could happen if the document changed status while the task was in the queue + logger.info("Document is no longer published. Stop here."); + return; + } + + const promises = []; + + // Copy document data to public collection + promises.push(publicDocumentRef.set(documentData)); + + // Copy associated files to public directory + const [files] = await storage.getFiles({prefix: `tanam-documents/${documentId}/`}); + for (const file of files) { + const publishedFileName = file.name.replace("tanam-documents/", "tanam-public/"); + promises.push(storage.file(file.name).copy(storage.file(publishedFileName))); + } + + await Promise.all(promises); + }, +); + +// Task to unpublish a document +// This task is responsible for removing the document from the public collection +// and deleting associated files from the cloud storage public directory. +export const taskUnpublishDocument = onTaskDispatched( + { + retryConfig: { + maxAttempts: 3, + minBackoffSeconds: 60, + }, + rateLimits: { + // Adjust for concurrency of documents in firestore + maxDispatchesPerSecond: 1, + }, + }, + async (req) => { + const documentId = req.data.documentId; + const publicDocumentRef = db.collection("tanam-public").doc(documentId); + const documentRef = db.collection("tanam-documents").doc(documentId); + const snap = await documentRef.get(); + + const documentData = snap.data(); + const document = new TanamDocumentAdmin(documentId, documentData as ITanamDocument); + + if (document.status === "published") { + // This could happen if the document changed status while the task was in the queue + logger.info("Document is in status published. Stop here."); + return; + } + + // Remove document from public collection + const promises = [publicDocumentRef.delete()]; + + // Delete associated files from public directory + const [files] = await storage.getFiles({prefix: `tanam-public/${documentId}/`}); + for (const file of files) { + promises.push(storage.file(file.name).delete().then()); + } + + await Promise.all(promises); + }, +); + +/** + * Normalize a date to a maximum offset from now + * + * @param {Date} date A date to normalize + * @param {number} hours Optional. Default is 720 hours (30 days) + * @return {Date} The normalized date + */ +function normalizeDateToMaxOffset(date: Date, hours = 720): Date { + const now = new Date(); + const maxDate = new Date(now.getTime() + hours * 60 * 60 * 1000); + + return date > maxDate ? maxDate : date; +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 3cd59b25..0a4f8157 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -4,3 +4,4 @@ const app = admin.initializeApp(); app.firestore().settings({ignoreUndefinedProperties: true}); export * from "./genkit"; +export * from "./document-publish"; diff --git a/functions/src/models/TanamDocument.ts b/functions/src/models/TanamDocument.ts index 67b04439..f24f2e5c 100644 --- a/functions/src/models/TanamDocument.ts +++ b/functions/src/models/TanamDocument.ts @@ -8,7 +8,7 @@ export interface ITanamDocument { data: DocumentData; documentType: string; revision?: number; - publishedAt?: Date; + publishedAt?: TimestampType; createdAt: TimestampType; updatedAt: TimestampType; } @@ -27,21 +27,12 @@ export abstract class TanamDocument { public readonly id: string; public data: DocumentData; public documentType: string; - public publishedAt?: Date; + public publishedAt?: TimestampType; public revision: number; public readonly createdAt: TimestampType; public readonly updatedAt: TimestampType; - get status(): TanamPublishStatus { - if (!this.publishedAt) { - return "unpublished"; - } else if (this.publishedAt > new Date()) { - return "scheduled"; - } else { - return "published"; - } - } - + abstract get status(): TanamPublishStatus; protected abstract getServerTimestamp(): FieldValueType; toJson(): object { diff --git a/functions/src/models/TanamDocumentAdmin.ts b/functions/src/models/TanamDocumentAdmin.ts index fa8aec7d..38cb866a 100644 --- a/functions/src/models/TanamDocumentAdmin.ts +++ b/functions/src/models/TanamDocumentAdmin.ts @@ -1,5 +1,5 @@ import {FieldValue, Timestamp} from "firebase-admin/firestore"; -import {ITanamDocument, TanamDocument} from "./TanamDocument"; +import {ITanamDocument, TanamDocument, TanamPublishStatus} from "./TanamDocument"; import {DocumentSnapshot} from "firebase-functions/v2/firestore"; export class TanamDocumentAdmin extends TanamDocument { @@ -7,6 +7,16 @@ export class TanamDocumentAdmin extends TanamDocument { super(id, json); } + get status(): TanamPublishStatus { + if (!this.publishedAt) { + return "unpublished"; + } else if (this.publishedAt.toMillis() > Timestamp.now().toMillis()) { + return "scheduled"; + } else { + return "published"; + } + } + getServerTimestamp(): FieldValue { return FieldValue.serverTimestamp(); } diff --git a/hosting/src/models/TanamDocumentClient.ts b/hosting/src/models/TanamDocumentClient.ts index 46a6b780..e0a7bd7e 100644 --- a/hosting/src/models/TanamDocumentClient.ts +++ b/hosting/src/models/TanamDocumentClient.ts @@ -1,11 +1,21 @@ -import {TanamDocument, ITanamDocument} from "@functions/models/TanamDocument"; -import {Timestamp, FieldValue, serverTimestamp, DocumentSnapshot} from "firebase/firestore"; +import {ITanamDocument, TanamDocument, TanamPublishStatus} from "@functions/models/TanamDocument"; +import {DocumentSnapshot, FieldValue, serverTimestamp, Timestamp} from "firebase/firestore"; export class TanamDocumentClient extends TanamDocument { constructor(id: string, json: ITanamDocument) { super(id, json); } + get status(): TanamPublishStatus { + if (!this.publishedAt) { + return "unpublished"; + } else if (this.publishedAt.toMillis() > Timestamp.now().toMillis()) { + return "scheduled"; + } else { + return "published"; + } + } + getServerTimestamp(): FieldValue { return serverTimestamp(); }