feat(medusa, link-modules): sales channel <> cart link (#5459)

* feat: sales channel joiner config

* feat: product sales channel link config, SC list method

* feat: migration

* fix: refactor list SC

* refactor: SC repo api

* chore: changeset

* feat: add dedicated FF

* wip: cart<>sc link and migration

* chore: changeset

* fix: update migration with the cart table constraints

* feat: populate the pivot table

* chore: remove relation from joiner config

* fix: constraint name

* fix: filter out link relations when calling internal services

* feat: product<> sc join entity

* fix: update case

* fix: add FF on in the repository, fix tests

* fix: assign id when FF is on

* fix: target table

* feat: product service - fetch SC with RQ

* feat: admin list products & SC with isolated product domain

* feat: get admin product

* feat: store endpoints

* fix: remove duplicate import

* fix: remove "name" prop

* feat: typeorm entity changes

* feat: pivot table, entity, on cart create changes

* feat: update carts' SC

* feat: cart - getValidatedSalesChannel with RQ

* feat: refactor

* wip: changes to create cart workflow

* fix: remove join table entity due to migrations failing

* fix: product seeder if FF is on

* feat: attach SC handler and test

* fix: env

* feat: workflow compensation, cart service retrieve with RQ

* fix: remote joiner implode map

* chore: update changesets

* fix: remove methods from SC service/repo

* feat: use remote link in handlers

* fix: remove SC service calls

* fix: link params

* fix: migration add constraint to make link upsert pass

* refactor: workflow product handlers to handle remote links

* fix: condition

* fix: use correct method

* fix: build

* wip: update FF

* fix: update FF in the handlers

* chore: migrate to medusav2 FF

* chore: uncomment test

* fix: product factory

* fix: unlinking SC and product

* fix: use module name variable

* refactor: cleanup query definitions

* fix: add constraint

* wip: migrate FF

* fix: comments

* feat: cart entity callbacks, fix tests

* fix: only create SC in test

* wip: services updates, changes to models

* chore: rename prop

* fix: add hook

* fix: address comments

* fix: temp sc filtering

* fix: use RQ to filter by SC

* fix: relations on retrieve

* feat: migration sync data, remove FF

* fix: revert order of queries

* fix: alter migration, relations in service

* fix: revert id

* fix: migrations

* fix: make expand work

* fix: remote link method call

* fix: try making tests work without id in the pivot table

* test: use remote link

* test: relations changes

* fix: preserve channel id column

* fix: seeder and factory

* fix: remove sales_channels from response

* feat: support feature flag arrays

* fix: cover everything with correct FF

* fix: remove verbose

* fix: unit and plugin tests

* chore: comments

* fix: reenable workflow handler, add comments, split cart create workflow tests

* chore: reenable link in the create mehod, update changesets

* fix: address feedback

* fix: revert migration

* fix: change the migration to follow link module

* fix: migration syntax

* fix: merge conflicts

* fix: typo

* feat: remove store sales channel foreign key

* fix: merge migrations

* fix: FF keys

* refactor: cart service

* refactor: FF missing key

* fix: comments

* fix: address PR comments

* fix: new changesets

* fix: revert flag router changes

* chore: refactor `isFeatureEnabled`

---------

Co-authored-by: Carlos R. L. Rodrigues <rodrigolr@gmail.com>
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
This commit is contained in:
Frane Polić
2023-12-22 13:05:36 +01:00
committed by GitHub
parent b5a07cfcf4
commit 76332ca6c1
28 changed files with 662 additions and 89 deletions

View File

@@ -0,0 +1,9 @@
---
"@medusajs/orchestration": patch
"@medusajs/link-modules": patch
"@medusajs/core-flows": patch
"@medusajs/medusa": patch
"@medusajs/utils": patch
---
feat: SalesChannel <> Cart joiner config

View File

@@ -19,6 +19,7 @@ import {
ShippingMethodFactoryData,
simpleShippingMethodFactory,
} from "./simple-shipping-method-factory"
import { generateEntityId } from "@medusajs/utils"
export type CartFactoryData = {
id?: string
@@ -32,6 +33,8 @@ export type CartFactoryData = {
sales_channel_id?: string
}
const isMedusaV2Enabled = process.env.MEDUSA_FF_MEDUSA_V2 == "true"
export const simpleCartFactory = async (
dataSource: DataSource,
data: CartFactoryData = {},
@@ -77,7 +80,7 @@ export const simpleCartFactory = async (
}
const id = data.id || `simple-cart-${Math.random() * 1000}`
const toSave = manager.create(Cart, {
let toSave = {
id,
email:
typeof data.email !== "undefined" ? data.email : faker.internet.email(),
@@ -85,7 +88,18 @@ export const simpleCartFactory = async (
customer_id: customerId,
shipping_address_id: address.id,
sales_channel_id: sales_channel?.id ?? data.sales_channel_id ?? null,
})
}
if (isMedusaV2Enabled) {
await manager.query(
`INSERT INTO "cart_sales_channel" (id, cart_id, sales_channel_id)
VALUES ('${generateEntityId(undefined, "cartsc")}', '${toSave.id}', '${
sales_channel?.id ?? data.sales_channel_id
}');`
)
}
toSave = manager.create(Cart, toSave)
const cart = await manager.save(toSave)

View File

@@ -0,0 +1,96 @@
import { Region } from "@medusajs/medusa"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import {
simpleProductFactory,
simpleSalesChannelFactory,
} from "../../../../factories"
jest.setTimeout(30000)
const env = {
MEDUSA_FF_MEDUSA_V2: true,
}
describe("/store/carts", () => {
let dbConnection
let shutdownServer
const doAfterEach = async () => {
const db = useDb()
return await db.teardown()
}
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
describe("POST /store/carts", () => {
let prod1
let prodSale
beforeEach(async () => {
const manager = dbConnection.manager
await manager.insert(Region, {
id: "region",
name: "Test Region",
currency_code: "usd",
tax_rate: 0,
})
await manager.query(
`UPDATE "country"
SET region_id='region'
WHERE iso_2 = 'us'`
)
prod1 = await simpleProductFactory(dbConnection, {
id: "test-product",
variants: [{ id: "test-variant_1" }],
})
prodSale = await simpleProductFactory(dbConnection, {
id: "test-product-sale",
variants: [
{
id: "test-variant-sale",
prices: [{ amount: 1000, currency: "usd" }],
},
],
})
await simpleSalesChannelFactory(dbConnection, {
id: "amazon-sc",
name: "Amazon store",
})
})
afterEach(async () => {
await doAfterEach()
})
it("should create a cart in a sales channel", async () => {
const api = useApi()
const response = await api.post("/store/carts", {
sales_channel_id: "amazon-sc",
})
expect(response.status).toEqual(200)
const getRes = await api.get(`/store/carts/${response.data.cart.id}`)
expect(getRes.status).toEqual(200)
expect(getRes.data.cart.sales_channel.id).toEqual("amazon-sc")
})
})
})

View File

@@ -23,7 +23,7 @@ describe("/store/carts", () => {
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd })
dbConnection = await initDb({ cwd } as any)
shutdownServer = await startBootstrapApp({ cwd })
})

View File

@@ -4,11 +4,7 @@ const {
startBootstrapApp,
} = require("../../../../environment-helpers/bootstrap-app")
const { initDb, useDb } = require("../../../../environment-helpers/use-db")
const {
setPort,
useApi,
useExpressServer,
} = require("../../../../environment-helpers/use-api")
const { useApi } = require("../../../../environment-helpers/use-api")
const adminSeeder = require("../../../../helpers/admin-seeder")
const {

View File

@@ -18,6 +18,7 @@ import { exportWorkflow, pipe } from "@medusajs/workflows-sdk"
enum CreateCartActions {
setContext = "setContext",
attachLineItems = "attachLineItems",
attachToSalesChannel = "attachToSalesChannel",
findRegion = "findRegion",
findSalesChannel = "findSalesChannel",
createCart = "createCart",
@@ -58,10 +59,13 @@ const workflowSteps: TransactionStepsDefinition = {
noCompensation: true,
next: {
action: CreateCartActions.createCart,
next: {
action: CreateCartActions.attachLineItems,
noCompensation: true,
},
next: [
{
action: CreateCartActions.attachLineItems,
noCompensation: true,
},
{ action: CreateCartActions.attachToSalesChannel },
],
},
},
},
@@ -134,6 +138,10 @@ const handlers = new Map([
invoke: pipe(
{
invoke: [
{
from: CreateCartActions.findSalesChannel,
alias: CartHandlers.createCart.aliases.SalesChannel,
},
{
from: CreateCartActions.findRegion,
alias: CartHandlers.createCart.aliases.Region,
@@ -186,6 +194,38 @@ const handlers = new Map([
),
},
],
[
CreateCartActions.attachToSalesChannel,
{
invoke: pipe(
{
invoke: [
{
from: CreateCartActions.createCart,
alias: CartHandlers.attachCartToSalesChannel.aliases.Cart,
},
{
from: CreateCartActions.findSalesChannel,
alias: CartHandlers.attachCartToSalesChannel.aliases.SalesChannel,
},
],
},
CartHandlers.attachCartToSalesChannel
),
compensate: pipe(
{
invoke: [
{
from: CreateCartActions.findSalesChannel,
alias:
CartHandlers.detachCartFromSalesChannel.aliases.SalesChannel,
},
],
},
CartHandlers.detachCartFromSalesChannel
),
},
],
])
WorkflowManager.register(Workflows.CreateCart, workflowSteps, handlers)

View File

@@ -0,0 +1,42 @@
import { MedusaV2Flag } from "@medusajs/utils"
import { WorkflowArguments } from "@medusajs/workflows-sdk"
type HandlerInputData = {
cart: {
id: string
}
sales_channel: {
sales_channel_id: string
}
}
enum Aliases {
Cart = "cart",
SalesChannel = "sales_channel",
}
export async function attachCartToSalesChannel({
container,
data,
}: WorkflowArguments<HandlerInputData>): Promise<void> {
const featureFlagRouter = container.resolve("featureFlagRouter")
const remoteLink = container.resolve("remoteLink")
if (!featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) {
return
}
const cart = data[Aliases.Cart]
const salesChannel = data[Aliases.SalesChannel]
await remoteLink.create({
cartService: {
cart_id: cart.id,
},
salesChannelService: {
sales_channel_id: salesChannel.sales_channel_id,
},
})
}
attachCartToSalesChannel.aliases = Aliases

View File

@@ -47,15 +47,13 @@ export async function createCart({
const cartService = container.resolve("cartService")
const cartServiceTx = cartService.withTransaction(manager)
const cart = await cartServiceTx.create({
return await cartServiceTx.create({
...data[Aliases.SalesChannel],
...data[Aliases.Addresses],
...data[Aliases.Customer],
...data[Aliases.Region],
...data[Aliases.Context],
})
return cart
}
createCart.aliases = Aliases

View File

@@ -0,0 +1,42 @@
import { MedusaV2Flag } from "@medusajs/utils"
import { WorkflowArguments } from "@medusajs/workflows-sdk"
type HandlerInputData = {
cart: {
id: string
}
sales_channel: {
sales_channel_id: string
}
}
enum Aliases {
Cart = "cart",
SalesChannel = "sales_channel",
}
export async function detachCartFromSalesChannel({
container,
data,
}: WorkflowArguments<HandlerInputData>): Promise<void> {
const featureFlagRouter = container.resolve("featureFlagRouter")
const remoteLink = container.resolve("remoteLink")
if (!featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) {
return
}
const cart = data[Aliases.Cart]
const salesChannel = data[Aliases.SalesChannel]
await remoteLink.dismiss({
cartService: {
cart_id: cart.id,
},
salesChannelService: {
sales_channel_id: salesChannel.sales_channel_id,
},
})
}
detachCartFromSalesChannel.aliases = Aliases

View File

@@ -2,3 +2,5 @@ export * from "./attach-line-items-to-cart"
export * from "./create-cart"
export * from "./remove-cart"
export * from "./retrieve-cart"
export * from "./attach-cart-to-sales-channel"
export * from "./detach-cart-from-sales-channel"

View File

@@ -0,0 +1,65 @@
import { ModuleJoinerConfig } from "@medusajs/types"
import { LINKS } from "../links"
export const CartSalesChannel: ModuleJoinerConfig = {
serviceName: LINKS.CartSalesChannel,
isLink: true,
databaseConfig: {
tableName: "cart_sales_channel",
idPrefix: "cartsc",
},
alias: [
{
name: "cart_sales_channel",
},
{
name: "cart_sales_channels",
},
],
primaryKeys: ["id", "cart_id", "sales_channel_id"],
relationships: [
{
serviceName: "cartService",
isInternalService: true,
primaryKey: "id",
foreignKey: "cart_id",
alias: "cart",
},
{
serviceName: "salesChannelService",
isInternalService: true,
primaryKey: "id",
foreignKey: "sales_channel_id",
alias: "sales_channel",
},
],
extends: [
{
serviceName: "cartService",
fieldAlias: {
sales_channel: "sales_channel_link.sales_channel",
},
relationship: {
serviceName: LINKS.CartSalesChannel,
isInternalService: true,
primaryKey: "cart_id",
foreignKey: "id",
alias: "sales_channel_link",
},
},
{
serviceName: "salesChannelService",
fieldAlias: {
carts: "cart_link.cart",
},
relationship: {
serviceName: LINKS.CartSalesChannel,
isInternalService: true,
primaryKey: "sales_channel_id",
foreignKey: "id",
alias: "cart_link",
isList: true,
},
},
],
}

View File

@@ -3,3 +3,4 @@ export * from "./product-variant-inventory-item"
export * from "./product-variant-price-set"
export * from "./product-shipping-profile"
export * from "./product-sales-channel"
export * from "./cart-sales-channel"

View File

@@ -28,4 +28,10 @@ export const LINKS = {
"salesChannelService",
"sales_channel_id"
),
CartSalesChannel: composeLinkName(
"cartService",
"cart_id",
"salesChannelService",
"sales_channel_id"
),
}

View File

@@ -16,6 +16,7 @@ import { StorePostCartsCartShippingMethodReq } from "./add-shipping-method"
import { StorePostCartsCartPaymentSessionReq } from "./set-payment-session"
import { StorePostCartsCartLineItemsItemReq } from "./update-line-item"
import { StorePostCartsCartPaymentSessionUpdateReq } from "./update-payment-session"
import { MedusaV2Flag } from "@medusajs/utils"
const route = Router()
@@ -26,7 +27,11 @@ export default (app, container) => {
app.use("/carts", route)
if (featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)) {
defaultStoreCartRelations.push("sales_channel")
if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) {
defaultStoreCartRelations.push("sales_channels")
} else {
defaultStoreCartRelations.push("sales_channel")
}
}
// Inject plugin routes

View File

@@ -1,15 +1,16 @@
import { Modules } from "@medusajs/modules-sdk"
import { ModuleJoinerConfig } from "@medusajs/types"
import { Cart } from "../models"
export default {
serviceName: "cartService",
primaryKeys: ["id"],
linkableKeys: { cart_id: "Cart" },
alias: [
{
name: "cart",
},
],
alias: {
name: ["cart", "carts"],
args: { entity: Cart.name },
},
relationships: [
{
serviceName: Modules.PRODUCT,

View File

@@ -32,7 +32,7 @@ export class addTableProductShippingProfile1680857773273
DROP INDEX IF EXISTS "idx_product_shipping_profile_product_id";
DROP INDEX IF EXISTS "idx_product_shipping_profile_profile_id";
ALTER TABLE "product" ADD COLUMN IF NOT EXISTS "profile_id";
ALTER TABLE "product" ADD COLUMN IF NOT EXISTS "profile_id" CHARACTER VARYING;
UPDATE "product" SET "profile_id" = "product_shipping_profile"."profile_id"
FROM "product_shipping_profile"

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from "typeorm"
import { MedusaV2Flag } from "@medusajs/utils"
import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels"
export const featureFlag = [SalesChannelFeatureFlag.key, MedusaV2Flag.key]
export class CartSalesChannelsLink1698160215000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "cart_sales_channel"
(
"id" character varying NOT NULL,
"cart_id" character varying NOT NULL,
"sales_channel_id" character varying NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"deleted_at" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "cart_sales_channel_pk" PRIMARY KEY ("cart_id", "sales_channel_id"),
CONSTRAINT "cart_sales_channel_cart_id_unique" UNIQUE ("cart_id")
);
CREATE INDEX IF NOT EXISTS "IDX_id_cart_sales_channel" ON "cart_sales_channel" ("id");
insert into "cart_sales_channel" (id, cart_id, sales_channel_id)
(select 'cartsc_' || substr(md5(random()::text), 0, 27), id, sales_channel_id from "cart" WHERE sales_channel_id IS NOT NULL);
ALTER TABLE IF EXISTS "cart" DROP CONSTRAINT IF EXISTS "FK_a2bd3c26f42e754b9249ba78fd6";
ALTER TABLE IF EXISTS "store" DROP CONSTRAINT IF EXISTS "FK_61b0f48cccbb5f41c750bac7286";
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE "cart" SET "sales_channel_id" = "cart_sales_channel"."sales_channel_id"
FROM "cart_sales_channel"
WHERE "cart"."id" = "cart_sales_channel"."cart_id";
DROP TABLE IF EXISTS "cart_sales_channel";
ALTER TABLE IF EXISTS "cart" ADD CONSTRAINT "FK_a2bd3c26f42e754b9249ba78fd6" FOREIGN KEY ("sales_channel_id") REFERENCES "sales_channel"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE IF EXISTS "store" ADD CONSTRAINT "FK_61b0f48cccbb5f41c750bac7286" FOREIGN KEY ("default_sales_channel_id") REFERENCES "sales_channel"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
`)
}
}

View File

@@ -0,0 +1,29 @@
import { BeforeInsert, Column, Index, PrimaryColumn } from "typeorm"
import { MedusaV2Flag, SalesChannelFeatureFlag } from "@medusajs/utils"
import { generateEntityId } from "../utils"
import { SoftDeletableEntity } from "../interfaces"
import { FeatureFlagEntity } from "../utils/feature-flag-decorators"
@FeatureFlagEntity([MedusaV2Flag.key, SalesChannelFeatureFlag.key])
export class CartSalesChannel extends SoftDeletableEntity {
@Column()
id: string
@Index("cart_sales_channel_cart_id_unique", {
unique: true,
})
@PrimaryColumn()
cart_id: string
@PrimaryColumn()
sales_channel_id: string
/**
* @apiIgnore
*/
@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "cartsc")
}
}

View File

@@ -233,6 +233,7 @@
import {
AfterLoad,
BeforeInsert,
BeforeUpdate,
Column,
Entity,
Index,
@@ -241,13 +242,10 @@ import {
ManyToMany,
ManyToOne,
OneToMany,
OneToOne
OneToOne,
} from "typeorm"
import { MedusaV2Flag, SalesChannelFeatureFlag } from "@medusajs/utils"
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import {
FeatureFlagColumn,
FeatureFlagDecorators,
} from "../utils/feature-flag-decorators"
import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity"
import { generateEntityId } from "../utils/generate-entity-id"
@@ -261,6 +259,10 @@ import { PaymentSession } from "./payment-session"
import { Region } from "./region"
import { SalesChannel } from "./sales-channel"
import { ShippingMethod } from "./shipping-method"
import {
FeatureFlagColumn,
FeatureFlagDecorators,
} from "../utils/feature-flag-decorators"
export enum CartType {
DEFAULT = "default",
@@ -387,15 +389,37 @@ export class Cart extends SoftDeletableEntity {
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: Record<string, unknown>
@FeatureFlagColumn("sales_channels", { type: "varchar", nullable: true })
@FeatureFlagColumn(SalesChannelFeatureFlag.key, {
type: "varchar",
nullable: true,
})
sales_channel_id: string | null
@FeatureFlagDecorators("sales_channels", [
@FeatureFlagDecorators(SalesChannelFeatureFlag.key, [
ManyToOne(() => SalesChannel),
JoinColumn({ name: "sales_channel_id" }),
])
sales_channel: SalesChannel
@FeatureFlagDecorators(
[MedusaV2Flag.key, SalesChannelFeatureFlag.key],
[
ManyToMany(() => SalesChannel, { cascade: ["remove", "soft-remove"] }),
JoinTable({
name: "cart_sales_channel",
joinColumn: {
name: "cart_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "sales_channel_id",
referencedColumnName: "id",
},
}),
]
)
sales_channels?: SalesChannel[]
shipping_total?: number
discount_total?: number
raw_discount_total?: number
@@ -412,18 +436,41 @@ export class Cart extends SoftDeletableEntity {
/**
* @apiIgnore
*/
@AfterLoad()
private afterLoad(): void {
if (this.payment_sessions) {
this.payment_session = this.payment_sessions.find((p) => p.is_selected)!
@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "cart")
if (this.sales_channel_id || this.sales_channel) {
this.sales_channels = [
{ id: this.sales_channel_id || this.sales_channel?.id },
] as SalesChannel[]
}
}
/**
* @apiIgnore
*/
@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "cart")
@BeforeUpdate()
private beforeUpdate(): void {
if (this.sales_channel_id || this.sales_channel) {
this.sales_channels = [
{ id: this.sales_channel_id || this.sales_channel?.id },
] as SalesChannel[]
}
}
/**
* @apiIgnore
*/
@AfterLoad()
private afterLoad(): void {
if (this.payment_sessions) {
this.payment_session = this.payment_sessions.find((p) => p.is_selected)!
}
if (this.sales_channels) {
this.sales_channel = this.sales_channels?.[0]
this.sales_channel_id = this.sales_channel?.id
delete this.sales_channels
}
}
}

View File

@@ -1,10 +1,15 @@
import { BeforeInsert, Column, JoinTable, ManyToMany, OneToMany } from "typeorm"
import { FeatureFlagEntity } from "../utils/feature-flag-decorators"
import { MedusaV2Flag } from "@medusajs/utils"
import {
FeatureFlagDecorators,
FeatureFlagEntity,
} from "../utils/feature-flag-decorators"
import { SoftDeletableEntity } from "../interfaces"
import { DbAwareColumn, generateEntityId } from "../utils"
import { SalesChannelLocation } from "./sales-channel-location"
import { Product } from "./product"
import { Cart } from "./cart"
@FeatureFlagEntity("sales_channels")
export class SalesChannel extends SoftDeletableEntity {
@@ -34,6 +39,22 @@ export class SalesChannel extends SoftDeletableEntity {
})
products: Product[]
@FeatureFlagDecorators(MedusaV2Flag.key, [
ManyToMany(() => Cart),
JoinTable({
name: "cart_sales_channel",
joinColumn: {
name: "sales_channel_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "cart_id",
referencedColumnName: "id",
},
}),
])
carts: Cart[]
@OneToMany(
() => SalesChannelLocation,
(scLocation) => scLocation.sales_channel,

View File

@@ -2668,6 +2668,7 @@ describe("CartService", () => {
.register("newTotalsService", asClass(NewTotalsService))
.register("cartService", asClass(CartService))
.register("remoteQuery", asValue(null))
.register("remoteLink", asValue(null))
.register("pricingModuleService", asValue(undefined))
.register("pricingService", asClass(PricingService))

View File

@@ -68,6 +68,8 @@ import { PaymentSessionRepository } from "../repositories/payment-session"
import { ShippingMethodRepository } from "../repositories/shipping-method"
import { PaymentSessionInput } from "../types/payment"
import { validateEmail } from "../utils/is-email"
import { RemoteQueryFunction } from "@medusajs/types"
import { RemoteLink } from "@medusajs/modules-sdk"
type InjectedDependencies = {
manager: EntityManager
@@ -98,6 +100,8 @@ type InjectedDependencies = {
priceSelectionStrategy: IPriceSelectionStrategy
productVariantInventoryService: ProductVariantInventoryService
pricingService: PricingService
remoteQuery: RemoteQueryFunction
remoteLink: RemoteLink
}
type TotalsConfig = {
@@ -139,6 +143,8 @@ class CartService extends TransactionBaseService {
protected readonly priceSelectionStrategy_: IPriceSelectionStrategy
protected readonly lineItemAdjustmentService_: LineItemAdjustmentService
protected readonly featureFlagRouter_: FlagRouter
protected remoteQuery_: RemoteQueryFunction
protected remoteLink_: RemoteLink
// eslint-disable-next-line max-len
protected readonly productVariantInventoryService_: ProductVariantInventoryService
protected readonly pricingService_: PricingService
@@ -169,6 +175,8 @@ class CartService extends TransactionBaseService {
salesChannelService,
featureFlagRouter,
storeService,
remoteQuery,
remoteLink,
productVariantInventoryService,
pricingService,
}: InjectedDependencies) {
@@ -202,6 +210,8 @@ class CartService extends TransactionBaseService {
this.storeService_ = storeService
this.productVariantInventoryService_ = productVariantInventoryService
this.pricingService_ = pricingService
this.remoteQuery_ = remoteQuery
this.remoteLink_ = remoteLink
}
/**
@@ -216,6 +226,7 @@ class CartService extends TransactionBaseService {
const cartRepo = this.activeManager_.withRepository(this.cartRepository_)
const query = buildQuery(selector, config)
return await cartRepo.find(query)
}
@@ -357,7 +368,10 @@ class CartService extends TransactionBaseService {
}
if (
this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key)
this.featureFlagRouter_.isFeatureEnabled(
SalesChannelFeatureFlag.key
) &&
!this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key)
) {
rawCart.sales_channel_id = (
await this.getValidatedSalesChannel(data.sales_channel_id)
@@ -365,8 +379,7 @@ class CartService extends TransactionBaseService {
}
if (data.customer_id || data.customer) {
const customer =
(data.customer ??
const customer = (data.customer ??
(data.customer_id &&
(await this.customerService_
.withTransaction(transactionManager)
@@ -476,6 +489,27 @@ class CartService extends TransactionBaseService {
const createdCart = cartRepo.create(rawCart)
const cart = await cartRepo.save(createdCart)
if (
this.featureFlagRouter_.isFeatureEnabled([
SalesChannelFeatureFlag.key,
MedusaV2Flag.key,
])
) {
const salesChannel = await this.getValidatedSalesChannel(
data.sales_channel_id
)
await this.remoteLink_.create({
cartService: {
cart_id: cart.id,
},
salesChannelService: {
sales_channel_id: salesChannel.id,
},
})
}
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.CREATED, {
@@ -491,9 +525,20 @@ class CartService extends TransactionBaseService {
): Promise<SalesChannel | never> {
let salesChannel: SalesChannel
if (isDefined(salesChannelId)) {
salesChannel = await this.salesChannelService_
.withTransaction(this.activeManager_)
.retrieve(salesChannelId)
if (this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key)) {
const query = {
sales_channel: {
__args: {
id: salesChannelId,
},
},
}
;[salesChannel] = await this.remoteQuery_(query)
} else {
salesChannel = await this.salesChannelService_
.withTransaction(this.activeManager_)
.retrieve(salesChannelId)
}
} else {
salesChannel = (
await this.storeService_.withTransaction(this.activeManager_).retrieve({
@@ -584,7 +629,7 @@ class CartService extends TransactionBaseService {
* Returns true if all products in the cart can be fulfilled with the current
* shipping methods.
* @param shippingMethods - the set of shipping methods to check from
* @param lineItem - the line item
* @param lineItemShippingProfiledId - the line item
* @return boolean representing whether shipping method is validated
*/
protected validateLineItemShipping_(
@@ -655,17 +700,22 @@ class CartService extends TransactionBaseService {
lineItem: LineItem,
config = { validateSalesChannels: true }
): Promise<void> {
const select: (keyof Cart)[] = ["id"]
const fields: (keyof Cart)[] = ["id"]
const relations: (keyof Cart)[] = ["shipping_methods"]
if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) {
select.push("sales_channel_id")
if (this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key)) {
relations.push("sales_channels")
} else {
fields.push("sales_channel_id")
}
}
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
let cart = await this.retrieve(cartId, {
select,
relations: ["shipping_methods"],
select: fields,
relations,
})
if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) {
@@ -797,17 +847,22 @@ class CartService extends TransactionBaseService {
): Promise<void> {
const items: LineItem[] = Array.isArray(lineItems) ? lineItems : [lineItems]
const select: (keyof Cart)[] = ["id", "customer_id", "region_id"]
const fields: (keyof Cart)[] = ["id", "customer_id", "region_id"]
const relations: (keyof Cart)[] = ["shipping_methods"]
if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) {
select.push("sales_channel_id")
if (this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key)) {
relations.push("sales_channels")
} else {
fields.push("sales_channel_id")
}
}
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
let cart = await this.retrieve(cartId, {
select,
relations: ["shipping_methods"],
select: fields,
relations,
})
if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) {
@@ -994,7 +1049,7 @@ class CartService extends TransactionBaseService {
* Updates a cart's existing line item.
* @param cartId - the id of the cart to update
* @param lineItemId - the id of the line item to update.
* @param lineItemUpdate - the line item to update. Must include an id field.
* @param update - the line item to update. Must include an id field.
* @return the result of the update operation
*/
async updateLineItem(
@@ -1223,8 +1278,39 @@ class CartService extends TransactionBaseService {
isDefined(data.sales_channel_id) &&
data.sales_channel_id != cart.sales_channel_id
) {
const salesChannel = await this.getValidatedSalesChannel(
data.sales_channel_id
)
await this.onSalesChannelChange(cart, data.sales_channel_id)
cart.sales_channel_id = data.sales_channel_id
/**
* TODO: remove this once update cart workflow is build
* since this will be handled in a handler by the workflow
*/
if (this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key)) {
if (cart.sales_channel_id) {
await this.remoteLink_.dismiss({
cartService: {
cart_id: cart.id,
},
salesChannelService: {
sales_channel_id: cart.sales_channel_id,
},
})
}
await this.remoteLink_.create({
cartService: {
cart_id: cart.id,
},
salesChannelService: {
sales_channel_id: salesChannel.id,
},
})
} else {
cart.sales_channel_id = salesChannel.id
}
}
if (isDefined(data.discounts) && data.discounts.length) {
@@ -2247,15 +2333,15 @@ class CartService extends TransactionBaseService {
const lineItemServiceTx =
this.lineItemService_.withTransaction(transactionManager)
let productShippinProfileMap = new Map<string, string>()
let productShippingProfileMap = new Map<string, string>()
if (this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key)) {
productShippinProfileMap =
productShippingProfileMap =
await this.shippingProfileService_.getMapProfileIdsByProductIds(
cart.items.map((item) => item.variant.product_id)
)
} else {
productShippinProfileMap = new Map<string, string>(
productShippingProfileMap = new Map<string, string>(
cart.items.map((item) => [
item.variant?.product?.id,
item.variant?.product?.profile_id,
@@ -2268,7 +2354,7 @@ class CartService extends TransactionBaseService {
return lineItemServiceTx.update(item.id, {
has_shipping: this.validateLineItemShipping_(
methods,
productShippinProfileMap.get(item.variant?.product_id)!
productShippingProfileMap.get(item.variant?.product_id)!
),
})
})

View File

@@ -76,7 +76,7 @@ class SalesChannelLocationService extends TransactionBaseService {
.retrieve(salesChannelId)
if (this.stockLocationService_) {
// trhows error if not found
// throws error if not found
await this.stockLocationService_.retrieve(locationId, undefined, {
transactionManager: this.activeManager_,
})
@@ -124,7 +124,7 @@ class SalesChannelLocationService extends TransactionBaseService {
/**
* Lists the sales channels associated with a stock location.
* @param {string} salesChannelId - The ID of the stock location.
* @param {string} locationId - The ID of the stock location.
* @returns {Promise<string[]>} A promise that resolves with an array of sales channel IDs.
*/
async listSalesChannelIds(locationId: string): Promise<string[]> {

View File

@@ -34,7 +34,7 @@ export function FeatureFlagColumn(
}
export function FeatureFlagDecorators(
featureFlag: string,
featureFlag: string | string[],
decorators: PropertyDecorator[]
): PropertyDecorator {
return function (target, propertyName) {
@@ -68,7 +68,7 @@ export function FeatureFlagClassDecorators(
}
export function FeatureFlagEntity(
featureFlag: string,
featureFlag: string | string[],
name?: string,
options?: EntityOptions
): ClassDecorator {

View File

@@ -17,6 +17,18 @@ export function remoteQueryFetchData(container: MedusaContainer) {
...RemoteQuery.getAllFieldsAndRelations(expand),
}
const expandRelations = Object.keys(expand.expands ?? {})
// filter out links from relations because TypeORM will throw if the relation doesn't exist
options.relations = options.relations.filter(
(relation) => !expandRelations.some((ex) => relation.startsWith(ex))
)
options.select = options.relations.filter(
(field) => !expandRelations.some((ex) => field.startsWith(ex))
)
if (ids) {
filters[keyField] = ids
}

View File

@@ -23,16 +23,16 @@ export type RemoteFetchDataCallback = (
path?: string
}>
type InternalImplodeMapping = {
location: string[]
property: string
path: string[]
isList?: boolean
}
export class RemoteJoiner {
private serviceConfigCache: Map<string, JoinerServiceConfig> = new Map()
private implodeMapping: {
location: string[]
property: string
path: string[]
isList?: boolean
}[] = []
private static filterFields(
data: any,
fields: string[],
@@ -355,7 +355,8 @@ export class RemoteJoiner {
private handleFieldAliases(
items: any[],
parsedExpands: Map<string, RemoteExpandProperty>
parsedExpands: Map<string, RemoteExpandProperty>,
implodeMapping: InternalImplodeMapping[]
) {
const getChildren = (item: any, prop: string) => {
if (Array.isArray(item)) {
@@ -373,7 +374,7 @@ export class RemoteJoiner {
}
const cleanup: [any, string][] = []
for (const alias of this.implodeMapping) {
for (const alias of implodeMapping) {
const propPath = alias.path
let itemsLocation = items
@@ -432,7 +433,8 @@ export class RemoteJoiner {
private async handleExpands(
items: any[],
parsedExpands: Map<string, RemoteExpandProperty>
parsedExpands: Map<string, RemoteExpandProperty>,
implodeMapping: InternalImplodeMapping[] = []
): Promise<void> {
if (!parsedExpands) {
return
@@ -458,7 +460,7 @@ export class RemoteJoiner {
}
}
this.handleFieldAliases(items, parsedExpands)
this.handleFieldAliases(items, parsedExpands, implodeMapping)
}
private async expandProperty(
@@ -567,13 +569,15 @@ export class RemoteJoiner {
initialService: RemoteExpandProperty,
query: RemoteJoinerQuery,
serviceConfig: JoinerServiceConfig,
expands: RemoteJoinerQuery["expands"]
expands: RemoteJoinerQuery["expands"],
implodeMapping: InternalImplodeMapping[]
): Map<string, RemoteExpandProperty> {
const parsedExpands = this.parseProperties(
initialService,
query,
serviceConfig,
expands
expands,
implodeMapping
)
const groupedExpands = this.groupExpands(parsedExpands)
@@ -585,7 +589,8 @@ export class RemoteJoiner {
initialService: RemoteExpandProperty,
query: RemoteJoinerQuery,
serviceConfig: JoinerServiceConfig,
expands: RemoteJoinerQuery["expands"]
expands: RemoteJoinerQuery["expands"],
implodeMapping: InternalImplodeMapping[]
): Map<string, RemoteExpandProperty> {
const parsedExpands = new Map<string, any>()
parsedExpands.set(BASE_PATH, initialService)
@@ -612,7 +617,7 @@ export class RemoteJoiner {
)
)
this.implodeMapping.push({
implodeMapping.push({
location: currentPath,
property: prop,
path: fullPath,
@@ -799,6 +804,7 @@ export class RemoteJoiner {
(arg) => !serviceConfig.primaryKeys.includes(arg.name)
)
const implodeMapping: InternalImplodeMapping[] = []
const parsedExpands = this.parseExpands(
{
property: "",
@@ -809,7 +815,8 @@ export class RemoteJoiner {
},
queryObj,
serviceConfig,
queryObj.expands!
queryObj.expands!,
implodeMapping
)
const root = parsedExpands.get(BASE_PATH)!
@@ -823,7 +830,11 @@ export class RemoteJoiner {
const data = response.path ? response.data[response.path!] : response.data
await this.handleExpands(Array.isArray(data) ? data : [data], parsedExpands)
await this.handleExpands(
Array.isArray(data) ? data : [data],
parsedExpands,
implodeMapping
)
return response.data
}

View File

@@ -1,3 +1,3 @@
export function isObject(obj: unknown): obj is object {
return typeof obj === "object" && !!obj
export function isObject(obj: any): obj is object {
return obj != null && obj?.constructor?.name === "Object"
}

View File

@@ -19,22 +19,24 @@ export class FlagRouter implements FeatureFlagTypes.IFlagRouter {
* @param flag - The flag to check
* @return {boolean} - Whether the flag is enabled or not
*/
public isFeatureEnabled(flag: string | Record<string, string>): boolean {
if (isString(flag)) {
return !!this.flags[flag]
}
public isFeatureEnabled(
flag: string | string[] | Record<string, string>
): boolean {
if (isObject(flag)) {
const [nestedFlag, value] = Object.entries(flag)[0]
if (typeof this.flags[nestedFlag] === "boolean") {
return this.flags[nestedFlag] as boolean
}
return !!this.flags[nestedFlag]?.[value]
}
throw Error("Flag must be a string or an object")
const flags = (Array.isArray(flag) ? flag : [flag]) as string[]
return flags.every((flag_) => {
if (!isString(flag_)) {
throw Error("Flag must be a string an array of string or an object")
}
return !!this.flags[flag_]
})
}
/**