diff --git a/integration-tests/api/__tests__/batch-jobs/__snapshots__/api.js.snap b/integration-tests/api/__tests__/batch-jobs/__snapshots__/api.js.snap index 237dd3c33f..6aa649d147 100644 --- a/integration-tests/api/__tests__/batch-jobs/__snapshots__/api.js.snap +++ b/integration-tests/api/__tests__/batch-jobs/__snapshots__/api.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`/admin/batch GET /admin/batch lists batch jobs created by the user 1`] = ` +exports[`/admin/batch-jobs GET /admin/batch-jobs lists batch jobs created by the user 1`] = ` Object { "batch_jobs": Array [ Object { @@ -8,6 +8,7 @@ Object { "created_at": Any, "created_by": "admin_user", "deleted_at": null, + "dry_run": false, "id": "job_3", "result": null, "status": "created", @@ -19,6 +20,7 @@ Object { "created_at": Any, "created_by": "admin_user", "deleted_at": null, + "dry_run": false, "id": "job_2", "result": null, "status": "created", @@ -30,6 +32,7 @@ Object { "created_at": Any, "created_by": "admin_user", "deleted_at": null, + "dry_run": false, "id": "job_1", "result": null, "status": "created", @@ -42,3 +45,45 @@ Object { "offset": 0, } `; + +exports[`/admin/batch-jobs POST /admin/batch-jobs/ Creates a batch job 1`] = ` +Object { + "canceled_at": null, + "completed_at": null, + "confirmed_at": null, + "context": Object {}, + "created_at": Any, + "created_by": "admin_user", + "deleted_at": null, + "dry_run": false, + "failed_at": null, + "id": Any, + "pre_processed_at": null, + "processing_at": null, + "result": null, + "status": "created", + "type": "batch_1", + "updated_at": Any, +} +`; + +exports[`/admin/batch-jobs POST /admin/batch-jobs/:id/cancel Cancels batch job created by the user 1`] = ` +Object { + "canceled_at": Any, + "completed_at": null, + "confirmed_at": null, + "context": Object {}, + "created_at": Any, + "created_by": "admin_user", + "deleted_at": null, + "dry_run": false, + "failed_at": null, + "id": "job_1", + "pre_processed_at": null, + "processing_at": null, + "result": null, + "status": "canceled", + "type": "batch_1", + "updated_at": Any, +} +`; diff --git a/integration-tests/api/__tests__/batch-jobs/api.js b/integration-tests/api/__tests__/batch-jobs/api.js index 5ce7972194..09ccf2b0b1 100644 --- a/integration-tests/api/__tests__/batch-jobs/api.js +++ b/integration-tests/api/__tests__/batch-jobs/api.js @@ -5,6 +5,8 @@ const { useApi } = require("../../../helpers/use-api") const { initDb, useDb } = require("../../../helpers/use-db") const adminSeeder = require("../../helpers/admin-seeder") +const userSeeder = require("../../helpers/user-seeder") + const { simpleBatchJobFactory } = require("../../factories") jest.setTimeout(50000) @@ -15,7 +17,41 @@ const adminReqConfig = { }, } -describe("/admin/batch", () => { +const setupJobDb = async (dbConnection) => { + try { + await adminSeeder(dbConnection) + await userSeeder(dbConnection) + + await simpleBatchJobFactory(dbConnection, { + id: "job_1", + type: "batch_1", + created_by: "admin_user", + }) + await simpleBatchJobFactory(dbConnection, { + id: "job_2", + type: "batch_2", + ready_at: new Date(), + created_by: "admin_user", + }) + await simpleBatchJobFactory(dbConnection, { + id: "job_3", + type: "batch_2", + ready_at: new Date(), + created_by: "admin_user", + }) + await simpleBatchJobFactory(dbConnection, { + id: "job_4", + type: "batch_1", + ready_at: new Date(), + created_by: "member-user", + }) + } catch (err) { + console.log(err) + throw err + } +} + +describe("/admin/batch-jobs", () => { let medusaProcess let dbConnection @@ -32,34 +68,9 @@ describe("/admin/batch", () => { medusaProcess.kill() }) - describe("GET /admin/batch", () => { + describe("GET /admin/batch-jobs", () => { beforeEach(async () => { - try { - await simpleBatchJobFactory(dbConnection, { - id: "job_1", - type: "batch_1", - created_by: "admin_user", - }) - await simpleBatchJobFactory(dbConnection, { - id: "job_2", - type: "batch_2", - created_by: "admin_user", - }) - await simpleBatchJobFactory(dbConnection, { - id: "job_3", - type: "batch_2", - created_by: "admin_user", - }) - await simpleBatchJobFactory(dbConnection, { - id: "job_4", - type: "batch_1", - created_by: "not_this_user", - }) - await adminSeeder(dbConnection) - } catch (err) { - console.log(err) - throw err - } + await setupJobDb(dbConnection) }) afterEach(async () => { @@ -69,7 +80,7 @@ describe("/admin/batch", () => { it("lists batch jobs created by the user", async () => { const api = useApi() - const response = await api.get("/admin/batch", adminReqConfig) + const response = await api.get("/admin/batch-jobs", adminReqConfig) expect(response.status).toEqual(200) expect(response.data.batch_jobs.length).toEqual(3) @@ -78,17 +89,196 @@ describe("/admin/batch", () => { { created_at: expect.any(String), updated_at: expect.any(String), + created_by: "admin_user" }, { created_at: expect.any(String), updated_at: expect.any(String), + created_by: "admin_user" }, { created_at: expect.any(String), updated_at: expect.any(String), + created_by: "admin_user" }, ], }) }) }) + + describe("GET /admin/batch-jobs/:id", () => { + beforeEach(async () => { + await setupJobDb(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("return batch job created by the user", async () => { + const api = useApi() + const response = await api.get("/admin/batch-jobs/job_1", adminReqConfig) + + expect(response.status).toEqual(200) + expect(response.data.batch_job).toEqual(expect.objectContaining({ + created_at: expect.any(String), + updated_at: expect.any(String), + created_by: "admin_user" + })) + }) + + it("should fail on batch job created by other user", async () => { + const api = useApi() + await api.get("/admin/batch-jobs/job_4", adminReqConfig) + .catch((err) => { + expect(err.response.status).toEqual(400) + expect(err.response.data.type).toEqual("not_allowed") + expect(err.response.data.message).toEqual( + "Cannot access a batch job that does not belong to the logged in user" + ) + }) + }) + }) + + describe("POST /admin/batch-jobs/", () => { + beforeEach(async() => { + try { + await adminSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async() => { + const db = useDb() + await db.teardown() + }) + + it("Creates a batch job", async() => { + const api = useApi() + + const response = await api.post( + "/admin/batch-jobs", + { + type: "batch_1", + context: {}, + }, + adminReqConfig + ) + + expect(response.status).toEqual(201) + expect(response.data.batch_job).toMatchSnapshot({ + created_by: "admin_user", + status: "created", + id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + }) + + describe("POST /admin/batch-jobs/:id/confirm", () => { + beforeEach(async () => { + await setupJobDb(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("Fails to confirm a batch job created by a different user", async () => { + const api = useApi() + + const jobId = "job_4" + + api + .post(`/admin/batch-jobs/${jobId}/confirm`, {}, adminReqConfig) + .catch((err) => { + expect(err.response.status).toEqual(400) + expect(err.response.data.type).toEqual("not_allowed") + expect(err.response.data.message).toEqual( + "Cannot access a batch job that does not belong to the logged in user" + ) + }) + }) + }) + + describe("POST /admin/batch-jobs/:id/cancel", () => { + beforeEach(async () => { + try { + await setupJobDb(dbConnection) + await simpleBatchJobFactory(dbConnection, { + id: "job_complete", + type: "batch_1", + created_by: "admin_user", + completed_at: new Date(), + }) + } catch (e) { + console.log(e) + throw e + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("Cancels batch job created by the user", async () => { + const api = useApi() + + const jobId = "job_1" + + const response = await api.post( + `/admin/batch-jobs/${jobId}/cancel`, + {}, + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data.batch_job).toMatchSnapshot({ + created_at: expect.any(String), + updated_at: expect.any(String), + canceled_at: expect.any(String), + status: "canceled", + }) + }) + + it("Fails to cancel a batch job created by a different user", async () => { + expect.assertions(3) + const api = useApi() + + const jobId = "job_4" + + api + .post(`/admin/batch-jobs/${jobId}/cancel`, {}, adminReqConfig) + .catch((err) => { + expect(err.response.status).toEqual(400) + expect(err.response.data.type).toEqual("not_allowed") + expect(err.response.data.message).toEqual( + "Cannot access a batch job that does not belong to the logged in user" + ) + }) + }) + + it("Fails to cancel a batch job that is already complete", async () => { + expect.assertions(3) + const api = useApi() + + const jobId = "job_complete" + + await api + .post(`/admin/batch-jobs/${jobId}/cancel`, {}, adminReqConfig) + .catch((err) => { + expect(err.response.status).toEqual(400) + expect(err.response.data.type).toEqual("not_allowed") + expect(err.response.data.message).toEqual( + "Cannot cancel completed batch job" + ) + }) + }) + }) }) diff --git a/integration-tests/api/factories/index.ts b/integration-tests/api/factories/index.ts index 42621df157..d77949c266 100644 --- a/integration-tests/api/factories/index.ts +++ b/integration-tests/api/factories/index.ts @@ -13,3 +13,5 @@ export * from "./simple-shipping-option-factory" export * from "./simple-shipping-method-factory" export * from "./simple-product-type-tax-rate-factory" export * from "./simple-price-list-factory" +export * from "./simple-batch-job-factory" + diff --git a/integration-tests/api/factories/simple-batch-job-factory.ts b/integration-tests/api/factories/simple-batch-job-factory.ts index 837137982c..b884dd17c4 100644 --- a/integration-tests/api/factories/simple-batch-job-factory.ts +++ b/integration-tests/api/factories/simple-batch-job-factory.ts @@ -7,6 +7,8 @@ export type BatchJobFactoryData = { status?: BatchJobStatus created_by?: string context?: Record + awaiting_confirmation_at?: Date | string + completed_at?: Date | string } export const simpleBatchJobFactory = async ( @@ -18,6 +20,8 @@ export const simpleBatchJobFactory = async ( const job = manager.create(BatchJob, { id: data.id, status: data.status ?? BatchJobStatus.CREATED, + awaiting_confirmation_at: data.awaiting_confirmation_at ?? null, + completed_at: data.completed_at ?? null, type: data.type ?? "test-job", created_by: data.created_by ?? null, context: data.context ?? {}, diff --git a/integration-tests/helpers/bootstrap-app.js b/integration-tests/helpers/bootstrap-app.js index 0dc1c221e2..52ebc998cb 100644 --- a/integration-tests/helpers/bootstrap-app.js +++ b/integration-tests/helpers/bootstrap-app.js @@ -5,26 +5,30 @@ const importFrom = require("import-from") module.exports = { bootstrapApp: async ({ cwd } = {}) => { - const app = express() + try { + const app = express() - const loaders = importFrom( - cwd || process.cwd(), - "@medusajs/medusa/dist/loaders" - ).default + const loaders = importFrom( + cwd || process.cwd(), + "@medusajs/medusa/dist/loaders" + ).default - const { container, dbConnection } = await loaders({ - directory: path.resolve(cwd || process.cwd()), - expressApp: app, - isTest: false, - }) + const { container, dbConnection } = await loaders({ + directory: path.resolve(cwd || process.cwd()), + expressApp: app, + isTest: false, + }) - const PORT = await getPort() + const PORT = await getPort() - return { - container, - db: dbConnection, - app, - port: PORT, + return { + container, + db: dbConnection, + app, + port: PORT, + } + } catch (e) { + console.log(e) } }, } diff --git a/packages/medusa/src/api/routes/admin/batch/cancel-batch-job.ts b/packages/medusa/src/api/routes/admin/batch/cancel-batch-job.ts new file mode 100644 index 0000000000..f5dcaab4b4 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/batch/cancel-batch-job.ts @@ -0,0 +1,30 @@ +import { BatchJobService } from "../../../../services" + +/** + * @oas [post] /batch-jobs/{id}/cancel + * operationId: "PostBatchJobsBatchJobCancel" + * summary: "Marks a batch job as canceled" + * description: "Marks a batch job as canceled" + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the batch job. + * tags: + * - Batch Job + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * properties: + * batch_job: + * $ref: "#/components/schemas/batch_job" + */ +export default async (req, res) => { + let batch_job = req.batch_job + + const batchJobService: BatchJobService = req.scope.resolve("batchJobService") + batch_job = await batchJobService.cancel(batch_job) + + res.json({ batch_job }) +} diff --git a/packages/medusa/src/api/routes/admin/batch/confirm-batch-job.ts b/packages/medusa/src/api/routes/admin/batch/confirm-batch-job.ts new file mode 100644 index 0000000000..b5bb86903e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/batch/confirm-batch-job.ts @@ -0,0 +1,31 @@ +import { BatchJobService } from "../../../../services" + +/** + * @oas [post] /batch-jobs/{id}/confirm + * operationId: "PostBatchJobsBatchJobConfirmProcessing" + * summary: "Confirm a batch job" + * description: "Confirms that a previously requested batch job should be executed." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the batch job. + * tags: + * - Batch Job + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * properties:x + * batch_job: + * $ref: "#/components/schemas/batch_job" + */ + +export default async (req, res) => { + let batch_job = req.batch_job + + const batchJobService: BatchJobService = req.scope.resolve("batchJobService") + batch_job = await batchJobService.confirm(batch_job) + + res.json({ batch_job }) +} diff --git a/packages/medusa/src/api/routes/admin/batch/create-batch-job.ts b/packages/medusa/src/api/routes/admin/batch/create-batch-job.ts new file mode 100644 index 0000000000..2cb4e0b2e0 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/batch/create-batch-job.ts @@ -0,0 +1,51 @@ +import { IsBoolean, IsObject, IsOptional, IsString } from "class-validator" +import BatchJobService from "../../../../services/batch-job" +import { validator } from "../../../../utils/validator" + +/** + * @oas [post] /batch-jobs + * operationId: "PostBatchJobs" + * summary: "Create a Batch Job" + * description: "Creates a Batch Job." + * x-authenticated: true + * parameters: + * - (body) type=* {string} The type of batch job to start. + * - (body) context=* {string} Additional infomration regarding the batch to be used for processing. + * - (body) dry_run=* {boolean} Set a batch job in dry_run mode to get some information on what will be done without applying any modifications. + * tags: + * - Customer + * responses: + * 201: + * description: OK + * content: + * application/json: + * schema: + * properties: + * batch_job: + * $ref: "#/components/schemas/batch_job" + */ +export default async (req, res) => { + const validated = await validator(AdminPostBatchesReq, req.body) + + const userId = req.user.id ?? req.user.userId + + const batchJobService: BatchJobService = req.scope.resolve("batchJobService") + const batch_job = await batchJobService.create({ + ...validated, + created_by: userId, + }) + + res.status(201).json({ batch_job }) +} + +export class AdminPostBatchesReq { + @IsString() + type: string + + @IsObject() + context: Record + + @IsBoolean() + @IsOptional() + dry_run = false +} diff --git a/packages/medusa/src/api/routes/admin/batch/get-batch-job.ts b/packages/medusa/src/api/routes/admin/batch/get-batch-job.ts new file mode 100644 index 0000000000..4bef63e026 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/batch/get-batch-job.ts @@ -0,0 +1,24 @@ +/** + * @oas [get] /batch-jobs/{id} + * operationId: "GetBatchJobsBatchJob" + * summary: "Retrieve a Batch Job" + * description: "Retrieves a Batch Job." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the Batch Job + * tags: + * - Batch Job + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * properties: + * batch_job: + * $ref: "#/components/schemas/batch_job" + */ +export default async (req, res) => { + const batch_job = req.batch_job + res.status(200).json({ batch_job: batch_job }) +} diff --git a/packages/medusa/src/api/routes/admin/batch/index.ts b/packages/medusa/src/api/routes/admin/batch/index.ts index 8fba2f055f..bbbe03ff20 100644 --- a/packages/medusa/src/api/routes/admin/batch/index.ts +++ b/packages/medusa/src/api/routes/admin/batch/index.ts @@ -1,13 +1,17 @@ import { Router } from "express" import { BatchJob } from "../../../.." import { DeleteResponse, PaginatedResponse } from "../../../../types/common" -import middlewares, { transformQuery } from "../../../middlewares" import { AdminGetBatchParams } from "./list-batch-jobs" +import middlewares, { + transformQuery, + getRequestedBatchJob, + canAccessBatchJob, +} from "../../../middlewares" export default (app) => { const route = Router() - app.use("/batch", route) + app.use("/batch-jobs", route) route.get( "/", @@ -17,6 +21,20 @@ export default (app) => { }), middlewares.wrap(require("./list-batch-jobs").default) ) + route.post("/", middlewares.wrap(require("./create-batch-job").default)) + + const batchJobRouter = Router({ mergeParams: true }) + route.use("/:id", getRequestedBatchJob, canAccessBatchJob, batchJobRouter) + batchJobRouter.get("/", middlewares.wrap(require("./get-batch-job").default)) + batchJobRouter.post( + "/confirm", + middlewares.wrap(require("./confirm-batch-job").default) + ) + batchJobRouter.post( + "/cancel", + middlewares.wrap(require("./cancel-batch-job").default) + ) + return app } @@ -32,7 +50,6 @@ export type AdminBatchJobListRes = PaginatedResponse & { export const defaultAdminBatchFields = [ "id", - "status", "type", "context", "result", diff --git a/packages/medusa/src/api/routes/admin/batch/list-batch-jobs.ts b/packages/medusa/src/api/routes/admin/batch/list-batch-jobs.ts index 50981e99d1..fd11b124c8 100644 --- a/packages/medusa/src/api/routes/admin/batch/list-batch-jobs.ts +++ b/packages/medusa/src/api/routes/admin/batch/list-batch-jobs.ts @@ -1,7 +1,6 @@ import { Type } from "class-transformer" import { IsArray, - IsEnum, IsNumber, IsOptional, IsString, @@ -9,14 +8,13 @@ import { } from "class-validator" import { pickBy } from "lodash" import BatchJobService from "../../../../services/batch-job" -import { BatchJobStatus } from "../../../../types/batch-job" import { DateComparisonOperator } from "../../../../types/common" import { IsType } from "../../../../utils/validators/is-type" import { Request } from "express" /** - * @oas [get] /batch - * operationId: "GetBatch" + * @oas [get] /batch-jobs + * operationId: "GetBatchJobs" * summary: "List Batch Jobs" * description: "Retrieve a list of Batch Jobs." * x-authenticated: true @@ -24,7 +22,11 @@ import { Request } from "express" * - (query) limit {string} The number of collections to return. * - (query) offset {string} The offset of collections to return. * - (query) type {string | string[]} Filter by the batch type - * - (query) status {string} Filter by the status of the batch operation + * - (query) confirmed_at {DateComparisonOperator} Date comparison for when resulting collections was confirmed, i.e. less than, greater than etc. + * - (query) pre_processed_at {DateComparisonOperator} Date comparison for when resulting collections was pre processed, i.e. less than, greater than etc. + * - (query) completed_at {DateComparisonOperator} Date comparison for when resulting collections was completed, i.e. less than, greater than etc. + * - (query) failed_at {DateComparisonOperator} Date comparison for when resulting collections was failed, i.e. less than, greater than etc. + * - (query) canceled_at {DateComparisonOperator} Date comparison for when resulting collections was canceled, i.e. less than, greater than etc. * - (query) order {string} Order used when retrieving batch jobs * - (query) expand[] {string} (Comma separated) Which fields should be expanded in each order of the result. * - (query) fields[] {string} (Comma separated) Which fields should be included in each order of the result. @@ -95,18 +97,32 @@ export class AdminGetBatchParams extends AdminGetBatchPaginationParams { @IsType([String, [String]]) id?: string | string[] - @IsOptional() - @IsArray() - @IsEnum(BatchJobStatus, { each: true }) - status?: BatchJobStatus[] - @IsArray() @IsOptional() type?: string[] @IsOptional() - @ValidateNested() @Type(() => DateComparisonOperator) + confirmed_at?: DateComparisonOperator + + @IsOptional() + @Type(() => DateComparisonOperator) + pre_processed_at?: DateComparisonOperator + + @IsOptional() + @Type(() => DateComparisonOperator) + completed_at?: DateComparisonOperator + + @IsOptional() + @Type(() => DateComparisonOperator) + failed_at?: DateComparisonOperator + + @IsOptional() + @Type(() => DateComparisonOperator) + canceled_at?: DateComparisonOperator + + @IsType([DateComparisonOperator]) + @IsOptional() created_at?: DateComparisonOperator @IsOptional() diff --git a/packages/medusa/src/migrations/1649775522087-add_batch_job_model.ts b/packages/medusa/src/migrations/1649775522087-add_batch_job_model.ts index 9ffd8588dd..b6fccc109a 100644 --- a/packages/medusa/src/migrations/1649775522087-add_batch_job_model.ts +++ b/packages/medusa/src/migrations/1649775522087-add_batch_job_model.ts @@ -5,15 +5,38 @@ export class addBatchJobModel1649775522087 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TYPE "batch_job_status_enum" AS ENUM('created', 'processing', 'awaiting_confirmation', 'completed')` + `CREATE TABLE "batch_job" + ( + "id" character varying NOT NULL, + "type" text NOT NULL, + "created_by" character varying, + "context" jsonb, + "result" jsonb, + "dry_run" boolean NOT NULL DEFAULT FALSE, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "pre_processed_at" TIMESTAMP WITH TIME ZONE, + "confirmed_at" TIMESTAMP WITH TIME ZONE, + "processing_at" TIMESTAMP WITH TIME ZONE, + "completed_at" TIMESTAMP WITH TIME ZONE, + "failed_at" TIMESTAMP WITH TIME ZONE, + "canceled_at" TIMESTAMP WITH TIME ZONE, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP WITH TIME ZONE, + CONSTRAINT "PK_e57f84d485145d5be96bc6d871e" PRIMARY KEY ("id") + )` ) + await queryRunner.query( - `CREATE TABLE "batch_job" ("id" character varying NOT NULL, "type" text NOT NULL, "status" "public"."batch_job_status_enum" NOT NULL, "created_by" text, "context" jsonb, "result" jsonb, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_e57f84d485145d5be96bc6d871e" PRIMARY KEY ("id"))` + `ALTER TABLE "batch_job" + ADD CONSTRAINT "FK_fa53ca4f5fd90605b532802a626" FOREIGN KEY ("created_by") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION` ) } public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "batch_job" + DROP CONSTRAINT "FK_fa53ca4f5fd90605b532802a626"` + ) await queryRunner.query(`DROP TABLE "batch_job"`) - await queryRunner.query(`DROP TYPE "batch_job_status_enum"`) } -} +} \ No newline at end of file diff --git a/packages/medusa/src/models/batch-job.ts b/packages/medusa/src/models/batch-job.ts index 944619926e..45a15ba055 100644 --- a/packages/medusa/src/models/batch-job.ts +++ b/packages/medusa/src/models/batch-job.ts @@ -1,30 +1,85 @@ -import { BeforeInsert, Column, Entity, PrimaryColumn } from "typeorm" +import { AfterLoad, BeforeInsert, Column, Entity, JoinColumn, ManyToOne } from "typeorm" import { BatchJobStatus } from "../types/batch-job" -import { DbAwareColumn } from "../utils/db-aware-column" +import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column" import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity" import { generateEntityId } from "../utils/generate-entity-id" +import { User } from "./user" @Entity() export class BatchJob extends SoftDeletableEntity { @DbAwareColumn({ type: "text" }) type: string - @DbAwareColumn({ type: "enum", enum: BatchJobStatus }) - status: BatchJobStatus - - @Column({ type: "text", nullable: true }) + @Column({ nullable: true }) created_by: string | null + @ManyToOne(() => User) + @JoinColumn({ name: "created_by" }) + created_by_user: User + @DbAwareColumn({ type: "jsonb", nullable: true }) - context: Record + context: { retry_count?: number; max_retry?: number } & Record @DbAwareColumn({ type: "jsonb", nullable: true }) result: Record + @Column({ type: "boolean", nullable: false, default: false }) + dry_run: boolean = false; + + @Column({ type: resolveDbType("timestamptz"), nullable: true }) + pre_processed_at?: Date + + @Column({ type: resolveDbType("timestamptz"), nullable: true }) + processing_at?: Date + + @Column({ type: resolveDbType("timestamptz"), nullable: true }) + confirmed_at?: Date + + @Column({ type: resolveDbType("timestamptz"), nullable: true }) + completed_at?: Date + + @Column({ type: resolveDbType("timestamptz"), nullable: true }) + canceled_at?: Date + + @Column({ type: resolveDbType("timestamptz"), nullable: true }) + failed_at?: Date + + status: BatchJobStatus + + @AfterLoad() + loadStatus(): void { + /* Always keep the status order consistent. */ + if (this.pre_processed_at) { + this.status = BatchJobStatus.PRE_PROCESSED + } + if (this.confirmed_at) { + this.status = BatchJobStatus.CONFIRMED + } + if (this.processing_at) { + this.status = BatchJobStatus.PROCESSING + } + if (this.completed_at) { + this.status = BatchJobStatus.COMPLETED + } + if (this.canceled_at) { + this.status = BatchJobStatus.CANCELED + } + if (this.failed_at) { + this.status = BatchJobStatus.FAILED + } + + this.status = this.status ?? BatchJobStatus.CREATED + } + @BeforeInsert() private beforeInsert(): void { this.id = generateEntityId(this.id, "batch") } + + toJSON() { + this.loadStatus() + return this + } } /** @@ -47,18 +102,40 @@ export class BatchJob extends SoftDeletableEntity { * type: string * enum: * - created + * - pre_processed * - processing - * - awaiting_confirmation * - completed + * - canceled + * - failed * created_by: * description: "The unique identifier of the user that created the batch job." * type: string * context: * description: "The context of the batch job, the type of the batch job determines what the context should contain." * type: object + * dry_run: + * description: "Specify if the job must apply the modifications or not." + * type: boolean + * default: false * result: * description: "The result of the batch job." * type: object + * pre_processed_at: + * description: "The date from which the job has been pre processed." + * type: string + * format: date-time + * confirmed_at: + * description: "The date when the confirmation has been done." + * type: string + * format: date-time + * completed_at: + * description: "The date of the completion." + * type: string + * format: date-time + * canceled_at: + * description: "The date of the concellation." + * type: string + * format: date-time * created_at: * description: "The date with timezone at which the resource was created." * type: string diff --git a/packages/medusa/src/services/__tests__/batch-job.ts b/packages/medusa/src/services/__tests__/batch-job.ts new file mode 100644 index 0000000000..3c7b41a3f6 --- /dev/null +++ b/packages/medusa/src/services/__tests__/batch-job.ts @@ -0,0 +1,199 @@ +import { IdMap, MockManager, MockRepository } from "medusa-test-utils" +import BatchJobService from "../batch-job" +import { EventBusService } from "../index" +import { BatchJobStatus } from "../../types/batch-job" +import { BatchJob } from "../../models" + +const eventBusServiceMock = { + emit: jest.fn(), + withTransaction: function() { + return this + }, +} as unknown as EventBusService +const batchJobRepositoryMock = MockRepository({ + create: jest.fn().mockImplementation((data) => { + return Object.assign(new BatchJob(), data) + }) +}) + +describe('BatchJobService', () => { + const batchJobId_1 = IdMap.getId("batchJob_1") + const batchJobService = new BatchJobService({ + manager: MockManager, + eventBusService: eventBusServiceMock, + batchJobRepository: batchJobRepositoryMock + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('update status', () => { + describe("confirm", () => { + it('should be able to confirm_processing a batch job to emit the processing event', async () => { + const batchJob = batchJobRepositoryMock.create({ + id: batchJobId_1, + dry_run: true, + status: BatchJobStatus.PRE_PROCESSED + }) + + const updatedBatchJob = await batchJobService.confirm(batchJob) + expect(updatedBatchJob.processing_at).not.toBeTruthy() + expect(eventBusServiceMock.emit) + .toHaveBeenCalledWith(BatchJobService.Events.CONFIRMED, { id: batchJobId_1 }) + }) + + it('should not be able to confirm a batch job with the wrong status', async () => { + const batchJob = batchJobRepositoryMock.create({ + id: batchJobId_1, + dry_run: true, + status: BatchJobStatus.CREATED + }) + + const err = await batchJobService.confirm(batchJob) + .catch(e => e) + expect(err).toBeTruthy() + expect(err.message).toBe("Cannot confirm processing for a batch job that is not pre processed") + expect(eventBusServiceMock.emit).toHaveBeenCalledTimes(0) + }) + }) + + describe("complete", () => { + it('should be able to complete a batch job', async () => { + const batchJob = batchJobRepositoryMock.create({ + id: batchJobId_1, + dry_run: true, + status: BatchJobStatus.PROCESSING + }) + + const updatedBatchJob = await batchJobService.complete(batchJob) + expect(updatedBatchJob.completed_at).toBeTruthy() + expect(eventBusServiceMock.emit) + .toHaveBeenCalledWith(BatchJobService.Events.COMPLETED, { id: batchJobId_1 }) + + const batchJob2 = batchJobRepositoryMock.create({ + id: batchJobId_1, + dry_run: false, + status: BatchJobStatus.PROCESSING + }) + + const updatedBatchJob2 = await batchJobService.complete(batchJob2) + expect(updatedBatchJob2.completed_at).toBeTruthy() + expect(eventBusServiceMock.emit) + .toHaveBeenCalledWith(BatchJobService.Events.COMPLETED, { id: batchJobId_1 }) + }) + + it('should not be able to complete a batch job with the wrong status', async () => { + const batchJob = batchJobRepositoryMock.create({ + id: batchJobId_1, + dry_run: true, + status: BatchJobStatus.CREATED + }) + + const err = await batchJobService.complete(batchJob) + .catch(e => e) + expect(err).toBeTruthy() + expect(err.message).toBe( `Cannot complete a batch job with status "${batchJob.status}". The batch job must be processing`) + expect(eventBusServiceMock.emit).toHaveBeenCalledTimes(0) + + const batchJob2 = batchJobRepositoryMock.create({ + id: batchJobId_1, + dry_run: false, + status: BatchJobStatus.PRE_PROCESSED + }) + + const err2 = await batchJobService.complete(batchJob2) + .catch(e => e) + expect(err2).toBeTruthy() + expect(err2.message).toBe( `Cannot complete a batch job with status "${batchJob2.status}". The batch job must be processing`) + expect(eventBusServiceMock.emit).toHaveBeenCalledTimes(0) + }) + }) + + describe("pre processed", () => { + it('should be able to mark as pre processed a batch job in dry_run', async () => { + const batchJob = batchJobRepositoryMock.create({ + id: batchJobId_1, + dry_run: true, + status: BatchJobStatus.CREATED + }) + + const updatedBatchJob = await batchJobService.setPreProcessingDone(batchJob) + expect(updatedBatchJob.pre_processed_at).toBeTruthy() + expect(eventBusServiceMock.emit) + .toHaveBeenCalledWith(BatchJobService.Events.PRE_PROCESSED, { id: batchJobId_1 }) + }) + + it('should be able to mark as completed a batch job that has been pre processed but not in dry_run', async () => { + const batchJob = batchJobRepositoryMock.create({ + id: batchJobId_1, + dry_run: false, + status: BatchJobStatus.CREATED + }) + + const updatedBatchJob = await batchJobService.setPreProcessingDone(batchJob) + expect(updatedBatchJob.pre_processed_at).toBeTruthy() + expect(updatedBatchJob.confirmed_at).toBeTruthy() + expect(eventBusServiceMock.emit).toHaveBeenCalledTimes(2) + expect(eventBusServiceMock.emit) + .toHaveBeenCalledWith(BatchJobService.Events.PRE_PROCESSED, { id: batchJobId_1 }) + expect(eventBusServiceMock.emit) + .toHaveBeenLastCalledWith(BatchJobService.Events.CONFIRMED, { id: batchJobId_1 }) + }) + }) + + describe("cancel", () => { + it('should be able to cancel a batch job', async () => { + const batchJob = batchJobRepositoryMock.create({ + id: batchJobId_1, + status: BatchJobStatus.CREATED + }) + + const updatedBatchJob = await batchJobService.cancel(batchJob) + expect(updatedBatchJob.canceled_at).toBeTruthy() + expect(eventBusServiceMock.emit) + .toHaveBeenCalledWith(BatchJobService.Events.CANCELED, { id: batchJobId_1 }) + }) + + it('should not be able to cancel a batch job with the wrong status', async () => { + const batchJob = batchJobRepositoryMock.create({ + id: batchJobId_1, + status: BatchJobStatus.COMPLETED + }) + + const err = await batchJobService.cancel(batchJob) + .catch(e => e) + expect(err).toBeTruthy() + expect(err.message).toBe("Cannot cancel completed batch job") + expect(eventBusServiceMock.emit).toHaveBeenCalledTimes(0) + }) + }) + + describe("processing", () => { + it('should be able to mark as processing a batch job', async () => { + const batchJob = batchJobRepositoryMock.create({ + id: batchJobId_1, + status: BatchJobStatus.CONFIRMED + }) + + const updatedBatchJob = await batchJobService.setProcessing(batchJob) + expect(updatedBatchJob.processing_at).toBeTruthy() + expect(eventBusServiceMock.emit) + .toHaveBeenCalledWith(BatchJobService.Events.PROCESSING, { id: batchJobId_1 }) + }) + + it('should not be able to mark as processing a batch job with the wrong status', async () => { + const batchJob = batchJobRepositoryMock.create({ + id: batchJobId_1, + status: BatchJobStatus.COMPLETED + }) + + const err = await batchJobService.setProcessing(batchJob) + .catch(e => e) + expect(err).toBeTruthy() + expect(err.message).toBe("Cannot mark a batch job as processing if the status is different that confirmed") + expect(eventBusServiceMock.emit).toHaveBeenCalledTimes(0) + }) + }) + }) +}) \ No newline at end of file diff --git a/packages/medusa/src/services/batch-job.ts b/packages/medusa/src/services/batch-job.ts index 37f27a2b98..c0bf9b25b0 100644 --- a/packages/medusa/src/services/batch-job.ts +++ b/packages/medusa/src/services/batch-job.ts @@ -1,34 +1,99 @@ -import { EntityManager } from "typeorm" - +import { DeepPartial, EntityManager } from "typeorm" import { BatchJob } from "../models" import { BatchJobRepository } from "../repositories/batch-job" -import { FilterableBatchJobProps } from "../types/batch-job" +import { + BatchJobCreateProps, + BatchJobStatus, + BatchJobUpdateProps, + FilterableBatchJobProps, +} from "../types/batch-job" import { FindConfig } from "../types/common" import { TransactionBaseService } from "../interfaces" -import { buildQuery, validateId } from "../utils" +import { buildQuery } from "../utils" import { MedusaError } from "medusa-core-utils" +import { EventBusService } from "./index" type InjectedDependencies = { manager: EntityManager + eventBusService: EventBusService batchJobRepository: typeof BatchJobRepository } class BatchJobService extends TransactionBaseService { - protected manager_: EntityManager - protected transactionManager_: EntityManager | undefined - protected readonly batchJobRepository_: typeof BatchJobRepository - static readonly Events = { CREATED: "batch.created", UPDATED: "batch.updated", + PRE_PROCESSED: "batch.pre_processed", + CONFIRMED: "batch.confirmed", + PROCESSING: "batch.processing", + COMPLETED: "batch.completed", CANCELED: "batch.canceled", + FAILED: "batch.failed", } - constructor({ manager, batchJobRepository }: InjectedDependencies) { - super({ manager, batchJobRepository }) + protected readonly manager_: EntityManager + protected transactionManager_: EntityManager | undefined + protected readonly batchJobRepository_: typeof BatchJobRepository + protected readonly eventBus_: EventBusService + + protected batchJobStatusMapToProps = new Map< + BatchJobStatus, + { entityColumnName: string; eventType: string } + >([ + [ + BatchJobStatus.PRE_PROCESSED, + { + entityColumnName: "pre_processed_at", + eventType: BatchJobService.Events.PRE_PROCESSED, + }, + ], + [ + BatchJobStatus.CONFIRMED, + { + entityColumnName: "confirmed_at", + eventType: BatchJobService.Events.CONFIRMED, + }, + ], + [ + BatchJobStatus.PROCESSING, + { + entityColumnName: "processing_at", + eventType: BatchJobService.Events.PROCESSING, + }, + ], + [ + BatchJobStatus.COMPLETED, + { + entityColumnName: "completed_at", + eventType: BatchJobService.Events.COMPLETED, + }, + ], + [ + BatchJobStatus.CANCELED, + { + entityColumnName: "canceled_at", + eventType: BatchJobService.Events.CANCELED, + }, + ], + [ + BatchJobStatus.FAILED, + { + entityColumnName: "failed_at", + eventType: BatchJobService.Events.FAILED, + }, + ], + ]) + + constructor({ + manager, + batchJobRepository, + eventBusService, + }: InjectedDependencies) { + super({ manager, batchJobRepository, eventBusService }) this.manager_ = manager this.batchJobRepository_ = batchJobRepository + this.eventBus_ = eventBusService } async retrieve( @@ -71,6 +136,206 @@ class BatchJobService extends TransactionBaseService { } ) } + + async create(data: BatchJobCreateProps): Promise { + return await this.atomicPhase_(async (manager) => { + const batchJobRepo: BatchJobRepository = manager.getCustomRepository( + this.batchJobRepository_ + ) + + const batchJob = batchJobRepo.create(data) + const result = await batchJobRepo.save(batchJob) + + await this.eventBus_ + .withTransaction(manager) + .emit(BatchJobService.Events.CREATED, { + id: result.id, + }) + + return result + }) + } + + async update( + batchJobId: string, + data: BatchJobUpdateProps + ): Promise { + return await this.atomicPhase_(async (manager) => { + const batchJobRepo: BatchJobRepository = manager.getCustomRepository( + this.batchJobRepository_ + ) + + let batchJob = await this.retrieve(batchJobId) + + const { context, ...rest } = data + if (context) { + batchJob.context = { ...batchJob.context, ...context } + } + + Object.keys(rest) + .filter((key) => typeof rest[key] !== `undefined`) + .forEach((key) => { + batchJob[key] = rest[key] + }) + + batchJob = await batchJobRepo.save(batchJob) + + await this.eventBus_ + .withTransaction(manager) + .emit(BatchJobService.Events.UPDATED, { + id: batchJob.id, + }) + + return batchJob + }) + } + + protected async updateStatus( + batchJobOrId: BatchJob | string, + status: BatchJobStatus + ): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + let batchJob: BatchJob = batchJobOrId as BatchJob + if (typeof batchJobOrId === "string") { + batchJob = await this.retrieve(batchJobOrId) + } + + const { entityColumnName, eventType } = + this.batchJobStatusMapToProps.get(status) || {} + + if (!entityColumnName || !eventType) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Unable to update the batch job status from ${batchJob.status} to ${status}. The status doesn't exist` + ) + } + + batchJob[entityColumnName] = new Date() + + const batchJobRepo = transactionManager.getCustomRepository( + this.batchJobRepository_ + ) + batchJob = await batchJobRepo.save(batchJob) + batchJob.loadStatus() + + this.eventBus_.withTransaction(transactionManager).emit(eventType, { + id: batchJob.id, + }) + + return batchJob + } + + async confirm(batchJobOrId: string | BatchJob): Promise { + return await this.atomicPhase_(async () => { + let batchJob: BatchJob = batchJobOrId as BatchJob + if (typeof batchJobOrId === "string") { + batchJob = await this.retrieve(batchJobOrId) + } + + if (batchJob.status !== BatchJobStatus.PRE_PROCESSED) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot confirm processing for a batch job that is not pre processed" + ) + } + + return await this.updateStatus(batchJob, BatchJobStatus.CONFIRMED) + }) + } + + async complete(batchJobOrId: string | BatchJob): Promise { + return await this.atomicPhase_(async () => { + let batchJob: BatchJob = batchJobOrId as BatchJob + if (typeof batchJobOrId === "string") { + batchJob = await this.retrieve(batchJobOrId) + } + + if (batchJob.status !== BatchJobStatus.PROCESSING) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot complete a batch job with status "${batchJob.status}". The batch job must be processing` + ) + } + + return await this.updateStatus(batchJob, BatchJobStatus.COMPLETED) + }) + } + + async cancel(batchJobOrId: string | BatchJob): Promise { + return await this.atomicPhase_(async () => { + let batchJob: BatchJob = batchJobOrId as BatchJob + if (typeof batchJobOrId === "string") { + batchJob = await this.retrieve(batchJobOrId) + } + + if (batchJob.status === BatchJobStatus.COMPLETED) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot cancel completed batch job" + ) + } + + return await this.updateStatus(batchJob, BatchJobStatus.CANCELED) + }) + } + + async setPreProcessingDone( + batchJobOrId: string | BatchJob + ): Promise { + return await this.atomicPhase_(async () => { + let batchJob: BatchJob = batchJobOrId as BatchJob + if (typeof batchJobOrId === "string") { + batchJob = await this.retrieve(batchJobOrId) + } + + if (batchJob.status === BatchJobStatus.PRE_PROCESSED) { + return batchJob + } + + if (batchJob.status !== BatchJobStatus.CREATED) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot mark a batch job as pre processed if it is not in created status" + ) + } + + batchJob = await this.updateStatus( + batchJobOrId, + BatchJobStatus.PRE_PROCESSED + ) + if (batchJob.dry_run) { + return batchJob + } + + return await this.confirm(batchJob) + }) + } + + async setProcessing( + batchJobOrId: string | BatchJob + ): Promise { + return await this.atomicPhase_(async () => { + let batchJob: BatchJob = batchJobOrId as BatchJob + if (typeof batchJobOrId === "string") { + batchJob = await this.retrieve(batchJobOrId) + } + + if (batchJob.status !== BatchJobStatus.CONFIRMED) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot mark a batch job as processing if the status is different that confirmed" + ) + } + + return await this.updateStatus(batchJob, BatchJobStatus.PROCESSING) + }) + } + + async setFailed(batchJobOrId: string | BatchJob): Promise { + return await this.atomicPhase_(async () => { + return await this.updateStatus(batchJobOrId, BatchJobStatus.FAILED) + }) + } } export default BatchJobService diff --git a/packages/medusa/src/services/index.ts b/packages/medusa/src/services/index.ts index 7f5e36a001..6a21fc63e6 100644 --- a/packages/medusa/src/services/index.ts +++ b/packages/medusa/src/services/index.ts @@ -40,3 +40,4 @@ export { default as TaxRateService } from "./tax-rate" export { default as TaxProviderService } from "./tax-provider" export { default as ProductTypeService } from "./product-type" export { default as PricingService } from "./pricing" +export { default as BatchJobService } from "./batch-job" diff --git a/packages/medusa/src/types/batch-job.ts b/packages/medusa/src/types/batch-job.ts index 4b64738ffa..48cf12878c 100644 --- a/packages/medusa/src/types/batch-job.ts +++ b/packages/medusa/src/types/batch-job.ts @@ -8,14 +8,20 @@ import { } from "class-validator" import { IsType } from "../utils/validators/is-type" import { DateComparisonOperator } from "./common" +import { BatchJob } from "../models" export enum BatchJobStatus { CREATED = "created", + PRE_PROCESSED = "pre_processed", + CONFIRMED = "confirmed", PROCESSING = "processing", - AWAITING_CONFIRMATION = "awaiting_confirmation", COMPLETED = "completed", + CANCELED = "canceled", + FAILED = "failed", } +export type BatchJobUpdateProps = Partial> + export class FilterableBatchJobProps { @IsOptional() @IsType([String, [String]]) @@ -44,3 +50,8 @@ export class FilterableBatchJobProps { @Type(() => DateComparisonOperator) updated_at?: DateComparisonOperator } + +export type BatchJobCreateProps = Pick< + BatchJob, + "context" | "type" | "created_by" | "dry_run" +>