diff --git a/apps/api/openapi/components/tags/application/common/ObjectAttachment.json b/apps/api/openapi/components/tags/application/common/ObjectAttachment.json index 2f598d3..742e31c 100644 --- a/apps/api/openapi/components/tags/application/common/ObjectAttachment.json +++ b/apps/api/openapi/components/tags/application/common/ObjectAttachment.json @@ -2,11 +2,17 @@ "type": "object", "description": "A single file attachment.", "properties": { + "uuid_file": { + "type": "string", + "format": "uuid", + "description": "The unique identifier of the file resource.", + "example": "123e4567-e89b-12d3-a456-426614174000" + }, "downloadUrl": { "type": "string", "format": "uri-relative", "description": "The relative URL path to download the attachment.", - "example": "/APPLICATION/ATTACHMENT/123e4f...-426614174001" + "example": "/APPLICATION/ATTACHMENT/123e4f...-426614174001?ticket=xyz" }, "name": { "type": "string", diff --git a/apps/api/openapi/components/tags/application/responses/GetApplicationSuccess.json b/apps/api/openapi/components/tags/application/responses/GetApplicationSuccess.json index 33c4535..a99f9bc 100644 --- a/apps/api/openapi/components/tags/application/responses/GetApplicationSuccess.json +++ b/apps/api/openapi/components/tags/application/responses/GetApplicationSuccess.json @@ -21,9 +21,8 @@ "bannerUrl": { "type": "string", "format": "uri-relative", - "description": "The relative URL path for the application's banner image. Null if no banner is set.", - "example": "/APPLICATION/BANNER/123e4567-e89b-12d3-a456-426614174000", - "nullable": true + "description": "The relative URL path for the application's banner image.", + "example": "/APPLICATION/BANNER/123e4f...-426614174001?ticket=xyz" }, "attachments": { "type": "array", diff --git a/apps/api/openapi/components/tags/project/responses/GetProjectSuccess.json b/apps/api/openapi/components/tags/project/responses/GetProjectSuccess.json index 42a4fa0..fe70344 100644 --- a/apps/api/openapi/components/tags/project/responses/GetProjectSuccess.json +++ b/apps/api/openapi/components/tags/project/responses/GetProjectSuccess.json @@ -22,8 +22,7 @@ "type": "string", "format": "uri-relative", "description": "The relative URL path for the project banner (inherited from application).", - "example": "/file/view/123e4567?ticket=xyz", - "nullable": true + "example": "/APPLICATION/BANNER/123e4f...-426614174001?ticket=xyz" }, "appAttachments": { "type": "array", @@ -31,6 +30,35 @@ "items": { "$ref": "../../application/common/ObjectAttachment.json" } + }, + "resources": { + "type": "array", + "description": "A list of file resources (deliverables) uploaded by the project team.", + "items": { + "allOf": [ + { + "$ref": "../../application/common/ObjectAttachment.json" + }, + { + "type": "object", + "properties": { + "downloadUrl": { + "type": "string", + "format": "uri-relative", + "description": "The relative URL path to download the resources.", + "example": "/PROJECT/RESOURCE/123e4f...-426614174001?ticket=xyz" + }, + "uuidAuthor": { + "type": "string", + "format": "uuid", + "description": "The UUID of the team member who uploaded this resource.", + "nullable": true, + "example": "a1b2c3d4-e5f6-7890-1234-56789abcdef0" + } + } + } + ] + } } } } diff --git a/packages/application/applications/getDetailedApplication.js b/packages/application/applications/getDetailedApplication.js index 2af7ad8..0daa0b6 100644 --- a/packages/application/applications/getDetailedApplication.js +++ b/packages/application/applications/getDetailedApplication.js @@ -1,5 +1,6 @@ import * as ApplicationError from "../errors/index.js"; import { validateUuid } from "../shared/validators.js"; +import { mapFileLink } from "../shared/fileUtils.js"; import { generateFileTicket } from "@reuc/domain/user/session/generateFileTicket.js"; import { getDetailedApplication as getAppDomain } from "@reuc/domain/application/getDetailedApplication.js"; import { getLinksByTarget } from "@reuc/domain/file/getLinksByTarget.js"; @@ -163,25 +164,7 @@ function _normalizeFiles(uuidUser, tokenConfig, fileLinks) { // 2. Map all ATTACHMENT links const attachments = fileLinks .filter((link) => link.purpose === "ATTACHMENT") - .map((link) => { - const basePath = buildFileUrl(link); - if (!basePath) return null; - - const fileIdentifier = basePath.substring(1); - const ticket = generateFileTicket({ - uuidUser: uuidUser, - fileIdentifier, - audience: "download", - tokenConfig, - }); - - return { - downloadUrl: `${basePath}?ticket=${ticket}`, - name: link.file.originalName, - size: link.file.fileSize, - type: link.file.mimetype, - }; - }) + .map((link) => mapFileLink(link, uuidUser, tokenConfig, "download")) .filter(Boolean); // Remove any nulls from failed buildFileUrl calls return { bannerUrl, attachments }; diff --git a/packages/application/project/getDetailedProject.js b/packages/application/project/getDetailedProject.js index 8e9e05e..3b8b3c1 100644 --- a/packages/application/project/getDetailedProject.js +++ b/packages/application/project/getDetailedProject.js @@ -1,5 +1,6 @@ import * as ApplicationError from "../errors/index.js"; import { validateUuid } from "../shared/validators.js"; +import { mapFileLink } from "../shared/fileUtils.js"; import { generateFileTicket } from "@reuc/domain/user/session/generateFileTicket.js"; import { getDetailedProject as getProjectDomain } from "@reuc/domain/project/getDetailedProject.js"; import { getLinksByTarget } from "@reuc/domain/file/getLinksByTarget.js"; @@ -37,17 +38,26 @@ export async function getDetailedProject( // Step 1: Fetch Project Data first const projectData = await getProjectDomain(projectUuid); - // Step 2: Fetch Files using the related application UUID - const appUuid = projectData.uuidApplication; - const fileLinks = await getLinksByTarget(appUuid); + // Step 2: Fetch Files concurrently + // A. Fetch files linked to the Application (Banner, Initial Attachments) + // B. Fetch files linked to the Project (Team Resources) + const [appLinks, projectLinks] = await Promise.all([ + getLinksByTarget(projectData.uuidApplication), + getLinksByTarget(projectData.uuid_project), + ]); // Step 3: Normalize and separate the data. const author = _normalizeAuthor(projectData.application.author); const details = _normalizeDetails(projectData); - const { bannerUrl, attachments } = _normalizeFiles( + const { bannerUrl, attachments } = _normalizeAppFiles( uuidRequestingUser, tokenConfig, - fileLinks + appLinks + ); + const resources = _normalizeProjectResources( + uuidRequestingUser, + tokenConfig, + projectLinks ); // Step 4: Stitch the final DTO. @@ -56,7 +66,7 @@ export async function getDetailedProject( details, bannerUrl, appAttachments: attachments, - // TODO: In the future, merge with project.attachments here + resources, }; } catch (err) { if (err instanceof DomainError.NotFoundError) @@ -178,7 +188,7 @@ function _normalizeDetails(projectData) { * Normalize file links into a banner URL and attachment list. * @param {Array} fileLinks - The list of file links from the domain. */ -function _normalizeFiles(uuidUser, tokenConfig, fileLinks) { +function _normalizeAppFiles(uuidUser, tokenConfig, fileLinks) { let bannerUrl = null; // 1. Find the first BANNER link @@ -201,26 +211,30 @@ function _normalizeFiles(uuidUser, tokenConfig, fileLinks) { // 2. Map all ATTACHMENT links const attachments = fileLinks .filter((link) => link.purpose === "ATTACHMENT") - .map((link) => { - const basePath = buildFileUrl(link); - if (!basePath) return null; + .map((link) => mapFileLink(link, uuidUser, tokenConfig, "download")) + .filter(Boolean); - const fileIdentifier = basePath.substring(1); - const ticket = generateFileTicket({ - uuidUser: uuidUser, - fileIdentifier, - audience: "download", - tokenConfig, - }); + return { bannerUrl, attachments }; +} + +/** + * @private + * Normalize project-level resources (Deliverables). + * @param {string} uuidUser + * @param {object} tokenConfig + * @param {Array} fileLinks + */ +function _normalizeProjectResources(uuidUser, tokenConfig, fileLinks) { + return fileLinks + .filter((link) => link.purpose === "RESOURCE") + .map((link) => { + const fileData = mapFileLink(link, uuidUser, tokenConfig, "download"); + if (!fileData) return null; return { - downloadUrl: `${basePath}?ticket=${ticket}`, - name: link.file.originalName, - size: link.file.fileSize, - type: link.file.mimetype, + ...fileData, + uuidAuthor: link.author?.uuid_user || null, // Attach the author UUID }; }) - .filter(Boolean); // Remove any nulls from failed buildFileUrl calls - - return { bannerUrl, attachments }; + .filter(Boolean); } diff --git a/packages/application/shared/fileUtils.js b/packages/application/shared/fileUtils.js new file mode 100644 index 0000000..820382c --- /dev/null +++ b/packages/application/shared/fileUtils.js @@ -0,0 +1,41 @@ +import { generateFileTicket } from "@reuc/domain/user/session/generateFileTicket.js"; +import { buildFileUrl } from "@reuc/domain/file/buildFileUrl.js"; + +/** + * Maps a raw file link to a normalized DTO with a secure access ticket. + * Used for attachments and resources across the application. + * @param {object} link - The file link object from the database/repository. + * @param {object} link.file + * @param {string} link.file.uuid_file - The unique identifier of the file. + * @param {string} link.file.originalName - The original name of the file. + * @param {number} link.file.fileSize - The size of the file in bytes. + * @param {string} link.file.mimetype - The MIME type of the file. + * @param {string} link.modelTarget - The type of model the file is linked to. + * @param {string} link.uuidTarget - The UUID of the target entity the file is linked to. + * @param {string} link.purpose - The purpose or category of the file attachment. + * @param {object} link.author + * @param {string} link.author.uuid_user - The unique identifier of the file author. + * @param {string} uuidUser - The UUID of the user requesting access. + * @param {object} tokenConfig - Token configuration for ticket generation. + * @param {"viewing"|"download"} audience - The intended use (viewing vs download). + */ +export function mapFileLink(link, uuidUser, tokenConfig, audience) { + const basePath = buildFileUrl(link); + if (!basePath) return null; + + const fileIdentifier = basePath.substring(1); + const ticket = generateFileTicket({ + uuidUser: uuidUser, + fileIdentifier, + audience, + tokenConfig, + }); + + return { + uuid_file: link.file.uuid_file, + downloadUrl: `${basePath}?ticket=${ticket}`, + name: link.file.originalName, + size: link.file.fileSize, + type: link.file.mimetype, + }; +} diff --git a/packages/infrastructure/fileRepo.js b/packages/infrastructure/fileRepo.js index e673324..783dfa7 100644 --- a/packages/infrastructure/fileRepo.js +++ b/packages/infrastructure/fileRepo.js @@ -93,6 +93,7 @@ export const fileRepo = { modelTarget, uuidTarget, purpose, + deletedAt: null, }; if (uuidFile) { @@ -149,6 +150,7 @@ export const fileRepo = { modelTarget, purpose, uuidTarget: { in: targetUuids }, + deletedAt: null, }, select: { uuidTarget: true, modelTarget: true, purpose: true }, }); @@ -183,11 +185,16 @@ export const fileRepo = { async getLinksAndMetadataByTarget(targetUuid) { try { return await db.file_Link.findMany({ - where: { uuidTarget: targetUuid }, + where: { uuidTarget: targetUuid, deletedAt: null }, select: { modelTarget: true, purpose: true, uuidTarget: true, + author: { + select: { + uuid_user: true, + }, + }, file: { select: { uuid_file: true, diff --git a/packages/infrastructure/prisma/schema.prisma b/packages/infrastructure/prisma/schema.prisma index f20c90c..6e575ff 100644 --- a/packages/infrastructure/prisma/schema.prisma +++ b/packages/infrastructure/prisma/schema.prisma @@ -433,7 +433,7 @@ model File_Link { purpose String createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @updatedAt @map("deleted_at") + deletedAt DateTime? @map("deleted_at") file File @relation(fields: [uuidFile], references: [uuid_file]) author User? @relation(fields: [uuidCreatedBy], references: [uuid_user])