feat(medusa): List batch jobs + Introduce composable handler pattern (#1541)

This commit is contained in:
Adrien de Peretti
2022-05-20 10:22:42 +02:00
committed by GitHub
parent f0ecef6b9a
commit 4489b75f5a
15 changed files with 521 additions and 10 deletions

View File

@@ -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<String>,
"created_by": "admin_user",
"deleted_at": null,
"id": "job_3",
"result": null,
"status": "created",
"type": "batch_2",
"updated_at": Any<String>,
},
Object {
"context": Object {},
"created_at": Any<String>,
"created_by": "admin_user",
"deleted_at": null,
"id": "job_2",
"result": null,
"status": "created",
"type": "batch_2",
"updated_at": Any<String>,
},
Object {
"context": Object {},
"created_at": Any<String>,
"created_by": "admin_user",
"deleted_at": null,
"id": "job_1",
"result": null,
"status": "created",
"type": "batch_1",
"updated_at": Any<String>,
},
],
"count": 3,
"limit": 10,
"offset": 0,
}
`;

View File

@@ -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),
},
],
})
})
})
})

View File

@@ -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"

View File

@@ -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<string, unknown>
}
export const simpleBatchJobFactory = async (
connection: Connection,
data: BatchJobFactoryData = {}
): Promise<BatchJob> => {
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)
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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,

View File

@@ -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"

View File

@@ -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<BatchJob>(
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
}

View File

@@ -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)

View File

@@ -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"

View File

@@ -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"

View File

@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { BatchJob } from "../models"
@EntityRepository(BatchJob)
export class BatchJobRepository extends Repository<BatchJob> {}

View File

@@ -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<BatchJobService> {
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<BatchJob> = {}
): Promise<BatchJob | never> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const batchJobRepo = transactionManager.getCustomRepository(
this.batchJobRepository_
)
const query = buildQuery<BatchJob>({ 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<BatchJob> = { 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

View File

@@ -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
}