diff --git a/README.md b/README.md index 2e69fd2a..84c0570f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This plugin can be consumed by the CAP application deployed on BTP to store thei - Link as attachments: Provides the capability to support link or URL as attachments. - Edit Link-type attachments: Provides the capability to update URL of link-type attachments. - Non-Draft Attachments: Provides the capability to work with attachments in non-draft (active) entities. +- Dynamic SDM Folder Paths: Provides the capability to organize attachments in custom nested folder structures within the SDM repository. ### Table of Contents @@ -28,6 +29,7 @@ This plugin can be consumed by the CAP application deployed on BTP to store thei - [Support for Link type attachments](#support-for-link-type-attachments) - [Support for Edit of Link type attachments](#support-for-edit-of-link-type-attachments) - [Support for Non-Draft Attachments](#support-for-non-draft-attachments) +- [Support for Dynamic SDM Folder Paths](#support-for-dynamic-sdm-folder-paths) - [Support for Multitenancy](#support-for-multitenancy) - [Deploying and testing the application](#deploying-and-testing-the-application) - [Running the unit tests](#running-the-unit-tests) @@ -630,6 +632,66 @@ service ProcessorService { } ``` +## Support for Dynamic SDM Folder Paths + +This plugin provides advanced folder management capabilities, allowing you to organize attachments in custom nested folder structures within the SDM repository. Instead of using the default entity-based folder structure, you can specify dynamic paths to organize attachments hierarchically based on your business logic. + +### Usage Examples + +#### 1. Basic Nested Folder Structure + +Organize attachments by project and document type: + +```javascript +// Create attachment with custom path +await POST('/Incidents(ID=)/attachments', { + filename: 'incident-report.pdf', + sdmPath: 'incidents/2024/reports' +}); +``` + +This creates the folder structure: +``` +SDM Repository Root +└── incidents/ + └── 2024/ + └── reports/ + └── incident-report.pdf +``` + +#### 2. Dynamic Path Based on Business Data + +Generate paths dynamically based on entity properties: + +```javascript +// In a custom handler +srv.before('CREATE', 'Attachments', async (req) => { + const incident = await SELECT.one.from(Incidents, req.data.up__ID); + + // Build path from incident properties + const year = new Date(incident.createdAt).getFullYear(); + const priority = incident.priority || 'normal'; + + req.data.sdmPath = `incidents/${year}/${priority}/${incident.customer.name}`; +}); +``` + +Resulting structure: +``` +incidents/ +├── 2024/ +│ ├── high/ +│ │ └── CustomerA/ +│ │ └── attachment.pdf +│ └── normal/ +│ └── CustomerB/ +│ └── attachment.pdf +└── 2025/ + └── high/ + └── CustomerC/ + └── attachment.pdf +``` + ## Support for Multitenancy This plugin automates repository lifecycle management in a multi-tenant setup. On tenant subscription, it provisions a repository and stores its details, and on unsubscription, it securely cleans up the repository. diff --git a/index.cds b/index.cds index 6e18875f..6f8cbad5 100644 --- a/index.cds +++ b/index.cds @@ -4,6 +4,7 @@ extend aspect Attachments with { repositoryId : String @title: 'Repository ID' @readonly default null; linkUrl: String @(title: '{i18n>LinkURL}') default null; type: String @(title: '{i18n>Type}') @(UI: {IsImageURL: true}) default 'sap-icon://document'; + sdmPath: String default null; }; annotate Attachments with @UI:{ HeaderInfo: { @@ -30,4 +31,5 @@ annotate Attachments with @UI:{ mimeType @UI.Hidden; status @UI.Hidden; repositoryId @UI.Hidden; + sdmPath @UI.Hidden; } diff --git a/lib/handler/index.js b/lib/handler/index.js index 9a44a45b..6dffcf24 100644 --- a/lib/handler/index.js +++ b/lib/handler/index.js @@ -109,6 +109,19 @@ async function getFolderIdByIDAsPath(req, credentials, destination, attachments) } } +async function getFolderIdByDynamicPath(repositoryId, folderPath, credentials, destination) { + const getFolderURL = `${credentials.uri}browser/${repositoryId}/root/${folderPath}?cmisselector=object`; + try { + const response = await executeHttpRequest(destination, { + method: 'GET', + url: getFolderURL, + }); + return response.data.properties["cmis:objectId"].value; + } catch { + return null; + } +} + async function createFolder(req, credentials, attachments, customFolderName, destination) { const upID = attachments.keys.up_.keys[0].$generatedFieldName; const { repositoryId } = getConfigurations(); @@ -138,6 +151,39 @@ async function createFolder(req, credentials, attachments, customFolderName, des return response } +async function createNestedFolders(repositoryId, folderPath, credentials, destination) { + const pathSegments = folderPath.split('/').filter((segment) => segment); + let currentPath = ''; + let parentId = null; + for (const segment of pathSegments) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const existingFolderId = await getFolderIdByDynamicPath(repositoryId, currentPath, credentials, destination); + if (existingFolderId) parentId = existingFolderId; + else { + const createURL = `${credentials.uri}browser/${repositoryId}/root`; + const formData = new FormData(); + formData.append('cmisaction', 'createFolder'); + if (parentId) formData.append('objectId', parentId); + formData.append('propertyId[0]', 'cmis:name'); + formData.append('propertyValue[0]', segment); + formData.append('propertyId[1]', 'cmis:objectTypeId'); + formData.append('propertyValue[1]', 'cmis:folder'); + formData.append('succinct', 'true'); + try { + const response = await executeHttpRequest(destination, { method: 'POST', url: createURL, data: formData }); + parentId = response.data.succinctProperties['cmis:objectId']; + } catch (error) { + const retryFolderId = await getFolderIdByDynamicPath(repositoryId, currentPath, credentials, destination); + if (retryFolderId) parentId = retryFolderId; + else { + throw error; + } + } + } + } + return parentId; +} + async function createAttachment( data, credentials, @@ -471,7 +517,9 @@ module.exports = { getRepositoryInfo, getFolderIdByPath, getFolderIdByIDAsPath, + getFolderIdByDynamicPath, createFolder, + createNestedFolders, createAttachment, editLink, deleteAttachmentsOfFolder, diff --git a/lib/sdm.js b/lib/sdm.js index 10dffdee..8de21ea4 100644 --- a/lib/sdm.js +++ b/lib/sdm.js @@ -5,7 +5,9 @@ const { getRepositoryInfo, getFolderIdByPath, getFolderIdByIDAsPath, + getFolderIdByDynamicPath, createFolder, + createNestedFolders, createAttachment, editLink, deleteAttachmentsOfFolder, @@ -558,8 +560,27 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; async getParentId(attachments, req, upId){ const { repositoryId } = getConfigurations(); - const folderIds = await getFolderIdForEntity(attachments, req, repositoryId, upId); + const destination = await this.getDestination(req); + + let attachmentID = req.data?.ID; + + if (req.event === 'UPDATE' || req.event === 'PUT') { + attachmentID = req.req.url.match(attachmentIDRegex)[1]; + } + + const metadata = await cds.ql.SELECT.one.from(req.target).where({ ID: attachmentID }); + let folderPath = metadata?.sdmPath; + let parentId = null; + + if(!folderPath) folderPath = req.data?.sdmPath; + + if (folderPath) { + parentId = await getFolderIdByDynamicPath(repositoryId, folderPath, this.creds, destination); + return parentId ?? (await createNestedFolders(repositoryId, folderPath, this.creds, destination)); + } + + const folderIds = await getFolderIdForEntity(attachments, req, repositoryId, upId); for (const folder of folderIds) { if (folder.folderId !== null) { @@ -1088,6 +1109,7 @@ let subdomain = cds.context?.user?.authInfo?.token?.payload?.ext_attr?.zdn; HasActiveEntity: false, linkUrl: req.data?.url, DraftAdministrativeData_DraftUUID: draftUUID[0].DraftAdministrativeData_DraftUUID, + sdmPath: req.data?.sdmPath }; console.info(`[createLink] updating link in draft`, { updatedFields }); diff --git a/test/lib/handler/index.test.js b/test/lib/handler/index.test.js index 3d126afa..8bb81960 100644 --- a/test/lib/handler/index.test.js +++ b/test/lib/handler/index.test.js @@ -34,7 +34,9 @@ const { readAttachment, getFolderIdByPath, getFolderIdByIDAsPath, + getFolderIdByDynamicPath, createFolder, + createNestedFolders, deleteFolderWithAttachments, getAttachment, getRepositoryInfo, @@ -352,6 +354,245 @@ describe("handlers", () => { }); }); + describe("getFolderIdByDynamicPath", () => { + let repositoryId, folderPath, credentials, destination; + + beforeEach(() => { + jest.clearAllMocks(); + executeHttpRequest.mockClear(); + repositoryId = "123"; + folderPath = "project/documents"; + credentials = { uri: "http://example.com/" }; + destination = { url: "http://example.com" }; + }); + + it("should return folderId when folder exists", async () => { + const mockResponse = { + data: { properties: { "cmis:objectId": { value: "folder-id-123" } } } + }; + executeHttpRequest.mockResolvedValue(mockResponse); + + const result = await getFolderIdByDynamicPath(repositoryId, folderPath, credentials, destination); + + expect(result).toBe("folder-id-123"); + expect(executeHttpRequest).toHaveBeenCalledWith(destination, { + method: 'GET', + url: `${credentials.uri}browser/${repositoryId}/root/${folderPath}?cmisselector=object` + }); + }); + + it("should return null when folder does not exist", async () => { + executeHttpRequest.mockRejectedValue(new Error("Not found")); + + const result = await getFolderIdByDynamicPath(repositoryId, folderPath, credentials, destination); + + expect(result).toBeNull(); + }); + + it("should handle nested folder paths correctly", async () => { + const nestedPath = "level1/level2/level3"; + const mockResponse = { + data: { properties: { "cmis:objectId": { value: "nested-folder-id" } } } + }; + executeHttpRequest.mockResolvedValue(mockResponse); + + const result = await getFolderIdByDynamicPath(repositoryId, nestedPath, credentials, destination); + + expect(result).toBe("nested-folder-id"); + expect(executeHttpRequest).toHaveBeenCalledWith(destination, { + method: 'GET', + url: `${credentials.uri}browser/${repositoryId}/root/${nestedPath}?cmisselector=object` + }); + }); + + it("should handle single folder path correctly", async () => { + const singlePath = "documents"; + const mockResponse = { + data: { properties: { "cmis:objectId": { value: "single-folder-id" } } } + }; + executeHttpRequest.mockResolvedValue(mockResponse); + + const result = await getFolderIdByDynamicPath(repositoryId, singlePath, credentials, destination); + + expect(result).toBe("single-folder-id"); + }); + + it("should return null on network error", async () => { + executeHttpRequest.mockRejectedValue(new Error("Network error")); + + const result = await getFolderIdByDynamicPath(repositoryId, folderPath, credentials, destination); + + expect(result).toBeNull(); + }); + }); + + describe("createNestedFolders", () => { + let repositoryId, folderPath, credentials, destination; + + beforeEach(() => { + jest.clearAllMocks(); + executeHttpRequest.mockReset(); + mockFormDataInstances = []; + repositoryId = "123"; + credentials = { uri: "http://example.com/" }; + destination = { url: "http://example.com" }; + }); + + it("should create nested folders and return last folder ID", async () => { + folderPath = "project/documents/2024"; + + jest.clearAllMocks(); + executeHttpRequest.mockReset(); + + // Für jedes Segment: GET (check), POST (create) + executeHttpRequest + .mockRejectedValueOnce(new Error("Not found")) // GET "project" -> doesn't exist + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "folder-1" } } }) // POST create "project" + .mockRejectedValueOnce(new Error("Not found")) // GET "project/documents" -> doesn't exist + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "folder-2" } } }) // POST create "documents" + .mockRejectedValueOnce(new Error("Not found")) // GET "project/documents/2024" -> doesn't exist + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "folder-3" } } }); // POST create "2024" + + const result = await createNestedFolders(repositoryId, folderPath, credentials, destination); + + expect(result).toBe("folder-3"); + expect(executeHttpRequest).toHaveBeenCalledTimes(6); // 3 GETs + 3 POSTs + }); + + it("should skip existing folders and only create missing ones", async () => { + folderPath = "existing/new"; + + jest.clearAllMocks(); + executeHttpRequest.mockReset(); + + // First folder exists, second doesn't + executeHttpRequest + .mockResolvedValueOnce({ data: { properties: { "cmis:objectId": { value: "existing-folder-id" } } } }) // GET "existing" -> exists + .mockRejectedValueOnce(new Error("Not found")) // GET "existing/new" -> doesn't exist + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "new-folder-id" } } }); // POST create "new" + + const result = await createNestedFolders(repositoryId, folderPath, credentials, destination); + + expect(result).toBe("new-folder-id"); + expect(executeHttpRequest).toHaveBeenCalledTimes(3); // 2 GETs + 1 POST + }); + + it("should handle single folder creation", async () => { + folderPath = "documents"; + + jest.clearAllMocks(); + executeHttpRequest.mockReset(); + + executeHttpRequest + .mockRejectedValueOnce(new Error("Not found")) // GET "documents" -> doesn't exist + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "single-folder-id" } } }); // POST create "documents" + + const result = await createNestedFolders(repositoryId, folderPath, credentials, destination); + + expect(result).toBe("single-folder-id"); + expect(executeHttpRequest).toHaveBeenCalledTimes(2); // 1 GET + 1 POST + }); + + it("should filter out empty path segments", async () => { + folderPath = "project//documents/"; // Double slash and trailing slash + + jest.clearAllMocks(); + executeHttpRequest.mockReset(); + + executeHttpRequest + .mockRejectedValueOnce(new Error("Not found")) // GET "project" + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "folder-1" } } }) // POST create "project" + .mockRejectedValueOnce(new Error("Not found")) // GET "project/documents" + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "folder-2" } } }); // POST create "documents" + + const result = await createNestedFolders(repositoryId, folderPath, credentials, destination); + + expect(result).toBe("folder-2"); + expect(executeHttpRequest).toHaveBeenCalledTimes(4); // 2 GETs + 2 POSTs (only 2 folders) + }); + + it("should use parent folder ID when creating nested folders", async () => { + folderPath = "parent/child"; + + jest.clearAllMocks(); + executeHttpRequest.mockReset(); + + executeHttpRequest + .mockRejectedValueOnce(new Error("Not found")) // GET "parent" + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "parent-id" } } }) // POST create "parent" + .mockRejectedValueOnce(new Error("Not found")) // GET "parent/child" + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "child-id" } } }); // POST create "child" + + await createNestedFolders(repositoryId, folderPath, credentials, destination); + + // Check that second folder creation includes parent ID + const secondFormData = mockFormDataInstances[1]; + expect(secondFormData.append).toHaveBeenCalledWith("objectId", "parent-id"); + }); + + it("should handle race condition by retrying folder check on creation error", async () => { + folderPath = "concurrent/folder"; + + jest.clearAllMocks(); + executeHttpRequest.mockReset(); + + // Simulate race condition: folder created by another process + const createError = new Error("Folder already exists"); + + executeHttpRequest + .mockRejectedValueOnce(new Error("Not found")) // GET "concurrent" + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "concurrent-folder-1" } } }) // POST create "concurrent" + .mockRejectedValueOnce(new Error("Not found")) // GET "concurrent/folder" + .mockRejectedValueOnce(createError) // POST create "folder" fails (race) + .mockResolvedValueOnce({ data: { properties: { "cmis:objectId": { value: "existing-folder-2" } } } }); // Retry GET finds it + + const result = await createNestedFolders(repositoryId, folderPath, credentials, destination); + + expect(result).toBe("existing-folder-2"); + }); + + it("should throw error when folder creation fails and retry also fails", async () => { + folderPath = "failing/folder"; + + jest.clearAllMocks(); + executeHttpRequest.mockReset(); + + const createError = new Error("Permanent error"); + + executeHttpRequest + .mockRejectedValueOnce(new Error("Not found")) // GET "failing" + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "folder-1" } } }) // POST create "failing" + .mockRejectedValueOnce(new Error("Not found")) // GET "failing/folder" + .mockRejectedValueOnce(createError) // POST create "folder" fails + .mockRejectedValueOnce(new Error("Not found")); // Retry GET also fails + + await expect(createNestedFolders(repositoryId, folderPath, credentials, destination)) + .rejects.toThrow("Permanent error"); + }); + + it("should create all folders when none exist", async () => { + folderPath = "level1/level2/level3/level4"; + + jest.clearAllMocks(); + executeHttpRequest.mockReset(); + + executeHttpRequest + .mockRejectedValueOnce(new Error("Not found")) // GET "level1" + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "id-1" } } }) // POST "level1" + .mockRejectedValueOnce(new Error("Not found")) // GET "level1/level2" + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "id-2" } } }) // POST "level2" + .mockRejectedValueOnce(new Error("Not found")) // GET "level1/level2/level3" + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "id-3" } } }) // POST "level3" + .mockRejectedValueOnce(new Error("Not found")) // GET "level1/level2/level3/level4" + .mockResolvedValueOnce({ data: { succinctProperties: { "cmis:objectId": "id-4" } } }); // POST "level4" + + const result = await createNestedFolders(repositoryId, folderPath, credentials, destination); + + expect(result).toBe("id-4"); + expect(executeHttpRequest).toHaveBeenCalledTimes(8); // 4 GETs + 4 POSTs + }); + }); + describe("createFolder", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/test/lib/sdm.test.js b/test/lib/sdm.test.js index 0a38e2f7..22ffd51f 100644 --- a/test/lib/sdm.test.js +++ b/test/lib/sdm.test.js @@ -39,7 +39,9 @@ const { readAttachment, getFolderIdByPath, getFolderIdByIDAsPath, + getFolderIdByDynamicPath, createFolder, + createNestedFolders, deleteFolderWithAttachments, getAttachment, getRepositoryInfo, @@ -132,7 +134,9 @@ jest.mock("../../lib/handler", () => ({ readAttachment: jest.fn(), getFolderIdByPath: jest.fn(), getFolderIdByIDAsPath: jest.fn(), + getFolderIdByDynamicPath: jest.fn(), createFolder: jest.fn(), + createNestedFolders: jest.fn(), deleteFolderWithAttachments: jest.fn(), getAttachment: jest.fn(), renameAttachment: jest.fn(), @@ -4348,6 +4352,134 @@ describe("SDMAttachmentsService", () => { expect(getFolderIdByPath).not.toHaveBeenCalled(); expect(createFolder).not.toHaveBeenCalled(); }); + + it("getParentId should use sdmPath from metadata when available", async () => { + const attachments = cds.model.definitions[mockReq.target.name + ".references"]; + mockReq.data = { ID: "attachment-123" }; + + // Mock global SELECT to return metadata with sdmPath + global.SELECT.one.from = jest.fn().mockReturnValue({ + where: jest.fn().mockResolvedValue({ sdmPath: "project/documents/2024" }) + }); + + getFolderIdByDynamicPath.mockResolvedValueOnce("existing-folder-id"); + + const parentId = await service.getParentId(attachments, mockReq, undefined); + + expect(getFolderIdByDynamicPath).toHaveBeenCalledWith( + "repo123", + "project/documents/2024", + service.creds, + mockDestination + ); + expect(parentId).toBe("existing-folder-id"); + expect(getFolderIdForEntity).not.toHaveBeenCalled(); + }); + + it("getParentId should use sdmPath from req.data when metadata sdmPath is not available", async () => { + const attachments = cds.model.definitions[mockReq.target.name + ".references"]; + mockReq.data = { ID: "attachment-123", sdmPath: "custom/path/files" }; + + // Mock global SELECT to return metadata without sdmPath + global.SELECT.one.from = jest.fn().mockReturnValue({ + where: jest.fn().mockResolvedValue({ sdmPath: null }) + }); + + getFolderIdByDynamicPath.mockResolvedValueOnce("custom-folder-id"); + + const parentId = await service.getParentId(attachments, mockReq, undefined); + + expect(getFolderIdByDynamicPath).toHaveBeenCalledWith( + "repo123", + "custom/path/files", + service.creds, + mockDestination + ); + expect(parentId).toBe("custom-folder-id"); + }); + + it("getParentId should create nested folders when sdmPath folder does not exist", async () => { + const attachments = cds.model.definitions[mockReq.target.name + ".references"]; + mockReq.data = { sdmPath: "new/nested/folder" }; + + // Mock global SELECT to return no metadata + global.SELECT.one.from = jest.fn().mockReturnValue({ + where: jest.fn().mockResolvedValue(null) + }); + + getFolderIdByDynamicPath.mockResolvedValueOnce(null); + createNestedFolders.mockResolvedValueOnce("newly-created-folder-id"); + + const parentId = await service.getParentId(attachments, mockReq, undefined); + + expect(createNestedFolders).toHaveBeenCalledWith( + "repo123", + "new/nested/folder", + service.creds, + mockDestination + ); + expect(parentId).toBe("newly-created-folder-id"); + }); + + it("getParentId should fallback to default behavior when no sdmPath is provided", async () => { + const attachments = cds.model.definitions[mockReq.target.name + ".references"]; + mockReq.data = { ID: "attachment-123" }; + + // Mock global SELECT to return metadata without sdmPath + global.SELECT.one.from = jest.fn().mockReturnValue({ + where: jest.fn().mockResolvedValue({ sdmPath: null }) + }); + + getFolderIdForEntity.mockResolvedValueOnce([{ folderId: "default-folder-id" }]); + + const parentId = await service.getParentId(attachments, mockReq, undefined); + + expect(getFolderIdForEntity).toHaveBeenCalled(); + expect(parentId).toBe("default-folder-id"); + expect(getFolderIdByDynamicPath).not.toHaveBeenCalled(); + expect(createNestedFolders).not.toHaveBeenCalled(); + }); + + it("getParentId should handle UPDATE event with sdmPath", async () => { + const attachments = cds.model.definitions[mockReq.target.name + ".references"]; + mockReq.event = "UPDATE"; + mockReq.req = { url: "/attachments(ID=550e8400-e29b-41d4-a716-446655440001,IsActiveEntity=true)" }; + + // Mock global SELECT to return metadata with sdmPath + global.SELECT.one.from = jest.fn().mockReturnValue({ + where: jest.fn().mockResolvedValue({ sdmPath: "updated/path" }) + }); + + getFolderIdByDynamicPath.mockResolvedValueOnce("updated-folder-id"); + + const parentId = await service.getParentId(attachments, mockReq, undefined); + + expect(parentId).toBe("updated-folder-id"); + }); + + it("getParentId should handle PUT event with sdmPath", async () => { + const attachments = cds.model.definitions[mockReq.target.name + ".references"]; + mockReq.event = "PUT"; + mockReq.req = { url: "/attachments(ID=550e8400-e29b-41d4-a716-446655440002,IsActiveEntity=false)" }; + + // Mock global SELECT to return metadata with sdmPath + global.SELECT.one.from = jest.fn().mockReturnValue({ + where: jest.fn().mockResolvedValue({ sdmPath: "draft/upload" }) + }); + + getFolderIdByDynamicPath.mockResolvedValueOnce(null); + createNestedFolders.mockResolvedValueOnce("draft-folder-id"); + + const parentId = await service.getParentId(attachments, mockReq, undefined); + + expect(createNestedFolders).toHaveBeenCalledWith( + "repo123", + "draft/upload", + service.creds, + mockDestination + ); + expect(parentId).toBe("draft-folder-id"); + }); }); describe("isFileNameDuplicateInDrafts", () => {