From 9e317d5d5ba4920dd14f4c0a1117fb32b3cd71d4 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Tue, 3 Feb 2026 17:37:32 +0100 Subject: [PATCH 1/5] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 1793dcd36..0249752cd 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13533,6 +13533,7 @@ paths: - id - url - mimetype + - presigned_url properties: id: type: number @@ -13546,6 +13547,10 @@ paths: type: string x-stoplight: id: 6tqj2cg96280v + presigned_url: + type: string + x-stoplight: + id: 8zt5euhrtitz3 cost: type: number x-stoplight: From d20a81e94ae1c60ecf02c68f8de4d203811bc9cf Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 17:52:33 +0100 Subject: [PATCH 2/5] feat: add presigned_url field to operations cost items --- src/schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/schema.ts b/src/schema.ts index eb8245458..57b911e6e 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5654,6 +5654,7 @@ export interface operations { id: number; url: string; mimetype: string; + presigned_url: string; }[]; cost: number; }[]; From ec2ae10f973eda70f2f5cfb37df65fc5f72147b5 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 17:53:00 +0100 Subject: [PATCH 3/5] feat: add expiration parameter to getPresignedUrl function --- src/features/s3/presignUrl/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/s3/presignUrl/index.ts b/src/features/s3/presignUrl/index.ts index dd0df6577..107332838 100644 --- a/src/features/s3/presignUrl/index.ts +++ b/src/features/s3/presignUrl/index.ts @@ -4,8 +4,11 @@ import { parseUrl } from "@aws-sdk/url-parser"; import { Hash } from "@aws-sdk/hash-node"; import { formatUrl } from "@aws-sdk/util-format-url"; -export const getPresignedUrl = async (url: string): Promise => { - const expirationSeconds = 1200; // 20 minutes +// default expiration is 20 minutes (1200 seconds) +export const getPresignedUrl = async ( + url: string, + expirationSeconds: number = 1200 +): Promise => { const s3ObjectUrl = parseUrl(url); const presigner = new S3RequestPresigner({ credentials: { From 7c52a4d6faf1e01b70595832aa2d538df6ec6c7d Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 17:54:08 +0100 Subject: [PATCH 4/5] feat: integrate presigned URLs for attachments in other costs retrieval --- .../finance/otherCosts/_get/index.spec.ts | 59 ++++++++++++++++++ .../finance/otherCosts/_get/index.ts | 61 +++++++++++-------- 2 files changed, 94 insertions(+), 26 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts index b475ade8a..9270882bf 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts @@ -1,8 +1,22 @@ import request from "supertest"; import app from "@src/app"; import { tryber } from "@src/features/database"; +import { getPresignedUrl } from "@src/features/s3/presignUrl"; + +jest.mock("@src/features/s3/presignUrl"); + +const mockedGetPresignedUrl = getPresignedUrl as jest.MockedFunction< + typeof getPresignedUrl +>; describe("GET /campaigns/campaignId/finance/otherCosts", () => { + beforeEach(() => { + mockedGetPresignedUrl.mockImplementation(async (url: string) => url); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); beforeAll(async () => { await tryber.tables.WpAppqEvdProfile.do().insert([ { @@ -176,11 +190,13 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { id: 1, url: "https://example.com/attachment1.pdf", mimetype: "application/pdf", + presigned_url: expect.any(String), }), expect.objectContaining({ id: 2, url: "https://example.com/attachment2.jpg", mimetype: "image/jpeg", + presigned_url: expect.any(String), }), ]), }), @@ -201,6 +217,7 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { id: 3, url: "https://example.com/attachment3.png", mimetype: "image/png", + presigned_url: expect.any(String), }), ]), }), @@ -230,6 +247,20 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { id: 1, }, description: "Cost 1 description", + attachments: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + url: "https://example.com/attachment1.pdf", + mimetype: "application/pdf", + presigned_url: expect.any(String), + }), + expect.objectContaining({ + id: 2, + url: "https://example.com/attachment2.jpg", + mimetype: "image/jpeg", + presigned_url: expect.any(String), + }), + ]), }), expect.objectContaining({ cost_id: 2, @@ -243,6 +274,14 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { id: 2, }, description: "Cost 2 description", + attachments: expect.arrayContaining([ + expect.objectContaining({ + id: 3, + url: "https://example.com/attachment3.png", + mimetype: "image/png", + presigned_url: expect.any(String), + }), + ]), }), ]), }) @@ -307,4 +346,24 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { expect(costWithoutAttachments.cost).toBe(50); expect(costWithoutAttachments.attachments).toEqual([]); }); + + it("Should call getPresignedUrl for each attachment with 3 hours expiration", async () => { + await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + + expect(mockedGetPresignedUrl).toHaveBeenCalledTimes(3); + expect(mockedGetPresignedUrl).toHaveBeenCalledWith( + "https://example.com/attachment1.pdf", + 10800 + ); + expect(mockedGetPresignedUrl).toHaveBeenCalledWith( + "https://example.com/attachment2.jpg", + 10800 + ); + expect(mockedGetPresignedUrl).toHaveBeenCalledWith( + "https://example.com/attachment3.png", + 10800 + ); + }); }); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts index 8234403fd..37eb32440 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts @@ -3,6 +3,7 @@ import CampaignRoute from "@src/features/routes/CampaignRoute"; import { tryber } from "@src/features/database"; import OpenapiError from "@src/features/OpenapiError"; +import { getPresignedUrl } from "@src/features/s3/presignUrl"; type OtherCost = { cost_id: number; @@ -20,6 +21,7 @@ type OtherCost = { id: number; url: string; mimetype: string; + presigned_url: string; }[]; }; @@ -77,31 +79,38 @@ export default class OtherCostsRoute extends CampaignRoute<{ .select("id", "url", "mime_type", "cost_id") .whereIn("cost_id", costIds); - return costs.map((cost) => { - const type = types.find((t) => t.id === cost.type_id); - const supplier = suppliers.find((s) => s.id === cost.supplier_id); - const costAttachments = attachments.filter( - (a) => a.cost_id === cost.cost_id - ); - - return { - cost_id: cost.cost_id, - cost: cost.cost, - type: { - name: type?.name || "", - id: type?.id || 0, - }, - supplier: { - name: supplier?.name || "", - id: supplier?.id || 0, - }, - description: cost.description, - attachments: costAttachments.map((a) => ({ - id: a.id, - url: a.url, - mimetype: a.mime_type, - })), - }; - }); + return Promise.all( + costs.map(async (cost) => { + const type = types.find((t) => t.id === cost.type_id); + const supplier = suppliers.find((s) => s.id === cost.supplier_id); + const costAttachments = attachments.filter( + (a) => a.cost_id === cost.cost_id + ); + + const resolvedAttachments = await Promise.all( + costAttachments.map(async (a) => ({ + id: a.id, + url: a.url, + mimetype: a.mime_type, + presigned_url: await getPresignedUrl(a.url, 10800), // 3 hours expiration + })) + ); + + return { + cost_id: cost.cost_id, + cost: cost.cost, + type: { + name: type?.name || "", + id: type?.id || 0, + }, + supplier: { + name: supplier?.name || "", + id: supplier?.id || 0, + }, + description: cost.description, + attachments: resolvedAttachments, + }; + }) + ); } } From d874ec55a33040d74c360fae2a81aac394a0da82 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 18:13:02 +0100 Subject: [PATCH 5/5] feat: enhance presigned URL handling for other costs with error logging --- .../finance/otherCosts/_get/index.spec.ts | 26 +++++++++---------- .../finance/otherCosts/_get/index.ts | 23 +++++++++++----- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts index 9270882bf..586d26162 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts @@ -3,17 +3,15 @@ import app from "@src/app"; import { tryber } from "@src/features/database"; import { getPresignedUrl } from "@src/features/s3/presignUrl"; -jest.mock("@src/features/s3/presignUrl"); - -const mockedGetPresignedUrl = getPresignedUrl as jest.MockedFunction< - typeof getPresignedUrl ->; +jest.mock("@src/features/s3/presignUrl", () => { + return { + getPresignedUrl: jest + .fn() + .mockImplementation((url: string) => Promise.resolve(url)), + }; +}); describe("GET /campaigns/campaignId/finance/otherCosts", () => { - beforeEach(() => { - mockedGetPresignedUrl.mockImplementation(async (url: string) => url); - }); - afterEach(() => { jest.clearAllMocks(); }); @@ -347,21 +345,21 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { expect(costWithoutAttachments.attachments).toEqual([]); }); - it("Should call getPresignedUrl for each attachment with 3 hours expiration", async () => { + it("Should call getPresignedUrl for each attachment", async () => { await request(app) .get("/campaigns/1/finance/otherCosts") .set("Authorization", "Bearer admin"); - expect(mockedGetPresignedUrl).toHaveBeenCalledTimes(3); - expect(mockedGetPresignedUrl).toHaveBeenCalledWith( + expect(getPresignedUrl).toHaveBeenCalledTimes(3); + expect(getPresignedUrl).toHaveBeenCalledWith( "https://example.com/attachment1.pdf", 10800 ); - expect(mockedGetPresignedUrl).toHaveBeenCalledWith( + expect(getPresignedUrl).toHaveBeenCalledWith( "https://example.com/attachment2.jpg", 10800 ); - expect(mockedGetPresignedUrl).toHaveBeenCalledWith( + expect(getPresignedUrl).toHaveBeenCalledWith( "https://example.com/attachment3.png", 10800 ); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts index 37eb32440..2fddca8d8 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts @@ -88,12 +88,23 @@ export default class OtherCostsRoute extends CampaignRoute<{ ); const resolvedAttachments = await Promise.all( - costAttachments.map(async (a) => ({ - id: a.id, - url: a.url, - mimetype: a.mime_type, - presigned_url: await getPresignedUrl(a.url, 10800), // 3 hours expiration - })) + costAttachments.map(async (a) => { + let presignedUrl = a.url; + try { + presignedUrl = await getPresignedUrl(a.url, 10800); + } catch (error) { + console.error( + `Failed to generate presigned URL for ${a.url}:`, + error + ); + } + return { + id: a.id, + url: a.url, + mimetype: a.mime_type, + presigned_url: presignedUrl, + }; + }) ); return {