fix(medusa): Shipping profile CRUD (#3154)

**What**
- Fixes wrong payload class for `POST /admin/shipping-profiles`
- Fixes wrong payload class for `POST /admin/shipping-profiles/:id`
- Fixes an issue where updating a shipping profile with products and/or shipping options would fail.
- Fixes an issue where passing `profile_id` to `ShippingOptionService.update()` would not update the shipping profile of the option.

**Testing** 
- Adds new `simpleshippingProfileFactory`
- Adds new integration test suite for shipping profiles operations.

Resolves CORE-1065
This commit is contained in:
Kasper Fabricius Kristensen
2023-02-06 11:57:12 -05:00
committed by GitHub
parent 4d6e63d68f
commit d0adaf57ed
18 changed files with 664 additions and 110 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
fix(medusa): Fixes payloads associated with shipping profile requests, as well as fixes to the shippingProfileService. Also adds test suite for shipping profiles.

View File

@@ -0,0 +1,346 @@
const path = require("path")
const setupServer = require("../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db")
const {
simpleProductFactory,
simpleShippingOptionFactory,
simpleShippingProfileFactory,
} = require("../../factories")
const adminSeeder = require("../../helpers/admin-seeder")
const adminReqConfig = {
headers: {
Authorization: "Bearer test_token",
},
}
jest.setTimeout(30000)
describe("/admin/shipping-profiles", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false })
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
describe("GET /admin/shipping-profiles", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("lists shipping profiles", async () => {
const api = useApi()
const {
data: { shipping_profiles },
status,
} = await api.get("/admin/shipping-profiles", adminReqConfig)
expect(status).toEqual(200)
// Should contain default and gift_card profiles
expect(shipping_profiles.length).toEqual(2)
})
it("gets a shipping profile by id", async () => {
const api = useApi()
const profile = await simpleShippingProfileFactory(dbConnection)
const {
data: { shipping_profile },
status,
} = await api.get(
`/admin/shipping-profiles/${profile.id}`,
adminReqConfig
)
expect(status).toEqual(200)
expect(shipping_profile).toEqual(
expect.objectContaining({
...profile,
updated_at: expect.any(String),
created_at: expect.any(String),
})
)
})
})
describe("POST /admin/shipping-profiles", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("creates a custom shipping profile", async () => {
const api = useApi()
const payload = {
name: "test-profile-2023",
type: "custom",
}
const {
data: { shipping_profile },
status,
} = await api.post("/admin/shipping-profiles", payload, adminReqConfig)
expect(status).toEqual(200)
expect(shipping_profile).toEqual(
expect.objectContaining({
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
...payload,
})
)
})
it("creates a default shipping profile", async () => {
const api = useApi()
const payload = {
name: "test-profile-2023",
type: "default",
}
const {
data: { shipping_profile },
status,
} = await api.post("/admin/shipping-profiles", payload, adminReqConfig)
expect(status).toEqual(200)
expect(shipping_profile).toEqual(
expect.objectContaining({
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
...payload,
})
)
})
it("creates a gift_card shipping profile", async () => {
const api = useApi()
const payload = {
name: "test-profile-2023",
type: "gift_card",
}
const {
data: { shipping_profile },
status,
} = await api.post("/admin/shipping-profiles", payload, adminReqConfig)
expect(status).toEqual(200)
expect(shipping_profile).toEqual(
expect.objectContaining({
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
...payload,
})
)
})
it("creates a shipping profile with metadata", async () => {
const api = useApi()
const payload = {
name: "test-profile-2023",
type: "default",
metadata: {
custom_key: "custom_value",
},
}
const {
data: { shipping_profile },
status,
} = await api.post("/admin/shipping-profiles", payload, adminReqConfig)
expect(status).toEqual(200)
expect(shipping_profile).toEqual(
expect.objectContaining({
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
...payload,
})
)
})
it("fails to create a shipping profile with invalid type", async () => {
const api = useApi()
expect.assertions(2)
const payload = {
name: "test-profile-2023",
type: "invalid",
}
await api
.post("/admin/shipping-profiles", payload, adminReqConfig)
.catch((err) => {
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
"type must be one of 'default', 'custom', 'gift_card'"
)
})
})
it("updates a shipping profile", async () => {
const api = useApi()
const testProducts = await Promise.all(
[...Array(5).keys()].map(async () => {
return await simpleProductFactory(dbConnection)
})
)
const testShippingOptions = await Promise.all(
[...Array(5).keys()].map(async () => {
return await simpleShippingOptionFactory(dbConnection)
})
)
const payload = {
name: "test-profile-2023",
type: "custom",
metadata: {
my_key: "my_value",
},
}
const {
data: { shipping_profile: created },
} = await api.post("/admin/shipping-profiles", payload, adminReqConfig)
const updatePayload = {
name: "test-profile-2023-updated",
products: testProducts.map((p) => p.id),
shipping_options: testShippingOptions.map((o) => o.id),
metadata: {
my_key: "",
my_new_key: "my_new_value",
},
}
const {
data: { shipping_profile },
status,
} = await api.post(
`/admin/shipping-profiles/${created.id}`,
updatePayload,
adminReqConfig
)
expect(status).toEqual(200)
expect(shipping_profile).toEqual(
expect.objectContaining({
name: "test-profile-2023-updated",
created_at: expect.any(String),
updated_at: expect.any(String),
metadata: {
my_new_key: "my_new_value",
},
deleted_at: null,
type: "custom",
})
)
const {
data: { products },
} = await api.get(`/admin/products`, adminReqConfig)
expect(products.length).toEqual(5)
expect(products).toEqual(
expect.arrayContaining(
testProducts.map((p) => {
return expect.objectContaining({
id: p.id,
profile_id: shipping_profile.id,
})
})
)
)
const {
data: { shipping_options },
} = await api.get(`/admin/shipping-options`, adminReqConfig)
const numberOfShippingOptionsWithProfile = shipping_options.filter(
(so) => so.profile_id === shipping_profile.id
).length
expect(numberOfShippingOptionsWithProfile).toEqual(5)
expect(shipping_options).toEqual(
expect.arrayContaining(
testShippingOptions.map((o) => {
return expect.objectContaining({
id: o.id,
profile_id: shipping_profile.id,
})
})
)
)
})
})
describe("DELETE /admin/shipping-profiles", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("deletes a shipping profile", async () => {
expect.assertions(2)
const api = useApi()
const profile = await simpleShippingProfileFactory(dbConnection)
const { status } = await api.delete(
`/admin/shipping-profiles/${profile.id}`,
adminReqConfig
)
expect(status).toEqual(200)
await api
.get(`/admin/shipping-profiles/${profile.id}`, adminReqConfig)
.catch((err) => {
expect(err.response.status).toEqual(404)
})
})
})
})

