feat(medusa): Product category, type and tags

This commit is contained in:
Oliver Windall Juhl
2021-02-12 08:42:19 +01:00
committed by GitHub
parent 2a8b556256
commit c4d1203155
45 changed files with 1704 additions and 45 deletions

View File

@@ -1,4 +1,4 @@
dist dist/
node_modules node_modules
*yarn-error.log *yarn-error.log

View File

@@ -0,0 +1,205 @@
const { dropDatabase } = require("pg-god");
const path = require("path");
const setupServer = require("../../../helpers/setup-server");
const { useApi } = require("../../../helpers/use-api");
const { initDb } = require("../../../helpers/use-db");
const adminSeeder = require("../../helpers/admin-seeder");
const productSeeder = require("../../helpers/product-seeder");
jest.setTimeout(30000);
describe("/admin/products", () => {
let medusaProcess;
let dbConnection;
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."));
dbConnection = await initDb({ cwd });
medusaProcess = await setupServer({ cwd });
});
afterAll(async () => {
await dbConnection.close();
await dropDatabase({ databaseName: "medusa-integration" });
medusaProcess.kill();
});
describe("POST /admin/products", () => {
beforeEach(async () => {
try {
await productSeeder(dbConnection);
await adminSeeder(dbConnection);
} catch (err) {
console.log(err);
throw err;
}
});
afterEach(async () => {
const manager = dbConnection.manager;
await manager.query(`DELETE FROM "product_option_value"`);
await manager.query(`DELETE FROM "product_option"`);
await manager.query(`DELETE FROM "money_amount"`);
await manager.query(`DELETE FROM "product_variant"`);
await manager.query(`DELETE FROM "product"`);
await manager.query(`DELETE FROM "product_collection"`);
await manager.query(`DELETE FROM "product_tag"`);
await manager.query(`DELETE FROM "product_type"`);
await manager.query(
`UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'`
);
await manager.query(`DELETE FROM "region"`);
await manager.query(`DELETE FROM "user"`);
});
it("creates a product", async () => {
const api = useApi();
const payload = {
title: "Test product",
description: "test-product-description",
type: { value: "test-type" },
collection_id: "test-collection",
tags: [{ value: "123" }, { value: "456" }],
options: [{ title: "size" }, { title: "color" }],
variants: [
{
title: "Test variant",
inventory_quantity: 10,
prices: [{ currency_code: "usd", amount: 100 }],
options: [{ value: "large" }, { value: "green" }],
},
],
};
const response = await api
.post("/admin/products", payload, {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err);
});
expect(response.status).toEqual(200);
expect(response.data.product).toEqual(
expect.objectContaining({
title: "Test product",
handle: "test-product",
tags: [
expect.objectContaining({
value: "123",
}),
expect.objectContaining({
value: "456",
}),
],
type: expect.objectContaining({
value: "test-type",
}),
collection: expect.objectContaining({
id: "test-collection",
title: "Test collection",
}),
options: [
expect.objectContaining({
title: "size",
}),
expect.objectContaining({
title: "color",
}),
],
variants: [
expect.objectContaining({
title: "Test variant",
prices: [
expect.objectContaining({
currency_code: "usd",
amount: 100,
}),
],
options: [
expect.objectContaining({
value: "large",
}),
expect.objectContaining({
value: "green",
}),
],
}),
],
})
);
});
it("updates a product (update tags, delete collection, delete type)", async () => {
const api = useApi();
const payload = {
collection_id: null,
type: null,
tags: [{ value: "123" }],
};
const response = await api
.post("/admin/products/test-product", payload, {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err);
});
expect(response.status).toEqual(200);
expect(response.data.product).toEqual(
expect.objectContaining({
tags: [
expect.objectContaining({
value: "123",
}),
],
type: null,
collection: null,
})
);
});
it("add option", async () => {
const api = useApi();
const payload = {
title: "should_add",
};
const response = await api
.post("/admin/products/test-product/options", payload, {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err);
});
expect(response.status).toEqual(200);
expect(response.data.product).toEqual(
expect.objectContaining({
options: [
expect.objectContaining({
title: "should_add",
product_id: "test-product",
}),
],
})
);
});
});
});

View File

@@ -0,0 +1,68 @@
const {
ProductCollection,
ProductTag,
ProductType,
Region,
Product,
ShippingProfile,
ProductVariant,
} = require("@medusajs/medusa");
module.exports = async (connection, data = {}) => {
const manager = connection.manager;
const defaultProfile = await manager.findOne(ShippingProfile, {
type: "default",
});
const coll = manager.create(ProductCollection, {
id: "test-collection",
title: "Test collection",
});
await manager.save(coll);
const tag = manager.create(ProductTag, {
id: "tag1",
value: "123",
});
await manager.save(tag);
const type = manager.create(ProductType, {
id: "test-type",
value: "test-type",
});
await manager.save(type);
await manager.insert(Region, {
id: "test-region",
name: "Test Region",
currency_code: "usd",
tax_rate: 0,
});
await manager.insert(Product, {
id: "test-product",
title: "Test product",
profile_id: defaultProfile.id,
description: "test-product-description",
collection_id: "test-collection",
type: { id: "test-type", value: "test-type" },
tags: [
{ id: "tag1", value: "123" },
{ tag: "tag2", value: "456" },
],
options: [{ id: "test-option", title: "Default value" }],
});
await manager.insert(ProductVariant, {
id: "test-variant",
inventory_quantity: 10,
title: "Test variant",
product_id: "test-product",
prices: [{ id: "test-price", currency_code: "usd", amount: 100 }],
options: [{ id: "test-variant-option", value: "Default variant" }],
});
};

View File

@@ -16,4 +16,4 @@
"@babel/node": "^7.12.10", "@babel/node": "^7.12.10",
"babel-preset-medusa-package": "^1.1.0" "babel-preset-medusa-package": "^1.1.0"
} }
} }

View File

@@ -18,4 +18,5 @@ module.exports = {
`.cache`, `.cache`,
], ],
transform: { "^.+\\.[jt]s$": `<rootDir>/jest-transformer.js` }, transform: { "^.+\\.[jt]s$": `<rootDir>/jest-transformer.js` },
setupFilesAfterEnv: ["<rootDir>/integration-tests/setup.js"],
}; };

View File

@@ -0,0 +1,5 @@
const { dropDatabase } = require("pg-god");
afterAll(() => {
dropDatabase({ databaseName: "medusa-integration" });
});

View File

@@ -12,4 +12,5 @@ yarn.lock
/services /services
/models /models
/subscribers /subscribers
/loaders

View File

