From afad79d5b12ee49702c2d7480e879413f52e4d5b Mon Sep 17 00:00:00 2001 From: Jan Phillips Date: Wed, 1 Oct 2025 10:54:52 +0100 Subject: [PATCH 01/11] feat(api): added columns path and documentCount to folder;path is set for new folders (APPLICS-1683) --- .../migration.sql | 20 ++++++ apps/api/src/database/schema.prisma | 2 + .../application/application.service.js | 4 +- .../application/application.validators.js | 1 + .../file-folders/folders.controller.js | 2 + .../examination-timetable-items.controller.js | 9 ++- .../__tests__/folder.repository.test.js | 18 +++++ .../server/repositories/case.repository.js | 6 +- .../server/repositories/folder.repository.js | 66 +++++++++++++++++-- 9 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/database/migrations/20250922072142_folder_document_count_and_path/migration.sql diff --git a/apps/api/src/database/migrations/20250922072142_folder_document_count_and_path/migration.sql b/apps/api/src/database/migrations/20250922072142_folder_document_count_and_path/migration.sql new file mode 100644 index 0000000000..9e40505bcd --- /dev/null +++ b/apps/api/src/database/migrations/20250922072142_folder_document_count_and_path/migration.sql @@ -0,0 +1,20 @@ +BEGIN TRY + +BEGIN TRAN; + +-- AlterTable +ALTER TABLE [dbo].[Folder] ADD [documentCount] INT, +[path] NVARCHAR(1000); + +COMMIT TRAN; + +END TRY +BEGIN CATCH + +IF @@TRANCOUNT > 0 +BEGIN + ROLLBACK TRAN; +END; +THROW + +END CATCH diff --git a/apps/api/src/database/schema.prisma b/apps/api/src/database/schema.prisma index 71f5b8268e..d553e1ed78 100644 --- a/apps/api/src/database/schema.prisma +++ b/apps/api/src/database/schema.prisma @@ -298,6 +298,8 @@ model Folder { childFolders Folder[] @relation("FolderTree") stage String? ExaminationTimetableItem ExaminationTimetableItem[] + documentCount Int? + path String? @@unique([caseId, displayNameEn, parentFolderId, deletedAt]) } diff --git a/apps/api/src/server/applications/application/application.service.js b/apps/api/src/server/applications/application/application.service.js index bb7125d5c5..7c8f4b537f 100644 --- a/apps/api/src/server/applications/application/application.service.js +++ b/apps/api/src/server/applications/application/application.service.js @@ -110,9 +110,9 @@ export const startApplication = async (id) => { data: {}, currentStatuses: caseDetails.CaseStatus, setReference: true - }, - folderRepository.createFolders(caseDetails.id) + } ); + await folderRepository.createFolders(caseDetails.id); if (!updatedCase) { throw new Error('Case does not exist'); diff --git a/apps/api/src/server/applications/application/application.validators.js b/apps/api/src/server/applications/application/application.validators.js index 5a0ae11107..24f9430569 100644 --- a/apps/api/src/server/applications/application/application.validators.js +++ b/apps/api/src/server/applications/application/application.validators.js @@ -342,4 +342,5 @@ export const verifyNotTraining = async (caseId) => { ) { throw new Error(`Case with ID ${caseId} is a training case.`); } + return projectWithSector; }; diff --git a/apps/api/src/server/applications/application/file-folders/folders.controller.js b/apps/api/src/server/applications/application/file-folders/folders.controller.js index 024aa0bdbc..166fe585a7 100644 --- a/apps/api/src/server/applications/application/file-folders/folders.controller.js +++ b/apps/api/src/server/applications/application/file-folders/folders.controller.js @@ -14,6 +14,7 @@ import { checkFoldersHaveNoDocuments, checkIfFolderIsCustom } from './folders.service.js'; +import { setPath } from '#repositories/folder.repository.js'; /** * Handles a GET request for multiple folders and sends the corresponding details in the request @@ -77,6 +78,7 @@ export const createFolder = async ({ params, body }, response) => { } const folder = await svcCreateFolder(params.id, body.name, body.parentFolderId); + await setPath(folder.id, folder.parentFolderId); response.send(folder); }; diff --git a/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js b/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js index dbe329023c..9c6444c3fe 100644 --- a/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js +++ b/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js @@ -19,6 +19,7 @@ import { EventType } from '@pins/event-client'; import { buildFolderPayload } from '#infrastructure/payload-builders/folder.js'; import { verifyNotTraining } from '../application/application.validators.js'; import { folderDocumentCaseStageMappings } from '../constants.js'; +import { setPath } from '#repositories/folder.repository.js'; /** @typedef {import('@pins/applications.api').Schema.Folder} Folder */ @@ -161,11 +162,15 @@ export const createExaminationTimetableItem = async ({ body }, response) => { throw new BackOfficeAppError('Failed to create sub folder for the examination item.', 500); } - const project = await caseRepository.getById(body.caseId, { sector: true }); + try { + await setPath(itemFolder.id, itemFolder.parentFolderId); + } catch (e) { + throw new BackOfficeAppError(`Failed to set path for folder ${itemFolder.id}`, 500); + } // now send broadcast event for folder creation - ignoring folders on training cases. try { - await verifyNotTraining(body.caseId); + const project = await verifyNotTraining(body.caseId); await eventClient.sendEvents( FOLDER, diff --git a/apps/api/src/server/repositories/__tests__/folder.repository.test.js b/apps/api/src/server/repositories/__tests__/folder.repository.test.js index e90d114203..71e0028c77 100644 --- a/apps/api/src/server/repositories/__tests__/folder.repository.test.js +++ b/apps/api/src/server/repositories/__tests__/folder.repository.test.js @@ -1,3 +1,5 @@ +import { setPath } from '../folder.repository.js'; + const { databaseConnector } = await import('../../utils/database-connector.js'); import * as folderRepository from '../folder.repository.js'; @@ -130,3 +132,19 @@ describe('Folder repository', () => { expect(updatedFolder).toEqual(updatedFolderExpected); }); }); + +describe('setPath', () => { + it('should update the folder path with the correct id', async () => { + const id = 42; + const expectedResult = { id, path: '/42/' }; + databaseConnector.folder.update.mockResolvedValue(expectedResult); + + const path = await setPath(id); + + expect(databaseConnector.folder.update).toHaveBeenCalledWith({ + where: { id }, + data: { path: '/42/' } + }); + expect(path).toEqual(expectedResult); + }); +}); diff --git a/apps/api/src/server/repositories/case.repository.js b/apps/api/src/server/repositories/case.repository.js index 761d27216e..df83b41622 100644 --- a/apps/api/src/server/repositories/case.repository.js +++ b/apps/api/src/server/repositories/case.repository.js @@ -606,14 +606,14 @@ const createNewStatuses = (id, status) => { * @param {number} id * @param {number |undefined} applicationDetailsId * @param {{status: string | object, data: {regionNames?: string[]}, currentStatuses: object[], setReference: boolean}} updateData - * @param {import('@prisma/client').PrismaPromise[]} additionalTransactions + * @param {Promise[] | null} additionalTransactions * @returns {Promise} */ export const updateApplicationStatusAndDataById = async ( id, applicationDetailsId, { status, data, currentStatuses, setReference = false }, - additionalTransactions + additionalTransactions = [] ) => { const { caseStatesToInvalidate, caseStatesToCreate } = separateStatusesToSaveAndInvalidate( status, @@ -637,7 +637,7 @@ export const updateApplicationStatusAndDataById = async ( transactions.push(assignApplicationReference(id)); } - transactions.push(...additionalTransactions); + if (additionalTransactions) transactions.push(...additionalTransactions); await databaseConnector.$transaction(transactions); diff --git a/apps/api/src/server/repositories/folder.repository.js b/apps/api/src/server/repositories/folder.repository.js index 2d3fb47471..ca7735c8e9 100644 --- a/apps/api/src/server/repositories/folder.repository.js +++ b/apps/api/src/server/repositories/folder.repository.js @@ -150,10 +150,38 @@ export const getFoldersByParentId = (parentFolderId, options = null) => { * @param {number|null} folder.displayOrder * @returns {Promise<(Folder |null)>} */ -export const createFolder = (folder, isCustom = true) => { - return databaseConnector.folder.create({ +export const createFolder = async (folder, isCustom = true) => { + const newFolder = await databaseConnector.folder.create({ data: { ...folder, isCustom } }); + await setPath(newFolder.id, newFolder.parentFolderId); + return newFolder; + // return databaseConnector.folder.create({ + // data: { ...folder, isCustom } + // }); +}; + +/** + * + * @param {number} id + * @param {number|null} parentFolderId + * @returns {*} + */ +// sets the path for the folder +export const setPath = async (id, parentFolderId) => { + if (parentFolderId) { + const parentFolder = await getById(parentFolderId); + if (parentFolder.path) { + return databaseConnector.folder.update({ + where: { id }, + data: { path: `${parentFolder.path}/${id}` } + }); + } + } + return databaseConnector.folder.update({ + where: { id }, + data: { path: `/${id}` } + }); }; /** @@ -249,8 +277,8 @@ const mapFolderTemplateWithCaseId = (caseId, folder) => { * @param {FolderTemplate[]} folders * @returns {Promise[]} */ -export const createFolders = (caseId, folders = defaultCaseFolders) => { - const foldersCreated = []; +export const createFolders = async (caseId, folders = defaultCaseFolders) => { + // const foldersCreated = []; // Prisma many to nested many does not work, so we cannot create the top folders and all subfolders nested using createMany. // so we loop through the top folders, using create to create the folder and all its subfolders, correctly assigning caseId, parentFolderId etc @@ -260,12 +288,36 @@ export const createFolders = (caseId, folders = defaultCaseFolders) => { data: mapFolderTemplateWithCaseId(caseId, topLevelFolder) }; - const topFoldersCreated = databaseConnector.folder.create(newFolders); + const topFoldersCreated = await databaseConnector.folder.create(newFolders); + await recursivelySetPaths(topFoldersCreated.id); + // foldersCreated.push(topFoldersCreated); + } + // return foldersCreated; +}; + +/** + * recursively navigates the folder structure and sets the path for each folder + * @param {number} folderId + **/ +const recursivelySetPaths = async (folderId) => { + const folder = await databaseConnector.folder.findUnique({ + where: { id: folderId }, + include: { childFolders: true } + }); + + if (folder && !folder.parentFolderId) await setPath(folder.id, null); - foldersCreated.push(topFoldersCreated); + if (!folder && !folder.childFolders) return; + + if (folder && !folder.childFolders) { + await setPath(folder.id, folder.parentFolderId); + return; } - return foldersCreated; + for (const childFolder of folder.childFolders) { + await setPath(childFolder.id, childFolder.parentFolderId); + await recursivelySetPaths(childFolder.id); + } }; /** From 18d18ddbc95871f801e62fdf0a39a55e1b7378d5 Mon Sep 17 00:00:00 2001 From: Jan Phillips Date: Thu, 2 Oct 2025 09:12:21 +0100 Subject: [PATCH 02/11] feat(api): documentCount is increased/decreased when a file is added/deleted (APPLICS-1683) --- .../documents/document.controller.js | 1 - .../application/documents/document.service.js | 14 +++++- .../repositories/document.repository.js | 44 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/api/src/server/applications/application/documents/document.controller.js b/apps/api/src/server/applications/application/documents/document.controller.js index a7af500387..38d06a376b 100644 --- a/apps/api/src/server/applications/application/documents/document.controller.js +++ b/apps/api/src/server/applications/application/documents/document.controller.js @@ -53,7 +53,6 @@ import { getRedactionStatus, validateDocumentVersionMetadataBody } from './docum */ export const createDocumentsOnCase = async ({ params, body }, response) => { const documentsToUpload = body['']; - const latestDocumentReference = await documentRepository.getLatestDocReferenceByCaseIdExcludingMigrated({ caseId: /** @type {number} */ (params.id) diff --git a/apps/api/src/server/applications/application/documents/document.service.js b/apps/api/src/server/applications/application/documents/document.service.js index dfe07b67e4..8f72b82890 100644 --- a/apps/api/src/server/applications/application/documents/document.service.js +++ b/apps/api/src/server/applications/application/documents/document.service.js @@ -144,6 +144,11 @@ const attemptInsertDocuments = async (caseId, documents, isS51) => { fromFrontOffice: documentToDB.fromFrontOffice, documentType: isS51 ? DOCUMENT_TYPES.S51Attachment : DOCUMENT_TYPES.Document }); + await documentRepository.incrementDocumentCount( + document.folderId, + document.caseId, + documentToDB.documentName + ); } catch (err) { logger.error(err); failed.add(documentToDB.documentName); @@ -1067,7 +1072,14 @@ export const deleteDocument = async (guid, caseId) => { // step 3: mark the document as deleted const deletedDocument = await documentRepository.deleteDocument(guid); - // Step 4: broadcast event message - ignoring training cases + // step 4: decrement documentCount of folder and ancestor folders + await documentRepository.decreaseDocumentCount( + deletedDocument.folderId, + caseId, + deletedDocument.guid + ); + + // Step 5: broadcast event message - ignoring training cases await broadcastNsipDocumentEvent(documentToDelete, EventType.Delete); return deletedDocument; diff --git a/apps/api/src/server/repositories/document.repository.js b/apps/api/src/server/repositories/document.repository.js index d42d1ad69c..620287103e 100644 --- a/apps/api/src/server/repositories/document.repository.js +++ b/apps/api/src/server/repositories/document.repository.js @@ -1,5 +1,7 @@ import { databaseConnector } from '#utils/database-connector.js'; import { getFileNameWithoutSuffix } from '../applications/application/documents/document.service.js'; +import BackOfficeAppError from '#utils/app-error.js'; +import { getById as getFolderById } from '#repositories/folder.repository.js'; /** * @typedef {import('@prisma/client').Document} Document @@ -677,3 +679,45 @@ export const getInFolderByName = (folderId, fileName, includeDeleted) => { } }); }; + +/** + * updates document count for the folder a file is added to, and the ancestor folders + * @param {number} folderId + * @param {number} caseId + * @param {string} documentName + * @returns Prisma.PrismaPromise + */ +export const incrementDocumentCount = async (folderId, caseId, documentName) => { + const folder = await getFolderById(folderId); + if (!folder || !folder.path) + throw new BackOfficeAppError(`Folder or folder path not found ${folder.id}`); + try { + return databaseConnector.$executeRaw`UPDATE folder SET documentCount = COALESCE(documentCount,0) + 1 + WHERE caseId = ${caseId} AND ${folder.path} LIKE CONCAT(path, '%')`; + } catch (e) { + throw new BackOfficeAppError( + `Couldn't increase document count for ${documentName} in folder ${folderId}` + ); + } +}; + +/** + * decreases the document count for the folder a file is added to, and the ancestor folders + * @param {number} folderId + * @param {number} caseId + * @param {string} documentGuid + * @returns Prisma.PrismaPromise + */ +export const decreaseDocumentCount = async (folderId, caseId, documentGuid) => { + const folder = await getFolderById(folderId); + if (!folder || !folder.path) + throw new BackOfficeAppError(`Folder or folder path not found ${folder.id}`); + try { + return databaseConnector.$executeRaw`UPDATE folder SET documentCount = documentCount - 1 + WHERE caseId = ${caseId} AND ${folder.path} LIKE CONCAT(path, '%')`; + } catch (e) { + throw new BackOfficeAppError( + `Couldn't decrease document count for ${documentGuid} in folder ${folderId}` + ); + } +}; From d7dbd6c5cbf18bb5cf2330870788f577e14a3cd2 Mon Sep 17 00:00:00 2001 From: Jan Phillips Date: Thu, 2 Oct 2025 12:35:46 +0100 Subject: [PATCH 03/11] feat(api): examination timetable item can only be deleted when folder is empty (APPLICS-1683) --- .../migration.sql | 19 +++++++++++ apps/api/src/database/schema.prisma | 2 +- .../examination-timetable-items.controller.js | 3 +- .../server/repositories/folder.repository.js | 33 ++++++++++++++++--- 4 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/database/migrations/20251002103819_document_count_default_value_zero/migration.sql diff --git a/apps/api/src/database/migrations/20251002103819_document_count_default_value_zero/migration.sql b/apps/api/src/database/migrations/20251002103819_document_count_default_value_zero/migration.sql new file mode 100644 index 0000000000..ad1b21c8ee --- /dev/null +++ b/apps/api/src/database/migrations/20251002103819_document_count_default_value_zero/migration.sql @@ -0,0 +1,19 @@ +BEGIN TRY + +BEGIN TRAN; + +-- AlterTable +ALTER TABLE [dbo].[Folder] ADD CONSTRAINT [Folder_documentCount_df] DEFAULT 0 FOR [documentCount]; + +COMMIT TRAN; + +END TRY +BEGIN CATCH + +IF @@TRANCOUNT > 0 +BEGIN + ROLLBACK TRAN; +END; +THROW + +END CATCH diff --git a/apps/api/src/database/schema.prisma b/apps/api/src/database/schema.prisma index d553e1ed78..758c12ceb2 100644 --- a/apps/api/src/database/schema.prisma +++ b/apps/api/src/database/schema.prisma @@ -298,7 +298,7 @@ model Folder { childFolders Folder[] @relation("FolderTree") stage String? ExaminationTimetableItem ExaminationTimetableItem[] - documentCount Int? + documentCount Int? @default(0) path String? @@unique([caseId, displayNameEn, parentFolderId, deletedAt]) diff --git a/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js b/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js index 9c6444c3fe..5c584a2575 100644 --- a/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js +++ b/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js @@ -47,8 +47,7 @@ export const getExaminationTimetableItems = async ({ params }, response) => { const examinationTimetableItemsForCase = await pMap( examinationTimetableItems, async (item) => { - const submissions = await service.validateSubmissions(item, examinationTimetable.caseId); - + const submissions = await folderRepository.getDocumentCount(item.folderId); return { ...item, submissions, diff --git a/apps/api/src/server/repositories/folder.repository.js b/apps/api/src/server/repositories/folder.repository.js index ca7735c8e9..ea4a7ce187 100644 --- a/apps/api/src/server/repositories/folder.repository.js +++ b/apps/api/src/server/repositories/folder.repository.js @@ -1,5 +1,6 @@ import { databaseConnector } from '#utils/database-connector.js'; import { folderDocumentCaseStageMappings } from '#api-constants'; +import BackOfficeAppError from '#utils/app-error.js'; /** @typedef {import('@pins/applications.api').Schema.Folder} Folder */ /** @typedef {import('@pins/applications').FolderTemplate} FolderTemplate */ @@ -156,16 +157,40 @@ export const createFolder = async (folder, isCustom = true) => { }); await setPath(newFolder.id, newFolder.parentFolderId); return newFolder; - // return databaseConnector.folder.create({ - // data: { ...folder, isCustom } - // }); +}; + +/** + * @param {number} folderId + * @returns {Promise<{ documentCount: number }>} + * returns document count for a folder + */ +export const getDocumentCount = async (folderId) => { + try { + const documentCount = await databaseConnector.folder.findUnique({ + where: { + id: folderId + }, + select: { + documentCount: true + } + }); + if (!documentCount) { + console.error(`No document count for folder: ${folderId}`); + return null; + } + return documentCount.documentCount; + } catch (e) { + throw new BackOfficeAppError( + `Error connecting to DB when getting document count for folder: ${folderId}` + ); + } }; /** * * @param {number} id * @param {number|null} parentFolderId - * @returns {*} + * @returns {Promise<(Folder |null)>} */ // sets the path for the folder export const setPath = async (id, parentFolderId) => { From 881ec080d8364bae5f5dc1b1762f2e48b4fe0a6d Mon Sep 17 00:00:00 2001 From: Jan Phillips Date: Sun, 5 Oct 2025 13:16:26 +0100 Subject: [PATCH 04/11] fix(api): documentCount of folder is updated when files moved; fixed tests(APPLICS-1683) --- .../__tests__/create-document.test.js | 17 +++- .../__tests__/delete-document.test.js | 11 ++- .../documents/document.controller.js | 10 +- .../application/documents/document.service.js | 19 ++-- .../examination-timetable-items.test.js | 52 ++++++++-- .../examination-timetable-items.controller.js | 7 -- .../s51advice/__tests__/s51-advice.test.js | 11 ++- .../__tests__/folder.repository.test.js | 94 ++++++++++++++++++- .../repositories/document.repository.js | 44 --------- .../server/repositories/folder.repository.js | 44 ++++++++- .../applications-documentation.controller.js | 8 +- .../applications-documentation.service.js | 9 +- .../applications-timetable.test.js.snap | 12 --- .../timetable-items-list.component.njk | 11 ++- 14 files changed, 250 insertions(+), 99 deletions(-) diff --git a/apps/api/src/server/applications/application/documents/__tests__/create-document.test.js b/apps/api/src/server/applications/application/documents/__tests__/create-document.test.js index 5d92e1f62b..22bb967434 100644 --- a/apps/api/src/server/applications/application/documents/__tests__/create-document.test.js +++ b/apps/api/src/server/applications/application/documents/__tests__/create-document.test.js @@ -5,6 +5,7 @@ import { EventType } from '@pins/event-client'; import { NSIP_DOCUMENT } from '#infrastructure/topics.js'; import { buildDocumentFolderPath } from '../document.service.js'; import { buildNsipDocumentPayload } from '#infrastructure/payload-builders/nsip-document.js'; +import { jest } from '@jest/globals'; const { eventClient } = await import('#infrastructure/event-client.js'); const application = { @@ -122,8 +123,22 @@ describe('Create documents', () => { databaseConnector.case.findUnique.mockResolvedValue(application); databaseConnector.folder.findMany.mockResolvedValue([{ id: 1, displayNameEn: 'folder 1' }]); - databaseConnector.folder.findUnique.mockResolvedValue({ id: 1, caseId: 1 }); + databaseConnector.folder.findUnique.mockResolvedValue({ + id: 1, + caseId: 1, + documentCount: 0, + path: '/1' + }); databaseConnector.document.create.mockResolvedValue({ id: 1, guid, name: 'test doc' }); + databaseConnector.folder.findUnique.mockResolvedValue({ + id: 1, + caseId: 1, + documentCount: 0, + path: '/1' + }); + databaseConnector.$executeRaw = jest + .fn() + .mockResolvedValue({ id: 1, caseId: 1, documentCount: 1, path: '/1' }); databaseConnector.document.findFirst.mockResolvedValueOnce(null); databaseConnector.documentVersion.upsert.mockResolvedValue(upsertedDocVersionResponse); databaseConnector.document.update.mockResolvedValue(updatedDocResponse); diff --git a/apps/api/src/server/applications/application/documents/__tests__/delete-document.test.js b/apps/api/src/server/applications/application/documents/__tests__/delete-document.test.js index 73ae47e89e..04b085ad81 100644 --- a/apps/api/src/server/applications/application/documents/__tests__/delete-document.test.js +++ b/apps/api/src/server/applications/application/documents/__tests__/delete-document.test.js @@ -49,7 +49,9 @@ const folderContainingDocumentToDelete = { case: { id: 100000001, reference: 'BC010001' - } + }, + documentCount: 1, + path: '/10003' }; const DocumentToDelete = { @@ -163,6 +165,13 @@ describe('delete Document', () => { documentVersionWithDocumentToDelete.Document.folder ); databaseConnector.document.findUnique.mockResolvedValue(DocumentToDelete); + databaseConnector.folder.findUnique = jest + .fn() + .mockResolvedValue(folderContainingDocumentToDelete); + databaseConnector.$executeRaw = jest.fn().mockResolvedValue({ + ...folderContainingDocumentToDelete, + documentCount: 0 + }); databaseConnector.case.findUnique.mockResolvedValue(application1); const isDeleted = true; diff --git a/apps/api/src/server/applications/application/documents/document.controller.js b/apps/api/src/server/applications/application/documents/document.controller.js index 38d06a376b..516b629a20 100644 --- a/apps/api/src/server/applications/application/documents/document.controller.js +++ b/apps/api/src/server/applications/application/documents/document.controller.js @@ -213,7 +213,7 @@ export const updateDocuments = async ({ body }, response) => { * @type {import('express').RequestHandler<{id: number}, any, any, any>} * */ export const moveDocumentsToAnotherFolder = async ({ body }, response) => { - const { documents, destinationFolderId, destinationFolderStage } = body; + const { documents, destinationFolderId, destinationFolderStage, currentFolderId } = body; const documentNamesToMove = documents.map((document) => document.fileName); const destinationFolderDocuments = await documentRepository.getDocumentsInFolder( @@ -249,6 +249,8 @@ export const moveDocumentsToAnotherFolder = async ({ body }, response) => { destinationFolderStage }); + // updateDocuments[0] is the array of Document objects updated + // updateDocuments[1] is array of DocumentVersion objects if (updateDocuments[0].count === 0 || updateDocuments[1].count === 0) { return response.send({ errors: { @@ -257,6 +259,12 @@ export const moveDocumentsToAnotherFolder = async ({ body }, response) => { }); } + // decrease documentCount for current folder and ancestors + await folderRepository.decreaseDocumentCount(Number(currentFolderId), updateDocuments[0].count); + + // update documentCount for new folder and ancestors + await folderRepository.increaseDocumentCount(destinationFolderId, updateDocuments[0].count); + response.send(updateDocuments); }; diff --git a/apps/api/src/server/applications/application/documents/document.service.js b/apps/api/src/server/applications/application/documents/document.service.js index 8f72b82890..ed43ccea36 100644 --- a/apps/api/src/server/applications/application/documents/document.service.js +++ b/apps/api/src/server/applications/application/documents/document.service.js @@ -13,7 +13,12 @@ import { mapDocumentVersionDetails } from '#utils/mapping/map-document-details.j import * as documentRepository from '#repositories/document.repository.js'; import * as documentVersionRepository from '#repositories/document-metadata.repository.js'; import * as documentActivityLogRepository from '#repositories/document-activity-log.repository.js'; -import { getFolderWithParents, getS51AdviceFolder } from '#repositories/folder.repository.js'; +import { + getFolderWithParents, + getS51AdviceFolder, + decreaseDocumentCount, + increaseDocumentCount +} from '#repositories/folder.repository.js'; import { getStorageLocation } from '#utils/document-storage.js'; import BackOfficeAppError from '#utils/app-error.js'; import logger from '#utils/logger.js'; @@ -144,11 +149,7 @@ const attemptInsertDocuments = async (caseId, documents, isS51) => { fromFrontOffice: documentToDB.fromFrontOffice, documentType: isS51 ? DOCUMENT_TYPES.S51Attachment : DOCUMENT_TYPES.Document }); - await documentRepository.incrementDocumentCount( - document.folderId, - document.caseId, - documentToDB.documentName - ); + await increaseDocumentCount(document.folderId); } catch (err) { logger.error(err); failed.add(documentToDB.documentName); @@ -1073,11 +1074,7 @@ export const deleteDocument = async (guid, caseId) => { const deletedDocument = await documentRepository.deleteDocument(guid); // step 4: decrement documentCount of folder and ancestor folders - await documentRepository.decreaseDocumentCount( - deletedDocument.folderId, - caseId, - deletedDocument.guid - ); + await decreaseDocumentCount(deletedDocument.folderId); // Step 5: broadcast event message - ignoring training cases await broadcastNsipDocumentEvent(documentToDelete, EventType.Delete); diff --git a/apps/api/src/server/applications/examination-timetable-items/__tests__/examination-timetable-items.test.js b/apps/api/src/server/applications/examination-timetable-items/__tests__/examination-timetable-items.test.js index 1981d98e11..adaa8a22dc 100644 --- a/apps/api/src/server/applications/examination-timetable-items/__tests__/examination-timetable-items.test.js +++ b/apps/api/src/server/applications/examination-timetable-items/__tests__/examination-timetable-items.test.js @@ -114,7 +114,8 @@ const examinationFolder = { stage: 'Examination', parentFolderId: null, displayOrder: 100, - isCustom: false + isCustom: false, + documentCount: 0 }; const examinationSubFolders = [ @@ -303,6 +304,7 @@ describe('Test examination timetable items API', () => { }); test('gets all examination timetable items for case', async () => { + //Arrange databaseConnector.folder.findUnique.mockResolvedValue(examinationFolder); databaseConnector.document.count.mockResolvedValue(0); databaseConnector.folder.findMany.mockResolvedValue([]); @@ -310,9 +312,13 @@ describe('Test examination timetable items API', () => { examinationTimetableItem ]); databaseConnector.examinationTimetable.findUnique.mockResolvedValue(examinationTimetableData); + + //Act const resp = await request.get('/applications/examination-timetable-items/case/1'); + + //Assert expect(resp.status).toEqual(200); - expect(resp.body.items[0].submissions).toBe(false); + expect(resp.body.items[0].submissions).toEqual(0); expect(resp.body.items[0].description).toBe( `{"preText":"pretext\\r\\n","bulletPoints":[" pointone\\r\\n"," pointtwo"]}` ); @@ -351,6 +357,11 @@ describe('Test examination timetable items API', () => { databaseConnector.case.findUnique.mockResolvedValue(project); databaseConnector.folder.findFirst.mockResolvedValue(examinationFolder); databaseConnector.folder.create.mockResolvedValue(examinationSubFolders[0]); + databaseConnector.folder.findUnique.mockResolvedValue(examinationFolder); + databaseConnector.folder.update.mockResolvedValue({ + ...examinationSubFolders[0], + path: '/1/2' + }); databaseConnector.examinationTimetableType.findUnique.mockResolvedValue({ name: 'NODeadline' }); databaseConnector.examinationTimetableItem.create.mockResolvedValue( examinationTimetableItemDeadline @@ -384,6 +395,11 @@ describe('Test examination timetable items API', () => { databaseConnector.case.findUnique.mockResolvedValue(project); databaseConnector.folder.findFirst.mockResolvedValue(examinationFolder); databaseConnector.folder.create.mockResolvedValue(examinationSubFolders[0]); + databaseConnector.folder.findUnique.mockResolvedValue(examinationFolder); + databaseConnector.folder.update({ + ...examinationSubFolders[0], + path: '/1/2' + }); databaseConnector.examinationTimetableType.findUnique.mockResolvedValue({ name: 'NODeadline', templateType: 'procedural-deadline' @@ -455,11 +471,29 @@ describe('Test examination timetable items API', () => { databaseConnector.examinationTimetable.findUnique.mockResolvedValue(null); databaseConnector.examinationTimetable.create.mockResolvedValue(examinationTimetableData); databaseConnector.folder.findFirst.mockResolvedValue(examinationFolder); - databaseConnector.folder.create - .mockResolvedValueOnce(examinationFolder) - .mockResolvedValueOnce(examinationSubFolders[0]) - .mockResolvedValueOnce(examinationSubFolders[1]) - .mockResolvedValueOnce(examinationSubFolders[2]); + databaseConnector.folder.create.mockResolvedValueOnce(examinationFolder); + databaseConnector.folder.update.mockResolvedValueOnce({ + ...examinationFolder, + path: '/1' + }); + databaseConnector.folder.create.mockResolvedValueOnce(examinationSubFolders[0]); + databaseConnector.folder.findUnique.mockResolvedValueOnce(examinationFolder); + databaseConnector.folder.update.mockResolvedValueOnce({ + ...examinationSubFolders[0], + path: '/1/2' + }); + databaseConnector.folder.create.mockResolvedValueOnce(examinationSubFolders[1]); + databaseConnector.folder.findUnique.mockResolvedValueOnce(examinationFolder); + databaseConnector.folder.update.mockResolvedValueOnce({ + ...examinationSubFolders[1], + path: '/1/3' + }); + databaseConnector.folder.create.mockResolvedValueOnce(examinationSubFolders[2]); + databaseConnector.folder.findUnique.mockResolvedValueOnce(examinationFolder); + databaseConnector.folder.update.mockResolvedValueOnce({ + ...examinationSubFolders[2], + path: '/1/4' + }); databaseConnector.examinationTimetableType.findUnique.mockResolvedValue({ name: 'Deadline' }); databaseConnector.examinationTimetableItem.create.mockResolvedValue( examinationTimetableItemDeadline @@ -795,8 +829,8 @@ describe('Test examination timetable items API', () => { expect(resp.status).toEqual(200); expect(resp.body).toEqual(examinationTimetableItemDeadlineUpdateResponse); expect(databaseConnector.folder.deleteMany).toHaveBeenCalledTimes(1); - expect(databaseConnector.folder.findUnique).toHaveBeenCalledTimes(1); - expect(databaseConnector.folder.update).toHaveBeenCalledTimes(1); + expect(databaseConnector.folder.findUnique).toHaveBeenCalledTimes(4); + expect(databaseConnector.folder.update).toHaveBeenCalledTimes(4); expect(databaseConnector.folder.create).toHaveBeenCalledTimes(3); diff --git a/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js b/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js index 5c584a2575..15804d70f0 100644 --- a/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js +++ b/apps/api/src/server/applications/examination-timetable-items/examination-timetable-items.controller.js @@ -19,7 +19,6 @@ import { EventType } from '@pins/event-client'; import { buildFolderPayload } from '#infrastructure/payload-builders/folder.js'; import { verifyNotTraining } from '../application/application.validators.js'; import { folderDocumentCaseStageMappings } from '../constants.js'; -import { setPath } from '#repositories/folder.repository.js'; /** @typedef {import('@pins/applications.api').Schema.Folder} Folder */ @@ -161,12 +160,6 @@ export const createExaminationTimetableItem = async ({ body }, response) => { throw new BackOfficeAppError('Failed to create sub folder for the examination item.', 500); } - try { - await setPath(itemFolder.id, itemFolder.parentFolderId); - } catch (e) { - throw new BackOfficeAppError(`Failed to set path for folder ${itemFolder.id}`, 500); - } - // now send broadcast event for folder creation - ignoring folders on training cases. try { const project = await verifyNotTraining(body.caseId); diff --git a/apps/api/src/server/applications/s51advice/__tests__/s51-advice.test.js b/apps/api/src/server/applications/s51advice/__tests__/s51-advice.test.js index b0a247a79d..acaa465b24 100644 --- a/apps/api/src/server/applications/s51advice/__tests__/s51-advice.test.js +++ b/apps/api/src/server/applications/s51advice/__tests__/s51-advice.test.js @@ -166,7 +166,9 @@ const folderContainingDocumentToDelete = { id: 100000001, reference: 'BC0110001', CaseStatus: [{ id: 1, valid: true, status: '0' }] - } + }, + documentCount: 1, + path: '/10003' }; const DocumentToDelete = { @@ -573,6 +575,13 @@ describe('Test S51 advice API', () => { databaseConnector.folder.findUnique.mockResolvedValue(folderContainingDocumentToDelete); databaseConnector.document.findUnique.mockResolvedValue(DocumentToDelete); databaseConnector.documentVersion.findUnique.mockResolvedValue(s51Document); + databaseConnector.document.delete.mockResolvedValue(DocumentToDelete); + databaseConnector.folder.findUnique.mockResolvedValue(folderContainingDocumentToDelete); + databaseConnector.$executeRaw = jest.fn().mockResolvedValue({ + ...folderContainingDocumentToDelete, + documentCount: 0, + path: '/10003' + }); const response = await request.delete('/applications/100000000/s51-advice/1').send(); diff --git a/apps/api/src/server/repositories/__tests__/folder.repository.test.js b/apps/api/src/server/repositories/__tests__/folder.repository.test.js index 71e0028c77..83b8be8854 100644 --- a/apps/api/src/server/repositories/__tests__/folder.repository.test.js +++ b/apps/api/src/server/repositories/__tests__/folder.repository.test.js @@ -1,8 +1,10 @@ -import { setPath } from '../folder.repository.js'; +import { decreaseDocumentCount, increaseDocumentCount, setPath } from '../folder.repository.js'; const { databaseConnector } = await import('../../utils/database-connector.js'); import * as folderRepository from '../folder.repository.js'; +import BackOfficeAppError from '#utils/app-error.js'; +import { jest } from '@jest/globals'; const existingTopLevelFolders = [ { @@ -134,17 +136,103 @@ describe('Folder repository', () => { }); describe('setPath', () => { - it('should update the folder path with the correct id', async () => { + it('sets the path as the /ID when no parent folder', async () => { + //Arrange const id = 42; const expectedResult = { id, path: '/42/' }; databaseConnector.folder.update.mockResolvedValue(expectedResult); + //Act const path = await setPath(id); + //Assert expect(databaseConnector.folder.update).toHaveBeenCalledWith({ where: { id }, - data: { path: '/42/' } + data: { path: '/42' } }); expect(path).toEqual(expectedResult); }); + it('sets the path as the "/parentId/ID" when no parent folder', async () => { + //Arrange + const id = 2; + const folder = { id, parentFolderId: 1, path: null }; + const expectedResult = { ...folder, path: '/1/2' }; + databaseConnector.folder.findUnique.mockResolvedValue(folder); + databaseConnector.folder.update.mockResolvedValue(expectedResult); + + //Act + const path = await setPath(id); + + //Assert + expect(path).toEqual(expectedResult); + }); +}); + +describe('decreaseDocumentCount', () => { + it('decreases document count for folder and ancestors', async () => { + //Arrange + const folder = { id: 1, path: '/1', documentCount: 1 }; + const expectedResult = { ...folder, documentCount: 0 }; + databaseConnector.folder.findUnique.mockResolvedValue(folder); + databaseConnector.$executeRaw = jest.fn().mockResolvedValue(expectedResult); + + //Act + const result = await decreaseDocumentCount(1); + + //Assert + expect(result).toEqual(expectedResult); + }); + + it('throws error if folder or path is missing', async () => { + //Arrange + databaseConnector.folder.findUnique.mockResolvedValue(null); + //Act and assert + await expect(decreaseDocumentCount(1)).rejects.toThrow(BackOfficeAppError); + }); + + it('throws error if $executeRaw fails', async () => { + //Arrange + const folder = { id: 1, path: '/1' }; + databaseConnector.folder.findUnique.mockResolvedValue(folder); + databaseConnector.$executeRaw.mockImplementation(() => { + throw new Error('DB error'); + }); + //Act and assert + await expect(decreaseDocumentCount(1)).rejects.toThrow(BackOfficeAppError); + }); +}); + +describe('increaseDocumentCount', () => { + it('increases document count for folder', async () => { + //Arrange + const folder = { id: 2, path: '/1/2', documentCount: 0 }; + const expectedResult = { ...folder, documentCount: 1 }; + databaseConnector.folder.findUnique.mockResolvedValue(folder); + databaseConnector.$executeRaw = jest.fn().mockResolvedValue(expectedResult); + + //Act + const result = await increaseDocumentCount(1); + + //Assert + expect(result).toEqual(expectedResult); + }); + + it('throws error if folder or path is missing', async () => { + // Arrange + databaseConnector.folder.findUnique.mockResolvedValue(null); + //Act and Assert + await expect(increaseDocumentCount(1)).rejects.toThrow(BackOfficeAppError); + }); + + it('throws error if $executeRaw fails', async () => { + //Arrange + const folder = { id: 1, path: '/1' }; + databaseConnector.folder.findUnique.mockResolvedValue(folder); + databaseConnector.$executeRaw.mockImplementation(() => { + throw new Error('DB error'); + }); + + //Act and assert + await expect(increaseDocumentCount(1)).rejects.toThrow(BackOfficeAppError); + }); }); diff --git a/apps/api/src/server/repositories/document.repository.js b/apps/api/src/server/repositories/document.repository.js index 620287103e..d42d1ad69c 100644 --- a/apps/api/src/server/repositories/document.repository.js +++ b/apps/api/src/server/repositories/document.repository.js @@ -1,7 +1,5 @@ import { databaseConnector } from '#utils/database-connector.js'; import { getFileNameWithoutSuffix } from '../applications/application/documents/document.service.js'; -import BackOfficeAppError from '#utils/app-error.js'; -import { getById as getFolderById } from '#repositories/folder.repository.js'; /** * @typedef {import('@prisma/client').Document} Document @@ -679,45 +677,3 @@ export const getInFolderByName = (folderId, fileName, includeDeleted) => { } }); }; - -/** - * updates document count for the folder a file is added to, and the ancestor folders - * @param {number} folderId - * @param {number} caseId - * @param {string} documentName - * @returns Prisma.PrismaPromise - */ -export const incrementDocumentCount = async (folderId, caseId, documentName) => { - const folder = await getFolderById(folderId); - if (!folder || !folder.path) - throw new BackOfficeAppError(`Folder or folder path not found ${folder.id}`); - try { - return databaseConnector.$executeRaw`UPDATE folder SET documentCount = COALESCE(documentCount,0) + 1 - WHERE caseId = ${caseId} AND ${folder.path} LIKE CONCAT(path, '%')`; - } catch (e) { - throw new BackOfficeAppError( - `Couldn't increase document count for ${documentName} in folder ${folderId}` - ); - } -}; - -/** - * decreases the document count for the folder a file is added to, and the ancestor folders - * @param {number} folderId - * @param {number} caseId - * @param {string} documentGuid - * @returns Prisma.PrismaPromise - */ -export const decreaseDocumentCount = async (folderId, caseId, documentGuid) => { - const folder = await getFolderById(folderId); - if (!folder || !folder.path) - throw new BackOfficeAppError(`Folder or folder path not found ${folder.id}`); - try { - return databaseConnector.$executeRaw`UPDATE folder SET documentCount = documentCount - 1 - WHERE caseId = ${caseId} AND ${folder.path} LIKE CONCAT(path, '%')`; - } catch (e) { - throw new BackOfficeAppError( - `Couldn't decrease document count for ${documentGuid} in folder ${folderId}` - ); - } -}; diff --git a/apps/api/src/server/repositories/folder.repository.js b/apps/api/src/server/repositories/folder.repository.js index ea4a7ce187..f32cee29bf 100644 --- a/apps/api/src/server/repositories/folder.repository.js +++ b/apps/api/src/server/repositories/folder.repository.js @@ -155,7 +155,11 @@ export const createFolder = async (folder, isCustom = true) => { const newFolder = await databaseConnector.folder.create({ data: { ...folder, isCustom } }); - await setPath(newFolder.id, newFolder.parentFolderId); + try { + await setPath(newFolder.id, newFolder.parentFolderId); + } catch (e) { + throw new BackOfficeAppError(`Failed to set path for folder ${newFolder.id}`, 500); + } return newFolder; }; @@ -374,6 +378,44 @@ export const getS51AdviceFolder = (caseId) => { }); }; +/** + * decreases the document count for the folder a file is added to, and the ancestor folders + * @param {number} folderId + * @param {number} [decreaseBy=1] - Optional value to decrease document count by, default == 1 + * @returns Prisma.PrismaPromise + */ +export const decreaseDocumentCount = async (folderId, decreaseBy) => { + decreaseBy = decreaseBy || 1; + const folder = await getById(folderId); + if (!folder || !folder.path) + throw new BackOfficeAppError(`Folder or folder path not found ${folderId}`); + try { + return databaseConnector.$executeRaw`UPDATE folder SET documentCount = documentCount - ${decreaseBy} + WHERE ${folder.path} LIKE CONCAT(path, '%')`; + } catch (e) { + throw new BackOfficeAppError(`Couldn't decrease document count for folder ${folderId}`); + } +}; + +/** + * updates document count for the folder a file is added to, and the ancestor folders + * @param {number} folderId + * @param {number} [increaseBy=1] - Optional value to increase document count by, default == 1 + * @returns Prisma.PrismaPromise + */ +export const increaseDocumentCount = async (folderId, increaseBy) => { + increaseBy = increaseBy || 1; + const folder = await getById(folderId); + if (!folder || !folder.path) + throw new BackOfficeAppError(`Folder or folder path not found ${folderId}`); + try { + return databaseConnector.$executeRaw`UPDATE folder SET documentCount = documentCount + ${increaseBy} + WHERE ${folder.path} LIKE CONCAT(path, '%')`; + } catch (e) { + throw new BackOfficeAppError(`Couldn't increase document count for folder ${folderId}`); + } +}; + /** * The default template folder structure for a new NI Applications type case * diff --git a/apps/web/src/server/applications/case/documentation/applications-documentation.controller.js b/apps/web/src/server/applications/case/documentation/applications-documentation.controller.js index 858f30ced9..b80207b6ca 100644 --- a/apps/web/src/server/applications/case/documentation/applications-documentation.controller.js +++ b/apps/web/src/server/applications/case/documentation/applications-documentation.controller.js @@ -920,13 +920,15 @@ export async function postDocumentationFolderExplorer(request, response) { const folderListViewData = moveDocumentsUtils.getFolderViewData(folderList); const parentFolderName = moveDocumentsUtils.getFolderNameById(folderList, openFolderId); const breadcrumbItems = documentationSessionHandlers.getSessionMoveDocumentsBreadcrumbs(session); - if (body.action === 'moveDocuments') { const destinationFolder = documentationSessionHandlers.getSessionMoveDocumentsParentFolder(session); const payload = moveDocumentsUtils.getMoveDocumentsPayload(session); - - const { errors: updateErrors } = await updateDocumentsFolderId(caseId, payload); + const { errors: updateErrors } = await updateDocumentsFolderId( + caseId, + payload, + Number(params.folderId) + ); if (updateErrors) { validationErrors = updateErrors; diff --git a/apps/web/src/server/applications/case/documentation/applications-documentation.service.js b/apps/web/src/server/applications/case/documentation/applications-documentation.service.js index 9c24ae7e6b..df7a8f7433 100644 --- a/apps/web/src/server/applications/case/documentation/applications-documentation.service.js +++ b/apps/web/src/server/applications/case/documentation/applications-documentation.service.js @@ -96,18 +96,21 @@ export const updateCaseDocumentationFiles = async (caseId, { status, redacted, d * Update folderId for a documents to 'move it' to another folder * @param {number} caseId * @param {{destinationFolderId: number|undefined, destinationFolderStage: string|undefined|null, documents: {documentGuid: string, fileName: string, version: number}[]}} _ + * @param {number} currentFolderId * @returns * */ export const updateDocumentsFolderId = async ( caseId, - { documents, destinationFolderId, destinationFolderStage } + { documents, destinationFolderId, destinationFolderStage }, + currentFolderId ) => { try { return await patch(`applications/${caseId}/move-documents`, { json: { documents, destinationFolderId, - destinationFolderStage + destinationFolderStage, + currentFolderId } }); } catch (/** @type {*} */ error) { @@ -351,7 +354,7 @@ export const deleteFolder = async (caseId, folderId) => { try { return await deleteRequest(`applications/${caseId}/folders/${folderId}`); } catch (/** @type {*} */ error) { - logger.error(`[API] ${JSON.stringify(error?.response?.body?.errors) || 'Unkown error'}`); + logger.error(`[API] ${JSON.stringify(error?.response?.body?.errors) || 'Unknown error'}`); if (error?.response?.statusCode === 403) { return { errors: { msg: 'Cannot delete a non-custom folder' } }; } diff --git a/apps/web/src/server/applications/case/examination-timetable/__tests__/__snapshots__/applications-timetable.test.js.snap b/apps/web/src/server/applications/case/examination-timetable/__tests__/__snapshots__/applications-timetable.test.js.snap index 8bd5e3706c..b1a4ff8cb0 100644 --- a/apps/web/src/server/applications/case/examination-timetable/__tests__/__snapshots__/applications-timetable.test.js.snap +++ b/apps/web/src/server/applications/case/examination-timetable/__tests__/__snapshots__/applications-timetable.test.js.snap @@ -3362,8 +3362,6 @@ exports[`Examination timetable page GET /case/123/examination-timetable should s

