diff --git a/firestore.indexes.json b/firestore.indexes.json index 94fccba4..93e19249 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -1,19 +1,5 @@ { "indexes": [ - { - "collectionGroup": "documents", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "dependencies", - "arrayConfig": "CONTAINS" - }, - { - "fieldPath": "rendered", - "order": "ASCENDING" - } - ] - }, { "collectionGroup": "documents", "queryScope": "COLLECTION", @@ -23,11 +9,7 @@ "order": "ASCENDING" }, { - "fieldPath": "status", - "order": "ASCENDING" - }, - { - "fieldPath": "published", + "fieldPath": "createdAt", "order": "DESCENDING" } ] @@ -41,21 +23,7 @@ "order": "ASCENDING" }, { - "fieldPath": "title", - "order": "ASCENDING" - } - ] - }, - { - "collectionGroup": "documents", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "documentType", - "order": "ASCENDING" - }, - { - "fieldPath": "title", + "fieldPath": "updatedAt", "order": "DESCENDING" } ] @@ -69,21 +37,7 @@ "order": "ASCENDING" }, { - "fieldPath": "updated", - "order": "ASCENDING" - } - ] - }, - { - "collectionGroup": "documents", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "documentType", - "order": "ASCENDING" - }, - { - "fieldPath": "updated", + "fieldPath": "publishedAt", "order": "DESCENDING" } ] @@ -101,166 +55,7 @@ "order": "ASCENDING" } ] - }, - { - "collectionGroup": "documents", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "status", - "order": "ASCENDING" - }, - { - "fieldPath": "title", - "order": "ASCENDING" - } - ] - }, - { - "collectionGroup": "documents", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "status", - "order": "ASCENDING" - }, - { - "fieldPath": "title", - "order": "DESCENDING" - } - ] - }, - { - "collectionGroup": "documents", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "status", - "order": "ASCENDING" - }, - { - "fieldPath": "updated", - "order": "ASCENDING" - } - ] - }, - { - "collectionGroup": "documents", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "status", - "order": "ASCENDING" - }, - { - "fieldPath": "updated", - "order": "DESCENDING" - } - ] - }, - { - "collectionGroup": "files", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "fileType", - "order": "ASCENDING" - }, - { - "fieldPath": "updated", - "order": "DESCENDING" - } - ] - }, - { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "isRead", - "order": "ASCENDING" - }, - { - "fieldPath": "created", - "order": "DESCENDING" - } - ] - }, - { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "isRead", - "order": "ASCENDING" - }, - { - "fieldPath": "updated", - "order": "DESCENDING" - } - ] - }, - { - "collectionGroup": "themes", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "fileType", - "order": "ASCENDING" - }, - { - "fieldPath": "updated", - "order": "DESCENDING" - } - ] } ], - "fieldOverrides": [ - { - "collectionGroup": "user", - "fieldPath": "uid", - "ttl": false, - "indexes": [ - { - "order": "ASCENDING", - "queryScope": "COLLECTION" - }, - { - "order": "DESCENDING", - "queryScope": "COLLECTION" - }, - { - "arrayConfig": "CONTAINS", - "queryScope": "COLLECTION" - }, - { - "order": "ASCENDING", - "queryScope": "COLLECTION_GROUP" - } - ] - }, - { - "collectionGroup": "user-invites", - "fieldPath": "email", - "ttl": false, - "indexes": [ - { - "order": "ASCENDING", - "queryScope": "COLLECTION" - }, - { - "order": "DESCENDING", - "queryScope": "COLLECTION" - }, - { - "arrayConfig": "CONTAINS", - "queryScope": "COLLECTION" - }, - { - "order": "ASCENDING", - "queryScope": "COLLECTION_GROUP" - } - ] - } - ] + "fieldOverrides": [] } diff --git a/firestore.rules b/firestore.rules index 5048beaf..3417d472 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1,14 +1,24 @@ service cloud.firestore { match /databases/{database}/documents { + match /tanam/{siteId} { + allow read: if true; + match /documents/{documentId} { + allow read: if true; + } + } match /tanam-types/{typeId} { - allow read: if hasAnyRole(); - allow write: if isAtLeastAdmin(); + allow read: if true; + allow write: if true; + match /fields/{fieldId} { + allow read: if true; + allow write: if true; + } } match /tanam-documents/{documentId} { - allow read: if hasAnyRole(); - allow write: if isPublisher(); + allow read: if true; + allow write: if true; match /revisions/{revisionId} { allow read: if hasAnyRole(); @@ -16,12 +26,40 @@ service cloud.firestore { } } + match /files/{fileId} { + allow read: if hasAnyRole(); + allow write: if isPublisher(); + } + + match /notifications/{notificationId} { + allow read: if isAtLeastAdmin(); + allow write: if isAtLeastAdmin(); + } + + match /themes/{document=**} { + allow read: if hasAnyRole(); + allow write: if isAtLeastAdmin(); + } + + match /users/{document=**} { + allow read, write: if isAtLeastAdmin(); + } + + match /user-invites/{document=**} { + allow read, write: if isSuperAdmin(); + } + + match /users/{userId} { + allow read: if isSignedInAs(userId); + allow write: if isSignedInAs(userId); + } + function hasUserRole(role) { - return isSignedIn() && role == request.auth.token.tanamRole; + return isSignedIn() && role in request.auth.token.tanam[siteId]; } function hasAnyRole() { - return isSignedIn() && request.auth.token.tanamRole != null; + return isSignedIn() && request.auth.token.tanam[siteId].size() > 0; } function isSuperAdmin() { diff --git a/hosting/src/app/(protected)/[site]/dashboard/page.tsx b/hosting/src/app/(protected)/[site]/dashboard/page.tsx new file mode 100644 index 00000000..82be3f91 --- /dev/null +++ b/hosting/src/app/(protected)/[site]/dashboard/page.tsx @@ -0,0 +1,47 @@ +"use client"; +import Notification from "@/components/common/Notification"; +import Loader from "@/components/common/Loader"; +import ContentCard from "@/components/Containers/ContentCard"; +import {Table} from "@/components/Table"; +import {useTanamDocumentTypes} from "@/hooks/useTanamDocumentTypes"; +import {useParams} from "next/navigation"; +import {Suspense} from "react"; + +export default function DashboardPage() { + const {site} = useParams<{site: string}>() ?? { + site: null, + }; + const {data: documentTypes, totalRecords: typeTotalRecords, error: typeError} = useTanamDocumentTypes(); + + if (typeError) { + return ( + <> + + + ); + } + + return ( + <> + {site && ( +
+ + }> +
+ [ +
{documentType.id}
, +
{documentType.documentTitleField}
, +

{documentType.createdAt.toDate().toUTCString()}

, + ])} + totalRecords={typeTotalRecords} + /> + + + + + )} + + ); +} diff --git a/hosting/src/components/Sidebar/Sidebar.tsx b/hosting/src/components/Sidebar/Sidebar.tsx index ea7cf4fd..11da30e3 100644 --- a/hosting/src/components/Sidebar/Sidebar.tsx +++ b/hosting/src/components/Sidebar/Sidebar.tsx @@ -4,10 +4,10 @@ import Image from "next/image"; import Link from "next/link"; import {usePathname} from "next/navigation"; import {useEffect, useRef, useState} from "react"; -import {useTanamDocumentTypes} from "../../hooks/useTanamDocumentTypes"; +import {useTanamDocumentTypes} from "@/hooks/useTanamDocumentTypes"; import {SidebarExpandableMenu, SidebarExpandableMenuSubItem} from "./SidebarExpandableMenu"; -import {SidebarMenuGroup} from "./SidebarMenuGroup"; -import {SidebarMenuItem} from "./SidebarMenuItem"; +import {SidebarMenuGroup} from "@/components/Sidebar/SidebarMenuGroup"; +import {SidebarMenuItem} from "@/components/Sidebar/SidebarMenuItem"; interface SidebarProps { sidebarOpen: boolean; diff --git a/hosting/src/components/Table/Table.tsx b/hosting/src/components/Table/Table.tsx index 6ce20267..7e9cdf60 100644 --- a/hosting/src/components/Table/Table.tsx +++ b/hosting/src/components/Table/Table.tsx @@ -3,6 +3,7 @@ import React from "react"; interface TableProps { headers: string[]; rows: React.ReactNode[][]; + totalRecords?: number; } /** @@ -28,7 +29,7 @@ interface TableProps { * @param {TableProps} param0 Table parameters * @return {JSX.Element} Table component */ -export function Table({headers, rows}: TableProps): JSX.Element { +export function Table({headers, rows, totalRecords}: TableProps): JSX.Element { return (
@@ -54,6 +55,8 @@ export function Table({headers, rows}: TableProps): JSX.Element { ))}
+ +
Total Records: {totalRecords ?? rows.length}
); diff --git a/hosting/src/hooks/useTanamDocumentFields.tsx b/hosting/src/hooks/useTanamDocumentFields.tsx index e71e122f..51bb475d 100644 --- a/hosting/src/hooks/useTanamDocumentFields.tsx +++ b/hosting/src/hooks/useTanamDocumentFields.tsx @@ -7,6 +7,7 @@ import {UserNotification} from "@/models/UserNotification"; interface TanamDocumentFieldHook { data: TanamDocumentField[]; + totalRecords: number; error: UserNotification | null; } @@ -21,6 +22,7 @@ export function useTanamDocumentFields(documentTypeId?: string): TanamDocumentFi documentTypeId: null, }; const [data, setData] = useState([]); + const [totalRecords, setTotalRecords] = useState(0); const [error, setError] = useState(null); useEffect(() => { @@ -38,6 +40,7 @@ export function useTanamDocumentFields(documentTypeId?: string): TanamDocumentFi const documentTypes = snapshot.docs.map( (doc) => new TanamDocumentField(doc.id, doc.data() as ITanamDocumentField), ); + setTotalRecords(snapshot.size); setData(documentTypes); }, (err) => { @@ -49,5 +52,5 @@ export function useTanamDocumentFields(documentTypeId?: string): TanamDocumentFi return () => unsubscribe(); }, [documentTypeId, paramType]); - return {data, error}; + return {data, totalRecords, error}; } diff --git a/hosting/src/hooks/useTanamDocumentTypes.tsx b/hosting/src/hooks/useTanamDocumentTypes.tsx index c5a61d98..0577c6a2 100644 --- a/hosting/src/hooks/useTanamDocumentTypes.tsx +++ b/hosting/src/hooks/useTanamDocumentTypes.tsx @@ -7,11 +7,13 @@ import {UserNotification} from "@/models/UserNotification"; interface TanamDocumentTypeHook { data: TanamDocumentTypeClient[]; + totalRecords: number; error: UserNotification | null; } interface SingleTanamDocumentTypeHook { data: TanamDocumentTypeClient | null; + totalRecords: number; error: UserNotification | null; } @@ -22,6 +24,7 @@ interface SingleTanamDocumentTypeHook { */ export function useTanamDocumentTypes(): TanamDocumentTypeHook { const [data, setData] = useState([]); + const [totalRecords, setTotalRecords] = useState(0); const [error, setError] = useState(null); useEffect(() => { @@ -31,6 +34,7 @@ export function useTanamDocumentTypes(): TanamDocumentTypeHook { collectionRef, (snapshot) => { const documentTypes = snapshot.docs.map((doc) => TanamDocumentTypeClient.fromFirestore(doc)); + setTotalRecords(snapshot.size); setData(documentTypes); }, (err) => { @@ -42,7 +46,7 @@ export function useTanamDocumentTypes(): TanamDocumentTypeHook { return () => unsubscribe(); }, []); - return {data, error}; + return {data, totalRecords, error}; } /** @@ -56,11 +60,13 @@ export function useTanamDocumentType(documentTypeId?: string): SingleTanamDocume documentTypeId: null, }; const [data, setData] = useState(null); + const [totalRecords, setTotalRecords] = useState(0); const [error, setError] = useState(null); useEffect(() => { const typeId = documentTypeId ?? paramType; if (!typeId) { + setTotalRecords(0); setData(null); return; } @@ -71,6 +77,7 @@ export function useTanamDocumentType(documentTypeId?: string): SingleTanamDocume docRef, (doc) => { if (doc.exists()) { + setTotalRecords(1); setData(TanamDocumentTypeClient.fromFirestore(doc)); } else { setError(new UserNotification("error", "Error fetching data", "Document type not found")); @@ -85,5 +92,5 @@ export function useTanamDocumentType(documentTypeId?: string): SingleTanamDocume return () => unsubscribe(); }, [documentTypeId, paramType]); - return {data, error}; + return {data, totalRecords, error}; } diff --git a/hosting/src/hooks/useTanamDocuments.tsx b/hosting/src/hooks/useTanamDocuments.tsx index 83a06242..9372850e 100644 --- a/hosting/src/hooks/useTanamDocuments.tsx +++ b/hosting/src/hooks/useTanamDocuments.tsx @@ -1,12 +1,25 @@ +import {useParams} from "next/navigation"; import {TanamDocumentClient} from "@/models/TanamDocumentClient"; import {firestore} from "@/plugins/firebase"; import {ITanamDocument} from "@functions/models/TanamDocument"; -import {Timestamp, collection, doc, onSnapshot, query, serverTimestamp, updateDoc, where} from "firebase/firestore"; +import { + Timestamp, + collection, + doc, + onSnapshot, + query, + serverTimestamp, + updateDoc, + where, + limit, + orderBy, +} from "firebase/firestore"; import {useEffect, useState} from "react"; import {UserNotification} from "@/models/UserNotification"; interface UseTanamDocumentsResult { data: TanamDocumentClient[]; + totalRecords: number; error: UserNotification | null; } @@ -18,6 +31,7 @@ interface UseTanamDocumentsResult { */ export function useTanamDocuments(documentTypeId?: string): UseTanamDocumentsResult { const [data, setData] = useState([]); + const [totalRecords, setTotalRecords] = useState(0); const [error, setError] = useState(null); useEffect(() => { @@ -33,6 +47,7 @@ export function useTanamDocuments(documentTypeId?: string): UseTanamDocumentsRes q, (snapshot) => { const documents = snapshot.docs.map((doc) => TanamDocumentClient.fromFirestore(doc)); + setTotalRecords(snapshot.size); setData(documents); }, (err) => { @@ -44,7 +59,64 @@ export function useTanamDocuments(documentTypeId?: string): UseTanamDocumentsRes return () => unsubscribe(); }, [documentTypeId]); - return {data, error}; + return {data, totalRecords, error}; +} + +type RecentField = "createdAt" | "updatedAt" | "publishedAt"; +/** + * Hook to get a stream of documents of a specific content type + * + * @param {RecentField} recentField Optional limit of documents to fetch (default to 10). + * @param {number} numResults Optional limit of documents to fetch (default to 10). + * @param {string?} documentTypeId Optional document type (default to content parameter from URL). + * @return {UseTanamDocumentsResult} Hook for documents subscription + */ +export function useTanamRecentDocuments( + recentField: RecentField, + numResults = 10, + documentTypeId?: string, +): UseTanamDocumentsResult { + const {site} = useParams<{site: string}>() ?? { + site: null, + }; + const [data, setData] = useState([]); + const [totalRecords, setTotalRecords] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + if (!site) { + setError(new UserNotification("error", "Missing parameter", "Site parameter is missing")); + return; + } + + const collectionRef = collection(firestore, `tanam/${site}/documents`); + const queryConstraints = []; + if (documentTypeId) { + queryConstraints.push(where("documentType", "==", documentTypeId)); + } + + queryConstraints.push(orderBy(recentField, "desc")); + queryConstraints.push(limit(numResults)); + + const q = query(collectionRef, ...queryConstraints); + + const unsubscribe = onSnapshot( + q, + (snapshot) => { + const documents = snapshot.docs.map((doc) => TanamDocumentClient.fromFirestore(doc)); + setTotalRecords(snapshot.size); + setData(documents); + }, + (err) => { + setError(new UserNotification("error", "Something wrong", err.message)); + }, + ); + + // Cleanup subscription on unmount + return () => unsubscribe(); + }, [site]); + + return {data, totalRecords, error}; } interface UseTanamDocumentResult {