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:
Kasper Fabricius Kristensen
2022-02-25 18:53:49 +01:00
committed by olivermrbl
parent 07a13f6faf
commit 1e4cc2fc80
14 changed files with 580 additions and 73 deletions
@@ -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[]
}
+13 -15
View File
@@ -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" })
+36 -5
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
/**
* Handles swaps