@@ -0,0 +1,59 @@
const checkContentTypes = async (container) => {
const contentfulService = container.resolve("contentfulService")
let product
let variant
try {
product = await contentfulService.getType("product")
variant = await contentfulService.getType("productVariant")
} catch (error) {
if (!product) {
throw Error("Content type: `product` is missing in Contentful")
}
if (!variant) {
throw Error("Content type: `productVariant` is missing in Contentful")
}
}
if (product && product.fields) {
const productFields = product.fields
const keys = Object.values(productFields).map((f) => f.id)
if (!requiredProductFields.every((f) => keys.includes(f))) {
throw Error(
`Contentful: Content type ${`product`} is missing some required key(s). Required: ${requiredProductFields.join(
", "
)}`
)
}
}
if (variant && variant.fields) {
const variantFields = variant.fields
const keys = Object.values(variantFields).map((f) => f.id)
if (!requiredVariantFields.every((f) => keys.includes(f))) {
throw Error(
`Contentful: Content type ${`productVariant`} is missing some required key(s). Required: ${requiredVariantFields.join(
", "
)}`
)
}
}
}
const requiredProductFields = [
"title",
"variants",
"options",
"objectId",
"type",
"collection",
"tags",
"handle",
]
const requiredVariantFields = ["title", "sku", "prices", "options", "objectId"]
export default checkContentTypes

View File

@@ -114,28 +114,58 @@ class ContentfulService extends BaseService {
async createProductInContentful(product) { async createProductInContentful(product) {
try { try {
const p = await this.productService_.retrieve(product.id, { const p = await this.productService_.retrieve(product.id, {
relations: ["variants", "options"], relations: ["variants", "options", "tags", "type", "collection"],
}) })
const environment = await this.getContentfulEnvironment_() const environment = await this.getContentfulEnvironment_()
const variantEntries = await this.getVariantEntries_(p.variants) const variantEntries = await this.getVariantEntries_(p.variants)
const variantLinks = this.getVariantLinks_(variantEntries) const variantLinks = this.getVariantLinks_(variantEntries)
const result = await environment.createEntryWithId("product", p.id, { const fields = {
fields: { title: {
title: { "en-US": p.title,
"en-US": p.title,
},
variants: {
"en-US": variantLinks,
},
options: {
"en-US": p.options,
},
objectId: {
"en-US": p.id,
},
}, },
variants: {
"en-US": variantLinks,
},
options: {
"en-US": p.options,
},
objectId: {
"en-US": p.id,
},
}
if (p.type) {
const type = {
"en-US": p.type.value,
}
fields.type = type
}
if (p.collection) {
const collection = {
"en-US": p.collection.title,
}
fields.collection = collection
}
if (p.tags) {
const tags = {
"en-US": p.tags,
}
fields.tags = tags
}
if (p.handle) {
const handle = {
"en-US": p.handle,
}
fields.handle = handle
}
const result = await environment.createEntryWithId("product", p.id, {
fields,
}) })
const ignoreIds = (await this.getIgnoreIds_("product")) || [] const ignoreIds = (await this.getIgnoreIds_("product")) || []
@@ -210,7 +240,7 @@ class ContentfulService extends BaseService {
} }
const p = await this.productService_.retrieve(product.id, { const p = await this.productService_.retrieve(product.id, {
relations: ["options", "variants"], relations: ["options", "variants", "type", "collection", "tags"],
}) })
const variantEntries = await this.getVariantEntries_(p.variants) const variantEntries = await this.getVariantEntries_(p.variants)
@@ -232,6 +262,34 @@ class ContentfulService extends BaseService {
}, },
} }
if (p.type) {
const type = {
"en-US": p.type.value,
}
productEntryFields.type = type
}
if (p.collection) {
const collection = {
"en-US": p.collection.title,
}
productEntryFields.collection = collection
}
if (p.tags) {
const tags = {
"en-US": p.tags,
}
productEntryFields.tags = tags
}
if (p.handle) {
const handle = {
"en-US": p.handle,
}
productEntryFields.handle = handle
}
productEntry.fields = productEntryFields productEntry.fields = productEntryFields
const updatedEntry = await productEntry.update() const updatedEntry = await productEntry.update()
@@ -372,6 +430,11 @@ class ContentfulService extends BaseService {
throw error throw error
} }
} }
async getType(type) {
const environment = await this.getContentfulEnvironment_()
return environment.getContentType(type)
}
} }
export default ContentfulService export default ContentfulService

View File

