Skip to content

Commit

Permalink
Merge pull request #2003 from openkfw/1955-updatewf-v2
Browse files Browse the repository at this point in the history
1955 update workflow
  • Loading branch information
MartinJurcoGlina authored Oct 25, 2024
2 parents 283e98a + e959071 commit a620eb4
Show file tree
Hide file tree
Showing 11 changed files with 319 additions and 64 deletions.
57 changes: 57 additions & 0 deletions api/src/handlerUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { MultipartFile } from "@fastify/multipart";

import { AuthenticatedRequest } from "./httpd/lib";
import { ServiceUser } from "./service/domain/organization/service_user";

Expand All @@ -10,3 +12,58 @@ export const extractUser = (request: AuthenticatedRequest): ServiceUser => {
};
return user;
};

export const parseMultiPartFile = async (part: MultipartFile): Promise<any> => {
const id = "";
const buffer = await part.toBuffer();
// TODO downstream functionality expects base64, but we should work with buffer directly in the future
const base64 = buffer.toString("base64");
const fileName = part.filename;
return { id, base64, fileName };
};

export const parseMultiPartRequest = async (request: AuthenticatedRequest): Promise<any> => {
let data = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let uploadedDocuments: any[] = [];
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
uploadedDocuments.push(await parseMultiPartFile(part));
} else {
switch (true) {
case part.fieldname.includes("link"): {
uploadedDocuments.push(JSON.parse(part.value as string));
break;
}
case part.fieldname.includes("comment_"): {
const index = parseInt(part.fieldname.split("_")[1]);
uploadedDocuments[index].comment = part.value;
break;
}
case part.fieldname === "apiVersion": {
break;
}
case part.fieldname === "tags": {
if (part.value === "") {
data[part.fieldname] = [];
} else {
data[part.fieldname] = (part.value as string).split(",");
}
break;
}
case part.value === "null":
data[part.fieldname] = undefined;
break;
case part.value === "undefined":
data[part.fieldname] = undefined;
break;
default:
data[part.fieldname] = part.value;
break;
}
}
}
data["documents"] = uploadedDocuments;
return data;
};
2 changes: 0 additions & 2 deletions api/src/httpd/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,6 @@ export const createBasicApp = (

server.register(fastifyMultipart, {
limits: { fileSize: MAX_DOCUMENT_SIZE_BINARY },
// routes that use Multipart Form:
prefix: "/v2/subproject.createWorkflowitem",
});

return server;
Expand Down
15 changes: 15 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ import * as WorkflowitemPermissionGrantAPI from "./workflowitem_permission_grant
import * as WorkflowitemPermissionRevokeAPI from "./workflowitem_permission_revoke";
import * as WorkflowitemPermissionsListAPI from "./workflowitem_permissions_list";
import * as WorkflowitemUpdateAPI from "./workflowitem_update";
import * as WorkflowitemUpdateV2API from "./workflowitem_update.v2";
import * as WorkflowitemValidateDocumentAPI from "./workflowitem_validate_document";
import * as WorkflowitemViewDetailsAPI from "./workflowitem_view_details";
import * as WorkflowitemViewHistoryAPI from "./workflowitem_view_history";
Expand Down Expand Up @@ -930,6 +931,20 @@ WorkflowitemUpdateAPI.addHttpHandler(server, URL_PREFIX, {
),
});

WorkflowitemUpdateV2API.addHttpHandler(server, URL_PREFIX, {
updateWorkflowitem: (ctx, user, projectId, subprojectId, workflowitemId, data) =>
WorkflowitemUpdateService.updateWorkflowitem(
db,
storageServiceClient,
ctx,
user,
projectId,
subprojectId,
workflowitemId,
data,
),
});

