feat(medusa): List products with Remote Query (#4969)
**What** - includes some type fixes in the DAL layer - List products including their prices and filtered by the sales channel as well as q parameter and category scope and all other filters - Assign shipping profile - ordering - Add missing columns in the product module - update product module migrations **Comment** - In regards to the fields, we can pass whatever we want the module will only return the one that exists (default behavior), but on the other hand, that is not possible for the relations. **question** - To simplify usage, should we expose the fields/relations available from the module to simplify building a query for the user and be aware of what the module provides **todo** - Add back the support for the user to ask for fields/relations
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
CartService,
|
||||
ProductService,
|
||||
ProductVariantInventoryService,
|
||||
SalesChannelService,
|
||||
} from "../../../../services"
|
||||
import {
|
||||
IsArray,
|
||||
@@ -22,6 +23,8 @@ import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-cha
|
||||
import { cleanResponseData } from "../../../../utils/clean-response-data"
|
||||
import { defaultStoreCategoryScope } from "../product-categories"
|
||||
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
|
||||
import IsolateProductDomain from "../../../../loaders/feature-flags/isolate-product-domain"
|
||||
import { defaultStoreProductsFields } from "./index"
|
||||
|
||||
/**
|
||||
* @oas [get] /store/products
|
||||
@@ -216,6 +219,8 @@ export default async (req, res) => {
|
||||
const pricingService: PricingService = req.scope.resolve("pricingService")
|
||||
const cartService: CartService = req.scope.resolve("cartService")
|
||||
|
||||
const featureFlagRouter = req.scope.resolve("featureFlagRouter")
|
||||
|
||||
const validated = req.validatedQuery as StoreGetProductsParams
|
||||
|
||||
let {
|
||||
@@ -224,7 +229,6 @@ export default async (req, res) => {
|
||||
currency_code: currencyCode,
|
||||
...filterableFields
|
||||
} = req.filterableFields
|
||||
|
||||
const listConfig = req.listConfig
|
||||
|
||||
// get only published products for store endpoint
|
||||
@@ -246,9 +250,23 @@ export default async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isIsolateProductDomain = featureFlagRouter.isFeatureEnabled(
|
||||
IsolateProductDomain.key
|
||||
)
|
||||
|
||||
const promises: Promise<any>[] = []
|
||||
|
||||
promises.push(productService.listAndCount(filterableFields, listConfig))
|
||||
if (isIsolateProductDomain) {
|
||||
promises.push(
|
||||
listAndCountProductWithIsolatedProductModule(
|
||||
req,
|
||||
filterableFields,
|
||||
listConfig
|
||||
)
|
||||
)
|
||||
} else {
|
||||
promises.push(productService.listAndCount(filterableFields, listConfig))
|
||||
}
|
||||
|
||||
if (validated.cart_id) {
|
||||
promises.push(
|
||||
@@ -312,6 +330,197 @@ export default async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
async function listAndCountProductWithIsolatedProductModule(
|
||||
req,
|
||||
filterableFields,
|
||||
listConfig
|
||||
) {
|
||||
// TODO: Add support for fields/expands
|
||||
|
||||
const remoteQuery = req.scope.resolve("remoteQuery")
|
||||
|
||||
let salesChannelIdFilter = filterableFields.sales_channel_id
|
||||
if (req.publishableApiKeyScopes?.sales_channel_ids.length) {
|
||||
salesChannelIdFilter ??= req.publishableApiKeyScopes.sales_channel_ids
|
||||
}
|
||||
|
||||
delete filterableFields.sales_channel_id
|
||||
|
||||
filterableFields["categories"] = {
|
||||
$or: [
|
||||
{
|
||||
id: null,
|
||||
},
|
||||
{
|
||||
...(filterableFields.categories || {}),
|
||||
// Store APIs are only allowed to query active and public categories
|
||||
...defaultStoreCategoryScope,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// This is not the best way of handling cross filtering but for now I would say it is fine
|
||||
if (salesChannelIdFilter) {
|
||||
const salesChannelService = req.scope.resolve(
|
||||
"salesChannelService"
|
||||
) as SalesChannelService
|
||||
|
||||
const productIdsInSalesChannel =
|
||||
await salesChannelService.listProductIdsBySalesChannelIds(
|
||||
salesChannelIdFilter
|
||||
)
|
||||
|
||||
let filteredProductIds = productIdsInSalesChannel[salesChannelIdFilter]
|
||||
|
||||
if (filterableFields.id) {
|
||||
filterableFields.id = Array.isArray(filterableFields.id)
|
||||
? filterableFields.id
|
||||
: [filterableFields.id]
|
||||
|
||||
const salesChannelProductIdsSet = new Set(filteredProductIds)
|
||||
|
||||
filteredProductIds = filterableFields.id.filter((productId) =>
|
||||
salesChannelProductIdsSet.has(productId)
|
||||
)
|
||||
}
|
||||
|
||||
filterableFields.id = filteredProductIds
|
||||
}
|
||||
|
||||
const variables = {
|
||||
filters: filterableFields,
|
||||
order: listConfig.order,
|
||||
skip: listConfig.skip,
|
||||
take: listConfig.take,
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
const args = `
|
||||
filters: $filters,
|
||||
order: $order,
|
||||
skip: $skip,
|
||||
take: $take
|
||||
`
|
||||
|
||||
const query = `
|
||||
query ($filters: any, $order: any, $skip: Int, $take: Int) {
|
||||
product (${args}) {
|
||||
${defaultStoreProductsFields.join("\n")}
|
||||
|
||||
images {
|
||||
id
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at
|
||||
url
|
||||
metadata
|
||||
}
|
||||
|
||||
tags {
|
||||
id
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at
|
||||
value
|
||||
}
|
||||
|
||||
type {
|
||||
id
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at
|
||||
value
|
||||
}
|
||||
|
||||
collection {
|
||||
title
|
||||
handle
|
||||
id
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at
|
||||
}
|
||||
|
||||
options {
|
||||
id
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at
|
||||
title
|
||||
product_id
|
||||
metadata
|
||||
values {
|
||||
id
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at
|
||||
value
|
||||
option_id
|
||||
variant_id
|
||||
metadata
|
||||
}
|
||||
}
|
||||
|
||||
variants {
|
||||
id
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at
|
||||
title
|
||||
product_id
|
||||
sku
|
||||
barcode
|
||||
ean
|
||||
upc
|
||||
variant_rank
|
||||
inventory_quantity
|
||||
allow_backorder
|
||||
manage_inventory
|
||||
hs_code
|
||||
origin_country
|
||||
mid_code
|
||||
material
|
||||
weight
|
||||
length
|
||||
height
|
||||
width
|
||||
metadata
|
||||
options {
|
||||
id
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at
|
||||
value
|
||||
option_id
|
||||
variant_id
|
||||
metadata
|
||||
}
|
||||
}
|
||||
|
||||
profile {
|
||||
id
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at
|
||||
name
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const {
|
||||
rows: products,
|
||||
metadata: { count },
|
||||
} = await remoteQuery(query, variables)
|
||||
|
||||
products.forEach((product) => {
|
||||
product.profile_id = product.profile?.id
|
||||
})
|
||||
|
||||
return [products, count]
|
||||
}
|
||||
|
||||
export class StoreGetProductsPaginationParams extends PriceSelectionParams {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||
import IsolateProductDomain from "../loaders/feature-flags/isolate-product-domain"
|
||||
|
||||
export const featureFlag = IsolateProductDomain.key
|
||||
|
||||
export class LineItemProductId1692870898424 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "product_shipping_profile" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now();
|
||||
ALTER TABLE "product_shipping_profile" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now();
|
||||
ALTER TABLE "product_shipping_profile" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP WITH TIME ZONE;
|
||||
`)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "product_shipping_profile" DROP COLUMN IF NOT EXISTS "created_at";
|
||||
ALTER TABLE "product_shipping_profile" DROP COLUMN IF NOT EXISTS "updated_at";
|
||||
ALTER TABLE "product_shipping_profile" DROP COLUMN IF NOT EXISTS "deleted_at";
|
||||
`)
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,7 @@ export class LineItem extends BaseEntity {
|
||||
}
|
||||
}
|
||||
|
||||
@FeatureFlagDecorators(IsolateProductDomain.key, [BeforeUpdate])
|
||||
@FeatureFlagDecorators(IsolateProductDomain.key, [BeforeUpdate()])
|
||||
beforeUpdate(): void {
|
||||
if (
|
||||
this.variant &&
|
||||
@@ -189,7 +189,7 @@ export class LineItem extends BaseEntity {
|
||||
}
|
||||
}
|
||||
|
||||
@FeatureFlagDecorators(IsolateProductDomain.key, [AfterLoad, AfterUpdate])
|
||||
@FeatureFlagDecorators(IsolateProductDomain.key, [AfterLoad(), AfterUpdate()])
|
||||
afterUpdateOrLoad(): void {
|
||||
if (this.variant) {
|
||||
return
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Brackets, DeleteResult, FindOptionsWhere, In, ILike } from "typeorm"
|
||||
import { DeleteResult, FindOptionsWhere, ILike, In } from "typeorm"
|
||||
import { SalesChannel } from "../models"
|
||||
import { ExtendedFindConfig } from "../types/common"
|
||||
import { dataSource } from "../loaders/database"
|
||||
@@ -76,6 +76,27 @@ export const SalesChannelRepository = dataSource
|
||||
.orIgnore()
|
||||
.execute()
|
||||
},
|
||||
|
||||
async listProductIdsBySalesChannelIds(
|
||||
salesChannelIds: string | string[]
|
||||
): Promise<{ [salesChannelId: string]: string[] }> {
|
||||
salesChannelIds = Array.isArray(salesChannelIds)
|
||||
? salesChannelIds
|
||||
: [salesChannelIds]
|
||||
|
||||
const result = await this.createQueryBuilder()
|
||||
.select(["sales_channel_id", "product_id"])
|
||||
.from(productSalesChannelTable, "psc")
|
||||
.where({ sales_channel_id: In(salesChannelIds) })
|
||||
.execute()
|
||||
|
||||
return result.reduce((acc, curr) => {
|
||||
acc[curr.sales_channel_id] ??= []
|
||||
acc[curr.sales_channel_id].push(curr.product_id)
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
})
|
||||
|
||||
export default SalesChannelRepository
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { ShippingProfile } from "../models"
|
||||
import { dataSource } from "../loaders/database"
|
||||
|
||||
export const ShippingProfileRepository =
|
||||
dataSource.getRepository(ShippingProfile)
|
||||
export const ShippingProfileRepository = dataSource
|
||||
.getRepository(ShippingProfile)
|
||||
.extend({
|
||||
async findByProducts(
|
||||
productIds: string | string[]
|
||||
): Promise<{ [product_id: string]: ShippingProfile[] }> {
|
||||
productIds = Array.isArray(productIds) ? productIds : [productIds]
|
||||
|
||||
const shippingProfiles = await this.createQueryBuilder("sp")
|
||||
.select("*")
|
||||
.innerJoin("product_shipping_profile", "psp", "psp.profile_id = sp.id")
|
||||
.where("psp.product_id IN (:...productIds)", { productIds })
|
||||
.execute()
|
||||
|
||||
return shippingProfiles.reduce((acc, productShippingProfile) => {
|
||||
acc[productShippingProfile.product_id] ??= []
|
||||
acc[productShippingProfile.product_id].push(productShippingProfile)
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
})
|
||||
export default ShippingProfileRepository
|
||||
|
||||
@@ -302,6 +302,22 @@ class SalesChannelService extends TransactionBaseService {
|
||||
return store.default_sales_channel
|
||||
}
|
||||
|
||||
/**
|
||||
* List all product ids that belongs to the sales channels ids
|
||||
*
|
||||
* @param salesChannelIds
|
||||
*/
|
||||
async listProductIdsBySalesChannelIds(
|
||||
salesChannelIds: string | string[]
|
||||
): Promise<{ [salesChannelId: string]: string[] }> {
|
||||
const salesChannelRepo = this.activeManager_.withRepository(
|
||||
this.salesChannelRepository_
|
||||
)
|
||||
return await salesChannelRepo.listProductIdsBySalesChannelIds(
|
||||
salesChannelIds
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a batch of product from a sales channel
|
||||
* @param salesChannelId - The id of the sales channel on which to remove the products
|
||||
|
||||
@@ -156,6 +156,36 @@ class ShippingProfileService extends TransactionBaseService {
|
||||
return profile
|
||||
}
|
||||
|
||||
async retrieveForProducts(
|
||||
productIds: string | string[]
|
||||
): Promise<{ [product_id: string]: ShippingProfile[] }> {
|
||||
if (!isDefined(productIds)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`"productIds" must be defined`
|
||||
)
|
||||
}
|
||||
|
||||
productIds = isString(productIds) ? [productIds] : productIds
|
||||
|
||||
const profileRepository = this.activeManager_.withRepository(
|
||||
this.shippingProfileRepository_
|
||||
)
|
||||
|
||||
const productProfilesMap = await profileRepository.findByProducts(
|
||||
productIds
|
||||
)
|
||||
|
||||
if (!Object.keys(productProfilesMap)?.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`No Profile found for products with id: ${productIds.join(", ")}`
|
||||
)
|
||||
}
|
||||
|
||||
return productProfilesMap
|
||||
}
|
||||
|
||||
async retrieveDefault(): Promise<ShippingProfile | null> {
|
||||
const profileRepository = this.activeManager_.withRepository(
|
||||
this.shippingProfileRepository_
|
||||
|
||||
Reference in New Issue
Block a user