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 2074b0dd..37674a24 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,4 +3,5 @@ import admin from "firebase-admin"; const app = admin.initializeApp(); app.firestore().settings({ignoreUndefinedProperties: true}); +export * from "./document-publish"; export * from "./triggers/users"; diff --git a/functions/src/models/TanamDocument.ts b/functions/src/models/TanamDocument.ts index 67b04439..642194fa 100644 --- a/functions/src/models/TanamDocument.ts +++ b/functions/src/models/TanamDocument.ts @@ -8,9 +8,9 @@ export interface ITanamDocument { data: DocumentData; documentType: string; revision?: number; - publishedAt?: Date; - createdAt: TimestampType; - updatedAt: TimestampType; + publishedAt?: TimestampType; + createdAt?: TimestampType; + updatedAt?: TimestampType; } export abstract class TanamDocument { @@ -27,16 +27,14 @@ 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; + 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"; } diff --git a/functions/src/models/TanamDocumentAdmin.ts b/functions/src/models/TanamDocumentAdmin.ts index fa8aec7d..7b4ec7b3 100644 --- a/functions/src/models/TanamDocumentAdmin.ts +++ b/functions/src/models/TanamDocumentAdmin.ts @@ -1,6 +1,6 @@ import {FieldValue, Timestamp} from "firebase-admin/firestore"; -import {ITanamDocument, TanamDocument} from "./TanamDocument"; import {DocumentSnapshot} from "firebase-functions/v2/firestore"; +import {ITanamDocument, TanamDocument} from "./TanamDocument"; export class TanamDocumentAdmin extends TanamDocument { constructor(id: string, json: ITanamDocument) { diff --git a/hosting/package-lock.json b/hosting/package-lock.json index b022b0dc..b39a1f2a 100644 --- a/hosting/package-lock.json +++ b/hosting/package-lock.json @@ -8,6 +8,7 @@ "name": "tanam-next", "version": "0.1.0", "dependencies": { + "@iconify-json/line-md": "^1.1.37", "@tiptap/extension-code-block-lowlight": "^2.4.0", "@tiptap/extension-floating-menu": "^2.5.5", "@tiptap/extension-underline": "^2.4.0", @@ -763,6 +764,14 @@ "@iconify/types": "*" } }, + "node_modules/@iconify-json/line-md": { + "version": "1.1.37", + "resolved": "https://registry.npmjs.org/@iconify-json/line-md/-/line-md-1.1.37.tgz", + "integrity": "sha512-qGezTafsQOX4N2STOV0gsSw/vr0u7DPXtg0jpVQTIa9ht1IXN68O+Qy7Ia2dHbuW4w5nz3UyhJzNrh7NB5T7BA==", + "dependencies": { + "@iconify/types": "*" + } + }, "node_modules/@iconify-json/ri": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/@iconify-json/ri/-/ri-1.1.20.tgz", @@ -775,8 +784,7 @@ "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==" }, "node_modules/@iconify/utils": { "version": "2.1.24", diff --git a/hosting/package.json b/hosting/package.json index 1fbf845b..69fa6c4b 100644 --- a/hosting/package.json +++ b/hosting/package.json @@ -12,6 +12,7 @@ "codecheck": "npm run prettier:fix && npm run lint:fix" }, "dependencies": { + "@iconify-json/line-md": "^1.1.37", "@tiptap/extension-code-block-lowlight": "^2.4.0", "@tiptap/extension-floating-menu": "^2.5.5", "@tiptap/extension-underline": "^2.4.0", diff --git a/hosting/src/app/(protected)/content/[documentTypeId]/page.tsx b/hosting/src/app/(protected)/content/[documentTypeId]/page.tsx index 0cb670a8..e7c4851c 100644 --- a/hosting/src/app/(protected)/content/[documentTypeId]/page.tsx +++ b/hosting/src/app/(protected)/content/[documentTypeId]/page.tsx @@ -8,6 +8,7 @@ import {useTanamDocuments} from "@/hooks/useTanamDocuments"; import {Suspense, useEffect, useState} from "react"; import {useParams} from "next/navigation"; import {UserNotification} from "@/models/UserNotification"; +import {Button} from "@/components/Button"; export default function DocumentTypeDocumentsPage() { const {documentTypeId} = useParams<{documentTypeId: string}>() ?? {}; @@ -19,10 +20,25 @@ export default function DocumentTypeDocumentsPage() { setNotification(docsError); }, [docsError]); + const addNewDocument = async () => {}; + return ( <> }> - {documentType ? : } + {documentType ? ( +
+ {" "} +
+
+
+ ) : ( + + )}
{notification && ( diff --git a/hosting/src/app/(protected)/content/article/[documentId]/page.tsx b/hosting/src/app/(protected)/content/article/[documentId]/page.tsx index 1bfc5aca..20554a37 100644 --- a/hosting/src/app/(protected)/content/article/[documentId]/page.tsx +++ b/hosting/src/app/(protected)/content/article/[documentId]/page.tsx @@ -12,7 +12,7 @@ export default function DocumentDetailsPage() { const router = useRouter(); const {documentId} = useParams<{documentId: string}>() ?? {}; const {data: document, error: documentError} = useTanamDocument(documentId); - const {update, error: writeError} = useCrudTanamDocument(documentId); + const {update, error: writeError} = useCrudTanamDocument(); const [readonlyMode] = useState(false); const [notification, setNotification] = useState(null); if (!!document?.documentType && document?.documentType !== "article") { @@ -26,7 +26,12 @@ export default function DocumentDetailsPage() { async function onDocumentContentChange(content: string) { console.log("[onDocumentContentChange]", content); - await update({data: {...document?.data, content}}); + if (!document) { + return; + } + + document.data.content = content; + await update(document); } return ( diff --git a/hosting/src/app/(protected)/content/article/page.tsx b/hosting/src/app/(protected)/content/article/page.tsx index b850db2a..dea7e72f 100644 --- a/hosting/src/app/(protected)/content/article/page.tsx +++ b/hosting/src/app/(protected)/content/article/page.tsx @@ -1,27 +1,53 @@ "use client"; +import {Button} from "@/components/Button"; import {DocumentTypeGenericList} from "@/components/DocumentType/DocumentTypeGenericList"; import Loader from "@/components/common/Loader"; import Notification from "@/components/common/Notification"; import PageHeader from "@/components/common/PageHeader"; import {useTanamDocumentType} from "@/hooks/useTanamDocumentTypes"; -import {useTanamDocuments} from "@/hooks/useTanamDocuments"; +import {useCrudTanamDocument, useTanamDocuments} from "@/hooks/useTanamDocuments"; import {UserNotification} from "@/models/UserNotification"; +import {useRouter} from "next/navigation"; import {Suspense, useEffect, useState} from "react"; export default function DocumentTypeDocumentsPage() { const {data: documentType} = useTanamDocumentType("article"); + const {create, error: crudError} = useCrudTanamDocument(); const {data: documents, error: docsError} = useTanamDocuments("article"); const [notification, setNotification] = useState(null); + const router = useRouter(); useEffect(() => { - setNotification(docsError); - }, [docsError]); + setNotification(docsError || crudError); + }, [docsError, crudError]); + + const addNewArticle = async () => { + const id = await create(documentType?.id); + + if (!id) return; + + router.push(`article/${id}`); + }; return ( <> }> - {documentType ? : } + {documentType ? ( +
+ +
+
+
+ ) : ( + + )}
+ {notification && ( )} diff --git a/hosting/src/components/Button.tsx b/hosting/src/components/Button.tsx index fec5a906..b3849fc9 100644 --- a/hosting/src/components/Button.tsx +++ b/hosting/src/components/Button.tsx @@ -36,16 +36,16 @@ export function Button({title, onClick, style = "rounded", color = "primary", ch switch (color) { case "primary": - styles.push("bg-primary", "text-white"); + styles.push("bg-primary", !style ? "text-white" : ""); break; case "meta-3": - styles.push("bg-meta-3", "text-white"); + styles.push("bg-meta-3", !style ? "text-white" : ""); break; case "black": - styles.push("bg-black", "text-white"); + styles.push("bg-black", !style ? "text-white" : ""); break; default: - styles.push("bg-primary", "text-white"); + styles.push("bg-primary", !style ? "text-white" : ""); } switch (style) { diff --git a/hosting/src/components/DocumentType/DocumentTypeGenericList.tsx b/hosting/src/components/DocumentType/DocumentTypeGenericList.tsx index 667cb3a7..770f417f 100644 --- a/hosting/src/components/DocumentType/DocumentTypeGenericList.tsx +++ b/hosting/src/components/DocumentType/DocumentTypeGenericList.tsx @@ -18,7 +18,7 @@ export function DocumentTypeGenericList({documents, documentType}: TableOverview

{document.data[documentType.titleField] as string}

,

- {document.createdAt.toDate().toUTCString()} + {document.createdAt?.toDate().toUTCString()}

,
+