10 Oct 2023 - Test

-
Item type
Procedural Deadline (Pre-Examination)
@@ -3418,8 +3416,6 @@ exports[`Examination timetable page GET /case/123/examination-timetable should s

10 Oct 2023 - Test

-
Item type
Deadline
@@ -3719,8 +3715,6 @@ exports[`Publish examination timetable preview page GET /case/123/examination-ti

10 Oct 2023 - Test

-
Item type
Procedural Deadline (Pre-Examination)
@@ -3775,8 +3769,6 @@ exports[`Publish examination timetable preview page GET /case/123/examination-ti

10 Oct 2023 - Test

-
Item type
Deadline
@@ -3892,8 +3884,6 @@ exports[`Unpublish examination timetable preview page GET /case/123/examination-

10 Oct 2023 - Test

-
Item type
Procedural Deadline (Pre-Examination)
@@ -3948,8 +3938,6 @@ exports[`Unpublish examination timetable preview page GET /case/123/examination-

10 Oct 2023 - Test

-
Item type
Deadline
diff --git a/apps/web/src/server/views/applications/case-timetable/components/timetable-items-list.component.njk b/apps/web/src/server/views/applications/case-timetable/components/timetable-items-list.component.njk index 7a96fb5089..3ea87a173b 100644 --- a/apps/web/src/server/views/applications/case-timetable/components/timetable-items-list.component.njk +++ b/apps/web/src/server/views/applications/case-timetable/components/timetable-items-list.component.njk @@ -15,7 +15,7 @@ {% endif %} {% set content %} - {% if not timetableItem.submissions %} + {% if timetableItem.submissions == 0 %} @@ -23,11 +23,18 @@ {{timetableItemSummary(timetableItem, isCaseWelsh, caseId)}} - {% if timetableItem.submissions %} + {% if timetableItem.submissions > 0 %}

There are already submissions in a folder associated with this timetable item. You cannot edit or delete items with existing submissions.

{% endif %} + + {% if timetableItem.submissions is null %} +

+ This folder can not be deleted. There was an issue counting the documents for it. +

+ {% endif %} + {% endset %} {% set row = { From e05c80fcf5410960a93576645bbdf919868a9d6d Mon Sep 17 00:00:00 2001 From: Jan Phillips Date: Sun, 5 Oct 2025 17:41:06 +0100 Subject: [PATCH 05/11] feat(api): script for populating path column for each Folder in db (APPLICS-1683) --- apps/api/src/database/seed/createPaths.js | 63 +++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 apps/api/src/database/seed/createPaths.js diff --git a/apps/api/src/database/seed/createPaths.js b/apps/api/src/database/seed/createPaths.js new file mode 100644 index 0000000000..b9707d633b --- /dev/null +++ b/apps/api/src/database/seed/createPaths.js @@ -0,0 +1,63 @@ +import { databaseConnector } from '#utils/database-connector.js'; +import BackOfficeAppError from '#utils/app-error.js'; + +// starting from the root folders, create the path for every child folder, stop when leaf node reached + +// get all root folders + +// for each root folder traverse the tree/folder populating the path column, until leaf reached +/** + * + * @param folder + * @param {string | null} parentsPath + * @returns {Promise} + */ +async function writePaths(folder, parentsPath) { + // Is it a root folder? + if (folder.parentFolderId == null) { + try { + const updatedRootFolder = await databaseConnector.folder.update({ + where: { id: folder.id }, + data: { path: `/${folder.id}` } + }); + // create paths for child folders + const childFolders = await databaseConnector.folder.findMany({ + where: { parentFolderId: folder.id } + }); + if (!childFolders || childFolders.length === 0) return; + + for (const childFolder of childFolders) { + await writePaths(childFolder, updatedRootFolder.path); + } + } catch (e) { + throw new BackOfficeAppError(`Error setting path for ${folder.id}`); + } + } else { + try { + // for child/non-root folders... + const updatedFolder = await databaseConnector.folder.update({ + where: { id: folder.id }, + data: { path: `${parentsPath}/${folder.id}` } + }); + + const childFolders = await databaseConnector.folder.findMany({ + where: { parentFolderId: folder.id } + }); + + if (!childFolders || childFolders.length === 0) return; + + for (const childFolder of childFolders) { + await writePaths(childFolder, updatedFolder.path); + } + } catch (e) { + throw new BackOfficeAppError(`Error setting path for ${folder.id}`); + } + } +} + +(async () => { + const rootFolders = await databaseConnector.folder.findMany({ + where: { parentFolderId: null } + }); + await Promise.all(rootFolders.map((rootFolder) => writePaths(rootFolder, null))); +})(); From 8818d5e7854f9d917a87cbcccb22a897840a0413 Mon Sep 17 00:00:00 2001 From: Jan Phillips Date: Mon, 6 Oct 2025 13:09:05 +0100 Subject: [PATCH 06/11] fix(api): a test script that asserts the of each entry in Folder table is correct (APPLICS-1683) --- .../seed/{createPaths.js => create-paths.js} | 0 .../test-scripts/create-paths.test.js | 33 +++++++++++++++++++ 2 files changed, 33 insertions(+) rename apps/api/src/database/seed/{createPaths.js => create-paths.js} (100%) create mode 100644 apps/api/src/database/test-scripts/create-paths.test.js diff --git a/apps/api/src/database/seed/createPaths.js b/apps/api/src/database/seed/create-paths.js similarity index 100% rename from apps/api/src/database/seed/createPaths.js rename to apps/api/src/database/seed/create-paths.js diff --git a/apps/api/src/database/test-scripts/create-paths.test.js b/apps/api/src/database/test-scripts/create-paths.test.js new file mode 100644 index 0000000000..eb738021c8 --- /dev/null +++ b/apps/api/src/database/test-scripts/create-paths.test.js @@ -0,0 +1,33 @@ +import { databaseConnector } from '#utils/database-connector.js'; +import assert from 'assert'; + +const randomFolderForACaseId = await databaseConnector.folder.findFirst({ + where: { + caseId: { + not: null + } + } +}); +let caseId = randomFolderForACaseId.caseId; +let rootFolders = await databaseConnector.folder.findMany({ + where: { + caseId, + parentFolderId: null + } +}); + +for (const folder of rootFolders) { + await checkPaths(folder, folder.path); +} + +async function checkPaths(folder, parentPath) { + const expectedPath = folder.parentFolderId ? `${parentPath}/${folder.id}` : `/${folder.id}`; + assert.strictEqual(folder.path, expectedPath); + const childFolders = await databaseConnector.folder.findMany({ + where: { + parentFolderId: folder.id + } + }); + if (!childFolders || childFolders.length === 0) return; + for (const child of childFolders) await checkPaths(child, expectedPath); +} From 5493276fb5906e461e260af2d72bb61612f62a3d Mon Sep 17 00:00:00 2001 From: Jan Phillips Date: Mon, 6 Oct 2025 14:22:46 +0100 Subject: [PATCH 07/11] fix(api): small refactoring and more error handling (APPLICS-1683) --- ...hs.test.js => create-paths-test-script.js} | 6 ++ .../examination-timetable-items.test.js | 2 +- .../server/repositories/folder.repository.js | 55 ++++++++++--------- 3 files changed, 36 insertions(+), 27 deletions(-) rename apps/api/src/database/test-scripts/{create-paths.test.js => create-paths-test-script.js} (76%) diff --git a/apps/api/src/database/test-scripts/create-paths.test.js b/apps/api/src/database/test-scripts/create-paths-test-script.js similarity index 76% rename from apps/api/src/database/test-scripts/create-paths.test.js rename to apps/api/src/database/test-scripts/create-paths-test-script.js index eb738021c8..352a701459 100644 --- a/apps/api/src/database/test-scripts/create-paths.test.js +++ b/apps/api/src/database/test-scripts/create-paths-test-script.js @@ -1,6 +1,12 @@ import { databaseConnector } from '#utils/database-connector.js'; import assert from 'assert'; +/* + * usage: `cd` into directory, then `node create-paths-test-script.js` + * This test script was designed to run using the `node` command + * instead of `npm run test`, so it can run on a server that does not have + * libraries for unit testing installed, e.g. production + */ const randomFolderForACaseId = await databaseConnector.folder.findFirst({ where: { caseId: { diff --git a/apps/api/src/server/applications/examination-timetable-items/__tests__/examination-timetable-items.test.js b/apps/api/src/server/applications/examination-timetable-items/__tests__/examination-timetable-items.test.js index adaa8a22dc..e8b4d4ab21 100644 --- a/apps/api/src/server/applications/examination-timetable-items/__tests__/examination-timetable-items.test.js +++ b/apps/api/src/server/applications/examination-timetable-items/__tests__/examination-timetable-items.test.js @@ -396,7 +396,7 @@ describe('Test examination timetable items API', () => { databaseConnector.folder.findFirst.mockResolvedValue(examinationFolder); databaseConnector.folder.create.mockResolvedValue(examinationSubFolders[0]); databaseConnector.folder.findUnique.mockResolvedValue(examinationFolder); - databaseConnector.folder.update({ + databaseConnector.folder.update.mockResolvedValue({ ...examinationSubFolders[0], path: '/1/2' }); diff --git a/apps/api/src/server/repositories/folder.repository.js b/apps/api/src/server/repositories/folder.repository.js index f32cee29bf..72857506cf 100644 --- a/apps/api/src/server/repositories/folder.repository.js +++ b/apps/api/src/server/repositories/folder.repository.js @@ -1,6 +1,7 @@ import { databaseConnector } from '#utils/database-connector.js'; import { folderDocumentCaseStageMappings } from '#api-constants'; import BackOfficeAppError from '#utils/app-error.js'; +import logger from '#utils/logger.js'; /** @typedef {import('@pins/applications.api').Schema.Folder} Folder */ /** @typedef {import('@pins/applications').FolderTemplate} FolderTemplate */ @@ -179,7 +180,7 @@ export const getDocumentCount = async (folderId) => { } }); if (!documentCount) { - console.error(`No document count for folder: ${folderId}`); + logger.info(`No document count for folder: ${folderId}`); return null; } return documentCount.documentCount; @@ -198,14 +199,18 @@ export const getDocumentCount = async (folderId) => { */ // sets the path for the folder export const setPath = async (id, parentFolderId) => { - if (parentFolderId) { - const parentFolder = await getById(parentFolderId); - if (parentFolder.path) { - return databaseConnector.folder.update({ - where: { id }, - data: { path: `${parentFolder.path}/${id}` } - }); + try { + if (parentFolderId) { + const parentFolder = await getById(parentFolderId); + if (parentFolder.path) { + return databaseConnector.folder.update({ + where: { id }, + data: { path: `${parentFolder.path}/${id}` } + }); + } } + } catch (e) { + throw new BackOfficeAppError(`There was a problem getting folder ${parentFolderId}`); } return databaseConnector.folder.update({ where: { id }, @@ -307,8 +312,6 @@ const mapFolderTemplateWithCaseId = (caseId, folder) => { * @returns {Promise[]} */ export const createFolders = async (caseId, folders = defaultCaseFolders) => { - // const foldersCreated = []; - // Prisma many to nested many does not work, so we cannot create the top folders and all subfolders nested using createMany. // so we loop through the top folders, using create to create the folder and all its subfolders, correctly assigning caseId, parentFolderId etc // and we return an array of these promises @@ -319,9 +322,7 @@ export const createFolders = async (caseId, folders = defaultCaseFolders) => { const topFoldersCreated = await databaseConnector.folder.create(newFolders); await recursivelySetPaths(topFoldersCreated.id); - // foldersCreated.push(topFoldersCreated); } - // return foldersCreated; }; /** @@ -329,23 +330,25 @@ export const createFolders = async (caseId, folders = defaultCaseFolders) => { * @param {number} folderId **/ const recursivelySetPaths = async (folderId) => { - const folder = await databaseConnector.folder.findUnique({ - where: { id: folderId }, - include: { childFolders: true } - }); - - if (folder && !folder.parentFolderId) await setPath(folder.id, null); - - if (!folder && !folder.childFolders) return; + try { + const folder = await databaseConnector.folder.findUnique({ + where: { id: folderId }, + include: { childFolders: true } + }); - if (folder && !folder.childFolders) { + if (!folder) return; + if (folder && Array.isArray(folder.childFolders) && folder.childFolders.length === 0) { + await setPath(folder.id, folder.parentFolderId); + return; + } await setPath(folder.id, folder.parentFolderId); - return; - } - for (const childFolder of folder.childFolders) { - await setPath(childFolder.id, childFolder.parentFolderId); - await recursivelySetPaths(childFolder.id); + for (const childFolder of folder.childFolders) { + await setPath(childFolder.id, childFolder.parentFolderId); + await recursivelySetPaths(childFolder.id); + } + } catch (e) { + throw new BackOfficeAppError(`Couldn't find folder ${folderId}`); } }; From 21448addb60e7488582067ab7173a5d36ead2a8f Mon Sep 17 00:00:00 2001 From: Jan Phillips Date: Mon, 6 Oct 2025 14:29:21 +0100 Subject: [PATCH 08/11] fix(api): test script checks paths for all Folders, Promise.All() for efficiency (APPLICS-1683) --- .../test-scripts/create-paths-test-script.js | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/api/src/database/test-scripts/create-paths-test-script.js b/apps/api/src/database/test-scripts/create-paths-test-script.js index 352a701459..54f7a14ce4 100644 --- a/apps/api/src/database/test-scripts/create-paths-test-script.js +++ b/apps/api/src/database/test-scripts/create-paths-test-script.js @@ -2,29 +2,22 @@ import { databaseConnector } from '#utils/database-connector.js'; import assert from 'assert'; /* + * This script checks the paths for all folders + * * usage: `cd` into directory, then `node create-paths-test-script.js` * This test script was designed to run using the `node` command * instead of `npm run test`, so it can run on a server that does not have * libraries for unit testing installed, e.g. production + * */ -const randomFolderForACaseId = await databaseConnector.folder.findFirst({ - where: { - caseId: { - not: null - } - } -}); -let caseId = randomFolderForACaseId.caseId; + let rootFolders = await databaseConnector.folder.findMany({ where: { - caseId, parentFolderId: null } }); -for (const folder of rootFolders) { - await checkPaths(folder, folder.path); -} +await Promise.all(rootFolders.map((folder) => checkPaths(folder, folder.path))); async function checkPaths(folder, parentPath) { const expectedPath = folder.parentFolderId ? `${parentPath}/${folder.id}` : `/${folder.id}`; From d05263f98a76051a766598f09f51e72822a1f221 Mon Sep 17 00:00:00 2001 From: JanPhillips Date: Mon, 6 Oct 2025 14:58:58 +0100 Subject: [PATCH 09/11] fix(web): using strict equality on submission count Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/timetable-items-list.component.njk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/server/views/applications/case-timetable/components/timetable-items-list.component.njk b/apps/web/src/server/views/applications/case-timetable/components/timetable-items-list.component.njk index 3ea87a173b..a392acc6d9 100644 --- a/apps/web/src/server/views/applications/case-timetable/components/timetable-items-list.component.njk +++ b/apps/web/src/server/views/applications/case-timetable/components/timetable-items-list.component.njk @@ -15,7 +15,7 @@ {% endif %} {% set content %} - {% if timetableItem.submissions == 0 %} + {% if timetableItem.submissions === 0 %} From 0835393d9d4adfc0acff9081dcfaa7f203cc737b Mon Sep 17 00:00:00 2001 From: JanPhillips Date: Mon, 6 Oct 2025 15:30:58 +0100 Subject: [PATCH 10/11] fix(api): using const instead of let (APPLICS-1683) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/api/src/database/test-scripts/create-paths-test-script.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/database/test-scripts/create-paths-test-script.js b/apps/api/src/database/test-scripts/create-paths-test-script.js index 54f7a14ce4..7f40cc116b 100644 --- a/apps/api/src/database/test-scripts/create-paths-test-script.js +++ b/apps/api/src/database/test-scripts/create-paths-test-script.js @@ -11,7 +11,7 @@ import assert from 'assert'; * */ -let rootFolders = await databaseConnector.folder.findMany({ +const rootFolders = await databaseConnector.folder.findMany({ where: { parentFolderId: null } From 8288f8c7e0c2442725d2cac851b47ddb4e056348 Mon Sep 17 00:00:00 2001 From: Jan Phillips Date: Fri, 10 Oct 2025 12:47:35 +0100 Subject: [PATCH 11/11] fix(api): refactored recursivelySetPaths() (APPLICS-1683) --- apps/api/src/server/repositories/folder.repository.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/api/src/server/repositories/folder.repository.js b/apps/api/src/server/repositories/folder.repository.js index 72857506cf..0b54f2e3cd 100644 --- a/apps/api/src/server/repositories/folder.repository.js +++ b/apps/api/src/server/repositories/folder.repository.js @@ -335,20 +335,18 @@ const recursivelySetPaths = async (folderId) => { where: { id: folderId }, include: { childFolders: true } }); - if (!folder) return; - if (folder && Array.isArray(folder.childFolders) && folder.childFolders.length === 0) { + if (folder && !folder.childFolders) { await setPath(folder.id, folder.parentFolderId); return; } await setPath(folder.id, folder.parentFolderId); - for (const childFolder of folder.childFolders) { await setPath(childFolder.id, childFolder.parentFolderId); await recursivelySetPaths(childFolder.id); } } catch (e) { - throw new BackOfficeAppError(`Couldn't find folder ${folderId}`); + throw new BackOfficeAppError(`there was a problem setting the path for ${folderId}`); } };