Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,43 @@
"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",
"description": "A list of file attachments inherited from the original application.",
"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"
}
}
}
]
}
}
}
}
Expand Down
21 changes: 2 additions & 19 deletions packages/application/applications/getDetailedApplication.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 };
Expand Down
62 changes: 38 additions & 24 deletions packages/application/project/getDetailedProject.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -178,7 +188,7 @@ function _normalizeDetails(projectData) {
* Normalize file links into a banner URL and attachment list.
* @param {Array<object>} 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
Expand All @@ -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<object>} 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);
}
41 changes: 41 additions & 0 deletions packages/application/shared/fileUtils.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
9 changes: 8 additions & 1 deletion packages/infrastructure/fileRepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const fileRepo = {
modelTarget,
uuidTarget,
purpose,
deletedAt: null,
};

if (uuidFile) {
Expand Down Expand Up @@ -149,6 +150,7 @@ export const fileRepo = {
modelTarget,
purpose,
uuidTarget: { in: targetUuids },
deletedAt: null,
},
select: { uuidTarget: true, modelTarget: true, purpose: true },
});
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/infrastructure/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading