Skip to content

Commit a620eb4

Browse files
Merge pull request #2003 from openkfw/1955-updatewf-v2
1955 update workflow
2 parents 283e98a + e959071 commit a620eb4

File tree

11 files changed

+319
-64
lines changed

11 files changed

+319
-64
lines changed

api/src/handlerUtils.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { MultipartFile } from "@fastify/multipart";
2+
13
import { AuthenticatedRequest } from "./httpd/lib";
24
import { ServiceUser } from "./service/domain/organization/service_user";
35

@@ -10,3 +12,58 @@ export const extractUser = (request: AuthenticatedRequest): ServiceUser => {
1012
};
1113
return user;
1214
};
15+
16+
export const parseMultiPartFile = async (part: MultipartFile): Promise<any> => {
17+
const id = "";
18+
const buffer = await part.toBuffer();
19+
// TODO downstream functionality expects base64, but we should work with buffer directly in the future
20+
const base64 = buffer.toString("base64");
21+
const fileName = part.filename;
22+
return { id, base64, fileName };
23+
};
24+
25+
export const parseMultiPartRequest = async (request: AuthenticatedRequest): Promise<any> => {
26+
let data = {};
27+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
28+
let uploadedDocuments: any[] = [];
29+
const parts = request.parts();
30+
for await (const part of parts) {
31+
if (part.type === "file") {
32+
uploadedDocuments.push(await parseMultiPartFile(part));
33+
} else {
34+
switch (true) {
35+
case part.fieldname.includes("link"): {
36+
uploadedDocuments.push(JSON.parse(part.value as string));
37+
break;
38+
}
39+
case part.fieldname.includes("comment_"): {
40+
const index = parseInt(part.fieldname.split("_")[1]);
41+
uploadedDocuments[index].comment = part.value;
42+
break;
43+
}
44+
case part.fieldname === "apiVersion": {
45+
break;
46+
}
47+
case part.fieldname === "tags": {
48+
if (part.value === "") {
49+
data[part.fieldname] = [];
50+
} else {
51+
data[part.fieldname] = (part.value as string).split(",");
52+
}
53+
break;
54+
}
55+
case part.value === "null":
56+
data[part.fieldname] = undefined;
57+
break;
58+
case part.value === "undefined":
59+
data[part.fieldname] = undefined;
60+
break;
61+
default:
62+
data[part.fieldname] = part.value;
63+
break;
64+
}
65+
}
66+
}
67+
data["documents"] = uploadedDocuments;
68+
return data;
69+
};

api/src/httpd/server.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,6 @@ export const createBasicApp = (
254254

255255
server.register(fastifyMultipart, {
256256
limits: { fileSize: MAX_DOCUMENT_SIZE_BINARY },
257-
// routes that use Multipart Form:
258-
prefix: "/v2/subproject.createWorkflowitem",
259257
});
260258

261259
return server;

api/src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ import * as WorkflowitemPermissionGrantAPI from "./workflowitem_permission_grant
160160
import * as WorkflowitemPermissionRevokeAPI from "./workflowitem_permission_revoke";
161161
import * as WorkflowitemPermissionsListAPI from "./workflowitem_permissions_list";
162162
import * as WorkflowitemUpdateAPI from "./workflowitem_update";
163+
import * as WorkflowitemUpdateV2API from "./workflowitem_update.v2";
163164
import * as WorkflowitemValidateDocumentAPI from "./workflowitem_validate_document";
164165
import * as WorkflowitemViewDetailsAPI from "./workflowitem_view_details";
165166
import * as WorkflowitemViewHistoryAPI from "./workflowitem_view_history";
@@ -930,6 +931,20 @@ WorkflowitemUpdateAPI.addHttpHandler(server, URL_PREFIX, {
930931
),
931932
});
932933

