From 4489b75f5ad669b7a53023e9afb07fb11dcb89d3 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Fri, 20 May 2022 10:22:42 +0200 Subject: [PATCH] feat(medusa): List batch jobs + Introduce composable handler pattern (#1541) --- .../batch-jobs/__snapshots__/api.js.snap | 44 ++++++ .../api/__tests__/batch-jobs/api.js | 94 ++++++++++++ integration-tests/api/factories/index.ts | 1 + .../api/factories/simple-batch-job-factory.ts | 27 ++++ .../batch-job/can-access-batch-job.ts | 20 +++ .../batch-job/get-requested-batch-job.ts | 14 ++ packages/medusa/src/api/middlewares/index.js | 3 + .../src/api/routes/admin/batch/index.ts | 41 +++++ .../api/routes/admin/batch/list-batch-jobs.ts | 142 ++++++++++++++++++ packages/medusa/src/api/routes/admin/index.js | 2 + packages/medusa/src/index.js | 1 + packages/medusa/src/models/index.ts | 21 +-- packages/medusa/src/repositories/batch-job.ts | 5 + packages/medusa/src/services/batch-job.ts | 76 ++++++++++ packages/medusa/src/types/batch-job.ts | 40 +++++ 15 files changed, 521 insertions(+), 10 deletions(-) create mode 100644 integration-tests/api/__tests__/batch-jobs/__snapshots__/api.js.snap create mode 100644 integration-tests/api/__tests__/batch-jobs/api.js create mode 100644 integration-tests/api/factories/simple-batch-job-factory.ts create mode 100644 packages/medusa/src/api/middlewares/batch-job/can-access-batch-job.ts create mode 100644 packages/medusa/src/api/middlewares/batch-job/get-requested-batch-job.ts create mode 100644 packages/medusa/src/api/routes/admin/batch/index.ts create mode 100644 packages/medusa/src/api/routes/admin/batch/list-batch-jobs.ts create mode 100644 packages/medusa/src/repositories/batch-job.ts create mode 100644 packages/medusa/src/services/batch-job.ts diff --git a/integration-tests/api/__tests__/batch-jobs/__snapshots__/api.js.snap b/integration-tests/api/__tests__/batch-jobs/__snapshots__/api.js.snap new file mode 100644 index 0000000000..237dd3c33f --- /dev/null +++ b/integration-tests/api/__tests__/batch-jobs/__snapshots__/api.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`/admin/batch GET /admin/batch lists batch jobs created by the user 1`] = ` +Object { + "batch_jobs": Array [ + Object { + "context": Object {}, + "created_at": Any, + "created_by": "admin_user", + "deleted_at": null, + "id": "job_3", + "result": null, + "status": "created", + "type": "batch_2", + "updated_at": Any, + }, + Object { + "context": Object {}, + "created_at": Any, + "created_by": "admin_user", + "deleted_at": null, + "id": "job_2", + "result": null, + "status": "created", + "type": "batch_2", + "updated_at": Any, + }, + Object { + "context": Object {}, + "created_at": Any, + "created_by": "admin_user", + "deleted_at": null, + "id": "job_1", + "result": null, + "status": "created", + "type": "batch_1", + "updated_at": Any, + }, + ], + "count": 3, + "limit": 10, + "offset": 0, +} +`; diff --git a/integration-tests/api/__tests__/batch-jobs/api.js b/integration-tests/api/__tests__/batch-jobs/api.js new file mode 100644 index 0000000000..5ce7972194 --- /dev/null +++ b/integration-tests/api/__tests__/batch-jobs/api.js @@ -0,0 +1,94 @@ +const path = require("path") + +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") + +const adminSeeder = require("../../helpers/admin-seeder") +const { simpleBatchJobFactory } = require("../../factories") + +jest.setTimeout(50000) + +const adminReqConfig = { + headers: { + Authorization: "Bearer test_token", + }, +} + +describe("/admin/batch", () => { + let medusaProcess + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd, verbose: false }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + medusaProcess.kill() + }) + + describe("GET /admin/batch", () => { + 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 + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("lists batch jobs created by the user", async () => { + const api = useApi() + const response = await api.get("/admin/batch", adminReqConfig) + + expect(response.status).toEqual(200) + expect(response.data.batch_jobs.length).toEqual(3) + expect(response.data).toMatchSnapshot({ + batch_jobs: [ + { + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + created_at: expect.any(String), + updated_at: expect.any(String), + }, + { + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + }) + }) + }) +}) diff --git a/integration-tests/api/factories/index.ts b/integration-tests/api/factories/index.ts index 4b63d87db9..42621df157 100644 --- a/integration-tests/api/factories/index.ts +++ b/integration-tests/api/factories/index.ts @@ -1,4 +1,5 @@ export * from "./simple-payment-factory" +export * from "./simple-batch-job-factory" export * from "./simple-order-factory" export * from "./simple-cart-factory" export * from "./simple-region-factory" diff --git a/integration-tests/api/factories/simple-batch-job-factory.ts b/integration-tests/api/factories/simple-batch-job-factory.ts new file mode 100644 index 0000000000..837137982c --- /dev/null +++ b/integration-tests/api/factories/simple-batch-job-factory.ts @@ -0,0 +1,27 @@ +import { Connection } from "typeorm" +import { BatchJob, BatchJobStatus } from "@medusajs/medusa" + +export type BatchJobFactoryData = { + id?: string + type?: string + status?: BatchJobStatus + created_by?: string + context?: Record +} + +export const simpleBatchJobFactory = async ( + connection: Connection, + data: BatchJobFactoryData = {} +): Promise => { + const manager = connection.manager + + const job = manager.create(BatchJob, { + id: data.id, + status: data.status ?? BatchJobStatus.CREATED, + type: data.type ?? "test-job", + created_by: data.created_by ?? null, + context: data.context ?? {}, + }) + + return await manager.save(job) +} diff --git a/packages/medusa/src/api/middlewares/batch-job/can-access-batch-job.ts b/packages/medusa/src/api/middlewares/batch-job/can-access-batch-job.ts new file mode 100644 index 0000000000..a521a8b348 --- /dev/null +++ b/packages/medusa/src/api/middlewares/batch-job/can-access-batch-job.ts @@ -0,0 +1,20 @@ +import { MedusaError } from "medusa-core-utils" +import BatchJobService from "../../../services/batch-job" + +export async function canAccessBatchJob(req, res, next) { + const id = req.params.id + const batchJobService: BatchJobService = req.scope.resolve("batchJobService") + const batch_job = req.batch_job ?? (await batchJobService.retrieve(id)) + + const userId = req.user.id ?? req.user.userId + if (batch_job.created_by !== userId) { + return next( + new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot access a batch job that does not belong to the logged in user" + ) + ) + } + + next() +} diff --git a/packages/medusa/src/api/middlewares/batch-job/get-requested-batch-job.ts b/packages/medusa/src/api/middlewares/batch-job/get-requested-batch-job.ts new file mode 100644 index 0000000000..2920d66ab7 --- /dev/null +++ b/packages/medusa/src/api/middlewares/batch-job/get-requested-batch-job.ts @@ -0,0 +1,14 @@ +import BatchJobService from "../../../services/batch-job" + +export async function getRequestedBatchJob(req, res, next) { + const id = req.params.id + const batchJobService: BatchJobService = req.scope.resolve("batchJobService") + + try { + req.batch_job = await batchJobService.retrieve(id) + } catch (error) { + return next(error) + } + + next() +} diff --git a/packages/medusa/src/api/middlewares/index.js b/packages/medusa/src/api/middlewares/index.js index a724c2803e..5844904f49 100644 --- a/packages/medusa/src/api/middlewares/index.js +++ b/packages/medusa/src/api/middlewares/index.js @@ -3,6 +3,9 @@ import { default as authenticate } from "./authenticate" import { default as normalizeQuery } from "./normalized-query" import { default as wrap } from "./await-middleware" +export { getRequestedBatchJob } from "./batch-job/get-requested-batch-job" +export { canAccessBatchJob } from "./batch-job/can-access-batch-job" + export default { authenticate, authenticateCustomer, diff --git a/packages/medusa/src/api/routes/admin/batch/index.ts b/packages/medusa/src/api/routes/admin/batch/index.ts new file mode 100644 index 0000000000..af5f81e7b5 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/batch/index.ts @@ -0,0 +1,41 @@ +import { Router } from "express" +import { BatchJob } from "../../../.." +import { DeleteResponse, PaginatedResponse } from "../../../../types/common" +import middlewares from "../../../middlewares" + +export default (app) => { + const route = Router() + + app.use("/batch", route) + + route.get( + "/", + middlewares.normalizeQuery(), + middlewares.wrap(require("./list-batch-jobs").default) + ) + return app +} + +export type AdminBatchJobRes = { + batch_job: BatchJob +} + +export type AdminBatchJobDeleteRes = DeleteResponse + +export type AdminBatchJobListRes = PaginatedResponse & { + batch_jobs: BatchJob[] +} + +export const defaultAdminBatchFields = [ + "id", + "status", + "type", + "context", + "result", + "created_by", + "created_at", + "updated_at", + "deleted_at", +] + +export * from "./list-batch-jobs" 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 new file mode 100644 index 0000000000..432849e4b2 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/batch/list-batch-jobs.ts @@ -0,0 +1,142 @@ +import { MedusaError } from "medusa-core-utils" +import { Type } from "class-transformer" +import { + IsArray, + IsEnum, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" +import { pickBy, omit, identity } from "lodash" +import { defaultAdminBatchFields } from "." +import BatchJobService from "../../../../services/batch-job" +import { BatchJob } from "../../../../models" +import { BatchJobStatus } from "../../../../types/batch-job" +import { DateComparisonOperator } from "../../../../types/common" +import { IsType } from "../../../../utils/validators/is-type" +import { getListConfig } from "../../../../utils/get-query-config" +import { validator } from "../../../../utils/validator" + +/** + * @oas [get] /batch + * operationId: "GetBatch" + * summary: "List Batch Jobs" + * description: "Retrieve a list of Batch Jobs." + * x-authenticated: true + * parameters: + * - (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) 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. + * - (query) deleted_at {DateComparisonOperator} Date comparison for when resulting collections was deleted, i.e. less than, greater than etc. + * - (query) created_at {DateComparisonOperator} Date comparison for when resulting collections was created, i.e. less than, greater than etc. + * - (query) updated_at {DateComparisonOperator} Date comparison for when resulting collections was updated, i.e. less than, greater than etc. + * 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 { fields, expand, order, limit, offset, ...filterableFields } = + await validator(AdminGetBatchParams, req.query) + + const batchService: BatchJobService = req.scope.resolve("batchJobService") + + let orderBy: { [k: symbol]: "DESC" | "ASC" } | undefined + if (typeof order !== "undefined") { + if (order.startsWith("-")) { + const [, field] = order.split("-") + orderBy = { [field]: "DESC" } + } else { + orderBy = { [order]: "ASC" } + } + } + + const listConfig = getListConfig( + defaultAdminBatchFields as (keyof BatchJob)[], + [], + fields?.split(",") as (keyof BatchJob)[], + expand?.split(","), + limit, + offset, + orderBy + ) + + const created_by: string = req.user.id || req.user.userId + + const [jobs, count] = await batchService.listAndCount( + pickBy( + { created_by, ...filterableFields }, + (val) => typeof val !== "undefined" + ), + listConfig + ) + + res.status(200).json({ + batch_jobs: jobs, + count, + offset: offset, + limit: limit, + }) +} + +export class AdminGetBatchPaginationParams { + @IsNumber() + @IsOptional() + @Type(() => Number) + limit = 10 + + @IsNumber() + @IsOptional() + @Type(() => Number) + offset = 0 + + @IsString() + @IsOptional() + expand?: string + + @IsString() + @IsOptional() + fields?: string + + @IsString() + @IsOptional() + order?: string +} + +export class AdminGetBatchParams extends AdminGetBatchPaginationParams { + @IsOptional() + @IsArray() + @IsType([String, [String]]) + id?: string | string[] + + @IsOptional() + @IsArray() + @IsEnum(BatchJobStatus, { each: true }) + status?: BatchJobStatus[] + + @IsArray() + @IsOptional() + type?: string[] + + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + created_at?: DateComparisonOperator + + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + updated_at?: DateComparisonOperator +} diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index bdc4cd808d..31d5359deb 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -14,6 +14,7 @@ import noteRoutes from "./notes" import notificationRoutes from "./notifications" import orderRoutes from "./orders" import priceListRoutes from "./price-lists" +import batchRoutes from "./batch" import productTagRoutes from "./product-tags" import productTypesRoutes from "./product-types" import productRoutes from "./products" @@ -63,6 +64,7 @@ export default (app, container, config) => { appRoutes(route) productRoutes(route) + batchRoutes(route) userRoutes(route) regionRoutes(route) shippingOptionRoutes(route) diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index 7854508490..bbef3b1412 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -1,5 +1,6 @@ export * from "./api" export * from "./interfaces" export * from "./types/price-list" +export * from "./types/batch-job" export * from "./models" export * from "./services" diff --git a/packages/medusa/src/models/index.ts b/packages/medusa/src/models/index.ts index 31ebea7f68..498ea0da40 100644 --- a/packages/medusa/src/models/index.ts +++ b/packages/medusa/src/models/index.ts @@ -1,11 +1,5 @@ -export * from "./shipping-tax-rate" -export * from "./product-tax-rate" -export * from "./product-type-tax-rate" -export * from "./tax-rate" -export * from "./shipping-method-tax-line" -export * from "./line-item-tax-line" -export * from "./line-item-adjustment" export * from "./address" +export * from "./batch-job" export * from "./cart" export * from "./claim-image" export * from "./claim-item" @@ -17,12 +11,12 @@ export * from "./custom-shipping-option" export * from "./customer" export * from "./customer-group" export * from "./discount" +export * from "./discount-condition" export * from "./discount-condition-customer-group" +export * from "./discount-condition-product" export * from "./discount-condition-product-collection" export * from "./discount-condition-product-tag" export * from "./discount-condition-product-type" -export * from "./discount-condition-product" -export * from "./discount-condition" export * from "./discount-rule" export * from "./draft-order" export * from "./fulfillment" @@ -34,6 +28,8 @@ export * from "./idempotency-key" export * from "./image" export * from "./invite" export * from "./line-item" +export * from "./line-item-adjustment" +export * from "./line-item-tax-line" export * from "./money-amount" export * from "./note" export * from "./notification" @@ -48,7 +44,9 @@ export * from "./product-collection" export * from "./product-option" export * from "./product-option-value" export * from "./product-tag" +export * from "./product-tax-rate" export * from "./product-type" +export * from "./product-type-tax-rate" export * from "./product-variant" export * from "./refund" export * from "./region" @@ -56,11 +54,14 @@ export * from "./return" export * from "./return-item" export * from "./return-reason" export * from "./shipping-method" +export * from "./shipping-method-tax-line" export * from "./shipping-option" export * from "./shipping-option-requirement" export * from "./shipping-profile" +export * from "./shipping-tax-rate" export * from "./staged-job" export * from "./store" export * from "./swap" -export * from "./user" export * from "./tax-provider" +export * from "./tax-rate" +export * from "./user" diff --git a/packages/medusa/src/repositories/batch-job.ts b/packages/medusa/src/repositories/batch-job.ts new file mode 100644 index 0000000000..eee8cfe738 --- /dev/null +++ b/packages/medusa/src/repositories/batch-job.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm" +import { BatchJob } from "../models" + +@EntityRepository(BatchJob) +export class BatchJobRepository extends Repository {} diff --git a/packages/medusa/src/services/batch-job.ts b/packages/medusa/src/services/batch-job.ts new file mode 100644 index 0000000000..37f27a2b98 --- /dev/null +++ b/packages/medusa/src/services/batch-job.ts @@ -0,0 +1,76 @@ +import { EntityManager } from "typeorm" + +import { BatchJob } from "../models" +import { BatchJobRepository } from "../repositories/batch-job" +import { FilterableBatchJobProps } from "../types/batch-job" +import { FindConfig } from "../types/common" +import { TransactionBaseService } from "../interfaces" +import { buildQuery, validateId } from "../utils" +import { MedusaError } from "medusa-core-utils" + +type InjectedDependencies = { + manager: EntityManager + 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", + CANCELED: "batch.canceled", + } + + constructor({ manager, batchJobRepository }: InjectedDependencies) { + super({ manager, batchJobRepository }) + + this.manager_ = manager + this.batchJobRepository_ = batchJobRepository + } + + async retrieve( + batchJobId: string, + config: FindConfig = {} + ): Promise { + return await this.atomicPhase_( + async (transactionManager: EntityManager) => { + const batchJobRepo = transactionManager.getCustomRepository( + this.batchJobRepository_ + ) + + const query = buildQuery({ id: batchJobId }, config) + const batchJob = await batchJobRepo.findOne(query) + + if (!batchJob) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Batch job with id ${batchJobId} was not found` + ) + } + + return batchJob + } + ) + } + + async listAndCount( + selector: FilterableBatchJobProps = {}, + config: FindConfig = { skip: 0, take: 20 } + ): Promise<[BatchJob[], number]> { + return await this.atomicPhase_( + async (manager: EntityManager): Promise<[BatchJob[], number]> => { + const batchJobRepo = manager.getCustomRepository( + this.batchJobRepository_ + ) + + const query = buildQuery(selector, config) + return await batchJobRepo.findAndCount(query) + } + ) + } +} + +export default BatchJobService diff --git a/packages/medusa/src/types/batch-job.ts b/packages/medusa/src/types/batch-job.ts index 87624fe26e..4b64738ffa 100644 --- a/packages/medusa/src/types/batch-job.ts +++ b/packages/medusa/src/types/batch-job.ts @@ -1,6 +1,46 @@ +import { Type } from "class-transformer" +import { + IsArray, + IsEnum, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" +import { IsType } from "../utils/validators/is-type" +import { DateComparisonOperator } from "./common" + export enum BatchJobStatus { CREATED = "created", PROCESSING = "processing", AWAITING_CONFIRMATION = "awaiting_confirmation", COMPLETED = "completed", } + +export class FilterableBatchJobProps { + @IsOptional() + @IsType([String, [String]]) + id?: string | string[] + + @IsOptional() + @IsEnum(BatchJobStatus, { each: true }) + status?: BatchJobStatus[] + + @IsArray() + @IsOptional() + type?: string[] + + @IsString() + @IsOptional() + @IsType([String, [String]]) + created_by?: string | string[] + + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + created_at?: DateComparisonOperator + + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + updated_at?: DateComparisonOperator +}