View File

@@ -1,25 +1,25 @@
export * from "./simple-gift-card-factory"
export * from "./simple-payment-factory"
export * from "./simple-batch-job-factory"
export * from "./simple-discount-factory"
export * from "./simple-order-factory"
export * from "./simple-cart-factory"
export * from "./simple-region-factory"
export * from "./simple-custom-shipping-option-factory"
export * from "./simple-customer-factory"
export * from "./simple-discount-factory"
export * from "./simple-gift-card-factory"
export * from "./simple-line-item-factory"
export * from "./simple-order-edit-factory"
export * from "./simple-order-factory"
export * from "./simple-order-item-change-factory"
export * from "./simple-payment-collection-factory"
export * from "./simple-payment-factory"
export * from "./simple-price-list-factory"
export * from "./simple-product-category-factory"
export * from "./simple-product-factory"
export * from "./simple-product-variant-factory"
export * from "./simple-product-tax-rate-factory"
export * from "./simple-product-type-tax-rate-factory"
export * from "./simple-product-variant-factory"
export * from "./simple-region-factory"
export * from "./simple-sales-channel-factory"
export * from "./simple-shipping-method-factory"
export * from "./simple-shipping-option-factory"
export * from "./simple-shipping-profile-factory"
export * from "./simple-shipping-tax-rate-factory"
export * from "./simple-tax-rate-factory"
export * from "./simple-shipping-option-factory"
export * from "./simple-shipping-method-factory"
export * from "./simple-product-type-tax-rate-factory"
export * from "./simple-price-list-factory"
export * from "./simple-batch-job-factory"
export * from "./simple-sales-channel-factory"
export * from "./simple-custom-shipping-option-factory"
export * from "./simple-payment-collection-factory"
export * from "./simple-order-edit-factory"
export * from "./simple-order-item-change-factory"
export * from "./simple-customer-factory"
export * from "./simple-product-category-factory"

View File