934+
WorkflowitemUpdateV2API.addHttpHandler(server, URL_PREFIX, {
935+
updateWorkflowitem: (ctx, user, projectId, subprojectId, workflowitemId, data) =>
936+
WorkflowitemUpdateService.updateWorkflowitem(
937+
db,
938+
storageServiceClient,
939+
ctx,
940+
user,
941+
projectId,
942+
subprojectId,
943+
workflowitemId,
944+
data,
945+
),
946+
});
947+
933948
WorkflowitemValidateDocumentAPI.addHttpHandler(server, URL_PREFIX, {
934949
matches: (
935950
documentBase64: string,

api/src/user_permission_grant.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { ServiceUser } from "./service/domain/organization/service_user";
1313
import * as UserRecord from "./service/domain/organization/user_record";
1414
import { AugmentedFastifyInstance } from "./types";
1515

16-
1716
/**
1817
* Represents the request body of the endpoint
1918
*/

api/src/workflowitem_create.v2.ts

Lines changed: 1 addition & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { MultipartFile } from "@fastify/multipart";
21
import Joi = require("joi");
32
import { VError } from "verror";
43

5-
import { extractUser } from "./handlerUtils";
4+
import { extractUser, parseMultiPartRequest } from "./handlerUtils";
65
import { toHttpError } from "./http_errors";
76
import * as NotAuthenticated from "./http_errors/not_authenticated";
87
import { AuthenticatedRequest } from "./httpd/lib";
@@ -21,49 +20,6 @@ import Type, { workflowitemTypeSchema } from "./service/domain/workflowitem_type
2120
import * as WorkflowitemCreate from "./service/workflowitem_create";
2221
import { AugmentedFastifyInstance } from "./types";
2322

24-
const parseMultiPartFile = async (part: MultipartFile): Promise<any> => {
25-
const id = "";
26-
const buffer = await part.toBuffer();
27-
// TODO downstream functionality expects base64, but we should work with buffer directly in the future
28-
const base64 = buffer.toString("base64");
29-
const fileName = part.filename;
30-
return { id, base64, fileName };
31-
};
32-
33-
const parseMultiPartRequest = async (request: AuthenticatedRequest): Promise<any> => {
34-
let data = {};
35-
let uploadedDocuments: any[] = [];
36-
const parts = request.parts();
37-
for await (const part of parts) {
38-
if (part.type === "file") {
39-
uploadedDocuments.push(await parseMultiPartFile(part));
40-
} else {
41-
if (part.fieldname.includes("comment_")) {
42-
const index = parseInt(part.fieldname.split("_")[1]);
43-
uploadedDocuments[index].comment = part.value;
44-
continue;
45-
}
46-
if (part.fieldname === "apiVersion") {
47-
continue;
48-
} else if (part.fieldname === "tags") {
49-
if (part.value === "") {
50-
data[part.fieldname] = [];
51-
} else {
52-
data[part.fieldname] = (part.value as string).split(",");
53-
}
54-
continue;
55-
}
56-
if (part.value === "null") {
57-
data[part.fieldname] = undefined;
58-
continue;
59-
}
60-
data[part.fieldname] = part.value;
61-
}
62-
}
63-
data["documents"] = uploadedDocuments;
64-
return data;
65-
};
66-
6723
/**
6824
* Represents the request body of the endpoint
6925
*/

api/src/workflowitem_update.v2.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import Joi = require("joi");
2+
import { VError } from "verror";
3+
4+
import { extractUser, parseMultiPartRequest } from "./handlerUtils";
5+
import { toHttpError } from "./http_errors";
6+
import * as NotAuthenticated from "./http_errors/not_authenticated";
7+
import { AuthenticatedRequest } from "./httpd/lib";
8+
import { Ctx } from "./lib/ctx";
9+
import * as Result from "./result";
10+
import { UploadedDocumentOrLink, uploadedDocumentSchema } from "./service/domain/document/document";
11+
import * as Project from "./service/domain/workflow/project";
12+
import * as Subproject from "./service/domain/workflow/subproject";
13+
import * as Workflowitem from "./service/domain/workflow/workflowitem";
14+
import * as WorkflowitemUpdated from "./service/domain/workflow/workflowitem_updated";
15+
import { AugmentedFastifyInstance } from "./types";
16+
17+
import { WorkflowitemUpdateServiceInterface } from "./index";
18+
19+
/**
20+
* Represents the request body of the endpoint
21+
*/
22+
interface UpdateWorkflowV2RequestBody {
23+
apiVersion: "2.0";
24+
data: {
25+
projectId: Project.Id;
26+
subprojectId: Subproject.Id;
27+
workflowitemId: Workflowitem.Id;
28+
displayName?: string;
29+
description?: string;
30+
amountType?: "N/A" | "disbursed" | "allocated";
31+
amount?: string;
32+
currency?: string;
33+
exchangeRate?: string;
34+
billingDate?: string;
35+
dueDate?: string;
36+
documents?: UploadedDocumentOrLink[];
37+
additionalData?: object;
38+
tags?: string[];
39+
};
40+
}
41+
42+
const requestBodyV2Schema = Joi.object({
43+
apiVersion: Joi.valid("2.0").required(),
44+
data: Joi.object({
45+
projectId: Project.idSchema.required(),
46+
subprojectId: Subproject.idSchema.required(),
47+
workflowitemId: Workflowitem.idSchema.required(),
48+
})
49+
.concat(WorkflowitemUpdated.modificationSchema)
50+
.keys({ documents: Joi.array().items(uploadedDocumentSchema) })
51+
.required(),
52+
});
53+
54+
type RequestBody = UpdateWorkflowV2RequestBody;
55+
const requestBodySchema = Joi.alternatives([requestBodyV2Schema]);
56+
57+
/**
58+
* Validates the request body of the http request
59+
*
60+
* @param body the request body
61+
* @returns the request body wrapped in a {@link Result.Type}. Contains either the object or an error
62+
*/
63+
function validateRequestBody(body: unknown): Result.Type<RequestBody> {
64+
const { error, value } = requestBodySchema.validate(body);
65+
return !error ? value : error;
66+
}
67+
68+
/**
69+
* Creates the swagger schema for the `/v2/workflowitem.update` endpoint
70+
*
71+
* @param server fastify server
72+
* @returns the swagger schema for this endpoint
73+
*/
74+
function mkSwaggerSchema(server: AugmentedFastifyInstance): Object {
75+
return {
76+
preValidation: [server.authenticate],
77+
schema: {
78+
description:
79+
"Partially update a workflowitem. Only properties mentioned in the request body are touched, " +
80+
"others are not affected. The assigned user will be notified about the change.\n" +
81+
"Note that the only possible values for 'amountType' are: 'disbursed', 'allocated', 'N/A'\n.\n" +
82+
"The only possible values for 'status' are: 'open' and 'closed'",
83+
tags: ["workflowitem"],
84+
summary: "Update a workflowitem",
85+
security: [
86+
{
87+
bearerToken: [],
88+
},
89+
],
90+
response: {
91+
200: {
92+
description: "successful response",
93+
type: "object",
94+
properties: {
95+
apiVersion: { type: "string", example: "1.0" },
96+
data: {
97+
type: "object",
98+
},
99+
},
100+
401: NotAuthenticated.schema,
101+
},
102+
},
103+
},
104+
};
105+
}
106+
107+
/**
108+
* Creates an http handler that handles incoming http requests for the `/workflowitem.update` route
109+
*
110+
* @param server the current fastify server instance
111+
* @param urlPrefix the prefix of the http url
112+
* @param service the service {@link Service} object used to offer an interface to the domain logic
113+
*/
114+
export function addHttpHandler(
115+
server: AugmentedFastifyInstance,
116+
urlPrefix: string,
117+
service: WorkflowitemUpdateServiceInterface,
118+
): void {
119+
server.register(async function () {
120+
server.post(
121+
`${urlPrefix}/v2/workflowitem.update`,
122+
mkSwaggerSchema(server),
123+
async (request: AuthenticatedRequest, reply) => {
124+
let body = {
125+
apiVersion: "2.0",
126+
data: await parseMultiPartRequest(request),
127+
};
128+
const ctx: Ctx = { requestId: request.id, source: "http" };
129+
130+
const user = extractUser(request as AuthenticatedRequest);
131+
132+
const bodyResult = validateRequestBody(body);
133+
134+
if (Result.isErr(bodyResult)) {
135+
const { code, body } = toHttpError(new VError(bodyResult, "failed to update project"));
136+
request.log.error({ err: bodyResult }, "Invalid request body");
137+
reply.status(code).send(body);
138+
return;
139+
}
140+
141+
const { projectId, subprojectId, workflowitemId, ...data } = bodyResult.data;
142+
143+
try {
144+
const result = await service.updateWorkflowitem(
145+
ctx,
146+
user,
147+
projectId,
148+
subprojectId,
149+
workflowitemId,
150+
data,
151+
);
152+
153+
if (Result.isErr(result)) {
154+
throw new VError(result, "workflowitem.update failed");
155+
}
156+
157+
const response = {
158+
apiVersion: "2.0",
159+
data: {},
160+
};
161+
reply.status(200).send(response);
162+
} catch (err) {
163+
const { code, body } = toHttpError(err);
164+
request.log.error({ err }, "Error while updating workflowitem");
165+
reply.status(code).send(body);
166+
}
167+
},
168+
);
169+
});
170+
}

e2e-test/cypress/integration/documents_spec.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe("Attaching a document to a workflowitem.", function () {
7777
};
7878

7979
it("A document can be validated.", function () {
80-
cy.intercept(apiRoute + "/workflowitem.update*").as("update");
80+
cy.intercept(apiRoute + "/v2/workflowitem.update*").as("update");
8181
cy.intercept(apiRoute + "/subproject.viewDetails*").as("viewDetails");
8282
cy.intercept(apiRoute + "/workflowitem.validate*").as("validate");
8383

@@ -112,7 +112,7 @@ describe("Attaching a document to a workflowitem.", function () {
112112
});
113113

114114
it("Validation of wrong document fails.", function () {
115-
cy.intercept(apiRoute + "/workflowitem.update*").as("update");
115+
cy.intercept(apiRoute + "/v2/workflowitem.update*").as("update");
116116
cy.intercept(apiRoute + "/subproject.viewDetails*").as("viewDetails");
117117
cy.intercept(apiRoute + "/workflowitem.validate*").as("validate");
118118

@@ -148,7 +148,7 @@ describe("Attaching a document to a workflowitem.", function () {
148148
});
149149

150150
it("The filename and document name are shown correctly", function () {
151-
cy.intercept(apiRoute + "/workflowitem.update*").as("update");
151+
cy.intercept(apiRoute + "/v2/workflowitem.update*").as("update");
152152
cy.intercept(apiRoute + "/subproject.viewDetails*").as("viewDetails");
153153
cy.intercept(apiRoute + "/workflowitem.validate*").as("validate");
154154

@@ -217,7 +217,7 @@ describe("Deleting a document from a workflowitem.", function () {
217217
};
218218

219219
it("A document can be deleted.", function () {
220-
cy.intercept(apiRoute + "/workflowitem.update*").as("update");
220+
cy.intercept(apiRoute + "/v2/workflowitem.update*").as("update");
221221
cy.intercept(apiRoute + "/subproject.viewDetails*").as("viewDetails");
222222
cy.intercept(apiRoute + "/workflowitem.deleteDocument*").as("deleteDocument");
223223

@@ -291,7 +291,7 @@ describe("Deleting a document from a closed workflowitem.", function () {
291291
};
292292

293293
it("A document cannot be deleted from a closed workflowitem.", function () {
294-
cy.intercept(apiRoute + "/workflowitem.update*").as("update");
294+
cy.intercept(apiRoute + "/v2/workflowitem.update*").as("update");
295295
cy.intercept(apiRoute + "/subproject.viewDetails*").as("viewDetails");
296296
cy.intercept(apiRoute + "/workflowitem.deleteDocument*").as("deleteDocument");
297297

e2e-test/cypress/integration/workflowitem_create_spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ describe("Workflowitem create", function () {
241241
it("When no validator is set in a subproject, the workflowitem assignee can be changed", function () {
242242
cy.intercept(apiRoute + "/project.viewDetails*").as("loadPage");
243243
cy.intercept(apiRoute + `/project.createSubproject`).as("subprojectCreated");
244-
cy.intercept(apiRoute + `/v2/subproject.createWorkflowitem`).as("workflowitemCreated");
244+
cy.intercept(apiRoute + `/subproject.createWorkflowitem`).as("workflowitemCreated");
245245

246246
//Create a subproject
247247
cy.visit(`/projects/${projectId}`);

0 commit comments

Comments
 (0)