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:
Adrien de Peretti
2023-09-12 15:55:05 +00:00
committed by GitHub
parent afd4e72cdf
commit 30863fee52
34 changed files with 1066 additions and 124 deletions
@@ -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()
@@ -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";
`)
}
}
+2 -2
View File
@@ -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_