feat: add and remove products to/from collection in bulk endpoints (#1032)
* adds bulk add/remove products to/from collection. Adds endpoint updateProducts on collections that uses these bulk operations * fix integration tests and test description * undo change to swap * made requested changes * added removeProducts endpoint * made requested changes * fix: set collection_id null * updated collection_id to type string | undefined
This commit is contained in:
committed by
olivermrbl
parent
07a13f6faf
commit
1e4cc2fc80
@@ -0,0 +1,91 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ProductCollectionServiceMock } from "../../../../../services/__mocks__/product-collection"
|
||||
|
||||
describe("POST /admin/collections/:id/products/batch", () => {
|
||||
describe("successfully adds products to collection", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
"POST",
|
||||
`/admin/collections/${IdMap.getId("col")}/products/batch`,
|
||||
{
|
||||
payload: {
|
||||
product_ids: ["prod_1", "prod_2"],
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("returns updated collection with new products", () => {
|
||||
expect(subject.body.collection.id).toEqual(IdMap.getId("col"))
|
||||
})
|
||||
|
||||
it("product collection service update", () => {
|
||||
expect(ProductCollectionServiceMock.addProducts).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
ProductCollectionServiceMock.addProducts
|
||||
).toHaveBeenCalledWith(IdMap.getId("col"), ["prod_1", "prod_2"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("error on non-existing collection", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
"POST",
|
||||
`/admin/collections/null/products/batch`,
|
||||
{
|
||||
payload: {
|
||||
product_ids: ["prod_1", "prod_2"],
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("throws error", () => {
|
||||
expect(subject.body.message).toBe("Product collection not found")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error invalid request", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
"POST",
|
||||
`/admin/collections/${IdMap.getId("col")}/products/batch`,
|
||||
{
|
||||
payload: {
|
||||
product_ids: [],
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 400", () => {
|
||||
expect(subject.status).toEqual(400)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { ProductCollectionServiceMock } from "../../../../../services/__mocks__/product-collection"
|
||||
|
||||
describe("DELETE /admin/collections/:id/products/batch", () => {
|
||||
describe("successfully removes products from collection", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
"DELETE",
|
||||
`/admin/collections/${IdMap.getId("col")}/products/batch`,
|
||||
{
|
||||
payload: {
|
||||
product_ids: ["prod_1", "prod_2"],
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("product collection service remove products", () => {
|
||||
expect(ProductCollectionServiceMock.removeProducts).toHaveBeenCalledTimes(
|
||||
1
|
||||
)
|
||||
expect(
|
||||
ProductCollectionServiceMock.removeProducts
|
||||
).toHaveBeenCalledWith(IdMap.getId("col"), ["prod_1", "prod_2"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("error on invalid request", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request(
|
||||
"DELETE",
|
||||
`/admin/collections/${IdMap.getId("col")}/products/batch`,
|
||||
{
|
||||
payload: {
|
||||
product_ids: [],
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 400", () => {
|
||||
expect(subject.status).toEqual(400)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ArrayNotEmpty, IsString } from "class-validator"
|
||||
import ProductCollectionService from "../../../../services/product-collection"
|
||||
import { validator } from "../../../../utils/validator"
|
||||
/**
|
||||
* @oas [post] /collections/{id}/products/batch
|
||||
* operationId: "PostProductsToCollection"
|
||||
* summary: "Updates products associated with a Product Collection"
|
||||
* description: "Updates products associated with a Product Collection"
|
||||
* x-authenticated: true
|
||||
* parameters:
|
||||
* - (path) id=* {string} The id of the Collection.
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* properties:
|
||||
* product_ids:
|
||||
* description: "An array of Product IDs to add to the Product Collection."
|
||||
* type: array
|
||||
* items:
|
||||
* properties:
|
||||
* id:
|
||||
* description: "The ID of a Product to add to the Product Collection."
|
||||
* type: string
|
||||
* tags:
|
||||
* - Collection
|
||||
* responses:
|
||||
* "200":
|
||||
* description: OK
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
const { id } = req.params
|
||||
|
||||
const validated = await validator(AdminPostProductsToCollectionReq, req.body)
|
||||
|
||||
const productCollectionService: ProductCollectionService = req.scope.resolve(
|
||||
"productCollectionService"
|
||||
)
|
||||
|
||||
const collection = await productCollectionService.addProducts(
|
||||
id,
|
||||
validated.product_ids
|
||||
)
|
||||
|
||||
res.status(200).json({ collection })
|
||||
}
|
||||
|
||||
export class AdminPostProductsToCollectionReq {
|
||||
@ArrayNotEmpty()
|
||||
@IsString({ each: true })
|
||||
product_ids: string[]
|
||||
}
|
||||
@@ -17,6 +17,9 @@ export default (app) => {
|
||||
route.get("/:id", middlewares.wrap(require("./get-collection").default))
|
||||
route.get("/", middlewares.wrap(require("./list-collections").default))
|
||||
|
||||
route.post("/:id/products/batch", middlewares.wrap(require("./add-products").default))
|
||||
route.delete("/:id/products/batch", middlewares.wrap(require("./remove-products").default))
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ArrayNotEmpty, IsString } from "class-validator"
|
||||
import ProductCollectionService from "../../../../services/product-collection"
|
||||
import { validator } from "../../../../utils/validator"
|
||||
/**
|
||||
* @oas [delete] /collections/{id}/products/batch
|
||||
* operationId: "DeleteProductsFromCollection"
|
||||
* summary: "Removes products associated with a Product Collection"
|
||||
* description: "Removes products associated with a Product Collection"
|
||||
* x-authenticated: true
|
||||
* parameters:
|
||||
* - (path) id=* {string} The id of the Collection.
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* properties:
|
||||
* product_ids:
|
||||
* description: "An array of Product IDs to remove from the Product Collection."
|
||||
* type: array
|
||||
* items:
|
||||
* properties:
|
||||
* id:
|
||||
* description: "The ID of a Product to remove from the Product Collection."
|
||||
* type: string
|
||||
* tags:
|
||||
* - Collection
|
||||
* responses:
|
||||
* "200":
|
||||
* description: OK
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
const { id } = req.params
|
||||
|
||||
const validated = await validator(
|
||||
AdminDeleteProductsFromCollectionReq,
|
||||
req.body
|
||||
)
|
||||
|
||||
const productCollectionService: ProductCollectionService = req.scope.resolve(
|
||||
"productCollectionService"
|
||||
)
|
||||
|
||||
await productCollectionService.removeProducts(id, validated.product_ids)
|
||||
|
||||
res.json({
|
||||
id,
|
||||
object: "product-collection",
|
||||
removed_products: validated.product_ids,
|
||||
})
|
||||
}
|
||||
|
||||
export class AdminDeleteProductsFromCollectionReq {
|
||||
@ArrayNotEmpty()
|
||||
@IsString({ each: true })
|
||||
product_ids: string[]
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
import _ from "lodash"
|
||||
import {
|
||||
Entity,
|
||||
Index,
|
||||
BeforeInsert,
|
||||
Column,
|
||||
DeleteDateColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
PrimaryColumn,
|
||||
OneToOne,
|
||||
OneToMany,
|
||||
ManyToOne,
|
||||
ManyToMany,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm"
|
||||
import { ulid } from "ulid"
|
||||
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
|
||||
|
||||
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
|
||||
import { Image } from "./image"
|
||||
import { ProductCollection } from "./product-collection"
|
||||
import { ProductOption } from "./product-option"
|
||||
@@ -24,7 +23,6 @@ import { ProductTag } from "./product-tag"
|
||||
import { ProductType } from "./product-type"
|
||||
import { ProductVariant } from "./product-variant"
|
||||
import { ShippingProfile } from "./shipping-profile"
|
||||
import _ from "lodash"
|
||||
|
||||
export enum Status {
|
||||
DRAFT = "draft",
|
||||
@@ -76,13 +74,13 @@ export class Product {
|
||||
|
||||
@OneToMany(
|
||||
() => ProductOption,
|
||||
productOption => productOption.product
|
||||
(productOption) => productOption.product
|
||||
)
|
||||
options: ProductOption[]
|
||||
|
||||
@OneToMany(
|
||||
() => ProductVariant,
|
||||
variant => variant.product,
|
||||
(variant) => variant.product,
|
||||
{ cascade: true }
|
||||
)
|
||||
variants: ProductVariant[]
|
||||
@@ -120,7 +118,7 @@ export class Product {
|
||||
material: string
|
||||
|
||||
@Column({ nullable: true })
|
||||
collection_id: string
|
||||
collection_id: string | null
|
||||
|
||||
@ManyToOne(() => ProductCollection)
|
||||
@JoinColumn({ name: "collection_id" })
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
EntityRepository,
|
||||
FindManyOptions,
|
||||
FindOperator,
|
||||
In,
|
||||
OrderByCondition,
|
||||
Repository,
|
||||
} from "typeorm"
|
||||
@@ -76,7 +77,9 @@ export class ProductRepository extends Repository<Product> {
|
||||
return [entities, count]
|
||||
}
|
||||
|
||||
private getGroupedRelations(relations: Array<keyof Product>): {
|
||||
private getGroupedRelations(
|
||||
relations: Array<keyof Product>
|
||||
): {
|
||||
[toplevel: string]: string[]
|
||||
} {
|
||||
const groupedRelations: { [toplevel: string]: string[] } = {}
|
||||
@@ -194,8 +197,9 @@ export class ProductRepository extends Repository<Product> {
|
||||
)
|
||||
|
||||
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
|
||||
const entitiesToReturn =
|
||||
this.mergeEntitiesWithRelations(entitiesAndRelations)
|
||||
const entitiesToReturn = this.mergeEntitiesWithRelations(
|
||||
entitiesAndRelations
|
||||
)
|
||||
|
||||
return [entitiesToReturn, count]
|
||||
}
|
||||
@@ -236,8 +240,9 @@ export class ProductRepository extends Repository<Product> {
|
||||
)
|
||||
|
||||
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
|
||||
const entitiesToReturn =
|
||||
this.mergeEntitiesWithRelations(entitiesAndRelations)
|
||||
const entitiesToReturn = this.mergeEntitiesWithRelations(
|
||||
entitiesAndRelations
|
||||
)
|
||||
|
||||
return entitiesToReturn
|
||||
}
|
||||
@@ -255,4 +260,30 @@ export class ProductRepository extends Repository<Product> {
|
||||
)
|
||||
return result[0]
|
||||
}
|
||||
|
||||
public async bulkAddToCollection(
|
||||
productIds: string[],
|
||||
collectionId: string
|
||||
): Promise<Product[]> {
|
||||
await this.createQueryBuilder()
|
||||
.update(Product)
|
||||
.set({ collection_id: collectionId })
|
||||
.where({ id: In(productIds) })
|
||||
.execute()
|
||||
|
||||
return this.findByIds(productIds)
|
||||
}
|
||||
|
||||
public async bulkRemoveFromCollection(
|
||||
productIds: string[],
|
||||
collectionId: string
|
||||
): Promise<Product[]> {
|
||||
await this.createQueryBuilder()
|
||||
.update(Product)
|
||||
.set({ collection_id: null })
|
||||
.where({ id: In(productIds), collection_id: collectionId })
|
||||
.execute()
|
||||
|
||||
return this.findByIds(productIds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IdMap } from "medusa-test-utils"
|
||||
|
||||
export const ProductCollectionServiceMock = {
|
||||
withTransaction: function () {
|
||||
withTransaction: function() {
|
||||
return this
|
||||
},
|
||||
create: jest.fn().mockImplementation((data) => {
|
||||
@@ -16,6 +16,16 @@ export const ProductCollectionServiceMock = {
|
||||
update: jest.fn().mockImplementation((id, value) => {
|
||||
return Promise.resolve({ id, title: value })
|
||||
}),
|
||||
addProducts: jest.fn().mockImplementation((id, product_ids) => {
|
||||
if (id === IdMap.getId("col")) {
|
||||
return Promise.resolve({
|
||||
id,
|
||||
products: product_ids.map((i) => ({ id: i })),
|
||||
})
|
||||
}
|
||||
throw new Error("Product collection not found")
|
||||
}),
|
||||
removeProducts: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
list: jest.fn().mockImplementation((data) => {
|
||||
return Promise.resolve([{ id: IdMap.getId("col"), title: "Suits" }])
|
||||
}),
|
||||
|
||||
@@ -162,6 +162,32 @@ class ProductCollectionService extends BaseService {
|
||||
})
|
||||
}
|
||||
|
||||
async addProducts(collectionId, productIds) {
|
||||
return this.atomicPhase_(async (manager) => {
|
||||
const productRepo = manager.getCustomRepository(this.productRepository_)
|
||||
|
||||
const { id } = await this.retrieve(collectionId, { select: ["id"] })
|
||||
|
||||
await productRepo.bulkAddToCollection(productIds, id)
|
||||
|
||||
return await this.retrieve(id, {
|
||||
relations: ["products"],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async removeProducts(collectionId, productIds) {
|
||||
return this.atomicPhase_(async (manager) => {
|
||||
const productRepo = manager.getCustomRepository(this.productRepository_)
|
||||
|
||||
const { id } = await this.retrieve(collectionId, { select: ["id"] })
|
||||
|
||||
await productRepo.bulkRemoveFromCollection(productIds, id)
|
||||
|
||||
return Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists product collections
|
||||
* @param {Object} selector - the query object for find
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseService } from "medusa-interfaces"
|
||||
import { MedusaError } from "medusa-core-utils"
|
||||
import { BaseService } from "medusa-interfaces"
|
||||
|
||||
/**
|
||||
* Handles swaps
|
||||
|
||||
Reference in New Issue
Block a user