@@ -7,28 +7,29 @@ import {
} from "@medusajs/medusa"
import faker from "faker"
import { Connection } from "typeorm"
import { simpleRegionFactory } from "./simple-region-factory"
export type ShippingOptionFactoryData = {
id?: string
name?: string
region_id: string
region_id?: string
is_return?: boolean
is_giftcard?: boolean
price?: number
price_type?: ShippingOptionPriceType
includes_tax?: boolean
data?: object
requirements: ShippingOptionRequirementData[]
requirements?: ShippingOptionRequirementData[]
}
type ShippingOptionRequirementData = {
type: 'min_subtotal' | 'max_subtotal'
type: "min_subtotal" | "max_subtotal"
amount: number
}
export const simpleShippingOptionFactory = async (
connection: Connection,
data: ShippingOptionFactoryData,
data: ShippingOptionFactoryData = {},
seed?: number
): Promise<ShippingOption> => {
if (typeof seed !== "undefined") {
@@ -44,11 +45,18 @@ export const simpleShippingOptionFactory = async (
type: ShippingProfileType.GIFT_CARD,
})
let region_id = data.region_id
if (!region_id) {
const { id } = await simpleRegionFactory(connection)
region_id = id
}
const shippingOptionData = {
id: data.id ?? `simple-so-${Math.random() * 1000}`,
name: data.name || "Test Method",
is_return: data.is_return ?? false,
region_id: data.region_id,
region_id: region_id,
provider_id: "test-ful",
profile_id: data.is_giftcard ? gcProfile.id : defaultProfile.id,
price_type: data.price_type ?? ShippingOptionPriceType.FLAT_RATE,

View File

@@ -0,0 +1,35 @@
import { ShippingProfile, ShippingProfileType } from "@medusajs/medusa"
import faker from "faker"
import { Connection } from "typeorm"
export type ShippingProfileFactoryData = {
id?: string
name?: string
type?: ShippingProfileType
metadata?: Record<string, unknown>
}
export const simpleShippingProfileFactory = async (
connection: Connection,
data: ShippingOptionFactoryData = {},
seed?: number
): Promise<ShippingProfile> => {
if (typeof seed !== "undefined") {
faker.seed(seed)
}
const manager = connection.manager
const shippingProfileData = {
id: data.id ?? `simple-sp-${Math.random() * 1000}`,
name: data.name || `sp-${Math.random() * 1000}`,
type: data.type || ShippingProfileType.DEFAULT,
metadata: data.metadata,
products: [],
shipping_options: [],
}
const created = manager.create(ShippingProfile, shippingProfileData)
return await manager.save(created)
}

View File

@@ -10,6 +10,7 @@ describe("POST /admin/shipping-profiles", () => {
subject = await request("POST", "/admin/shipping-profiles", {
payload: {
name: "Test Profile",
type: "default",
},
adminSession: {
jwt: {
@@ -27,6 +28,7 @@ describe("POST /admin/shipping-profiles", () => {
expect(ShippingProfileServiceMock.create).toHaveBeenCalledTimes(1)
expect(ShippingProfileServiceMock.create).toHaveBeenCalledWith({
name: "Test Profile",
type: "default",
})
})
})

View File

@@ -1,7 +1,8 @@
import { IsString } from "class-validator"
import { IsEnum, IsObject, IsOptional, IsString } from "class-validator"
import { EntityManager } from "typeorm"
import { ShippingProfileType } from "../../../../models"
import { ShippingProfileService } from "../../../../services"
import { validator } from "../../../../utils/validator"
import { EntityManager } from "typeorm"
/**
* @oas [post] /shipping-profiles
@@ -84,12 +85,26 @@ export default async (req, res) => {
* type: object
* required:
* - name
* - type
* properties:
* name:
* description: "The name of the Shipping Profile"
* description: The name of the Shipping Profile
* type: string
* type:
* description: The type of the Shipping Profile
* type: string
* enum: [default, gift_card, custom]
*/
export class AdminPostShippingProfilesReq {
@IsString()
name: string
@IsEnum(ShippingProfileType, {
message: "type must be one of 'default', 'custom', 'gift_card'",
})
type: ShippingProfileType
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>
}

View File

@@ -1,8 +1,15 @@
import { IsOptional, IsString } from "class-validator"
import {
IsArray,
IsEnum,
IsObject,
IsOptional,
IsString,
} from "class-validator"
import { EntityManager } from "typeorm"
import { ShippingProfileType } from "../../../../models"
import { ShippingProfileService } from "../../../../services"
import { validator } from "../../../../utils/validator"
import { EntityManager } from "typeorm"
/**
* @oas [post] /shipping-profiles/{id}
@@ -93,11 +100,44 @@ export default async (req, res) => {
* type: object
* properties:
* name:
* description: "The name of the Shipping Profile"
* description: The name of the Shipping Profile
* type: string
* metadata:
* description: An optional set of key-value pairs with additional information.
* type: object
* type:
* description: The type of the Shipping Profile
* type: string
* enum: [default, gift_card, custom]
* products:
* description: An optional array of product ids to associate with the Shipping Profile
* type: array
* shipping_options:
* description: An optional array of shipping option ids to associate with the Shipping Profile
* type: array
*/
export class AdminPostShippingProfilesProfileReq {
@IsString()
@IsOptional()
name?: string
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>
@IsOptional()
@IsEnum(ShippingProfileType, {
message: "type must be one of 'default', 'custom', 'gift_card'",
})
type?: ShippingProfileType
@IsOptional()
@IsArray()
@IsString({ each: true })
products?: string[]
@IsOptional()
@IsArray()
@IsString({ each: true })
shipping_options?: string[]
}

View File

@@ -6,7 +6,7 @@ import {
In,
Repository,
} from "typeorm"
import { PriceList, Product, SalesChannel, ProductCategory } from "../models"
import { PriceList, Product, ProductCategory, SalesChannel } from "../models"
import {
ExtendedFindConfig,
Selector,
@@ -540,6 +540,25 @@ export class ProductRepository extends Repository<Product> {
)
}
/**
* Upserts shipping profile for products
* @param productIds IDs of products to update
* @param shippingProfileId ID of shipping profile to assign to products
* @returns updated products
*/
public async upsertShippingProfile(
productIds: string[],
shippingProfileId: string
): Promise<Product[]> {
await this.createQueryBuilder()
.update(Product)
.set({ profile_id: shippingProfileId })
.where({ id: In(productIds) })
.execute()
return await this.findByIds(productIds)
}
private _cleanOptions(
options: FindWithoutRelationsOptions
): WithRequiredProperty<FindWithoutRelationsOptions, "where"> {

View File

@@ -1,5 +1,18 @@
import { EntityRepository, Repository } from "typeorm"
import { EntityRepository, In, Repository } from "typeorm"
import { ShippingOption } from "../models/shipping-option"
@EntityRepository(ShippingOption)
export class ShippingOptionRepository extends Repository<ShippingOption> {}
export class ShippingOptionRepository extends Repository<ShippingOption> {
public async upsertShippingProfile(
shippingOptionIds: string[],
shippingProfileId: string
): Promise<ShippingOption[]> {
await this.createQueryBuilder()
.update(ShippingOption)
.set({ profile_id: shippingProfileId })
.where({ id: In(shippingOptionIds) })
.execute()
return this.findByIds(shippingOptionIds)
}
}

View File

@@ -124,6 +124,7 @@ export const ProductServiceMock = {
deleteOption: jest
.fn()
.mockReturnValue(Promise.resolve(products.productWithOptions)),
updateshippingProfiles: jest.fn().mockReturnValue(Promise.resolve()),
retrieveVariants: jest.fn().mockImplementation((productId) => {
if (productId === IdMap.getId("product1")) {
return Promise.resolve([

View File

@@ -89,6 +89,7 @@ export const ShippingOptionServiceMock = {
return Promise.resolve(undefined)
}),
update: jest.fn().mockReturnValue(Promise.resolve()),
updateShippingprofile: jest.fn().mockReturnValue(Promise.resolve()),
listAndCount: jest.fn().mockImplementation((data) => {
if (data.region_id === IdMap.getId("region-france")) {
return Promise.resolve([[shippingOptions.franceShipping], 1])

View File

@@ -19,10 +19,10 @@ export const ShippingProfileServiceMock = {
withTransaction: function () {
return this
},
update: jest.fn().mockImplementation(data => {
update: jest.fn().mockImplementation((data) => {
return Promise.resolve()
}),
create: jest.fn().mockImplementation(data => {
create: jest.fn().mockImplementation((data) => {
return Promise.resolve(data)
}),
createDefault: jest.fn().mockImplementation(() => {
@@ -31,7 +31,7 @@ export const ShippingProfileServiceMock = {
createGiftCardDefault: jest.fn().mockImplementation(() => {
return Promise.resolve()
}),
retrieve: jest.fn().mockImplementation(data => {
retrieve: jest.fn().mockImplementation((data) => {
if (data === IdMap.getId("default")) {
return Promise.resolve(profiles.default)
}
@@ -40,13 +40,13 @@ export const ShippingProfileServiceMock = {
}
return Promise.resolve(profiles.default)
}),
retrieveGiftCardDefault: jest.fn().mockImplementation(data => {
retrieveGiftCardDefault: jest.fn().mockImplementation((data) => {
return Promise.resolve({ id: IdMap.getId("giftCardProfile") })
}),
retrieveDefault: jest.fn().mockImplementation(data => {
retrieveDefault: jest.fn().mockImplementation((data) => {
return Promise.resolve({ id: IdMap.getId("default_shipping_profile") })
}),
list: jest.fn().mockImplementation(selector => {
list: jest.fn().mockImplementation((selector) => {
if (!selector) {
return Promise.resolve([])
}
@@ -135,7 +135,7 @@ export const ShippingProfileServiceMock = {
])
}
}),
decorate: jest.fn().mockImplementation(d => Promise.resolve(d)),
decorate: jest.fn().mockImplementation((d) => Promise.resolve(d)),
addShippingOption: jest.fn().mockImplementation(() => Promise.resolve()),
removeShippingOption: jest.fn().mockImplementation(() => Promise.resolve()),
addProduct: jest.fn().mockImplementation(() => Promise.resolve()),

View File

@@ -35,14 +35,14 @@ describe("ShippingProfileService", () => {
})
const productService = {
update: jest.fn(),
updateShippingProfile: jest.fn(),
withTransaction: function () {
return this
},
}
const shippingOptionService = {
update: jest.fn(),
updateShippingProfile: jest.fn(),
withTransaction: function () {
return this
},
@@ -75,10 +75,11 @@ describe("ShippingProfileService", () => {
products: [IdMap.getId("product1")],
})
expect(productService.update).toBeCalledTimes(1)
expect(productService.update).toBeCalledWith(IdMap.getId("product1"), {
profile_id: id,
})
expect(productService.updateShippingProfile).toBeCalledTimes(1)
expect(productService.updateShippingProfile).toBeCalledWith(
[IdMap.getId("product1")],
id
)
})
it("calls updateOne with shipping options", async () => {
@@ -88,10 +89,10 @@ describe("ShippingProfileService", () => {
shipping_options: [IdMap.getId("validId")],
})
expect(shippingOptionService.update).toBeCalledTimes(1)
expect(shippingOptionService.update).toBeCalledWith(
IdMap.getId("validId"),
{ profile_id: id }
expect(shippingOptionService.updateShippingProfile).toBeCalledTimes(1)
expect(shippingOptionService.updateShippingProfile).toBeCalledWith(
[IdMap.getId("validId")],
id
)
})
})
@@ -125,7 +126,7 @@ describe("ShippingProfileService", () => {
const profRepo = MockRepository({ findOne: () => Promise.resolve({}) })
const productService = {
update: jest.fn(),
updateShippingProfile: jest.fn(),
withTransaction: function () {
return this
},
@@ -142,15 +143,15 @@ describe("ShippingProfileService", () => {
})
it("add product to profile successfully", async () => {
await profileService.addProduct(
IdMap.getId("validId"),
IdMap.getId("product2")
)
await profileService.addProduct(IdMap.getId("validId"), [
IdMap.getId("product2"),
])
expect(productService.update).toBeCalledTimes(1)
expect(productService.update).toBeCalledWith(IdMap.getId("product2"), {
profile_id: IdMap.getId("validId"),
})
expect(productService.updateShippingProfile).toBeCalledTimes(1)
expect(productService.updateShippingProfile).toBeCalledWith(
[IdMap.getId("product2")],
IdMap.getId("validId")
)
})
})
@@ -300,7 +301,7 @@ describe("ShippingProfileService", () => {
const profRepo = MockRepository({ findOne: () => Promise.resolve({}) })
const shippingOptionService = {
update: jest.fn(),
updateShippingProfile: jest.fn(),
withTransaction: function () {
return this
},
@@ -317,15 +318,14 @@ describe("ShippingProfileService", () => {
})
it("add shipping option to profile successfully", async () => {
await profileService.addShippingOption(
IdMap.getId("validId"),
IdMap.getId("freeShipping")
)
expect(shippingOptionService.update).toBeCalledTimes(1)
expect(shippingOptionService.update).toBeCalledWith(
await profileService.addShippingOption(IdMap.getId("validId"), [
IdMap.getId("freeShipping"),
{ profile_id: IdMap.getId("validId") }
])
expect(shippingOptionService.updateShippingProfile).toBeCalledTimes(1)
expect(shippingOptionService.updateShippingProfile).toBeCalledWith(
[IdMap.getId("freeShipping")],
IdMap.getId("validId")
)
})
})

View File

@@ -1,18 +1,18 @@
import { FlagRouter } from "../utils/flag-router"
import { isDefined, MedusaError } from "medusa-core-utils"
import { EntityManager, In } from "typeorm"
import { EntityManager } from "typeorm"
import { ProductVariantService, SearchService } from "."
import { TransactionBaseService } from "../interfaces"
import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels"
import {
Product,
ProductCategory,
ProductOption,
ProductTag,
ProductType,
ProductVariant,
SalesChannel,
ProductCategory,
} from "../models"
import { ImageRepository } from "../repositories/image"
import {
@@ -33,7 +33,7 @@ import {
ProductSelector,
UpdateProductInput,
} from "../types/product"
import { buildQuery, setMetadata } from "../utils"
import { buildQuery, isString, setMetadata } from "../utils"
import EventBusService from "./event-bus"
type InjectedDependencies = {
@@ -446,7 +446,9 @@ class ProductService extends TransactionBaseService {
if (categories?.length) {
const categoryIds = categories.map((c) => c.id)
const categoryRecords = categoryIds.map((id) => ({ id } as ProductCategory))
const categoryRecords = categoryIds.map(
(id) => ({ id } as ProductCategory)
)
product.categories = categoryRecords
}
@@ -560,7 +562,9 @@ class ProductService extends TransactionBaseService {
if (categories?.length) {
const categoryIds = categories.map((c) => c.id)
const categoryRecords = categoryIds.map((id) => ({ id } as ProductCategory))
const categoryRecords = categoryIds.map(
(id) => ({ id } as ProductCategory)
)
product.categories = categoryRecords
}
@@ -869,6 +873,31 @@ class ProductService extends TransactionBaseService {
})
}
/**
*
* @param productIds ID or IDs of the products to update
* @param profileId Shipping profile ID to update the shipping options with
* @returns updated shipping options
*/
async updateShippingProfile(
productIds: string | string[],
profileId: string
): Promise<Product[]> {
return await this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const ids = isString(productIds) ? [productIds] : productIds
const products = await productRepo.upsertShippingProfile(ids, profileId)
await this.eventBus_
.withTransaction(manager)
.emit(ProductService.Events.UPDATED, products)
return products
})
}
/**
* Creates a query object to be used for list queries.
* @param selector - the selector to create the query from

View File

@@ -21,7 +21,7 @@ import {
UpdateShippingOptionInput,
ValidatePriceTypeAndAmountInput,
} from "../types/shipping-options"
import { buildQuery, setMetadata } from "../utils"
import { buildQuery, isString, setMetadata } from "../utils"
import { FlagRouter } from "../utils/flag-router"
import FulfillmentProviderService from "./fulfillment-provider"
import RegionService from "./region"
@@ -663,6 +663,10 @@ class ShippingOptionService extends TransactionBaseService {
optionWithValidatedPrice.admin_only = update.admin_only
}
if (isDefined(update.profile_id)) {
optionWithValidatedPrice.profile_id = update.profile_id
}
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
@@ -754,6 +758,25 @@ class ShippingOptionService extends TransactionBaseService {
})
}
/**
*
* @param optionIds ID or IDs of the shipping options to update
* @param profileId Shipping profile ID to update the shipping options with
* @returns updated shipping options
*/
async updateShippingProfile(
optionIds: string | string[],
profileId: string
): Promise<ShippingOption[]> {
return await this.atomicPhase_(async (manager) => {
const optionRepo = manager.getCustomRepository(this.optionRepository_)
const ids = isString(optionIds) ? [optionIds] : optionIds
return await optionRepo.upsertShippingProfile(ids, profileId)
})
}
/**
* Returns the amount to be paid for a shipping method. Will ask the
* fulfillment provider to calculate the price if the shipping option has the

View File

@@ -15,7 +15,7 @@ import {
CreateShippingProfile,
UpdateShippingProfile,
} from "../types/shipping-profile"
import { buildQuery, setMetadata } from "../utils"
import { buildQuery, isString, setMetadata } from "../utils"
import CustomShippingOptionService from "./custom-shipping-option"
import ProductService from "./product"
import ShippingOptionService from "./shipping-option"
@@ -260,7 +260,14 @@ class ShippingProfileService extends TransactionBaseService {
)
}
const created = profileRepository.create(profile)
const { metadata, ...rest } = profile
const created = profileRepository.create(rest)
if (metadata) {
created.metadata = setMetadata(created, metadata)
}
const result = await profileRepository.save(created)
return result
})
@@ -284,7 +291,7 @@ class ShippingProfileService extends TransactionBaseService {
this.shippingProfileRepository_
)
const profile = await this.retrieve(profileId, {
let profile = await this.retrieve(profileId, {
relations: [
"products",
"products.profile",
@@ -295,27 +302,16 @@ class ShippingProfileService extends TransactionBaseService {
const { metadata, products, shipping_options, ...rest } = update
if (metadata) {
profile.metadata = setMetadata(profile, metadata)
}
if (products) {
const productServiceTx = this.productService_.withTransaction(manager)
for (const pId of products) {
await productServiceTx.update(pId, {
profile_id: profile.id,
})
}
profile = await this.addProduct(profile.id, products)
}
if (shipping_options) {
const shippingOptionServiceTx =
this.shippingOptionService_.withTransaction(manager)
for (const oId of shipping_options) {
await shippingOptionServiceTx.update(oId, {
profile_id: profile.id,
})
}
profile = await this.addShippingOption(profile.id, shipping_options)
}
if (metadata) {
profile.metadata = setMetadata(profile, metadata)
}
for (const [key, value] of Object.entries(rest)) {
@@ -352,22 +348,31 @@ class ShippingProfileService extends TransactionBaseService {
}
/**
* Adds a product to a profile. The method is idempotent, so multiple calls
* with the same product variant will have the same result.
* @param profileId - the profile to add the product to.
* @param productId - the product to add.
* Adds a product of an array of products to the profile.
* @param profileId - the profile to add the products to.
* @param productId - the ID of the product or multiple products to add.
* @return the result of update
*/
async addProduct(
profileId: string,
productId: string
productId: string | string[]
): Promise<ShippingProfile> {
return await this.atomicPhase_(async (manager) => {
await this.productService_
.withTransaction(manager)
.update(productId, { profile_id: profileId })
const productServiceTx = this.productService_.withTransaction(manager)
return await this.retrieve(profileId)
await productServiceTx.updateShippingProfile(
isString(productId) ? [productId] : productId,
profileId
)
return await this.retrieve(profileId, {
relations: [
"products",
"products.profile",
"shipping_options",
"shipping_options.profile",
],
})
})
}
@@ -375,20 +380,30 @@ class ShippingProfileService extends TransactionBaseService {
* Adds a shipping option to the profile. The shipping option can be used to
* fulfill the products in the products field.
* @param profileId - the profile to apply the shipping option to
* @param optionId - the option to add to the profile
* @param optionId - the ID of the option or multiple options to add to the profile
* @return the result of the model update operation
*/
async addShippingOption(
profileId: string,
optionId: string
optionId: string | string[]
): Promise<ShippingProfile> {
return await this.atomicPhase_(async (manager) => {
await this.shippingOptionService_
.withTransaction(manager)
.update(optionId, { profile_id: profileId })
const shippingOptionServiceTx =
this.shippingOptionService_.withTransaction(manager)
const updated = await this.retrieve(profileId)
return updated
await shippingOptionServiceTx.updateShippingProfile(
isString(optionId) ? [optionId] : optionId,
profileId
)
return await this.retrieve(profileId, {
relations: [
"products",
"products.profile",
"shipping_options",
"shipping_options.profile",
],
})
})
}

View File

@@ -1,7 +1,9 @@
import { Product, ShippingOption, ShippingProfileType } from "../models"
import { ShippingProfileType } from "../models"
export type CreateShippingProfile = {
name: string
type: ShippingProfileType
metadata?: Record<string, unknown>
}
export type UpdateShippingProfile = {