refactor(medusa): Migrate ProductService to TS (#1625)

* refactor: product service

* fix: type errors in consumers

* fix: use Status enum instead of ProductStatus

* fix: remove ProductStatus

* fix: rename ProductStatus

* fix: product model nullable fields

* fix: explicit typecasting in listVariants

* fix: use atomicPhase in public methods

* fix: use transactionManager in protected methods

* fix: remove disable eslint rule comment

* fix: retrieveVariants relations

* fix: retrieveVariants

* fix: FilterableProductProps validation

* fix: nullable thumbnail

* fix: tests by making model column types more explicit

* move upsert method to repo layer

* fix: unit tests

* fix: integration tests

* fix: product tags query + integration test

* fix: rename FindWithRelationsOptions to FindWithoutRelationsOptions

* fix (productRepository): use string[] for relations

* refactor: extract price relation filtering logic into util

* fix: failing unit test

* rename DTO suffix to Input suffix

* fix: missing awaits

* fix: check for images and tags length

* fix: remove unneeded function

* extract types used in product service to types folder

* fix: use text instead of varchar

* remove: reorderOptions from ProductService

* fix: product model

* fix: add private retrieve method

* fix: conflicts

* fix: failing unit test

* fix: integration test

* fix: remove ProductSelector type

* fix: remove validateId

* fix: use spread operator

* fix: repo method typings

* fix: remove comment
This commit is contained in:
Zakaria El Asri
2022-06-20 10:50:46 +01:00
committed by GitHub
parent 9e686a8e47
commit f0be31120f
32 changed files with 808 additions and 650 deletions

View File

@@ -29,7 +29,7 @@ module.exports = {
files: [`*.ts`],
parser: `@typescript-eslint/parser`,
plugins: [`@typescript-eslint/eslint-plugin`],
extends: [`plugin:@typescript-eslint/recommended`],
extends: [`plugin:@typescript-eslint/recommended`, "prettier"],
rules: {
"valid-jsdoc": [
"error",

View File

@@ -12,13 +12,13 @@ Array [
"created_at": Any<String>,
"id": "tag3",
"updated_at": Any<String>,
"value": "123",
"value": "1235",
},
Object {
"created_at": Any<String>,
"id": "tag4",
"updated_at": Any<String>,
"value": "123",
"value": "1234",
},
]
`;
@@ -35,13 +35,13 @@ Array [
"created_at": Any<String>,
"id": "tag3",
"updated_at": Any<String>,
"value": "123",
"value": "1235",
},
Object {
"created_at": Any<String>,
"id": "tag4",
"updated_at": Any<String>,
"value": "123",
"value": "1234",
},
]
`;

View File

@@ -89,11 +89,10 @@ describe("/admin/product-tags", () => {
updated_at: expect.any(String),
}
expect(res.data.product_tags.map((pt) => pt.value)).toEqual([
"123",
"123",
"123",
])
expect(res.data.product_tags.length).toEqual(3)
expect(res.data.product_tags.map((pt) => pt.value)).toEqual(
expect.arrayContaining(["123", "1235", "1234"])
)
expect(res.data.product_tags).toMatchSnapshot([
tagMatch,

View File

@@ -2250,4 +2250,45 @@ describe("/admin/products", () => {
)
})
})
describe("GET /admin/products/tag-usage", () => {
beforeEach(async () => {
try {
await productSeeder(dbConnection)
await adminSeeder(dbConnection)
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("successfully gets the tags usage", async () => {
const api = useApi()
const res = await api
.get("/admin/products/tag-usage", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err)
})
expect(res.status).toEqual(200)
expect(res.data.tags.length).toEqual(3)
expect(res.data.tags).toEqual(
expect.arrayContaining([
{ id: "tag1", usage_count: "2", value: "123" },
{ id: "tag3", usage_count: "2", value: "1235" },
{ id: "tag4", usage_count: "1", value: "1234" },
])
)
})
})
})

View File

@@ -50,14 +50,14 @@ module.exports = async (connection, data = {}) => {
const tag3 = await manager.create(ProductTag, {
id: "tag3",
value: "123",
value: "1235",
})
await manager.save(tag3)
const tag4 = await manager.create(ProductTag, {
id: "tag4",
value: "123",
value: "1234",
})
await manager.save(tag4)

View File

@@ -8,6 +8,7 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { ProductStatus } from "../../../../models"
import { DateComparisonOperator } from "../../../../types/common"
import { FilterableProductProps } from "../../../../types/product"
import { AdminGetProductsPaginationParams } from "../products"
@@ -88,13 +89,6 @@ export default async (req: Request, res) => {
})
}
enum ProductStatus {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
REJECTED = "rejected",
}
export class AdminGetPriceListsPriceListProductsParams extends AdminGetProductsPaginationParams {
@IsString()
@IsOptional()

View File

@@ -17,7 +17,7 @@ import {
ProductVariantService,
ShippingProfileService,
} from "../../../../services"
import { ProductStatus } from "../../../../types/product"
import { ProductStatus } from "../../../../models"
import { ProductVariantPricesCreateReq } from "../../../../types/product-variant"
import { validator } from "../../../../utils/validator"
@@ -376,7 +376,7 @@ class ProductVariantReq {
@IsObject()
@IsOptional()
metadata?: object
metadata?: Record<string, unknown>
@IsArray()
@ValidateNested({ each: true })
@@ -485,5 +485,5 @@ export class AdminPostProductsReq {
@IsObject()
@IsOptional()
metadata?: object
metadata?: Record<string, unknown>
}

View File

@@ -75,7 +75,7 @@ export const defaultAdminProductRelations = [
"collection",
]
export const defaultAdminProductFields = [
export const defaultAdminProductFields: (keyof Product)[] = [
"id",
"title",
"subtitle",

View File

@@ -9,7 +9,7 @@ import {
ValidateNested,
} from "class-validator"
import { omit } from "lodash"
import { Product } from "../../../../models/product"
import { Product, ProductStatus } from "../../../../models/product"
import { DateComparisonOperator } from "../../../../types/common"
import {
allowedAdminProductFields,
@@ -97,13 +97,6 @@ export default async (req, res) => {
res.json(result)
}
export enum ProductStatus {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
REJECTED = "rejected",
}
export class AdminGetProductsPaginationParams {
@IsNumber()
@IsOptional()

View File

@@ -12,11 +12,8 @@ import {
ValidateIf,
ValidateNested,
} from "class-validator"
import {
defaultAdminProductFields,
defaultAdminProductRelations,
ProductStatus,
} from "."
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { ProductStatus } from "../../../../models"
import { ProductService, PricingService } from "../../../../services"
import { ProductVariantPricesUpdateReq } from "../../../../types/product-variant"
import { validator } from "../../../../utils/validator"
@@ -320,7 +317,7 @@ class ProductVariantReq {
@IsObject()
@IsOptional()
metadata?: object
metadata?: Record<string, unknown>
@IsArray()
@IsOptional()
@@ -424,5 +421,5 @@ export class AdminPostProductsProductReq {
@IsObject()
@IsOptional()
metadata?: object
metadata?: Record<string, unknown>
}

View File

@@ -20,6 +20,7 @@ import { PriceSelectionParams } from "../../../../types/price-selection"
import { validator } from "../../../../utils/validator"
import { IsType } from "../../../../utils/validators/is-type"
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
import { Product } from "../../../../models"
/**
* @oas [get] /products
@@ -85,9 +86,9 @@ export default async (req, res) => {
// get only published products for store endpoint
filterableFields["status"] = ["published"]
let includeFields: string[] = []
let includeFields: (keyof Product)[] = []
if (validated.fields) {
const set = new Set(validated.fields.split(","))
const set = new Set(validated.fields.split(",")) as Set<keyof Product>
set.add("id")
includeFields = [...set]
}

View File

@@ -6,6 +6,7 @@ import { Product } from "../../models/product"
import { ProductService, PricingService } from "../../services"
import { getListConfig } from "../../utils/get-query-config"
import { FilterableProductProps } from "../../types/product"
import { PricedProduct } from "../../types/pricing"
type ListContext = {
limit: number
@@ -73,7 +74,7 @@ const listAndCount = async (
listConfig
)
let products = rawProducts
let products: (Product | PricedProduct)[] = rawProducts
const includesPricing = ["variants", "variants.prices"].every((relation) =>
listConfig?.relations?.includes(relation)

View File

@@ -30,7 +30,10 @@ export class LineItem extends BaseEntity {
@Column({ nullable: true })
cart_id: string
@ManyToOne(() => Cart, (cart) => cart.items)
@ManyToOne(
() => Cart,
(cart) => cart.items
)
@JoinColumn({ name: "cart_id" })
cart: Cart
@@ -38,7 +41,10 @@ export class LineItem extends BaseEntity {
@Column({ nullable: true })
order_id: string
@ManyToOne(() => Order, (order) => order.items)
@ManyToOne(
() => Order,
(order) => order.items
)
@JoinColumn({ name: "order_id" })
order: Order
@@ -46,7 +52,10 @@ export class LineItem extends BaseEntity {
@Column({ nullable: true })
swap_id: string
@ManyToOne(() => Swap, (swap) => swap.additional_items)
@ManyToOne(
() => Swap,
(swap) => swap.additional_items
)
@JoinColumn({ name: "swap_id" })
swap: Swap
@@ -54,16 +63,27 @@ export class LineItem extends BaseEntity {
@Column({ nullable: true })
claim_order_id: string
@ManyToOne(() => ClaimOrder, (co) => co.additional_items)
@ManyToOne(
() => ClaimOrder,
(co) => co.additional_items
)
@JoinColumn({ name: "claim_order_id" })
claim_order: ClaimOrder
@OneToMany(() => LineItemTaxLine, (tl) => tl.item, { cascade: ["insert"] })
@OneToMany(
() => LineItemTaxLine,
(tl) => tl.item,
{ cascade: ["insert"] }
)
tax_lines: LineItemTaxLine[]
@OneToMany(() => LineItemAdjustment, (lia) => lia.item, {
cascade: ["insert"],
})
@OneToMany(
() => LineItemAdjustment,
(lia) => lia.item,
{
cascade: ["insert"],
}
)
adjustments: LineItemAdjustment[]
@Column()
@@ -72,8 +92,8 @@ export class LineItem extends BaseEntity {
@Column({ nullable: true })
description: string
@Column({ nullable: true })
thumbnail: string
@Column({ type: "text", nullable: true })
thumbnail: string | null
@Column({ default: false })
is_return: boolean

View File

@@ -21,7 +21,7 @@ import { ShippingProfile } from "./shipping-profile"
import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity"
import { generateEntityId } from "../utils/generate-entity-id"
export enum Status {
export enum ProductStatus {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
@@ -33,21 +33,21 @@ export class Product extends SoftDeletableEntity {
@Column()
title: string
@Column({ nullable: true })
subtitle: string
@Column({ type: "text", nullable: true })
subtitle: string | null
@Column({ nullable: true })
description: string
@Column({ type: "text", nullable: true })
description: string | null
@Index({ unique: true, where: "deleted_at IS NULL" })
@Column({ nullable: true })
handle: string
@Column({ type: "text", nullable: true })
handle: string | null
@Column({ default: false })
is_giftcard: boolean
@DbAwareColumn({ type: "enum", enum: Status, default: "draft" })
status: Status
@DbAwareColumn({ type: "enum", enum: ProductStatus, default: "draft" })
status: ProductStatus
@ManyToMany(() => Image, { cascade: ["insert"] })
@JoinTable({
@@ -63,15 +63,22 @@ export class Product extends SoftDeletableEntity {
})
images: Image[]
@Column({ nullable: true })
thumbnail: string
@Column({ type: "text", nullable: true })
thumbnail: string | null
@OneToMany(() => ProductOption, (productOption) => productOption.product)
@OneToMany(
() => ProductOption,
(productOption) => productOption.product
)
options: ProductOption[]
@OneToMany(() => ProductVariant, (variant) => variant.product, {
cascade: true,
})
@OneToMany(
() => ProductVariant,
(variant) => variant.product,
{
cascade: true,
}
)
variants: ProductVariant[]
@Index()
@@ -83,38 +90,38 @@ export class Product extends SoftDeletableEntity {
profile: ShippingProfile
@Column({ type: "int", nullable: true })
weight: number
weight: number | null
@Column({ type: "int", nullable: true })
length: number
length: number | null
@Column({ type: "int", nullable: true })
height: number
height: number | null
@Column({ type: "int", nullable: true })
width: number
width: number | null
@Column({ nullable: true })
hs_code: string
@Column({ type: "text", nullable: true })
hs_code: string | null
@Column({ nullable: true })
origin_country: string
@Column({ type: "text", nullable: true })
origin_country: string | null
@Column({ nullable: true })
mid_code: string
@Column({ type: "text", nullable: true })
mid_code: string | null
@Column({ nullable: true })
material: string
@Column({ type: "text", nullable: true })
material: string | null
@Column({ nullable: true })
@Column({ type: "text", nullable: true })
collection_id: string | null
@ManyToOne(() => ProductCollection)
@JoinColumn({ name: "collection_id" })
collection: ProductCollection
@Column({ nullable: true })
type_id: string
@Column({ type: "text", nullable: true })
type_id: string | null
@ManyToOne(() => ProductType)
@JoinColumn({ name: "type_id" })
@@ -137,11 +144,11 @@ export class Product extends SoftDeletableEntity {
@Column({ default: true })
discountable: boolean
@Column({ nullable: true })
external_id: string
@Column({ type: "text", nullable: true })
external_id: string | null
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: Record<string, unknown>
metadata: Record<string, unknown> | null
@BeforeInsert()
private beforeInsert(): void {

View File

@@ -1,5 +1,31 @@
import { EntityRepository, Repository } from "typeorm"
import { EntityRepository, In, Repository } from "typeorm"
import { Image } from "../models/image"
@EntityRepository(Image)
export class ImageRepository extends Repository<Image> {}
export class ImageRepository extends Repository<Image> {
public async upsertImages(imageUrls: string[]) {
const existingImages = await this.find({
where: {
url: In(imageUrls),
},
})
const existingImagesMap = new Map(
existingImages.map<[string, Image]>((img) => [img.url, img])
)
const upsertedImgs: Image[] = []
for (const url of imageUrls) {
const aImg = existingImagesMap.get(url)
if (aImg) {
upsertedImgs.push(aImg)
} else {
const newImg = this.create({ url })
const savedImg = await this.save(newImg)
upsertedImgs.push(savedImg)
}
}
return upsertedImgs
}
}

View File

@@ -11,7 +11,10 @@ import { CustomFindOptions, ExtendedFindConfig } from "../types/common"
import { CustomerGroup } from "../models"
import { FilterablePriceListProps } from "../types/price-list"
export type PriceListFindOptions = CustomFindOptions<PriceList, "status" | "type">
export type PriceListFindOptions = CustomFindOptions<
PriceList,
"status" | "type"
>
@EntityRepository(PriceList)
export class PriceListRepository extends Repository<PriceList> {
@@ -108,10 +111,10 @@ export class PriceListRepository extends Repository<PriceList> {
.take(query.take)
if (groups) {
qb.leftJoinAndSelect("price_list.customer_groups", "group").andWhere(
"group.id IN (:...ids)",
{ ids: groups.value }
)
qb.leftJoinAndSelect(
"price_list.customer_groups",
"group"
).andWhere("group.id IN (:...ids)", { ids: groups.value })
}
if (query.relations?.length) {

View File

@@ -1,5 +1,50 @@
import { EntityRepository, Repository } from "typeorm"
import { EntityRepository, In, Repository } from "typeorm"
import { ProductTag } from "../models/product-tag"
type UpsertTagsInput = (Partial<ProductTag> & {
value: string
})[]
@EntityRepository(ProductTag)
export class ProductTagRepository extends Repository<ProductTag> {}
export class ProductTagRepository extends Repository<ProductTag> {
public async listTagsByUsage(count = 10): Promise<ProductTag[]> {
return await this.query(
`
SELECT id, COUNT(pts.product_tag_id) as usage_count, pt.value
FROM product_tag pt
LEFT JOIN product_tags pts ON pt.id = pts.product_tag_id
GROUP BY id
ORDER BY usage_count DESC
LIMIT $1
`,
[count]
)
}
public async upsertTags(tags: UpsertTagsInput): Promise<ProductTag[]> {
const tagsValues = tags.map((tag) => tag.value)
const existingTags = await this.find({
where: {
value: In(tagsValues),
},
})
const existingTagsMap = new Map(
existingTags.map<[string, ProductTag]>((tag) => [tag.value, tag])
)
const upsertedTags: ProductTag[] = []
for (const tag of tags) {
const aTag = existingTagsMap.get(tag.value)
if (aTag) {
upsertedTags.push(aTag)
} else {
const newTag = this.create(tag)
const savedTag = await this.save(newTag)
upsertedTags.push(savedTag)
}
}
return upsertedTags
}
}

View File

@@ -1,5 +1,29 @@
import { EntityRepository, Repository } from "typeorm"
import { ProductType } from "../models/product-type"
type UpsertTypeInput = Partial<ProductType> & {
value: string
}
@EntityRepository(ProductType)
export class ProductTypeRepository extends Repository<ProductType> {}
export class ProductTypeRepository extends Repository<ProductType> {
async upsertType(type?: UpsertTypeInput): Promise<ProductType | null> {
if (!type) {
return null
}
const existing = await this.findOne({
where: { value: type.value },
})
if (existing) {
return existing
}
const created = this.create({
value: type.value,
})
const result = await this.save(created)
return result
}
}

View File

@@ -2,32 +2,32 @@ import { flatten, groupBy, map, merge } from "lodash"
import {
Brackets,
EntityRepository,
FindManyOptions,
FindOperator,
In,
OrderByCondition,
Repository,
} from "typeorm"
import { ProductTag } from ".."
import { PriceList } from "../models/price-list"
import { Product } from "../models/product"
import { WithRequiredProperty } from "../types/common"
import {
ExtendedFindConfig,
Selector,
WithRequiredProperty,
} from "../types/common"
type DefaultWithoutRelations = Omit<FindManyOptions<Product>, "relations">
type CustomOptions = {
select?: DefaultWithoutRelations["select"]
where?: DefaultWithoutRelations["where"] & {
tags?: FindOperator<ProductTag>
price_list_id?: FindOperator<PriceList>
}
order?: OrderByCondition
skip?: number
take?: number
withDeleted?: boolean
export type ProductSelector = Omit<Selector<Product>, "tags"> & {
tags: FindOperator<string[]>
}
type FindWithRelationsOptions = CustomOptions
export type DefaultWithoutRelations = Omit<
ExtendedFindConfig<Product, ProductSelector>,
"relations"
>
export type FindWithoutRelationsOptions = DefaultWithoutRelations & {
where: DefaultWithoutRelations["where"] & {
price_list_id?: FindOperator<PriceList>
}
}
@EntityRepository(Product)
export class ProductRepository extends Repository<Product> {
@@ -41,7 +41,7 @@ export class ProductRepository extends Repository<Product> {
}
private async queryProducts(
optionsWithoutRelations: FindWithRelationsOptions,
optionsWithoutRelations: FindWithoutRelationsOptions,
shouldCount = false
): Promise<[Product[], number]> {
const tags = optionsWithoutRelations?.where?.tags
@@ -106,7 +106,7 @@ export class ProductRepository extends Repository<Product> {
}
private getGroupedRelations(
relations: Array<keyof Product>
relations: string[]
): {
[toplevel: string]: string[]
} {
@@ -189,8 +189,8 @@ export class ProductRepository extends Repository<Product> {
}
public async findWithRelationsAndCount(
relations: Array<keyof Product> = [],
idsOrOptionsWithoutRelations: FindWithRelationsOptions = { where: {} }
relations: string[] = [],
idsOrOptionsWithoutRelations: FindWithoutRelationsOptions = { where: {} }
): Promise<[Product[], number]> {
let count: number
let entities: Product[]
@@ -239,8 +239,10 @@ export class ProductRepository extends Repository<Product> {
}
public async findWithRelations(
relations: Array<keyof Product> = [],
idsOrOptionsWithoutRelations: FindWithRelationsOptions | string[] = {},
relations: string[] = [],
idsOrOptionsWithoutRelations: FindWithoutRelationsOptions | string[] = {
where: {},
},
withDeleted = false
): Promise<Product[]> {
let entities: Product[]
@@ -285,8 +287,8 @@ export class ProductRepository extends Repository<Product> {
}
public async findOneWithRelations(
relations: Array<keyof Product> = [],
optionsWithoutRelations: FindWithRelationsOptions = { where: {} }
relations: string[] = [],
optionsWithoutRelations: FindWithoutRelationsOptions = { where: {} }
): Promise<Product> {
// Limit 1
optionsWithoutRelations.take = 1
@@ -326,8 +328,8 @@ export class ProductRepository extends Repository<Product> {
public async getFreeTextSearchResultsAndCount(
q: string,
options: CustomOptions = { where: {} },
relations: (keyof Product)[] = []
options: FindWithoutRelationsOptions = { where: {} },
relations: string[] = []
): Promise<[Product[], number]> {
const cleanedOptions = this._cleanOptions(options)
@@ -364,8 +366,8 @@ export class ProductRepository extends Repository<Product> {
}
private _cleanOptions(
options: CustomOptions
): WithRequiredProperty<CustomOptions, "where"> {
options: FindWithoutRelationsOptions
): WithRequiredProperty<FindWithoutRelationsOptions, "where"> {
const where = options.where ?? {}
if ("description" in where) {
delete where.description

View File

@@ -3,11 +3,28 @@ import ProductService from "../product"
const eventBusService = {
emit: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
const mockUpsertTags = jest.fn().mockImplementation((data) =>
Promise.resolve(
data.map(({ value, id }) => ({
value,
id: id || (value === "title" ? "tag-1" : "tag-2"),
}))
)
)
const mockUpsertType = jest.fn().mockImplementation((value) => {
const productType = {
id: "type",
value: value,
}
return Promise.resolve(productType)
})
describe("ProductService", () => {
describe("retrieve", () => {
const productRepo = MockRepository({
@@ -81,15 +98,17 @@ describe("ProductService", () => {
}
},
})
productTagRepository.upsertTags = mockUpsertTags
const productTypeRepository = MockRepository({
findOne: () => Promise.resolve(undefined),
create: (data) => {
return { id: "type", value: "type1" }
},
})
productTypeRepository.upsertType = mockUpsertType
const productCollectionService = {
withTransaction: function () {
withTransaction: function() {
return this
},
retrieve: (id) =>
@@ -148,13 +167,9 @@ describe("ProductService", () => {
],
})
expect(productTagRepository.findOne).toHaveBeenCalledTimes(2)
// We add two tags, that does not exist therefore we make sure
// that create is also called
expect(productTagRepository.create).toHaveBeenCalledTimes(2)
expect(productTagRepository.upsertTags).toHaveBeenCalledTimes(1)
expect(productTypeRepository.findOne).toHaveBeenCalledTimes(1)
expect(productTypeRepository.create).toHaveBeenCalledTimes(1)
expect(productTypeRepository.upsertType).toHaveBeenCalledTimes(1)
expect(productRepository.save).toHaveBeenCalledTimes(1)
expect(productRepository.save).toHaveBeenCalledWith({
@@ -227,11 +242,12 @@ describe("ProductService", () => {
return { id: "type", value: "type1" }
},
})
productTypeRepository.upsertType = mockUpsertType
const productVariantRepository = MockRepository()
const productVariantService = {
withTransaction: function () {
withTransaction: function() {
return this
},
update: (variant, update) => {
@@ -252,6 +268,7 @@ describe("ProductService", () => {
}
},
})
productTagRepository.upsertTags = mockUpsertTags
const cartRepository = MockRepository({
findOne: (data) => {
@@ -470,7 +487,7 @@ describe("ProductService", () => {
})
const productVariantService = {
withTransaction: function () {
withTransaction: function() {
return this
},
addOptionValue: jest.fn(),
@@ -590,72 +607,6 @@ describe("ProductService", () => {
})
})
describe("reorderOptions", () => {
const productRepository = MockRepository({
findOneWithRelations: (query) =>
Promise.resolve({
id: IdMap.getId("ironman"),
options: [
{ id: IdMap.getId("material") },
{ id: IdMap.getId("color") },
],
}),
})
const productService = new ProductService({
manager: MockManager,
productRepository,
eventBusService,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("reorders options", async () => {
await productService.reorderOptions(IdMap.getId("ironman"), [
IdMap.getId("color"),
IdMap.getId("material"),
])
expect(productRepository.save).toBeCalledTimes(1)
expect(productRepository.save).toBeCalledWith({
id: IdMap.getId("ironman"),
options: [
{ id: IdMap.getId("color") },
{ id: IdMap.getId("material") },
],
})
})
it("throws if one option id is not in the product options", async () => {
try {
await productService.reorderOptions(IdMap.getId("ironman"), [
IdMap.getId("packaging"),
IdMap.getId("material"),
])
} catch (err) {
expect(err.message).toEqual(
`Product has no option with id: ${IdMap.getId("packaging")}`
)
}
})
it("throws if order length and product option lengths differ", async () => {
try {
await productService.reorderOptions(IdMap.getId("ironman"), [
IdMap.getId("size"),
IdMap.getId("color"),
IdMap.getId("material"),
])
} catch (err) {
expect(err.message).toEqual(
`Product options and new options order differ in length.`
)
}
})
})
describe("updateOption", () => {
const productRepository = MockRepository({
findOneWithRelations: (query) =>

View File

@@ -106,7 +106,7 @@ class BatchJobService extends TransactionBaseService<BatchJobService> {
this.batchJobRepository_
)
const query = buildQuery<BatchJob>({ id: batchJobId }, config)
const query = buildQuery({ id: batchJobId }, config)
const batchJob = await batchJobRepo.findOne(query)
if (!batchJob) {

View File

@@ -262,7 +262,7 @@ class CartService extends TransactionBaseService<CartService> {
this.cartRepository_
)
const query = buildQuery<Cart>(selector, config)
const query = buildQuery(selector, config)
return await cartRepo.find(query)
}
)
@@ -290,7 +290,7 @@ class CartService extends TransactionBaseService<CartService> {
const { select, relations, totalsToSelect } =
this.transformQueryForTotals_(options)
const query = buildQuery<Cart>(
const query = buildQuery(
{ id: validatedId },
{ ...options, select, relations }
)

View File

@@ -819,7 +819,7 @@ export default class ClaimService extends TransactionBaseService<
const claimRepo = transactionManager.getCustomRepository(
this.claimRepository_
)
const query = buildQuery<ClaimOrder>(selector, config)
const query = buildQuery(selector, config)
return await claimRepo.find(query)
}
)
@@ -841,7 +841,7 @@ export default class ClaimService extends TransactionBaseService<
this.claimRepository_
)
const query = buildQuery<ClaimOrder>({ id }, config)
const query = buildQuery({ id }, config)
const claim = await claimRepo.findOne(query)
if (!claim) {

View File

@@ -23,6 +23,7 @@ import { buildQuery } from "../utils"
import { FilterableProductProps } from "../types/product"
import ProductVariantService from "./product-variant"
import { FilterableProductVariantProps } from "../types/product-variant"
import { ProductVariantRepository } from "../repositories/product-variant"
type PriceListConstructorProps = {
manager: EntityManager
@@ -32,6 +33,7 @@ type PriceListConstructorProps = {
productVariantService: ProductVariantService
priceListRepository: typeof PriceListRepository
moneyAmountRepository: typeof MoneyAmountRepository
productVariantRepository: typeof ProductVariantRepository
}
/**
@@ -48,6 +50,7 @@ class PriceListService extends TransactionBaseService<PriceListService> {
protected readonly variantService_: ProductVariantService
protected readonly priceListRepo_: typeof PriceListRepository
protected readonly moneyAmountRepo_: typeof MoneyAmountRepository
protected readonly productVariantRepo_: typeof ProductVariantRepository
constructor({
manager,
@@ -57,6 +60,7 @@ class PriceListService extends TransactionBaseService<PriceListService> {
productVariantService,
priceListRepository,
moneyAmountRepository,
productVariantRepository,
}: PriceListConstructorProps) {
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
@@ -68,6 +72,7 @@ class PriceListService extends TransactionBaseService<PriceListService> {
this.regionService_ = regionService
this.priceListRepo_ = priceListRepository
this.moneyAmountRepo_ = moneyAmountRepository
this.productVariantRepo_ = productVariantRepository
}
/**
@@ -247,10 +252,7 @@ class PriceListService extends TransactionBaseService<PriceListService> {
const priceListRepo = manager.getCustomRepository(this.priceListRepo_)
const { q, ...priceListSelector } = selector
const query = buildQuery<FilterablePriceListProps>(
priceListSelector,
config
)
const query = buildQuery(priceListSelector, config)
const groups = query.where.customer_groups as FindOperator<string[]>
query.where.customer_groups = undefined
@@ -277,10 +279,10 @@ class PriceListService extends TransactionBaseService<PriceListService> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const priceListRepo = manager.getCustomRepository(this.priceListRepo_)
const { q, ...priceListSelector } = selector
const { relations, ...query } = buildQuery<FilterablePriceListProps>(
priceListSelector,
config
)
const { relations, ...query } = buildQuery<
FilterablePriceListProps,
FilterablePriceListProps
>(priceListSelector, config)
const groups = query.where.customer_groups as FindOperator<string[]>
delete query.where.customer_groups
@@ -327,6 +329,9 @@ class PriceListService extends TransactionBaseService<PriceListService> {
requiresPriceList = false
): Promise<[Product[], number]> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const productVariantRepo = manager.getCustomRepository(
this.productVariantRepo_
)
const [products, count] = await this.productService_.listAndCount(
selector,
config
@@ -346,10 +351,10 @@ class PriceListService extends TransactionBaseService<PriceListService> {
requiresPriceList
)
return {
return productVariantRepo.create({
...v,
prices,
}
})
})
)
}

View File

@@ -1,15 +1,59 @@
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { defaultAdminProductsVariantsRelations } from "../api/routes/admin/products"
import { EntityManager } from "typeorm"
import { SearchService } from "."
import { TransactionBaseService } from "../interfaces"
import { Product, ProductTag, ProductType, ProductVariant } from "../models"
import { ImageRepository } from "../repositories/image"
import {
FindWithoutRelationsOptions,
ProductRepository,
} from "../repositories/product"
import { ProductOptionRepository } from "../repositories/product-option"
import { ProductTagRepository } from "../repositories/product-tag"
import { ProductTypeRepository } from "../repositories/product-type"
import { ProductVariantRepository } from "../repositories/product-variant"
import { Selector } from "../types/common"
import {
CreateProductInput,
FilterableProductProps,
FindProductConfig,
ProductOptionInput,
UpdateProductInput,
} from "../types/product"
import { buildQuery, setMetadata } from "../utils"
import { formatException } from "../utils/exception-formatter"
import EventBusService from "./event-bus"
import ProductVariantService from "./product-variant"
/**
* Provides layer to manipulate products.
* @extends BaseService
*/
class ProductService extends BaseService {
static IndexName = `products`
static Events = {
type InjectedDependencies = {
manager: EntityManager
productOptionRepository: typeof ProductOptionRepository
productRepository: typeof ProductRepository
productVariantRepository: typeof ProductVariantRepository
productTypeRepository: typeof ProductTypeRepository
productTagRepository: typeof ProductTagRepository
imageRepository: typeof ImageRepository
productVariantService: ProductVariantService
searchService: SearchService
eventBusService: EventBusService
}
class ProductService extends TransactionBaseService<ProductService> {
protected manager_: EntityManager
protected transactionManager_: EntityManager | undefined
protected readonly productOptionRepository_: typeof ProductOptionRepository
protected readonly productRepository_: typeof ProductRepository
protected readonly productVariantRepository_: typeof ProductVariantRepository
protected readonly productTypeRepository_: typeof ProductTypeRepository
protected readonly productTagRepository_: typeof ProductTagRepository
protected readonly imageRepository_: typeof ImageRepository
protected readonly productVariantService_: ProductVariantService
protected readonly searchService_: SearchService
protected readonly eventBus_: EventBusService
static readonly IndexName = `products`
static readonly Events = {
UPDATED: "product.updated",
CREATED: "product.created",
DELETED: "product.deleted",
@@ -26,133 +70,101 @@ class ProductService extends BaseService {
productTagRepository,
imageRepository,
searchService,
}) {
super()
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ProductOption} */
this.productOptionRepository_ = productOptionRepository
/** @private @const {Product} */
this.productRepository_ = productRepository
/** @private @const {ProductVariant} */
this.productVariantRepository_ = productVariantRepository
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
/** @private @const {ProductVariantService} */
this.productVariantService_ = productVariantService
/** @private @const {ProductCollectionService} */
this.productTypeRepository_ = productTypeRepository
/** @private @const {ProductCollectionService} */
this.productTagRepository_ = productTagRepository
/** @private @const {ImageRepository} */
this.imageRepository_ = imageRepository
/** @private @const {SearchService} */
this.searchService_ = searchService
}
withTransaction(transactionManager) {
if (!transactionManager) {
return this
}
const cloned = new ProductService({
manager: transactionManager,
productRepository: this.productRepository_,
productVariantRepository: this.productVariantRepository_,
productOptionRepository: this.productOptionRepository_,
eventBusService: this.eventBus_,
productVariantService: this.productVariantService_,
productTagRepository: this.productTagRepository_,
productTypeRepository: this.productTypeRepository_,
imageRepository: this.imageRepository_,
}: InjectedDependencies) {
super({
manager,
productRepository,
productVariantRepository,
productOptionRepository,
eventBusService,
productVariantService,
productTypeRepository,
productTagRepository,
imageRepository,
searchService,
})
cloned.transactionManager_ = transactionManager
return cloned
this.manager_ = manager
this.productOptionRepository_ = productOptionRepository
this.productRepository_ = productRepository
this.productVariantRepository_ = productVariantRepository
this.eventBus_ = eventBusService
this.productVariantService_ = productVariantService
this.productTypeRepository_ = productTypeRepository
this.productTagRepository_ = productTagRepository
this.imageRepository_ = imageRepository
this.searchService_ = searchService
}
/**
* Lists products based on the provided parameters.
* @param {object} selector - an object that defines rules to filter products
* @param selector - an object that defines rules to filter products
* by
* @param {object} config - object that defines the scope for what should be
* @param config - object that defines the scope for what should be
* returned
* @return {Promise<Product[]>} the result of the find operation
* @return the result of the find operation
*/
async list(
selector = {},
config = {
selector: FilterableProductProps | Selector<Product> = {},
config: FindProductConfig = {
relations: [],
skip: 0,
take: 20,
include_discount_prices: false,
}
) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
): Promise<Product[]> {
return await this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const { q, query, relations } = this.prepareListQuery_(selector, config)
const { q, query, relations } = this.prepareListQuery_(selector, config)
if (q) {
const [products] = await productRepo.getFreeTextSearchResultsAndCount(
q,
query,
relations
)
return products
}
if (q) {
const [products] = await productRepo.getFreeTextSearchResultsAndCount(
q,
query,
relations
)
return products
}
return await productRepo.findWithRelations(relations, query)
return await productRepo.findWithRelations(relations, query)
})
}
/**
* Lists products based on the provided parameters and includes the count of
* products that match the query.
* @param {object} selector - an object that defines rules to filter products
* @param selector - an object that defines rules to filter products
* by
* @param {object} config - object that defines the scope for what should be
* @param config - object that defines the scope for what should be
* returned
* @return {Promise<[Product[], number]>} an array containing the products as
* @return an array containing the products as
* the first element and the total count of products that matches the query
* as the second element.
*/
async listAndCount(
selector = {},
config = {
selector: FilterableProductProps | Selector<Product>,
config: FindProductConfig = {
relations: [],
skip: 0,
take: 20,
include_discount_prices: false,
}
) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
): Promise<[Product[], number]> {
return await this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const { q, query, relations } = this.prepareListQuery_(selector, config)
const { q, query, relations } = this.prepareListQuery_(selector, config)
if (q) {
return await productRepo.getFreeTextSearchResultsAndCount(
q,
query,
relations
)
}
if (q) {
return await productRepo.getFreeTextSearchResultsAndCount(
q,
query,
relations
)
}
return await productRepo.findWithRelationsAndCount(relations, query)
return await productRepo.findWithRelationsAndCount(relations, query)
})
}
/**
@@ -160,231 +172,172 @@ class ProductService extends BaseService {
* @param {object} selector - the selector to choose products by
* @return {Promise} the result of the count operation
*/
count(selector = {}) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
const query = this.buildQuery_(selector)
return productRepo.count(query)
async count(selector: Selector<Product> = {}): Promise<number> {
return await this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const query = buildQuery(selector)
return await productRepo.count(query)
})
}
/**
* Gets a product by id.
* Throws in case of DB Error and if product was not found.
* @param {string} productId - id of the product to get.
* @param {object} config - object that defines what should be included in the
* @param productId - id of the product to get.
* @param config - object that defines what should be included in the
* query response
* @return {Promise<Product>} the result of the find one operation.
* @return the result of the find one operation.
*/
async retrieve(productId, config = { include_discount_prices: false }) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
const validatedId = this.validateId_(productId)
const query = { where: { id: validatedId } }
if (config.relations && config.relations.length > 0) {
query.relations = config.relations
async retrieve(
productId: string,
config: FindProductConfig = {
include_discount_prices: false,
}
if (config.select && config.select.length > 0) {
query.select = config.select
}
const rels = query.relations
delete query.relations
const product = await productRepo.findOneWithRelations(rels, query)
if (!product) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with id: ${productId} was not found`
)
}
return product
): Promise<Product> {
return await this.atomicPhase_(async () => {
return await this.retrieve_({ id: productId }, config)
})
}
/**
* Gets a product by handle.
* Throws in case of DB Error and if product was not found.
* @param {string} productHandle - handle of the product to get.
* @param {object} config - details about what to get from the product
* @return {Promise<Product>} the result of the find one operation.
* @param productHandle - handle of the product to get.
* @param config - details about what to get from the product
* @return the result of the find one operation.
*/
async retrieveByHandle(productHandle, config = {}) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
const query = { where: { handle: productHandle } }
if (config.relations && config.relations.length > 0) {
query.relations = config.relations
}
if (config.select && config.select.length > 0) {
query.select = config.select
}
const rels = query.relations
delete query.relations
const product = await productRepo.findOneWithRelations(rels, query)
if (!product) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with handle: ${productHandle} was not found`
)
}
return product
async retrieveByHandle(
productHandle: string,
config: FindProductConfig = {}
): Promise<Product> {
return await this.atomicPhase_(async () => {
return await this.retrieve_({ handle: productHandle }, config)
})
}
/**
* Gets a product by external id.
* Throws in case of DB Error and if product was not found.
* @param {string} externalId - handle of the product to get.
* @param {object} config - details about what to get from the product
* @return {Promise<Product>} the result of the find one operation.
* @param externalId - handle of the product to get.
* @param config - details about what to get from the product
* @return the result of the find one operation.
*/
async retrieveByExternalId(externalId, config = {}) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
async retrieveByExternalId(
externalId: string,
config: FindProductConfig = {}
): Promise<Product> {
return await this.atomicPhase_(async () => {
return await this.retrieve_({ external_id: externalId }, config)
})
}
const query = { where: { external_id: externalId } }
if (config.relations && config.relations.length > 0) {
query.relations = config.relations
/**
* Gets a product by selector.
* Throws in case of DB Error and if product was not found.
* @param selector - selector object
* @param config - object that defines what should be included in the
* query response
* @return the result of the find one operation.
*/
async retrieve_(
selector: Selector<Product>,
config: FindProductConfig = {
include_discount_prices: false,
}
): Promise<Product> {
return await this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
if (config.select && config.select.length > 0) {
query.select = config.select
}
const { relations, ...query } = buildQuery(selector, config)
const rels = query.relations
delete query.relations
const product = await productRepo.findOneWithRelations(rels, query)
if (!product) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with exteral_id: ${externalId} was not found`
const product = await productRepo.findOneWithRelations(
relations,
query as FindWithoutRelationsOptions
)
}
return product
if (!product) {
const selectorConstraints = Object.entries(selector)
.map(([key, value]) => `${key}: ${value}`)
.join(", ")
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with ${selectorConstraints} was not found`
)
}
return product
})
}
/**
* Gets all variants belonging to a product.
* @param {string} productId - the id of the product to get variants from.
* @param {FindConfig<Product>} config - The config to select and configure relations etc...
* @return {Promise} an array of variants
* @param productId - the id of the product to get variants from.
* @param config - The config to select and configure relations etc...
* @return an array of variants
*/
async retrieveVariants(
productId,
config = {
productId: string,
config: FindProductConfig = {
skip: 0,
take: 50,
relations: defaultAdminProductsVariantsRelations,
}
) {
const product = await this.retrieve(productId, config)
return product.variants
}
): Promise<ProductVariant[]> {
return await this.atomicPhase_(async () => {
const givenRelations = config.relations ?? []
const requiredRelations = ["variants"]
const relationsSet = new Set([...givenRelations, ...requiredRelations])
async listTypes() {
const productTypeRepository = this.manager_.getCustomRepository(
this.productTypeRepository_
)
return await productTypeRepository.find({})
}
async listTagsByUsage(count = 10) {
const tags = await this.manager_.query(
`
SELECT ID, O.USAGE_COUNT, PT.VALUE
FROM PRODUCT_TAG PT
LEFT JOIN
(SELECT COUNT(*) AS USAGE_COUNT,
PRODUCT_TAG_ID
FROM PRODUCT_TAGS
GROUP BY PRODUCT_TAG_ID) O ON O.PRODUCT_TAG_ID = PT.ID
ORDER BY O.USAGE_COUNT DESC
LIMIT $1`,
[count]
)
return tags
}
async upsertProductType_(type) {
const productTypeRepository = this.manager_.getCustomRepository(
this.productTypeRepository_
)
if (type === null) {
return null
}
const existing = await productTypeRepository.findOne({
where: { value: type.value },
})
if (existing) {
return existing.id
}
const created = productTypeRepository.create({
value: type.value,
})
const result = await productTypeRepository.save(created)
return result.id
}
async upsertProductTags_(tags) {
const productTagRepository = this.manager_.getCustomRepository(
this.productTagRepository_
)
const newTags = []
for (const tag of tags) {
const existing = await productTagRepository.findOne({
where: { value: tag.value },
const product = await this.retrieve(productId, {
...config,
relations: [...relationsSet],
})
return product.variants
})
}
if (existing) {
newTags.push(existing)
} else {
const created = productTagRepository.create(tag)
const result = await productTagRepository.save(created)
newTags.push(result)
}
}
async listTypes(): Promise<ProductType[]> {
return await this.atomicPhase_(async (manager) => {
const productTypeRepository = manager.getCustomRepository(
this.productTypeRepository_
)
return newTags
return await productTypeRepository.find({})
})
}
async listTagsByUsage(count = 10): Promise<ProductTag[]> {
return await this.atomicPhase_(async (manager) => {
const productTagRepo = manager.getCustomRepository(
this.productTagRepository_
)
return await productTagRepo.listTagsByUsage(count)
})
}
/**
* Creates a product.
* @param {object} productObject - the product to create
* @return {Promise} resolves to the creation result.
* @param productObject - the product to create
* @return resolves to the creation result.
*/
async create(productObject) {
return this.atomicPhase_(async (manager) => {
async create(productObject: CreateProductInput): Promise<Product> {
return await this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const productTagRepo = manager.getCustomRepository(
this.productTagRepository_
)
const productTypeRepo = manager.getCustomRepository(
this.productTypeRepository_
)
const imageRepo = manager.getCustomRepository(this.imageRepository_)
const optionRepo = manager.getCustomRepository(
this.productOptionRepository_
)
const { options, tags, type, images, ...rest } = productObject
if (!rest.thumbnail && images && images.length) {
if (!rest.thumbnail && images?.length) {
rest.thumbnail = images[0]
}
@@ -396,23 +349,23 @@ class ProductService extends BaseService {
try {
let product = productRepo.create(rest)
if (images) {
product.images = await this.upsertImages_(images)
if (images?.length) {
product.images = await imageRepo.upsertImages(images)
}
if (tags) {
product.tags = await this.upsertProductTags_(tags)
if (tags?.length) {
product.tags = await productTagRepo.upsertTags(tags)
}
if (typeof type !== `undefined`) {
product.type_id = await this.upsertProductType_(type)
product.type_id = (await productTypeRepo.upsertType(type))?.id || null
}
product = await productRepo.save(product)
product.options = await Promise.all(
options.map(async (o) => {
const res = optionRepo.create({ ...o, product_id: product.id })
(options ?? []).map(async (option) => {
const res = optionRepo.create({ ...option, product_id: product.id })
await optionRepo.save(res)
return res
})
@@ -434,28 +387,6 @@ class ProductService extends BaseService {
})
}
async upsertImages_(images) {
const imageRepository = this.manager_.getCustomRepository(
this.imageRepository_
)
const productImages = []
for (const img of images) {
const existing = await imageRepository.findOne({
where: { url: img },
})
if (existing) {
productImages.push(existing)
} else {
const created = imageRepository.create({ url: img })
productImages.push(created)
}
}
return productImages
}
/**
* Updates a product. Product variant updates should use dedicated methods,
* e.g. `addVariant`, etc. The function will throw errors if metadata or
@@ -465,12 +396,22 @@ class ProductService extends BaseService {
* @param {object} update - an object with the update values.
* @return {Promise} resolves to the update result.
*/
async update(productId, update) {
return this.atomicPhase_(async (manager) => {
async update(
productId: string,
update: UpdateProductInput
): Promise<Product> {
return await this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const productVariantRepo = manager.getCustomRepository(
this.productVariantRepository_
)
const productTagRepo = manager.getCustomRepository(
this.productTagRepository_
)
const productTypeRepo = manager.getCustomRepository(
this.productTypeRepository_
)
const imageRepo = manager.getCustomRepository(this.imageRepository_)
const product = await this.retrieve(productId, {
relations: ["variants", "tags", "images"],
@@ -483,19 +424,19 @@ class ProductService extends BaseService {
}
if (images) {
product.images = await this.upsertImages_(images)
product.images = await imageRepo.upsertImages(images)
}
if (metadata) {
product.metadata = this.setMetadata_(product, metadata)
product.metadata = setMetadata(product, metadata)
}
if (typeof type !== `undefined`) {
product.type_id = await this.upsertProductType_(type)
product.type_id = (await productTypeRepo.upsertType(type))?.id || null
}
if (tags) {
product.tags = await this.upsertProductTags_(tags)
product.tags = await productTagRepo.upsertTags(tags)
}
if (variants) {
@@ -507,9 +448,9 @@ class ProductService extends BaseService {
}
}
const newVariants = []
const newVariants: ProductVariant[] = []
for (const [i, newVariant] of variants.entries()) {
newVariant.variant_rank = i
const variant_rank = i
if (newVariant.id) {
const variant = product.variants.find((v) => v.id === newVariant.id)
@@ -523,7 +464,11 @@ class ProductService extends BaseService {
const saved = await this.productVariantService_
.withTransaction(manager)
.update(variant, newVariant)
.update(variant, {
...newVariant,
variant_rank,
product_id: variant.product_id,
})
newVariants.push(saved)
} else {
@@ -531,7 +476,12 @@ class ProductService extends BaseService {
// should be created
const created = await this.productVariantService_
.withTransaction(manager)
.create(product.id, newVariant)
.create(product.id, {
...newVariant,
variant_rank,
options: newVariant.options || [],
prices: newVariant.prices || [],
})
newVariants.push(created)
}
@@ -561,12 +511,12 @@ class ProductService extends BaseService {
/**
* Deletes a product from a given product id. The product's associated
* variants will also be deleted.
* @param {string} productId - the id of the product to delete. Must be
* @param productId - the id of the product to delete. Must be
* castable as an ObjectId
* @return {Promise} empty promise
* @return empty promise
*/
async delete(productId) {
return this.atomicPhase_(async (manager) => {
async delete(productId: string): Promise<void> {
return await this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
// Should not fail, if product does not exist, since delete is idempotent
@@ -595,12 +545,12 @@ class ProductService extends BaseService {
* Adds an option to a product. Options can, for example, be "Size", "Color",
* etc. Will update all the products variants with a dummy value for the newly
* created option. The same option cannot be added more than once.
* @param {string} productId - the product to apply the new option to
* @param {string} optionTitle - the display title of the option, e.g. "Size"
* @return {Promise} the result of the model update operation
* @param productId - the product to apply the new option to
* @param optionTitle - the display title of the option, e.g. "Size"
* @return the result of the model update operation
*/
async addOption(productId, optionTitle) {
return this.atomicPhase_(async (manager) => {
async addOption(productId: string, optionTitle: string): Promise<Product> {
return await this.atomicPhase_(async (manager) => {
const productOptionRepo = manager.getCustomRepository(
this.productOptionRepository_
)
@@ -638,8 +588,11 @@ class ProductService extends BaseService {
})
}
async reorderVariants(productId, variantOrder) {
return this.atomicPhase_(async (manager) => {
async reorderVariants(
productId: string,
variantOrder: string[]
): Promise<Product> {
return await this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const product = await this.retrieve(productId, {
@@ -673,58 +626,20 @@ class ProductService extends BaseService {
})
}
/**
* Changes the order of a product's options. Will throw if the length of
* optionOrder and the length of the product's options are different. Will
* throw optionOrder contains an id not associated with the product.
* @param {string} productId - the product whose options we are reordering
* @param {string[]} optionOrder - the ids of the product's options in the
* new order
* @return {Promise} the result of the update operation
*/
async reorderOptions(productId, optionOrder) {
return this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const product = await this.retrieve(productId, { relations: ["options"] })
if (product.options.length !== optionOrder.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Product options and new options order differ in length.`
)
}
product.options = optionOrder.map((oId) => {
const option = product.options.find((o) => o.id === oId)
if (!option) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Product has no option with id: ${oId}`
)
}
return option
})
const result = productRepo.save(product)
await this.eventBus_
.withTransaction(manager)
.emit(ProductService.Events.UPDATED, result)
return result
})
}
/**
* Updates a product's option. Throws if the call tries to update an option
* not associated with the product. Throws if the updated title already exists.
* @param {string} productId - the product whose option we are updating
* @param {string} optionId - the id of the option we are updating
* @param {object} data - the data to update the option with
* @return {Promise} the updated product
* @param productId - the product whose option we are updating
* @param optionId - the id of the option we are updating
* @param data - the data to update the option with
* @return the updated product
*/
async updateOption(productId, optionId, data) {
return this.atomicPhase_(async (manager) => {
async updateOption(
productId: string,
optionId: string,
data: ProductOptionInput
): Promise<Product> {
return await this.atomicPhase_(async (manager) => {
const productOptionRepo = manager.getCustomRepository(
this.productOptionRepository_
)
@@ -756,7 +671,9 @@ class ProductService extends BaseService {
}
productOption.title = title
productOption.values = values
if (values) {
productOption.values = values
}
await productOptionRepo.save(productOption)
@@ -769,12 +686,15 @@ class ProductService extends BaseService {
/**
* Delete an option from a product.
* @param {string} productId - the product to delete an option from
* @param {string} optionId - the option to delete
* @return {Promise} the updated product
* @param productId - the product to delete an option from
* @param optionId - the option to delete
* @return the updated product
*/
async deleteOption(productId, optionId) {
return this.atomicPhase_(async (manager) => {
async deleteOption(
productId: string,
optionId: string
): Promise<Product | void> {
return await this.atomicPhase_(async (manager) => {
const productOptionRepo = manager.getCustomRepository(
this.productOptionRepository_
)
@@ -803,12 +723,12 @@ class ProductService extends BaseService {
const valueToMatch = firstVariant.options.find(
(o) => o.option_id === optionId
).value
)?.value
const equalsFirst = await Promise.all(
product.variants.map(async (v) => {
const option = v.options.find((o) => o.option_id === optionId)
return option.value === valueToMatch
return option?.value === valueToMatch
})
)
@@ -829,40 +749,28 @@ class ProductService extends BaseService {
})
}
/**
* Decorates a product with product variants.
* @param {string} productId - the productId to decorate.
* @param {string[]} fields - the fields to include.
* @param {string[]} expandFields - fields to expand.
* @param {object} config - retrieve config for price calculation.
* @return {Product} return the decorated product.
*/
async decorate(productId, fields = [], expandFields = [], config = {}) {
const requiredFields = ["id", "metadata"]
fields = fields.concat(requiredFields)
return await this.retrieve(productId, {
select: fields,
relations: expandFields,
})
}
/**
* Creates a query object to be used for list queries.
* @param {object} selector - the selector to create the query from
* @param {object} config - the config to use for the query
* @return {object} an object containing the query, relations and free-text
* @param selector - the selector to create the query from
* @param config - the config to use for the query
* @return an object containing the query, relations and free-text
* search param.
*/
prepareListQuery_(selector, config) {
protected prepareListQuery_(
selector: FilterableProductProps | Selector<Product>,
config: FindProductConfig
): {
q: string
relations: (keyof Product)[]
query: FindWithoutRelationsOptions
} {
let q
if ("q" in selector) {
q = selector.q
delete selector.q
}
const query = this.buildQuery_(selector, config)
const query = buildQuery(selector, config)
if (config.relations && config.relations.length > 0) {
query.relations = config.relations
@@ -876,8 +784,8 @@ class ProductService extends BaseService {
delete query.relations
return {
query,
relations: rels,
query: query as FindWithoutRelationsOptions,
relations: rels as (keyof Product)[],
q,
}
}

View File

@@ -36,9 +36,12 @@ export type Writable<T> = {
| FindOperator<string[]>
}
export type ExtendedFindConfig<TEntity> = FindConfig<TEntity> &
export type ExtendedFindConfig<
TEntity,
TWhereKeys = TEntity
> = FindConfig<TEntity> &
(FindOneOptions<TEntity> | FindManyOptions<TEntity>) & {
where: Partial<Writable<TEntity>>
where: Partial<Writable<TWhereKeys>>
withDeleted?: boolean
relations?: string[]
}

View File

@@ -155,3 +155,11 @@ export type PriceListPriceCreateInput = {
min_quantity?: number
max_quantity?: number
}
export type PriceListLoadConfig = {
include_discount_prices?: boolean
customer_id?: string
cart_id?: string
region_id?: string
currency_code?: string
}

View File

@@ -62,7 +62,7 @@ export type CreateProductVariantInput = {
export type UpdateProductVariantInput = {
title?: string
product_id: string
product_id?: string
sku?: string
barcode?: string
ean?: string
@@ -72,14 +72,15 @@ export type UpdateProductVariantInput = {
manage_inventory?: boolean
hs_code?: string
origin_country?: string
variant_rank?: number
mid_code?: string
material?: string
weight?: number
length?: number
height?: number
width?: number
options: ProductVariantOption[]
prices: ProductVariantPrice[]
options?: ProductVariantOption[]
prices?: ProductVariantPrice[]
metadata?: object
}

View File

@@ -7,21 +7,25 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { FindOperator } from "typeorm"
import { Product, ProductOptionValue, ProductStatus } from "../models"
import { optionalBooleanMapper } from "../utils/validators/is-boolean"
import { IsType } from "../utils/validators/is-type"
import { DateComparisonOperator, StringComparisonOperator } from "./common"
export enum ProductStatus {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
REJECTED = "rejected",
}
import {
DateComparisonOperator,
FindConfig,
Selector,
StringComparisonOperator,
} from "./common"
import { PriceListLoadConfig } from "./price-list"
/**
* API Level DTOs + Validation rules
*/
export class FilterableProductProps {
@IsString()
@IsOptional()
id?: string
@IsType([String, [String]])
id?: string | string[]
@IsString()
@IsOptional()
@@ -123,3 +127,115 @@ export class FilterableProductTypeProps {
@IsOptional()
q?: string
}
/**
* Service Level DTOs
*/
export type CreateProductInput = {
title: string
subtitle?: string
profile_id?: string
description?: string
is_giftcard?: boolean
discountable?: boolean
images?: string[]
thumbnail?: string
handle?: string
status?: ProductStatus
type?: CreateProductProductTypeInput
collection_id?: string
tags?: CreateProductProductTagInput[]
options?: CreateProductProductOption[]
variants?: CreateProductProductVariantInput[]
weight?: number
length?: number
height?: number
width?: number
hs_code?: string
origin_country?: string
mid_code?: string
material?: string
metadata?: Record<string, unknown>
}
export type CreateProductProductTagInput = {
id?: string
value: string
}
export type CreateProductProductTypeInput = {
id?: string
value: string
}
export type CreateProductProductVariantInput = {
title: string
sku?: string
ean?: string
upc?: string
barcode?: string
hs_code?: string
inventory_quantity?: number
allow_backorder?: boolean
manage_inventory?: boolean
weight?: number
length?: number
height?: number
width?: number
origin_country?: string
mid_code?: string
material?: string
metadata?: object
prices?: CreateProductProductVariantPriceInput[]
options?: { value: string }[]
}
export type UpdateProductProductVariantDTO = {
id?: string
title?: string
sku?: string
ean?: string
upc?: string
barcode?: string
hs_code?: string
inventory_quantity?: number
allow_backorder?: boolean
manage_inventory?: boolean
weight?: number
length?: number
height?: number
width?: number
origin_country?: string
mid_code?: string
material?: string
metadata?: object
prices?: CreateProductProductVariantPriceInput[]
options?: { value: string; option_id: string }[]
}
export type CreateProductProductOption = {
title: string
}
export type CreateProductProductVariantPriceInput = {
region_id?: string
currency_code?: string
amount: number
min_quantity?: number
max_quantity?: number
}
export type UpdateProductInput = Omit<
Partial<CreateProductInput>,
"variants"
> & {
variants?: UpdateProductProductVariantDTO[]
}
export type ProductOptionInput = {
title: string
values?: ProductOptionValue[]
}
export type FindProductConfig = FindConfig<Product> & PriceListLoadConfig

View File

@@ -7,18 +7,16 @@ import {
import { FindOperator, In, Raw } from "typeorm"
/**
* Used to build TypeORM queries.
* @param selector The selector
* @param config The config
* @return The QueryBuilderConfig
*/
export function buildQuery<TEntity = unknown>(
selector: Selector<TEntity>,
* Used to build TypeORM queries.
* @param selector The selector
* @param config The config
* @return The QueryBuilderConfig
*/
export function buildQuery<TWhereKeys, TEntity = unknown>(
selector: TWhereKeys,
config: FindConfig<TEntity> = {}
): ExtendedFindConfig<TEntity> {
const build = (
obj: Selector<TEntity>
): Partial<Writable<TEntity>> => {
): ExtendedFindConfig<TEntity, TWhereKeys> {
const build = (obj: Selector<TEntity>): Partial<Writable<TWhereKeys>> => {
return Object.entries(obj).reduce((acc, [key, value]: any) => {
// Undefined values indicate that they have no significance to the query.
// If the query is looking for rows where a column is not set it should use null instead of undefined
@@ -75,10 +73,10 @@ export function buildQuery<TEntity = unknown>(
}
return acc
}, {} as Partial<Writable<TEntity>>)
}, {} as Partial<Writable<TWhereKeys>>)
}
const query: ExtendedFindConfig<TEntity> = {
const query: ExtendedFindConfig<TEntity, TWhereKeys> = {
where: build(selector),
}
@@ -107,4 +105,4 @@ export function buildQuery<TEntity = unknown>(
}
return query
}
}

View File

@@ -0,0 +1,15 @@
/**
*
* @param relations relations from which a relation should be removed
* @param relation relation to be removed
* @returns tuple containing the new relations and a boolean indicating whether the relation was found in the relations array
*/
export const omitRelationIfExists = (
relations: string[],
relation: string
): [string[], boolean] => {
const filteredRelations = relations.filter((rel) => rel !== relation)
const includesRelation = relations.length !== filteredRelations.length
return [relations, includesRelation]
}

View File

@@ -7,7 +7,7 @@ import { MedusaError } from "medusa-core-utils/dist"
* @return resolves to the updated result.
*/
export function setMetadata(
obj: { metadata: Record<string, unknown> },
obj: { metadata: Record<string, unknown> | null },
metadata: Record<string, unknown>
): Record<string, unknown> {
const existing = obj.metadata || {}