feat(medusa): PublishableApiKeys SC scopes (#2590)
**What**
- add/ remove sales channels to/from PublishableApiKey in batch
- associate created cart with SC defined by PK
- filter products if PK with SC association is present
- retrieve a list of sales channels for a PK
- implement 3 new middleware
- `extendRequestParams`
- _extend req object with PK scopes (a list of sales channels) if a publishable key is present in the header of the request_
- `validateProductSalesChannelAssociation`
- _validate if the passed product id belongs to a SC from the PK's scope_
- `validateSalesChannelParam`
- _validate that passed SC ids in the req body/query are within the scope of the PK_
**How**
- The general idea was to reuse existing logic in the controller layer which expects `sales_channel_id` array to be passed. The middleware sets associated SC ids in the request context if a PK is present. These ids are then merged in the controller and passed to the service layer.
**TODO**
- filter response from the search endpoint (CORE-824)
**Testing**
- _integration tests_
- add sales channels to the publishable API key scope
- remove sales channels from the publishable API key scope
- returns products from a specific channel associated with a publishable key
- returns products from multiples sales channels associated with a publishable key
- returns all products if PK is not passed
- returns all products if passed PK doesn't have associated channels
- should assign sales channel to order on cart completion if PK is present in the header
- list sales channels from the publishable api key
- throws because sales channel in query/body is not in the scope of passed PK
---
**Discussion**
- what about the other endpoints (e.g. GET /store/product/:id - do we return 404 if the product is not in the SC associated with passed PK)
- what about products search route
- what about `/admin/products` & `/admin/orders` routes (do we add the middleware there as well)
---
RESOLVES CORE-792
RESOLVES CORE-793
RESOLVES CORE-816
This commit is contained in:
@@ -9,6 +9,11 @@ const adminSeeder = require("../../helpers/admin-seeder")
|
||||
const {
|
||||
simplePublishableApiKeyFactory,
|
||||
} = require("../../factories/simple-publishable-api-key-factory")
|
||||
const {
|
||||
simpleSalesChannelFactory,
|
||||
simpleProductFactory,
|
||||
simpleRegionFactory,
|
||||
} = require("../../factories")
|
||||
|
||||
jest.setTimeout(50000)
|
||||
|
||||
@@ -18,6 +23,13 @@ const adminHeaders = {
|
||||
},
|
||||
}
|
||||
|
||||
const customerData = {
|
||||
email: "medusa@test.hr",
|
||||
password: "medusatest",
|
||||
first_name: "medusa",
|
||||
last_name: "medusa",
|
||||
}
|
||||
|
||||
describe("[MEDUSA_FF_PUBLISHABLE_API_KEYS] Publishable API keys", () => {
|
||||
let medusaProcess
|
||||
let dbConnection
|
||||
@@ -27,7 +39,10 @@ describe("[MEDUSA_FF_PUBLISHABLE_API_KEYS] Publishable API keys", () => {
|
||||
const cwd = path.resolve(path.join(__dirname, "..", ".."))
|
||||
const [process, connection] = await startServerWithEnvironment({
|
||||
cwd,
|
||||
env: { MEDUSA_FF_PUBLISHABLE_API_KEYS: true },
|
||||
env: {
|
||||
MEDUSA_FF_PUBLISHABLE_API_KEYS: true,
|
||||
MEDUSA_FF_SALES_CHANNELS: true,
|
||||
},
|
||||
verbose: false,
|
||||
})
|
||||
dbConnection = connection
|
||||
@@ -280,4 +295,777 @@ describe("[MEDUSA_FF_PUBLISHABLE_API_KEYS] Publishable API keys", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /admin/publishable-api-keys/:id/sales-channels/batch", () => {
|
||||
const pubKeyId = IdMap.getId("pubkey-get-id-batch")
|
||||
let salesChannel1
|
||||
let salesChannel2
|
||||
|
||||
beforeEach(async () => {
|
||||
await adminSeeder(dbConnection)
|
||||
|
||||
await simplePublishableApiKeyFactory(dbConnection, {
|
||||
id: pubKeyId,
|
||||
})
|
||||
|
||||
salesChannel1 = await simpleSalesChannelFactory(dbConnection, {
|
||||
name: "test name",
|
||||
description: "test description",
|
||||
})
|
||||
|
||||
salesChannel2 = await simpleSalesChannelFactory(dbConnection, {
|
||||
name: "test name 2",
|
||||
description: "test description 2",
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const db = useDb()
|
||||
return await db.teardown()
|
||||
})
|
||||
|
||||
it("add sales channels to the publishable api key scope", async () => {
|
||||
const api = useApi()
|
||||
|
||||
const response = await api.post(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
sales_channel_ids: [
|
||||
{ id: salesChannel1.id },
|
||||
{ id: salesChannel2.id },
|
||||
],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const mappings = await dbConnection.manager.query(
|
||||
`SELECT *
|
||||
FROM publishable_api_key_sales_channel
|
||||
WHERE publishable_key_id = '${pubKeyId}'`
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
expect(mappings).toEqual([
|
||||
{
|
||||
sales_channel_id: salesChannel1.id,
|
||||
publishable_key_id: pubKeyId,
|
||||
},
|
||||
{
|
||||
sales_channel_id: salesChannel2.id,
|
||||
publishable_key_id: pubKeyId,
|
||||
},
|
||||
])
|
||||
|
||||
expect(response.data.publishable_api_key).toMatchObject({
|
||||
id: pubKeyId,
|
||||
created_at: expect.any(String),
|
||||
updated_at: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("DELETE /admin/publishable-api-keys/:id/sales-channels/batch", () => {
|
||||
const pubKeyId = IdMap.getId("pubkey-get-id-batch-v2")
|
||||
let salesChannel1
|
||||
let salesChannel2
|
||||
let salesChannel3
|
||||
|
||||
beforeEach(async () => {
|
||||
await adminSeeder(dbConnection)
|
||||
|
||||
await simplePublishableApiKeyFactory(dbConnection, {
|
||||
id: pubKeyId,
|
||||
})
|
||||
|
||||
salesChannel1 = await simpleSalesChannelFactory(dbConnection, {
|
||||
name: "test name",
|
||||
description: "test description",
|
||||
})
|
||||
|
||||
salesChannel2 = await simpleSalesChannelFactory(dbConnection, {
|
||||
name: "test name 2",
|
||||
description: "test description 2",
|
||||
})
|
||||
|
||||
salesChannel3 = await simpleSalesChannelFactory(dbConnection, {
|
||||
name: "test name 3",
|
||||
description: "test description 3",
|
||||
})
|
||||
|
||||
await dbConnection.manager.query(
|
||||
`INSERT INTO
|
||||
publishable_api_key_sales_channel
|
||||
(publishable_key_id, sales_channel_id)
|
||||
VALUES
|
||||
('${pubKeyId}', '${salesChannel1.id}'),
|
||||
('${pubKeyId}', '${salesChannel2.id}'),
|
||||
('${pubKeyId}', '${salesChannel3.id}');`
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const db = useDb()
|
||||
return await db.teardown()
|
||||
})
|
||||
|
||||
it("remove sales channels from the publishable api key scope", async () => {
|
||||
const api = useApi()
|
||||
|
||||
const response = await api.delete(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
data: {
|
||||
sales_channel_ids: [
|
||||
{ id: salesChannel1.id },
|
||||
{ id: salesChannel2.id },
|
||||
],
|
||||
},
|
||||
...adminHeaders,
|
||||
}
|
||||
)
|
||||
|
||||
const mappings = await dbConnection.manager.query(
|
||||
`SELECT *
|
||||
FROM publishable_api_key_sales_channel
|
||||
WHERE publishable_key_id = '${pubKeyId}'`
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
expect(mappings).toEqual([
|
||||
{
|
||||
sales_channel_id: salesChannel3.id,
|
||||
publishable_key_id: pubKeyId,
|
||||
},
|
||||
])
|
||||
|
||||
expect(response.data.publishable_api_key).toMatchObject({
|
||||
id: pubKeyId,
|
||||
created_at: expect.any(String),
|
||||
updated_at: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /admin/publishable-api-keys/:id/sales-channels", () => {
|
||||
const pubKeyId = IdMap.getId("pubkey-get-id-batch-v2")
|
||||
let salesChannel1
|
||||
let salesChannel2
|
||||
let salesChannel3
|
||||
|
||||
beforeEach(async () => {
|
||||
await adminSeeder(dbConnection)
|
||||
|
||||
await simplePublishableApiKeyFactory(dbConnection, {
|
||||
id: pubKeyId,
|
||||
})
|
||||
|
||||
salesChannel1 = await simpleSalesChannelFactory(dbConnection, {
|
||||
name: "test name",
|
||||
description: "test description",
|
||||
})
|
||||
|
||||
salesChannel2 = await simpleSalesChannelFactory(dbConnection, {
|
||||
name: "test name 2",
|
||||
description: "test description 2",
|
||||
})
|
||||
|
||||
salesChannel3 = await simpleSalesChannelFactory(dbConnection, {
|
||||
name: "test name 3",
|
||||
description: "test description 3",
|
||||
})
|
||||
|
||||
await dbConnection.manager.query(
|
||||
`INSERT INTO
|
||||
publishable_api_key_sales_channel
|
||||
(publishable_key_id, sales_channel_id)
|
||||
VALUES
|
||||
('${pubKeyId}', '${salesChannel1.id}'),
|
||||
('${pubKeyId}', '${salesChannel2.id}');`
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const db = useDb()
|
||||
return await db.teardown()
|
||||
})
|
||||
|
||||
it("list sales channels from the publishable api key", async () => {
|
||||
const api = useApi()
|
||||
|
||||
const response = await api.get(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels`,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.data.sales_channels).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: salesChannel1.id,
|
||||
deleted_at: null,
|
||||
name: "test name",
|
||||
description: "test description",
|
||||
is_disabled: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: salesChannel2.id,
|
||||
deleted_at: null,
|
||||
name: "test name 2",
|
||||
description: "test description 2",
|
||||
is_disabled: false,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /store/products", () => {
|
||||
const pubKeyId = IdMap.getId("pubkey-get-id")
|
||||
|
||||
let salesChannel1
|
||||
let salesChannel2
|
||||
let product1
|
||||
let product2
|
||||
let product3
|
||||
|
||||
beforeEach(async () => {
|
||||
await adminSeeder(dbConnection)
|
||||
|
||||
salesChannel1 = await simpleSalesChannelFactory(dbConnection, {
|
||||
name: "salesChannel1",
|
||||
description: "salesChannel1",
|
||||
})
|
||||
|
||||
salesChannel2 = await simpleSalesChannelFactory(dbConnection, {
|
||||
name: "salesChannel2",
|
||||
description: "salesChannel2",
|
||||
})
|
||||
|
||||
product1 = await simpleProductFactory(dbConnection, {
|
||||
title: "prod 1",
|
||||
status: "published",
|
||||
sales_channels: [salesChannel1],
|
||||
})
|
||||
|
||||
product2 = await simpleProductFactory(dbConnection, {
|
||||
title: "prod 2",
|
||||
status: "published",
|
||||
sales_channels: [salesChannel2],
|
||||
})
|
||||
|
||||
product3 = await simpleProductFactory(dbConnection, {
|
||||
title: "prod 3",
|
||||
status: "published",
|
||||
})
|
||||
|
||||
await simplePublishableApiKeyFactory(dbConnection, {
|
||||
id: pubKeyId,
|
||||
created_by: adminUserId,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const db = useDb()
|
||||
await db.teardown()
|
||||
})
|
||||
|
||||
it("returns products from a specific channel associated with a publishable key", async () => {
|
||||
const api = useApi()
|
||||
|
||||
await api.post(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
sales_channel_ids: [{ id: salesChannel1.id }],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const response = await api.get(`/store/products`, {
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.data.products.length).toBe(1)
|
||||
expect(response.data.products).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: product1.id,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("returns products from multiples sales channels associated with a publishable key", async () => {
|
||||
const api = useApi()
|
||||
|
||||
await api.post(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
sales_channel_ids: [
|
||||
{ id: salesChannel1.id },
|
||||
{ id: salesChannel2.id },
|
||||
],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const response = await api.get(`/store/products`, {
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.data.products.length).toBe(2)
|
||||
expect(response.data.products).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: product1.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product2.id,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("SC param overrides PK channels (but SK still needs to be in the PK's scope", async () => {
|
||||
const api = useApi()
|
||||
|
||||
await api.post(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
sales_channel_ids: [
|
||||
{ id: salesChannel1.id },
|
||||
{ id: salesChannel2.id },
|
||||
],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const response = await api.get(
|
||||
`/store/products?sales_channel_id[0]=${salesChannel2.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.data.products.length).toBe(1)
|
||||
expect(response.data.products).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: product2.id,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("returns all products if PK is not passed", async () => {
|
||||
const api = useApi()
|
||||
|
||||
await api.post(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
sales_channel_ids: [
|
||||
{ id: salesChannel1.id },
|
||||
{ id: salesChannel2.id },
|
||||
],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const response = await api.get(`/store/products`, {
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
// "x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.data.products.length).toBe(3)
|
||||
expect(response.data.products).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: product1.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product2.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product3.id,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("returns all products if passed PK doesn't have associated channels", async () => {
|
||||
const api = useApi()
|
||||
|
||||
const response = await api.get(`/store/products`, {
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.data.products.length).toBe(3)
|
||||
expect(response.data.products).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: product1.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product2.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product3.id,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("throws because sales channel param is not in the scope of passed PK", async () => {
|
||||
const api = useApi()
|
||||
|
||||
await api.post(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
sales_channel_ids: [{ id: salesChannel1.id }],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
try {
|
||||
await api.get(
|
||||
`/store/products?sales_channel_id[]=${salesChannel2.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
expect(error.response.status).toEqual(400)
|
||||
expect(error.response.data.errors[0]).toEqual(
|
||||
`Provided sales channel id param: ${salesChannel2.id} is not associated with the Publishable API Key passed in the header of the request.`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /store/products/:id", () => {
|
||||
const pubKeyId = IdMap.getId("pubkey-get-id")
|
||||
|
||||
let salesChannel1
|
||||
let product1
|
||||
let product2
|
||||
|
||||
beforeEach(async () => {
|
||||
await adminSeeder(dbConnection)
|
||||
|
||||
salesChannel1 = await simpleSalesChannelFactory(dbConnection, {
|
||||
name: "salesChannel1",
|
||||
description: "salesChannel1",
|
||||
})
|
||||
|
||||
product1 = await simpleProductFactory(dbConnection, {
|
||||
title: "prod 1",
|
||||
status: "published",
|
||||
sales_channels: [salesChannel1],
|
||||
})
|
||||
|
||||
product2 = await simpleProductFactory(dbConnection, {
|
||||
title: "prod 2",
|
||||
status: "published",
|
||||
})
|
||||
|
||||
await simplePublishableApiKeyFactory(dbConnection, {
|
||||
id: pubKeyId,
|
||||
created_by: adminUserId,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const db = useDb()
|
||||
await db.teardown()
|
||||
})
|
||||
|
||||
it("retrieve a products from a specific channel associated with a publishable key", async () => {
|
||||
const api = useApi()
|
||||
|
||||
await api.post(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
sales_channel_ids: [{ id: salesChannel1.id }],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const response = await api.get(`/store/products/${product1.id}`, {
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.data.product).toEqual(
|
||||
expect.objectContaining({
|
||||
id: product1.id,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("return 400 because requested product is not in the SC associated with a publishable key", async () => {
|
||||
const api = useApi()
|
||||
|
||||
await api.post(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
sales_channel_ids: [{ id: salesChannel1.id }],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const response = await api
|
||||
.get(`/store/products/${product2.id}`, {
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
return err.response
|
||||
})
|
||||
|
||||
expect(response.status).toEqual(400)
|
||||
})
|
||||
|
||||
it("correctly returns a product if passed PK has no associated SCs", async () => {
|
||||
const api = useApi()
|
||||
|
||||
let response = await api
|
||||
.get(`/store/products/${product1.id}`, {
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
return err.response
|
||||
})
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
|
||||
response = await api
|
||||
.get(`/store/products/${product2.id}`, {
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
return err.response
|
||||
})
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /store/carts/:id", () => {
|
||||
let product
|
||||
const pubKeyId = IdMap.getId("pubkey-get-id")
|
||||
|
||||
beforeEach(async () => {
|
||||
await adminSeeder(dbConnection)
|
||||
|
||||
await simpleRegionFactory(dbConnection, {
|
||||
id: "test-region",
|
||||
})
|
||||
|
||||
await simplePublishableApiKeyFactory(dbConnection, {
|
||||
id: pubKeyId,
|
||||
created_by: adminUserId,
|
||||
})
|
||||
|
||||
product = await simpleProductFactory(dbConnection, {
|
||||
sales_channels: [
|
||||
{
|
||||
id: "sales-channel",
|
||||
name: "Sales channel",
|
||||
description: "Sales channel",
|
||||
},
|
||||
{
|
||||
id: "sales-channel2",
|
||||
name: "Sales channel2",
|
||||
description: "Sales channel2",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const db = useDb()
|
||||
return await db.teardown()
|
||||
})
|
||||
|
||||
it("should assign sales channel to order on cart completion if PK is present in the header", async () => {
|
||||
const api = useApi()
|
||||
|
||||
await api.post(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
sales_channel_ids: [{ id: "sales-channel" }],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const customerRes = await api.post("/store/customers", customerData, {
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
const createCartRes = await api.post(
|
||||
"/store/carts",
|
||||
{
|
||||
region_id: "test-region",
|
||||
items: [
|
||||
{
|
||||
variant_id: product.variants[0].id,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const cart = createCartRes.data.cart
|
||||
|
||||
await api.post(`/store/carts/${cart.id}`, {
|
||||
customer_id: customerRes.data.customer.id,
|
||||
})
|
||||
|
||||
await api.post(`/store/carts/${cart.id}/payment-sessions`)
|
||||
|
||||
const createdOrder = await api.post(
|
||||
`/store/carts/${cart.id}/complete-cart`
|
||||
)
|
||||
|
||||
expect(createdOrder.data.type).toEqual("order")
|
||||
expect(createdOrder.status).toEqual(200)
|
||||
expect(createdOrder.data.data).toEqual(
|
||||
expect.objectContaining({
|
||||
sales_channel_id: "sales-channel",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("SC from params defines where product is assigned (passed SC still has to be in the scope of PK from the header)", async () => {
|
||||
const api = useApi()
|
||||
|
||||
await api.post(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
sales_channel_ids: [
|
||||
{ id: "sales-channel" },
|
||||
{ id: "sales-channel2" },
|
||||
],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const customerRes = await api.post("/store/customers", customerData, {
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
const createCartRes = await api.post(
|
||||
"/store/carts",
|
||||
{
|
||||
sales_channel_id: "sales-channel2",
|
||||
region_id: "test-region",
|
||||
items: [
|
||||
{
|
||||
variant_id: product.variants[0].id,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const cart = createCartRes.data.cart
|
||||
|
||||
await api.post(`/store/carts/${cart.id}`, {
|
||||
customer_id: customerRes.data.customer.id,
|
||||
})
|
||||
|
||||
await api.post(`/store/carts/${cart.id}/payment-sessions`)
|
||||
|
||||
const createdOrder = await api.post(
|
||||
`/store/carts/${cart.id}/complete-cart`
|
||||
)
|
||||
|
||||
expect(createdOrder.data.type).toEqual("order")
|
||||
expect(createdOrder.status).toEqual(200)
|
||||
expect(createdOrder.data.data).toEqual(
|
||||
expect.objectContaining({
|
||||
sales_channel_id: "sales-channel2",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw because SC id in the body is not in the scope of PK from the header", async () => {
|
||||
const api = useApi()
|
||||
|
||||
await api.post(
|
||||
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
|
||||
{
|
||||
sales_channel_ids: [{ id: "sales-channel" }],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
try {
|
||||
await api.post(
|
||||
"/store/carts",
|
||||
{
|
||||
sales_channel_id: "sales-channel2", // SC not in the PK scope
|
||||
region_id: "test-region",
|
||||
items: [
|
||||
{
|
||||
variant_id: product.variants[0].id,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer test_token",
|
||||
"x-publishable-api-key": pubKeyId,
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
expect(error.response.status).toEqual(400)
|
||||
expect(error.response.data.errors[0]).toEqual(
|
||||
`Provided sales channel id param: sales-channel2 is not associated with the Publishable API Key passed in the header of the request.`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,15 +6,9 @@ const { useApi } = require("../../../../helpers/use-api")
|
||||
const { useDb } = require("../../../../helpers/use-db")
|
||||
|
||||
const {
|
||||
simpleCartFactory,
|
||||
simpleRegionFactory,
|
||||
simpleShippingOptionFactory,
|
||||
simpleCustomShippingOptionFactory,
|
||||
simpleProductFactory,
|
||||
simplePriceListFactory,
|
||||
simpleDiscountFactory,
|
||||
} = require("../../../factories")
|
||||
const { IdMap } = require("medusa-test-utils")
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextFunction, Request, Response } from "express"
|
||||
|
||||
import PublishableApiKeyService from "../../../services/publishable-api-key"
|
||||
|
||||
export type PublishableApiKeyScopes = {
|
||||
sales_channel_id: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The middleware, in case that a key is present in the request header,
|
||||
* attaches ids of resources within the scope of the key to the req object.
|
||||
*
|
||||
* @param req - request object
|
||||
* @param res - response object
|
||||
* @param next - next middleware call
|
||||
*
|
||||
* @throws if sales channel id is passed as a url or body param
|
||||
* but that id is not in the scope defined by the PK from the header
|
||||
*/
|
||||
async function extendRequestParams(
|
||||
req: Request & { publishableApiKeyScopes: PublishableApiKeyScopes },
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const pubKey = req.get("x-publishable-api-key")
|
||||
|
||||
if (pubKey) {
|
||||
const publishableKeyService: PublishableApiKeyService = req.scope.resolve(
|
||||
"publishableApiKeyService"
|
||||
)
|
||||
|
||||
req.publishableApiKeyScopes = await publishableKeyService.getResourceScopes(
|
||||
pubKey
|
||||
)
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
export { extendRequestParams }
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextFunction, Request, Response } from "express"
|
||||
|
||||
import PublishableApiKeyService from "../../../services/publishable-api-key"
|
||||
import { ProductService } from "../../../services"
|
||||
|
||||
/**
|
||||
* The middleware check if requested product is assigned to a SC associated with PK in the header.
|
||||
*
|
||||
* @param req - request object
|
||||
* @param res - response object
|
||||
* @param next - next middleware call
|
||||
*/
|
||||
async function validateProductSalesChannelAssociation(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const pubKey = req.get("x-publishable-api-key")
|
||||
|
||||
if (pubKey) {
|
||||
const productService: ProductService = req.scope.resolve("productService")
|
||||
const publishableKeyService: PublishableApiKeyService = req.scope.resolve(
|
||||
"publishableApiKeyService"
|
||||
)
|
||||
|
||||
const { sales_channel_id: salesChannelIds } =
|
||||
await publishableKeyService.getResourceScopes(pubKey)
|
||||
|
||||
if (
|
||||
salesChannelIds.length &&
|
||||
!(await productService.isProductInSalesChannels(
|
||||
req.params.id,
|
||||
salesChannelIds
|
||||
))
|
||||
) {
|
||||
req.errors = req.errors ?? []
|
||||
req.errors.push(
|
||||
`Product with id: ${req.params.id} is not associated with sales channels defined by the Publishable API Key passed in the header of the request.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
export { validateProductSalesChannelAssociation }
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextFunction, Request, Response } from "express"
|
||||
|
||||
import { PublishableApiKeyScopes } from "./extend-request-params"
|
||||
|
||||
/**
|
||||
* The middleware will return 400 if sales channel id is passed as an url or body param
|
||||
* but that id is not in the scope of the PK from the header.
|
||||
*
|
||||
* NOTE: must be applied after the `extendRequestParams` middleware
|
||||
*
|
||||
* @param req - request object
|
||||
* @param res - response object
|
||||
* @param next - next middleware call
|
||||
*/
|
||||
async function validateSalesChannelParam(
|
||||
req: Request & { publishableApiKeyScopes: PublishableApiKeyScopes },
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const pubKey = req.get("x-publishable-api-key")
|
||||
|
||||
if (pubKey) {
|
||||
const scopes = req.publishableApiKeyScopes
|
||||
let channelIds = req.body.sales_channel_id || req.query.sales_channel_id
|
||||
|
||||
if (!channelIds) {
|
||||
return next()
|
||||
}
|
||||
|
||||
channelIds = !Array.isArray(channelIds) ? [channelIds] : channelIds
|
||||
|
||||
if (
|
||||
scopes.sales_channel_id.length &&
|
||||
!channelIds.every((sc) => scopes.sales_channel_id.includes(sc))
|
||||
) {
|
||||
req.errors = req.errors ?? []
|
||||
req.errors.push(
|
||||
`Provided sales channel id param: ${channelIds} is not associated with the Publishable API Key passed in the header of the request.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
export { validateSalesChannelParam }
|
||||
@@ -0,0 +1,124 @@
|
||||
import { IsArray, ValidateNested } from "class-validator"
|
||||
import { Request, Response } from "express"
|
||||
import { Type } from "class-transformer"
|
||||
import { EntityManager } from "typeorm"
|
||||
|
||||
import { ProductBatchSalesChannel } from "../../../../types/sales-channels"
|
||||
import PublishableApiKeyService from "../../../../services/publishable-api-key"
|
||||
|
||||
/**
|
||||
* @oas [post] /publishable-api-keys/{id}/sales-channels/batch
|
||||
* operationId: "PostPublishableApiKeySalesChannelsChannelsBatch"
|
||||
* summary: "Add sales channel to a publishable api key scope"
|
||||
* description: "Assign a batch of sales channels to a publishable api key."
|
||||
* x-authenticated: true
|
||||
* parameters:
|
||||
* - (path) id=* {string} The ID of the Publishable Api Key.
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* required:
|
||||
* - sales_channel_ids
|
||||
* properties:
|
||||
* sales_channel_ids:
|
||||
* description: The IDs of the sales channels to add to the publishable api key
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* required:
|
||||
* - id
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: The ID of the sales channel
|
||||
* x-codeSamples:
|
||||
* - lang: JavaScript
|
||||
* label: JS Client
|
||||
* source: |
|
||||
* import Medusa from "@medusajs/medusa-js"
|
||||
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
|
||||
* // must be previously logged in or use api token
|
||||
* medusa.admin.publishableApiKeys.addSalesChannels(publishableApiKeyId, {
|
||||
* sales_channel_ids: [
|
||||
* {
|
||||
* id: channel_id
|
||||
* }
|
||||
* ]
|
||||
* })
|
||||
* .then(({ publishable_api_key }) => {
|
||||
* console.log(publishable_api_key.id);
|
||||
* });
|
||||
* - lang: Shell
|
||||
* label: cURL
|
||||
* source: |
|
||||
* curl --location --request POST 'https://medusa-url.com/admin/publishable-api-keys/afasf/batch' \
|
||||
* --header 'Authorization: Bearer {api_token}' \
|
||||
* --header 'Content-Type: application/json' \
|
||||
* --data-raw '{
|
||||
* "sales_channel_ids": [
|
||||
* {
|
||||
* "id": "{sales_channel_id}"
|
||||
* }
|
||||
* ]
|
||||
* }'
|
||||
* security:
|
||||
* - api_token: []
|
||||
* - cookie_auth: []
|
||||
* tags:
|
||||
* - Publishable Api Key
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* properties:
|
||||
* publishable_api_key:
|
||||
* $ref: "#/components/schemas/publishable_api_key"
|
||||
* "400":
|
||||
* $ref: "#/components/responses/400_error"
|
||||
* "401":
|
||||
* $ref: "#/components/responses/unauthorized"
|
||||
* "404":
|
||||
* $ref: "#/components/responses/not_found_error"
|
||||
* "409":
|
||||
* $ref: "#/components/responses/invalid_state_error"
|
||||
* "422":
|
||||
* $ref: "#/components/responses/invalid_request_error"
|
||||
* "500":
|
||||
* $ref: "#/components/responses/500_error"
|
||||
*/
|
||||
export default async (req: Request, res: Response): Promise<void> => {
|
||||
const validatedBody =
|
||||
req.validatedBody as AdminPostPublishableApiKeySalesChannelsBatchReq
|
||||
|
||||
const { id } = req.params
|
||||
|
||||
const publishableApiKeyService: PublishableApiKeyService = req.scope.resolve(
|
||||
"publishableApiKeyService"
|
||||
)
|
||||
|
||||
const manager: EntityManager = req.scope.resolve("manager")
|
||||
const publishableApiKey = await manager.transaction(
|
||||
async (transactionManager) => {
|
||||
await publishableApiKeyService
|
||||
.withTransaction(transactionManager)
|
||||
.addSalesChannels(
|
||||
id,
|
||||
validatedBody.sales_channel_ids.map((p) => p.id)
|
||||
)
|
||||
|
||||
return await publishableApiKeyService.retrieve(id)
|
||||
}
|
||||
)
|
||||
|
||||
res.status(200).json({ publishable_api_key: publishableApiKey })
|
||||
}
|
||||
|
||||
export class AdminPostPublishableApiKeySalesChannelsBatchReq {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ProductBatchSalesChannel)
|
||||
sales_channel_ids: ProductBatchSalesChannel[]
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { IsArray, ValidateNested } from "class-validator"
|
||||
import { Request, Response } from "express"
|
||||
import { Type } from "class-transformer"
|
||||
import { EntityManager } from "typeorm"
|
||||
|
||||
import { ProductBatchSalesChannel } from "../../../../types/sales-channels"
|
||||
import PublishableApiKeyService from "../../../../services/publishable-api-key"
|
||||
|
||||
/**
|
||||
* @oas [delete] /publishable-api-keys/{id}/sales-channels/batch
|
||||
* operationId: "DeletePublishableApiKeySalesChannelsChannelsBatch"
|
||||
* summary: "Remove sales channel from a publishable api key scope"
|
||||
* description: "Remove a batch of sales channels from a publishable api key."
|
||||
* x-authenticated: true
|
||||
* parameters:
|
||||
* - (path) id=* {string} The ID of the Publishable Api Key.
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* required:
|
||||
* - sales_channel_ids
|
||||
* properties:
|
||||
* sales_channel_ids:
|
||||
* description: The IDs of the sales channels to delete from the publishable api key
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* required:
|
||||
* - id
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: The ID of the sales channel
|
||||
* x-codeSamples:
|
||||
* - lang: JavaScript
|
||||
* label: JS Client
|
||||
* source: |
|
||||
* import Medusa from "@medusajs/medusa-js"
|
||||
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
|
||||
* // must be previously logged in or use api token
|
||||
* medusa.admin.publishableApiKeys.removeSalesChannels(publishableApiKeyId, {
|
||||
* sales_channel_ids: [
|
||||
* {
|
||||
* id: channel_id
|
||||
* }
|
||||
* ]
|
||||
* })
|
||||
* .then(({ publishable_api_key }) => {
|
||||
* console.log(publishable_api_key.id);
|
||||
* });
|
||||
* - lang: Shell
|
||||
* label: cURL
|
||||
* source: |
|
||||
* curl --location --request DELETE 'https://medusa-url.com/admin/publishable-api-keys/afasf/batch' \
|
||||
* --header 'Authorization: Bearer {api_token}' \
|
||||
* --header 'Content-Type: application/json' \
|
||||
* --data-raw '{
|
||||
* "sales_channel_ids": [
|
||||
* {
|
||||
* "id": "{sales_channel_id}"
|
||||
* }
|
||||
* ]
|
||||
* }'
|
||||
* security:
|
||||
* - api_token: []
|
||||
* - cookie_auth: []
|
||||
* tags:
|
||||
* - Publishable Api Key
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* properties:
|
||||
* publishable_api_key:
|
||||
* $ref: "#/components/schemas/publishable_api_key"
|
||||
* "400":
|
||||
* $ref: "#/components/responses/400_error"
|
||||
* "401":
|
||||
* $ref: "#/components/responses/unauthorized"
|
||||
* "404":
|
||||
* $ref: "#/components/responses/not_found_error"
|
||||
* "409":
|
||||
* $ref: "#/components/responses/invalid_state_error"
|
||||
* "422":
|
||||
* $ref: "#/components/responses/invalid_request_error"
|
||||
* "500":
|
||||
* $ref: "#/components/responses/500_error"
|
||||
*/
|
||||
export default async (req: Request, res: Response): Promise<void> => {
|
||||
const validatedBody =
|
||||
req.validatedBody as AdminDeletePublishableApiKeySalesChannelsBatchReq
|
||||
|
||||
const { id } = req.params
|
||||
|
||||
const publishableApiKeyService: PublishableApiKeyService = req.scope.resolve(
|
||||
"publishableApiKeyService"
|
||||
)
|
||||
|
||||
const manager: EntityManager = req.scope.resolve("manager")
|
||||
const publishableApiKey = await manager.transaction(
|
||||
async (transactionManager) => {
|
||||
await publishableApiKeyService
|
||||
.withTransaction(transactionManager)
|
||||
.removeSalesChannels(
|
||||
id,
|
||||
validatedBody.sales_channel_ids.map((p) => p.id)
|
||||
)
|
||||
|
||||
return await publishableApiKeyService.retrieve(id)
|
||||
}
|
||||
)
|
||||
|
||||
res.status(200).json({ publishable_api_key: publishableApiKey })
|
||||
}
|
||||
|
||||
export class AdminDeletePublishableApiKeySalesChannelsBatchReq {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ProductBatchSalesChannel)
|
||||
sales_channel_ids: ProductBatchSalesChannel[]
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import { PublishableApiKey } from "../../../../models"
|
||||
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
|
||||
import { AdminPostPublishableApiKeysReq } from "./create-publishable-api-key"
|
||||
import { AdminPostPublishableApiKeysPublishableApiKeyReq } from "./update-publishable-api-key"
|
||||
import { AdminDeletePublishableApiKeySalesChannelsBatchReq } from "./delete-channels-batch"
|
||||
import { AdminPostPublishableApiKeySalesChannelsBatchReq } from "./add-channels-batch"
|
||||
import { GetPublishableApiKeySalesChannelsParams } from "./list-publishable-api-key-sales-channels"
|
||||
|
||||
const route = Router()
|
||||
|
||||
@@ -55,6 +58,26 @@ export default (app) => {
|
||||
}),
|
||||
middlewares.wrap(require("./list-publishable-api-keys").default)
|
||||
)
|
||||
|
||||
route.get(
|
||||
"/:id/sales-channels",
|
||||
transformQuery(GetPublishableApiKeySalesChannelsParams, { isList: true }),
|
||||
middlewares.wrap(
|
||||
require("./list-publishable-api-key-sales-channels").default
|
||||
)
|
||||
)
|
||||
|
||||
route.post(
|
||||
"/:id/sales-channels/batch",
|
||||
transformBody(AdminPostPublishableApiKeySalesChannelsBatchReq),
|
||||
middlewares.wrap(require("./add-channels-batch").default)
|
||||
)
|
||||
|
||||
route.delete(
|
||||
"/:id/sales-channels/batch",
|
||||
transformBody(AdminDeletePublishableApiKeySalesChannelsBatchReq),
|
||||
middlewares.wrap(require("./delete-channels-batch").default)
|
||||
)
|
||||
}
|
||||
|
||||
export type AdminPublishableApiKeysRes = {
|
||||
@@ -65,6 +88,9 @@ export type AdminPublishableApiKeysListRes = PaginatedResponse & {
|
||||
}
|
||||
export type AdminPublishableApiKeyDeleteRes = DeleteResponse
|
||||
|
||||
export * from "./add-channels-batch"
|
||||
export * from "./delete-channels-batch"
|
||||
export * from "./list-publishable-api-keys"
|
||||
export * from "./list-publishable-api-key-sales-channels"
|
||||
export * from "./create-publishable-api-key"
|
||||
export * from "./update-publishable-api-key"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
import PublishableApiKeyService from "../../../../services/publishable-api-key"
|
||||
|
||||
/**
|
||||
* @oas [get] /publishable-api-keys/:id/sales-channels
|
||||
* operationId: "GetPublishableApiKeySalesChannels"
|
||||
* summary: "List PublishableApiKey's SalesChannels"
|
||||
* description: "List PublishableApiKey's SalesChannels"
|
||||
* x-authenticated: true
|
||||
* x-codeSamples:
|
||||
* - lang: JavaScript
|
||||
* label: JS Client
|
||||
* source: |
|
||||
* import Medusa from "@medusajs/medusa-js"
|
||||
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
|
||||
* // must be previously logged in or use api token
|
||||
* medusa.admin.publishableApiKeys.listSalesChannels()
|
||||
* .then(({ sales_channels, limit, offset, count }) => {
|
||||
* console.log(sales_channels)
|
||||
* })
|
||||
* - lang: Shell
|
||||
* label: cURL
|
||||
* source: |
|
||||
* curl --location --request GET 'https://medusa-url.com/admin/publishable-api-keys/pk_123/sales-channels' \
|
||||
* --header 'Authorization: Bearer {api_token}'
|
||||
* security:
|
||||
* - api_token: []
|
||||
* - cookie_auth: []
|
||||
* tags:
|
||||
* - PublishableApiKeySalesChannels
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* properties:
|
||||
* sales_channels:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: "#/components/schemas/sales_channel"
|
||||
* "400":
|
||||
* $ref: "#/components/responses/400_error"
|
||||
* "401":
|
||||
* $ref: "#/components/responses/unauthorized"
|
||||
* "404":
|
||||
* $ref: "#/components/responses/not_found_error"
|
||||
* "409":
|
||||
* $ref: "#/components/responses/invalid_state_error"
|
||||
* "422":
|
||||
* $ref: "#/components/responses/invalid_request_error"
|
||||
* "500":
|
||||
* $ref: "#/components/responses/500_error"
|
||||
*/
|
||||
export default async (req: Request, res: Response) => {
|
||||
const { id } = req.params
|
||||
const publishableApiKeyService: PublishableApiKeyService = req.scope.resolve(
|
||||
"publishableApiKeyService"
|
||||
)
|
||||
|
||||
const salesChannels = await publishableApiKeyService.listSalesChannels(id)
|
||||
|
||||
return res.json({
|
||||
sales_channels: salesChannels,
|
||||
})
|
||||
}
|
||||
|
||||
export class GetPublishableApiKeySalesChannelsParams {}
|
||||
@@ -2,12 +2,11 @@ import {
|
||||
DateComparisonOperator,
|
||||
extendedFindParamsMixin,
|
||||
} from "../../../../types/common"
|
||||
import { IsNumber, IsOptional, IsString, ValidateNested } from "class-validator"
|
||||
import { IsOptional, IsString, ValidateNested } from "class-validator"
|
||||
import { Request, Response } from "express"
|
||||
|
||||
import { SalesChannelService } from "../../../../services"
|
||||
import { Type } from "class-transformer"
|
||||
import { removeUndefinedProperties } from "../../../../utils"
|
||||
|
||||
/**
|
||||
* @oas [get] /sales-channels
|
||||
|
||||
@@ -23,6 +23,7 @@ import { FlagRouter } from "../../../../utils/flag-router"
|
||||
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
|
||||
import { CartCreateProps } from "../../../../types/cart"
|
||||
import { isDefined } from "../../../../utils"
|
||||
import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys"
|
||||
|
||||
/**
|
||||
* @oas [post] /carts
|
||||
@@ -159,6 +160,23 @@ export default async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) {
|
||||
if (
|
||||
!toCreate.sales_channel_id &&
|
||||
req.publishableApiKeyScopes?.sales_channel_id.length
|
||||
) {
|
||||
if (req.publishableApiKeyScopes.sales_channel_id.length > 1) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.UNEXPECTED_STATE,
|
||||
"The PublishableApiKey provided in the request header has multiple associated sales channels."
|
||||
)
|
||||
}
|
||||
|
||||
toCreate.sales_channel_id =
|
||||
req.publishableApiKeyScopes.sales_channel_id[0]
|
||||
}
|
||||
}
|
||||
|
||||
let cart: Cart
|
||||
await entityManager.transaction(async (manager) => {
|
||||
cart = await cartService.withTransaction(manager).create(toCreate)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from "express"
|
||||
import "reflect-metadata"
|
||||
import { RequestHandler, Router } from "express"
|
||||
|
||||
import { Cart, Order, Swap } from "../../../../"
|
||||
import { DeleteResponse, FindParams } from "../../../../types/common"
|
||||
import middlewares, {
|
||||
@@ -8,6 +9,10 @@ import middlewares, {
|
||||
} from "../../../middlewares"
|
||||
import { StorePostCartsCartReq } from "./update-cart"
|
||||
import { StorePostCartReq } from "./create-cart"
|
||||
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
|
||||
import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys"
|
||||
import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params"
|
||||
import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param"
|
||||
|
||||
const route = Router()
|
||||
|
||||
@@ -17,7 +22,7 @@ export default (app, container) => {
|
||||
|
||||
app.use("/carts", route)
|
||||
|
||||
if (featureFlagRouter.isFeatureEnabled("sales_channels")) {
|
||||
if (featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)) {
|
||||
defaultStoreCartRelations.push("sales_channel")
|
||||
}
|
||||
|
||||
@@ -37,10 +42,21 @@ export default (app, container) => {
|
||||
middlewares.wrap(require("./get-cart").default)
|
||||
)
|
||||
|
||||
route.post(
|
||||
"/",
|
||||
const createMiddlewares = [
|
||||
middlewareService.usePreCartCreation(),
|
||||
transformBody(StorePostCartReq),
|
||||
]
|
||||
|
||||
if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) {
|
||||
createMiddlewares.push(
|
||||
extendRequestParams as unknown as RequestHandler,
|
||||
validateSalesChannelParam as unknown as RequestHandler
|
||||
)
|
||||
}
|
||||
|
||||
route.post(
|
||||
"/",
|
||||
...createMiddlewares,
|
||||
middlewares.wrap(require("./create-cart").default)
|
||||
)
|
||||
|
||||
|
||||
@@ -31,12 +31,14 @@ export default (app, container, config) => {
|
||||
})
|
||||
)
|
||||
|
||||
const featureFlagRouter = container.resolve("featureFlagRouter")
|
||||
|
||||
route.use(middlewares.authenticateCustomer())
|
||||
|
||||
authRoutes(route)
|
||||
collectionRoutes(route)
|
||||
customerRoutes(route, container)
|
||||
productRoutes(route)
|
||||
productRoutes(route, featureFlagRouter)
|
||||
productTypesRoutes(route)
|
||||
orderRoutes(route)
|
||||
orderEditRoutes(route)
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import { Router } from "express"
|
||||
import { RequestHandler, Router } from "express"
|
||||
import "reflect-metadata"
|
||||
|
||||
import { Product } from "../../../.."
|
||||
import { PaginatedResponse } from "../../../../types/common"
|
||||
import middlewares from "../../../middlewares"
|
||||
import { FlagRouter } from "../../../../utils/flag-router"
|
||||
import { PaginatedResponse } from "../../../../types/common"
|
||||
import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params"
|
||||
import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys"
|
||||
import { validateProductSalesChannelAssociation } from "../../../middlewares/publishable-api-key/validate-product-sales-channel-association"
|
||||
import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default (app) => {
|
||||
export default (app, featureFlagRouter: FlagRouter) => {
|
||||
app.use("/products", route)
|
||||
|
||||
if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) {
|
||||
route.use(
|
||||
"/",
|
||||
extendRequestParams as unknown as RequestHandler,
|
||||
validateSalesChannelParam as unknown as RequestHandler
|
||||
)
|
||||
route.use("/:id", validateProductSalesChannelAssociation)
|
||||
}
|
||||
|
||||
route.get("/", middlewares.wrap(require("./list-products").default))
|
||||
route.post("/search", middlewares.wrap(require("./search").default))
|
||||
route.get("/:id", middlewares.wrap(require("./get-product").default))
|
||||
route.post("/search", middlewares.wrap(require("./search").default))
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators
|
||||
import { validator } from "../../../../utils/validator"
|
||||
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
|
||||
import { IsType } from "../../../../utils/validators/is-type"
|
||||
import { FlagRouter } from "../../../../utils/flag-router"
|
||||
import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys"
|
||||
|
||||
/**
|
||||
* @oas [get] /products
|
||||
@@ -195,8 +197,18 @@ export default async (req, res) => {
|
||||
const cartService: CartService = req.scope.resolve("cartService")
|
||||
const regionService: RegionService = req.scope.resolve("regionService")
|
||||
|
||||
const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter")
|
||||
|
||||
const validated = await validator(StoreGetProductsParams, req.query)
|
||||
|
||||
if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) {
|
||||
if (req.publishableApiKeyScopes?.sales_channel_id.length) {
|
||||
validated.sales_channel_id =
|
||||
validated.sales_channel_id ||
|
||||
req.publishableApiKeyScopes.sales_channel_id
|
||||
}
|
||||
}
|
||||
|
||||
const filterableFields: StoreGetProductsParams = omit(validated, [
|
||||
"fields",
|
||||
"expand",
|
||||
|
||||
@@ -397,6 +397,22 @@ export class ProductRepository extends Repository<Product> {
|
||||
return [products, count]
|
||||
}
|
||||
|
||||
public async isProductInSalesChannels(
|
||||
id: string,
|
||||
salesChannelIds: string[]
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await this.createQueryBuilder("product")
|
||||
.leftJoin(
|
||||
"product.sales_channels",
|
||||
"sales_channels",
|
||||
"sales_channels.id IN (:...salesChannelIds)",
|
||||
{ salesChannelIds }
|
||||
)
|
||||
.getCount()) > 0
|
||||
)
|
||||
}
|
||||
|
||||
private _cleanOptions(
|
||||
options: FindWithoutRelationsOptions
|
||||
): WithRequiredProperty<FindWithoutRelationsOptions, "where"> {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { EntityRepository, In, Repository } from "typeorm"
|
||||
|
||||
import { PublishableApiKeySalesChannel, SalesChannel } from "../models"
|
||||
|
||||
@EntityRepository(PublishableApiKeySalesChannel)
|
||||
export class PublishableApiKeySalesChannelRepository extends Repository<PublishableApiKeySalesChannel> {
|
||||
/**
|
||||
* Query a list of sales channels that are assigned to the publishable key scope
|
||||
*
|
||||
* @param publishableApiKeyId - id of the key to retrieve channels for
|
||||
*/
|
||||
public async findSalesChannels(
|
||||
publishableApiKeyId: string
|
||||
): Promise<SalesChannel[]> {
|
||||
const data = await this.createQueryBuilder("PublishableKeySalesChannel")
|
||||
.select("PublishableKeySalesChannel.sales_channel_id")
|
||||
.innerJoinAndMapOne(
|
||||
"PublishableKeySalesChannel.sales_channel_id",
|
||||
SalesChannel,
|
||||
"SalesChannel",
|
||||
"PublishableKeySalesChannel.sales_channel_id = SalesChannel.id"
|
||||
)
|
||||
.where(
|
||||
"PublishableKeySalesChannel.publishable_key_id = :publishableApiKeyId",
|
||||
{
|
||||
publishableApiKeyId,
|
||||
}
|
||||
)
|
||||
.getMany()
|
||||
|
||||
return data.map(
|
||||
(record) => record.sales_channel_id as unknown as SalesChannel
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign (multiple) sales channels to the Publishable Key scope
|
||||
*
|
||||
* @param publishableApiKeyId - publishable key id
|
||||
* @param salesChannelIds - an array of SC ids
|
||||
*/
|
||||
public async addSalesChannels(
|
||||
publishableApiKeyId: string,
|
||||
salesChannelIds: string[]
|
||||
): Promise<void> {
|
||||
await this.createQueryBuilder()
|
||||
.insert()
|
||||
.into("publishable_api_key_sales_channel")
|
||||
.values(
|
||||
salesChannelIds.map((id) => ({
|
||||
sales_channel_id: id,
|
||||
publishable_key_id: publishableApiKeyId,
|
||||
}))
|
||||
)
|
||||
.orIgnore()
|
||||
.execute()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove multiple sales channels from the PK scope
|
||||
*
|
||||
* @param publishableApiKeyId -publishable key id
|
||||
* @param salesChannelIds - an array of SC ids
|
||||
*/
|
||||
public async removeSalesChannels(
|
||||
publishableApiKeyId: string,
|
||||
salesChannelIds: string[]
|
||||
): Promise<void> {
|
||||
await this.createQueryBuilder()
|
||||
.delete()
|
||||
.from("publishable_api_key_sales_channel")
|
||||
.where({
|
||||
sales_channel_id: In(salesChannelIds),
|
||||
publishable_key_id: publishableApiKeyId,
|
||||
})
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { flatten, groupBy, merge } from "lodash"
|
||||
import { EntityRepository, FindManyOptions, Repository } from "typeorm"
|
||||
|
||||
import { PublishableApiKey } from "../models/publishable-api-key"
|
||||
import { PublishableApiKey } from "../models"
|
||||
|
||||
@EntityRepository(PublishableApiKey)
|
||||
export class PublishableApiKeyRepository extends Repository<PublishableApiKey> {
|
||||
|
||||
@@ -27,8 +27,12 @@ describe("PublishableApiKeyService", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const publishableApiKeySalesChannelRepository = MockRepository({})
|
||||
|
||||
const publishableApiKeyService = new PublishableApiKeyService({
|
||||
manager: MockManager,
|
||||
publishableApiKeySalesChannelRepository:
|
||||
publishableApiKeySalesChannelRepository,
|
||||
publishableApiKeyRepository: publishableApiKeyRepository,
|
||||
eventBusService: EventBusServiceMock as unknown as EventBusService,
|
||||
})
|
||||
|
||||
@@ -324,6 +324,29 @@ class ProductService extends TransactionBaseService {
|
||||
return await productTagRepo.listTagsByUsage(count)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the product is assigned to at least one of the provided sales channels.
|
||||
*
|
||||
* @param id - product id
|
||||
* @param salesChannelIds - an array of sales channel ids
|
||||
*/
|
||||
async isProductInSalesChannels(
|
||||
id: string,
|
||||
salesChannelIds: string[]
|
||||
): Promise<boolean> {
|
||||
const product = await this.retrieve_(
|
||||
{ id },
|
||||
{ relations: ["sales_channels"] }
|
||||
)
|
||||
|
||||
// TODO: reimplement this to use db level check
|
||||
const productsSalesChannels = product.sales_channels.map(
|
||||
(channel) => channel.id
|
||||
)
|
||||
|
||||
return productsSalesChannels.some((id) => salesChannelIds.includes(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a product.
|
||||
* @param productObject - the product to create
|
||||
|
||||
@@ -3,7 +3,7 @@ import { MedusaError } from "medusa-core-utils"
|
||||
|
||||
import { PublishableApiKeyRepository } from "../repositories/publishable-api-key"
|
||||
import { FindConfig, Selector } from "../types/common"
|
||||
import { PublishableApiKey } from "../models"
|
||||
import { PublishableApiKey, SalesChannel } from "../models"
|
||||
import { TransactionBaseService } from "../interfaces"
|
||||
import EventBusService from "./event-bus"
|
||||
import { buildQuery, isDefined, isString } from "../utils"
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
CreatePublishableApiKeyInput,
|
||||
UpdatePublishableApiKeyInput,
|
||||
} from "../types/publishable-api-key"
|
||||
import { PublishableApiKeySalesChannelRepository } from "../repositories/publishable-api-key-sales-channel"
|
||||
|
||||
type InjectedDependencies = {
|
||||
manager: EntityManager
|
||||
|
||||
eventBusService: EventBusService
|
||||
publishableApiKeyRepository: typeof PublishableApiKeyRepository
|
||||
publishableApiKeySalesChannelRepository: typeof PublishableApiKeySalesChannelRepository
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,17 +35,21 @@ class PublishableApiKeyService extends TransactionBaseService {
|
||||
|
||||
protected readonly eventBusService_: EventBusService
|
||||
protected readonly publishableApiKeyRepository_: typeof PublishableApiKeyRepository
|
||||
protected readonly publishableApiKeySalesChannelRepository_: typeof PublishableApiKeySalesChannelRepository
|
||||
|
||||
constructor({
|
||||
manager,
|
||||
eventBusService,
|
||||
publishableApiKeyRepository,
|
||||
publishableApiKeySalesChannelRepository,
|
||||
}: InjectedDependencies) {
|
||||
super(arguments[0])
|
||||
|
||||
this.manager_ = manager
|
||||
this.eventBusService_ = eventBusService
|
||||
this.publishableApiKeyRepository_ = publishableApiKeyRepository
|
||||
this.publishableApiKeySalesChannelRepository_ =
|
||||
publishableApiKeySalesChannelRepository
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,6 +253,91 @@ class PublishableApiKeyService extends TransactionBaseService {
|
||||
const pubKey = await this.retrieve(publishableApiKeyId)
|
||||
return pubKey.revoked_by === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Associate provided sales channels with the publishable api key.
|
||||
*
|
||||
* @param publishableApiKeyId
|
||||
* @param salesChannelIds
|
||||
*/
|
||||
async addSalesChannels(
|
||||
publishableApiKeyId: string,
|
||||
salesChannelIds: string[]
|
||||
): Promise<void | never> {
|
||||
return await this.atomicPhase_(async (transactionManager) => {
|
||||
const pubKeySalesChannelRepo = transactionManager.getCustomRepository(
|
||||
this.publishableApiKeySalesChannelRepository_
|
||||
)
|
||||
|
||||
await pubKeySalesChannelRepo.addSalesChannels(
|
||||
publishableApiKeyId,
|
||||
salesChannelIds
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove provided sales channels from the publishable api key scope.
|
||||
*
|
||||
* @param publishableApiKeyId
|
||||
* @param salesChannelIds
|
||||
*/
|
||||
async removeSalesChannels(
|
||||
publishableApiKeyId: string,
|
||||
salesChannelIds: string[]
|
||||
): Promise<void | never> {
|
||||
return await this.atomicPhase_(async (transactionManager) => {
|
||||
const pubKeySalesChannelRepo = transactionManager.getCustomRepository(
|
||||
this.publishableApiKeySalesChannelRepository_
|
||||
)
|
||||
|
||||
await pubKeySalesChannelRepo.removeSalesChannels(
|
||||
publishableApiKeyId,
|
||||
salesChannelIds
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List SalesChannels associated with the PublishableKey
|
||||
*
|
||||
* @param publishableApiKeyId - id of the key SalesChannels are listed for
|
||||
*/
|
||||
async listSalesChannels(
|
||||
publishableApiKeyId: string
|
||||
): Promise<SalesChannel[]> {
|
||||
const manager = this.manager_
|
||||
const pubKeySalesChannelRepo = manager.getCustomRepository(
|
||||
this.publishableApiKeySalesChannelRepository_
|
||||
)
|
||||
|
||||
return await pubKeySalesChannelRepo.findSalesChannels(publishableApiKeyId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a map of resources ids that are withing the key's scope.
|
||||
*
|
||||
* @param publishableApiKeyId
|
||||
*/
|
||||
async getResourceScopes(
|
||||
publishableApiKeyId: string
|
||||
): Promise<{ sales_channel_id: string[] }> {
|
||||
const manager = this.manager_
|
||||
const pubKeySalesChannelRepo = manager.getCustomRepository(
|
||||
this.publishableApiKeySalesChannelRepository_
|
||||
)
|
||||
|
||||
const salesChannels = await pubKeySalesChannelRepo.find({
|
||||
select: ["sales_channel_id"],
|
||||
where: { publishable_key_id: publishableApiKeyId },
|
||||
})
|
||||
|
||||
return {
|
||||
sales_channel_id: salesChannels.map(
|
||||
({ sales_channel_id }) => sales_channel_id
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PublishableApiKeyService
|
||||
|
||||
Reference in New Issue
Block a user