diff --git a/integration-tests/api/.gitignore b/integration-tests/api/.gitignore index 273b65dc38..a2c424c876 100644 --- a/integration-tests/api/.gitignore +++ b/integration-tests/api/.gitignore @@ -1,4 +1,4 @@ -dist +dist/ node_modules *yarn-error.log diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js new file mode 100644 index 0000000000..111a9265b5 --- /dev/null +++ b/integration-tests/api/__tests__/admin/product.js @@ -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", + }), + ], + }) + ); + }); + }); +}); diff --git a/integration-tests/api/helpers/product-seeder.js b/integration-tests/api/helpers/product-seeder.js new file mode 100644 index 0000000000..14255c729e --- /dev/null +++ b/integration-tests/api/helpers/product-seeder.js @@ -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" }], + }); +}; diff --git a/integration-tests/api/package.json b/integration-tests/api/package.json index 6fcd9c0593..40595aaf5b 100644 --- a/integration-tests/api/package.json +++ b/integration-tests/api/package.json @@ -16,4 +16,4 @@ "@babel/node": "^7.12.10", "babel-preset-medusa-package": "^1.1.0" } -} +} \ No newline at end of file diff --git a/integration-tests/jest.config.js b/integration-tests/jest.config.js index e100b6b394..bf175fbbeb 100644 --- a/integration-tests/jest.config.js +++ b/integration-tests/jest.config.js @@ -18,4 +18,5 @@ module.exports = { `.cache`, ], transform: { "^.+\\.[jt]s$": `/jest-transformer.js` }, + setupFilesAfterEnv: ["/integration-tests/setup.js"], }; diff --git a/integration-tests/setup.js b/integration-tests/setup.js new file mode 100644 index 0000000000..861746aaaa --- /dev/null +++ b/integration-tests/setup.js @@ -0,0 +1,5 @@ +const { dropDatabase } = require("pg-god"); + +afterAll(() => { + dropDatabase({ databaseName: "medusa-integration" }); +}); diff --git a/packages/medusa-plugin-contentful/.gitignore b/packages/medusa-plugin-contentful/.gitignore index a28122e219..03b009616f 100644 --- a/packages/medusa-plugin-contentful/.gitignore +++ b/packages/medusa-plugin-contentful/.gitignore @@ -12,4 +12,5 @@ yarn.lock /services /models /subscribers +/loaders diff --git a/packages/medusa-plugin-contentful/src/loaders/check-types.js b/packages/medusa-plugin-contentful/src/loaders/check-types.js new file mode 100644 index 0000000000..88f6ea78ef --- /dev/null +++ b/packages/medusa-plugin-contentful/src/loaders/check-types.js @@ -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 diff --git a/packages/medusa-plugin-contentful/src/services/contentful.js b/packages/medusa-plugin-contentful/src/services/contentful.js index 51b2f6eabe..33f45acd32 100644 --- a/packages/medusa-plugin-contentful/src/services/contentful.js +++ b/packages/medusa-plugin-contentful/src/services/contentful.js @@ -114,28 +114,58 @@ class ContentfulService extends BaseService { async createProductInContentful(product) { try { const p = await this.productService_.retrieve(product.id, { - relations: ["variants", "options"], + relations: ["variants", "options", "tags", "type", "collection"], }) const environment = await this.getContentfulEnvironment_() const variantEntries = await this.getVariantEntries_(p.variants) const variantLinks = this.getVariantLinks_(variantEntries) - const result = await environment.createEntryWithId("product", p.id, { - fields: { - title: { - "en-US": p.title, - }, - variants: { - "en-US": variantLinks, - }, - options: { - "en-US": p.options, - }, - objectId: { - "en-US": p.id, - }, + const fields = { + title: { + "en-US": p.title, }, + 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")) || [] @@ -210,7 +240,7 @@ class ContentfulService extends BaseService { } const p = await this.productService_.retrieve(product.id, { - relations: ["options", "variants"], + relations: ["options", "variants", "type", "collection", "tags"], }) 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 const updatedEntry = await productEntry.update() @@ -372,6 +430,11 @@ class ContentfulService extends BaseService { throw error } } + + async getType(type) { + const environment = await this.getContentfulEnvironment_() + return environment.getContentType(type) + } } export default ContentfulService diff --git a/packages/medusa/package.json b/packages/medusa/package.json index 27705454fe..efb716e30e 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -49,6 +49,7 @@ "dependencies": { "@babel/plugin-transform-classes": "^7.9.5", "@hapi/joi": "^16.1.8", + "@types/lodash": "^4.14.168", "awilix": "^4.2.3", "body-parser": "^1.19.0", "bull": "^3.12.1", diff --git a/packages/medusa/src/api/routes/admin/collections/__tests__/create-collection.js b/packages/medusa/src/api/routes/admin/collections/__tests__/create-collection.js new file mode 100644 index 0000000000..2e3b2b750a --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/__tests__/create-collection.js @@ -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`) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/collections/__tests__/delete-collection.js b/packages/medusa/src/api/routes/admin/collections/__tests__/delete-collection.js new file mode 100644 index 0000000000..9d9ec41034 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/__tests__/delete-collection.js @@ -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, + }) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/collections/__tests__/get-collection.js b/packages/medusa/src/api/routes/admin/collections/__tests__/get-collection.js new file mode 100644 index 0000000000..7a69a5170e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/__tests__/get-collection.js @@ -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")) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/collections/__tests__/list-collections.js b/packages/medusa/src/api/routes/admin/collections/__tests__/list-collections.js new file mode 100644 index 0000000000..1d7b82dda0 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/__tests__/list-collections.js @@ -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) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/collections/__tests__/update-collection.js b/packages/medusa/src/api/routes/admin/collections/__tests__/update-collection.js new file mode 100644 index 0000000000..7ed785194b --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/__tests__/update-collection.js @@ -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", + } + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/collections/create-collection.js b/packages/medusa/src/api/routes/admin/collections/create-collection.js new file mode 100644 index 0000000000..31f2c02e0e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/create-collection.js @@ -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 + } +} diff --git a/packages/medusa/src/api/routes/admin/collections/delete-collection.js b/packages/medusa/src/api/routes/admin/collections/delete-collection.js new file mode 100644 index 0000000000..20fd34783e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/delete-collection.js @@ -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 + } +} diff --git a/packages/medusa/src/api/routes/admin/collections/get-collection.js b/packages/medusa/src/api/routes/admin/collections/get-collection.js new file mode 100644 index 0000000000..a32b2eab28 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/get-collection.js @@ -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 + } +} diff --git a/packages/medusa/src/api/routes/admin/collections/index.js b/packages/medusa/src/api/routes/admin/collections/index.js new file mode 100644 index 0000000000..54b10f6aae --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/index.js @@ -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"] diff --git a/packages/medusa/src/api/routes/admin/collections/list-collections.js b/packages/medusa/src/api/routes/admin/collections/list-collections.js new file mode 100644 index 0000000000..f6d7fb02a6 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/list-collections.js @@ -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 + } +} diff --git a/packages/medusa/src/api/routes/admin/collections/update-collection.js b/packages/medusa/src/api/routes/admin/collections/update-collection.js new file mode 100644 index 0000000000..66738f09ff --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/update-collection.js @@ -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 + } +} diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 433575eff5..8f15f67a20 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -18,6 +18,7 @@ import appRoutes from "./apps" import swapRoutes from "./swaps" import returnRoutes from "./returns" import variantRoutes from "./variants" +import collectionRoutes from "./collections" const route = Router() @@ -60,6 +61,7 @@ export default (app, container, config) => { swapRoutes(route) returnRoutes(route) variantRoutes(route) + collectionRoutes(route) return app } diff --git a/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js b/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js index 4dc9cc6cf9..b820b4b69c 100644 --- a/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js +++ b/packages/medusa/src/api/routes/admin/products/__tests__/create-product.js @@ -12,7 +12,7 @@ describe("POST /admin/products", () => { payload: { title: "Test Product", description: "Test Description", - tags: "hi,med,dig", + tags: [{ id: "test", value: "test" }], handle: "test-product", }, adminSession: { @@ -36,7 +36,7 @@ describe("POST /admin/products", () => { expect(ProductServiceMock.create).toHaveBeenCalledWith({ title: "Test Product", description: "Test Description", - tags: "hi,med,dig", + tags: [{ id: "test", value: "test" }], handle: "test-product", is_giftcard: false, profile_id: IdMap.getId("default_shipping_profile"), diff --git a/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js b/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js index d63a6129ba..011374e81d 100644 --- a/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js +++ b/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js @@ -34,11 +34,12 @@ describe("GET /admin/products/:id", () => { "title", "subtitle", "description", - "tags", "handle", "is_giftcard", "thumbnail", "profile_id", + "collection_id", + "type_id", "weight", "length", "height", @@ -57,6 +58,9 @@ describe("GET /admin/products/:id", () => { "variants.options", "images", "options", + "tags", + "type", + "collection", ], } ) diff --git a/packages/medusa/src/api/routes/admin/products/create-product.js b/packages/medusa/src/api/routes/admin/products/create-product.js index 62d13e52ab..86ee3c74bd 100644 --- a/packages/medusa/src/api/routes/admin/products/create-product.js +++ b/packages/medusa/src/api/routes/admin/products/create-product.js @@ -6,13 +6,28 @@ export default async (req, res) => { title: Validator.string().required(), subtitle: Validator.string().allow(""), description: Validator.string().allow(""), - tags: Validator.string().optional(), is_giftcard: Validator.boolean().default(false), images: Validator.array() .items(Validator.string()) .optional(), thumbnail: 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({ title: Validator.string().required(), }), @@ -48,7 +63,7 @@ export default async (req, res) => { Validator.object() .keys({ region_id: Validator.string(), - currency_code: Validator.string().required(), + currency_code: Validator.string(), amount: Validator.number() .integer() .required(), diff --git a/packages/medusa/src/api/routes/admin/products/index.js b/packages/medusa/src/api/routes/admin/products/index.js index be70604d03..d93e75279a 100644 --- a/packages/medusa/src/api/routes/admin/products/index.js +++ b/packages/medusa/src/api/routes/admin/products/index.js @@ -8,6 +8,11 @@ export default app => { route.post("/", middlewares.wrap(require("./create-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( "/:id/variants", @@ -52,6 +57,9 @@ export const defaultRelations = [ "variants.options", "images", "options", + "tags", + "type", + "collection", ] export const defaultFields = [ @@ -59,11 +67,12 @@ export const defaultFields = [ "title", "subtitle", "description", - "tags", "handle", "is_giftcard", "thumbnail", "profile_id", + "collection_id", + "type_id", "weight", "length", "height", @@ -82,11 +91,12 @@ export const allowedFields = [ "title", "subtitle", "description", - "tags", "handle", "is_giftcard", "thumbnail", "profile_id", + "collection_id", + "type_id", "weight", "length", "height", @@ -105,4 +115,7 @@ export const allowedRelations = [ "variants.prices", "images", "options", + "tags", + "type", + "collection", ] diff --git a/packages/medusa/src/api/routes/admin/products/list-tag-usage-count.js b/packages/medusa/src/api/routes/admin/products/list-tag-usage-count.js new file mode 100644 index 0000000000..9bd36ac04d --- /dev/null +++ b/packages/medusa/src/api/routes/admin/products/list-tag-usage-count.js @@ -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 + } +} diff --git a/packages/medusa/src/api/routes/admin/products/list-types.js b/packages/medusa/src/api/routes/admin/products/list-types.js new file mode 100644 index 0000000000..0020c9e07c --- /dev/null +++ b/packages/medusa/src/api/routes/admin/products/list-types.js @@ -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 + } +} diff --git a/packages/medusa/src/api/routes/admin/products/update-product.js b/packages/medusa/src/api/routes/admin/products/update-product.js index b8abd1dbcf..d017e36951 100644 --- a/packages/medusa/src/api/routes/admin/products/update-product.js +++ b/packages/medusa/src/api/routes/admin/products/update-product.js @@ -7,7 +7,22 @@ export default async (req, res) => { const schema = Validator.object().keys({ title: 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(), weight: Validator.number().optional(), length: Validator.number().optional(), diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index 866f6a511e..3ff3ec0c8b 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -21,10 +21,13 @@ export { Order } from "./models/order" export { PaymentProvider } from "./models/payment-provider" export { PaymentSession } from "./models/payment-session" 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 { 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 { Region } from "./models/region" export { ReturnItem } from "./models/return-item" diff --git a/packages/medusa/src/migrations/1611909563253-product_type_category_tags.ts b/packages/medusa/src/migrations/1611909563253-product_type_category_tags.ts new file mode 100644 index 0000000000..811fc62509 --- /dev/null +++ b/packages/medusa/src/migrations/1611909563253-product_type_category_tags.ts @@ -0,0 +1,76 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class productTypeCategoryTags1611909563253 + implements MigrationInterface { + name = "productTypeCategoryTags1611909563253" + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`) + } +} diff --git a/packages/medusa/src/models/product-collection.ts b/packages/medusa/src/models/product-collection.ts new file mode 100644 index 0000000000..9dac2375f2 --- /dev/null +++ b/packages/medusa/src/models/product-collection.ts @@ -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) + } + } +} diff --git a/packages/medusa/src/models/product-option.ts b/packages/medusa/src/models/product-option.ts index d908ba159b..e3a7207d46 100644 --- a/packages/medusa/src/models/product-option.ts +++ b/packages/medusa/src/models/product-option.ts @@ -29,6 +29,9 @@ export class ProductOption { ) values: ProductOptionValue + @Column() + product_id: string + @ManyToOne( () => Product, product => product.options diff --git a/packages/medusa/src/models/product-tag.ts b/packages/medusa/src/models/product-tag.ts new file mode 100644 index 0000000000..ae93e89d48 --- /dev/null +++ b/packages/medusa/src/models/product-tag.ts @@ -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}` + } +} diff --git a/packages/medusa/src/models/product-type.ts b/packages/medusa/src/models/product-type.ts new file mode 100644 index 0000000000..b274937d60 --- /dev/null +++ b/packages/medusa/src/models/product-type.ts @@ -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}` + } +} diff --git a/packages/medusa/src/models/product.ts b/packages/medusa/src/models/product.ts index 1efb640700..3aeaf8eb27 100644 --- a/packages/medusa/src/models/product.ts +++ b/packages/medusa/src/models/product.ts @@ -17,9 +17,13 @@ import { import { ulid } from "ulid" import { Image } from "./image" +import { ProductCollection } from "./product-collection" import { ProductOption } from "./product-option" +import { ProductTag } from "./product-tag" +import { ProductType } from "./product-type" import { ProductVariant } from "./product-variant" import { ShippingProfile } from "./shipping-profile" +import _ from "lodash" @Entity() export class Product { @@ -35,9 +39,6 @@ export class Product { @Column({ nullable: true }) description: string - @Column({ nullable: true }) - tags: string - @Index({ unique: true }) @Column({ nullable: true }) handle: string @@ -107,6 +108,34 @@ export class Product { @Column({ nullable: true }) 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" }) created_at: Date @@ -124,5 +153,9 @@ export class Product { if (this.id) return const id = ulid() this.id = `prod_${id}` + + if (!this.handle) { + this.handle = _.kebabCase(this.title) + } } } diff --git a/packages/medusa/src/repositories/product-collection.ts b/packages/medusa/src/repositories/product-collection.ts new file mode 100644 index 0000000000..0c8df0deb4 --- /dev/null +++ b/packages/medusa/src/repositories/product-collection.ts @@ -0,0 +1,7 @@ +import { EntityRepository, Repository } from "typeorm" +import { ProductCollection } from "../models/product-collection" + +@EntityRepository(ProductCollection) +export class ProductCollectionRepository extends Repository< + ProductCollection +> {} diff --git a/packages/medusa/src/repositories/product-tag.ts b/packages/medusa/src/repositories/product-tag.ts new file mode 100644 index 0000000000..0d937cfb5b --- /dev/null +++ b/packages/medusa/src/repositories/product-tag.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm" +import { ProductTag } from "../models/product-tag" + +@EntityRepository(ProductTag) +export class ProductTagRepository extends Repository {} diff --git a/packages/medusa/src/repositories/product-type.ts b/packages/medusa/src/repositories/product-type.ts new file mode 100644 index 0000000000..1510eef480 --- /dev/null +++ b/packages/medusa/src/repositories/product-type.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm" +import { ProductType } from "../models/product-type" + +@EntityRepository(ProductType) +export class ProductTypeRepository extends Repository {} diff --git a/packages/medusa/src/services/__mocks__/product-collection.js b/packages/medusa/src/services/__mocks__/product-collection.js new file mode 100644 index 0000000000..e41bae350b --- /dev/null +++ b/packages/medusa/src/services/__mocks__/product-collection.js @@ -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 diff --git a/packages/medusa/src/services/__tests__/product-collection.js b/packages/medusa/src/services/__tests__/product-collection.js new file mode 100644 index 0000000000..c2648d2f60 --- /dev/null +++ b/packages/medusa/src/services/__tests__/product-collection.js @@ -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) + }) + }) +}) diff --git a/packages/medusa/src/services/__tests__/product.js b/packages/medusa/src/services/__tests__/product.js index af45fcfdf8..de1eb7fe78 100644 --- a/packages/medusa/src/services/__tests__/product.js +++ b/packages/medusa/src/services/__tests__/product.js @@ -36,13 +36,54 @@ describe("ProductService", () => { describe("create", () => { const productRepository = MockRepository({ - create: () => - Promise.resolve({ id: IdMap.getId("ironman"), title: "Suit" }), + create: () => ({ + 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({ manager: MockManager, productRepository, eventBusService, + productCollectionService, + productTagRepository, + productTypeRepository, }) beforeEach(() => { @@ -50,9 +91,11 @@ describe("ProductService", () => { }) it("successfully create a product", async () => { - const result = await productService.create({ + await productService.create({ title: "Suit", options: [], + tags: [{ value: "title" }, { value: "title2" }], + type: "type-1", }) expect(eventBusService.emit).toHaveBeenCalledTimes(1) @@ -64,15 +107,30 @@ describe("ProductService", () => { expect(productRepository.create).toHaveBeenCalledTimes(1) expect(productRepository.create).toHaveBeenCalledWith({ 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"), title: "Suit", 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 productVariantService = { @@ -102,11 +167,24 @@ describe("ProductService", () => { 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({ manager: MockManager, productRepository, productVariantService, productVariantRepository, + productTagRepository, + productTypeRepository, eventBusService, }) @@ -146,6 +224,10 @@ describe("ProductService", () => { it("successfully updates product", async () => { await productService.update(IdMap.getId("ironman"), { title: "Full suit", + collection: { + id: IdMap.getId("test"), + value: "test", + }, }) expect(eventBusService.emit).toHaveBeenCalledTimes(1) @@ -158,6 +240,34 @@ describe("ProductService", () => { expect(productRepository.save).toHaveBeenCalledWith({ id: IdMap.getId("ironman"), 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" }, + ], }) }) diff --git a/packages/medusa/src/services/product-collection.js b/packages/medusa/src/services/product-collection.js new file mode 100644 index 0000000000..5dbd7f34d7 --- /dev/null +++ b/packages/medusa/src/services/product-collection.js @@ -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} 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} 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} 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 diff --git a/packages/medusa/src/services/product.js b/packages/medusa/src/services/product.js index 5f2aa41b72..7e38385f2a 100644 --- a/packages/medusa/src/services/product.js +++ b/packages/medusa/src/services/product.js @@ -20,6 +20,9 @@ class ProductService extends BaseService { productOptionRepository, eventBusService, productVariantService, + productCollectionService, + productTypeRepository, + productTagRepository, }) { super() @@ -40,6 +43,15 @@ class ProductService extends BaseService { /** @private @const {ProductVariantService} */ this.productVariantService_ = productVariantService + + /** @private @const {ProductCollectionService} */ + this.productCollectionService_ = productCollectionService + + /** @private @const {ProductCollectionService} */ + this.productTypeRepository_ = productTypeRepository + + /** @private @const {ProductCollectionService} */ + this.productTagRepository_ = productTagRepository } withTransaction(transactionManager) { @@ -54,6 +66,9 @@ class ProductService extends BaseService { productOptionRepository: this.productOptionRepository_, eventBusService: this.eventBus_, productVariantService: this.productVariantService_, + productCollectionService: this.productCollectionService_, + productTagRepository: this.productTagRepository_, + productTypeRepository: this.productTypeRepository_, }) cloned.transactionManager_ = transactionManager @@ -88,6 +103,7 @@ class ProductService extends BaseService { alias: "product", leftJoinAndSelect: { variant: "product.variants", + collection: "product.collection", }, } @@ -100,6 +116,7 @@ class ProductService extends BaseService { .orWhere(`product.description ILIKE :q`, { q: `%${q}%` }) .orWhere(`variant.title 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 } + 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. * @param {object} productObject - the product to create @@ -164,17 +253,29 @@ class ProductService extends BaseService { 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( - productObject.options.map(async o => { - const res = await optionRepo.create({ ...o, product_id: product.id }) + options.map(async o => { + const res = optionRepo.create({ ...o, product_id: product.id }) await optionRepo.save(res) return res }) ) - const result = await productRepo.save(product) + const result = await this.retrieve(product.id, { relations: ["options"] }) await this.eventBus_ .withTransaction(manager) @@ -202,10 +303,18 @@ class ProductService extends BaseService { ) 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) { product.thumbnail = images[0] @@ -215,6 +324,14 @@ class ProductService extends BaseService { 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) { // Iterate product variants and update their properties accordingly for (const variant of product.variants) { @@ -321,7 +438,7 @@ class ProductService extends BaseService { product_id: productId, }) - const result = await productOptionRepo.save(option) + await productOptionRepo.save(option) for (const variant of product.variants) { this.productVariantService_ @@ -329,6 +446,8 @@ class ProductService extends BaseService { .addOptionValue(variant.id, option.id, "Default Value") } + const result = await this.retrieve(productId) + await this.eventBus_ .withTransaction(manager) .emit(ProductService.Events.UPDATED, result) diff --git a/packages/medusa/yarn.lock b/packages/medusa/yarn.lock index cdee4cf112..f9f14f3462 100644 --- a/packages/medusa/yarn.lock +++ b/packages/medusa/yarn.lock @@ -2101,6 +2101,11 @@ "@types/istanbul-lib-coverage" "*" "@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@*": version "13.13.4" resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.4.tgz#1581d6c16e3d4803eb079c87d4ac893ee7501c2c"