@@ -49,6 +49,7 @@
"dependencies": { "dependencies": {
"@babel/plugin-transform-classes": "^7.9.5", "@babel/plugin-transform-classes": "^7.9.5",
"@hapi/joi": "^16.1.8", "@hapi/joi": "^16.1.8",
"@types/lodash": "^4.14.168",
"awilix": "^4.2.3", "awilix": "^4.2.3",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"bull": "^3.12.1", "bull": "^3.12.1",

View File

@@ -0,0 +1,65 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductCollectionServiceMock } from "../../../../../services/__mocks__/product-collection"
describe("POST /admin/collections", () => {
describe("successful creation", () => {
let subject
beforeAll(async () => {
subject = await request("POST", "/admin/collections", {
payload: {
title: "Suits",
handle: "suits",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns created product collection", () => {
expect(subject.body.collection.id).toEqual(IdMap.getId("col"))
})
it("calls production collection service create", () => {
expect(ProductCollectionServiceMock.create).toHaveBeenCalledTimes(1)
expect(ProductCollectionServiceMock.create).toHaveBeenCalledWith({
title: "Suits",
handle: "suits",
})
})
})
describe("invalid data returns error details", () => {
let subject
beforeAll(async () => {
subject = await request("POST", "/admin/collections", {
payload: {
handle: "no-title-collection",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error details", () => {
expect(subject.body.name).toEqual("invalid_data")
expect(subject.body.message[0].message).toEqual(`"title" is required`)
})
})
})

View File

@@ -0,0 +1,42 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductCollectionServiceMock } from "../../../../../services/__mocks__/product-collection"
describe("DELETE /admin/collections/:id", () => {
describe("successful removes collection", () => {
let subject
beforeAll(async () => {
subject = await request(
"DELETE",
`/admin/collections/${IdMap.getId("collection")}`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls product collection service delete", () => {
expect(ProductCollectionServiceMock.delete).toHaveBeenCalledTimes(1)
expect(ProductCollectionServiceMock.delete).toHaveBeenCalledWith(
IdMap.getId("collection")
)
})
it("returns delete result", () => {
expect(subject.body).toEqual({
id: IdMap.getId("collection"),
object: "product-collection",
deleted: true,
})
})
})
})

View File

@@ -0,0 +1,37 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductCollectionServiceMock } from "../../../../../services/__mocks__/product-collection"
describe("GET /admin/categories/:id", () => {
describe("get collection by id successfully", () => {
let subject
beforeAll(async () => {
subject = await request(
"GET",
`/admin/collections/${IdMap.getId("col")}`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls retrieve from product collection service", () => {
expect(ProductCollectionServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(ProductCollectionServiceMock.retrieve).toHaveBeenCalledWith(
IdMap.getId("col")
)
})
it("returns variant decorated", () => {
expect(subject.body.collection.id).toEqual(IdMap.getId("col"))
})
})
})

View File

@@ -0,0 +1,28 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductCollectionServiceMock } from "../../../../../services/__mocks__/product-collection"
describe("GET /admin/collections", () => {
describe("successful retrieval", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request("GET", `/admin/collections`, {
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls product collection service list", () => {
expect(ProductCollectionServiceMock.list).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,44 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductCollectionServiceMock } from "../../../../../services/__mocks__/product-collection"
describe("POST /admin/collections/:id", () => {
describe("successful update", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/collections/${IdMap.getId("col")}`,
{
payload: {
title: "Suits and vests",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns updated product collection", () => {
expect(subject.body.collection.id).toEqual(IdMap.getId("col"))
})
it("product collection service update", () => {
expect(ProductCollectionServiceMock.update).toHaveBeenCalledTimes(1)
expect(ProductCollectionServiceMock.update).toHaveBeenCalledWith(
IdMap.getId("col"),
{
title: "Suits and vests",
}
)
})
})
})

View File

@@ -0,0 +1,29 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const schema = Validator.object().keys({
title: Validator.string().required(),
handle: Validator.string()
.optional()
.allow(""),
metadata: Validator.object().optional(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const productCollectionService = req.scope.resolve(
"productCollectionService"
)
const created = await productCollectionService.create(value)
const collection = await productCollectionService.retrieve(created.id)
res.status(200).json({ collection })
} catch (err) {
throw err
}
}

View File

@@ -0,0 +1,18 @@
export default async (req, res) => {
const { id } = req.params
try {
const productCollectionService = req.scope.resolve(
"productCollectionService"
)
await productCollectionService.delete(id)
res.json({
id,
object: "product-collection",
deleted: true,
})
} catch (err) {
throw err
}
}

View File

@@ -0,0 +1,13 @@
export default async (req, res) => {
const { id } = req.params
try {
const productCollectionService = req.scope.resolve(
"productCollectionService"
)
const collection = await productCollectionService.retrieve(id)
res.status(200).json({ collection })
} catch (err) {
throw err
}
}

View File

@@ -0,0 +1,21 @@
import { Router } from "express"
import middlewares from "../../../middlewares"
const route = Router()
export default app => {
app.use("/collections", route)
route.post("/", middlewares.wrap(require("./create-collection").default))
route.post("/:id", middlewares.wrap(require("./update-collection").default))
route.delete("/:id", middlewares.wrap(require("./delete-collection").default))
route.get("/:id", middlewares.wrap(require("./get-collection").default))
route.get("/", middlewares.wrap(require("./list-collections").default))
return app
}
export const defaultFields = ["id", "title", "handle"]
export const defaultRelations = ["products"]

View File

@@ -0,0 +1,30 @@
import { defaultFields, defaultRelations } from "."
export default async (req, res) => {
try {
const selector = {}
const limit = parseInt(req.query.limit) || 10
const offset = parseInt(req.query.offset) || 0
const productCollectionService = req.scope.resolve(
"productCollectionService"
)
const listConfig = {
select: defaultFields,
relations: defaultRelations,
skip: offset,
take: limit,
}
const collections = await productCollectionService.list(
selector,
listConfig
)
res.status(200).json({ collections })
} catch (err) {
throw err
}
}

View File

@@ -0,0 +1,29 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
title: Validator.string().optional(),
handle: Validator.string().optional(),
metadata: Validator.object().optional(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const productCollectionService = req.scope.resolve(
"productCollectionService"
)
const updated = await productCollectionService.update(id, value)
const collection = await productCollectionService.retrieve(updated.id)
res.status(200).json({ collection })
} catch (err) {
throw err
}
}

View File

@@ -18,6 +18,7 @@ import appRoutes from "./apps"
import swapRoutes from "./swaps" import swapRoutes from "./swaps"
import returnRoutes from "./returns" import returnRoutes from "./returns"
import variantRoutes from "./variants" import variantRoutes from "./variants"
import collectionRoutes from "./collections"
const route = Router() const route = Router()
@@ -60,6 +61,7 @@ export default (app, container, config) => {
swapRoutes(route) swapRoutes(route)
returnRoutes(route) returnRoutes(route)
variantRoutes(route) variantRoutes(route)
collectionRoutes(route)
return app return app
} }

View File

@@ -12,7 +12,7 @@ describe("POST /admin/products", () => {
payload: { payload: {
title: "Test Product", title: "Test Product",
description: "Test Description", description: "Test Description",
tags: "hi,med,dig", tags: [{ id: "test", value: "test" }],
handle: "test-product", handle: "test-product",
}, },
adminSession: { adminSession: {
@@ -36,7 +36,7 @@ describe("POST /admin/products", () => {
expect(ProductServiceMock.create).toHaveBeenCalledWith({ expect(ProductServiceMock.create).toHaveBeenCalledWith({
title: "Test Product", title: "Test Product",
description: "Test Description", description: "Test Description",
tags: "hi,med,dig", tags: [{ id: "test", value: "test" }],
handle: "test-product", handle: "test-product",
is_giftcard: false, is_giftcard: false,
profile_id: IdMap.getId("default_shipping_profile"), profile_id: IdMap.getId("default_shipping_profile"),

View File

@@ -34,11 +34,12 @@ describe("GET /admin/products/:id", () => {
"title", "title",
"subtitle", "subtitle",
"description", "description",
"tags",
"handle", "handle",
"is_giftcard", "is_giftcard",
"thumbnail", "thumbnail",
"profile_id", "profile_id",
"collection_id",
"type_id",
"weight", "weight",
"length", "length",
"height", "height",
@@ -57,6 +58,9 @@ describe("GET /admin/products/:id", () => {
"variants.options", "variants.options",
"images", "images",
"options", "options",
"tags",
"type",
"collection",
], ],
} }
) )

View File

@@ -6,13 +6,28 @@ export default async (req, res) => {
title: Validator.string().required(), title: Validator.string().required(),
subtitle: Validator.string().allow(""), subtitle: Validator.string().allow(""),
description: Validator.string().allow(""), description: Validator.string().allow(""),
tags: Validator.string().optional(),
is_giftcard: Validator.boolean().default(false), is_giftcard: Validator.boolean().default(false),
images: Validator.array() images: Validator.array()
.items(Validator.string()) .items(Validator.string())
.optional(), .optional(),
thumbnail: Validator.string().optional(), thumbnail: Validator.string().optional(),
handle: Validator.string().optional(), handle: Validator.string().optional(),
type: Validator.object()
.keys({
id: Validator.string().optional(),
value: Validator.string().required(),
})
.allow(null)
.optional(),
collection_id: Validator.string()
.allow(null)
.optional(),
tags: Validator.array()
.items({
id: Validator.string().optional(),
value: Validator.string().required(),
})
.optional(),
options: Validator.array().items({ options: Validator.array().items({
title: Validator.string().required(), title: Validator.string().required(),
}), }),
@@ -48,7 +63,7 @@ export default async (req, res) => {
Validator.object() Validator.object()
.keys({ .keys({
region_id: Validator.string(), region_id: Validator.string(),
currency_code: Validator.string().required(), currency_code: Validator.string(),
amount: Validator.number() amount: Validator.number()
.integer() .integer()
.required(), .required(),

View File

@@ -8,6 +8,11 @@ export default app => {
route.post("/", middlewares.wrap(require("./create-product").default)) route.post("/", middlewares.wrap(require("./create-product").default))
route.post("/:id", middlewares.wrap(require("./update-product").default)) route.post("/:id", middlewares.wrap(require("./update-product").default))
route.get("/types", middlewares.wrap(require("./list-types").default))
route.get(
"/tag-usage",
middlewares.wrap(require("./list-tag-usage-count").default)
)
route.post( route.post(
"/:id/variants", "/:id/variants",
@@ -52,6 +57,9 @@ export const defaultRelations = [
"variants.options", "variants.options",
"images", "images",
"options", "options",
"tags",
"type",
"collection",
] ]
export const defaultFields = [ export const defaultFields = [
@@ -59,11 +67,12 @@ export const defaultFields = [
"title", "title",
"subtitle", "subtitle",
"description", "description",
"tags",
"handle", "handle",
"is_giftcard", "is_giftcard",
"thumbnail", "thumbnail",
"profile_id", "profile_id",
"collection_id",
"type_id",
"weight", "weight",
"length", "length",
"height", "height",
@@ -82,11 +91,12 @@ export const allowedFields = [
"title", "title",
"subtitle", "subtitle",
"description", "description",
"tags",
"handle", "handle",
"is_giftcard", "is_giftcard",
"thumbnail", "thumbnail",
"profile_id", "profile_id",
"collection_id",
"type_id",
"weight", "weight",
"length", "length",
"height", "height",
@@ -105,4 +115,7 @@ export const allowedRelations = [
"variants.prices", "variants.prices",
"images", "images",
"options", "options",
"tags",
"type",
"collection",
] ]

View File

@@ -0,0 +1,11 @@
export default async (req, res) => {
try {
const productService = req.scope.resolve("productService")
const tags = await productService.listTagsByUsage()
res.json({ tags })
} catch (error) {
throw error
}
}

View File

@@ -0,0 +1,11 @@
export default async (req, res) => {
try {
const productService = req.scope.resolve("productService")
const types = await productService.listTypes()
res.json({ types })
} catch (error) {
throw error
}
}

View File

@@ -7,7 +7,22 @@ export default async (req, res) => {
const schema = Validator.object().keys({ const schema = Validator.object().keys({
title: Validator.string().optional(), title: Validator.string().optional(),
description: Validator.string().optional(), description: Validator.string().optional(),
tags: Validator.string().optional(), type: Validator.object()
.keys({
id: Validator.string().optional(),
value: Validator.string().required(),
})
.allow(null)
.optional(),
collection_id: Validator.string()
.allow(null)
.optional(),
tags: Validator.array()
.items({
id: Validator.string().optional(),
value: Validator.string().required(),
})
.optional(),
handle: Validator.string().optional(), handle: Validator.string().optional(),
weight: Validator.number().optional(), weight: Validator.number().optional(),
length: Validator.number().optional(), length: Validator.number().optional(),

View File

@@ -21,10 +21,13 @@ export { Order } from "./models/order"
export { PaymentProvider } from "./models/payment-provider" export { PaymentProvider } from "./models/payment-provider"
export { PaymentSession } from "./models/payment-session" export { PaymentSession } from "./models/payment-session"
export { Payment } from "./models/payment" export { Payment } from "./models/payment"
export { ProductOptionValue } from "./models/product-option-value"
export { ProductOption } from "./models/product-option"
export { ProductVariant } from "./models/product-variant"
export { Product } from "./models/product" export { Product } from "./models/product"
export { ProductCollection } from "./models/product-collection"
export { ProductOption } from "./models/product-option"
export { ProductOptionValue } from "./models/product-option-value"
export { ProductVariant } from "./models/product-variant"
export { ProductTag } from "./models/product-tag"
export { ProductType } from "./models/product-type"
export { Refund } from "./models/refund" export { Refund } from "./models/refund"
export { Region } from "./models/region" export { Region } from "./models/region"
export { ReturnItem } from "./models/return-item" export { ReturnItem } from "./models/return-item"

View File

@@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class productTypeCategoryTags1611909563253
implements MigrationInterface {
name = "productTypeCategoryTags1611909563253"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "product_collection" ("id" character varying NOT NULL, "title" character varying NOT NULL, "handle" character varying, "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, "metadata" jsonb, CONSTRAINT "PK_49d419fc77d3aed46c835c558ac" PRIMARY KEY ("id"))`
)
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_6910923cb678fd6e99011a21cc" ON "product_collection" ("handle") `
)
await queryRunner.query(
`CREATE TABLE "product_tag" ("id" character varying NOT NULL, "value" 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, "metadata" jsonb, CONSTRAINT "PK_1439455c6528caa94fcc8564fda" PRIMARY KEY ("id"))`
)
await queryRunner.query(
`CREATE TABLE "product_type" ("id" character varying NOT NULL, "value" 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, "metadata" jsonb, CONSTRAINT "PK_e0843930fbb8854fe36ca39dae1" PRIMARY KEY ("id"))`
)
await queryRunner.query(
`CREATE TABLE "product_tags" ("product_id" character varying NOT NULL, "product_tag_id" character varying NOT NULL, CONSTRAINT "PK_1cf5c9537e7198df494b71b993f" PRIMARY KEY ("product_id", "product_tag_id"))`
)
await queryRunner.query(
`CREATE INDEX "IDX_5b0c6fc53c574299ecc7f9ee22" ON "product_tags" ("product_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_21683a063fe82dafdf681ecc9c" ON "product_tags" ("product_tag_id") `
)
await queryRunner.query(`ALTER TABLE "product" DROP COLUMN "tags"`)
await queryRunner.query(
`ALTER TABLE "product" ADD "collection_id" character varying`
)
await queryRunner.query(
`ALTER TABLE "product" ADD "type_id" character varying`
)
await queryRunner.query(
`ALTER TABLE "product" ADD CONSTRAINT "FK_49d419fc77d3aed46c835c558ac" FOREIGN KEY ("collection_id") REFERENCES "product_collection"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "product_tags" ADD CONSTRAINT "FK_5b0c6fc53c574299ecc7f9ee22e" FOREIGN KEY ("product_id") REFERENCES "product"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "product_tags" ADD CONSTRAINT "FK_21683a063fe82dafdf681ecc9c4" FOREIGN KEY ("product_tag_id") REFERENCES "product_tag"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "product" ADD CONSTRAINT "FK_e0843930fbb8854fe36ca39dae1" FOREIGN KEY ("type_id") REFERENCES "product_type"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "product_tags" DROP CONSTRAINT "FK_21683a063fe82dafdf681ecc9c4"`
)
await queryRunner.query(
`ALTER TABLE "product_tags" DROP CONSTRAINT "FK_5b0c6fc53c574299ecc7f9ee22e"`
)
await queryRunner.query(
`ALTER TABLE "product" DROP CONSTRAINT "FK_49d419fc77d3aed46c835c558ac"`
)
await queryRunner.query(`ALTER TABLE "product" DROP COLUMN "type_id"`)
await queryRunner.query(`ALTER TABLE "product" DROP COLUMN "collection_id"`)
await queryRunner.query(
`ALTER TABLE "product" DROP CONSTRAINT "FK_e0843930fbb8854fe36ca39dae1"`
)
await queryRunner.query(
`ALTER TABLE "product" ADD "tags" character varying`
)
await queryRunner.query(`DROP INDEX "IDX_21683a063fe82dafdf681ecc9c"`)
await queryRunner.query(`DROP INDEX "IDX_5b0c6fc53c574299ecc7f9ee22"`)
await queryRunner.query(`DROP TABLE "product_tags"`)
await queryRunner.query(`DROP TABLE "product_type"`)
await queryRunner.query(`DROP TABLE "product_tag"`)
await queryRunner.query(`DROP INDEX "IDX_6910923cb678fd6e99011a21cc"`)
await queryRunner.query(`DROP TABLE "product_collection"`)
}
}

View File

@@ -0,0 +1,57 @@
import {
Entity,
BeforeInsert,
DeleteDateColumn,
CreateDateColumn,
UpdateDateColumn,
Column,
PrimaryColumn,
ManyToMany,
Index,
OneToMany,
} from "typeorm"
import { ulid } from "ulid"
import { Product } from "./product"
import _ from "lodash"
@Entity()
export class ProductCollection {
@PrimaryColumn()
id: string
@Column()
title: string
@Index({ unique: true })
@Column({ nullable: true })
handle: string
@OneToMany(
() => Product,
product => product.collection
)
products: Product[]
@CreateDateColumn({ type: "timestamptz" })
created_at: Date
@UpdateDateColumn({ type: "timestamptz" })
updated_at: Date
@DeleteDateColumn({ type: "timestamptz" })
deleted_at: Date
@Column({ type: "jsonb", nullable: true })
metadata: any
@BeforeInsert()
private beforeInsert() {
if (this.id) return
const id = ulid()
this.id = `pcol_${id}`
if (!this.handle) {
this.handle = _.kebabCase(this.title)
}
}
}

View File

@@ -29,6 +29,9 @@ export class ProductOption {
) )
values: ProductOptionValue values: ProductOptionValue
@Column()
product_id: string
@ManyToOne( @ManyToOne(
() => Product, () => Product,
product => product.options product => product.options

View File

@@ -0,0 +1,38 @@
import {
Entity,
BeforeInsert,
DeleteDateColumn,
CreateDateColumn,
UpdateDateColumn,
Column,
PrimaryColumn,
} from "typeorm"
import { ulid } from "ulid"
@Entity()
export class ProductTag {
@PrimaryColumn()
id: string
@Column()
value: string
@CreateDateColumn({ type: "timestamptz" })
created_at: Date
@UpdateDateColumn({ type: "timestamptz" })
updated_at: Date
@DeleteDateColumn({ type: "timestamptz" })
deleted_at: Date
@Column({ type: "jsonb", nullable: true })
metadata: any
@BeforeInsert()
private beforeInsert() {
if (this.id) return
const id = ulid()
this.id = `ptag_${id}`
}
}

View File

@@ -0,0 +1,38 @@
import {
Entity,
BeforeInsert,
DeleteDateColumn,
CreateDateColumn,
UpdateDateColumn,
Column,
PrimaryColumn,
} from "typeorm"
import { ulid } from "ulid"
@Entity()
export class ProductType {
@PrimaryColumn()
id: string
@Column()
value: string
@CreateDateColumn({ type: "timestamptz" })
created_at: Date
@UpdateDateColumn({ type: "timestamptz" })
updated_at: Date
@DeleteDateColumn({ type: "timestamptz" })
deleted_at: Date
@Column({ type: "jsonb", nullable: true })
metadata: any
@BeforeInsert()
private beforeInsert() {
if (this.id) return
const id = ulid()
this.id = `ptyp_${id}`
}
}

View File

@@ -17,9 +17,13 @@ import {
import { ulid } from "ulid" import { ulid } from "ulid"
import { Image } from "./image" import { Image } from "./image"
import { ProductCollection } from "./product-collection"
import { ProductOption } from "./product-option" import { ProductOption } from "./product-option"
import { ProductTag } from "./product-tag"
import { ProductType } from "./product-type"
import { ProductVariant } from "./product-variant" import { ProductVariant } from "./product-variant"
import { ShippingProfile } from "./shipping-profile" import { ShippingProfile } from "./shipping-profile"
import _ from "lodash"
@Entity() @Entity()
export class Product { export class Product {
@@ -35,9 +39,6 @@ export class Product {
@Column({ nullable: true }) @Column({ nullable: true })
description: string description: string
@Column({ nullable: true })
tags: string
@Index({ unique: true }) @Index({ unique: true })
@Column({ nullable: true }) @Column({ nullable: true })
handle: string handle: string
@@ -107,6 +108,34 @@ export class Product {
@Column({ nullable: true }) @Column({ nullable: true })
material: string material: string
@Column({ nullable: true })
collection_id: string
@ManyToOne(() => ProductCollection)
@JoinColumn({ name: "collection_id" })
collection: ProductCollection
@Column({ nullable: true })
type_id: string
@ManyToOne(() => ProductType)
@JoinColumn({ name: "type_id" })
type: ProductType
@ManyToMany(() => ProductTag)
@JoinTable({
name: "product_tags",
joinColumn: {
name: "product_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "product_tag_id",
referencedColumnName: "id",
},
})
tags: ProductTag[]
@CreateDateColumn({ type: "timestamptz" }) @CreateDateColumn({ type: "timestamptz" })
created_at: Date created_at: Date
@@ -124,5 +153,9 @@ export class Product {
if (this.id) return if (this.id) return
const id = ulid() const id = ulid()
this.id = `prod_${id}` this.id = `prod_${id}`
if (!this.handle) {
this.handle = _.kebabCase(this.title)
}
} }
} }

View File

@@ -0,0 +1,7 @@
import { EntityRepository, Repository } from "typeorm"
import { ProductCollection } from "../models/product-collection"
@EntityRepository(ProductCollection)
export class ProductCollectionRepository extends Repository<
ProductCollection
> {}

View File

@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { ProductTag } from "../models/product-tag"
@EntityRepository(ProductTag)
export class ProductTagRepository extends Repository<ProductTag> {}

View File

@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { ProductType } from "../models/product-type"
@EntityRepository(ProductType)
export class ProductTypeRepository extends Repository<ProductType> {}

View File

@@ -0,0 +1,28 @@
import { IdMap } from "medusa-test-utils"
export const ProductCollectionServiceMock = {
withTransaction: function() {
return this
},
create: jest.fn().mockImplementation(data => {
return Promise.resolve({ id: IdMap.getId("col"), ...data })
}),
retrieve: jest.fn().mockImplementation(id => {
if (id === IdMap.getId("col")) {
return Promise.resolve({ id: IdMap.getId("col"), title: "Suits" })
}
}),
delete: jest.fn().mockReturnValue(Promise.resolve()),
update: jest.fn().mockImplementation((id, value) => {
return Promise.resolve({ id, title: value })
}),
list: jest.fn().mockImplementation(data => {
return Promise.resolve([{ id: IdMap.getId("col"), title: "Suits" }])
}),
}
const mock = jest.fn().mockImplementation(() => {
return ProductCollectionServiceMock
})
export default mock

View File

@@ -0,0 +1,149 @@
import { IdMap, MockRepository, MockManager } from "medusa-test-utils"
import ProductCollectionService from "../product-collection"
describe("ProductCollectionService", () => {
describe("retrieve", () => {
const productCollectionRepository = MockRepository({
findOne: query => {
if (query.where.id === "non-existing") {
return Promise.resolve(undefined)
}
return Promise.resolve({ id: IdMap.getId("bathrobe") })
},
})
const productCollectionService = new ProductCollectionService({
manager: MockManager,
productCollectionRepository,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("successfully retrieves a product collection", async () => {
const result = await productCollectionService.retrieve(
IdMap.getId("bathrobe")
)
expect(productCollectionRepository.findOne).toHaveBeenCalledTimes(1)
expect(productCollectionRepository.findOne).toHaveBeenCalledWith({
where: { id: IdMap.getId("bathrobe") },
})
expect(result.id).toEqual(IdMap.getId("bathrobe"))
})
it("fails on non-existing product collection id", async () => {
try {
await productCollectionService.retrieve("non-existing")
} catch (error) {
expect(error.message).toBe(
`Product collection with id: non-existing was not found`
)
}
})
})
describe("create", () => {
const productCollectionRepository = MockRepository({
findOne: query => Promise.resolve({ id: IdMap.getId("bathrobe") }),
})
const productCollectionService = new ProductCollectionService({
manager: MockManager,
productCollectionRepository,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("successfully creates a product collection", async () => {
await productCollectionService.create({ title: "bathrobe" })
expect(productCollectionRepository.create).toHaveBeenCalledTimes(1)
expect(productCollectionRepository.create).toHaveBeenCalledWith({
title: "bathrobe",
})
})
})
describe("update", () => {
const productCollectionRepository = MockRepository({
findOne: query => {
if (query.where.id === "non-existing") {
return Promise.resolve(undefined)
}
return Promise.resolve({ id: IdMap.getId("bathrobe") })
},
})
const productCollectionService = new ProductCollectionService({
manager: MockManager,
productCollectionRepository,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("successfully updates a product collection", async () => {
await productCollectionService.update(IdMap.getId("bathrobe"), {
title: "bathrobes",
})
expect(productCollectionRepository.save).toHaveBeenCalledTimes(1)
expect(productCollectionRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("bathrobe"),
title: "bathrobes",
})
})
it("fails on non-existing product collection", async () => {
try {
await productCollectionService.update(IdMap.getId("test"), {
title: "bathrobes",
})
} catch (error) {
expect(error.message).toBe(
`Product collection with id: ${IdMap.getId("test")} was not found`
)
}
})
})
describe("delete", () => {
const productCollectionRepository = MockRepository({
findOne: query => {
if (query.where.id === "non-existing") {
return Promise.resolve(undefined)
}
return Promise.resolve({ id: IdMap.getId("bathrobe") })
},
})
const productCollectionService = new ProductCollectionService({
manager: MockManager,
productCollectionRepository,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("successfully removes a product collection", async () => {
await productCollectionService.delete(IdMap.getId("bathrobe"))
expect(productCollectionRepository.remove).toHaveBeenCalledTimes(1)
expect(productCollectionRepository.remove).toHaveBeenCalledWith({
id: IdMap.getId("bathrobe"),
})
})
it("succeeds idempotently", async () => {
const result = await productCollectionService.delete(IdMap.getId("test"))
expect(result).toBe(undefined)
})
})
})

View File

@@ -36,13 +36,54 @@ describe("ProductService", () => {
describe("create", () => { describe("create", () => {
const productRepository = MockRepository({ const productRepository = MockRepository({
create: () => create: () => ({
Promise.resolve({ id: IdMap.getId("ironman"), title: "Suit" }), id: IdMap.getId("ironman"),
title: "Suit",
options: [],
collection: { id: IdMap.getId("cat"), title: "Suits" },
}),
findOne: () => ({
id: IdMap.getId("ironman"),
title: "Suit",
options: [],
collection: { id: IdMap.getId("cat"), title: "Suits" },
}),
}) })
const productTagRepository = MockRepository({
findOne: () => Promise.resolve(undefined),
create: data => {
if (data.value === "title") {
return { id: "tag-1", value: "title" }
}
if (data.value === "title2") {
return { id: "tag-2", value: "title2" }
}
},
})
const productTypeRepository = MockRepository({
findOne: () => Promise.resolve(undefined),
create: data => {
return { id: "type", value: "type1" }
},
})
const productCollectionService = {
withTransaction: function() {
return this
},
retrieve: id =>
Promise.resolve({ id: IdMap.getId("cat"), title: "Suits" }),
}
const productService = new ProductService({ const productService = new ProductService({
manager: MockManager, manager: MockManager,
productRepository, productRepository,
eventBusService, eventBusService,
productCollectionService,
productTagRepository,
productTypeRepository,
}) })
beforeEach(() => { beforeEach(() => {
@@ -50,9 +91,11 @@ describe("ProductService", () => {
}) })
it("successfully create a product", async () => { it("successfully create a product", async () => {
const result = await productService.create({ await productService.create({
title: "Suit", title: "Suit",
options: [], options: [],
tags: [{ value: "title" }, { value: "title2" }],
type: "type-1",
}) })
expect(eventBusService.emit).toHaveBeenCalledTimes(1) expect(eventBusService.emit).toHaveBeenCalledTimes(1)
@@ -64,15 +107,30 @@ describe("ProductService", () => {
expect(productRepository.create).toHaveBeenCalledTimes(1) expect(productRepository.create).toHaveBeenCalledTimes(1)
expect(productRepository.create).toHaveBeenCalledWith({ expect(productRepository.create).toHaveBeenCalledWith({
title: "Suit", title: "Suit",
options: [],
}) })
expect(productRepository.save).toHaveBeenCalledTimes(1) expect(productTagRepository.findOne).toHaveBeenCalledTimes(2)
// We add two tags, that does not exist therefore we make sure
// that create is also called
expect(productTagRepository.create).toHaveBeenCalledTimes(2)
expect(result).toEqual({ expect(productTypeRepository.findOne).toHaveBeenCalledTimes(1)
expect(productTypeRepository.create).toHaveBeenCalledTimes(1)
expect(productRepository.save).toHaveBeenCalledTimes(1)
expect(productRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("ironman"), id: IdMap.getId("ironman"),
title: "Suit", title: "Suit",
options: [], options: [],
tags: [
{ id: "tag-1", value: "title" },
{ id: "tag-2", value: "title2" },
],
type_id: "type",
collection: {
id: IdMap.getId("cat"),
title: "Suits",
},
}) })
}) })
}) })
@@ -93,6 +151,13 @@ describe("ProductService", () => {
}, },
}) })
const productTypeRepository = MockRepository({
findOne: () => Promise.resolve(undefined),
create: data => {
return { id: "type", value: "type1" }
},
})
const productVariantRepository = MockRepository() const productVariantRepository = MockRepository()
const productVariantService = { const productVariantService = {
@@ -102,11 +167,24 @@ describe("ProductService", () => {
update: () => Promise.resolve(), update: () => Promise.resolve(),
} }
const productTagRepository = MockRepository({
findOne: data => {
if (data.where.value === "test") {
return Promise.resolve({ id: IdMap.getId("test"), value: "test" })
}
if (data.where.value === "test2") {
return Promise.resolve({ id: IdMap.getId("test2"), value: "test2" })
}
},
})
const productService = new ProductService({ const productService = new ProductService({
manager: MockManager, manager: MockManager,
productRepository, productRepository,
productVariantService, productVariantService,
productVariantRepository, productVariantRepository,
productTagRepository,
productTypeRepository,
eventBusService, eventBusService,
}) })
@@ -146,6 +224,10 @@ describe("ProductService", () => {
it("successfully updates product", async () => { it("successfully updates product", async () => {
await productService.update(IdMap.getId("ironman"), { await productService.update(IdMap.getId("ironman"), {
title: "Full suit", title: "Full suit",
collection: {
id: IdMap.getId("test"),
value: "test",
},
}) })
expect(eventBusService.emit).toHaveBeenCalledTimes(1) expect(eventBusService.emit).toHaveBeenCalledTimes(1)
@@ -158,6 +240,34 @@ describe("ProductService", () => {
expect(productRepository.save).toHaveBeenCalledWith({ expect(productRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("ironman"), id: IdMap.getId("ironman"),
title: "Full suit", title: "Full suit",
collection: {
id: IdMap.getId("test"),
value: "test",
},
})
})
it("successfully updates tags", async () => {
await productService.update(IdMap.getId("ironman"), {
tags: [
{ id: IdMap.getId("test"), value: "test" },
{ id: IdMap.getId("test2"), value: "test2" },
],
})
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
expect(eventBusService.emit).toHaveBeenCalledWith(
"product.updated",
expect.any(Object)
)
expect(productRepository.save).toHaveBeenCalledTimes(1)
expect(productRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("ironman"),
tags: [
{ id: IdMap.getId("test"), value: "test" },
{ id: IdMap.getId("test2"), value: "test2" },
],
}) })
}) })

View File

@@ -0,0 +1,153 @@
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
/**
* Provides layer to manipulate product collections.
* @implements BaseService
*/
class ProductCollectionService extends BaseService {
constructor({
manager,
productCollectionRepository,
productRepository,
eventBusService,
}) {
super()
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ProductCollectionRepository} */
this.productCollectionRepository_ = productCollectionRepository
/** @private @const {ProductRepository} */
this.productRepository_ = productRepository
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
}
withTransaction(transactionManager) {
if (!transactionManager) {
return this
}
const cloned = new ProductCollectionService({
manager: transactionManager,
productCollectionRepository: this.productCollectionRepository_,
productRepository: this.productRepository_,
eventBusService: this.eventBus_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Retrieves a product collection by id.
* @param {string} collectionId - the id of the collection to retrieve.
* @return {Promise<ProductCollection>} the collection.
*/
async retrieve(collectionId, config = {}) {
const collectionRepo = this.manager_.getCustomRepository(
this.productCollectionRepository_
)
const validatedId = this.validateId_(collectionId)
const query = this.buildQuery_({ id: validatedId }, config)
const collection = await collectionRepo.findOne(query)
if (!collection) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product collection with id: ${collectionId} was not found`
)
}
return collection
}
/**
* Creates a product collection
* @param {object} collection - the collection to create
* @return {Promise<ProductCollection>} created collection
*/
async create(collection) {
return this.atomicPhase_(async manager => {
const collectionRepo = manager.getCustomRepository(
this.productCollectionRepository_
)
const productCollection = collectionRepo.create(collection)
return collectionRepo.save(productCollection)
})
}
/**
* Updates a product collection
* @param {string} collectionId - id of collection to update
* @param {object} update - update object
* @return {Promise<ProductCollection>} update collection
*/
async update(collectionId, update) {
return this.atomicPhase_(async manager => {
const collectionRepo = manager.getCustomRepository(
this.productCollectionRepository_
)
const collection = await this.retrieve(collectionId)
const { metadata, ...rest } = update
if (metadata) {
collection.metadata = this.setMetadata_(collection, metadata)
}
for (const [key, value] of Object.entries(rest)) {
collection[key] = value
}
return collectionRepo.save(collection)
})
}
/**
* Deletes a product collection idempotently
* @param {string} collectionId - id of collection to delete
* @return {Promise} empty promise
*/
async delete(collectionId) {
return this.atomicPhase_(async manager => {
const productCollectionRepo = manager.getCustomRepository(
this.productCollectionRepository_
)
const collection = await this.retrieve(collectionId)
if (!collection) return Promise.resolve()
await productCollectionRepo.remove(collection)
return Promise.resolve()
})
}
/**
* Lists product collections
* @param {Object} selector - the query object for find
* @return {Promise} the result of the find operation
*/
async list(selector = {}, config = { skip: 0, take: 20 }) {
const productCollectionRepo = this.manager_.getCustomRepository(
this.productCollectionRepository_
)
const query = this.buildQuery_(selector, config)
return productCollectionRepo.find(query)
}
}
export default ProductCollectionService

View File

@@ -20,6 +20,9 @@ class ProductService extends BaseService {
productOptionRepository, productOptionRepository,
eventBusService, eventBusService,
productVariantService, productVariantService,
productCollectionService,
productTypeRepository,
productTagRepository,
}) { }) {
super() super()
@@ -40,6 +43,15 @@ class ProductService extends BaseService {
/** @private @const {ProductVariantService} */ /** @private @const {ProductVariantService} */
this.productVariantService_ = productVariantService this.productVariantService_ = productVariantService
/** @private @const {ProductCollectionService} */
this.productCollectionService_ = productCollectionService
/** @private @const {ProductCollectionService} */
this.productTypeRepository_ = productTypeRepository
/** @private @const {ProductCollectionService} */
this.productTagRepository_ = productTagRepository
} }
withTransaction(transactionManager) { withTransaction(transactionManager) {
@@ -54,6 +66,9 @@ class ProductService extends BaseService {
productOptionRepository: this.productOptionRepository_, productOptionRepository: this.productOptionRepository_,
eventBusService: this.eventBus_, eventBusService: this.eventBus_,
productVariantService: this.productVariantService_, productVariantService: this.productVariantService_,
productCollectionService: this.productCollectionService_,
productTagRepository: this.productTagRepository_,
productTypeRepository: this.productTypeRepository_,
}) })
cloned.transactionManager_ = transactionManager cloned.transactionManager_ = transactionManager
@@ -88,6 +103,7 @@ class ProductService extends BaseService {
alias: "product", alias: "product",
leftJoinAndSelect: { leftJoinAndSelect: {
variant: "product.variants", variant: "product.variants",
collection: "product.collection",
}, },
} }
@@ -100,6 +116,7 @@ class ProductService extends BaseService {
.orWhere(`product.description ILIKE :q`, { q: `%${q}%` }) .orWhere(`product.description ILIKE :q`, { q: `%${q}%` })
.orWhere(`variant.title ILIKE :q`, { q: `%${q}%` }) .orWhere(`variant.title ILIKE :q`, { q: `%${q}%` })
.orWhere(`variant.sku ILIKE :q`, { q: `%${q}%` }) .orWhere(`variant.sku ILIKE :q`, { q: `%${q}%` })
.orWhere(`collection.title ILIKE :q`, { q: `%${q}%` })
}) })
) )
} }
@@ -152,6 +169,78 @@ class ProductService extends BaseService {
return product.variants return product.variants
} }
async listTypes() {
const productTypeRepository = this.manager_.getCustomRepository(
this.productTypeRepository_
)
return await productTypeRepository.find({})
}
async listTagsByUsage(count = 10) {
const tags = await this.manager_.query(
`
SELECT ID, O.USAGE_COUNT, PT.VALUE
FROM PRODUCT_TAG PT
LEFT JOIN
(SELECT COUNT(*) AS USAGE_COUNT,
PRODUCT_TAG_ID
FROM PRODUCT_TAGS
GROUP BY PRODUCT_TAG_ID) O ON O.PRODUCT_TAG_ID = PT.ID
ORDER BY O.USAGE_COUNT DESC
LIMIT $1`,
[count]
)
return tags
}
async upsertProductType_(type) {
const productTypeRepository = this.manager_.getCustomRepository(
this.productTypeRepository_
)
if (type === null) {
return null
}
const existing = await productTypeRepository.findOne({
where: { value: type.value },
})
if (existing) {
return existing
}
const created = productTypeRepository.create(type)
const result = await productTypeRepository.save(created)
return result.id
}
async upsertProductTags_(tags) {
const productTagRepository = this.manager_.getCustomRepository(
this.productTagRepository_
)
let newTags = []
for (const tag of tags) {
const existing = await productTagRepository.findOne({
where: { value: tag.value },
})
if (existing) {
newTags.push(existing)
} else {
const created = productTagRepository.create(tag)
const result = await productTagRepository.save(created)
newTags.push(result)
}
}
return newTags
}
/** /**
* Creates a product. * Creates a product.
* @param {object} productObject - the product to create * @param {object} productObject - the product to create
@@ -164,17 +253,29 @@ class ProductService extends BaseService {
this.productOptionRepository_ this.productOptionRepository_
) )
const product = await productRepo.create(productObject) const { options, tags, type, ...rest } = productObject
let product = productRepo.create(rest)
if (tags) {
product.tags = await this.upsertProductTags_(tags)
}
if (typeof type !== `undefined`) {
product.type_id = await this.upsertProductType_(type)
}
product = await productRepo.save(product)
product.options = await Promise.all( product.options = await Promise.all(
productObject.options.map(async o => { options.map(async o => {
const res = await optionRepo.create({ ...o, product_id: product.id }) const res = optionRepo.create({ ...o, product_id: product.id })
await optionRepo.save(res) await optionRepo.save(res)
return res return res
}) })
) )
const result = await productRepo.save(product) const result = await this.retrieve(product.id, { relations: ["options"] })
await this.eventBus_ await this.eventBus_
.withTransaction(manager) .withTransaction(manager)
@@ -202,10 +303,18 @@ class ProductService extends BaseService {
) )
const product = await this.retrieve(productId, { const product = await this.retrieve(productId, {
relations: ["variants"], relations: ["variants", "tags"],
}) })
const { variants, metadata, options, images, ...rest } = update const {
variants,
metadata,
options,
images,
tags,
type,
...rest
} = update
if (!product.thumbnail && !update.thumbnail && images && images.length) { if (!product.thumbnail && !update.thumbnail && images && images.length) {
product.thumbnail = images[0] product.thumbnail = images[0]
@@ -215,6 +324,14 @@ class ProductService extends BaseService {
product.metadata = this.setMetadata_(product, metadata) product.metadata = this.setMetadata_(product, metadata)
} }
if (typeof type !== `undefined`) {
product.type_id = await this.upsertProductType_(type)
}
if (tags) {
product.tags = await this.upsertProductTags_(tags)
}
if (variants) { if (variants) {
// Iterate product variants and update their properties accordingly // Iterate product variants and update their properties accordingly
for (const variant of product.variants) { for (const variant of product.variants) {
@@ -321,7 +438,7 @@ class ProductService extends BaseService {
product_id: productId, product_id: productId,
}) })
const result = await productOptionRepo.save(option) await productOptionRepo.save(option)
for (const variant of product.variants) { for (const variant of product.variants) {
this.productVariantService_ this.productVariantService_
@@ -329,6 +446,8 @@ class ProductService extends BaseService {
.addOptionValue(variant.id, option.id, "Default Value") .addOptionValue(variant.id, option.id, "Default Value")
} }
const result = await this.retrieve(productId)
await this.eventBus_ await this.eventBus_
.withTransaction(manager) .withTransaction(manager)
.emit(ProductService.Events.UPDATED, result) .emit(ProductService.Events.UPDATED, result)

View File

@@ -2101,6 +2101,11 @@
"@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-coverage" "*"
"@types/istanbul-lib-report" "*" "@types/istanbul-lib-report" "*"
"@types/lodash@^4.14.168":
version "4.14.168"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008"
integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==
"@types/node@*": "@types/node@*":
version "13.13.4" version "13.13.4"
resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.4.tgz#1581d6c16e3d4803eb079c87d4ac893ee7501c2c" resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.4.tgz#1581d6c16e3d4803eb079c87d4ac893ee7501c2c"