feat(medusa): cart context (#201)

- Adds a context field to Cart
- context is automatically populated with ip + user agent
- context can be updated via POST /store/cart/:id or set when creating via POST /store/cart
This commit is contained in:
Sebastian Rindom
2021-03-12 11:48:51 +01:00
committed by GitHub
parent a031f1f338
commit dd7b306333
20 changed files with 3499 additions and 657 deletions

View File

@@ -72,6 +72,26 @@ describe("/store/carts", () => {
const getRes = await api.post(`/store/carts/${response.data.cart.id}`); const getRes = await api.post(`/store/carts/${response.data.cart.id}`);
expect(getRes.status).toEqual(200); expect(getRes.status).toEqual(200);
}); });
it("creates a cart with context", async () => {
const api = useApi();
const response = await api.post("/store/carts", {
context: {
test_id: "test",
},
});
expect(response.status).toEqual(200);
const getRes = await api.post(`/store/carts/${response.data.cart.id}`);
expect(getRes.status).toEqual(200);
const cart = getRes.data.cart;
expect(cart.context).toEqual({
ip: "::ffff:127.0.0.1",
user_agent: "axios/0.21.1",
test_id: "test",
});
});
}); });
describe("POST /store/carts/:id", () => { describe("POST /store/carts/:id", () => {

View File

@@ -1,13 +1,7 @@
const glob = require(`glob`); // API
const pkgs = glob
.sync(`${__dirname}/*/`)
.map((p) => p.replace(__dirname, `<rootDir>/integration-tests`));
module.exports = { module.exports = {
testEnvironment: `node`, testEnvironment: `node`,
rootDir: `../`,
roots: pkgs,
testPathIgnorePatterns: [ testPathIgnorePatterns: [
`/examples/`, `/examples/`,
`/www/`, `/www/`,
@@ -17,6 +11,6 @@ module.exports = {
`__testfixtures__`, `__testfixtures__`,
`.cache`, `.cache`,
], ],
transform: { "^.+\\.[jt]s$": `<rootDir>/jest-transformer.js` }, transform: { "^.+\\.[jt]s$": `../../jest-transformer.js` },
setupFilesAfterEnv: ["<rootDir>/integration-tests/setup.js"], setupFilesAfterEnv: ["../setup.js"],
}; };

View File

@@ -4,17 +4,19 @@
"main": "index.js", "main": "index.js",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test": "jest --runInBand",
"build": "babel src -d dist --extensions \".ts,.js\"" "build": "babel src -d dist --extensions \".ts,.js\""
}, },
"dependencies": { "dependencies": {
"@medusajs/medusa": "^1.1.3", "@medusajs/medusa": "1.1.11-dev-1615544779907",
"medusa-interfaces": "^1.1.0", "medusa-interfaces": "1.1.1-dev-1615544779907",
"typeorm": "^0.2.31" "typeorm": "^0.2.31"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.12.10", "@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10", "@babel/core": "^7.12.10",
"@babel/node": "^7.12.10", "@babel/node": "^7.12.10",
"babel-preset-medusa-package": "^1.1.0" "babel-preset-medusa-package": "1.1.0-dev-1615544779907",
"jest": "^26.6.3"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
const path = require("path"); const path = require("path");
const express = require("express"); const express = require("express");
const getPort = require("get-port"); const getPort = require("get-port");
const importFrom = require("import-from");
const loaders = require("@medusajs/medusa/dist/loaders").default;
const initialize = async () => { const initialize = async () => {
const app = express(); const app = express();
const loaders = importFrom(process.cwd(), "@medusajs/medusa/dist/loaders")
.default;
const { dbConnection } = await loaders({ const { dbConnection } = await loaders({
directory: path.resolve(process.cwd()), directory: path.resolve(process.cwd()),
expressApp: app, expressApp: app,

View File

@@ -18,6 +18,7 @@
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"express": "^4.17.1", "express": "^4.17.1",
"get-port": "^5.1.1", "get-port": "^5.1.1",
"import-from": "^3.0.0",
"jest": "^26.6.3", "jest": "^26.6.3",
"lerna": "^3.22.1", "lerna": "^3.22.1",
"mongoose": "^5.10.15", "mongoose": "^5.10.15",

View File

@@ -17,13 +17,13 @@
"author": "Sebastian Rindom", "author": "Sebastian Rindom",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.7.5", "@babel/cli": "^7.13.0",
"@babel/core": "^7.7.5", "@babel/core": "^7.13.8",
"@babel/plugin-proposal-class-properties": "^7.7.4", "@babel/plugin-proposal-class-properties": "^7.13.0",
"@babel/plugin-transform-classes": "^7.9.5", "@babel/plugin-transform-classes": "^7.9.5",
"@babel/plugin-transform-runtime": "^7.7.6", "@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.5", "@babel/preset-env": "^7.13.9",
"@babel/runtime": "^7.9.6", "@babel/runtime": "^7.13.9",
"cross-env": "^5.2.1", "cross-env": "^5.2.1",
"eslint": "^6.8.0", "eslint": "^6.8.0",
"jest": "^25.5.2", "jest": "^25.5.2",

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,9 @@ class SegmentService extends BaseService {
* e.g. * e.g.
* { * {
* write_key: Segment write key given in Segment dashboard * write_key: Segment write key given in Segment dashboard
* use_ga_id: If set to true the plugin will look for a ga_id in the cart
* context if present this id will be used as the Google Analytics
* client id.
* } * }
*/ */
constructor({ totalsService, productService }, options) { constructor({ totalsService, productService }, options) {

View File

@@ -3,15 +3,19 @@ class OrderSubscriber {
segmentService, segmentService,
eventBusService, eventBusService,
orderService, orderService,
cartService,
claimService, claimService,
returnService, returnService,
fulfillmentService, fulfillmentService,
}) { }) {
this.orderService_ = orderService this.orderService_ = orderService
this.cartService_ = cartService
this.returnService_ = returnService this.returnService_ = returnService
this.claimService_ = claimService this.claimService_ = claimService
this.fulfillmentService_ = fulfillmentService this.fulfillmentService_ = fulfillmentService
eventBusService.subscribe( eventBusService.subscribe(
@@ -239,12 +243,46 @@ class OrderSubscriber {
], ],
}) })
const eventContext = {}
const integrations = {}
if (order.cart_id) {
try {
const cart = await this.cartService_.retrieve(order.cart_id, {
select: ["context"],
})
if (cart.context) {
if (cart.context.ip) {
eventContext.ip = cart.context.ip
}
if (cart.context.user_agent) {
eventContext.user_agent = cart.context.user_agent
}
if (segmentService.options_ && segmentService.options_.use_ga_id) {
if (cart.context.ga_id) {
integrations["Google Analytics"] = {
clientId: cart.context.ga_id,
}
}
}
}
} catch (err) {
console.log(err)
console.warn("Failed to gather context for order")
}
}
const orderData = await segmentService.buildOrder(order) const orderData = await segmentService.buildOrder(order)
const orderEvent = { const orderEvent = {
event: "Order Completed", event: "Order Completed",
userId: order.customer_id, userId: order.customer_id,
properties: orderData, properties: orderData,
timestamp: order.created_at, timestamp: order.created_at,
context: eventContext,
integrations,
} }
segmentService.track(orderEvent) segmentService.track(orderEvent)

View File

@@ -44,7 +44,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"medusa-interfaces": "1.x", "medusa-interfaces": "1.x",
"mongoose": "5.x" "typeorm": "0.2.x"
}, },
"dependencies": { "dependencies": {
"@babel/plugin-transform-classes": "^7.9.5", "@babel/plugin-transform-classes": "^7.9.5",
@@ -82,7 +82,6 @@
"request-ip": "^2.1.3", "request-ip": "^2.1.3",
"resolve-cwd": "^3.0.0", "resolve-cwd": "^3.0.0",
"scrypt-kdf": "^2.0.1", "scrypt-kdf": "^2.0.1",
"typeorm": "^0.2.29",
"ulid": "^2.3.0", "ulid": "^2.3.0",
"uuid": "^8.3.1", "uuid": "^8.3.1",
"winston": "^3.2.1" "winston": "^3.2.1"

View File

@@ -11,6 +11,9 @@ describe("POST /store/carts", () => {
subject = await request("POST", `/store/carts`, { subject = await request("POST", `/store/carts`, {
payload: { payload: {
region_id: IdMap.getId("testRegion"), region_id: IdMap.getId("testRegion"),
context: {
clientId: "test",
},
}, },
}) })
}) })
@@ -23,6 +26,11 @@ describe("POST /store/carts", () => {
expect(CartServiceMock.create).toHaveBeenCalledTimes(1) expect(CartServiceMock.create).toHaveBeenCalledTimes(1)
expect(CartServiceMock.create).toHaveBeenCalledWith({ expect(CartServiceMock.create).toHaveBeenCalledWith({
region_id: IdMap.getId("testRegion"), region_id: IdMap.getId("testRegion"),
context: {
ip: "::ffff:127.0.0.1",
user_agent: "node-superagent/3.8.3",
clientId: "test",
},
}) })
}) })

View File

@@ -1,3 +1,4 @@
import reqIp from "request-ip"
import { Validator, MedusaError } from "medusa-core-utils" import { Validator, MedusaError } from "medusa-core-utils"
import { defaultFields, defaultRelations } from "./" import { defaultFields, defaultRelations } from "./"
@@ -31,6 +32,9 @@ import { defaultFields, defaultRelations } from "./"
* quantity: * quantity:
* description: The quantity of the Product Variant to add * description: The quantity of the Product Variant to add
* type: integer * type: integer
* context:
* description: "An optional object to provide context to the Cart. The `context` field is automatically populated with `ip` and `user_agent`"
* type: object
* tags: * tags:
* - Cart * - Cart
* responses: * responses:
@@ -53,6 +57,7 @@ export default async (req, res) => {
quantity: Validator.number().required(), quantity: Validator.number().required(),
}) })
.optional(), .optional(),
context: Validator.object().optional(),
}) })
const { value, error } = schema.validate(req.body) const { value, error } = schema.validate(req.body)
@@ -60,6 +65,11 @@ export default async (req, res) => {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
} }
const reqContext = {
ip: reqIp.getClientIp(req),
user_agent: req.get("user-agent"),
}
try { try {
const lineItemService = req.scope.resolve("lineItemService") const lineItemService = req.scope.resolve("lineItemService")
const cartService = req.scope.resolve("cartService") const cartService = req.scope.resolve("cartService")
@@ -77,6 +87,10 @@ export default async (req, res) => {
const toCreate = { const toCreate = {
region_id: regionId, region_id: regionId,
context: {
...reqContext,
...value.context,
},
} }
if (req.user && req.user.customer_id) { if (req.user && req.user.customer_id) {

View File

@@ -51,6 +51,9 @@ import { defaultFields, defaultRelations } from "./"
* customer_id: * customer_id:
* description: "The id of the Customer to associate the Cart with." * description: "The id of the Customer to associate the Cart with."
* type: string * type: string
* context:
* description: "An optional object to provide context to the Cart."
* type: object
* tags: * tags:
* - Cart * - Cart
* responses: * responses:
@@ -85,6 +88,7 @@ export default async (req, res) => {
}) })
.optional(), .optional(),
customer_id: Validator.string().optional(), customer_id: Validator.string().optional(),
context: Validator.object().optional(),
}) })
const { value, error } = schema.validate(req.body) const { value, error } = schema.validate(req.body)

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class cartContext1614684597235 implements MigrationInterface {
name = "cartContext1614684597235"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cart" ADD "context" jsonb`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cart" DROP COLUMN "context"`)
}
}

View File

@@ -243,6 +243,9 @@ export class Cart {
@Column({ nullable: true }) @Column({ nullable: true })
idempotency_key: string idempotency_key: string
@Column({ type: "jsonb", nullable: true })
context: any
// Total fields // Total fields
shipping_total: number shipping_total: number
discount_total: number discount_total: number

View File

@@ -641,6 +641,14 @@ class CartService extends BaseService {
} }
} }
if ("context" in update) {
const prevContext = cart.context || {}
cart.context = {
...prevContext,
...update.context,
}
}
const result = await cartRepo.save(cart) const result = await cartRepo.save(cart)
if ("email" in update || "customer_id" in update) { if ("email" in update || "customer_id" in update) {

View File

@@ -4,10 +4,11 @@ FIXTURE_PATTERN=$1
lerna run build lerna run build
medusa-dev --set-path-to-repo .
cd docs-util/fixture-gen cd docs-util/fixture-gen
yarn medusa-dev --force-install --scan-once
yarn link @medusajs/medusa medusa-interfaces
cd ../.. cd ../..

10
scripts/integration-tests.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
medusa-dev --set-path-to-repo .
cd integration-tests/api
medusa-dev --force-install --scan-once
yarn test

View File

@@ -5392,6 +5392,13 @@ import-fresh@^2.0.0:
caller-path "^2.0.0" caller-path "^2.0.0"
resolve-from "^3.0.0" resolve-from "^3.0.0"
import-from@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/import-from/-/import-from-3.0.0.tgz#055cfec38cd5a27d8057ca51376d7d3bf0891966"
integrity sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==
dependencies:
resolve-from "^5.0.0"
import-local@^2.0.0: import-local@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d"