diff --git a/.eslintrc.js b/.eslintrc.js index ee070ed040..82c29f3eb6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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", diff --git a/integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap index 91656cacc1..50244b3fb8 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/product-tag.js.snap @@ -12,13 +12,13 @@ Array [ "created_at": Any, "id": "tag3", "updated_at": Any, - "value": "123", + "value": "1235", }, Object { "created_at": Any, "id": "tag4", "updated_at": Any, - "value": "123", + "value": "1234", }, ] `; @@ -35,13 +35,13 @@ Array [ "created_at": Any, "id": "tag3", "updated_at": Any, - "value": "123", + "value": "1235", }, Object { "created_at": Any, "id": "tag4", "updated_at": Any, - "value": "123", + "value": "1234", }, ] `; diff --git a/integration-tests/api/__tests__/admin/product-tag.js b/integration-tests/api/__tests__/admin/product-tag.js index 1c3de72149..b2cfffdfb2 100644 --- a/integration-tests/api/__tests__/admin/product-tag.js +++ b/integration-tests/api/__tests__/admin/product-tag.js @@ -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, diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index c9951c5f5c..866381ad17 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -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" }, + ]) + ) + }) + }) }) diff --git a/integration-tests/api/helpers/product-seeder.js b/integration-tests/api/helpers/product-seeder.js index a9ce2cb21f..d66a0c862b 100644 --- a/integration-tests/api/helpers/product-seeder.js +++ b/integration-tests/api/helpers/product-seeder.js @@ -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) diff --git a/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts index f62aa3eaa2..85b2dc8807 100644 --- a/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts +++ b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts @@ -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() diff --git a/packages/medusa/src/api/routes/admin/products/create-product.ts b/packages/medusa/src/api/routes/admin/products/create-product.ts index 830a6561c9..48475ce267 100644 --- a/packages/medusa/src/api/routes/admin/products/create-product.ts +++ b/packages/medusa/src/api/routes/admin/products/create-product.ts @@ -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 @IsArray() @ValidateNested({ each: true }) @@ -485,5 +485,5 @@ export class AdminPostProductsReq { @IsObject() @IsOptional() - metadata?: object + metadata?: Record } diff --git a/packages/medusa/src/api/routes/admin/products/index.ts b/packages/medusa/src/api/routes/admin/products/index.ts index 397bf78550..2b3931680f 100644 --- a/packages/medusa/src/api/routes/admin/products/index.ts +++ b/packages/medusa/src/api/routes/admin/products/index.ts @@ -75,7 +75,7 @@ export const defaultAdminProductRelations = [ "collection", ] -export const defaultAdminProductFields = [ +export const defaultAdminProductFields: (keyof Product)[] = [ "id", "title", "subtitle", diff --git a/packages/medusa/src/api/routes/admin/products/list-products.ts b/packages/medusa/src/api/routes/admin/products/list-products.ts index c102aae7f9..22468e13b6 100644 --- a/packages/medusa/src/api/routes/admin/products/list-products.ts +++ b/packages/medusa/src/api/routes/admin/products/list-products.ts @@ -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() diff --git a/packages/medusa/src/api/routes/admin/products/update-product.ts b/packages/medusa/src/api/routes/admin/products/update-product.ts index 66306a5ce1..d5935c2f3f 100644 --- a/packages/medusa/src/api/routes/admin/products/update-product.ts +++ b/packages/medusa/src/api/routes/admin/products/update-product.ts @@ -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 @IsArray() @IsOptional() @@ -424,5 +421,5 @@ export class AdminPostProductsProductReq { @IsObject() @IsOptional() - metadata?: object + metadata?: Record } diff --git a/packages/medusa/src/api/routes/store/products/list-products.ts b/packages/medusa/src/api/routes/store/products/list-products.ts index eef9ff57e7..586ac505f4 100644 --- a/packages/medusa/src/api/routes/store/products/list-products.ts +++ b/packages/medusa/src/api/routes/store/products/list-products.ts @@ -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 set.add("id") includeFields = [...set] } diff --git a/packages/medusa/src/controllers/products/admin-list-products.ts b/packages/medusa/src/controllers/products/admin-list-products.ts index 334ef46f98..417a1ba2b8 100644 --- a/packages/medusa/src/controllers/products/admin-list-products.ts +++ b/packages/medusa/src/controllers/products/admin-list-products.ts @@ -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) diff --git a/packages/medusa/src/models/line-item.ts b/packages/medusa/src/models/line-item.ts index 0701961754..abad1cd5ad 100644 --- a/packages/medusa/src/models/line-item.ts +++ b/packages/medusa/src/models/line-item.ts @@ -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 diff --git a/packages/medusa/src/models/product.ts b/packages/medusa/src/models/product.ts index cfe025eeb2..f3d6f84aa6 100644 --- a/packages/medusa/src/models/product.ts +++ b/packages/medusa/src/models/product.ts @@ -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 + metadata: Record | null @BeforeInsert() private beforeInsert(): void { diff --git a/packages/medusa/src/repositories/image.ts b/packages/medusa/src/repositories/image.ts index c8799ca37d..fac2aad568 100644 --- a/packages/medusa/src/repositories/image.ts +++ b/packages/medusa/src/repositories/image.ts @@ -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 {} +export class ImageRepository extends Repository { + 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 + } +} diff --git a/packages/medusa/src/repositories/price-list.ts b/packages/medusa/src/repositories/price-list.ts index 17badaef2d..38012f9fc2 100644 --- a/packages/medusa/src/repositories/price-list.ts +++ b/packages/medusa/src/repositories/price-list.ts @@ -11,7 +11,10 @@ import { CustomFindOptions, ExtendedFindConfig } from "../types/common" import { CustomerGroup } from "../models" import { FilterablePriceListProps } from "../types/price-list" -export type PriceListFindOptions = CustomFindOptions +export type PriceListFindOptions = CustomFindOptions< + PriceList, + "status" | "type" +> @EntityRepository(PriceList) export class PriceListRepository extends Repository { @@ -108,10 +111,10 @@ export class PriceListRepository extends Repository { .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) { diff --git a/packages/medusa/src/repositories/product-tag.ts b/packages/medusa/src/repositories/product-tag.ts index 0d937cfb5b..f2a5412578 100644 --- a/packages/medusa/src/repositories/product-tag.ts +++ b/packages/medusa/src/repositories/product-tag.ts @@ -1,5 +1,50 @@ -import { EntityRepository, Repository } from "typeorm" +import { EntityRepository, In, Repository } from "typeorm" import { ProductTag } from "../models/product-tag" +type UpsertTagsInput = (Partial & { + value: string +})[] + @EntityRepository(ProductTag) -export class ProductTagRepository extends Repository {} +export class ProductTagRepository extends Repository { + public async listTagsByUsage(count = 10): Promise { + 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 { + 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 + } +} diff --git a/packages/medusa/src/repositories/product-type.ts b/packages/medusa/src/repositories/product-type.ts index 1510eef480..eb1e9391ea 100644 --- a/packages/medusa/src/repositories/product-type.ts +++ b/packages/medusa/src/repositories/product-type.ts @@ -1,5 +1,29 @@ import { EntityRepository, Repository } from "typeorm" import { ProductType } from "../models/product-type" +type UpsertTypeInput = Partial & { + value: string +} @EntityRepository(ProductType) -export class ProductTypeRepository extends Repository {} +export class ProductTypeRepository extends Repository { + async upsertType(type?: UpsertTypeInput): Promise { + 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 + } +} diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index 4a3979aaec..6935e01d35 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -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, "relations"> - -type CustomOptions = { - select?: DefaultWithoutRelations["select"] - where?: DefaultWithoutRelations["where"] & { - tags?: FindOperator - price_list_id?: FindOperator - } - order?: OrderByCondition - skip?: number - take?: number - withDeleted?: boolean +export type ProductSelector = Omit, "tags"> & { + tags: FindOperator } -type FindWithRelationsOptions = CustomOptions +export type DefaultWithoutRelations = Omit< + ExtendedFindConfig, + "relations" +> + +export type FindWithoutRelationsOptions = DefaultWithoutRelations & { + where: DefaultWithoutRelations["where"] & { + price_list_id?: FindOperator + } +} @EntityRepository(Product) export class ProductRepository extends Repository { @@ -41,7 +41,7 @@ export class ProductRepository extends Repository { } 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 { } private getGroupedRelations( - relations: Array + relations: string[] ): { [toplevel: string]: string[] } { @@ -189,8 +189,8 @@ export class ProductRepository extends Repository { } public async findWithRelationsAndCount( - relations: Array = [], - 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 { } public async findWithRelations( - relations: Array = [], - idsOrOptionsWithoutRelations: FindWithRelationsOptions | string[] = {}, + relations: string[] = [], + idsOrOptionsWithoutRelations: FindWithoutRelationsOptions | string[] = { + where: {}, + }, withDeleted = false ): Promise { let entities: Product[] @@ -285,8 +287,8 @@ export class ProductRepository extends Repository { } public async findOneWithRelations( - relations: Array = [], - optionsWithoutRelations: FindWithRelationsOptions = { where: {} } + relations: string[] = [], + optionsWithoutRelations: FindWithoutRelationsOptions = { where: {} } ): Promise { // Limit 1 optionsWithoutRelations.take = 1 @@ -326,8 +328,8 @@ export class ProductRepository extends Repository { 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 { } private _cleanOptions( - options: CustomOptions - ): WithRequiredProperty { + options: FindWithoutRelationsOptions + ): WithRequiredProperty { const where = options.where ?? {} if ("description" in where) { delete where.description diff --git a/packages/medusa/src/services/__tests__/product.js b/packages/medusa/src/services/__tests__/product.js index bbfc3bb278..18049d646a 100644 --- a/packages/medusa/src/services/__tests__/product.js +++ b/packages/medusa/src/services/__tests__/product.js @@ -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) => diff --git a/packages/medusa/src/services/batch-job.ts b/packages/medusa/src/services/batch-job.ts index c0bf9b25b0..43ad932a47 100644 --- a/packages/medusa/src/services/batch-job.ts +++ b/packages/medusa/src/services/batch-job.ts @@ -106,7 +106,7 @@ class BatchJobService extends TransactionBaseService { this.batchJobRepository_ ) - const query = buildQuery({ id: batchJobId }, config) + const query = buildQuery({ id: batchJobId }, config) const batchJob = await batchJobRepo.findOne(query) if (!batchJob) { diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index 817bbc7f1f..bcdf70e698 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -262,7 +262,7 @@ class CartService extends TransactionBaseService { this.cartRepository_ ) - const query = buildQuery(selector, config) + const query = buildQuery(selector, config) return await cartRepo.find(query) } ) @@ -290,7 +290,7 @@ class CartService extends TransactionBaseService { const { select, relations, totalsToSelect } = this.transformQueryForTotals_(options) - const query = buildQuery( + const query = buildQuery( { id: validatedId }, { ...options, select, relations } ) diff --git a/packages/medusa/src/services/claim.ts b/packages/medusa/src/services/claim.ts index 0e5ba19eb6..a241a3421a 100644 --- a/packages/medusa/src/services/claim.ts +++ b/packages/medusa/src/services/claim.ts @@ -819,7 +819,7 @@ export default class ClaimService extends TransactionBaseService< const claimRepo = transactionManager.getCustomRepository( this.claimRepository_ ) - const query = buildQuery(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({ id }, config) + const query = buildQuery({ id }, config) const claim = await claimRepo.findOne(query) if (!claim) { diff --git a/packages/medusa/src/services/price-list.ts b/packages/medusa/src/services/price-list.ts index 286df6fbc0..97b3afd650 100644 --- a/packages/medusa/src/services/price-list.ts +++ b/packages/medusa/src/services/price-list.ts @@ -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 { 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 { productVariantService, priceListRepository, moneyAmountRepository, + productVariantRepository, }: PriceListConstructorProps) { // eslint-disable-next-line prefer-rest-params super(arguments[0]) @@ -68,6 +72,7 @@ class PriceListService extends TransactionBaseService { this.regionService_ = regionService this.priceListRepo_ = priceListRepository this.moneyAmountRepo_ = moneyAmountRepository + this.productVariantRepo_ = productVariantRepository } /** @@ -247,10 +252,7 @@ class PriceListService extends TransactionBaseService { const priceListRepo = manager.getCustomRepository(this.priceListRepo_) const { q, ...priceListSelector } = selector - const query = buildQuery( - priceListSelector, - config - ) + const query = buildQuery(priceListSelector, config) const groups = query.where.customer_groups as FindOperator query.where.customer_groups = undefined @@ -277,10 +279,10 @@ class PriceListService extends TransactionBaseService { return await this.atomicPhase_(async (manager: EntityManager) => { const priceListRepo = manager.getCustomRepository(this.priceListRepo_) const { q, ...priceListSelector } = selector - const { relations, ...query } = buildQuery( - priceListSelector, - config - ) + const { relations, ...query } = buildQuery< + FilterablePriceListProps, + FilterablePriceListProps + >(priceListSelector, config) const groups = query.where.customer_groups as FindOperator delete query.where.customer_groups @@ -327,6 +329,9 @@ class PriceListService extends TransactionBaseService { 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 { requiresPriceList ) - return { + return productVariantRepo.create({ ...v, prices, - } + }) }) ) } diff --git a/packages/medusa/src/services/product.js b/packages/medusa/src/services/product.ts similarity index 50% rename from packages/medusa/src/services/product.js rename to packages/medusa/src/services/product.ts index 39def3b4fc..5b424047e1 100644 --- a/packages/medusa/src/services/product.js +++ b/packages/medusa/src/services/product.ts @@ -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 { + 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} the result of the find operation + * @return the result of the find operation */ async list( - selector = {}, - config = { + selector: FilterableProductProps | Selector = {}, + config: FindProductConfig = { relations: [], skip: 0, take: 20, include_discount_prices: false, } - ) { - const productRepo = this.manager_.getCustomRepository( - this.productRepository_ - ) + ): Promise { + 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, + 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 = {}): Promise { + 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} 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 { + 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} 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 { + 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} 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 { + 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, + config: FindProductConfig = { + include_discount_prices: false, } + ): Promise { + 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} 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 { + 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 { + return await this.atomicPhase_(async (manager) => { + const productTypeRepository = manager.getCustomRepository( + this.productTypeRepository_ + ) - return newTags + return await productTypeRepository.find({}) + }) + } + + async listTagsByUsage(count = 10): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + 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, } } diff --git a/packages/medusa/src/types/common.ts b/packages/medusa/src/types/common.ts index e005e5b249..e3b96f5adc 100644 --- a/packages/medusa/src/types/common.ts +++ b/packages/medusa/src/types/common.ts @@ -36,9 +36,12 @@ export type Writable = { | FindOperator } -export type ExtendedFindConfig = FindConfig & +export type ExtendedFindConfig< + TEntity, + TWhereKeys = TEntity +> = FindConfig & (FindOneOptions | FindManyOptions) & { - where: Partial> + where: Partial> withDeleted?: boolean relations?: string[] } diff --git a/packages/medusa/src/types/price-list.ts b/packages/medusa/src/types/price-list.ts index 0215ab3043..0a009d831f 100644 --- a/packages/medusa/src/types/price-list.ts +++ b/packages/medusa/src/types/price-list.ts @@ -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 +} diff --git a/packages/medusa/src/types/product-variant.ts b/packages/medusa/src/types/product-variant.ts index 73d2ff85cd..14699edaac 100644 --- a/packages/medusa/src/types/product-variant.ts +++ b/packages/medusa/src/types/product-variant.ts @@ -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 } diff --git a/packages/medusa/src/types/product.ts b/packages/medusa/src/types/product.ts index 5b27d0e2e5..c9eab96276 100644 --- a/packages/medusa/src/types/product.ts +++ b/packages/medusa/src/types/product.ts @@ -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 +} + +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, + "variants" +> & { + variants?: UpdateProductProductVariantDTO[] +} + +export type ProductOptionInput = { + title: string + values?: ProductOptionValue[] +} + +export type FindProductConfig = FindConfig & PriceListLoadConfig diff --git a/packages/medusa/src/utils/build-query.ts b/packages/medusa/src/utils/build-query.ts index eed9fed57d..4be9f14f75 100644 --- a/packages/medusa/src/utils/build-query.ts +++ b/packages/medusa/src/utils/build-query.ts @@ -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( - selector: Selector, + * Used to build TypeORM queries. + * @param selector The selector + * @param config The config + * @return The QueryBuilderConfig + */ +export function buildQuery( + selector: TWhereKeys, config: FindConfig = {} -): ExtendedFindConfig { - const build = ( - obj: Selector - ): Partial> => { +): ExtendedFindConfig { + const build = (obj: Selector): Partial> => { 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( } return acc - }, {} as Partial>) + }, {} as Partial>) } - const query: ExtendedFindConfig = { + const query: ExtendedFindConfig = { where: build(selector), } @@ -107,4 +105,4 @@ export function buildQuery( } return query -} \ No newline at end of file +} diff --git a/packages/medusa/src/utils/omit-relation-if-exists.ts b/packages/medusa/src/utils/omit-relation-if-exists.ts new file mode 100644 index 0000000000..938b43fdbc --- /dev/null +++ b/packages/medusa/src/utils/omit-relation-if-exists.ts @@ -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] +} diff --git a/packages/medusa/src/utils/set-metadata.ts b/packages/medusa/src/utils/set-metadata.ts index a7b645a190..e56ca95050 100644 --- a/packages/medusa/src/utils/set-metadata.ts +++ b/packages/medusa/src/utils/set-metadata.ts @@ -7,7 +7,7 @@ import { MedusaError } from "medusa-core-utils/dist" * @return resolves to the updated result. */ export function setMetadata( - obj: { metadata: Record }, + obj: { metadata: Record | null }, metadata: Record ): Record { const existing = obj.metadata || {}