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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const defaultAdminProductRelations = [
|
||||
"collection",
|
||||
]
|
||||
|
||||
export const defaultAdminProductFields = [
|
||||
export const defaultAdminProductFields: (keyof Product)[] = [
|
||||
"id",
|
||||
"title",
|
||||
"subtitle",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
15
packages/medusa/src/utils/omit-relation-if-exists.ts
Normal file
15
packages/medusa/src/utils/omit-relation-if-exists.ts
Normal 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]
|
||||
}
|
||||
@@ -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 || {}
|
||||
|
||||
Reference in New Issue
Block a user