WorkflowitemValidateDocumentAPI.addHttpHandler(server, URL_PREFIX, {
matches: (
documentBase64: string,
Expand Down
1 change: 0 additions & 1 deletion api/src/user_permission_grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { ServiceUser } from "./service/domain/organization/service_user";
import * as UserRecord from "./service/domain/organization/user_record";
import { AugmentedFastifyInstance } from "./types";


/**
* Represents the request body of the endpoint
*/
Expand Down
46 changes: 1 addition & 45 deletions api/src/workflowitem_create.v2.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { MultipartFile } from "@fastify/multipart";
import Joi = require("joi");
import { VError } from "verror";

import { extractUser } from "./handlerUtils";
import { extractUser, parseMultiPartRequest } from "./handlerUtils";
import { toHttpError } from "./http_errors";
import * as NotAuthenticated from "./http_errors/not_authenticated";
import { AuthenticatedRequest } from "./httpd/lib";
Expand All @@ -21,49 +20,6 @@ import Type, { workflowitemTypeSchema } from "./service/domain/workflowitem_type
import * as WorkflowitemCreate from "./service/workflowitem_create";
import { AugmentedFastifyInstance } from "./types";

const parseMultiPartFile = async (part: MultipartFile): Promise<any> => {
const id = "";
const buffer = await part.toBuffer();
// TODO downstream functionality expects base64, but we should work with buffer directly in the future
const base64 = buffer.toString("base64");
const fileName = part.filename;
return { id, base64, fileName };
};

const parseMultiPartRequest = async (request: AuthenticatedRequest): Promise<any> => {
let data = {};
let uploadedDocuments: any[] = [];
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
uploadedDocuments.push(await parseMultiPartFile(part));
} else {
if (part.fieldname.includes("comment_")) {
const index = parseInt(part.fieldname.split("_")[1]);
uploadedDocuments[index].comment = part.value;
continue;
}
if (part.fieldname === "apiVersion") {
continue;
} else if (part.fieldname === "tags") {
if (part.value === "") {
data[part.fieldname] = [];
} else {
data[part.fieldname] = (part.value as string).split(",");
}
continue;
}
if (part.value === "null") {
data[part.fieldname] = undefined;
continue;
}
data[part.fieldname] = part.value;
}
}
data["documents"] = uploadedDocuments;
return data;
};

/**
* Represents the request body of the endpoint
*/
Expand Down
170 changes: 170 additions & 0 deletions api/src/workflowitem_update.v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import Joi = require("joi");
import { VError } from "verror";

import { extractUser, parseMultiPartRequest } from "./handlerUtils";
import { toHttpError } from "./http_errors";
import * as NotAuthenticated from "./http_errors/not_authenticated";
import { AuthenticatedRequest } from "./httpd/lib";
import { Ctx } from "./lib/ctx";
import * as Result from "./result";
import { UploadedDocumentOrLink, uploadedDocumentSchema } from "./service/domain/document/document";
import * as Project from "./service/domain/workflow/project";
import * as Subproject from "./service/domain/workflow/subproject";
import * as Workflowitem from "./service/domain/workflow/workflowitem";
import * as WorkflowitemUpdated from "./service/domain/workflow/workflowitem_updated";
import { AugmentedFastifyInstance } from "./types";

import { WorkflowitemUpdateServiceInterface } from "./index";

/**
* Represents the request body of the endpoint
*/
interface UpdateWorkflowV2RequestBody {
apiVersion: "2.0";
data: {
projectId: Project.Id;
subprojectId: Subproject.Id;
workflowitemId: Workflowitem.Id;
displayName?: string;
description?: string;
amountType?: "N/A" | "disbursed" | "allocated";
amount?: string;
currency?: string;
exchangeRate?: string;
billingDate?: string;
dueDate?: string;
documents?: UploadedDocumentOrLink[];
additionalData?: object;
tags?: string[];
};
}

const requestBodyV2Schema = Joi.object({
apiVersion: Joi.valid("2.0").required(),
data: Joi.object({
projectId: Project.idSchema.required(),
subprojectId: Subproject.idSchema.required(),
workflowitemId: Workflowitem.idSchema.required(),
})
.concat(WorkflowitemUpdated.modificationSchema)
.keys({ documents: Joi.array().items(uploadedDocumentSchema) })
.required(),
});

type RequestBody = UpdateWorkflowV2RequestBody;
const requestBodySchema = Joi.alternatives([requestBodyV2Schema]);

/**
* Validates the request body of the http request
*
* @param body the request body
* @returns the request body wrapped in a {@link Result.Type}. Contains either the object or an error
*/
function validateRequestBody(body: unknown): Result.Type<RequestBody> {
const { error, value } = requestBodySchema.validate(body);
return !error ? value : error;
}

/**
* Creates the swagger schema for the `/v2/workflowitem.update` endpoint
*
* @param server fastify server
* @returns the swagger schema for this endpoint
*/
function mkSwaggerSchema(server: AugmentedFastifyInstance): Object {
return {
preValidation: [server.authenticate],
schema: {
description:
"Partially update a workflowitem. Only properties mentioned in the request body are touched, " +
"others are not affected. The assigned user will be notified about the change.\n" +
"Note that the only possible values for 'amountType' are: 'disbursed', 'allocated', 'N/A'\n.\n" +
"The only possible values for 'status' are: 'open' and 'closed'",
tags: ["workflowitem"],
summary: "Update a workflowitem",
security: [
{
bearerToken: [],
},
],
response: {
200: {
description: "successful response",
type: "object",
properties: {
apiVersion: { type: "string", example: "1.0" },
data: {
type: "object",
},
},
401: NotAuthenticated.schema,
},
},
},
};
}

/**
* Creates an http handler that handles incoming http requests for the `/workflowitem.update` route
*
* @param server the current fastify server instance
* @param urlPrefix the prefix of the http url
* @param service the service {@link Service} object used to offer an interface to the domain logic
*/
export function addHttpHandler(
server: AugmentedFastifyInstance,
urlPrefix: string,
service: WorkflowitemUpdateServiceInterface,
): void {
server.register(async function () {
server.post(
`${urlPrefix}/v2/workflowitem.update`,
mkSwaggerSchema(server),
async (request: AuthenticatedRequest, reply) => {
let body = {
apiVersion: "2.0",
data: await parseMultiPartRequest(request),
};
const ctx: Ctx = { requestId: request.id, source: "http" };

const user = extractUser(request as AuthenticatedRequest);

const bodyResult = validateRequestBody(body);

if (Result.isErr(bodyResult)) {
const { code, body } = toHttpError(new VError(bodyResult, "failed to update project"));
request.log.error({ err: bodyResult }, "Invalid request body");
reply.status(code).send(body);
return;
}

const { projectId, subprojectId, workflowitemId, ...data } = bodyResult.data;

try {
const result = await service.updateWorkflowitem(
ctx,
user,
projectId,
subprojectId,
workflowitemId,
data,
);

if (Result.isErr(result)) {
throw new VError(result, "workflowitem.update failed");
}

const response = {
apiVersion: "2.0",
data: {},
};
reply.status(200).send(response);
} catch (err) {
const { code, body } = toHttpError(err);
request.log.error({ err }, "Error while updating workflowitem");
reply.status(code).send(body);
}
},
);
});
}
10 changes: 5 additions & 5 deletions e2e-test/cypress/integration/documents_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe("Attaching a document to a workflowitem.", function () {
};

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

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

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

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

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

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

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

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

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

Expand Down
2 changes: 1 addition & 1 deletion e2e-test/cypress/integration/workflowitem_create_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ describe("Workflowitem create", function () {
it("When no validator is set in a subproject, the workflowitem assignee can be changed", function () {
cy.intercept(apiRoute + "/project.viewDetails*").as("loadPage");
cy.intercept(apiRoute + `/project.createSubproject`).as("subprojectCreated");
cy.intercept(apiRoute + `/v2/subproject.createWorkflowitem`).as("workflowitemCreated");
cy.intercept(apiRoute + `/subproject.createWorkflowitem`).as("workflowitemCreated");

//Create a subproject
cy.visit(`/projects/${projectId}`);
Expand Down
Loading

0 comments on commit a620eb4

Please sign in to comment.