fix: merge conflicts

This commit is contained in:
zakariaelas committed 2021-09-30 20:29:58 +01:00
commit 632ad17e3f
134 files changed
+5517 -628

No files matched your search

@@ -12,6 +12,9 @@ import { Validator, MedusaError } from "medusa-core-utils"
* application/json:
* schema:
* properties:
* email:
* type: string
* description: The Customer's email. Only providable if user not registered.
* first_name:
* type: string
* description: The Customer's first name.
@@ -37,6 +40,7 @@ export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
email: Validator.string().optional(),
first_name: Validator.string().optional(),
last_name: Validator.string().optional(),
password: Validator.string().optional(),
@@ -50,9 +54,19 @@ export default async (req, res) => {
try {
const customerService = req.scope.resolve("customerService")
let customer = await customerService.retrieve(id)
if (value.email && customer.has_account) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Email cannot be changed when the user has registered their account"
)
}
await customerService.update(id, value)
const customer = await customerService.retrieve(id, {
customer = await customerService.retrieve(id, {
relations: ["orders"],
})
res.status(200).json({ customer })
@@ -46,6 +46,7 @@ describe("POST /admin/discounts/:discount_id/regions/:region_id", () => {
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
],
relations: ["rule", "parent_discount", "regions", "rule.valid_for"],
}
@@ -46,6 +46,7 @@ describe("POST /admin/discounts/:discount_id/variants/:variant_id", () => {
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
],
relations: ["rule", "parent_discount", "regions", "rule.valid_for"],
}
@@ -16,6 +16,8 @@ describe("POST /admin/discounts", () => {
value: 10,
allocation: "total",
},
starts_at: "02/02/2021 13:45",
ends_at: "03/14/2021 04:30",
},
adminSession: {
jwt: {
@@ -39,12 +41,99 @@ describe("POST /admin/discounts", () => {
value: 10,
allocation: "total",
},
starts_at: new Date("02/02/2021 13:45"),
ends_at: new Date("03/14/2021 04:30"),
is_disabled: false,
is_dynamic: false,
})
})
})
describe("unsuccessful creation with dynamic discount using an invalid iso8601 duration", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request("POST", "/admin/discounts", {
payload: {
code: "TEST",
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: "02/02/2021 13:45",
is_dynamic: true,
valid_duration: "PaMT2D",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error", () => {
expect(subject.body.message[0].message).toEqual(
`"valid_duration" must be a valid ISO 8601 duration`
)
})
})
describe("successful creation with dynamic discount", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request("POST", "/admin/discounts", {
payload: {
code: "TEST",
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: "02/02/2021 13:45",
is_dynamic: true,
valid_duration: "P1Y2M03DT04H05M",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service create", () => {
expect(DiscountServiceMock.create).toHaveBeenCalledTimes(1)
expect(DiscountServiceMock.create).toHaveBeenCalledWith({
code: "TEST",
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: new Date("02/02/2021 13:45"),
is_disabled: false,
is_dynamic: true,
valid_duration: "P1Y2M03DT04H05M",
})
})
})
describe("fails on invalid data", () => {
let subject
@@ -74,4 +163,84 @@ describe("POST /admin/discounts", () => {
expect(subject.body.message[0].message).toEqual(`"rule.type" is required`)
})
})
describe("fails on invalid date intervals", () => {
let subject
beforeAll(async () => {
subject = await request("POST", "/admin/discounts", {
payload: {
code: "TEST",
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
ends_at: "02/02/2021",
starts_at: "03/14/2021",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error", () => {
expect(subject.body.message[0].message).toEqual(
`"ends_at" must be greater than "ref:starts_at"`
)
})
})
describe("succesfully creates a dynamic discount without setting valid duration", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request("POST", "/admin/discounts", {
payload: {
code: "TEST",
is_dynamic: true,
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: "03/14/2021 14:30",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns error", () => {
expect(DiscountServiceMock.create).toHaveBeenCalledWith({
code: "TEST",
is_dynamic: true,
is_disabled: false,
rule: {
description: "Test",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: new Date("03/14/2021 14:30"),
})
})
})
})
@@ -17,6 +17,7 @@ const defaultFields = [
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
]
const defaultRelations = [
@@ -17,6 +17,7 @@ const defaultFields = [
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
]
const defaultRelations = [
@@ -17,6 +17,7 @@ const defaultFields = [
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
]
const defaultRelations = [
@@ -7,6 +7,7 @@ describe("POST /admin/discounts", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request(
"POST",
`/admin/discounts/${IdMap.getId("total10")}`,
@@ -50,4 +51,139 @@ describe("POST /admin/discounts", () => {
)
})
})
describe("unsuccessful update with dynamic discount using an invalid iso8601 duration", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request(
"POST",
`/admin/discounts/${IdMap.getId("total10")}`,
{
payload: {
code: "10TOTALOFF",
rule: {
id: "1234",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: "02/02/2021 13:45",
is_dynamic: true,
valid_duration: "PaMT2D",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error", () => {
expect(subject.body.message[0].message).toEqual(
`"valid_duration" must be a valid ISO 8601 duration`
)
})
})
describe("successful update with dynamic discount", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request(
"POST",
`/admin/discounts/${IdMap.getId("total10")}`,
{
payload: {
code: "10TOTALOFF",
rule: {
id: "1234",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: "02/02/2021 13:45",
is_dynamic: true,
valid_duration: "P1Y2M03DT04H05M",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service update", () => {
expect(DiscountServiceMock.update).toHaveBeenCalledTimes(1)
expect(DiscountServiceMock.update).toHaveBeenCalledWith(
IdMap.getId("total10"),
{
code: "10TOTALOFF",
rule: {
id: "1234",
type: "fixed",
value: 10,
allocation: "total",
},
starts_at: new Date("02/02/2021 13:45"),
is_dynamic: true,
valid_duration: "P1Y2M03DT04H05M",
}
)
})
})
describe("fails on invalid date intervals", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request(
"POST",
`/admin/discounts/${IdMap.getId("total10")}`,
{
payload: {
code: "10TOTALOFF",
rule: {
id: "1234",
type: "fixed",
value: 10,
allocation: "total",
},
ends_at: "02/02/2021",
starts_at: "03/14/2021",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error", () => {
expect(subject.body.message[0].message).toEqual(
`"ends_at" must be greater than "ref:starts_at"`
)
})
})
})
@@ -71,7 +71,13 @@ export default async (req, res) => {
.required(),
is_disabled: Validator.boolean().default(false),
starts_at: Validator.date().optional(),
ends_at: Validator.date().optional(),
ends_at: Validator.date()
.greater(Validator.ref("starts_at"))
.optional(),
valid_duration: Validator.string()
.isoDuration()
.allow(null)
.optional(),
usage_limit: Validator.number()
.positive()
.optional(),
@@ -37,9 +37,9 @@ export default async (req, res) => {
try {
const discountService = req.scope.resolve("discountService")
await discountService.createDynamicCode(discount_id, value)
const created = await discountService.createDynamicCode(discount_id, value)
const discount = await discountService.retrieve(discount_id, {
const discount = await discountService.retrieve(created.id, {
relations: ["rule", "rule.valid_for", "regions"],
})
@@ -74,6 +74,7 @@ export const defaultFields = [
"updated_at",
"deleted_at",
"metadata",
"valid_duration",
]
export const defaultRelations = [
@@ -68,7 +68,16 @@ export default async (req, res) => {
.optional(),
is_disabled: Validator.boolean().optional(),
starts_at: Validator.date().optional(),
ends_at: Validator.date().optional(),
ends_at: Validator.when("starts_at", {
not: undefined,
then: Validator.date()
.greater(Validator.ref("starts_at"))
.optional(),
otherwise: Validator.date().optional(),
}),
valid_duration: Validator.string()
.isoDuration().allow(null)
.optional(),
usage_limit: Validator.number()
.positive()
.optional(),
@@ -78,6 +87,7 @@ export default async (req, res) => {
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
@@ -22,6 +22,7 @@ import variantRoutes from "./variants"
import draftOrderRoutes from "./draft-orders"
import collectionRoutes from "./collections"
import notificationRoutes from "./notifications"
import noteRoutes from "./notes"
const route = Router()
@@ -68,6 +69,7 @@ export default (app, container, config) => {
collectionRoutes(route)
notificationRoutes(route)
returnReasonRoutes(route)
noteRoutes(route)
return app
}
@@ -0,0 +1,63 @@
import { MedusaError, Validator } from "medusa-core-utils"
/**
* @oas [post] /notes
* operationId: "PostNotes"
* summary: "Creates a Note"
* description: "Creates a Note which can be associated with any resource as required."
* requestBody:
* content:
* application/json:
* schema:
* properties:
* resource_id:
* type: string
* description: The id of the resource which the Note relates to.
* resource_type:
* type: string
* description: The type of resource which the Note relates to.
* value:
* type: string
* description: The content of the Note to create.
* tags:
* - Note
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* note:
* $ref: "#/components/schemas/note"
*
*/
export default async (req, res) => {
const schema = Validator.object().keys({
resource_id: Validator.string(),
resource_type: Validator.string(),
value: Validator.string(),
})
const userId = req.user.id || req.user.userId
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const noteService = req.scope.resolve("noteService")
const result = await noteService.create({
resource_id: value.resource_id,
resource_type: value.resource_type,
value: value.value,
author_id: userId,
})
res.status(200).json({ note: result })
} catch (err) {
throw err
}
}
@@ -0,0 +1,35 @@
/**
* @oas [delete] /notes/{id}
* operationId: "DeleteNotesNote"
* summary: "Deletes a Note"
* description: "Deletes a Note."
* parameters:
* - (path) id=* {string} The id of the Note to delete.
* tags:
* - Note
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* id:
* type: string
* description: The id of the deleted Note.
* deleted:
* type: boolean
* description: Whether or not the Note was deleted.
*/
export default async (req, res) => {
const { id } = req.params
try {
const noteService = req.scope.resolve("noteService")
await noteService.delete(id)
res.status(200).json({ id, deleted: true })
} catch (err) {
throw err
}
}
@@ -0,0 +1,31 @@
/**
* @oas [get] /notes/{id}
* operationId: "GetNoteNote"
* summary: "Get Note"
* description: "Retrieves a single note using its id"
* parameters:
* - (path) id=* {string} The id of the note to retrieve.
* tags:
* - Note
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* note:
* $ref: "#/components/schemas/note"
*/
export default async (req, res) => {
const { id } = req.params
try {
const noteService = req.scope.resolve("noteService")
const note = await noteService.retrieve(id, { relations: ["author"] })
res.status(200).json({ note })
} catch (err) {
throw err
}
}
@@ -0,0 +1,20 @@
import { Router } from "express"
import middlewares from "../../../middlewares"
const route = Router()
export default app => {
app.use("/notes", route)
route.get("/:id", middlewares.wrap(require("./get-note").default))
route.get("/", middlewares.wrap(require("./list-notes").default))
route.post("/", middlewares.wrap(require("./create-note").default))
route.post("/:id", middlewares.wrap(require("./update-note").default))
route.delete("/:id", middlewares.wrap(require("./delete-note").default))
return app
}
@@ -0,0 +1,42 @@
/**
* @oas [get] /notes
* operationId: "GetNotes"
* summary: "List Notes"
* description: "Retrieves a list of notes"
* tags:
* - Note
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* notes:
* type: array
* items:
* $ref: "#/components/schemas/note"
*/
export default async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 50
const offset = parseInt(req.query.offset) || 0
const selector = {}
if ("resource_id" in req.query) {
selector.resource_id = req.query.resource_id
}
const noteService = req.scope.resolve("noteService")
const notes = await noteService.list(selector, {
take: limit,
skip: offset,
relations: ["author"],
})
res.status(200).json({ notes })
} catch (err) {
throw err
}
}
@@ -0,0 +1,51 @@
import { MedusaError, Validator } from "medusa-core-utils"
/**
* @oas [post] /notes/{id}
* operationId: "PostNotesNote"
* summary: "Updates a Note"
* description: "Updates a Note associated with some resource"
* parameters:
* - (path) id=* {string} The id of the Note to update
* requestBody:
* content:
* application/json:
* schema:
* properties:
* value:
* type: string
* description: The updated description of the Note.
* tags:
* - Note
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* note:
* $ref: "#/components/schemas/note"
*
*/
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
value: Validator.string(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const noteService = req.scope.resolve("noteService")
const result = await noteService.update(id, value.value)
res.status(200).json({ note: result })
} catch (err) {
throw err
}
}
@@ -48,6 +48,9 @@ import { defaultFields, defaultRelations } from "./"
* no_notification:
* description: If set to true no notification will be send related to this Swap.
* type: boolean
* allow_backorder:
* description: If true, swaps can be completed with items out of stock
* type: boolean
* tags:
* - Order
* responses:
@@ -89,6 +92,7 @@ export default async (req, res) => {
quantity: Validator.number().required(),
}),
no_notification: Validator.boolean().optional(),
allow_backorder: Validator.boolean().default(true),
})
const { value, error } = schema.validate(req.body)
@@ -148,6 +152,7 @@ export default async (req, res) => {
{
idempotency_key: idempotencyKey.idempotency_key,
no_notification: value.no_notification,
allow_backorder: value.allow_backorder,
}
)
@@ -110,6 +110,7 @@ describe("POST /admin/products", () => {
description: "Test Description",
tags: [{ id: "test", value: "test" }],
handle: "test-product",
status: "draft",
is_giftcard: false,
options: [{ title: "Denominations" }],
profile_id: IdMap.getId("default_shipping_profile"),
@@ -170,6 +171,7 @@ describe("POST /admin/products", () => {
options: [{ title: "Denominations" }],
handle: "test-gift-card",
is_giftcard: true,
status: "draft",
profile_id: IdMap.getId("giftCardProfile"),
})
})
@@ -193,6 +193,9 @@ export default async (req, res) => {
.optional(),
thumbnail: Validator.string().optional(),
handle: Validator.string().optional(),
status: Validator.string()
.valid("proposed", "draft", "published", "rejected")
.default("draft"),
type: Validator.object()
.keys({
id: Validator.string().optional(),
@@ -1,4 +1,5 @@
import _ from "lodash"
import { MedusaError, Validator } from "medusa-core-utils"
import { defaultFields, defaultRelations } from "./"
/**
@@ -56,6 +57,20 @@ export default async (req, res) => {
selector.is_giftcard = req.query.is_giftcard === "true"
}
if ("status" in req.query) {
const schema = Validator.array()
.items(
Validator.string().valid("proposed", "draft", "published", "rejected")
)
.single()
const { value, error } = schema.validate(req.query.status)
if (value && !error) {
selector.status = value
}
}
const listConfig = {
select: includeFields.length ? includeFields : defaultFields,
relations: expandFields.length ? expandFields : defaultRelations,
@@ -193,6 +193,12 @@ export default async (req, res) => {
.allow(null, ""),
description: Validator.string().optional(),
discountable: Validator.boolean().optional(),
status: Validator.string().valid(
"proposed",
"draft",
"published",
"rejected"
),
type: Validator.object()
.keys({
id: Validator.string().optional(),
@@ -39,6 +39,7 @@ export default async (req, res) => {
const schema = Validator.object().keys({
value: Validator.string().required(),
label: Validator.string().required(),
parent_return_reason_id: Validator.string().optional(),
description: Validator.string()
.optional()
.allow(""),
@@ -0,0 +1,41 @@
/**
* @oas [delete] /return-reason/{id}
* operationId: "DeleteReturnReason"
* summary: "Delete a return reason"
* description: "Deletes a return reason."
* parameters:
* - (path) id=* {string} The id of the return reason
* tags:
* - Return Reason
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* id:
* type: string
* description: The id of the deleted return reason
* object:
* type: string
* description: The type of the object that was deleted.
* deleted:
* type: boolean
*/
export default async (req, res) => {
const { id } = req.params
try {
const returnReasonService = req.scope.resolve("returnReasonService")
await returnReasonService.delete(id)
res.json({
id: id,
object: "return_reason",
deleted: true,
})
} catch (err) {
throw err
}
}
@@ -26,6 +26,11 @@ export default app => {
*/
route.post("/:id", middlewares.wrap(require("./update-reason").default))
/**
* Delete a reason
*/
route.delete("/:id", middlewares.wrap(require("./delete-reason").default))
return app
}
@@ -33,10 +38,14 @@ export const defaultFields = [
"id",
"value",
"label",
"parent_return_reason_id",
"description",
"created_at",
"updated_at",
"deleted_at",
]
export const defaultRelations = []
export const defaultRelations = [
"parent_return_reason",
"return_reason_children",
]
@@ -24,7 +24,7 @@ export default async (req, res) => {
try {
const returnReasonService = req.scope.resolve("returnReasonService")
const query = {}
const query = { parent_return_reason_id: null }
const data = await returnReasonService.list(query, {
select: defaultFields,
relations: defaultRelations,
@@ -42,6 +42,7 @@ export default async (req, res) => {
const schema = Validator.object().keys({
label: Validator.string().optional(),
parent_return_reason_id: Validator.string().optional(),
description: Validator.string()
.optional()
.allow(""),
@@ -138,18 +138,36 @@ export default async (req, res) => {
// If cart is part of swap, we register swap as complete
switch (cart.type) {
case "swap": {
const swapId = cart.metadata?.swap_id
let swap = await swapService
.withTransaction(manager)
.registerCartCompletion(swapId)
try {
const swapId = cart.metadata?.swap_id
let swap = await swapService
.withTransaction(manager)
.registerCartCompletion(swapId)
swap = await swapService
.withTransaction(manager)
.retrieve(swap.id, { relations: ["shipping_address"] })
swap = await swapService
.withTransaction(manager)
.retrieve(swap.id, { relations: ["shipping_address"] })
return {
response_code: 200,
response_body: { data: swap, type: "swap" },
return {
response_code: 200,
response_body: { data: swap, type: "swap" },
}
} catch (error) {
if (
error &&
error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY
) {
return {
response_code: 409,
response_body: {
message: error.message,
type: error.type,
code: error.code,
},
}
} else {
throw error
}
}
}
// case "payment_link":
@@ -11,6 +11,7 @@ describe("POST /store/customers/:id", () => {
payload: {
first_name: "LeBron",
last_name: "James",
email: "test@email.com",
},
clientSession: {
jwt: {
@@ -31,6 +32,7 @@ describe("POST /store/customers/:id", () => {
{
first_name: "LeBron",
last_name: "James",
email: "test@email.com",
}
)
})
@@ -1,3 +1,4 @@
import { optional } from "joi"
import { Validator, MedusaError } from "medusa-core-utils"
import { defaultRelations, defaultFields } from "./"
@@ -27,6 +28,9 @@ import { defaultRelations, defaultFields } from "./"
* phone:
* description: "The Customer's phone number."
* type: string
* email:
* description: "The email of the customer."
* type: string
* metadata:
* description: "Metadata about the customer."
* type: object
@@ -51,6 +55,7 @@ export default async (req, res) => {
last_name: Validator.string().optional(),
password: Validator.string().optional(),
phone: Validator.string().optional(),
email: Validator.string().optional(),
metadata: Validator.object().optional(),
})
@@ -18,7 +18,7 @@ describe("GET /store/products", () => {
it("calls get product from productSerice", () => {
expect(ProductServiceMock.list).toHaveBeenCalledTimes(1)
expect(ProductServiceMock.list).toHaveBeenCalledWith(
{},
{ status: ["published"] },
{ relations: defaultRelations, skip: 0, take: 100 }
)
})
@@ -43,7 +43,7 @@ describe("GET /store/products", () => {
it("calls list from productSerice", () => {
expect(ProductServiceMock.list).toHaveBeenCalledTimes(1)
expect(ProductServiceMock.list).toHaveBeenCalledWith(
{ is_giftcard: true },
{ is_giftcard: true, status: ["published"] },
{ relations: defaultRelations, skip: 0, take: 100 }
)
})
@@ -1,3 +1,4 @@
import { MedusaError, Validator } from "medusa-core-utils"
import { defaultRelations } from "."
/**
@@ -41,6 +42,8 @@ export default async (req, res) => {
selector.is_giftcard = req.query.is_giftcard === "true"
}
selector.status = ["published"]
const listConfig = {
relations: defaultRelations,
skip: offset,
@@ -23,10 +23,14 @@ export const defaultFields = [
"id",
"value",
"label",
"parent_return_reason_id",
"description",
"created_at",
"updated_at",
"deleted_at",
]
export const defaultRelations = []
export const defaultRelations = [
"parent_return_reason",
"return_reason_children",
]
@@ -24,7 +24,7 @@ export default async (req, res) => {
try {
const returnReasonService = req.scope.resolve("returnReasonService")
const query = {}
const query = { parent_return_reason_id: null}
const data = await returnReasonService.list(query, {
select: defaultFields,
relations: defaultRelations,
@@ -28,10 +28,7 @@ describe("Get variant by id", () => {
describe("get variant with prices", () => {
let subject
beforeAll(async () => {
subject = await request(
"GET",
`/store/variants/${IdMap.getId("variantWithPrices")}`
)
subject = await request("GET", `/store/variants/variant_with_prices`)
})
it("successfully retrieves variants with prices", async () => {
expect(subject.status).toEqual(200)
+3
View File
@@ -114,6 +114,9 @@ const t = async function({ directory, migrate, seedFile }) {
const variants = p.variants
delete p.variants
// default to the products being visible
p.status = p.status || "published"
p.profile_id = defaultProfile.id
if (p.is_giftcard) {
p.profile_id = gcProfile.id
+1
View File
@@ -45,4 +45,5 @@ export { Swap } from "./models/swap"
export { User } from "./models/user"
export { DraftOrder } from "./models/draft-order"
export { ReturnReason } from "./models/return-reason"
export { Note } from "./models/note"
export { RMAShippingOption } from "./models/rma-shipping-option"
@@ -0,0 +1,24 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class allowBackorderSwaps1630505790603 implements MigrationInterface {
name = 'allowBackorderSwaps1630505790603'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "swap" ADD "allow_backorder" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "cart" ADD "payment_authorized_at" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TYPE "swap_payment_status_enum" RENAME TO "swap_payment_status_enum_old"`);
await queryRunner.query(`CREATE TYPE "swap_payment_status_enum" AS ENUM('not_paid', 'awaiting', 'captured', 'confirmed', 'canceled', 'difference_refunded', 'partially_refunded', 'refunded', 'requires_action')`);
await queryRunner.query(`ALTER TABLE "swap" ALTER COLUMN "payment_status" TYPE "swap_payment_status_enum" USING "payment_status"::"text"::"swap_payment_status_enum"`);
await queryRunner.query(`DROP TYPE "swap_payment_status_enum_old"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TYPE "swap_payment_status_enum_old" AS ENUM('not_paid', 'awaiting', 'captured', 'canceled', 'difference_refunded', 'partially_refunded', 'refunded', 'requires_action')`);
await queryRunner.query(`ALTER TABLE "swap" ALTER COLUMN "payment_status" TYPE "swap_payment_status_enum_old" USING "payment_status"::"text"::"swap_payment_status_enum_old"`);
await queryRunner.query(`DROP TYPE "swap_payment_status_enum"`);
await queryRunner.query(`ALTER TYPE "swap_payment_status_enum_old" RENAME TO "swap_payment_status_enum"`);
await queryRunner.query(`ALTER TABLE "cart" DROP COLUMN "payment_authorized_at"`);
await queryRunner.query(`ALTER TABLE "swap" DROP COLUMN "allow_backorder"`);
}
}
@@ -0,0 +1,14 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class validDurationForDiscount1631696624528 implements MigrationInterface {
name = 'validDurationForDiscount1631696624528'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "discount" ADD "valid_duration" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "discount" DROP COLUMN "valid_duration"`);
}
}
@@ -0,0 +1,20 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class nestedReturnReasons1631800727788 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "return_reason" ADD "parent_return_reason_id" character varying`
)
await queryRunner.query(`ALTER TABLE "return_reason" ADD CONSTRAINT "FK_2250c5d9e975987ab212f61a657" FOREIGN KEY ("parent_return_reason_id") REFERENCES "return_reason"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "return_reason" DROP COLUMN "parent_return_reason_id"`
)
}
}
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class statusOnProduct1631864388026 implements MigrationInterface {
name = "statusOnProduct1631864388026"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "product_status_enum" AS ENUM('draft', 'proposed', 'published', 'rejected')`
)
await queryRunner.query(
`ALTER TABLE "product" ADD "status" "product_status_enum" `
)
await queryRunner.query(
`UPDATE "product" SET "status" = 'published' WHERE "status" IS NULL`
)
await queryRunner.query(
`ALTER TABLE "product" ALTER COLUMN "status" SET NOT NULL`
)
await queryRunner.query(
`ALTER TABLE "product" ALTER COLUMN "status" SET DEFAULT 'draft'`
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "product" DROP COLUMN "status"`)
await queryRunner.query(`DROP TYPE "product_status_enum"`)
}
}
@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class addNotes1632220294687 implements MigrationInterface {
name = "addNotes1632220294687"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "note" ("id" character varying NOT NULL, "value" character varying NOT NULL, "resource_type" character varying NOT NULL, "resource_id" character varying NOT NULL, "author_id" 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_96d0c172a4fba276b1bbed43058" PRIMARY KEY ("id"))`
)
await queryRunner.query(
`CREATE INDEX "IDX_f74980b411cf94af523a72af7d" ON "note" ("resource_type") `
)
await queryRunner.query(
`CREATE INDEX "IDX_3287f98befad26c3a7dab088cf" ON "note" ("resource_id") `
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_3287f98befad26c3a7dab088cf"`)
await queryRunner.query(`DROP INDEX "IDX_f74980b411cf94af523a72af7d"`)
await queryRunner.query(`DROP TABLE "note"`)
}
}
+3
View File
@@ -231,6 +231,9 @@ export class Cart {
@Column({ type: resolveDbType("timestamptz"), nullable: true })
completed_at: Date
@Column({ type: resolveDbType("timestamptz"), nullable: true })
payment_authorized_at: Date
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
+1 -1
View File
@@ -46,7 +46,7 @@ export class Customer {
)
shipping_addresses: Address[]
@Column({ nullable: true })
@Column({ nullable: true, select: false })
password_hash: string
@Column({ nullable: true })
+4 -1
View File
@@ -44,7 +44,7 @@ export class Discount {
@Column({ nullable: true })
parent_discount_id: string
@ManyToOne(() => Discount)
@JoinColumn({ name: "parent_discount_id" })
parent_discount: Discount
@@ -58,6 +58,9 @@ export class Discount {
@Column({ type: resolveDbType("timestamptz"), nullable: true })
ends_at: Date
@Column({ nullable: true })
valid_duration: string
@ManyToMany(() => Region, { cascade: true })
@JoinTable({
name: "discount_regions",
+96
View File
@@ -0,0 +1,96 @@
import {
Entity,
BeforeInsert,
Column,
DeleteDateColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
JoinColumn,
PrimaryColumn,
ManyToOne,
} from "typeorm"
import { ulid } from "ulid"
import { User } from "./user"
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
@Entity()
export class Note {
@PrimaryColumn()
id: string
@Column()
value: string
@Index()
@Column()
resource_type: string
@Index()
@Column()
resource_id: string
@Column({ nullable: true })
author_id: string
@ManyToOne(() => User)
@JoinColumn({ name: "author_id" })
author: User
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
updated_at: Date
@DeleteDateColumn({ type: resolveDbType("timestamptz") })
deleted_at: Date
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
@BeforeInsert()
private beforeInsert() {
if (this.id) return
const id = ulid()
this.id = `note_${id}`
}
}
/**
* @schema note
* title: "Note"
* description: "Notes are elements which we can use in association with different resources to allow users to describe additional information in relation to these."
* x-resourceId: note
* properties:
* id:
* description: "The id of the Note. This value will be prefixed by `note_`."
* type: string
* resource_type:
* description: "The type of resource that the Note refers to."
* type: string
* resource_id:
* description: "The id of the resource that the Note refers to."
* type: string
* value:
* description: "The contents of the note."
* type: string
* author:
* description: "The author of the note."
* type: User
* created_at:
* description: "The date with timezone at which the resource was created."
* type: string
* format: date-time
* updated_at:
* description: "The date with timezone at which the resource was last updated."
* type: string
* format: date-time
* deleted_at:
* description: "The date with timezone at which the resource was deleted."
* type: string
* format: date-time
* metadata:
* description: "An optional key-value map with additional information."
* type: object
*/
+10
View File
@@ -26,6 +26,13 @@ import { ProductVariant } from "./product-variant"
import { ShippingProfile } from "./shipping-profile"
import _ from "lodash"
export enum Status {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
REJECTED = "rejected",
}
@Entity()
export class Product {
@PrimaryColumn()
@@ -47,6 +54,9 @@ export class Product {
@Column({ default: false })
is_giftcard: boolean
@DbAwareColumn({ type: "enum", enum: Status, default: "draft" })
status: Status
@ManyToMany(() => Image, { cascade: ["insert"] })
@JoinTable({
name: "product_images",
@@ -7,6 +7,9 @@ import {
CreateDateColumn,
UpdateDateColumn,
PrimaryColumn,
ManyToOne,
OneToMany,
JoinColumn
} from "typeorm"
import { ulid } from "ulid"
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
@@ -26,6 +29,21 @@ export class ReturnReason {
@Column({ nullable: true })
description: string
@Column({ nullable: true })
parent_return_reason_id: string
@ManyToOne(() => ReturnReason, {cascade: ['soft-remove']}
)
@JoinColumn({ name: "parent_return_reason_id" })
parent_return_reason: ReturnReason
@OneToMany(
() => ReturnReason,
return_reason => return_reason.parent_return_reason,
{ cascade: ["insert", 'soft-remove'] }
)
return_reason_children: ReturnReason[]
@CreateDateColumn({ type: resolveDbType("timestamptz") })
created_at: Date
+7
View File
@@ -39,6 +39,7 @@ export enum PaymentStatus {
NOT_PAID = "not_paid",
AWAITING = "awaiting",
CAPTURED = "captured",
CONFIRMED = "confirmed",
CANCELED = "canceled",
DIFFERENCE_REFUNDED = "difference_refunded",
PARTIALLY_REFUNDED = "partially_refunded",
@@ -145,6 +146,9 @@ export class Swap {
@Column({ type: "boolean", nullable: true })
no_notification: Boolean
@Column({ type: "boolean", default: false })
allow_backorder: Boolean
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
@@ -227,6 +231,9 @@ export class Swap {
* cart_id:
* description: "The id of the Cart that the Customer will use to confirm the Swap."
* type: string
* allow_backorder:
* description: "If true, swaps can be completed with items out of stock"
* type: boolean
* confirmed_at:
* description: "The date with timezone at which the Swap was confirmed by the Customer."
* type: string
+1 -1
View File
@@ -26,7 +26,7 @@ export class User {
@Column({ nullable: true })
last_name: string
@Column({ nullable: true })
@Column({ nullable: true, select: false })
password_hash: string
@Column({ nullable: true })
+5
View File
@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { Note } from "../models/note"
@EntityRepository(Note)
export class NoteRepository extends Repository<Note> {}
@@ -223,7 +223,7 @@ export const ProductVariantServiceMock = {
if (variantId === "4") {
return Promise.resolve(variant4)
}
if (variantId === IdMap.getId("variantWithPrices")) {
if (variantId === "variant_with_prices") {
return Promise.resolve(variantWithPrices)
}
if (variantId === IdMap.getId("validId")) {
@@ -1083,6 +1083,9 @@ describe("CartService", () => {
id: "region",
countries: [{ iso_2: "us" }],
},
shipping_address: {
country_code: "us",
},
items: [IdMap.getId("testitem"), null],
payment_session: null,
payment_sessions: [],
@@ -1538,6 +1541,12 @@ describe("CartService", () => {
})
describe("applyDiscount", () => {
const getOffsetDate = offset => {
const date = new Date()
date.setDate(date.getDate() + offset)
return date
}
const cartRepository = MockRepository({
findOneWithRelations: (rels, q) => {
if (q.where.id === IdMap.getId("with-d")) {
@@ -1605,6 +1614,69 @@ describe("CartService", () => {
},
})
}
if (code === "EarlyDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(1),
ends_at: getOffsetDate(10),
})
}
if (code === "ExpiredDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
ends_at: getOffsetDate(-1),
starts_at: getOffsetDate(-10),
})
}
if (code === "ExpiredDynamicDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
is_dynamic: true,
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(-1),
})
}
if (code === "ExpiredDynamicDiscountEndDate") {
return Promise.resolve({
id: IdMap.getId("10off"),
is_dynamic: true,
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(-3),
valid_duration: "P0Y0M1D",
})
}
if (code === "ValidDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(10),
})
}
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
@@ -1755,6 +1827,76 @@ describe("CartService", () => {
})
})
it("successfully applies valid discount with expiration date to cart", async () => {
await cartService.update(IdMap.getId("fr-cart"), {
discounts: [
{
code: "ValidDiscount",
},
],
})
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
expect(eventBusService.emit).toHaveBeenCalledWith(
"cart.updated",
expect.any(Object)
)
expect(cartRepository.save).toHaveBeenCalledTimes(1)
expect(cartRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("cart"),
region_id: IdMap.getId("good"),
discount_total: 0,
shipping_total: 0,
subtotal: 0,
tax_total: 0,
total: 0,
discounts: [
{
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: expect.any(Date),
ends_at: expect.any(Date),
},
],
})
})
it("throws if discount is applied too before it's valid", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "EarlyDiscount" }],
})
).rejects.toThrow("Discount is not valid yet")
})
it("throws if expired discount is applied", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "ExpiredDiscount" }],
})
).rejects.toThrow("Discount is expired")
})
it("throws if expired dynamic discount is applied", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "ExpiredDynamicDiscount" }],
})
).rejects.toThrow("Discount is expired")
})
it("throws if expired dynamic discount is applied after ends_at", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "ExpiredDynamicDiscountEndDate" }],
})
).rejects.toThrow("Discount is expired")
})
it("throws if discount limit is reached", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
@@ -1,5 +1,4 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import { add } from "winston"
import CustomerService from "../customer"
const eventBusService = {
@@ -55,6 +55,74 @@ describe("DiscountService", () => {
expect(discountRepository.save).toHaveBeenCalledTimes(1)
})
it("successfully creates discount with start and end dates", async () => {
await discountService.create({
code: "test",
rule: {
type: "percentage",
allocation: "total",
value: 20,
},
starts_at: new Date("03/14/2021"),
ends_at: new Date("03/15/2021"),
regions: [IdMap.getId("france")],
})
expect(discountRuleRepository.create).toHaveBeenCalledTimes(1)
expect(discountRuleRepository.create).toHaveBeenCalledWith({
type: "percentage",
allocation: "total",
value: 20,
})
expect(discountRuleRepository.save).toHaveBeenCalledTimes(1)
expect(discountRepository.create).toHaveBeenCalledTimes(1)
expect(discountRepository.create).toHaveBeenCalledWith({
code: "TEST",
rule: expect.anything(),
regions: [{ id: IdMap.getId("france") }],
starts_at: new Date("03/14/2021"),
ends_at: new Date("03/15/2021"),
})
expect(discountRepository.save).toHaveBeenCalledTimes(1)
})
it("successfully creates discount with start date and a valid duration", async () => {
await discountService.create({
code: "test",
rule: {
type: "percentage",
allocation: "total",
value: 20,
},
starts_at: new Date("03/14/2021"),
valid_duration: "P0Y0M1D",
regions: [IdMap.getId("france")],
})
expect(discountRuleRepository.create).toHaveBeenCalledTimes(1)
expect(discountRuleRepository.create).toHaveBeenCalledWith({
type: "percentage",
allocation: "total",
value: 20,
})
expect(discountRuleRepository.save).toHaveBeenCalledTimes(1)
expect(discountRepository.create).toHaveBeenCalledTimes(1)
expect(discountRepository.create).toHaveBeenCalledWith({
code: "TEST",
rule: expect.anything(),
regions: [{ id: IdMap.getId("france") }],
starts_at: new Date("03/14/2021"),
valid_duration: "P0Y0M1D",
})
expect(discountRepository.save).toHaveBeenCalledTimes(1)
})
})
describe("retrieve", () => {
@@ -376,6 +444,7 @@ describe("DiscountService", () => {
id: "parent",
is_dynamic: true,
rule_id: "parent_rule",
valid_duration: "P1Y",
}),
})
@@ -412,6 +481,8 @@ describe("DiscountService", () => {
rule_id: "parent_rule",
parent_discount_id: "parent",
code: "HI",
usage_limit: undefined,
ends_at: expect.any(Date),
})
})
})
@@ -494,9 +565,6 @@ describe("DiscountService", () => {
expect(discountRepository.findAndCount).toHaveBeenCalledTimes(1)
expect(discountRepository.findAndCount).toHaveBeenCalledWith({
join: {
alias: "discount",
},
where: expect.anything(),
skip: 0,
take: 50,
@@ -0,0 +1,202 @@
import NoteService from "../note"
import { MockManager, MockRepository, IdMap } from "medusa-test-utils"
import { EventBusServiceMock } from "../__mocks__/event-bus"
describe("NoteService", () => {
describe("list", () => {
const noteRepo = MockRepository({
find: q => {
return Promise.resolve([
{ id: IdMap.getId("note"), value: "some note" },
])
},
})
const noteService = new NoteService({
manager: MockManager,
noteRepository: noteRepo,
})
beforeAll(async () => {
jest.clearAllMocks()
})
it("calls note model functions", async () => {
await noteService.list(
{ resource_id: IdMap.getId("note") },
{
relations: ["author"],
}
)
expect(noteRepo.find).toHaveBeenCalledTimes(1)
expect(noteRepo.find).toHaveBeenCalledWith({
where: {
resource_id: IdMap.getId("note"),
},
relations: ["author"],
})
})
})
describe("retrieve", () => {
const noteRepo = MockRepository({
findOne: q => {
switch (q.where.id) {
case IdMap.getId("note"):
return Promise.resolve({
id: IdMap.getId("note"),
value: "some note",
})
default:
return Promise.resolve()
}
},
})
const noteService = new NoteService({
manager: MockManager,
noteRepository: noteRepo,
})
beforeAll(async () => {
jest.clearAllMocks()
})
it("calls note model functions", async () => {
await noteService.retrieve(IdMap.getId("note"), { relations: ["author"] })
expect(noteRepo.findOne).toHaveBeenCalledTimes(1)
expect(noteRepo.findOne).toHaveBeenCalledWith({
where: { id: IdMap.getId("note") },
relations: ["author"],
})
})
it("fails when note is not found", async () => {
await expect(
noteService.retrieve(IdMap.getId("not-existing"))
).rejects.toThrow(
`Note with id: ${IdMap.getId("not-existing")} was not found.`
)
})
})
describe("create", () => {
const note = {
id: IdMap.getId("note"),
author_id: IdMap.getId("user"),
}
const noteRepo = MockRepository({
create: f => Promise.resolve(note),
save: f => Promise.resolve(note),
})
const noteService = new NoteService({
manager: MockManager,
noteRepository: noteRepo,
eventBusService: EventBusServiceMock,
})
beforeAll(async () => {
jest.clearAllMocks()
})
it("calls note model functions", async () => {
await noteService.create({
resource_id: IdMap.getId("resource-id"),
resource_type: "type",
value: "my note",
author_id: IdMap.getId("user"),
})
expect(noteRepo.create).toHaveBeenCalledTimes(1)
expect(noteRepo.create).toHaveBeenCalledWith({
resource_id: IdMap.getId("resource-id"),
resource_type: "type",
value: "my note",
author_id: IdMap.getId("user"),
metadata: {},
})
expect(noteRepo.save).toHaveBeenCalledTimes(1)
expect(noteRepo.save).toHaveBeenCalledWith({
id: IdMap.getId("note"),
author_id: IdMap.getId("user"),
})
expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1)
expect(EventBusServiceMock.emit).toHaveBeenCalledWith(
NoteService.Events.CREATED,
{ id: IdMap.getId("note") }
)
})
})
describe("update", () => {
const note = { id: IdMap.getId("note") }
const noteRepo = MockRepository({
findOne: f => Promise.resolve(note),
save: f => Promise.resolve(note),
})
const noteService = new NoteService({
manager: MockManager,
noteRepository: noteRepo,
eventBusService: EventBusServiceMock,
})
beforeAll(async () => {
jest.clearAllMocks()
})
it("calls note model functions", async () => {
await noteService.update(IdMap.getId("note"), "new note")
expect(noteRepo.save).toHaveBeenCalledTimes(1)
expect(noteRepo.save).toHaveBeenCalledWith({
...note,
value: "new note",
})
expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1)
expect(EventBusServiceMock.emit).toHaveBeenCalledWith(
NoteService.Events.UPDATED,
{ id: IdMap.getId("note") }
)
})
})
describe("delete", () => {
const note = { id: IdMap.getId("note") }
const noteRepo = MockRepository({
softRemove: f => Promise.resolve(),
findOne: f => Promise.resolve(note),
})
const noteService = new NoteService({
manager: MockManager,
noteRepository: noteRepo,
eventBusService: EventBusServiceMock,
})
beforeAll(async () => {
jest.clearAllMocks()
})
it("calls note model functions", async () => {
await noteService.delete(IdMap.getId("note"))
expect(noteRepo.softRemove).toHaveBeenCalledTimes(1)
expect(noteRepo.softRemove).toHaveBeenCalledWith(note)
expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1)
expect(EventBusServiceMock.emit).toHaveBeenCalledWith(
NoteService.Events.DELETED,
{ id: IdMap.getId("note") }
)
})
})
})
@@ -203,6 +203,7 @@ describe("OrderService", () => {
}
orderService.cartService_.retrieve = jest.fn(() => Promise.resolve(cart))
orderService.cartService_.update = jest.fn(() => Promise.resolve())
await orderService.createFromCart("cart_id")
const order = {
@@ -305,6 +306,7 @@ describe("OrderService", () => {
orderService.cartService_.retrieve = () => {
return Promise.resolve(cart)
}
orderService.cartService_.update = () => Promise.resolve()
await orderService.createFromCart("cart_id")
const order = {
@@ -453,6 +455,7 @@ describe("OrderService", () => {
total: 100,
}
orderService.cartService_.retrieve = () => Promise.resolve(cart)
orderService.cartService_.update = () => Promise.resolve()
const res = orderService.createFromCart(cart)
await expect(res).rejects.toThrow(
"Variant with id: variant-1 does not have the required inventory"
@@ -199,6 +199,12 @@ describe("ProductService", () => {
variants: [{ id: IdMap.getId("green"), title: "Green" }],
})
}
if (query.where.id === "prod_status") {
return Promise.resolve({
id: "prod_status",
status: "draft",
})
}
if (query.where.id === "123") {
return undefined
}
@@ -290,6 +296,18 @@ describe("ProductService", () => {
expect(productRepository.save).toHaveBeenCalledTimes(1)
})
it("successfully updates product status", async () => {
await productService.update(IdMap.getId("ironman"), {
status: "published",
})
expect(productRepository.save).toHaveBeenCalledTimes(1)
expect(productRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("ironman"),
status: "published",
})
})
it("successfully updates product", async () => {
await productService.update(IdMap.getId("ironman"), {
title: "Full suit",
@@ -272,11 +272,7 @@ describe("ShippingOptionService", () => {
amount: 10,
})
expect(shippingOptionRequirementRepository.create).toBeCalledTimes(1)
expect(shippingOptionRequirementRepository.create).toBeCalledWith({
type: "max_subtotal",
amount: 10,
})
expect(shippingOptionRequirementRepository.create).toBeCalledTimes(0)
expect(shippingOptionRepository.save).toBeCalledTimes(1)
expect(shippingOptionRepository.save).toBeCalledWith({
@@ -747,6 +747,13 @@ describe("SwapService", () => {
},
}
const cartService = {
update: jest.fn(),
withTransaction: function() {
return this
},
}
const swapRepo = MockRepository({
findOneWithRelations: () => Promise.resolve(existing),
})
@@ -758,6 +765,7 @@ describe("SwapService", () => {
lineItemService,
eventBusService,
fulfillmentService,
cartService,
})
it("creates a shipment", async () => {
@@ -837,6 +845,15 @@ describe("SwapService", () => {
},
}
const cartService = {
update: () => {
return Promise.resolve()
},
withTransaction: function() {
return this
},
}
const paymentProviderService = {
getStatus: jest.fn(() => {
return Promise.resolve("authorized")
@@ -844,6 +861,9 @@ describe("SwapService", () => {
updatePayment: jest.fn(() => {
return Promise.resolve()
}),
cancelPayment: jest.fn(() => {
return Promise.resolve()
}),
withTransaction: function() {
return this
},
@@ -878,6 +898,7 @@ describe("SwapService", () => {
eventBusService,
swapRepository: swapRepo,
totalsService,
cartService,
paymentProviderService,
eventBusService,
shippingOptionService,
@@ -939,6 +960,7 @@ describe("SwapService", () => {
eventBusService,
swapRepository: swapRepo,
totalsService,
cartService,
paymentProviderService,
eventBusService,
shippingOptionService,
+17 -5
View File
@@ -76,12 +76,17 @@ class AuthService extends BaseService {
*/
async authenticate(email, password) {
try {
const user = await this.userService_.retrieveByEmail(email)
const userPasswordHash = await this.userService_.retrieveByEmail(email, {
select: ["password_hash"],
})
const passwordsMatch = await this.comparePassword_(
password,
user.password_hash
userPasswordHash.password_hash
)
if (passwordsMatch) {
const user = await this.userService_.retrieveByEmail(email)
return {
success: true,
user,
@@ -113,8 +118,13 @@ class AuthService extends BaseService {
*/
async authenticateCustomer(email, password) {
try {
const customer = await this.customerService_.retrieveByEmail(email)
if (!customer.password_hash) {
const customerPasswordHash = await this.customerService_.retrieveByEmail(
email,
{
select: ["password_hash"],
}
)
if (!customerPasswordHash.password_hash) {
return {
success: false,
error: "Invalid email or password",
@@ -123,9 +133,11 @@ class AuthService extends BaseService {
const passwordsMatch = await this.comparePassword_(
password,
customer.password_hash
customerPasswordHash.password_hash
)
if (passwordsMatch) {
const customer = await this.customerService_.retrieveByEmail(email)
return {
success: true,
customer,
+34 -4
View File
@@ -683,6 +683,14 @@ class CartService extends BaseService {
}
}
if ("completed_at" in update) {
cart.completed_at = update.completed_at
}
if ("payment_authorized_at" in update) {
cart.payment_authorized_at = update.payment_authorized_at
}
const result = await cartRepo.save(cart)
if ("email" in update || "customer_id" in update) {
@@ -880,6 +888,21 @@ class CartService extends BaseService {
)
}
const today = new Date()
if (discount.starts_at > today) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is not valid yet"
)
}
if (discount.ends_at && discount.ends_at < today) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is expired"
)
}
let regions = discount.regions
if (discount.parent_discount_id) {
const parent = await this.discountService_.retrieve(
@@ -1028,7 +1051,7 @@ class CartService extends BaseService {
// If cart total is 0, we don't perform anything payment related
if (cart.total <= 0) {
cart.completed_at = new Date()
cart.payment_authorized_at = new Date()
return cartRepository.save(cart)
}
@@ -1047,7 +1070,7 @@ class CartService extends BaseService {
.createPayment(freshCart)
freshCart.payment = payment
freshCart.completed_at = new Date()
freshCart.payment_authorized_at = new Date()
}
const updated = await cartRepository.save(freshCart)
@@ -1386,7 +1409,7 @@ class CartService extends BaseService {
* @return {Promise} the result of the update operation
*/
async setRegion_(cart, regionId, countryCode) {
if (cart.completed_at) {
if (cart.completed_at || cart.payment_authorized_at) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Cannot change the region of a completed cart"
@@ -1472,7 +1495,7 @@ class CartService extends BaseService {
}
}
await addrRepo.save(updated)
await this.updateShippingAddress_(cart, updated, addrRepo)
}
// Shipping methods are determined by region so the user needs to find a
@@ -1528,6 +1551,13 @@ class CartService extends BaseService {
)
}
if (cart.payment_authorized_at) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't delete a cart with an authorized payment"
)
}
const cartRepo = manager.getCustomRepository(this.cartRepository_)
return cartRepo.remove(cartId)
})
+7 -11
View File
@@ -3,7 +3,7 @@ import Scrypt from "scrypt-kdf"
import _ from "lodash"
import { Validator, MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { Brackets } from "typeorm"
import { Brackets, ILike } from "typeorm"
/**
* Provides layer to manipulate customers.
@@ -149,9 +149,9 @@ class CustomerService extends BaseService {
qb.andWhere(
new Brackets(qb => {
qb.where(`email ILIKE :q`, { q: `%${q}%` })
.orWhere(`first_name ILIKE :q`, { q: `%${q}%` })
.orWhere(`last_name ILIKE :q`, { q: `%${q}%` })
qb.where({ email: ILike(`%${q}%`) })
.orWhere({ first_name: ILike(`%${q}%`) })
.orWhere({ last_name: ILike(`%${q}%`) })
})
)
}
@@ -183,18 +183,14 @@ class CustomerService extends BaseService {
delete where.first_name
delete where.last_name
query.join = {
alias: "customer",
}
query.where = qb => {
qb.where(where)
qb.andWhere(
new Brackets(qb => {
qb.where(`customer.first_name ILIKE :q`, { q: `%${q}%` })
.orWhere(`customer.last_name ILIKE :q`, { q: `%${q}%` })
.orWhere(`customer.email ILIKE :q`, { q: `%${q}%` })
qb.where({ email: ILike(`%${q}%`) })
.orWhere({ first_name: ILike(`%${q}%`) })
.orWhere({ last_name: ILike(`%${q}%`) })
})
)
}
+20 -6
View File
@@ -2,7 +2,9 @@ import _ from "lodash"
import randomize from "randomatic"
import { BaseService } from "medusa-interfaces"
import { Validator, MedusaError } from "medusa-core-utils"
import { Brackets } from "typeorm"
import { MedusaErrorCodes } from "medusa-core-utils/dist/errors"
import { parse, toSeconds } from "iso8601-duration"
import { Brackets, ILike } from "typeorm"
/**
* Provides layer to manipulate discounts.
@@ -151,16 +153,12 @@ class DiscountService extends BaseService {
delete where.code
query.join = {
alias: "discount",
}
query.where = qb => {
qb.where(where)
qb.andWhere(
new Brackets(qb => {
qb.where(`discount.code ILIKE :q`, { q: `%${q}%` })
qb.where({ code: ILike(`%${q}%`) })
})
)
}
@@ -274,6 +272,15 @@ class DiscountService extends BaseService {
const { rule, metadata, regions, ...rest } = update
if (rest.ends_at) {
if (discount.starts_at >= new Date(update.ends_at)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`"ends_at" must be greater than "starts_at"`
)
}
}
if (regions) {
discount.regions = await Promise.all(
regions.map(regionId => this.regionService_.retrieve(regionId))
@@ -333,6 +340,13 @@ class DiscountService extends BaseService {
usage_limit: discount.usage_limit,
}
if (discount.valid_duration) {
const lastValidDate = new Date()
lastValidDate.setSeconds(
lastValidDate.getSeconds() + toSeconds(parse(discount.valid_duration))
)
toCreate.ends_at = lastValidDate
}
const created = await discountRepo.create(toCreate)
const result = await discountRepo.save(created)
return result
+1 -1
View File
@@ -1,7 +1,7 @@
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
import { Brackets } from "typeorm"
import { Brackets, ILike } from "typeorm"
/**
* Handles draft orders
+3 -4
View File
@@ -1,8 +1,7 @@
import _ from "lodash"
import randomize from "randomatic"
import { BaseService } from "medusa-interfaces"
import { Brackets } from "typeorm"
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import randomize from "randomatic"
import { Brackets } from "typeorm"
/**
* Provides layer to manipulate gift cards.
+169
View File
@@ -0,0 +1,169 @@
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import _ from "lodash"
import { TransactionManager } from "typeorm"
class NoteService extends BaseService {
static Events = {
CREATED: "note.created",
UPDATED: "note.updated",
DELETED: "note.deleted",
}
constructor({ manager, noteRepository, eventBusService, userService }) {
super()
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {NoteRepository} */
this.noteRepository_ = noteRepository
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
}
/**
* Sets the service's manager to a given transaction manager
* @param {EntityManager} transactionManager - the manager to use
* @return {NoteService} a cloned note service
*/
withTransaction(transactionManager) {
if (!TransactionManager) {
return this
}
const cloned = new NoteService({
manager: transactionManager,
noteRepository: this.noteRepository_,
eventBus: this.eventBus_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Retrieves a specific note.
* @param {*} id - the id of the note to retrieve.
* @param {*} config - any options needed to query for the result.
* @returns {Promise} which resolves to the requested note.
*/
async retrieve(id, config = {}) {
const noteRepo = this.manager_.getCustomRepository(this.noteRepository_)
const validatedId = this.validateId_(id)
const query = this.buildQuery_({ id: validatedId }, config)
const note = await noteRepo.findOne(query)
if (!note) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Note with id: ${id} was not found.`
)
}
return note
}
/** Fetches all notes related to the given selector
* @param {Object} selector - the query object for find
* @param {Object} config - the configuration used to find the objects. contains relations, skip, and take.
* @return {Promise<Note[]>} notes related to the given search.
*/
async list(
selector,
config = {
skip: 0,
take: 50,
relations: [],
}
) {
const noteRepo = this.manager_.getCustomRepository(this.noteRepository_)
const query = this.buildQuery_(selector, config)
return noteRepo.find(query)
}
/**
* Creates a note associated with a given author
* @param {object} data - the note to create
* @param {*} config - any configurations if needed, including meta data
* @returns {Promise} resolves to the creation result
*/
async create(data, config = { metadata: {} }) {
const { metadata } = config
const { resource_id, resource_type, value, author_id } = data
return this.atomicPhase_(async manager => {
const noteRepo = manager.getCustomRepository(this.noteRepository_)
const toCreate = {
resource_id,
resource_type,
value,
author_id,
metadata,
}
const note = await noteRepo.create(toCreate)
const result = await noteRepo.save(note)
await this.eventBus_
.withTransaction(manager)
.emit(NoteService.Events.CREATED, { id: result.id })
return result
})
}
/**
* Updates a given note with a new value
* @param {*} noteId - the id of the note to update
* @param {*} value - the new value
* @returns {Promise} resolves to the updated element
*/
async update(noteId, value) {
return this.atomicPhase_(async manager => {
const noteRepo = manager.getCustomRepository(this.noteRepository_)
const note = await this.retrieve(noteId, { relations: ["author"] })
note.value = value
const result = await noteRepo.save(note)
await this.eventBus_
.withTransaction(manager)
.emit(NoteService.Events.UPDATED, { id: result.id })
return result
})
}
/**
* Deletes a given note
* @param {*} noteId - id of the note to delete
* @returns {Promise}
*/
async delete(noteId) {
return this.atomicPhase_(async manager => {
const noteRepo = manager.getCustomRepository(this.noteRepository_)
const note = await this.retrieve(noteId)
await noteRepo.softRemove(note)
await this.eventBus_
.withTransaction(manager)
.emit(NoteService.Events.DELETED, { id: noteId })
return Promise.resolve()
})
}
}
export default NoteService
+1 -1
View File
@@ -41,7 +41,7 @@ class NotificationService extends BaseService {
/**
* Sets the service's manager to a given transaction manager.
* @parma {EntityManager} transactionManager - the manager to use
* @param {EntityManager} transactionManager - the manager to use
* return {NotificationService} a cloned notification service
*/
withTransaction(transactionManager) {
+11 -3
View File
@@ -1,5 +1,4 @@
import _ from "lodash"
import { Validator, MedusaError } from "medusa-core-utils"
import { MedusaError, Validator } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { Brackets } from "typeorm"
@@ -237,7 +236,9 @@ class OrderService extends BaseService {
qb.andWhere(
new Brackets(qb => {
qb.where(`shipping_address.first_name ILIKE :q`, { q: `%${q}%` })
qb.where(`shipping_address.first_name ILIKE :qfn`, {
qfn: `%${q}%`,
})
.orWhere(`order.email ILIKE :q`, { q: `%${q}%` })
.orWhere(`display_id::varchar(255) ILIKE :dId`, { dId: `${q}` })
})
@@ -477,6 +478,9 @@ class OrderService extends BaseService {
.withTransaction(manager)
.cancelPayment(payment)
}
await this.cartService_
.withTransaction(manager)
.update(cart.id, { payment_authorized_at: null })
throw err
}
}
@@ -595,6 +599,10 @@ class OrderService extends BaseService {
no_notification: result.no_notification,
})
await this.cartService_
.withTransaction(manager)
.update(cart.id, { completed_at: new Date() })
return result
})
}
@@ -328,7 +328,7 @@ class PaymentProviderService extends BaseService {
payment.canceled_at = now.toISOString()
const paymentRepo = manager.getCustomRepository(this.paymentRepository_)
return paymentRepo.save(payment)
return await paymentRepo.save(payment)
})
}
@@ -1,6 +1,6 @@
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { Brackets, Raw, IsNull } from "typeorm"
import { Brackets, Raw, IsNull, ILike } from "typeorm"
import { Validator, MedusaError } from "medusa-core-utils"
/**
@@ -558,14 +558,11 @@ class ProductVariantService extends BaseService {
}
query.where = qb => {
qb.where(where).andWhere(
new Brackets(qb => {
qb.where([
{ sku: Raw(a => `${a} ILIKE :q`, { q: `%${q}%` }) },
{ title: Raw(a => `${a} ILIKE :q`, { q: `%${q}%` }) },
]).orWhere(`product.title ILIKE :q`, { q: `%${q}%` })
})
)
qb.where(where).andWhere([
{ sku: ILike(`%${q}%`) },
{ title: ILike(`%${q}%`) },
{ product: { title: ILike(`%${q}%`) } },
])
}
}
+39 -3
View File
@@ -1,6 +1,6 @@
import _ from "lodash"
import { Validator, MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { In } from "typeorm"
class ReturnReasonService extends BaseService {
constructor({ manager, returnReasonRepository }) {
@@ -32,6 +32,17 @@ class ReturnReasonService extends BaseService {
return this.atomicPhase_(async manager => {
const rrRepo = manager.getCustomRepository(this.retReasonRepo_)
if (data.parent_return_reason_id && data.parent_return_reason_id !== "") {
const parentReason = await this.retrieve(data.parent_return_reason_id)
if (parentReason.parent_return_reason_id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Doubly nested return reasons is not supported"
)
}
}
const created = rrRepo.create(data)
const result = await rrRepo.save(created)
@@ -44,14 +55,20 @@ class ReturnReasonService extends BaseService {
const rrRepo = manager.getCustomRepository(this.retReasonRepo_)
const reason = await this.retrieve(id)
if ("description" in data) {
const { description, label, parent_return_reason_id } = data
if (description) {
reason.description = data.description
}
if ("label" in data) {
if (label) {
reason.label = data.label
}
if (parent_return_reason_id) {
reason.parent_return_reason_id = parent_return_reason_id
}
await rrRepo.save(reason)
return reason
@@ -92,6 +109,25 @@ class ReturnReasonService extends BaseService {
return item
}
async delete(returnReasonId) {
return this.atomicPhase_(async manager => {
const rrRepo = manager.getCustomRepository(this.retReasonRepo_)
// We include the relation 'return_reason_children' to enable cascading deletes of return reasons if a parent is removed
const reason = await this.retrieve(returnReasonId, {
relations: ["return_reason_children"],
})
if (!reason) {
return Promise.resolve()
}
await rrRepo.softRemove(reason)
return Promise.resolve()
})
}
}
export default ReturnReasonService
+12
View File
@@ -374,6 +374,18 @@ class ReturnService extends BaseService {
refund_amount: Math.floor(toRefund),
}
const returnReasons = await this.returnReasonService_.list(
{ id: [...returnLines.map(rl => rl.reason_id)] },
{ relations: ["return_reason_children"] }
)
if (returnReasons.some(rr => rr.return_reason_children?.length > 0)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Cannot apply return reason category"
)
}
const rItemRepo = manager.getCustomRepository(this.returnItemRepository_)
returnObject.items = returnLines.map(i =>
rItemRepo.create({
@@ -96,6 +96,13 @@ class ShippingOptionService extends BaseService {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "ID does not exist")
}
// If no option id is provided, we are currently in the process of creating
// a new shipping option. Therefore, simply return the requirement, such
// that the cascading will take care of the creation of the requirement.
if (!optionId) {
return requirement
}
let req
if (existingReq) {
req = await reqRepo.save({
@@ -359,11 +366,33 @@ class ShippingOptionService extends BaseService {
}
if ("requirements" in data) {
option.requirements = await Promise.all(
data.requirements.map(r => {
return this.validateRequirement_(r, option.id)
})
)
const acc = []
for (const r of data.requirements) {
const validated = await this.validateRequirement_(r)
if (acc.find(raw => raw.type === validated.type)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Only one requirement of each type is allowed"
)
}
if (
acc.find(
raw =>
(raw.type === "max_subtotal" &&
validated.amount > raw.amount) ||
(raw.type === "min_subtotal" && validated.amount < raw.amount)
)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Max. subtotal must be greater than Min. subtotal"
)
}
acc.push(validated)
}
}
const result = await optionRepo.save(option)
+24 -7
View File
@@ -672,20 +672,33 @@ class SwapService extends BaseService {
}
const cart = swap.cart
const { payment } = cart
const items = swap.cart.items
for (const item of items) {
await this.inventoryService_
.withTransaction(manager)
.confirmInventory(item.variant_id, item.quantity)
if (!swap.allow_backorder) {
for (const item of items) {
try {
await this.inventoryService_
.withTransaction(manager)
.confirmInventory(item.variant_id, item.quantity)
} catch (err) {
if (payment) {
await this.paymentProviderService_
.withTransaction(manager)
.cancelPayment(payment)
}
await this.cartService_
.withTransaction(manager)
.update(cart.id, { payment_authorized_at: null })
throw err
}
}
}
const total = await this.totalsService_.getTotal(cart)
if (total > 0) {
const { payment } = cart
if (!payment) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
@@ -724,7 +737,7 @@ class SwapService extends BaseService {
swap.shipping_address_id = cart.shipping_address_id
swap.shipping_methods = cart.shipping_methods
swap.confirmed_at = now.toISOString()
swap.payment_status = total === 0 ? "difference_refunded" : "awaiting"
swap.payment_status = total === 0 ? "confirmed" : "awaiting"
const swapRepo = manager.getCustomRepository(this.swapRepository_)
const result = await swapRepo.save(swap)
@@ -744,6 +757,10 @@ class SwapService extends BaseService {
no_notification: swap.no_notification,
})
await this.cartService_
.withTransaction(manager)
.update(cart.id, { completed_at: new Date() })
return result
})
}
+15 -6
View File
@@ -48,7 +48,18 @@ class UserService extends BaseService {
* @return {string} the validated email
*/
validateEmail_(email) {
return email
const schema = Validator.string()
.email()
.required()
const { value, error } = schema.validate(email)
if (error) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The email is not valid"
)
}
return value.toLowerCase()
}
/**
@@ -114,13 +125,11 @@ class UserService extends BaseService {
* @param {string} email - the email of the user to get.
* @return {Promise<User>} the user document.
*/
async retrieveByEmail(email, relations = []) {
async retrieveByEmail(email, config = {}) {
const userRepo = this.manager_.getCustomRepository(this.userRepository_)
const user = await userRepo.findOne({
where: { email },
relations,
})
const query = this.buildQuery_({ email: email.toLowerCase() }, config)
const user = await userRepo.findOne(query)
if (!user) {
throw new MedusaError(