From 9d0109cce511224a9f6864247fa8f16f967fd536 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Fri, 23 Jan 2026 15:41:04 +0100 Subject: [PATCH 01/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 79 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index e5dc569a7..fee3628b7 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -3981,6 +3981,85 @@ paths: required: true schema: type: string + '/campaigns/{campaign}/finance/attachments': + parameters: + - schema: + type: string + name: campaign + in: path + required: true + - schema: + type: string + name: campaign + in: path + required: true + post: + summary: Your POST endpoint + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + attachments: + type: array + x-stoplight: + id: 8emd735q3eekn + items: + x-stoplight: + id: ma1yyks3wpuez + type: object + required: + - url + - name + - mime_type + properties: + url: + type: string + x-stoplight: + id: ysk2k5gez9d5h + name: + type: string + x-stoplight: + id: prluokcfrw7p9 + mime_type: + type: string + x-stoplight: + id: l7f6z5ngj01uy + '403': + $ref: '#/components/responses/NotAuthorized' + '500': + description: Internal Server Error + operationId: post-campaigns-campaign-finance-attachments + x-stoplight: + id: 0ucra88nj3skb + security: + - JWT: [] + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + attachment: + x-stoplight: + id: loo9qp15yg7er + oneOf: + - type: string + x-stoplight: + id: 1y0da4vjut50n + format: binary + - type: array + x-stoplight: + id: 567eldf6sv5mh + items: + x-stoplight: + id: ykrxebcilk657 + type: string + format: binary '/campaigns/{campaign}/forms': get: description: '' From 777fcceb6d3f1d3859e3501cb869bf7742d6d9ba Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Tue, 27 Jan 2026 15:48:45 +0100 Subject: [PATCH 02/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index fee3628b7..22e303b2a 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -4029,6 +4029,26 @@ paths: type: string x-stoplight: id: l7f6z5ngj01uy + failed: + type: array + x-stoplight: + id: 7ddxgc7abxz39 + items: + x-stoplight: + id: 5r1ivzdsjci00 + type: object + required: + - name + - path + properties: + name: + type: string + x-stoplight: + id: cwjl6g75vg2lr + path: + type: string + x-stoplight: + id: s3fuamuxbq1wn '403': $ref: '#/components/responses/NotAuthorized' '500': From 11e9c13d9a8a5307c0a786a4316ccfe7ad234af5 Mon Sep 17 00:00:00 2001 From: ZecD Date: Tue, 27 Jan 2026 16:48:05 +0100 Subject: [PATCH 03/78] feat: add POST /campaigns/{campaign}/finance/attachments route and update OpenAPI schema --- src/reference/openapi.yml | 20 +-- .../finance/attachment/_post/index.spec.ts | 98 +++++++++++++ .../finance/attachment/_post/index.ts | 132 ++++++++++++++++++ src/schema.ts | 43 ++++++ 4 files changed, 275 insertions(+), 18 deletions(-) create mode 100644 src/routes/campaigns/campaignId/finance/attachment/_post/index.spec.ts create mode 100644 src/routes/campaigns/campaignId/finance/attachment/_post/index.ts diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 22e303b2a..1ea5c9b11 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -4006,11 +4006,7 @@ paths: properties: attachments: type: array - x-stoplight: - id: 8emd735q3eekn items: - x-stoplight: - id: ma1yyks3wpuez type: object required: - url @@ -4019,23 +4015,15 @@ paths: properties: url: type: string - x-stoplight: - id: ysk2k5gez9d5h + name: type: string - x-stoplight: - id: prluokcfrw7p9 mime_type: type: string - x-stoplight: - id: l7f6z5ngj01uy failed: type: array - x-stoplight: - id: 7ddxgc7abxz39 + items: - x-stoplight: - id: 5r1ivzdsjci00 type: object required: - name @@ -4043,12 +4031,8 @@ paths: properties: name: type: string - x-stoplight: - id: cwjl6g75vg2lr path: type: string - x-stoplight: - id: s3fuamuxbq1wn '403': $ref: '#/components/responses/NotAuthorized' '500': diff --git a/src/routes/campaigns/campaignId/finance/attachment/_post/index.spec.ts b/src/routes/campaigns/campaignId/finance/attachment/_post/index.spec.ts new file mode 100644 index 000000000..0d3a10b36 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/attachment/_post/index.spec.ts @@ -0,0 +1,98 @@ +import app from "@src/app"; +import { tryber } from "@src/features/database"; +import upload from "@src/features/upload"; +import request from "supertest"; + +jest.mock("@src/features/upload"); + +const profile = { + id: 1, + wp_user_id: 1, + email: "tester@example.com", + employment_id: 1, + education_id: 1, +}; +const wpUser = { + ID: 1, + user_login: "tester", + user_email: "tester@example.com", + user_pass: "pass", +}; +const campaign = { + id: 1, + title: "Test Campaign", + customer_title: "Test Campaign", + start_date: "2020-01-01", + end_date: "2020-01-01", + pm_id: 1, + page_manual_id: 0, + page_preview_id: 0, + platform_id: 1, + customer_id: 1, + project_id: 1, +}; + +const campaign2 = { + ...campaign, + id: 2, +}; + +describe("Route POST /campaigns/{campaignId}/finance/attachments", () => { + beforeAll(async () => { + (upload as jest.Mock).mockImplementation( + ({ key, bucket }: { bucket: string; key: string }) => { + return `https://s3.amazonaws.com/${bucket}/${key}`; + } + ); + await tryber.tables.WpUsers.do().insert(wpUser); + await tryber.tables.WpAppqEvdProfile.do().insert(profile); + await tryber.tables.WpAppqEvdCampaign.do().insert([campaign, campaign2]); + }); + + afterAll(async () => { + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + }); + + it("Should answer 403 if not logged in", async () => { + const response = await request(app).post( + "/campaigns/1/finance/attachments" + ); + expect(response.status).toBe(403); + }); + + it("Should answer 200 and mark as failed if try to send file as .bat, .sh and .exe", async () => { + const mockFileBuffer = Buffer.from("some data"); + + const response = await request(app) + .post("/campaigns/1/finance/attachments") + .attach("media", mockFileBuffer, "void.bat") + .attach("media", mockFileBuffer, "image.png") + .attach("media", mockFileBuffer, "void.sh") + .attach("media", mockFileBuffer, "void.exe") + .set("authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("failed", [ + { path: "INVALID_FILE_EXTENSION", name: "void.bat" }, + { path: "INVALID_FILE_EXTENSION", name: "void.sh" }, + { path: "INVALID_FILE_EXTENSION", name: "void.exe" }, + ]); + }); + + it("Should answer 200 and mark as failed if try to send an oversized file", async () => { + process.env.MAX_FILE_SIZE = "100"; + const mockFileBuffer = Buffer.alloc(101); + + const response = await request(app) + .post("/campaigns/1/finance/attachments") + .attach("media", mockFileBuffer, "oversized.png") + .set("authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("failed", [ + { path: "FILE_TOO_BIG", name: "oversized.png" }, + ]); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/attachment/_post/index.ts b/src/routes/campaigns/campaignId/finance/attachment/_post/index.ts new file mode 100644 index 000000000..08586a79a --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/attachment/_post/index.ts @@ -0,0 +1,132 @@ +/** OPENAPI-CLASS: post-campaigns-campaign-finance-attachments */ +import upload from "@src/features/upload"; +import path from "path"; +import busboyMapper from "@src/features/busboyMapper"; +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import OpenapiError from "@src/features/OpenapiError"; +import debugMessage from "@src/features/debugMessage"; + +interface InvalidMedia { + name: string; + path: string; +} + +interface UploadSuccess { + files: { name: string; mime_type: string; path: string }[]; + failed: InvalidMedia[]; +} + +interface UploadError { + element: string; + id: number; + message: string; +} + +export default class SingleCampaignRoute extends CampaignRoute<{ + response: StoplightOperations["post-campaigns-campaign-finance-attachments"]["responses"]["200"]["content"]["application/json"]; + body: StoplightOperations["post-campaigns-campaign-finance-attachments"]["requestBody"]["content"]["multipart/form-data"]; + parameters: StoplightOperations["post-campaigns-campaign-finance-attachments"]["parameters"]["path"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + return false; + } + return true; + } + + protected async prepare(): Promise { + try { + const result = await this.uploadAttachmentFiles(); + + if ("message" in result) { + throw new OpenapiError(result.message); + } + + return this.setSuccess(200, { + attachments: result.files.map((file) => ({ + url: file.path, + name: file.name, + mime_type: file.mime_type, + })), + failed: result.failed, + }); + } catch (err) { + debugMessage(err); + this.setError( + (err as OpenapiError).status_code || 500, + err as OpenapiError + ); + } + } + + protected getKey({ + filename, + extension, + }: { + filename: string; + extension: string; + }): string { + return `${ + process.env.FINANCE_ATTACHMENTS_FOLDER || "finance-attachments" + }/CP${this.cp_id}/${filename}_${new Date().getTime()}${extension}`; + } + + protected isAcceptableFile(file: { name: string }): boolean { + return ![".bat", ".sh", ".exe"].includes( + path.extname(file.name).toLowerCase() + ); + } + + private async uploadAttachmentFiles(): Promise { + try { + const { valid, invalid } = await busboyMapper( + this.configuration.request, + (file) => { + if (!this.isAcceptableFile(file)) { + return "INVALID_FILE_EXTENSION"; + } + return false; + } + ); + + return { + files: await this.uploadFiles(valid), + failed: invalid.map((fail) => ({ + name: fail.name, + path: fail.errorCode, + })), + }; + } catch (err) { + return { + element: "media-upload", + id: 0, + message: err instanceof Error ? err.message : "Unknown error", + }; + } + } + + private async uploadFiles( + files: Media[] + ): Promise<{ name: string; mime_type: string; path: string }[]> { + let uploadedFiles = []; + for (const media of files) { + const uploadedPath = await upload({ + bucket: process.env.MEDIA_BUCKET || "", + key: this.getKey({ + filename: path.basename(media.name, path.extname(media.name)), + extension: path.extname(media.name), + }), + file: media, + }); + + uploadedFiles.push({ + name: media.name, + mime_type: media.mimeType, + path: uploadedPath.toString(), + }); + } + return uploadedFiles; + } +} diff --git a/src/schema.ts b/src/schema.ts index 05530f136..9f5413273 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -146,6 +146,14 @@ export interface paths { }; }; }; + "/campaigns/{campaign}/finance/attachments": { + post: operations["post-campaigns-campaign-finance-attachments"]; + parameters: { + path: { + campaign: string; + }; + }; + }; "/campaigns/{campaign}/forms": { get: operations["get-campaigns-campaign-forms"]; parameters: { @@ -2113,6 +2121,41 @@ export interface operations { 404: components["responses"]["NotFound"]; }; }; + "post-campaigns-campaign-finance-attachments": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + attachments?: { + url: string; + name: string; + mime_type: string; + }[]; + failed?: { + name: string; + path: string; + }[]; + }; + }; + }; + 403: components["responses"]["NotAuthorized"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "multipart/form-data": { + attachment?: string | string[]; + }; + }; + }; + }; "get-campaigns-campaign-forms": { parameters: { path: { From 39443dab1dfd673d2859746db9aef4ff9f21f58f Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Wed, 28 Jan 2026 16:25:46 +0100 Subject: [PATCH 04/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index e5dc569a7..a785d2206 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13199,6 +13199,48 @@ paths: - jotformId - testerQuestionId description: '' + '/campaigns/{campaign}/finance/supplier': + parameters: + - schema: + type: string + name: campaign + in: path + required: true + post: + summary: POST a new supplier + tags: [] + responses: + '201': + description: Created + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + operationId: post-campaigns-campaign-finance-supplier + x-stoplight: + id: j17dlfvjwluu7 + description: Create a new campaign supplier + requestBody: + content: + application/json: + schema: + type: object + required: + - name + - created_by + properties: + name: + type: string + x-stoplight: + id: bzvnmo26hda42 + created_by: + type: integer + x-stoplight: + id: 5ri3rzkppb17t + security: + - JWT: [] servers: - url: 'https://api.app-quality.com' tags: From c2577d2de6262c029658aba3da96574b2d771b89 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Wed, 28 Jan 2026 16:57:23 +0100 Subject: [PATCH 05/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index a785d2206..c3361481a 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13229,16 +13229,11 @@ paths: type: object required: - name - - created_by properties: name: type: string x-stoplight: id: bzvnmo26hda42 - created_by: - type: integer - x-stoplight: - id: 5ri3rzkppb17t security: - JWT: [] servers: From c075223c6c51e07593637ee02736dae748e9b213 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Wed, 28 Jan 2026 16:58:42 +0100 Subject: [PATCH 06/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index c3361481a..99ab1bb63 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13234,6 +13234,10 @@ paths: type: string x-stoplight: id: bzvnmo26hda42 + examples: + Example 1: + value: + name: Respondent security: - JWT: [] servers: From ee47019428829c1a4a5570cd8e6de5f9100f410e Mon Sep 17 00:00:00 2001 From: Kariamos Date: Wed, 28 Jan 2026 17:03:24 +0100 Subject: [PATCH 07/78] feat: add endpoint to create a new campaign supplier --- src/schema.ts | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/schema.ts b/src/schema.ts index 05530f136..c62e4cee8 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -796,6 +796,15 @@ export interface paths { }; }; }; + "/campaigns/{campaign}/finance/supplier": { + /** Create a new campaign supplier */ + post: operations["post-campaigns-campaign-finance-supplier"]; + parameters: { + path: { + campaign: string; + }; + }; + }; } export interface components { @@ -809,6 +818,7 @@ export interface components { }; Agreement: { expirationDate: string; + /** @default false */ isTokenBased?: boolean; note?: string; startDate: string; @@ -888,6 +898,7 @@ export interface components { applied?: boolean; /** @description If bugform is deactivated is a boolean else contains URLs to bugforms for each languages */ bugform_link?: boolean | components["schemas"]["TranslatablePage"]; + /** @default 0 */ csm_effort?: number; customerCanViewReviewing?: boolean; customer_title?: string; @@ -910,7 +921,9 @@ export interface components { public?: boolean; status?: boolean; titleRule?: boolean; + /** @default 0 */ tokens?: number; + /** @default 0 */ ux_effort?: number; visibility?: { freeSpots?: number; @@ -2984,15 +2997,20 @@ export interface operations { }; } & { autoApply?: number; + /** @default 0 */ autoApprove?: number; bugLanguage?: components["schemas"]["BugLang"]; hasBugForm?: number; hasBugParade?: number; /** @enum {string} */ pageVersion?: "v1" | "v2"; + /** @default 0 */ skipPagesAndTasks?: number; } & { - /** @enum {undefined} */ + /** + * @default 0 + * @enum {undefined} + */ notify_everyone?: 0 | 1; /** @example 1 */ ux_notify?: number; @@ -3013,6 +3031,7 @@ export interface operations { content: { "application/json": { autoApply: number; + /** @default 0 */ autoApprove: number; browsers?: { id: number; @@ -3035,6 +3054,7 @@ export interface operations { name: string; }[]; deviceRequirements?: string; + /** @default false */ hasPlan?: boolean; /** Format: date-time */ endDate: string; @@ -3121,6 +3141,7 @@ export interface operations { content: { "application/json": components["schemas"]["DossierCreationData"] & { autoApply?: number; + /** @default 0 */ autoApprove?: number; bugLanguage?: components["schemas"]["BugLang"] | boolean; hasBugParade?: number; @@ -5458,6 +5479,29 @@ export interface operations { }; }; }; + /** Create a new campaign supplier */ + "post-campaigns-campaign-finance-supplier": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** Created */ + 201: unknown; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + name: string; + }; + }; + }; + }; } export interface external {} From 09288fd7abafe907f0e52a660aafe5df03b02596 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Wed, 28 Jan 2026 17:03:39 +0100 Subject: [PATCH 08/78] feat: implement POST route for campaign finance supplier with validation --- .../finance/supplier/_post/index.spec.ts | 144 ++++++++++++++++++ .../finance/supplier/_post/index.ts | 59 +++++++ 2 files changed, 203 insertions(+) create mode 100644 src/routes/campaigns/campaignId/finance/supplier/_post/index.spec.ts create mode 100644 src/routes/campaigns/campaignId/finance/supplier/_post/index.ts diff --git a/src/routes/campaigns/campaignId/finance/supplier/_post/index.spec.ts b/src/routes/campaigns/campaignId/finance/supplier/_post/index.spec.ts new file mode 100644 index 000000000..0fabdaaf6 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/supplier/_post/index.spec.ts @@ -0,0 +1,144 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; + +describe("POST /campaigns/campaignId/finance/supplier", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + { + id: 2, + name: "John", + surname: "Doe", + wp_user_id: 2, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert([{ ID: 1 }, { ID: 2 }]); + await tryber.tables.WpAppqEvdCampaign.do().insert({ + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }); + }); + + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + ]); + }); + + afterEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + }); + + afterAll(async () => { + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + describe("Not enough permissions", () => { + it("Should return 403 if logged out", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }); + expect(response.status).toBe(403); + }); + + it("Should return 403 if logged in as not admin user", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }) + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if no access to the campaign", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }) + .set("Authorization", 'Bearer tester"'); + expect(response.status).toBe(403); + }); + }); + + describe("Enough permissions - admin", () => { + it("Should return 200 if logged in as admin", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + }); + + it("Should add new finance supplier", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + }); + it("Should not add existing supplier", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "Supplier 1" }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + }); + + it("Should not add supplier with empty name", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: " " }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Supplier name should not be empty", + }) + ); + }); + }); + + describe("Enough permissions - olp", () => { + it("Should add supplier ", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(201); + }); + + it("Should not add existing supplier", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "Supplier 1" }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(400); + }); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/supplier/_post/index.ts b/src/routes/campaigns/campaignId/finance/supplier/_post/index.ts new file mode 100644 index 000000000..031568347 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/supplier/_post/index.ts @@ -0,0 +1,59 @@ +/** OPENAPI-CLASS: post-campaigns-campaign-finance-supplier */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; + +export default class SupplierRoute extends CampaignRoute<{ + response: StoplightOperations["post-campaigns-campaign-finance-supplier"]["responses"]["201"]; + parameters: StoplightOperations["post-campaigns-campaign-finance-supplier"]["parameters"]["path"]; + body: StoplightOperations["post-campaigns-campaign-finance-supplier"]["requestBody"]["content"]["application/json"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + + return false; + } + + if (this.getBody().name.trim() === "") { + this.setError(400, new OpenapiError("Supplier name should not be empty")); + return false; + } + + return true; + } + + protected async prepare(): Promise { + if (await this.checkSupplierExists(this.getBody().name)) { + return this.setError(400, new OpenapiError("Supplier already exists")); + } + + try { + await this.createNewSupplier(this.getBody().name); + return this.setSuccess(201, {}); + } catch (e) { + console.error("Error creating new supplier: ", e); + return this.setError( + 500, + new OpenapiError("Error creating new supplier") + ); + } + } + + private async createNewSupplier(name: string): Promise { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert({ + name, + created_by: this.getTesterId(), + }); + } + + private async checkSupplierExists(name: string): Promise { + const supplier = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ name }) + .first(); + return supplier !== undefined; + } +} From 7e105f7be2445cd07d909d61d1b08a03ebe46a97 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 11:52:36 +0100 Subject: [PATCH 09/78] fix: update @appquality/tryber-database version to 0.46.17 in package.json and package-lock.json --- package-lock.json | 74 ++++++++++++++++++++++++++++++++--------------- package.json | 2 +- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index f31d9f1d1..d6091ad94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@appquality/tryber-database": "^0.46.11", + "@appquality/tryber-database": "^0.46.17", "@appquality/wp-auth": "^1.0.7", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/hash-node": "^3.374.0", @@ -108,9 +108,9 @@ } }, "node_modules/@appquality/tryber-database": { - "version": "0.46.11", - "resolved": "https://registry.npmjs.org/@appquality/tryber-database/-/tryber-database-0.46.11.tgz", - "integrity": "sha512-mec9oRm+ojlVoVqQqHZfVRbJIPSmZs163oWYsRXH+Ab7XESeWqlAySjg4hpVl0WZXr5wnH+AgJ2vt4d2LFhzpw==", + "version": "0.46.17", + "resolved": "https://registry.npmjs.org/@appquality/tryber-database/-/tryber-database-0.46.17.tgz", + "integrity": "sha512-pipi0ypxbkSqkGD69yEx/6M8yYkd3uCevS0Gw7tc97MieSgsGU75X/CIBF7Adr04xbk1tPMLIS7uZ9Q+nJ10vQ==", "license": "ISC", "dependencies": { "better-sqlite3": "^12.5.0", @@ -468,7 +468,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1004,6 +1003,7 @@ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1028,6 +1028,7 @@ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -1082,6 +1083,7 @@ "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -1097,6 +1099,7 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=12.22" }, @@ -1111,7 +1114,8 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -2841,7 +2845,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -3385,7 +3388,6 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3584,7 +3586,6 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -3745,7 +3746,8 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/abbrev": { "version": "3.0.1", @@ -3776,7 +3778,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3790,6 +3791,7 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -4447,7 +4449,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.14.7" } @@ -4834,7 +4835,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5658,7 +5658,8 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/deepmerge": { "version": "4.3.1", @@ -5810,6 +5811,7 @@ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -6044,6 +6046,7 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6142,6 +6145,7 @@ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -6159,6 +6163,7 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -6178,6 +6183,7 @@ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -6221,6 +6227,7 @@ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -6234,6 +6241,7 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -6277,6 +6285,7 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6671,7 +6680,8 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -6724,6 +6734,7 @@ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flat-cache": "^3.0.4" }, @@ -6807,6 +6818,7 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -6824,6 +6836,7 @@ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -6838,7 +6851,8 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/floating-point-regex": { "version": "0.1.0", @@ -7194,6 +7208,7 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -7235,6 +7250,7 @@ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -7596,6 +7612,7 @@ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -7899,6 +7916,7 @@ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -8111,7 +8129,6 @@ "integrity": "sha512-N4GT5on8UkZgH0O5LUavMRV1EDEhNTL0KEfRmDIeZHSV7p2XgLoY9t9VDUgL6o+yfdgYHVxuz81G8oB9VG5uyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^28.1.3", "@jest/types": "^28.1.3", @@ -9209,7 +9226,8 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -9229,7 +9247,8 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json5": { "version": "2.2.3", @@ -9309,6 +9328,7 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -9446,6 +9466,7 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -9485,6 +9506,7 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -10763,6 +10785,7 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -10797,6 +10820,7 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -10855,6 +10879,7 @@ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -11194,6 +11219,7 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8.0" } @@ -11617,6 +11643,7 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -12816,7 +12843,8 @@ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/through2": { "version": "2.0.5", @@ -12899,7 +12927,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13091,7 +13118,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13697,6 +13723,7 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -13720,6 +13747,7 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -13746,7 +13774,6 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14111,6 +14138,7 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index 8618d0745..1203c771a 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "author": "", "license": "ISC", "dependencies": { - "@appquality/tryber-database": "^0.46.11", + "@appquality/tryber-database": "^0.46.17", "@appquality/wp-auth": "^1.0.7", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/hash-node": "^3.374.0", From e3163d560baa7f3ec7d9bdf2395d44df33e1513d Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Thu, 29 Jan 2026 12:12:14 +0100 Subject: [PATCH 10/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index e5dc569a7..358ff9ac0 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13199,6 +13199,56 @@ paths: - jotformId - testerQuestionId description: '' + '/campaigns/{campaign}/finance/type': + parameters: + - schema: + type: string + name: campaign + in: path + required: true + get: + summary: GET types + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + type: array + x-stoplight: + id: d7f0e5l5qrx6o + items: + x-stoplight: + id: r1p9rcymebjpu + type: object + properties: + name: + type: string + x-stoplight: + id: y0keeoutvijjh + examples: + Example 1: + value: + items: + - name: Recruiting + - name: Survey + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Shared Response + operationId: get-campaigns-campaign-finance-type + x-stoplight: + id: 02e8ns5xdhecm + security: + - JWT: [] servers: - url: 'https://api.app-quality.com' tags: From d08d1231115c501c3dba9bb15bcfd2e87e38d469 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 12:14:44 +0100 Subject: [PATCH 11/78] fix: update @appquality/tryber-database dependency to version 0.46.17 --- package-lock.json | 74 ++++++++++++++++++++++++++++++++--------------- package.json | 2 +- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index f31d9f1d1..d6091ad94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@appquality/tryber-database": "^0.46.11", + "@appquality/tryber-database": "^0.46.17", "@appquality/wp-auth": "^1.0.7", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/hash-node": "^3.374.0", @@ -108,9 +108,9 @@ } }, "node_modules/@appquality/tryber-database": { - "version": "0.46.11", - "resolved": "https://registry.npmjs.org/@appquality/tryber-database/-/tryber-database-0.46.11.tgz", - "integrity": "sha512-mec9oRm+ojlVoVqQqHZfVRbJIPSmZs163oWYsRXH+Ab7XESeWqlAySjg4hpVl0WZXr5wnH+AgJ2vt4d2LFhzpw==", + "version": "0.46.17", + "resolved": "https://registry.npmjs.org/@appquality/tryber-database/-/tryber-database-0.46.17.tgz", + "integrity": "sha512-pipi0ypxbkSqkGD69yEx/6M8yYkd3uCevS0Gw7tc97MieSgsGU75X/CIBF7Adr04xbk1tPMLIS7uZ9Q+nJ10vQ==", "license": "ISC", "dependencies": { "better-sqlite3": "^12.5.0", @@ -468,7 +468,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1004,6 +1003,7 @@ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1028,6 +1028,7 @@ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -1082,6 +1083,7 @@ "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -1097,6 +1099,7 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=12.22" }, @@ -1111,7 +1114,8 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -2841,7 +2845,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -3385,7 +3388,6 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3584,7 +3586,6 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -3745,7 +3746,8 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/abbrev": { "version": "3.0.1", @@ -3776,7 +3778,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3790,6 +3791,7 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -4447,7 +4449,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.14.7" } @@ -4834,7 +4835,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5658,7 +5658,8 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/deepmerge": { "version": "4.3.1", @@ -5810,6 +5811,7 @@ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -6044,6 +6046,7 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6142,6 +6145,7 @@ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -6159,6 +6163,7 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -6178,6 +6183,7 @@ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -6221,6 +6227,7 @@ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -6234,6 +6241,7 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -6277,6 +6285,7 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6671,7 +6680,8 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -6724,6 +6734,7 @@ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flat-cache": "^3.0.4" }, @@ -6807,6 +6818,7 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -6824,6 +6836,7 @@ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -6838,7 +6851,8 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/floating-point-regex": { "version": "0.1.0", @@ -7194,6 +7208,7 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -7235,6 +7250,7 @@ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -7596,6 +7612,7 @@ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -7899,6 +7916,7 @@ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -8111,7 +8129,6 @@ "integrity": "sha512-N4GT5on8UkZgH0O5LUavMRV1EDEhNTL0KEfRmDIeZHSV7p2XgLoY9t9VDUgL6o+yfdgYHVxuz81G8oB9VG5uyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^28.1.3", "@jest/types": "^28.1.3", @@ -9209,7 +9226,8 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -9229,7 +9247,8 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json5": { "version": "2.2.3", @@ -9309,6 +9328,7 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -9446,6 +9466,7 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -9485,6 +9506,7 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -10763,6 +10785,7 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -10797,6 +10820,7 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -10855,6 +10879,7 @@ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -11194,6 +11219,7 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8.0" } @@ -11617,6 +11643,7 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -12816,7 +12843,8 @@ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/through2": { "version": "2.0.5", @@ -12899,7 +12927,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13091,7 +13118,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13697,6 +13723,7 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -13720,6 +13747,7 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -13746,7 +13774,6 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14111,6 +14138,7 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index 8618d0745..1203c771a 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "author": "", "license": "ISC", "dependencies": { - "@appquality/tryber-database": "^0.46.11", + "@appquality/tryber-database": "^0.46.17", "@appquality/wp-auth": "^1.0.7", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/hash-node": "^3.374.0", From f84104460e949d7048e333df533d0a8f9d7efdf7 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 12:15:05 +0100 Subject: [PATCH 12/78] feat: add finance type endpoint for campaigns --- src/schema.ts | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/schema.ts b/src/schema.ts index 05530f136..ebc110b71 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -796,6 +796,14 @@ export interface paths { }; }; }; + "/campaigns/{campaign}/finance/type": { + get: operations["get-campaigns-campaign-finance-type"]; + parameters: { + path: { + campaign: string; + }; + }; + }; } export interface components { @@ -809,6 +817,7 @@ export interface components { }; Agreement: { expirationDate: string; + /** @default false */ isTokenBased?: boolean; note?: string; startDate: string; @@ -888,6 +897,7 @@ export interface components { applied?: boolean; /** @description If bugform is deactivated is a boolean else contains URLs to bugforms for each languages */ bugform_link?: boolean | components["schemas"]["TranslatablePage"]; + /** @default 0 */ csm_effort?: number; customerCanViewReviewing?: boolean; customer_title?: string; @@ -910,7 +920,9 @@ export interface components { public?: boolean; status?: boolean; titleRule?: boolean; + /** @default 0 */ tokens?: number; + /** @default 0 */ ux_effort?: number; visibility?: { freeSpots?: number; @@ -2984,15 +2996,20 @@ export interface operations { }; } & { autoApply?: number; + /** @default 0 */ autoApprove?: number; bugLanguage?: components["schemas"]["BugLang"]; hasBugForm?: number; hasBugParade?: number; /** @enum {string} */ pageVersion?: "v1" | "v2"; + /** @default 0 */ skipPagesAndTasks?: number; } & { - /** @enum {undefined} */ + /** + * @default 0 + * @enum {undefined} + */ notify_everyone?: 0 | 1; /** @example 1 */ ux_notify?: number; @@ -3013,6 +3030,7 @@ export interface operations { content: { "application/json": { autoApply: number; + /** @default 0 */ autoApprove: number; browsers?: { id: number; @@ -3035,6 +3053,7 @@ export interface operations { name: string; }[]; deviceRequirements?: string; + /** @default false */ hasPlan?: boolean; /** Format: date-time */ endDate: string; @@ -3121,6 +3140,7 @@ export interface operations { content: { "application/json": components["schemas"]["DossierCreationData"] & { autoApply?: number; + /** @default 0 */ autoApprove?: number; bugLanguage?: components["schemas"]["BugLang"] | boolean; hasBugParade?: number; @@ -5458,6 +5478,29 @@ export interface operations { }; }; }; + "get-campaigns-campaign-finance-type": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + items: { + name?: string; + }[]; + }; + }; + }; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Shared Response */ + 500: unknown; + }; + }; } export interface external {} From 3b762a792901195f3c58880359249eaa3066b2fc Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 12:44:38 +0100 Subject: [PATCH 13/78] feat: implement finance type retrieval for campaigns --- .../finance/type/_get/index.spec.ts | 115 ++++++++++++++++++ .../campaignId/finance/type/_get/index.ts | 31 +++++ 2 files changed, 146 insertions(+) create mode 100644 src/routes/campaigns/campaignId/finance/type/_get/index.spec.ts create mode 100644 src/routes/campaigns/campaignId/finance/type/_get/index.ts diff --git a/src/routes/campaigns/campaignId/finance/type/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/type/_get/index.spec.ts new file mode 100644 index 000000000..3c9580e41 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/type/_get/index.spec.ts @@ -0,0 +1,115 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; + +describe("GET /campaigns/campaignId/finance/type", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert({ ID: 1 }); + await tryber.tables.WpAppqEvdCampaign.do().insert({ + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }); + await tryber.tables.WpAppqCampaignOtherCostsType.do().insert([ + { + id: 1, + name: "Type 1", + }, + { + id: 2, + name: "Type 2", + }, + ]); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsType.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + it("Should return 403 if logged out", async () => { + const response = await request(app).get("/campaigns/1/finance/type"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if logged in as not admin user", async () => { + const response = await request(app) + .get("/campaigns/1/finance/type") + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if no access to the campaign", async () => { + const response = await request(app) + .get("/campaigns/1/finance/type") + .set("Authorization", 'Bearer tester"'); + expect(response.status).toBe(403); + }); + + it("Should return 200 if logged in as admin", async () => { + const response = await request(app) + .get("/campaigns/1/finance/type") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should return finance types - admin", async () => { + const response = await request(app) + .get("/campaigns/1/finance/type") + .set("Authorization", "Bearer admin"); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + name: "Type 1", + }), + expect.objectContaining({ + name: "Type 2", + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); + + it("Should return types - olp permissions", async () => { + const response = await request(app) + .get("/campaigns/1/finance/type") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + name: "Type 1", + }), + expect.objectContaining({ + name: "Type 2", + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/type/_get/index.ts b/src/routes/campaigns/campaignId/finance/type/_get/index.ts new file mode 100644 index 000000000..484995ac4 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/type/_get/index.ts @@ -0,0 +1,31 @@ +/** OPENAPI-CLASS: get-campaigns-campaign-finance-type */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; + +export default class TypeRoute extends CampaignRoute<{ + response: StoplightOperations["get-campaigns-campaign-finance-type"]["responses"]["200"]["content"]["application/json"]; + parameters: StoplightOperations["get-campaigns-campaign-finance-type"]["parameters"]["path"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + + return false; + } + return true; + } + + protected async prepare(): Promise { + const types = await this.getTypes(); + + return this.setSuccess(200, { items: types }); + } + + private async getTypes() { + return await tryber.tables.WpAppqCampaignOtherCostsType.do().select("name"); + } +} From 40752e0bc7b7058d7622aba997a01d4ceb9c2bcf Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Wed, 28 Jan 2026 10:12:29 +0100 Subject: [PATCH 14/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 105 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 568641bb7..10471d637 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -3981,6 +3981,65 @@ paths: required: true schema: type: string + '/campaigns/{campaign}/finance/supplier': + get: + description: Get all suppliers for finance + operationId: get-campaigns-cid-finance-supplier + parameters: [] + responses: + '200': + content: + application/json: + examples: + Example 2: + value: + id: 1 + name: Respondent + created_on: '2026-01-01' + created_by: 32 + schema: + type: object + required: + - id + - name + properties: + id: + type: number + x-stoplight: + id: ctt8sbti2xlsa + name: + type: string + x-stoplight: + id: p42d6s7ngpjtz + created_on: + type: string + x-stoplight: + id: cx6fszbvodyk6 + created_by: + type: integer + x-stoplight: + id: 9avtyf2lbwksr + description: OK + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + security: + - JWT: [] + summary: Get all suppliers + tags: + - Campaign + x-stoplight: + id: hx8pmv3nzamq0 + parameters: + - description: A campaign id + in: path + name: campaign + required: true + schema: + type: string '/campaigns/{campaign}/finance/attachments': parameters: - schema: @@ -13282,6 +13341,52 @@ paths: - jotformId - testerQuestionId description: '' + '/campaigns/{campaign}/finance/suppliers': + get: + description: Get all finance suppliers + operationId: get-campaigns-cid-finance-supplier + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: number + x-stoplight: + id: hhr19m5dfxjtt + name: + type: string + x-stoplight: + id: tyq39dn4ard6q + created_at: + type: string + x-stoplight: + id: fdxd59qz1usvz + created_by: + type: integer + x-stoplight: + id: dkqscj2zlqbq1 + examples: + Example 1: + value: + id: 1 + name: Respondent + created_at: '2026-01-01' + created_by: 10 + '400': + description: Bad Request + '403': + description: Forbidden + '500': + description: Internal Server Error + security: + - JWT: [] '/campaigns/{campaign}/finance/type': parameters: - schema: From 67ef756420b75fba24e98e028efdb266eae388e2 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Wed, 28 Jan 2026 10:41:30 +0100 Subject: [PATCH 15/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 57 ++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 10471d637..ffd97d8bb 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13344,7 +13344,7 @@ paths: '/campaigns/{campaign}/finance/suppliers': get: description: Get all finance suppliers - operationId: get-campaigns-cid-finance-supplier + operationId: get-campaigns-campaign-finance-supplier responses: '200': description: OK @@ -13353,32 +13353,44 @@ paths: schema: type: object required: - - id - - name + - items properties: - id: - type: number + items: + type: array x-stoplight: id: hhr19m5dfxjtt - name: - type: string - x-stoplight: - id: tyq39dn4ard6q - created_at: - type: string - x-stoplight: - id: fdxd59qz1usvz - created_by: - type: integer - x-stoplight: - id: dkqscj2zlqbq1 + items: + x-stoplight: + id: ihcfe7bywmrjs + type: object + required: + - name + - id + properties: + name: + type: string + x-stoplight: + id: 08yzm5eu8tl1h + created_at: + type: string + x-stoplight: + id: w0fmflgc090ff + created_by: + type: integer + x-stoplight: + id: gcs2p8mvl32gc + id: + type: integer + x-stoplight: + id: 3lwwkv3h1vsun examples: - Example 1: + Example 2: value: - id: 1 - name: Respondent - created_at: '2026-01-01' - created_by: 10 + items: + - id: 1 + name: Respondent + created_at: '2026-01-01' + created_by: 10 '400': description: Bad Request '403': @@ -13387,6 +13399,7 @@ paths: description: Internal Server Error security: - JWT: [] + summary: Get finance supplier '/campaigns/{campaign}/finance/type': parameters: - schema: From adbc425d1fe1a8710a7d4654d5d8f963ceefec34 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Wed, 28 Jan 2026 10:46:07 +0100 Subject: [PATCH 16/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index ffd97d8bb..b51e74dd3 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13400,6 +13400,12 @@ paths: security: - JWT: [] summary: Get finance supplier + parameters: + - schema: + type: string + name: campaign + in: path + required: true '/campaigns/{campaign}/finance/type': parameters: - schema: From 933e544d7c65df61a726d350b18ef25a4f7792c5 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Wed, 28 Jan 2026 11:15:39 +0100 Subject: [PATCH 17/78] feat: add finance suppliers endpoints for campaigns --- src/schema.ts | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/schema.ts b/src/schema.ts index e65ad0843..87d90c684 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -154,6 +154,16 @@ export interface paths { }; }; }; + "/campaigns/{campaign}/finance/supplier": { + /** Get all suppliers for finance */ + get: operations["get-campaigns-cid-finance-supplier"]; + parameters: { + path: { + /** A campaign id */ + campaign: string; + }; + }; + }; "/campaigns/{campaign}/forms": { get: operations["get-campaigns-campaign-forms"]; parameters: { @@ -812,6 +822,15 @@ export interface paths { }; }; }; + "/campaigns/{campaign}/finance/suppliers": { + /** Get all finance suppliers */ + get: operations["get-campaigns-campaign-finance-supplier"]; + parameters: { + path: { + campaign: string; + }; + }; + }; } export interface components { @@ -2168,6 +2187,32 @@ export interface operations { }; }; }; + /** Get all suppliers for finance */ + "get-campaigns-cid-finance-supplier": { + parameters: { + path: { + /** A campaign id */ + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + id: number; + name: string; + created_on?: string; + created_by?: number; + }; + }; + }; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + }; "get-campaigns-campaign-forms": { parameters: { path: { @@ -5544,6 +5589,35 @@ export interface operations { 500: unknown; }; }; + /** Get all finance suppliers */ + "get-campaigns-campaign-finance-supplier": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + items: { + name: string; + created_at?: string; + created_by?: number; + id: number; + }[]; + }; + }; + }; + /** Bad Request */ + 400: unknown; + /** Forbidden */ + 403: unknown; + /** Internal Server Error */ + 500: unknown; + }; + }; } export interface external {} From 2e1205715b5980eb1b5186177a5ea171b8890e7a Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Wed, 28 Jan 2026 11:41:31 +0100 Subject: [PATCH 18/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 86 +++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index b51e74dd3..dd35be6db 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -3981,51 +3981,65 @@ paths: required: true schema: type: string - '/campaigns/{campaign}/finance/supplier': - get: - description: Get all suppliers for finance - operationId: get-campaigns-cid-finance-supplier - parameters: [] + '/campaigns/{campaign}/finance/attachments': + parameters: + - schema: + type: string + name: campaign + in: path + required: true + - schema: + type: string + name: campaign + in: path + required: true + post: + summary: Your POST endpoint + tags: [] responses: '200': + description: OK content: application/json: - examples: - Example 2: - value: - id: 1 - name: Respondent - created_on: '2026-01-01' - created_by: 32 schema: type: object - required: - - id - - name properties: - id: - type: number - x-stoplight: - id: ctt8sbti2xlsa - name: - type: string - x-stoplight: - id: p42d6s7ngpjtz - created_on: - type: string - x-stoplight: - id: cx6fszbvodyk6 - created_by: - type: integer - x-stoplight: - id: 9avtyf2lbwksr - description: OK + attachments: + type: array + items: + type: object + required: + - url + - name + - mime_type + properties: + url: + type: string + + name: + type: string + mime_type: + type: string + failed: + type: array + + items: + type: object + required: + - name + - path + properties: + name: + type: string + path: + type: string '403': $ref: '#/components/responses/NotAuthorized' - '404': - $ref: '#/components/responses/NotFound' '500': description: Internal Server Error + operationId: post-campaigns-campaign-finance-attachments + x-stoplight: + id: 0ucra88nj3skb security: - JWT: [] summary: Get all suppliers @@ -13341,7 +13355,7 @@ paths: - jotformId - testerQuestionId description: '' - '/campaigns/{campaign}/finance/suppliers': + '/campaigns/{campaign}/finance/supplier': get: description: Get all finance suppliers operationId: get-campaigns-campaign-finance-supplier @@ -13400,6 +13414,8 @@ paths: security: - JWT: [] summary: Get finance supplier + x-stoplight: + id: tlziygldrfder parameters: - schema: type: string From d1f9a38148156a5a8470a7a6e98b064eeb571e4e Mon Sep 17 00:00:00 2001 From: Kariamos Date: Wed, 28 Jan 2026 11:45:36 +0100 Subject: [PATCH 19/78] refactor: update finance suppliers endpoint and remove duplicated operation --- src/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schema.ts b/src/schema.ts index 87d90c684..a6df456fd 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -822,7 +822,7 @@ export interface paths { }; }; }; - "/campaigns/{campaign}/finance/suppliers": { + "/campaigns/{campaign}/finance/supplier": { /** Get all finance suppliers */ get: operations["get-campaigns-campaign-finance-supplier"]; parameters: { From c9de03090150941fae72ec296c0c22f8ddfb03ec Mon Sep 17 00:00:00 2001 From: Kariamos Date: Wed, 28 Jan 2026 12:02:35 +0100 Subject: [PATCH 20/78] feat: implement finance suppliers endpoint for campaigns --- .../finance/supplier/_get/index.spec.ts | 133 ++++++++++++++++++ .../campaignId/finance/supplier/_get/index.ts | 45 ++++++ 2 files changed, 178 insertions(+) create mode 100644 src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts create mode 100644 src/routes/campaigns/campaignId/finance/supplier/_get/index.ts diff --git a/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts new file mode 100644 index 000000000..da5e4c384 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts @@ -0,0 +1,133 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; + +describe("GET /campaigns/campaignId/finance/supplier", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + { + id: 2, + name: "John", + surname: "Doe", + wp_user_id: 2, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert([{ ID: 1 }, { ID: 2 }]); + await tryber.tables.WpAppqEvdCampaign.do().insert({ + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + { + id: 2, + name: "Supplier 2", + created_by: 2, + created_on: "2024-01-02 11:00:00", + }, + ]); + }); + + it("Should return 403 if logged out", async () => { + const response = await request(app).get("/campaigns/1/finance/supplier"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if logged in as not admin user", async () => { + const response = await request(app) + .get("/campaigns/1/finance/supplier") + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if no access to the campaign", async () => { + const response = await request(app) + .get("/campaigns/1/finance/supplier") + .set("Authorization", 'Bearer tester"'); + expect(response.status).toBe(403); + }); + + it("Should return 200 if logged in as admin", async () => { + const response = await request(app) + .get("/campaigns/1/finance/supplier") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should return finance suppliers", async () => { + const response = await request(app) + .get("/campaigns/1/finance/supplier") + .set("Authorization", "Bearer admin"); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }), + expect.objectContaining({ + id: 2, + name: "Supplier 2", + created_by: 2, + created_on: "2024-01-02 11:00:00", + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); + + it("Should return suppliers with olp permissions", async () => { + const response = await request(app) + .get("/campaigns/1/finance/supplier") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }), + expect.objectContaining({ + id: 2, + name: "Supplier 2", + created_by: 2, + created_on: "2024-01-02 11:00:00", + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts b/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts new file mode 100644 index 000000000..af65a8118 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts @@ -0,0 +1,45 @@ +/** OPENAPI-CLASS: get-campaigns-campaign-finance-supplier */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; + +type Supplier = { + id: number; + name: string; + created_on?: string; + created_by?: number; +}; + +export default class SupplierRoute extends CampaignRoute<{ + response: StoplightOperations["get-campaigns-campaign-finance-supplier"]["responses"]["200"]["content"]["application/json"]; + parameters: StoplightOperations["get-campaigns-campaign-finance-supplier"]["parameters"]["path"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + + return false; + } + return true; + } + + protected async prepare(): Promise { + const suppliers = await this.getSuppliers(); + + return this.setSuccess(200, { items: suppliers }); + } + + private async getSuppliers(): Promise { + const results = + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().select( + "id", + "name", + "created_on", + "created_by" + ); + return results; + } +} From 9dc4013eaedc43f0b438aa2fd521fefaa24c0487 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 09:53:37 +0100 Subject: [PATCH 21/78] refactor: enhance tests for finance suppliers endpoint and improve cleanup logic --- .../campaignId/finance/supplier/_get/index.spec.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts index da5e4c384..c353f0704 100644 --- a/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts @@ -1,6 +1,7 @@ import request from "supertest"; import app from "@src/app"; import { tryber } from "@src/features/database"; +import { after } from "node:test"; describe("GET /campaigns/campaignId/finance/supplier", () => { beforeAll(async () => { @@ -54,6 +55,13 @@ describe("GET /campaigns/campaignId/finance/supplier", () => { ]); }); + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + it("Should return 403 if logged out", async () => { const response = await request(app).get("/campaigns/1/finance/supplier"); expect(response.status).toBe(403); @@ -80,7 +88,7 @@ describe("GET /campaigns/campaignId/finance/supplier", () => { expect(response.status).toBe(200); }); - it("Should return finance suppliers", async () => { + it("Should return finance suppliers - admin", async () => { const response = await request(app) .get("/campaigns/1/finance/supplier") .set("Authorization", "Bearer admin"); @@ -105,7 +113,7 @@ describe("GET /campaigns/campaignId/finance/supplier", () => { expect(response.body.items).toHaveLength(2); }); - it("Should return suppliers with olp permissions", async () => { + it("Should return suppliers - olp permissions", async () => { const response = await request(app) .get("/campaigns/1/finance/supplier") .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); From fab376b89214e739a2738676f4b6317461ea1b71 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Thu, 29 Jan 2026 13:07:40 +0100 Subject: [PATCH 22/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index dd35be6db..a7f60fe18 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13379,7 +13379,6 @@ paths: type: object required: - name - - id properties: name: type: string @@ -13393,16 +13392,11 @@ paths: type: integer x-stoplight: id: gcs2p8mvl32gc - id: - type: integer - x-stoplight: - id: 3lwwkv3h1vsun examples: Example 2: value: items: - - id: 1 - name: Respondent + - name: Respondent created_at: '2026-01-01' created_by: 10 '400': From d5d4c8149f5d399e12bcefed2c21bd00d8ba1283 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 13:09:58 +0100 Subject: [PATCH 23/78] refactor: remove redundant 'id' field from operations interface --- src/schema.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/schema.ts b/src/schema.ts index a6df456fd..c9c6870f7 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5605,7 +5605,6 @@ export interface operations { name: string; created_at?: string; created_by?: number; - id: number; }[]; }; }; From 8780256b14c73e8d34aea283c71ed7457499eda4 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 13:10:08 +0100 Subject: [PATCH 24/78] refactor: remove 'id' field from Supplier type and related queries --- .../campaignId/finance/supplier/_get/index.spec.ts | 5 ----- .../campaignId/finance/supplier/_get/index.ts | 14 +++++--------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts index c353f0704..ee1bf1af0 100644 --- a/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts @@ -1,7 +1,6 @@ import request from "supertest"; import app from "@src/app"; import { tryber } from "@src/features/database"; -import { after } from "node:test"; describe("GET /campaigns/campaignId/finance/supplier", () => { beforeAll(async () => { @@ -96,13 +95,11 @@ describe("GET /campaigns/campaignId/finance/supplier", () => { expect.objectContaining({ items: expect.arrayContaining([ expect.objectContaining({ - id: 1, name: "Supplier 1", created_by: 1, created_on: "2024-01-01 10:00:00", }), expect.objectContaining({ - id: 2, name: "Supplier 2", created_by: 2, created_on: "2024-01-02 11:00:00", @@ -122,13 +119,11 @@ describe("GET /campaigns/campaignId/finance/supplier", () => { expect.objectContaining({ items: expect.arrayContaining([ expect.objectContaining({ - id: 1, name: "Supplier 1", created_by: 1, created_on: "2024-01-01 10:00:00", }), expect.objectContaining({ - id: 2, name: "Supplier 2", created_by: 2, created_on: "2024-01-02 11:00:00", diff --git a/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts b/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts index af65a8118..807584851 100644 --- a/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts +++ b/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts @@ -5,7 +5,6 @@ import { tryber } from "@src/features/database"; import OpenapiError from "@src/features/OpenapiError"; type Supplier = { - id: number; name: string; created_on?: string; created_by?: number; @@ -33,13 +32,10 @@ export default class SupplierRoute extends CampaignRoute<{ } private async getSuppliers(): Promise { - const results = - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().select( - "id", - "name", - "created_on", - "created_by" - ); - return results; + return await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().select( + "name", + "created_on", + "created_by" + ); } } From 963432e78037a752b030a480e6911c7ac16572e2 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 17:06:54 +0100 Subject: [PATCH 25/78] refactor: remove duplicated finance suppliers endpoint from campaigns path --- src/schema.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index c9c6870f7..68523450b 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -154,16 +154,6 @@ export interface paths { }; }; }; - "/campaigns/{campaign}/finance/supplier": { - /** Get all suppliers for finance */ - get: operations["get-campaigns-cid-finance-supplier"]; - parameters: { - path: { - /** A campaign id */ - campaign: string; - }; - }; - }; "/campaigns/{campaign}/forms": { get: operations["get-campaigns-campaign-forms"]; parameters: { From 23cf049be0496e44c2b33de9e6bc45da3111309a Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 17:22:53 +0100 Subject: [PATCH 26/78] refactor: update finance supplier endpoint and adjust related operations --- src/reference/openapi.yml | 78 --------------------------------------- src/schema.ts | 66 ++++++++++----------------------- 2 files changed, 20 insertions(+), 124 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index a7f60fe18..0593f1d08 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -3988,84 +3988,6 @@ paths: name: campaign in: path required: true - - schema: - type: string - name: campaign - in: path - required: true - post: - summary: Your POST endpoint - tags: [] - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - attachments: - type: array - items: - type: object - required: - - url - - name - - mime_type - properties: - url: - type: string - - name: - type: string - mime_type: - type: string - failed: - type: array - - items: - type: object - required: - - name - - path - properties: - name: - type: string - path: - type: string - '403': - $ref: '#/components/responses/NotAuthorized' - '500': - description: Internal Server Error - operationId: post-campaigns-campaign-finance-attachments - x-stoplight: - id: 0ucra88nj3skb - security: - - JWT: [] - summary: Get all suppliers - tags: - - Campaign - x-stoplight: - id: hx8pmv3nzamq0 - parameters: - - description: A campaign id - in: path - name: campaign - required: true - schema: - type: string - '/campaigns/{campaign}/finance/attachments': - parameters: - - schema: - type: string - name: campaign - in: path - required: true - - schema: - type: string - name: campaign - in: path - required: true post: summary: Your POST endpoint tags: [] diff --git a/src/schema.ts b/src/schema.ts index 68523450b..f7bd799a7 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -804,17 +804,17 @@ export interface paths { }; }; }; - "/campaigns/{campaign}/finance/type": { - get: operations["get-campaigns-campaign-finance-type"]; + "/campaigns/{campaign}/finance/supplier": { + /** Get all finance suppliers */ + get: operations["get-campaigns-campaign-finance-supplier"]; parameters: { path: { campaign: string; }; }; }; - "/campaigns/{campaign}/finance/supplier": { - /** Get all finance suppliers */ - get: operations["get-campaigns-campaign-finance-supplier"]; + "/campaigns/{campaign}/finance/type": { + get: operations["get-campaigns-campaign-finance-type"]; parameters: { path: { campaign: string; @@ -2177,32 +2177,6 @@ export interface operations { }; }; }; - /** Get all suppliers for finance */ - "get-campaigns-cid-finance-supplier": { - parameters: { - path: { - /** A campaign id */ - campaign: string; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "application/json": { - id: number; - name: string; - created_on?: string; - created_by?: number; - }; - }; - }; - 403: components["responses"]["NotAuthorized"]; - 404: components["responses"]["NotFound"]; - /** Internal Server Error */ - 500: unknown; - }; - }; "get-campaigns-campaign-forms": { parameters: { path: { @@ -5556,7 +5530,8 @@ export interface operations { }; }; }; - "get-campaigns-campaign-finance-type": { + /** Get all finance suppliers */ + "get-campaigns-campaign-finance-supplier": { parameters: { path: { campaign: string; @@ -5568,19 +5543,22 @@ export interface operations { content: { "application/json": { items: { - name?: string; + name: string; + created_at?: string; + created_by?: number; }[]; }; }; }; - 403: components["responses"]["NotAuthorized"]; - 404: components["responses"]["NotFound"]; - /** Shared Response */ + /** Bad Request */ + 400: unknown; + /** Forbidden */ + 403: unknown; + /** Internal Server Error */ 500: unknown; }; }; - /** Get all finance suppliers */ - "get-campaigns-campaign-finance-supplier": { + "get-campaigns-campaign-finance-type": { parameters: { path: { campaign: string; @@ -5592,18 +5570,14 @@ export interface operations { content: { "application/json": { items: { - name: string; - created_at?: string; - created_by?: number; + name?: string; }[]; }; }; }; - /** Bad Request */ - 400: unknown; - /** Forbidden */ - 403: unknown; - /** Internal Server Error */ + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Shared Response */ 500: unknown; }; }; From 1d1bb3f02cf6a57efada50751db15062ed432cb4 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 17:37:52 +0100 Subject: [PATCH 27/78] refactor: update campaign finance supplier endpoint to remove unnecessary request body and adjust response handling --- src/reference/openapi.yml | 21 --------------------- src/schema.ts | 30 ++++++++++++------------------ 2 files changed, 12 insertions(+), 39 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 04fb00e09..27ce7c04c 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13389,27 +13389,6 @@ paths: $ref: '#/components/responses/NotFound' '500': description: Internal Server Error - operationId: post-campaigns-campaign-finance-supplier - x-stoplight: - id: j17dlfvjwluu7 - description: Create a new campaign supplier - requestBody: - content: - application/json: - schema: - type: object - required: - - name - properties: - name: - type: string - x-stoplight: - id: bzvnmo26hda42 - examples: - Example 1: - value: - name: Respondent - description: Shared Response operationId: get-campaigns-campaign-finance-type x-stoplight: id: 02e8ns5xdhecm diff --git a/src/schema.ts b/src/schema.ts index 87a035c36..2a3bcf830 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -805,10 +805,19 @@ export interface paths { }; }; "/campaigns/{campaign}/finance/supplier": { - /** Create a new campaign supplier */ - post: operations["post-campaigns-campaign-finance-supplier"]; /** Get all finance suppliers */ get: operations["get-campaigns-campaign-finance-supplier"]; + post: { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** Created */ + 201: unknown; + }; + }; parameters: { path: { campaign: string; @@ -5532,8 +5541,6 @@ export interface operations { }; }; }; - /** Create a new campaign supplier */ - "post-campaigns-campaign-finance-supplier": { /** Get all finance suppliers */ "get-campaigns-campaign-finance-supplier": { parameters: { @@ -5542,19 +5549,6 @@ export interface operations { }; }; responses: { - /** Created */ - 201: unknown; - 403: components["responses"]["NotAuthorized"]; - 404: components["responses"]["NotFound"]; - /** Internal Server Error */ - 500: unknown; - }; - requestBody: { - content: { - "application/json": { - name: string; - }; - }; /** OK */ 200: { content: { @@ -5594,7 +5588,7 @@ export interface operations { }; 403: components["responses"]["NotAuthorized"]; 404: components["responses"]["NotFound"]; - /** Shared Response */ + /** Internal Server Error */ 500: unknown; }; }; From 422ea7d99bb175fc532f119d686dcc8fc873f653 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 17:41:44 +0100 Subject: [PATCH 28/78] refactor: enhance post campaign finance supplier endpoint with detailed request and response schemas --- src/reference/openapi.yml | 37 +++++++++++++++++++++++++++++++++- src/schema.ts | 42 +++++++++++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 27ce7c04c..f7a4bca71 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13340,10 +13340,45 @@ paths: required: true post: summary: POST a new supplier - tags: [] + operationId: post-campaigns-campaign-finance-supplier + tags: + - Campaign responses: '201': description: Created + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: integer + '400': + description: Bad Request + '403': + description: Forbidden + '500': + description: Internal Server Error + security: + - JWT: [] + requestBody: + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + examples: + Example 1: + value: + name: Respondent + x-stoplight: + id: j17dlfvjwluu7 '/campaigns/{campaign}/finance/type': parameters: - schema: diff --git a/src/schema.ts b/src/schema.ts index 2a3bcf830..45315b605 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -807,17 +807,7 @@ export interface paths { "/campaigns/{campaign}/finance/supplier": { /** Get all finance suppliers */ get: operations["get-campaigns-campaign-finance-supplier"]; - post: { - parameters: { - path: { - campaign: string; - }; - }; - responses: { - /** Created */ - 201: unknown; - }; - }; + post: operations["post-campaigns-campaign-finance-supplier"]; parameters: { path: { campaign: string; @@ -5569,6 +5559,36 @@ export interface operations { 500: unknown; }; }; + "post-campaigns-campaign-finance-supplier": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** Created */ + 201: { + content: { + "application/json": { + id: number; + }; + }; + }; + /** Bad Request */ + 400: unknown; + /** Forbidden */ + 403: unknown; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + name: string; + }; + }; + }; + }; "get-campaigns-campaign-finance-type": { parameters: { path: { From 67ec301a5afea4b6004ff5ecdcd9606527c9e2dc Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Thu, 29 Jan 2026 17:49:30 +0100 Subject: [PATCH 29/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index f7a4bca71..26703c487 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -4010,14 +4010,12 @@ paths: properties: url: type: string - name: type: string mime_type: type: string failed: type: array - items: type: object required: @@ -13346,15 +13344,7 @@ paths: responses: '201': description: Created - content: - application/json: - schema: - type: object - required: - - id - properties: - id: - type: integer + content: {} '400': description: Bad Request '403': From a08afd83a6dec283ad94ad02f654f1b442025a29 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 17:50:36 +0100 Subject: [PATCH 30/78] refactor: simplify response schema for post campaign operation --- src/schema.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 45315b605..e7ab71112 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5567,13 +5567,7 @@ export interface operations { }; responses: { /** Created */ - 201: { - content: { - "application/json": { - id: number; - }; - }; - }; + 201: unknown; /** Bad Request */ 400: unknown; /** Forbidden */ From cbf0f1e55b39302364f1f8c738d5a356dba58ed0 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 29 Jan 2026 17:51:00 +0100 Subject: [PATCH 31/78] fix: correct error handling message for supplier creation From 9e899057165e11f67ca87b4aab908337da973386 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Fri, 30 Jan 2026 17:43:39 +0100 Subject: [PATCH 32/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 26703c487..cfcaf99f0 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13419,6 +13419,20 @@ paths: id: 02e8ns5xdhecm security: - JWT: [] + '/campaigns/{campaign}/finance/otherCosts': + parameters: + - schema: + type: string + name: campaign + in: path + required: true + get: + summary: Your GET endpoint + tags: [] + responses: {} + operationId: get-campaigns-campaign-finance-otherCosts + x-stoplight: + id: 9hp8r67rwl59d servers: - url: 'https://api.app-quality.com' tags: From 5be9a8541eb95a59a13b51440e6f754917f594c6 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Fri, 30 Jan 2026 17:45:44 +0100 Subject: [PATCH 33/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index cfcaf99f0..016556737 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13429,10 +13429,38 @@ paths: get: summary: Your GET endpoint tags: [] - responses: {} + responses: + '200': + description: OK + '403': + description: Forbidden + '404': + description: Not Found + '500': + description: Internal Server Error + content: + application/json: + schema: + type: object + properties: + items: + type: array + x-stoplight: + id: 7k2qykvqlzwfe + items: + x-stoplight: + id: srl106ylr6cpm + type: object + properties: + type: + type: object + x-stoplight: + id: itchthltx575n operationId: get-campaigns-campaign-finance-otherCosts x-stoplight: id: 9hp8r67rwl59d + security: + - JWT: [] servers: - url: 'https://api.app-quality.com' tags: From cd53dfa21589a4de4f05fa13303dc12679036c7a Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Fri, 30 Jan 2026 17:50:26 +0100 Subject: [PATCH 34/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 016556737..e894d3a24 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13452,10 +13452,77 @@ paths: id: srl106ylr6cpm type: object properties: + cost_id: + type: number + x-stoplight: + id: 5tq4npphsv3wx type: type: object x-stoplight: id: itchthltx575n + properties: + name: + type: string + x-stoplight: + id: mkzkmmdtonn2u + id: + type: number + x-stoplight: + id: ng1a3g0fc695g + supplier: + type: object + x-stoplight: + id: pewi2gjqq2j1h + properties: + name: + type: string + x-stoplight: + id: eel1e2x9tsc4x + id: + type: number + x-stoplight: + id: ppawzunmvk3qx + description: + type: string + x-stoplight: + id: jlosijekgy2c6 + attachments: + type: array + x-stoplight: + id: jj9p1k4imekme + items: + x-stoplight: + id: w31uej26rl532 + type: object + properties: + id: + type: number + x-stoplight: + id: bz8ydut5na834 + url: + type: string + x-stoplight: + id: nctzb7saomq7c + mimetype: + type: string + x-stoplight: + id: 6tqj2cg96280v + examples: + Example 1: + value: + items: + - type: + name: string + id: 0 + supplier: + name: string + id: 0 + description: string + attachments: + - id: 0 + url: string + mimetype: string + cost_id: 0 operationId: get-campaigns-campaign-finance-otherCosts x-stoplight: id: 9hp8r67rwl59d From 4b30d9e4d4e7d4e05a8bd739b3712044a012556d Mon Sep 17 00:00:00 2001 From: Kariamos Date: Fri, 30 Jan 2026 18:11:23 +0100 Subject: [PATCH 35/78] feat: add endpoint for retrieving other costs associated with a campaign --- src/schema.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/schema.ts b/src/schema.ts index e7ab71112..cdbfa2c57 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -822,6 +822,14 @@ export interface paths { }; }; }; + "/campaigns/{campaign}/finance/otherCosts": { + get: operations["get-campaigns-campaign-finance-otherCosts"]; + parameters: { + path: { + campaign: string; + }; + }; + }; } export interface components { @@ -5606,6 +5614,45 @@ export interface operations { 500: unknown; }; }; + "get-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Forbidden */ + 403: unknown; + /** Not Found */ + 404: unknown; + /** Internal Server Error */ + 500: { + content: { + "application/json": { + items?: { + cost_id?: number; + type?: { + name?: string; + id?: number; + }; + supplier?: { + name?: string; + id?: number; + }; + description?: string; + attachments?: { + id?: number; + url?: string; + mimetype?: string; + }[]; + }[]; + }; + }; + }; + }; + }; } export interface external {} From 14bba46a90f944f7b8997c64abb447ed95c792c2 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Fri, 30 Jan 2026 18:15:56 +0100 Subject: [PATCH 36/78] fix: reorder response status codes for get campaigns campaign finance other costs endpoint --- src/reference/openapi.yml | 13 +++++++------ src/schema.ts | 14 +++++++------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index e894d3a24..15e200366 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13432,12 +13432,6 @@ paths: responses: '200': description: OK - '403': - description: Forbidden - '404': - description: Not Found - '500': - description: Internal Server Error content: application/json: schema: @@ -13523,6 +13517,13 @@ paths: url: string mimetype: string cost_id: 0 + '403': + description: Forbidden + '404': + description: Not Found + '500': + description: Internal Server Error + operationId: get-campaigns-campaign-finance-otherCosts x-stoplight: id: 9hp8r67rwl59d diff --git a/src/schema.ts b/src/schema.ts index cdbfa2c57..8fca4a1ec 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5622,13 +5622,7 @@ export interface operations { }; responses: { /** OK */ - 200: unknown; - /** Forbidden */ - 403: unknown; - /** Not Found */ - 404: unknown; - /** Internal Server Error */ - 500: { + 200: { content: { "application/json": { items?: { @@ -5651,6 +5645,12 @@ export interface operations { }; }; }; + /** Forbidden */ + 403: unknown; + /** Not Found */ + 404: unknown; + /** Internal Server Error */ + 500: unknown; }; }; } From 45cdf6ccc12a09280c785522e13fea5886c285d2 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Fri, 30 Jan 2026 18:16:22 +0100 Subject: [PATCH 37/78] wip get --- .../finance/otherCosts/_get/index.spec.ts | 305 ++++++++++++++++++ .../finance/otherCosts/_get/index.ts | 104 ++++++ 2 files changed, 409 insertions(+) create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts new file mode 100644 index 000000000..65705ae42 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts @@ -0,0 +1,305 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; + +describe("GET /campaigns/campaignId/finance/otherCosts", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert({ ID: 1 }); + await tryber.tables.WpAppqEvdCampaign.do().insert([ + { + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + { + id: 2, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "Another campaign", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsType.do().insert([ + { + id: 1, + name: "Type 1", + }, + { + id: 2, + name: "Type 2", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + { + id: 2, + name: "Supplier 2", + created_by: 1, + created_on: "2024-01-02 11:00:00", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost 1 description", + cost: 100.5, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost 2 description", + cost: 200.75, + type_id: 2, + supplier_id: 2, + }, + { + id: 3, + campaign_id: 2, + description: "Cost for other campaign", + cost: 150.0, + type_id: 1, + supplier_id: 1, + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://example.com/attachment1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://example.com/attachment2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 2, + url: "https://example.com/attachment3.png", + mime_type: "image/png", + }, + ]); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); + await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + await tryber.tables.WpAppqCampaignOtherCostsType.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + it("Should return 403 if logged out", async () => { + const response = await request(app).get("/campaigns/1/finance/otherCosts"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if logged in as not admin user", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if no access to the campaign", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester"'); + expect(response.status).toBe(403); + }); + + it("Should return 200 if logged in as admin", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should return finance other costs - admin", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + cost_id: 1, + type: { + name: "Type 1", + id: 1, + }, + supplier: { + name: "Supplier 1", + id: 1, + }, + description: "Cost 1 description", + attachments: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + url: "https://example.com/attachment1.pdf", + mimetype: "application/pdf", + }), + expect.objectContaining({ + id: 2, + url: "https://example.com/attachment2.jpg", + mimetype: "image/jpeg", + }), + ]), + }), + expect.objectContaining({ + cost_id: 2, + type: { + name: "Type 2", + id: 2, + }, + supplier: { + name: "Supplier 2", + id: 2, + }, + description: "Cost 2 description", + attachments: expect.arrayContaining([ + expect.objectContaining({ + id: 3, + url: "https://example.com/attachment3.png", + mimetype: "image/png", + }), + ]), + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); + + it("Should return other costs - olp permissions", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + cost_id: 1, + type: { + name: "Type 1", + id: 1, + }, + supplier: { + name: "Supplier 1", + id: 1, + }, + description: "Cost 1 description", + }), + expect.objectContaining({ + cost_id: 2, + type: { + name: "Type 2", + id: 2, + }, + supplier: { + name: "Supplier 2", + id: 2, + }, + description: "Cost 2 description", + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); + + it("Should return empty items array if no costs exist for campaign", async () => { + await tryber.tables.WpAppqEvdCampaign.do().insert({ + id: 99, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "Campaign with no costs", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }); + + const response = await request(app) + .get("/campaigns/99/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + items: [], + }); + }); + + it("Should not include costs from other campaigns", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(response.body.items).toHaveLength(2); + expect( + response.body.items.find((item: any) => item.cost_id === 3) + ).toBeUndefined(); + }); + + it("Should return cost with empty attachments array if cost has no attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 10, + campaign_id: 1, + description: "Cost without attachments", + cost: 50.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const costWithoutAttachments = response.body.items.find( + (item: any) => item.cost_id === 10 + ); + expect(costWithoutAttachments).toBeDefined(); + expect(costWithoutAttachments.attachments).toEqual([]); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts new file mode 100644 index 000000000..e3d48bd92 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts @@ -0,0 +1,104 @@ +/** OPENAPI-CLASS: get-campaigns-campaign-finance-otherCosts */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; + +type OtherCost = { + cost_id: number; + type: { + name: string; + id: number; + }; + supplier: { + name: string; + id: number; + }; + description: string; + attachments: { + id: number; + url: string; + mimetype: string; + }[]; +}; + +export default class OtherCostsRoute extends CampaignRoute<{ + response: StoplightOperations["get-campaigns-campaign-finance-otherCosts"]["responses"]["200"]["content"]["application/json"]; + parameters: StoplightOperations["get-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + + return false; + } + return true; + } + + protected async prepare(): Promise { + const costs = await this.getOtherCosts(); + + return this.setSuccess(200, { items: costs }); + } + + private async getOtherCosts(): Promise { + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .select( + tryber + .ref("id") + .withSchema("wp_appq_campaign_other_costs") + .as("cost_id"), + "description", + "type_id", + "supplier_id" + ) + .where("campaign_id", this.cp_id); + + if (!costs.length) return []; + + const typeIds = [...new Set(costs.map((c) => c.type_id))]; + const supplierIds = [...new Set(costs.map((c) => c.supplier_id))]; + const costIds = costs.map((c) => c.cost_id); + + const types = await tryber.tables.WpAppqCampaignOtherCostsType.do() + .select("id", "name") + .whereIn("id", typeIds); + + const suppliers = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .select("id", "name") + .whereIn("id", supplierIds); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .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, + 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, + })), + }; + }); + } +} From f9b769ade3fa88f76e3fb285bf4ca3543f358f36 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Fri, 30 Jan 2026 18:40:46 +0100 Subject: [PATCH 38/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 15e200366..46dd74eb5 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13299,6 +13299,7 @@ paths: type: object required: - name + - id properties: name: type: string @@ -13312,11 +13313,16 @@ paths: type: integer x-stoplight: id: gcs2p8mvl32gc + id: + type: number + x-stoplight: + id: ek7zfipegepg5 examples: Example 2: value: items: - - name: Respondent + - id: 1 + name: Respondent created_at: '2026-01-01' created_by: 10 '400': @@ -13344,7 +13350,17 @@ paths: responses: '201': description: Created - content: {} + content: + application/json: + schema: + type: object + required: + - supplier_id + properties: + supplier_id: + type: number + x-stoplight: + id: rsrwvlerw0w2j '400': description: Bad Request '403': @@ -13523,7 +13539,6 @@ paths: description: Not Found '500': description: Internal Server Error - operationId: get-campaigns-campaign-finance-otherCosts x-stoplight: id: 9hp8r67rwl59d From ec8857db494c07efdca8e7fc67772e4923bdc97e Mon Sep 17 00:00:00 2001 From: Kariamos Date: Fri, 30 Jan 2026 18:52:18 +0100 Subject: [PATCH 39/78] fix: add missing commas in SQL query and attachment filter From 41583a7403d87df35b79e8d94a8726ebd2e3a9e9 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Fri, 30 Jan 2026 18:52:52 +0100 Subject: [PATCH 40/78] feat: enhance supplier endpoints to include supplier ID in responses and update schema --- .../finance/supplier/_get/index.spec.ts | 4 ++++ .../campaignId/finance/supplier/_get/index.ts | 2 ++ .../finance/supplier/_post/index.spec.ts | 2 ++ .../finance/supplier/_post/index.ts | 24 ++++++++++++------- src/schema.ts | 9 ++++++- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts index ee1bf1af0..ab4f40ee6 100644 --- a/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts @@ -95,11 +95,13 @@ describe("GET /campaigns/campaignId/finance/supplier", () => { expect.objectContaining({ items: expect.arrayContaining([ expect.objectContaining({ + id: 1, name: "Supplier 1", created_by: 1, created_on: "2024-01-01 10:00:00", }), expect.objectContaining({ + id: 2, name: "Supplier 2", created_by: 2, created_on: "2024-01-02 11:00:00", @@ -119,11 +121,13 @@ describe("GET /campaigns/campaignId/finance/supplier", () => { expect.objectContaining({ items: expect.arrayContaining([ expect.objectContaining({ + id: 1, name: "Supplier 1", created_by: 1, created_on: "2024-01-01 10:00:00", }), expect.objectContaining({ + id: 2, name: "Supplier 2", created_by: 2, created_on: "2024-01-02 11:00:00", diff --git a/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts b/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts index 807584851..da33fa92a 100644 --- a/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts +++ b/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts @@ -5,6 +5,7 @@ import { tryber } from "@src/features/database"; import OpenapiError from "@src/features/OpenapiError"; type Supplier = { + id: number; name: string; created_on?: string; created_by?: number; @@ -33,6 +34,7 @@ export default class SupplierRoute extends CampaignRoute<{ private async getSuppliers(): Promise { return await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().select( + "id", "name", "created_on", "created_by" diff --git a/src/routes/campaigns/campaignId/finance/supplier/_post/index.spec.ts b/src/routes/campaigns/campaignId/finance/supplier/_post/index.spec.ts index 0fabdaaf6..649f9ffca 100644 --- a/src/routes/campaigns/campaignId/finance/supplier/_post/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/supplier/_post/index.spec.ts @@ -101,6 +101,7 @@ describe("POST /campaigns/campaignId/finance/supplier", () => { .send({ name: "New Supplier" }) .set("Authorization", "Bearer admin"); expect(response.status).toBe(201); + expect(response.body).toEqual({ supplier_id: expect.any(Number) }); }); it("Should not add existing supplier", async () => { const response = await request(app) @@ -131,6 +132,7 @@ describe("POST /campaigns/campaignId/finance/supplier", () => { .send({ name: "New Supplier" }) .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); expect(response.status).toBe(201); + expect(response.body).toEqual({ supplier_id: expect.any(Number) }); }); it("Should not add existing supplier", async () => { diff --git a/src/routes/campaigns/campaignId/finance/supplier/_post/index.ts b/src/routes/campaigns/campaignId/finance/supplier/_post/index.ts index 031568347..a6fa15ef7 100644 --- a/src/routes/campaigns/campaignId/finance/supplier/_post/index.ts +++ b/src/routes/campaigns/campaignId/finance/supplier/_post/index.ts @@ -5,7 +5,7 @@ import { tryber } from "@src/features/database"; import OpenapiError from "@src/features/OpenapiError"; export default class SupplierRoute extends CampaignRoute<{ - response: StoplightOperations["post-campaigns-campaign-finance-supplier"]["responses"]["201"]; + response: StoplightOperations["post-campaigns-campaign-finance-supplier"]["responses"]["201"]["content"]["application/json"]; parameters: StoplightOperations["post-campaigns-campaign-finance-supplier"]["parameters"]["path"]; body: StoplightOperations["post-campaigns-campaign-finance-supplier"]["requestBody"]["content"]["application/json"]; }> { @@ -32,8 +32,8 @@ export default class SupplierRoute extends CampaignRoute<{ } try { - await this.createNewSupplier(this.getBody().name); - return this.setSuccess(201, {}); + const supplierId = await this.createNewSupplier(this.getBody().name); + return this.setSuccess(201, { supplier_id: supplierId }); } catch (e) { console.error("Error creating new supplier: ", e); return this.setError( @@ -43,11 +43,19 @@ export default class SupplierRoute extends CampaignRoute<{ } } - private async createNewSupplier(name: string): Promise { - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert({ - name, - created_by: this.getTesterId(), - }); + private async createNewSupplier(name: string): Promise { + const result = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .insert({ + name, + created_by: this.getTesterId(), + }) + .returning("id"); + + const id = result[0]?.id ?? result[0]; + + if (!id) throw new Error("Error creating supplier"); + + return id; } private async checkSupplierExists(name: string): Promise { diff --git a/src/schema.ts b/src/schema.ts index 8fca4a1ec..7fd1eec9b 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5555,6 +5555,7 @@ export interface operations { name: string; created_at?: string; created_by?: number; + id: number; }[]; }; }; @@ -5575,7 +5576,13 @@ export interface operations { }; responses: { /** Created */ - 201: unknown; + 201: { + content: { + "application/json": { + supplier_id: number; + }; + }; + }; /** Bad Request */ 400: unknown; /** Forbidden */ From 894e27e387a7356127f4f710a560c3028a2d7617 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Fri, 30 Jan 2026 18:54:28 +0100 Subject: [PATCH 41/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 46dd74eb5..bfd704803 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13413,17 +13413,26 @@ paths: x-stoplight: id: r1p9rcymebjpu type: object + required: + - name + - id properties: name: type: string x-stoplight: id: y0keeoutvijjh + id: + type: number + x-stoplight: + id: r546xns1ecc4j examples: Example 1: value: items: - name: Recruiting + id: 1 - name: Survey + id: 2 '403': $ref: '#/components/responses/NotAuthorized' '404': From 3c26801c30e1863bdeabf785cb85d751eb392f8b Mon Sep 17 00:00:00 2001 From: Kariamos Date: Fri, 30 Jan 2026 18:57:15 +0100 Subject: [PATCH 42/78] feat: include ID in finance type responses and update schema --- .../campaigns/campaignId/finance/type/_get/index.spec.ts | 4 ++++ src/routes/campaigns/campaignId/finance/type/_get/index.ts | 5 ++++- src/schema.ts | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/type/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/type/_get/index.spec.ts index 3c9580e41..300f8d481 100644 --- a/src/routes/campaigns/campaignId/finance/type/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/type/_get/index.spec.ts @@ -82,9 +82,11 @@ describe("GET /campaigns/campaignId/finance/type", () => { expect.objectContaining({ items: expect.arrayContaining([ expect.objectContaining({ + id: 1, name: "Type 1", }), expect.objectContaining({ + id: 2, name: "Type 2", }), ]), @@ -102,9 +104,11 @@ describe("GET /campaigns/campaignId/finance/type", () => { expect.objectContaining({ items: expect.arrayContaining([ expect.objectContaining({ + id: 1, name: "Type 1", }), expect.objectContaining({ + id: 2, name: "Type 2", }), ]), diff --git a/src/routes/campaigns/campaignId/finance/type/_get/index.ts b/src/routes/campaigns/campaignId/finance/type/_get/index.ts index 484995ac4..3b8f5e66d 100644 --- a/src/routes/campaigns/campaignId/finance/type/_get/index.ts +++ b/src/routes/campaigns/campaignId/finance/type/_get/index.ts @@ -26,6 +26,9 @@ export default class TypeRoute extends CampaignRoute<{ } private async getTypes() { - return await tryber.tables.WpAppqCampaignOtherCostsType.do().select("name"); + return await tryber.tables.WpAppqCampaignOtherCostsType.do().select( + "name", + "id" + ); } } diff --git a/src/schema.ts b/src/schema.ts index 7fd1eec9b..1a508ac4e 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5610,7 +5610,8 @@ export interface operations { content: { "application/json": { items: { - name?: string; + name: string; + id: number; }[]; }; }; From 4d1b170b9a41bdcf4f51f088908f094f70b89edb Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 10:33:16 +0100 Subject: [PATCH 43/78] fix: correct cost_id order --- .../campaignId/finance/otherCosts/_get/index.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 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 65705ae42..a575d52b6 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts @@ -97,21 +97,21 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ { id: 1, - cost_id: 1, url: "https://example.com/attachment1.pdf", mime_type: "application/pdf", + cost_id: 1, }, { id: 2, - cost_id: 1, url: "https://example.com/attachment2.jpg", mime_type: "image/jpeg", + cost_id: 1, }, { id: 3, - cost_id: 2, url: "https://example.com/attachment3.png", mime_type: "image/png", + cost_id: 2, }, ]); }); From 3cb200180e289c55a4b6f9e0ceb3ac264ac0d11e Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 10:34:54 +0100 Subject: [PATCH 44/78] chore: update @appquality/tryber-database to version 0.46.18 in package.json and package-lock.json --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d6091ad94..d8c15a6e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@appquality/tryber-database": "^0.46.17", + "@appquality/tryber-database": "^0.46.18", "@appquality/wp-auth": "^1.0.7", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/hash-node": "^3.374.0", @@ -108,9 +108,9 @@ } }, "node_modules/@appquality/tryber-database": { - "version": "0.46.17", - "resolved": "https://registry.npmjs.org/@appquality/tryber-database/-/tryber-database-0.46.17.tgz", - "integrity": "sha512-pipi0ypxbkSqkGD69yEx/6M8yYkd3uCevS0Gw7tc97MieSgsGU75X/CIBF7Adr04xbk1tPMLIS7uZ9Q+nJ10vQ==", + "version": "0.46.18", + "resolved": "https://registry.npmjs.org/@appquality/tryber-database/-/tryber-database-0.46.18.tgz", + "integrity": "sha512-7G9GIX3gpWCJJpo/DgdrSXRu/Jm/MJnbhglT9q0SoemEQQPZwSREFnxbGXxqg77URTeG7iNHXRzH1DXSkCVmSg==", "license": "ISC", "dependencies": { "better-sqlite3": "^12.5.0", diff --git a/package.json b/package.json index 1203c771a..9a1103eb5 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "author": "", "license": "ISC", "dependencies": { - "@appquality/tryber-database": "^0.46.17", + "@appquality/tryber-database": "^0.46.18", "@appquality/wp-auth": "^1.0.7", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/hash-node": "^3.374.0", From dee0247cdfa64fdba449a3e0c59fa484075c40ff Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Mon, 2 Feb 2026 11:04:30 +0100 Subject: [PATCH 45/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 78 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index bfd704803..6fa770306 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13451,6 +13451,11 @@ paths: name: campaign in: path required: true + - schema: + type: string + name: campaign + in: path + required: true get: summary: Your GET endpoint tags: [] @@ -13553,6 +13558,79 @@ paths: id: 9hp8r67rwl59d security: - JWT: [] + post: + summary: Your POST endpoint + tags: [] + responses: + '201': + description: Created + '400': + description: Bad Request + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + operationId: post-campaigns-campaign-finance-otherCosts + x-stoplight: + id: aujq76gdkus39 + description: Create a new campaign cost + requestBody: + content: + application/json: + schema: + type: object + x-examples: + Example 1: + description: Riparazione hardware ufficio + type_id: 3 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg + required: + - description + - type_id + - supplier_id + - cost + - attachments + properties: + description: + type: string + type_id: + type: integer + supplier_id: + type: integer + cost: + type: number + attachments: + type: array + items: + type: object + required: + - url + - mime_type + properties: + url: + type: string + mime_type: + type: string + examples: + Example 1: + value: + description: Riparazione hardware ufficio + type_id: 3 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg servers: - url: 'https://api.app-quality.com' tags: From ca68578ffad66aef7ac317ee185d4b967db921c3 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Mon, 2 Feb 2026 11:09:50 +0100 Subject: [PATCH 46/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 6fa770306..8f28384df 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13451,11 +13451,6 @@ paths: name: campaign in: path required: true - - schema: - type: string - name: campaign - in: path - required: true get: summary: Your GET endpoint tags: [] From 63bb92a8913768953677d2a651c099727f0731ca Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Mon, 2 Feb 2026 11:10:41 +0100 Subject: [PATCH 47/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 8f28384df..851c42a12 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13446,11 +13446,7 @@ paths: - JWT: [] '/campaigns/{campaign}/finance/otherCosts': parameters: - - schema: - type: string - name: campaign - in: path - required: true + - $ref: '#/components/parameters/campaign' get: summary: Your GET endpoint tags: [] From 3089b5811a78f823c87f803d6bc07a91e40c0441 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 11:13:19 +0100 Subject: [PATCH 48/78] feat: add endpoint to create new campaign cost and update campaign parameter type --- src/schema.ts | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 1a508ac4e..09a9f787f 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -824,9 +824,12 @@ export interface paths { }; "/campaigns/{campaign}/finance/otherCosts": { get: operations["get-campaigns-campaign-finance-otherCosts"]; + /** Create a new campaign cost */ + post: operations["post-campaigns-campaign-finance-otherCosts"]; parameters: { path: { - campaign: string; + /** A campaign id */ + campaign: components["parameters"]["campaign"]; }; }; }; @@ -5625,7 +5628,8 @@ export interface operations { "get-campaigns-campaign-finance-otherCosts": { parameters: { path: { - campaign: string; + /** A campaign id */ + campaign: components["parameters"]["campaign"]; }; }; responses: { @@ -5661,6 +5665,39 @@ export interface operations { 500: unknown; }; }; + /** Create a new campaign cost */ + "post-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: components["parameters"]["campaign"]; + }; + }; + responses: { + /** Created */ + 201: unknown; + /** Bad Request */ + 400: unknown; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + }; + }; + }; + }; } export interface external {} From 4eac84125bfb3c835a0debe5806eaf627d7911ec Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 11:28:49 +0100 Subject: [PATCH 49/78] feat: add security definition for JWT in OpenAPI specification --- src/reference/openapi.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 851c42a12..5651f404c 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13622,6 +13622,8 @@ paths: mime_type: application/pdf - url: 'https://esempio.com/immagini/danno.jpg' mime_type: image/jpeg + security: + - JWT: [] servers: - url: 'https://api.app-quality.com' tags: From b4a6458d7d31993cdde8867098886e3525a51edb Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 11:38:10 +0100 Subject: [PATCH 50/78] feat: implement POST endpoint for campaign other costs with validation and attachment handling --- .../finance/otherCosts/_post/index.spec.ts | 501 ++++++++++++++++++ .../finance/otherCosts/_post/index.ts | 133 +++++ 2 files changed, 634 insertions(+) create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts new file mode 100644 index 000000000..ce842050b --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts @@ -0,0 +1,501 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; + +describe("POST /campaigns/campaignId/finance/otherCosts", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + { + id: 2, + name: "Jane", + surname: "Doe", + wp_user_id: 2, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert([{ ID: 1 }, { ID: 2 }]); + await tryber.tables.WpAppqEvdCampaign.do().insert([ + { + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + { + id: 2, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "Another campaign", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsType.do().insert([ + { + id: 1, + name: "Type 1", + }, + { + id: 2, + name: "Type 2", + }, + { + id: 3, + name: "Type 3", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + { + id: 2, + name: "Supplier 2", + created_by: 1, + created_on: "2024-01-02 11:00:00", + }, + { + id: 105, + name: "Supplier 105", + created_by: 1, + created_on: "2024-01-03 12:00:00", + }, + ]); + }); + + afterEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); + await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + await tryber.tables.WpAppqCampaignOtherCostsType.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + const validPayload = { + description: "Riparazione hardware ufficio", + type_id: 3, + supplier_id: 105, + cost: 250.5, + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }; + + describe("Not enough permissions", () => { + it("Should return 403 if logged out", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload); + expect(response.status).toBe(403); + }); + + it("Should return 403 if logged in as not admin user", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if no access to the campaign", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[2]}'); + expect(response.status).toBe(403); + }); + }); + + describe("Validation errors", () => { + it("Should return 400 if description is empty", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, description: "" }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Description should not be empty", + }) + ); + }); + + it("Should return 400 if description is only whitespace", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, description: " " }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Description should not be empty", + }) + ); + }); + + it("Should return 400 if cost is 0", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, cost: 0 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Cost must be greater than 0", + }) + ); + }); + + it("Should return 400 if cost is negative", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, cost: -10 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Cost must be greater than 0", + }) + ); + }); + + it("Should return 400 if type_id does not exist", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, type_id: 999 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Type not found", + }) + ); + }); + + it("Should return 400 if supplier_id does not exist", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, supplier_id: 999 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Supplier not found", + }) + ); + }); + + it("Should return 400 if attachments array is empty", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, attachments: [] }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "At least one attachment is required", + }) + ); + }); + + it("Should return 400 if attachment url is empty", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ + ...validPayload, + attachments: [ + { + url: "", + mime_type: "application/pdf", + }, + ], + }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Attachment URL is required", + }) + ); + }); + + it("Should return 400 if attachment mime_type is empty", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ + ...validPayload, + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "", + }, + ], + }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Attachment mime_type is required", + }) + ); + }); + }); + + describe("Success - admin permissions", () => { + it("Should return 201 if logged in as admin", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + }); + + it("Should create other cost in database", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + type: { name: "Type 3", id: 3 }, + supplier: { name: "Supplier 105", id: 105 }, + }) + ); + }); + + it("Should create attachments in database", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items[0].attachments).toHaveLength(2); + expect(getResponse.body.items[0].attachments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + url: "https://esempio.com/documenti/fattura.pdf", + mimetype: "application/pdf", + }), + expect.objectContaining({ + url: "https://esempio.com/immagini/danno.jpg", + mimetype: "image/jpeg", + }), + ]) + ); + }); + + it("Should create cost with single attachment", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ + ...validPayload, + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + ], + }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items[0].attachments).toHaveLength(1); + }); + + it("Should create cost with multiple attachments", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ + ...validPayload, + attachments: [ + { + url: "https://esempio.com/documenti/fattura1.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/documenti/fattura2.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items[0].attachments).toHaveLength(3); + }); + + it("Should create multiple costs independently", async () => { + const response1 = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response1.status).toBe(201); + + const response2 = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ + ...validPayload, + description: "Second cost", + cost: 100.0, + }) + .set("Authorization", "Bearer admin"); + expect(response2.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(2); + }); + + it("Should accept decimal cost values", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, cost: 123.456 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items[0]).toBeDefined(); + }); + }); + + describe("Success - olp permissions", () => { + it("Should return 201 if logged in as olp with access to campaign", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(201); + }); + + it("Should create other cost in database with olp permissions", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + }) + ); + }); + + it("Should create attachments with olp permissions", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items[0].attachments).toHaveLength(2); + }); + + it("Should return 403 if olp does not have access to campaign", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[2]}'); + expect(response.status).toBe(403); + }); + }); + + describe("Campaign isolation", () => { + it("Should create cost only for specified campaign", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse1 = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse1.status).toBe(200); + expect(getResponse1.body.items).toHaveLength(1); + + const getResponse2 = await request(app) + .get("/campaigns/2/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse2.status).toBe(200); + expect(getResponse2.body.items).toHaveLength(0); + }); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts new file mode 100644 index 000000000..537d1d4ca --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts @@ -0,0 +1,133 @@ +/** OPENAPI-CLASS: post-campaigns-campaign-finance-otherCosts */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; + +export default class OtherCostsPostRoute extends CampaignRoute<{ + response: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["responses"]["201"]; + parameters: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; + body: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + return false; + } + + const body = this.getBody(); + + // Validate description + if (!body.description || body.description.trim() === "") { + this.setError(400, new OpenapiError("Description should not be empty")); + return false; + } + + // Validate cost + if (body.cost <= 0) { + this.setError(400, new OpenapiError("Cost must be greater than 0")); + return false; + } + + // Validate type_id exists + if (!(await this.typeExists(body.type_id))) { + this.setError(400, new OpenapiError("Type not found")); + return false; + } + + // Validate supplier_id exists + if (!(await this.supplierExists(body.supplier_id))) { + this.setError(400, new OpenapiError("Supplier not found")); + return false; + } + + // Validate attachments + if (!body.attachments || body.attachments.length === 0) { + this.setError( + 400, + new OpenapiError("At least one attachment is required") + ); + return false; + } + + for (const attachment of body.attachments) { + if (!attachment.url || attachment.url.trim() === "") { + this.setError(400, new OpenapiError("Attachment URL is required")); + return false; + } + if (!attachment.mime_type || attachment.mime_type.trim() === "") { + this.setError( + 400, + new OpenapiError("Attachment mime_type is required") + ); + return false; + } + } + + return true; + } + + protected async prepare(): Promise { + try { + const body = this.getBody(); + const costId = await this.createOtherCost(body); + await this.createAttachments(costId, body.attachments); + + return this.setSuccess(201, undefined); + } catch (e) { + console.error("Error creating other cost: ", e); + return this.setError(500, new OpenapiError("Error creating other cost")); + } + } + + private async createOtherCost( + body: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"] + ): Promise { + const result = await tryber.tables.WpAppqCampaignOtherCosts.do() + .insert({ + campaign_id: this.cp_id, + description: body.description, + cost: body.cost, + type_id: body.type_id, + supplier_id: body.supplier_id, + }) + .returning("id"); + + const id = result[0]?.id ?? result[0]; + + if (!id) throw new Error("Error creating other cost"); + + return id; + } + + private async createAttachments( + costId: number, + attachments: { url: string; mime_type: string }[] + ): Promise { + const attachmentsData = attachments.map((attachment) => ({ + cost_id: costId, + url: attachment.url, + mime_type: attachment.mime_type, + })); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert( + attachmentsData + ); + } + + private async typeExists(typeId: number): Promise { + const type = await tryber.tables.WpAppqCampaignOtherCostsType.do() + .where({ id: typeId }) + .first(); + return type !== undefined; + } + + private async supplierExists(supplierId: number): Promise { + const supplier = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ id: supplierId }) + .first(); + return supplier !== undefined; + } +} From 4eca4639de3c4021d5daad319e81f88effdde8f4 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Mon, 2 Feb 2026 14:20:11 +0100 Subject: [PATCH 51/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 5651f404c..bc88cae91 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13446,6 +13446,11 @@ paths: - JWT: [] '/campaigns/{campaign}/finance/otherCosts': parameters: + - schema: + type: string + name: campaign + in: path + required: true - $ref: '#/components/parameters/campaign' get: summary: Your GET endpoint @@ -13624,6 +13629,41 @@ paths: mime_type: image/jpeg security: - JWT: [] + delete: + summary: Your DELETE endpoint + tags: [] + responses: + '200': + description: OK + '400': + description: Bad Request + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + operationId: delete-campaigns-campaign-finance-otherCosts + x-stoplight: + id: p9q4g69c20okp + security: + - JWT: [] + requestBody: + content: + application/json: + schema: + type: object + required: + - cost_id + properties: + cost_id: + type: integer + x-stoplight: + id: b27nhd7f5ugfs + examples: + Example 1: + value: + cost_id: 80 servers: - url: 'https://api.app-quality.com' tags: From b39676761029f045503c1322b760897f0f9f2386 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 14:23:14 +0100 Subject: [PATCH 52/78] feat: add DELETE endpoint for campaign finance other costs --- src/schema.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/schema.ts b/src/schema.ts index 09a9f787f..b1f835100 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -826,6 +826,7 @@ export interface paths { get: operations["get-campaigns-campaign-finance-otherCosts"]; /** Create a new campaign cost */ post: operations["post-campaigns-campaign-finance-otherCosts"]; + delete: operations["delete-campaigns-campaign-finance-otherCosts"]; parameters: { path: { /** A campaign id */ @@ -5698,6 +5699,31 @@ export interface operations { }; }; }; + "delete-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: components["parameters"]["campaign"]; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: unknown; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + cost_id: number; + }; + }; + }; + }; } export interface external {} From b89e62ce1711c048e2c1cc707a9a867316a0c4a0 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 14:50:09 +0100 Subject: [PATCH 53/78] feat: implement DELETE endpoint for campaign finance other costs --- .../finance/otherCosts/_delete/index.spec.ts | 648 ++++++++++++++++++ .../finance/otherCosts/_delete/index.ts | 64 ++ 2 files changed, 712 insertions(+) create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts new file mode 100644 index 000000000..4e2009f4a --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts @@ -0,0 +1,648 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; + +describe("DELETE /campaigns/campaignId/finance/otherCosts", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + { + id: 2, + name: "Jane", + surname: "Doe", + wp_user_id: 2, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert([{ ID: 1 }, { ID: 2 }]); + await tryber.tables.WpAppqEvdCampaign.do().insert([ + { + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + { + id: 2, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "Another campaign", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsType.do().insert([ + { + id: 1, + name: "Type 1", + }, + { + id: 2, + name: "Type 2", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + { + id: 2, + name: "Supplier 2", + created_by: 1, + created_on: "2024-01-02 11:00:00", + }, + ]); + }); + + afterEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); + await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + await tryber.tables.WpAppqCampaignOtherCostsType.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + describe("Authentication and Authorization", () => { + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 403 if user is not authenticated", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }); + expect(response.status).toBe(403); + }); + + it("Should return 403 if user does not have access to campaign", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[2]}'); + expect(response.status).toBe(403); + }); + + it("Should return 200 if logged in as admin", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should return 200 if logged in as olp with access to campaign", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + }); + }); + + describe("Input Validation", () => { + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 400 if cost_id is missing", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({}) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost_id is null", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: null }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost_id is not a number", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: "invalid" }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost_id is zero", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 0 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "cost_id must be a positive number", + }) + ); + }); + + it("Should return 400 if cost_id is negative", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: -1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "cost_id must be a positive number", + }) + ); + }); + }); + + describe("Not Found ", () => { + it("Should return 404 if cost does not exist", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 999 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Cost not found for this campaign", + }) + ); + }); + + it("Should return 404 if cost belongs to another campaign", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 10, + campaign_id: 2, + description: "Cost for another campaign", + cost: 50.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 10 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Cost not found for this campaign", + }) + ); + }); + }); + + describe("Success - admin permissions", () => { + it("Should delete cost from database", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(0); + }); + + it("Should delete cost and all its attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://example.com/attachment1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://example.com/attachment2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 1, + url: "https://example.com/attachment3.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(0); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(0); + }); + + it("Should only delete specified cost, not others", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const cost1 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(cost1).toHaveLength(0); + + const cost2 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 2 }) + .select(); + expect(cost2).toHaveLength(1); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 2, + description: "Cost to keep", + }) + ); + }); + + it("Should only delete attachments of the deleted cost", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://example.com/delete1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://example.com/delete2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 2, + url: "https://example.com/keep.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const attachmentsCost1 = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachmentsCost1).toHaveLength(0); + + const attachmentsCost2 = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 2 }) + .select(); + expect(attachmentsCost2).toHaveLength(1); + expect(attachmentsCost2[0]).toEqual( + expect.objectContaining({ + id: 3, + url: "https://example.com/keep.png", + }) + ); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 2, + description: "Cost to keep", + attachments: expect.arrayContaining([ + expect.objectContaining({ + id: 3, + url: "https://example.com/keep.png", + mimetype: "image/png", + }), + ]), + }) + ); + }); + + it("Should delete cost without attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost without attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(0); + }); + + it("Should delete correctly only one cost item", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const deleteResponse = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(deleteResponse.status).toBe(200); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 2, + description: "Cost to keep", + }) + ); + }); + }); + + describe("Success - olp permissions", () => { + it("Should delete cost with olp permissions", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(0); + }); + + it("Should delete cost and attachments ", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://example.com/attachment1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://example.com/attachment2.jpg", + mime_type: "image/jpeg", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(0); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(0); + }); + + it("Should delete correctly only one cost item", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const deleteResponse = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(deleteResponse.status).toBe(200); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 2, + description: "Cost to keep", + }) + ); + }); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts new file mode 100644 index 000000000..a3a750e74 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts @@ -0,0 +1,64 @@ +/** OPENAPI-CLASS: delete-campaigns-campaign-finance-otherCosts */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; + +export default class OtherCostsDeleteRoute extends CampaignRoute<{ + response: StoplightOperations["delete-campaigns-campaign-finance-otherCosts"]["responses"]["200"]; + parameters: StoplightOperations["delete-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; + body: StoplightOperations["delete-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + return false; + } + + const body = this.getBody(); + + if (body.cost_id <= 0) { + this.setError(400, new OpenapiError("cost_id must be a positive number")); + return false; + } + + const costExists = await this.costExistsInCampaign(body.cost_id); + if (!costExists) { + this.setError(404, new OpenapiError("Cost not found for this campaign")); + return false; + } + + return true; + } + + protected async prepare(): Promise { + try { + const body = this.getBody(); + await this.deleteOtherCost(body.cost_id); + + return this.setSuccess(200, {}); + } catch (e) { + console.error("Error deleting other cost: ", e); + return this.setError(500, new OpenapiError("Error deleting other cost")); + } + } + + private async costExistsInCampaign(costId: number): Promise { + const cost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: costId, campaign_id: this.cp_id }) + .first(); + return cost !== undefined; + } + + private async deleteOtherCost(costId: number): Promise { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: costId }) + .delete(); + + await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: costId }) + .delete(); + } +} From 86ca6642c50ee014957697c5e9d7c22cf509db63 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 15:01:53 +0100 Subject: [PATCH 54/78] feat: implement S3 deletion for campaign finance other costs --- .../finance/otherCosts/_delete/index.spec.ts | 158 ++++++++++++++++++ .../finance/otherCosts/_delete/index.ts | 25 ++- 2 files changed, 180 insertions(+), 3 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts index 4e2009f4a..848c12edd 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts @@ -1,6 +1,9 @@ import request from "supertest"; import app from "@src/app"; import { tryber } from "@src/features/database"; +import deleteFromS3 from "@src/features/deleteFromS3"; + +jest.mock("@src/features/deleteFromS3"); describe("DELETE /campaigns/campaignId/finance/otherCosts", () => { beforeAll(async () => { @@ -82,6 +85,7 @@ describe("DELETE /campaigns/campaignId/finance/otherCosts", () => { afterEach(async () => { await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + jest.clearAllMocks(); }); afterAll(async () => { @@ -645,4 +649,158 @@ describe("DELETE /campaigns/campaignId/finance/otherCosts", () => { ); }); }); + + describe("S3 Deletion", () => { + it("Should not call deleteFromS3 if cost has no attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost without attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(0); + }); + + it("Should call deleteFromS3 once for cost with one attachment", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with one attachment", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert({ + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + mime_type: "application/pdf", + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(1); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + }); + }); + + it("Should call deleteFromS3 three times for cost with three attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with multiple attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file3.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(3); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file2.jpg", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file3.png", + }); + }); + + it("Should only delete S3 files for the specified cost, not others", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 2, + url: "https://s3.eu-west-1.amazonaws.com/bucket/keep.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(2); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete1.pdf", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete2.jpg", + }); + expect(deleteFromS3).not.toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/keep.png", + }); + }); + }); }); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts index a3a750e74..754e6eca3 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/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 deleteFromS3 from "@src/features/deleteFromS3"; export default class OtherCostsDeleteRoute extends CampaignRoute<{ response: StoplightOperations["delete-campaigns-campaign-finance-otherCosts"]["responses"]["200"]; @@ -53,9 +54,27 @@ export default class OtherCostsDeleteRoute extends CampaignRoute<{ } private async deleteOtherCost(costId: number): Promise { - await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() - .where({ cost_id: costId }) - .delete(); + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .select("url", "id") + .where({ cost_id: costId }); + + if (attachments.length > 0) { + for (const attachment of attachments) { + try { + await deleteFromS3({ url: attachment.url }); + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ id: attachment.id }) + .delete(); + } catch (e) { + console.error( + `Error deleting attachment from S3: ${attachment.url}`, + e + ); + throw new Error("Error deleting attachment from S3"); + } + } + } await tryber.tables.WpAppqCampaignOtherCosts.do() .where({ id: costId }) From cfa9e02b407ca88b6014c2db4f07d73bcd65c8f4 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 15:06:09 +0100 Subject: [PATCH 55/78] fix: ensure attachments are deleted from the database after S3 deletion --- .../campaignId/finance/otherCosts/_delete/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts index 754e6eca3..45234b047 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts @@ -63,9 +63,6 @@ export default class OtherCostsDeleteRoute extends CampaignRoute<{ for (const attachment of attachments) { try { await deleteFromS3({ url: attachment.url }); - await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() - .where({ id: attachment.id }) - .delete(); } catch (e) { console.error( `Error deleting attachment from S3: ${attachment.url}`, @@ -73,6 +70,9 @@ export default class OtherCostsDeleteRoute extends CampaignRoute<{ ); throw new Error("Error deleting attachment from S3"); } + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ id: attachment.id }) + .delete(); } } From 54aebe889a7d3ff185a07aff7fac6915e0170d76 Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Mon, 2 Feb 2026 15:59:48 +0100 Subject: [PATCH 56/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index bc88cae91..91c079801 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13462,6 +13462,8 @@ paths: application/json: schema: type: object + required: + - items properties: items: type: array @@ -13471,6 +13473,13 @@ paths: x-stoplight: id: srl106ylr6cpm type: object + required: + - cost_id + - type + - supplier + - description + - attachments + - cost properties: cost_id: type: number @@ -13480,6 +13489,9 @@ paths: type: object x-stoplight: id: itchthltx575n + required: + - name + - id properties: name: type: string @@ -13493,6 +13505,9 @@ paths: type: object x-stoplight: id: pewi2gjqq2j1h + required: + - name + - id properties: name: type: string @@ -13514,6 +13529,10 @@ paths: x-stoplight: id: w31uej26rl532 type: object + required: + - id + - url + - mimetype properties: id: type: number @@ -13527,6 +13546,10 @@ paths: type: string x-stoplight: id: 6tqj2cg96280v + cost: + type: number + x-stoplight: + id: 4zut3xli3ht5f examples: Example 1: value: @@ -13543,6 +13566,7 @@ paths: url: string mimetype: string cost_id: 0 + cost: 10.2 '403': description: Forbidden '404': From 1425fff8cc947af1ba372dc57ccc5f09ae92ca3f Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 16:01:57 +0100 Subject: [PATCH 57/78] fix: update operations schema to require fields for cost items --- src/schema.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index b1f835100..3d6b88bdc 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5638,22 +5638,23 @@ export interface operations { 200: { content: { "application/json": { - items?: { - cost_id?: number; - type?: { - name?: string; - id?: number; + items: { + cost_id: number; + type: { + name: string; + id: number; }; - supplier?: { - name?: string; - id?: number; + supplier: { + name: string; + id: number; }; - description?: string; - attachments?: { - id?: number; - url?: string; - mimetype?: string; + description: string; + attachments: { + id: number; + url: string; + mimetype: string; }[]; + cost: number; }[]; }; }; From 8b1e391352fa373de1a10703970bf7626d40e2be Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 16:05:35 +0100 Subject: [PATCH 58/78] feat: add cost field to OtherCost type and update related tests --- .../campaignId/finance/otherCosts/_get/index.spec.ts | 5 +++++ .../campaigns/campaignId/finance/otherCosts/_get/index.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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 a575d52b6..b475ade8a 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts @@ -161,6 +161,7 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { items: expect.arrayContaining([ expect.objectContaining({ cost_id: 1, + cost: 100.5, type: { name: "Type 1", id: 1, @@ -185,6 +186,7 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { }), expect.objectContaining({ cost_id: 2, + cost: 200.75, type: { name: "Type 2", id: 2, @@ -218,6 +220,7 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { items: expect.arrayContaining([ expect.objectContaining({ cost_id: 1, + cost: 100.5, type: { name: "Type 1", id: 1, @@ -230,6 +233,7 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { }), expect.objectContaining({ cost_id: 2, + cost: 200.75, type: { name: "Type 2", id: 2, @@ -300,6 +304,7 @@ describe("GET /campaigns/campaignId/finance/otherCosts", () => { (item: any) => item.cost_id === 10 ); expect(costWithoutAttachments).toBeDefined(); + expect(costWithoutAttachments.cost).toBe(50); expect(costWithoutAttachments.attachments).toEqual([]); }); }); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts index e3d48bd92..8234403fd 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts @@ -6,6 +6,7 @@ import OpenapiError from "@src/features/OpenapiError"; type OtherCost = { cost_id: number; + cost: number; type: { name: string; id: number; @@ -52,7 +53,8 @@ export default class OtherCostsRoute extends CampaignRoute<{ .as("cost_id"), "description", "type_id", - "supplier_id" + "supplier_id", + "cost" ) .where("campaign_id", this.cp_id); @@ -84,6 +86,7 @@ export default class OtherCostsRoute extends CampaignRoute<{ return { cost_id: cost.cost_id, + cost: cost.cost, type: { name: type?.name || "", id: type?.id || 0, From fa87f05bfa570689428d61e0e9ccf935de657d0f Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Mon, 2 Feb 2026 16:45:59 +0100 Subject: [PATCH 59/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 148 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 6 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 91c079801..c445f0c62 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13444,13 +13444,8 @@ paths: id: 02e8ns5xdhecm security: - JWT: [] - '/campaigns/{campaign}/finance/otherCosts': + /campaigns/finance/otherCosts: parameters: - - schema: - type: string - name: campaign - in: path - required: true - $ref: '#/components/parameters/campaign' get: summary: Your GET endpoint @@ -13688,6 +13683,147 @@ paths: Example 1: value: cost_id: 80 + patch: + summary: Your PATCH endpoint + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + x-examples: + Example 1: + description: Riparazione hardware ufficio + type_id: 3 + cost_id: 2 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg + required: + - description + - type + - cost_id + - supplier + - cost + - attachments + properties: + description: + type: string + type: + type: string + x-stoplight: + id: q54ltj77jcyf0 + cost_id: + type: integer + supplier: + type: string + x-stoplight: + id: 5aunsjh1dxfq1 + cost: + type: number + attachments: + type: array + items: + type: object + required: + - url + - mime_type + properties: + url: + type: string + mime_type: + type: string + examples: + Example 1: + value: + description: description + type: Type 1 + cost_id: 10 + supplier: Supplier + cost: 104 + attachments: + - url: 'https://example.com/' + mime_type: image/jpg + '400': + description: Bad Request + '403': + description: Forbidden + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + operationId: patch-campaigns-campaign-finance-otherCosts + x-stoplight: + id: mwhcb91voxivy + security: + - JWT: [] + requestBody: + content: + application/json: + schema: + type: object + x-examples: + Example 1: + description: Riparazione hardware ufficio + type_id: 3 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg + required: + - description + - type_id + - supplier_id + - cost + - attachments + - cost_id + properties: + description: + type: string + type_id: + type: integer + supplier_id: + type: integer + cost: + type: number + attachments: + type: array + items: + type: object + required: + - url + - mime_type + properties: + url: + type: string + mime_type: + type: string + cost_id: + type: integer + x-stoplight: + id: drnv0dayw8k18 + examples: + Example 1: + value: + description: Riparazione hardware ufficio + type_id: 3 + cost_id: 2 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg servers: - url: 'https://api.app-quality.com' tags: From 69a0806b22a42f33703ea5d1358f9d591a4d68ef Mon Sep 17 00:00:00 2001 From: "it@app-quality.com" Date: Mon, 2 Feb 2026 17:02:08 +0100 Subject: [PATCH 60/78] Modified src/reference/openapi.yml --- src/reference/openapi.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index c445f0c62..5823a5715 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13444,9 +13444,14 @@ paths: id: 02e8ns5xdhecm security: - JWT: [] - /campaigns/finance/otherCosts: + '/campaigns/{campaign}/finance/otherCosts': parameters: - - $ref: '#/components/parameters/campaign' + - description: A campaign id + in: path + name: campaign + required: true + schema: + type: string get: summary: Your GET endpoint tags: [] From 167765d29f750c412f8a5942942546e506f457fe Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 17:40:16 +0100 Subject: [PATCH 61/78] feat: add patch operation for campaign finance other costs and update parameters --- src/schema.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 3d6b88bdc..61c2ae705 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -827,10 +827,11 @@ export interface paths { /** Create a new campaign cost */ post: operations["post-campaigns-campaign-finance-otherCosts"]; delete: operations["delete-campaigns-campaign-finance-otherCosts"]; + patch: operations["patch-campaigns-campaign-finance-otherCosts"]; parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; }; @@ -5630,7 +5631,7 @@ export interface operations { parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; responses: { @@ -5672,7 +5673,7 @@ export interface operations { parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; responses: { @@ -5704,7 +5705,7 @@ export interface operations { parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; responses: { @@ -5725,6 +5726,57 @@ export interface operations { }; }; }; + "patch-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + description: string; + type: string; + cost_id: number; + supplier: string; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + }; + }; + }; + /** Bad Request */ + 400: unknown; + /** Forbidden */ + 403: unknown; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + description: string; + type_id: number; + /** @description ID of existing supplier (mutually exclusive with new_supplier_name) */ + supplier_id?: number; + /** @description Name for new supplier (mutually exclusive with supplier_id) */ + new_supplier_name?: string; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + cost_id: number; + }; + }; + }; + }; } export interface external {} From b90bcc98a30a34b6bde53b23435181807b32c0c9 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 17:40:34 +0100 Subject: [PATCH 62/78] feat: update campaign finance parameters to include new_supplier_name and remove supplier_id --- src/reference/openapi.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 5823a5715..079897112 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13787,7 +13787,6 @@ paths: required: - description - type_id - - supplier_id - cost - attachments - cost_id @@ -13798,6 +13797,10 @@ paths: type: integer supplier_id: type: integer + description: ID of existing supplier (mutually exclusive with new_supplier_name) + new_supplier_name: + type: string + description: Name for new supplier (mutually exclusive with supplier_id) cost: type: number attachments: @@ -13829,6 +13832,16 @@ paths: mime_type: application/pdf - url: 'https://esempio.com/immagini/danno.jpg' mime_type: image/jpeg + Example 2 (New Supplier): + value: + description: Riparazione hardware ufficio + type_id: 3 + cost_id: 2 + new_supplier_name: Nuovo Fornitore SRL + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf servers: - url: 'https://api.app-quality.com' tags: From 01e5d5b71106f4d03e972f75a7e03af20e0da4e6 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Mon, 2 Feb 2026 17:43:21 +0100 Subject: [PATCH 63/78] feat: implement PATCH route for updating campaign finance other costs with validation and supplier management --- .../finance/otherCosts/_patch/index.spec.ts | 965 ++++++++++++++++++ .../finance/otherCosts/_patch/index.ts | 252 +++++ 2 files changed, 1217 insertions(+) create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts create mode 100644 src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts new file mode 100644 index 000000000..a14e13d02 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts @@ -0,0 +1,965 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; +import deleteFromS3 from "@src/features/deleteFromS3"; + +jest.mock("@src/features/deleteFromS3"); + +describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { + beforeAll(async () => { + (deleteFromS3 as jest.Mock).mockResolvedValue(undefined); + + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + { + id: 2, + name: "Jane", + surname: "Doe", + wp_user_id: 2, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert([{ ID: 1 }, { ID: 2 }]); + await tryber.tables.WpAppqEvdCampaign.do().insert([ + { + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + { + id: 2, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "Another campaign", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsType.do().insert([ + { + id: 1, + name: "Type 1", + }, + { + id: 2, + name: "Type 2", + }, + { + id: 3, + name: "Type 3", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + { + id: 2, + name: "Supplier 2", + created_by: 1, + created_on: "2024-01-02 11:00:00", + }, + { + id: 105, + name: "Supplier 105", + created_by: 1, + created_on: "2024-01-03 12:00:00", + }, + ]); + }); + + afterEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); + await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + await tryber.tables.WpAppqCampaignOtherCostsType.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + const validPayload = { + description: "Riparazione hardware ufficio", + type_id: 3, + cost_id: 1, + supplier_id: 105, + cost: 250.5, + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }; + + describe("Authentication and Authorization", () => { + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 403 if user is not logged in", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload); + expect(response.status).toBe(403); + }); + + it("Should return 403 if user is not admin and does not have olp permissions", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if user has olp permissions for different campaign", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[2]}'); + expect(response.status).toBe(403); + }); + + it("Should allow access with admin permissions", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should allow access with olp permissions for the campaign", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + }); + }); + + describe("Input Validation", () => { + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 400 if cost_id is missing", async () => { + const payload = { ...validPayload }; + delete (payload as any).cost_id; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost_id is null", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, cost_id: null }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost_id is zero", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, cost_id: 0 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "cost_id must be a positive number", + }) + ); + }); + + it("Should return 400 if cost_id is negative", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, cost_id: -1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "cost_id must be a positive number", + }) + ); + }); + + it("Should return 400 if description is missing", async () => { + const payload = { ...validPayload }; + delete (payload as any).description; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if type_id is missing", async () => { + const payload = { ...validPayload }; + delete (payload as any).type_id; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost is missing", async () => { + const payload = { ...validPayload }; + delete (payload as any).cost; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if attachments is missing", async () => { + const payload = { ...validPayload }; + delete (payload as any).attachments; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if attachments array item is missing url", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ + ...validPayload, + attachments: [{ mime_type: "application/pdf" }], + }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if attachments array item is missing mime_type", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ + ...validPayload, + attachments: [{ url: "https://example.com/file.pdf" }], + }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + }); + + describe("Resource Validation", () => { + it("Should return 404 if cost_id does not exist", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, cost_id: 999 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Cost not found for this campaign", + }) + ); + }); + + it("Should return 404 if cost belongs to another campaign", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 10, + campaign_id: 2, + description: "Cost for another campaign", + cost: 50.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, cost_id: 10 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Cost not found for this campaign", + }) + ); + }); + + it("Should return 404 if type_id does not exist", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, type_id: 999 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Type not found", + }) + ); + }); + + it("Should return 404 if supplier_id does not exist", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, supplier_id: 999 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Supplier not found", + }) + ); + }); + + it("Should return 400 if both supplier_id and new_supplier_name are provided", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ + ...validPayload, + supplier_id: 105, + new_supplier_name: "New Supplier", + }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Cannot provide both supplier_id and new_supplier_name", + }) + ); + }); + + it("Should return 400 if neither supplier_id nor new_supplier_name are provided", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const payload = { ...validPayload }; + delete (payload as any).supplier_id; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Either supplier_id or new_supplier_name must be provided", + }) + ); + }); + }); + + describe("Success - admin permissions", () => { + it("Should update cost in database", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + + expect(updatedCost).toEqual( + expect.objectContaining({ + id: 1, + campaign_id: 1, + description: "Riparazione hardware ufficio", + cost: 250.5, + type_id: 3, + supplier_id: 105, + }) + ); + }); + + it("Should update cost and replace attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://old.com/old1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://old.com/old2.jpg", + mime_type: "image/jpeg", + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + + expect(attachments).toHaveLength(2); + expect(attachments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + cost_id: 1, + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }), + expect.objectContaining({ + cost_id: 1, + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }), + ]) + ); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 1, + description: "Riparazione hardware ufficio", + cost: 250.5, + type: { + name: "Type 3", + id: 3, + }, + supplier: { + name: "Supplier 105", + id: 105, + }, + }) + ); + expect(getResponse.body.items[0].attachments).toHaveLength(2); + }); + + it("Should delete old attachments from S3 when updating", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert({ + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/old-file.pdf", + mime_type: "application/pdf", + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toHaveBeenCalledTimes(1); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/old-file.pdf", + }); + }); + + it("Should delete multiple old attachments from S3 when updating", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file3.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toHaveBeenCalledTimes(3); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file2.jpg", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file3.png", + }); + }); + + it("Should update cost without old attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(2); + expect(deleteFromS3).not.toHaveBeenCalled(); + }); + + it("Should update cost with empty attachments array", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert({ + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/old-file.pdf", + mime_type: "application/pdf", + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, attachments: [] }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(0); + expect(deleteFromS3).toHaveBeenCalledTimes(1); + }); + + it("Should only update specified cost, not others", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to update", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); + + const untouchedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 2 }) + .first(); + expect(untouchedCost).toEqual( + expect.objectContaining({ + description: "Cost to keep", + cost: 200.0, + }) + ); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(2); + }); + + it("Should create new supplier when new_supplier_name is provided", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const payload = { + ...validPayload, + new_supplier_name: "Nuovo Fornitore SRL", + }; + delete (payload as any).supplier_id; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const newSupplier = + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ name: "Nuovo Fornitore SRL" }) + .first(); + expect(newSupplier).toBeDefined(); + expect(newSupplier?.name).toBe("Nuovo Fornitore SRL"); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost?.supplier_id).toBe(newSupplier?.id); + }); + + it("Should reuse existing supplier if new_supplier_name matches existing name", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const payload = { + ...validPayload, + new_supplier_name: "Supplier 1", // This already exists in the database + }; + delete (payload as any).supplier_id; + + const initialSupplierCount = + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .count("id") + .as("count") + .first(); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + // Verify no new supplier was created + const finalSupplierCount = + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .count("id") + .as("count") + .first(); + expect(Number(finalSupplierCount?.count)).toBe( + Number(initialSupplierCount?.count) + ); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost?.supplier_id).toBe(1); // Should use Supplier 1's ID + }); + }); + + describe("Success - olp permissions", () => { + it("Should update cost with olp permissions", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); + }); + + it("Should update cost and replace attachments with olp permissions", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://old.com/old1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://old.com/old2.jpg", + mime_type: "image/jpeg", + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(2); + expect(deleteFromS3).toHaveBeenCalledTimes(2); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 1, + description: "Riparazione hardware ufficio", + }) + ); + }); + + it("Should create new supplier with olp permissions when new_supplier_name is provided", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const payload = { + ...validPayload, + new_supplier_name: "Fornitore OLP Test", + }; + delete (payload as any).supplier_id; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const newSupplier = + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ name: "Fornitore OLP Test" }) + .first(); + expect(newSupplier).toBeDefined(); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost?.supplier_id).toBe(newSupplier?.id); + }); + }); + + describe("Error Handling", () => { + it("Should return 500 if S3 deletion fails", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert({ + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file.pdf", + mime_type: "application/pdf", + }); + + (deleteFromS3 as jest.Mock).mockRejectedValueOnce( + new Error("S3 deletion failed") + ); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(500); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Error updating other cost", + }) + ); + }); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts new file mode 100644 index 000000000..74847f16b --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -0,0 +1,252 @@ +/** OPENAPI-CLASS: patch-campaigns-campaign-finance-otherCosts */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; +import deleteFromS3 from "@src/features/deleteFromS3"; + +export default class OtherCostsPatchRoute extends CampaignRoute<{ + response: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["responses"]["200"]["content"]["application/json"]; + parameters: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; + body: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + return false; + } + + const body = this.getBody(); + + if (body.cost_id <= 0) { + this.setError(400, new OpenapiError("cost_id must be a positive number")); + return false; + } + + const costExists = await this.costExistsInCampaign(body.cost_id); + if (!costExists) { + this.setError(404, new OpenapiError("Cost not found for this campaign")); + return false; + } + + const typeExists = await this.typeExists(body.type_id); + if (!typeExists) { + this.setError(404, new OpenapiError("Type not found")); + return false; + } + + // Validate supplier: either supplier_id OR new_supplier_name, but not both + const hasSupplier = + body.supplier_id !== undefined && body.supplier_id !== null; + const hasNewSupplierName = + body.new_supplier_name !== undefined && + body.new_supplier_name !== null && + body.new_supplier_name.trim() !== ""; + + if (!hasSupplier && !hasNewSupplierName) { + this.setError( + 400, + new OpenapiError( + "Either supplier_id or new_supplier_name must be provided" + ) + ); + return false; + } + + if (hasSupplier && hasNewSupplierName) { + this.setError( + 400, + new OpenapiError( + "Cannot provide both supplier_id and new_supplier_name" + ) + ); + return false; + } + + if (hasSupplier) { + const supplierExists = await this.supplierExists(body.supplier_id!); + if (!supplierExists) { + this.setError(404, new OpenapiError("Supplier not found")); + return false; + } + } + + return true; + } + + protected async prepare(): Promise { + try { + const body = this.getBody(); + await this.updateOtherCost(body); + + const updatedCost = await this.getUpdatedCost(body.cost_id); + + return this.setSuccess(200, updatedCost); + } catch (e) { + console.error("Error updating other cost: ", e); + return this.setError(500, new OpenapiError("Error updating other cost")); + } + } + + private async costExistsInCampaign(costId: number): Promise { + const cost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: costId, campaign_id: this.cp_id }) + .first(); + return cost !== undefined; + } + + private async typeExists(typeId: number): Promise { + const type = await tryber.tables.WpAppqCampaignOtherCostsType.do() + .where({ id: typeId }) + .first(); + return type !== undefined; + } + + private async supplierExists(supplierId: number): Promise { + const supplier = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ id: supplierId }) + .first(); + return supplier !== undefined; + } + + private async updateOtherCost( + body: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"] + ): Promise { + await this.deleteExistingAttachments(body.cost_id); + + let supplierId: number; + if (body.supplier_id !== undefined && body.supplier_id !== null) { + supplierId = body.supplier_id; + } else if (body.new_supplier_name) { + supplierId = await this.createOrGetSupplier(body.new_supplier_name); + } else { + throw new Error("No supplier information provided"); + } + + await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: body.cost_id }) + .update({ + description: body.description, + cost: body.cost, + type_id: body.type_id, + supplier_id: supplierId, + }); + + if (body.attachments && body.attachments.length > 0) { + await this.createAttachments(body.cost_id, body.attachments); + } + } + + private async createOrGetSupplier(supplierName: string): Promise { + const existingSupplier = + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ name: supplierName }) + .first(); + + if (existingSupplier) { + return existingSupplier.id; + } + + const result = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .insert({ + name: supplierName, + created_by: this.getWordpressId(), + created_on: tryber.fn.now(), + }) + .returning("id"); + + const id = result[0]?.id ?? result[0]; + if (!id) throw new Error("Error creating supplier"); + + return id; + } + + private async deleteExistingAttachments(costId: number): Promise { + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .select("url", "id") + .where({ cost_id: costId }); + + if (attachments.length > 0) { + for (const attachment of attachments) { + try { + await deleteFromS3({ url: attachment.url }); + } catch (e) { + console.error( + `Error deleting attachment from S3: ${attachment.url}`, + e + ); + throw new Error("Error deleting attachment from S3"); + } + } + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: costId }) + .delete(); + } + } + + private async createAttachments( + costId: number, + attachments: { url: string; mime_type: string }[] + ): Promise { + const attachmentsData = attachments.map((attachment) => ({ + cost_id: costId, + url: attachment.url, + mime_type: attachment.mime_type, + })); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert( + attachmentsData + ); + } + + private async getUpdatedCost(costId: number) { + const cost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .select( + tryber + .ref("id") + .withSchema("wp_appq_campaign_other_costs") + .as("cost_id"), + "description", + "type_id", + "supplier_id", + "cost" + ) + .where({ id: costId }) + .first(); + + if (!cost) { + throw new Error("Cost not found after update"); + } + + const type = await tryber.tables.WpAppqCampaignOtherCostsType.do() + .select("id", "name") + .where({ id: cost.type_id }) + .first(); + + const supplier = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .select("id", "name") + .where({ id: cost.supplier_id }) + .first(); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .select("id", "url", "mime_type") + .where({ cost_id: costId }); + + return { + description: cost.description, + type: type?.name || "", + cost_id: cost.cost_id, + supplier: supplier?.name || "", + cost: cost.cost, + attachments: attachments.map((a) => ({ + url: a.url, + mime_type: a.mime_type, + })), + }; + } +} From dd2c7453a61886c89dde3641149cddc30fa40c26 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 00:09:48 +0100 Subject: [PATCH 64/78] feat: update campaign finance schema to require supplier_id and remove new_supplier_name --- src/reference/openapi.yml | 15 +-------------- src/schema.ts | 5 +---- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 079897112..5823a5715 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13787,6 +13787,7 @@ paths: required: - description - type_id + - supplier_id - cost - attachments - cost_id @@ -13797,10 +13798,6 @@ paths: type: integer supplier_id: type: integer - description: ID of existing supplier (mutually exclusive with new_supplier_name) - new_supplier_name: - type: string - description: Name for new supplier (mutually exclusive with supplier_id) cost: type: number attachments: @@ -13832,16 +13829,6 @@ paths: mime_type: application/pdf - url: 'https://esempio.com/immagini/danno.jpg' mime_type: image/jpeg - Example 2 (New Supplier): - value: - description: Riparazione hardware ufficio - type_id: 3 - cost_id: 2 - new_supplier_name: Nuovo Fornitore SRL - cost: 250.5 - attachments: - - url: 'https://esempio.com/documenti/fattura.pdf' - mime_type: application/pdf servers: - url: 'https://api.app-quality.com' tags: diff --git a/src/schema.ts b/src/schema.ts index 61c2ae705..c5a54a6b3 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5763,10 +5763,7 @@ export interface operations { "application/json": { description: string; type_id: number; - /** @description ID of existing supplier (mutually exclusive with new_supplier_name) */ - supplier_id?: number; - /** @description Name for new supplier (mutually exclusive with supplier_id) */ - new_supplier_name?: string; + supplier_id: number; cost: number; attachments: { url: string; From 36fece47c59f5dd8c37ba345652342c5874a2dd7 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 00:09:59 +0100 Subject: [PATCH 65/78] refactor: simplify supplier validation and remove new_supplier_name handling in other costs patch --- .../finance/otherCosts/_patch/index.spec.ts | 190 ++---------------- .../finance/otherCosts/_patch/index.ts | 71 +------ 2 files changed, 20 insertions(+), 241 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts index a14e13d02..238b4c419 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts @@ -191,8 +191,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); it("Should return 400 if cost_id is missing", async () => { - const payload = { ...validPayload }; - delete (payload as any).cost_id; + const { cost_id, ...payload } = validPayload; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") @@ -238,8 +237,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); it("Should return 400 if description is missing", async () => { - const payload = { ...validPayload }; - delete (payload as any).description; + const { description, ...payload } = validPayload; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") @@ -250,8 +248,18 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); it("Should return 400 if type_id is missing", async () => { - const payload = { ...validPayload }; - delete (payload as any).type_id; + const { type_id, ...payload } = validPayload; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(payload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if supplier_id is missing", async () => { + const { supplier_id, ...payload } = validPayload; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") @@ -262,8 +270,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); it("Should return 400 if cost is missing", async () => { - const payload = { ...validPayload }; - delete (payload as any).cost; + const { cost, ...payload } = validPayload; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") @@ -274,8 +281,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); it("Should return 400 if attachments is missing", async () => { - const payload = { ...validPayload }; - delete (payload as any).attachments; + const { attachments, ...payload } = validPayload; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") @@ -389,57 +395,6 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }) ); }); - - it("Should return 400 if both supplier_id and new_supplier_name are provided", async () => { - await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ - id: 1, - campaign_id: 1, - description: "Original cost", - cost: 100.0, - type_id: 1, - supplier_id: 1, - }); - - const response = await request(app) - .patch("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - supplier_id: 105, - new_supplier_name: "New Supplier", - }) - .set("Authorization", "Bearer admin"); - expect(response.status).toBe(400); - expect(response.body).toEqual( - expect.objectContaining({ - message: "Cannot provide both supplier_id and new_supplier_name", - }) - ); - }); - - it("Should return 400 if neither supplier_id nor new_supplier_name are provided", async () => { - await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ - id: 1, - campaign_id: 1, - description: "Original cost", - cost: 100.0, - type_id: 1, - supplier_id: 1, - }); - - const payload = { ...validPayload }; - delete (payload as any).supplier_id; - - const response = await request(app) - .patch("/campaigns/1/finance/otherCosts") - .send(payload) - .set("Authorization", "Bearer admin"); - expect(response.status).toBe(400); - expect(response.body).toEqual( - expect.objectContaining({ - message: "Either supplier_id or new_supplier_name must be provided", - }) - ); - }); }); describe("Success - admin permissions", () => { @@ -733,85 +688,6 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { expect(getResponse.status).toBe(200); expect(getResponse.body.items).toHaveLength(2); }); - - it("Should create new supplier when new_supplier_name is provided", async () => { - await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ - id: 1, - campaign_id: 1, - description: "Original cost", - cost: 100.0, - type_id: 1, - supplier_id: 1, - }); - - const payload = { - ...validPayload, - new_supplier_name: "Nuovo Fornitore SRL", - }; - delete (payload as any).supplier_id; - - const response = await request(app) - .patch("/campaigns/1/finance/otherCosts") - .send(payload) - .set("Authorization", "Bearer admin"); - expect(response.status).toBe(200); - - const newSupplier = - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .where({ name: "Nuovo Fornitore SRL" }) - .first(); - expect(newSupplier).toBeDefined(); - expect(newSupplier?.name).toBe("Nuovo Fornitore SRL"); - - const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() - .where({ id: 1 }) - .first(); - expect(updatedCost?.supplier_id).toBe(newSupplier?.id); - }); - - it("Should reuse existing supplier if new_supplier_name matches existing name", async () => { - await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ - id: 1, - campaign_id: 1, - description: "Original cost", - cost: 100.0, - type_id: 1, - supplier_id: 1, - }); - - const payload = { - ...validPayload, - new_supplier_name: "Supplier 1", // This already exists in the database - }; - delete (payload as any).supplier_id; - - const initialSupplierCount = - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .count("id") - .as("count") - .first(); - - const response = await request(app) - .patch("/campaigns/1/finance/otherCosts") - .send(payload) - .set("Authorization", "Bearer admin"); - expect(response.status).toBe(200); - - // Verify no new supplier was created - const finalSupplierCount = - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .count("id") - .as("count") - .first(); - expect(Number(finalSupplierCount?.count)).toBe( - Number(initialSupplierCount?.count) - ); - - const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() - .where({ id: 1 }) - .first(); - expect(updatedCost?.supplier_id).toBe(1); // Should use Supplier 1's ID - }); }); describe("Success - olp permissions", () => { @@ -892,40 +768,6 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }) ); }); - - it("Should create new supplier with olp permissions when new_supplier_name is provided", async () => { - await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ - id: 1, - campaign_id: 1, - description: "Original cost", - cost: 100.0, - type_id: 1, - supplier_id: 1, - }); - - const payload = { - ...validPayload, - new_supplier_name: "Fornitore OLP Test", - }; - delete (payload as any).supplier_id; - - const response = await request(app) - .patch("/campaigns/1/finance/otherCosts") - .send(payload) - .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); - expect(response.status).toBe(200); - - const newSupplier = - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .where({ name: "Fornitore OLP Test" }) - .first(); - expect(newSupplier).toBeDefined(); - - const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() - .where({ id: 1 }) - .first(); - expect(updatedCost?.supplier_id).toBe(newSupplier?.id); - }); }); describe("Error Handling", () => { diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts index 74847f16b..b6d2c4430 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -37,42 +37,12 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ return false; } - // Validate supplier: either supplier_id OR new_supplier_name, but not both - const hasSupplier = - body.supplier_id !== undefined && body.supplier_id !== null; - const hasNewSupplierName = - body.new_supplier_name !== undefined && - body.new_supplier_name !== null && - body.new_supplier_name.trim() !== ""; - - if (!hasSupplier && !hasNewSupplierName) { - this.setError( - 400, - new OpenapiError( - "Either supplier_id or new_supplier_name must be provided" - ) - ); + const supplierExists = await this.supplierExists(body.supplier_id); + if (!supplierExists) { + this.setError(404, new OpenapiError("Supplier not found")); return false; } - if (hasSupplier && hasNewSupplierName) { - this.setError( - 400, - new OpenapiError( - "Cannot provide both supplier_id and new_supplier_name" - ) - ); - return false; - } - - if (hasSupplier) { - const supplierExists = await this.supplierExists(body.supplier_id!); - if (!supplierExists) { - this.setError(404, new OpenapiError("Supplier not found")); - return false; - } - } - return true; } @@ -116,22 +86,13 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ ): Promise { await this.deleteExistingAttachments(body.cost_id); - let supplierId: number; - if (body.supplier_id !== undefined && body.supplier_id !== null) { - supplierId = body.supplier_id; - } else if (body.new_supplier_name) { - supplierId = await this.createOrGetSupplier(body.new_supplier_name); - } else { - throw new Error("No supplier information provided"); - } - await tryber.tables.WpAppqCampaignOtherCosts.do() .where({ id: body.cost_id }) .update({ description: body.description, cost: body.cost, type_id: body.type_id, - supplier_id: supplierId, + supplier_id: body.supplier_id, }); if (body.attachments && body.attachments.length > 0) { @@ -139,30 +100,6 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ } } - private async createOrGetSupplier(supplierName: string): Promise { - const existingSupplier = - await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .where({ name: supplierName }) - .first(); - - if (existingSupplier) { - return existingSupplier.id; - } - - const result = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() - .insert({ - name: supplierName, - created_by: this.getWordpressId(), - created_on: tryber.fn.now(), - }) - .returning("id"); - - const id = result[0]?.id ?? result[0]; - if (!id) throw new Error("Error creating supplier"); - - return id; - } - private async deleteExistingAttachments(costId: number): Promise { const attachments = await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() From fba01971c20ac6eeda4b95cc0e649e9cc2eac893 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 09:54:48 +0100 Subject: [PATCH 66/78] refactor: streamline validation checks for cost, type, and supplier in other costs patch --- .../campaignId/finance/otherCosts/_patch/index.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts index b6d2c4430..dd364f669 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -25,20 +25,17 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ return false; } - const costExists = await this.costExistsInCampaign(body.cost_id); - if (!costExists) { + if (!(await this.costExistsInCampaign(body.cost_id))) { this.setError(404, new OpenapiError("Cost not found for this campaign")); return false; } - const typeExists = await this.typeExists(body.type_id); - if (!typeExists) { + if (!(await this.typeExists(body.type_id))) { this.setError(404, new OpenapiError("Type not found")); return false; } - const supplierExists = await this.supplierExists(body.supplier_id); - if (!supplierExists) { + if (!(await this.supplierExists(body.supplier_id))) { this.setError(404, new OpenapiError("Supplier not found")); return false; } From 8181f2e2c712711dc8eb388516a14574ac1bea4a Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 11:25:10 +0100 Subject: [PATCH 67/78] refactor: update attachment handling in other costs patch to streamline deletion and addition --- .../finance/otherCosts/_patch/index.ts | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts index dd364f669..ab14d1b68 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -81,7 +81,7 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ private async updateOtherCost( body: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"] ): Promise { - await this.deleteExistingAttachments(body.cost_id); + await this.updateAttachments(); await tryber.tables.WpAppqCampaignOtherCosts.do() .where({ id: body.cost_id }) @@ -91,22 +91,29 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ type_id: body.type_id, supplier_id: body.supplier_id, }); - - if (body.attachments && body.attachments.length > 0) { - await this.createAttachments(body.cost_id, body.attachments); - } } - private async deleteExistingAttachments(costId: number): Promise { - const attachments = + private async updateAttachments(): Promise { + const { cost_id, attachments } = this.getBody(); + const existingAttachments = await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() - .select("url", "id") - .where({ cost_id: costId }); + .select("id", "url", "mime_type") + .where({ cost_id: cost_id }); + + const existingUrls = existingAttachments.map((a) => a.url); + const newUrls = attachments.map((a) => a.url); + + const attachmentsToDelete = existingAttachments.filter( + (existing) => !newUrls.includes(existing.url) + ); - if (attachments.length > 0) { - for (const attachment of attachments) { + if (attachmentsToDelete.length > 0) { + for (const attachment of attachmentsToDelete) { try { await deleteFromS3({ url: attachment.url }); + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where("id", attachment.id) + .delete(); } catch (e) { console.error( `Error deleting attachment from S3: ${attachment.url}`, @@ -115,26 +122,23 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ throw new Error("Error deleting attachment from S3"); } } - - await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() - .where({ cost_id: costId }) - .delete(); } - } - private async createAttachments( - costId: number, - attachments: { url: string; mime_type: string }[] - ): Promise { - const attachmentsData = attachments.map((attachment) => ({ - cost_id: costId, - url: attachment.url, - mime_type: attachment.mime_type, - })); - - await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert( - attachmentsData + const attachmentsToAdd = attachments.filter( + (newAttachment) => !existingUrls.includes(newAttachment.url) ); + + if (attachmentsToAdd.length > 0) { + const attachmentsData = attachmentsToAdd.map((attachment) => ({ + cost_id: cost_id, + url: attachment.url, + mime_type: attachment.mime_type, + })); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert( + attachmentsData + ); + } } private async getUpdatedCost(costId: number) { From f28a09463ea1400df56f6053ddfb6a3842f43b6e Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 12:09:43 +0100 Subject: [PATCH 68/78] refactor: add validation for attachments in other costs patch to require at least one attachment --- .../campaignId/finance/otherCosts/_patch/index.spec.ts | 9 +++++++++ .../campaignId/finance/otherCosts/_patch/index.ts | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts index 238b4c419..56bb2b697 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts @@ -291,6 +291,15 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { expect(response.body.err).toBeDefined(); }); + it("Should return 400 if attachments is an empty array", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send({ ...validPayload, attachments: [] }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.message).toBe("At least one attachment is required"); + }); + it("Should return 400 if attachments array item is missing url", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts index ab14d1b68..c90f41c22 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -20,6 +20,14 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ const body = this.getBody(); + if (body.attachments.length === 0) { + this.setError( + 400, + new OpenapiError("At least one attachment is required") + ); + return false; + } + if (body.cost_id <= 0) { this.setError(400, new OpenapiError("cost_id must be a positive number")); return false; From 1a82c85381937ade00904ae3c9eadd73cb9b3b8c Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 13:12:40 +0100 Subject: [PATCH 69/78] refactor: update schema to support array of objects for attachments in operations --- src/reference/openapi.yml | 290 +++++++++++++++++++------------------- src/schema.ts | 6 +- 2 files changed, 151 insertions(+), 145 deletions(-) diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 5823a5715..1793dcd36 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -13600,57 +13600,59 @@ paths: content: application/json: schema: - type: object - x-examples: - Example 1: - description: Riparazione hardware ufficio - type_id: 3 - supplier_id: 105 - cost: 250.5 + type: array + items: + type: object + x-examples: + Example 1: + description: Riparazione hardware ufficio + type_id: 3 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg + required: + - description + - type_id + - supplier_id + - cost + - attachments + properties: + description: + type: string + type_id: + type: integer + supplier_id: + type: integer + cost: + type: number attachments: - - url: 'https://esempio.com/documenti/fattura.pdf' - mime_type: application/pdf - - url: 'https://esempio.com/immagini/danno.jpg' - mime_type: image/jpeg - required: - - description - - type_id - - supplier_id - - cost - - attachments - properties: - description: - type: string - type_id: - type: integer - supplier_id: - type: integer - cost: - type: number - attachments: - type: array - items: - type: object - required: - - url - - mime_type - properties: - url: - type: string - mime_type: - type: string + type: array + items: + type: object + required: + - url + - mime_type + properties: + url: + type: string + mime_type: + type: string examples: Example 1: value: - description: Riparazione hardware ufficio - type_id: 3 - supplier_id: 105 - cost: 250.5 - attachments: - - url: 'https://esempio.com/documenti/fattura.pdf' - mime_type: application/pdf - - url: 'https://esempio.com/immagini/danno.jpg' - mime_type: image/jpeg + - description: Riparazione hardware ufficio + type_id: 3 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg security: - JWT: [] delete: @@ -13697,12 +13699,90 @@ paths: content: application/json: schema: + type: array + items: + type: object + x-examples: + Example 1: + description: Riparazione hardware ufficio + type_id: 3 + cost_id: 2 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg + required: + - description + - type + - cost_id + - supplier + - cost + - attachments + properties: + description: + type: string + type: + type: string + x-stoplight: + id: q54ltj77jcyf0 + cost_id: + type: integer + supplier: + type: string + x-stoplight: + id: 5aunsjh1dxfq1 + cost: + type: number + attachments: + type: array + items: + type: object + required: + - url + - mime_type + properties: + url: + type: string + mime_type: + type: string + examples: + Example 1: + value: + - description: description + type: Type 1 + cost_id: 10 + supplier: Supplier + cost: 104 + attachments: + - url: 'https://example.com/' + mime_type: image/jpg + '400': + description: Bad Request + '403': + description: Forbidden + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + operationId: patch-campaigns-campaign-finance-otherCosts + x-stoplight: + id: mwhcb91voxivy + security: + - JWT: [] + requestBody: + content: + application/json: + schema: + type: array + items: type: object x-examples: Example 1: description: Riparazione hardware ufficio type_id: 3 - cost_id: 2 supplier_id: 105 cost: 250.5 attachments: @@ -13712,24 +13792,18 @@ paths: mime_type: image/jpeg required: - description - - type - - cost_id - - supplier + - type_id + - supplier_id - cost - attachments + - cost_id properties: description: type: string - type: - type: string - x-stoplight: - id: q54ltj77jcyf0 - cost_id: + type_id: + type: integer + supplier_id: type: integer - supplier: - type: string - x-stoplight: - id: 5aunsjh1dxfq1 cost: type: number attachments: @@ -13744,91 +13818,23 @@ paths: type: string mime_type: type: string - examples: - Example 1: - value: - description: description - type: Type 1 - cost_id: 10 - supplier: Supplier - cost: 104 - attachments: - - url: 'https://example.com/' - mime_type: image/jpg - '400': - description: Bad Request - '403': - description: Forbidden - '404': - $ref: '#/components/responses/NotFound' - '500': - description: Internal Server Error - operationId: patch-campaigns-campaign-finance-otherCosts - x-stoplight: - id: mwhcb91voxivy - security: - - JWT: [] - requestBody: - content: - application/json: - schema: - type: object - x-examples: - Example 1: - description: Riparazione hardware ufficio - type_id: 3 - supplier_id: 105 - cost: 250.5 - attachments: - - url: 'https://esempio.com/documenti/fattura.pdf' - mime_type: application/pdf - - url: 'https://esempio.com/immagini/danno.jpg' - mime_type: image/jpeg - required: - - description - - type_id - - supplier_id - - cost - - attachments - - cost_id - properties: - description: - type: string - type_id: - type: integer - supplier_id: - type: integer - cost: - type: number - attachments: - type: array - items: - type: object - required: - - url - - mime_type - properties: - url: - type: string - mime_type: - type: string - cost_id: - type: integer - x-stoplight: - id: drnv0dayw8k18 + cost_id: + type: integer + x-stoplight: + id: drnv0dayw8k18 examples: Example 1: value: - description: Riparazione hardware ufficio - type_id: 3 - cost_id: 2 - supplier_id: 105 - cost: 250.5 - attachments: - - url: 'https://esempio.com/documenti/fattura.pdf' - mime_type: application/pdf - - url: 'https://esempio.com/immagini/danno.jpg' - mime_type: image/jpeg + - description: Riparazione hardware ufficio + type_id: 3 + cost_id: 2 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg servers: - url: 'https://api.app-quality.com' tags: diff --git a/src/schema.ts b/src/schema.ts index c5a54a6b3..eb8245458 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -5697,7 +5697,7 @@ export interface operations { url: string; mime_type: string; }[]; - }; + }[]; }; }; }; @@ -5747,7 +5747,7 @@ export interface operations { url: string; mime_type: string; }[]; - }; + }[]; }; }; /** Bad Request */ @@ -5770,7 +5770,7 @@ export interface operations { mime_type: string; }[]; cost_id: number; - }; + }[]; }; }; }; From a198b866151e572da441187af91e4fa31f6cb571 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 13:12:54 +0100 Subject: [PATCH 70/78] refactor: update other costs routes to accept an array of cost items with enhanced validation --- .../finance/otherCosts/_patch/index.ts | 105 +++++++++++----- .../finance/otherCosts/_post/index.ts | 114 +++++++++++------- 2 files changed, 143 insertions(+), 76 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts index c90f41c22..71458012b 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -5,6 +5,15 @@ import { tryber } from "@src/features/database"; import OpenapiError from "@src/features/OpenapiError"; import deleteFromS3 from "@src/features/deleteFromS3"; +type OtherCostItem = { + cost_id: number; + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { url: string; mime_type: string }[]; +}; + export default class OtherCostsPatchRoute extends CampaignRoute<{ response: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["responses"]["200"]["content"]["application/json"]; parameters: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; @@ -20,32 +29,60 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ const body = this.getBody(); - if (body.attachments.length === 0) { + if (!Array.isArray(body) || body.length === 0) { this.setError( 400, - new OpenapiError("At least one attachment is required") + new OpenapiError("Body must be a non-empty array of cost items") ); return false; } - if (body.cost_id <= 0) { - this.setError(400, new OpenapiError("cost_id must be a positive number")); - return false; - } + for (const item of body) { + if (item.attachments.length === 0) { + this.setError( + 400, + new OpenapiError( + `Item ${item.cost_id}: At least one attachment is required` + ) + ); + return false; + } - if (!(await this.costExistsInCampaign(body.cost_id))) { - this.setError(404, new OpenapiError("Cost not found for this campaign")); - return false; - } + if (item.cost_id <= 0) { + this.setError( + 400, + new OpenapiError( + `Item ${item.cost_id}: cost_id must be a positive number` + ) + ); + return false; + } - if (!(await this.typeExists(body.type_id))) { - this.setError(404, new OpenapiError("Type not found")); - return false; - } + if (!(await this.costExistsInCampaign(item.cost_id))) { + this.setError( + 404, + new OpenapiError( + `Item ${item.cost_id}: Cost not found for this campaign` + ) + ); + return false; + } - if (!(await this.supplierExists(body.supplier_id))) { - this.setError(404, new OpenapiError("Supplier not found")); - return false; + if (!(await this.typeExists(item.type_id))) { + this.setError( + 404, + new OpenapiError(`Item ${item.cost_id}: Type not found`) + ); + return false; + } + + if (!(await this.supplierExists(item.supplier_id))) { + this.setError( + 404, + new OpenapiError(`Item ${item.cost_id}: Supplier not found`) + ); + return false; + } } return true; @@ -54,14 +91,18 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ protected async prepare(): Promise { try { const body = this.getBody(); - await this.updateOtherCost(body); + const updatedCosts = []; - const updatedCost = await this.getUpdatedCost(body.cost_id); + for (const item of body) { + await this.updateOtherCost(item); + const updatedCost = await this.getUpdatedCost(item.cost_id); + updatedCosts.push(updatedCost); + } - return this.setSuccess(200, updatedCost); + return this.setSuccess(200, updatedCosts); } catch (e) { - console.error("Error updating other cost: ", e); - return this.setError(500, new OpenapiError("Error updating other cost")); + console.error("Error updating other costs: ", e); + return this.setError(500, new OpenapiError("Error updating other costs")); } } @@ -86,23 +127,21 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ return supplier !== undefined; } - private async updateOtherCost( - body: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"] - ): Promise { - await this.updateAttachments(); + private async updateOtherCost(item: OtherCostItem): Promise { + await this.updateAttachments(item); await tryber.tables.WpAppqCampaignOtherCosts.do() - .where({ id: body.cost_id }) + .where({ id: item.cost_id }) .update({ - description: body.description, - cost: body.cost, - type_id: body.type_id, - supplier_id: body.supplier_id, + description: item.description, + cost: item.cost, + type_id: item.type_id, + supplier_id: item.supplier_id, }); } - private async updateAttachments(): Promise { - const { cost_id, attachments } = this.getBody(); + private async updateAttachments(item: OtherCostItem): Promise { + const { cost_id, attachments } = item; const existingAttachments = await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() .select("id", "url", "mime_type") diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts index 537d1d4ca..d65478f94 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts @@ -4,6 +4,14 @@ import CampaignRoute from "@src/features/routes/CampaignRoute"; import { tryber } from "@src/features/database"; import OpenapiError from "@src/features/OpenapiError"; +type OtherCostItem = { + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { url: string; mime_type: string }[]; +}; + export default class OtherCostsPostRoute extends CampaignRoute<{ response: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["responses"]["201"]; parameters: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; @@ -19,51 +27,70 @@ export default class OtherCostsPostRoute extends CampaignRoute<{ const body = this.getBody(); - // Validate description - if (!body.description || body.description.trim() === "") { - this.setError(400, new OpenapiError("Description should not be empty")); + if (!Array.isArray(body) || body.length === 0) { + this.setError( + 400, + new OpenapiError("Body must be a non-empty array of cost items") + ); return false; } - // Validate cost - if (body.cost <= 0) { - this.setError(400, new OpenapiError("Cost must be greater than 0")); - return false; - } + for (const item of body) { + const i = body.indexOf(item); - // Validate type_id exists - if (!(await this.typeExists(body.type_id))) { - this.setError(400, new OpenapiError("Type not found")); - return false; - } + if (!item.description || item.description.trim() === "") { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Description should not be empty`) + ); + return false; + } - // Validate supplier_id exists - if (!(await this.supplierExists(body.supplier_id))) { - this.setError(400, new OpenapiError("Supplier not found")); - return false; - } + if (item.cost <= 0) { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Cost must be greater than 0`) + ); + return false; + } - // Validate attachments - if (!body.attachments || body.attachments.length === 0) { - this.setError( - 400, - new OpenapiError("At least one attachment is required") - ); - return false; - } + if (!(await this.typeExists(item.type_id))) { + this.setError(400, new OpenapiError(`Item ${i + 1}: Type not found`)); + return false; + } - for (const attachment of body.attachments) { - if (!attachment.url || attachment.url.trim() === "") { - this.setError(400, new OpenapiError("Attachment URL is required")); + if (!(await this.supplierExists(item.supplier_id))) { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Supplier not found`) + ); return false; } - if (!attachment.mime_type || attachment.mime_type.trim() === "") { + + if (!item.attachments || item.attachments.length === 0) { this.setError( 400, - new OpenapiError("Attachment mime_type is required") + new OpenapiError(`Item ${i + 1}: At least one attachment is required`) ); return false; } + + for (const attachment of item.attachments) { + if (!attachment.url || attachment.url.trim() === "") { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Attachment URL is required`) + ); + return false; + } + if (!attachment.mime_type || attachment.mime_type.trim() === "") { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Attachment mime_type is required`) + ); + return false; + } + } } return true; @@ -72,26 +99,27 @@ export default class OtherCostsPostRoute extends CampaignRoute<{ protected async prepare(): Promise { try { const body = this.getBody(); - const costId = await this.createOtherCost(body); - await this.createAttachments(costId, body.attachments); - return this.setSuccess(201, undefined); + for (const item of body) { + const costId = await this.createOtherCost(item); + await this.createAttachments(costId, item.attachments); + } + + return this.setSuccess(201, {}); } catch (e) { - console.error("Error creating other cost: ", e); - return this.setError(500, new OpenapiError("Error creating other cost")); + console.error("Error creating other costs: ", e); + return this.setError(500, new OpenapiError("Error creating other costs")); } } - private async createOtherCost( - body: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"] - ): Promise { + private async createOtherCost(item: OtherCostItem): Promise { const result = await tryber.tables.WpAppqCampaignOtherCosts.do() .insert({ campaign_id: this.cp_id, - description: body.description, - cost: body.cost, - type_id: body.type_id, - supplier_id: body.supplier_id, + description: item.description, + cost: item.cost, + type_id: item.type_id, + supplier_id: item.supplier_id, }) .returning("id"); From fd5866e436894f5d12011b2a6b6315820240d2c4 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Tue, 3 Feb 2026 13:46:52 +0100 Subject: [PATCH 71/78] refactor: update other costs routes to accept an array of cost items with improved validation and error handling --- .../finance/otherCosts/_patch/index.spec.ts | 222 ++++++++++++----- .../finance/otherCosts/_patch/index.ts | 20 +- .../finance/otherCosts/_post/index.spec.ts | 235 ++++++++++++------ 3 files changed, 320 insertions(+), 157 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts index 56bb2b697..7df3e00a0 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts @@ -108,23 +108,25 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { await tryber.tables.WpAppqEvdProfile.do().delete(); }); - const validPayload = { - description: "Riparazione hardware ufficio", - type_id: 3, - cost_id: 1, - supplier_id: 105, - cost: 250.5, - attachments: [ - { - url: "https://esempio.com/documenti/fattura.pdf", - mime_type: "application/pdf", - }, - { - url: "https://esempio.com/immagini/danno.jpg", - mime_type: "image/jpeg", - }, - ], - }; + const validPayload = [ + { + description: "Riparazione hardware ufficio", + type_id: 3, + cost_id: 1, + supplier_id: 105, + cost: 250.5, + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }, + ]; describe("Authentication and Authorization", () => { beforeEach(async () => { @@ -190,12 +192,32 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { }); }); + it("Should return 400 if body is not an array", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload[0]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if array is empty", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.message).toBe( + "Body must be a non-empty array of cost items" + ); + }); + it("Should return 400 if cost_id is missing", async () => { - const { cost_id, ...payload } = validPayload; + const { cost_id, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); @@ -204,7 +226,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if cost_id is null", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost_id: null }) + .send([{ ...validPayload[0], cost_id: null }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); @@ -213,12 +235,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if cost_id is zero", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost_id: 0 }) + .send([{ ...validPayload[0], cost_id: 0 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "cost_id must be a positive number", + message: "Item 1: cost_id must be a positive number", }) ); }); @@ -226,66 +248,66 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if cost_id is negative", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost_id: -1 }) + .send([{ ...validPayload[0], cost_id: -1 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "cost_id must be a positive number", + message: "Item 1: cost_id must be a positive number", }) ); }); it("Should return 400 if description is missing", async () => { - const { description, ...payload } = validPayload; + const { description, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); }); it("Should return 400 if type_id is missing", async () => { - const { type_id, ...payload } = validPayload; + const { type_id, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); }); it("Should return 400 if supplier_id is missing", async () => { - const { supplier_id, ...payload } = validPayload; + const { supplier_id, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); }); it("Should return 400 if cost is missing", async () => { - const { cost, ...payload } = validPayload; + const { cost, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); }); it("Should return 400 if attachments is missing", async () => { - const { attachments, ...payload } = validPayload; + const { attachments, ...item } = validPayload[0]; const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send(payload) + .send([item]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); @@ -294,19 +316,23 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if attachments is an empty array", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, attachments: [] }) + .send([{ ...validPayload[0], attachments: [] }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); - expect(response.body.message).toBe("At least one attachment is required"); + expect(response.body.message).toBe( + "Item 1: At least one attachment is required" + ); }); it("Should return 400 if attachments array item is missing url", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [{ mime_type: "application/pdf" }], - }) + .send([ + { + ...validPayload[0], + attachments: [{ mime_type: "application/pdf" }], + }, + ]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); @@ -315,10 +341,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if attachments array item is missing mime_type", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [{ url: "https://example.com/file.pdf" }], - }) + .send([ + { + ...validPayload[0], + attachments: [{ url: "https://example.com/file.pdf" }], + }, + ]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body.err).toBeDefined(); @@ -329,12 +357,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { it("Should return 404 if cost_id does not exist", async () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost_id: 999 }) + .send([{ ...validPayload[0], cost_id: 999 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(404); expect(response.body).toEqual( expect.objectContaining({ - message: "Cost not found for this campaign", + message: "Item 1: Cost not found for this campaign", }) ); }); @@ -351,12 +379,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost_id: 10 }) + .send([{ ...validPayload[0], cost_id: 10 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(404); expect(response.body).toEqual( expect.objectContaining({ - message: "Cost not found for this campaign", + message: "Item 1: Cost not found for this campaign", }) ); }); @@ -373,12 +401,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, type_id: 999 }) + .send([{ ...validPayload[0], type_id: 999 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(404); expect(response.body).toEqual( expect.objectContaining({ - message: "Type not found", + message: "Item 1: Type not found", }) ); }); @@ -395,12 +423,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, supplier_id: 999 }) + .send([{ ...validPayload[0], supplier_id: 999 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(404); expect(response.body).toEqual( expect.objectContaining({ - message: "Supplier not found", + message: "Item 1: Supplier not found", }) ); }); @@ -422,6 +450,15 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { .send(validPayload) .set("Authorization", "Bearer admin"); expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toEqual( + expect.objectContaining({ + cost_id: 1, + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() .where({ id: 1 }) @@ -469,6 +506,8 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { .send(validPayload) .set("Authorization", "Bearer admin"); expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); const attachments = await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() @@ -633,16 +672,12 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { const response = await request(app) .patch("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, attachments: [] }) + .send([{ ...validPayload[0], attachments: [] }]) .set("Authorization", "Bearer admin"); - expect(response.status).toBe(200); - - const attachments = - await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() - .where({ cost_id: 1 }) - .select(); - expect(attachments).toHaveLength(0); - expect(deleteFromS3).toHaveBeenCalledTimes(1); + expect(response.status).toBe(400); + expect(response.body.message).toBe( + "Item 1: At least one attachment is required" + ); }); it("Should only update specified cost, not others", async () => { @@ -670,6 +705,8 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { .send(validPayload) .set("Authorization", "Bearer admin"); expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() .where({ id: 1 }) @@ -697,6 +734,63 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { expect(getResponse.status).toBe(200); expect(getResponse.body.items).toHaveLength(2); }); + + it("Should update multiple costs in single request", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "First cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Second cost", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([ + validPayload[0], + { + ...validPayload[0], + cost_id: 2, + description: "Updated second cost", + cost: 300.0, + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + + const cost1 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(cost1).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); + + const cost2 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 2 }) + .first(); + expect(cost2).toEqual( + expect.objectContaining({ + description: "Updated second cost", + cost: 300.0, + }) + ); + }); }); describe("Success - olp permissions", () => { @@ -715,6 +809,8 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { .send(validPayload) .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() .where({ id: 1 }) @@ -757,6 +853,8 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { .send(validPayload) .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); const attachments = await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() @@ -808,7 +906,7 @@ describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { expect(response.status).toBe(500); expect(response.body).toEqual( expect.objectContaining({ - message: "Error updating other cost", + message: "Error updating other costs", }) ); }); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts index 71458012b..76a9e3246 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -38,12 +38,11 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ } for (const item of body) { + const i = body.indexOf(item); if (item.attachments.length === 0) { this.setError( 400, - new OpenapiError( - `Item ${item.cost_id}: At least one attachment is required` - ) + new OpenapiError(`Item ${i + 1}: At least one attachment is required`) ); return false; } @@ -51,9 +50,7 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ if (item.cost_id <= 0) { this.setError( 400, - new OpenapiError( - `Item ${item.cost_id}: cost_id must be a positive number` - ) + new OpenapiError(`Item ${i + 1}: cost_id must be a positive number`) ); return false; } @@ -61,25 +58,20 @@ export default class OtherCostsPatchRoute extends CampaignRoute<{ if (!(await this.costExistsInCampaign(item.cost_id))) { this.setError( 404, - new OpenapiError( - `Item ${item.cost_id}: Cost not found for this campaign` - ) + new OpenapiError(`Item ${i + 1}: Cost not found for this campaign`) ); return false; } if (!(await this.typeExists(item.type_id))) { - this.setError( - 404, - new OpenapiError(`Item ${item.cost_id}: Type not found`) - ); + this.setError(404, new OpenapiError(`Item ${i + 1}: Type not found`)); return false; } if (!(await this.supplierExists(item.supplier_id))) { this.setError( 404, - new OpenapiError(`Item ${item.cost_id}: Supplier not found`) + new OpenapiError(`Item ${i + 1}: Supplier not found`) ); return false; } diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts index ce842050b..2b0294d73 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts @@ -102,22 +102,24 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { await tryber.tables.WpAppqEvdProfile.do().delete(); }); - const validPayload = { - description: "Riparazione hardware ufficio", - type_id: 3, - supplier_id: 105, - cost: 250.5, - attachments: [ - { - url: "https://esempio.com/documenti/fattura.pdf", - mime_type: "application/pdf", - }, - { - url: "https://esempio.com/immagini/danno.jpg", - mime_type: "image/jpeg", - }, - ], - }; + const validPayload = [ + { + description: "Riparazione hardware ufficio", + type_id: 3, + supplier_id: 105, + cost: 250.5, + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }, + ]; describe("Not enough permissions", () => { it("Should return 403 if logged out", async () => { @@ -145,15 +147,37 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { }); describe("Validation errors", () => { + it("Should return 400 if body is not an array", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload[0]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if body is an empty array", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Body must be a non-empty array of cost items", + }) + ); + }); + it("Should return 400 if description is empty", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, description: "" }) + .send([{ ...validPayload[0], description: "" }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Description should not be empty", + message: "Item 1: Description should not be empty", }) ); }); @@ -161,12 +185,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if description is only whitespace", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, description: " " }) + .send([{ ...validPayload[0], description: " " }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Description should not be empty", + message: "Item 1: Description should not be empty", }) ); }); @@ -174,12 +198,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if cost is 0", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost: 0 }) + .send([{ ...validPayload[0], cost: 0 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Cost must be greater than 0", + message: "Item 1: Cost must be greater than 0", }) ); }); @@ -187,12 +211,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if cost is negative", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost: -10 }) + .send([{ ...validPayload[0], cost: -10 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Cost must be greater than 0", + message: "Item 1: Cost must be greater than 0", }) ); }); @@ -200,12 +224,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if type_id does not exist", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, type_id: 999 }) + .send([{ ...validPayload[0], type_id: 999 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Type not found", + message: "Item 1: Type not found", }) ); }); @@ -213,12 +237,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if supplier_id does not exist", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, supplier_id: 999 }) + .send([{ ...validPayload[0], supplier_id: 999 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Supplier not found", + message: "Item 1: Supplier not found", }) ); }); @@ -226,12 +250,12 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if attachments array is empty", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, attachments: [] }) + .send([{ ...validPayload[0], attachments: [] }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "At least one attachment is required", + message: "Item 1: At least one attachment is required", }) ); }); @@ -239,20 +263,22 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if attachment url is empty", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [ - { - url: "", - mime_type: "application/pdf", - }, - ], - }) + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "", + mime_type: "application/pdf", + }, + ], + }, + ]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Attachment URL is required", + message: "Item 1: Attachment URL is required", }) ); }); @@ -260,20 +286,35 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should return 400 if attachment mime_type is empty", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [ - { - url: "https://esempio.com/documenti/fattura.pdf", - mime_type: "", - }, - ], + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "", + }, + ], + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Attachment mime_type is required", }) + ); + }); + + it("Should return 400 for second item with invalid data", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([validPayload[0], { ...validPayload[0], description: "" }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(400); expect(response.body).toEqual( expect.objectContaining({ - message: "Attachment mime_type is required", + message: "Item 2: Description should not be empty", }) ); }); @@ -338,15 +379,17 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should create cost with single attachment", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [ - { - url: "https://esempio.com/documenti/fattura.pdf", - mime_type: "application/pdf", - }, - ], - }) + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + ], + }, + ]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(201); @@ -360,23 +403,25 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { it("Should create cost with multiple attachments", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - attachments: [ - { - url: "https://esempio.com/documenti/fattura1.pdf", - mime_type: "application/pdf", - }, - { - url: "https://esempio.com/documenti/fattura2.pdf", - mime_type: "application/pdf", - }, - { - url: "https://esempio.com/immagini/danno.jpg", - mime_type: "image/jpeg", - }, - ], - }) + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "https://esempio.com/documenti/fattura1.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/documenti/fattura2.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }, + ]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(201); @@ -396,11 +441,13 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { const response2 = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ - ...validPayload, - description: "Second cost", - cost: 100.0, - }) + .send([ + { + ...validPayload[0], + description: "Second cost", + cost: 100.0, + }, + ]) .set("Authorization", "Bearer admin"); expect(response2.status).toBe(201); @@ -411,10 +458,36 @@ describe("POST /campaigns/campaignId/finance/otherCosts", () => { expect(getResponse.body.items).toHaveLength(2); }); + it("Should create multiple costs in single request", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([ + validPayload[0], + { + ...validPayload[0], + description: "Second cost", + cost: 100.0, + }, + { + ...validPayload[0], + description: "Third cost", + cost: 150.0, + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(3); + }); + it("Should accept decimal cost values", async () => { const response = await request(app) .post("/campaigns/1/finance/otherCosts") - .send({ ...validPayload, cost: 123.456 }) + .send([{ ...validPayload[0], cost: 123.456 }]) .set("Authorization", "Bearer admin"); expect(response.status).toBe(201); 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 72/78] 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 73/78] 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 74/78] 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 75/78] 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 76/78] 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 { From 59a75b31a0555ddaeb8ec52bd8e3b7213b53b77a Mon Sep 17 00:00:00 2001 From: Kariamos Date: Wed, 4 Feb 2026 15:49:20 +0100 Subject: [PATCH 77/78] chore: update @appquality/tryber-database to version 0.46.20 in package.json and package-lock.json --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d8c15a6e2..5b9a210f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@appquality/tryber-database": "^0.46.18", + "@appquality/tryber-database": "^0.46.20", "@appquality/wp-auth": "^1.0.7", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/hash-node": "^3.374.0", @@ -108,9 +108,9 @@ } }, "node_modules/@appquality/tryber-database": { - "version": "0.46.18", - "resolved": "https://registry.npmjs.org/@appquality/tryber-database/-/tryber-database-0.46.18.tgz", - "integrity": "sha512-7G9GIX3gpWCJJpo/DgdrSXRu/Jm/MJnbhglT9q0SoemEQQPZwSREFnxbGXxqg77URTeG7iNHXRzH1DXSkCVmSg==", + "version": "0.46.20", + "resolved": "https://registry.npmjs.org/@appquality/tryber-database/-/tryber-database-0.46.20.tgz", + "integrity": "sha512-1lW6F6wqNni1AZTMPA1WnaozikUXB3mnGHP2XkXwOZ2YP1nsl6BCFoFqHgiPakd2bVzZHyFCQnwv6VNXg1Q+qQ==", "license": "ISC", "dependencies": { "better-sqlite3": "^12.5.0", diff --git a/package.json b/package.json index 9a1103eb5..b381c482c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "author": "", "license": "ISC", "dependencies": { - "@appquality/tryber-database": "^0.46.18", + "@appquality/tryber-database": "^0.46.20", "@appquality/wp-auth": "^1.0.7", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/hash-node": "^3.374.0", From 415c9aa3be73d0e0f7a7e940d416cdc1f9e14ab5 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Thu, 5 Feb 2026 12:04:11 +0100 Subject: [PATCH 78/78] feat: update attachment deletion logic to ensure database records are removed after S3 deletion --- .../campaignId/finance/otherCosts/_delete/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts index 45234b047..754e6eca3 100644 --- a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts @@ -63,6 +63,9 @@ export default class OtherCostsDeleteRoute extends CampaignRoute<{ for (const attachment of attachments) { try { await deleteFromS3({ url: attachment.url }); + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ id: attachment.id }) + .delete(); } catch (e) { console.error( `Error deleting attachment from S3: ${attachment.url}`, @@ -70,9 +73,6 @@ export default class OtherCostsDeleteRoute extends CampaignRoute<{ ); throw new Error("Error deleting attachment from S3"); } - await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() - .where({ id: attachment.id }) - .delete(); } }