diff --git a/packages/medusa/src/api/routes/admin/customers/create-customer.ts b/packages/medusa/src/api/routes/admin/customers/create-customer.ts index a54476b38e..34e9960656 100644 --- a/packages/medusa/src/api/routes/admin/customers/create-customer.ts +++ b/packages/medusa/src/api/routes/admin/customers/create-customer.ts @@ -53,5 +53,5 @@ export class AdminPostCustomersReq { @IsObject() @IsOptional() - metadata?: object + metadata?: Record } diff --git a/packages/medusa/src/api/routes/admin/customers/update-customer.ts b/packages/medusa/src/api/routes/admin/customers/update-customer.ts index 32607ff6cc..42d635875f 100644 --- a/packages/medusa/src/api/routes/admin/customers/update-customer.ts +++ b/packages/medusa/src/api/routes/admin/customers/update-customer.ts @@ -129,7 +129,7 @@ export class AdminPostCustomersCustomerReq { @IsObject() @IsOptional() - metadata?: object + metadata?: Record @IsArray() @IsOptional() diff --git a/packages/medusa/src/api/routes/store/customers/index.ts b/packages/medusa/src/api/routes/store/customers/index.ts index 2a5d400831..f884948201 100644 --- a/packages/medusa/src/api/routes/store/customers/index.ts +++ b/packages/medusa/src/api/routes/store/customers/index.ts @@ -64,7 +64,7 @@ export const defaultStoreCustomersRelations = [ "billing_address", ] -export const defaultStoreCustomersFields = [ +export const defaultStoreCustomersFields: (keyof Customer)[] = [ "id", "email", "first_name", diff --git a/packages/medusa/src/api/routes/store/customers/update-address.ts b/packages/medusa/src/api/routes/store/customers/update-address.ts index cb4a9dbd52..2e1716b37e 100644 --- a/packages/medusa/src/api/routes/store/customers/update-address.ts +++ b/packages/medusa/src/api/routes/store/customers/update-address.ts @@ -45,9 +45,9 @@ export default async (req, res) => { "customerService" ) as CustomerService - let customer = await customerService.updateAddress(id, address_id, validated) + await customerService.updateAddress(id, address_id, validated) - customer = await customerService.retrieve(id, { + const customer = await customerService.retrieve(id, { relations: defaultStoreCustomersRelations, select: defaultStoreCustomersFields, }) diff --git a/packages/medusa/src/api/routes/store/customers/update-customer.ts b/packages/medusa/src/api/routes/store/customers/update-customer.ts index 498178b232..e223c4982d 100644 --- a/packages/medusa/src/api/routes/store/customers/update-customer.ts +++ b/packages/medusa/src/api/routes/store/customers/update-customer.ts @@ -93,5 +93,5 @@ export class StorePostCustomersCustomerReq { @IsOptional() @IsObject() - metadata?: object + metadata?: Record } diff --git a/packages/medusa/src/models/customer.ts b/packages/medusa/src/models/customer.ts index 22d4e5dd72..042939fae1 100644 --- a/packages/medusa/src/models/customer.ts +++ b/packages/medusa/src/models/customer.ts @@ -31,7 +31,7 @@ export class Customer extends SoftDeletableEntity { @Index() @Column({ nullable: true }) - billing_address_id: string + billing_address_id: string | null @OneToOne(() => Address) @JoinColumn({ name: "billing_address_id" }) diff --git a/packages/medusa/src/repositories/customer.ts b/packages/medusa/src/repositories/customer.ts index 9e87b43d4c..3bf79e0262 100644 --- a/packages/medusa/src/repositories/customer.ts +++ b/packages/medusa/src/repositories/customer.ts @@ -1,23 +1,46 @@ -import { EntityRepository, Repository } from "typeorm" +import { Brackets, EntityRepository, ILike, Repository } from "typeorm" import { Customer } from "../models/customer" +import { ExtendedFindConfig, Selector } from "../types/common" @EntityRepository(Customer) export class CustomerRepository extends Repository { - async listAndCount(query, groups): Promise<[Customer[], number]> { - let qb = this.createQueryBuilder("customer") - .where(query.where) + async listAndCount( + query: ExtendedFindConfig>, + q: string | undefined = undefined + ): Promise<[Customer[], number]> { + const groups = query.where.groups as { value: string[] } + delete query.where.groups + + const qb = this.createQueryBuilder("customer") .skip(query.skip) .take(query.take) + if (q) { + delete query.where.email + delete query.where.first_name + delete query.where.last_name + + qb.where( + new Brackets((qb) => { + qb.where({ email: ILike(`%${q}%`) }) + .orWhere({ first_name: ILike(`%${q}%`) }) + .orWhere({ last_name: ILike(`%${q}%`) }) + }) + ) + } + + qb.andWhere(query.where) + if (groups) { - qb = qb - .leftJoinAndSelect("customer.groups", "group") - .andWhere(`group.id IN (:...ids)`, { ids: groups.value }) + qb.leftJoinAndSelect("customer.groups", "group").andWhere( + `group.id IN (:...ids)`, + { ids: groups.value } + ) } if (query.relations?.length) { query.relations.forEach((rel) => { - qb = qb.leftJoinAndSelect(`customer.${rel}`, rel) + qb.leftJoinAndSelect(`customer.${rel}`, rel) }) } diff --git a/packages/medusa/src/services/customer.js b/packages/medusa/src/services/customer.ts similarity index 51% rename from packages/medusa/src/services/customer.js rename to packages/medusa/src/services/customer.ts index c8531d762e..ee7c801424 100644 --- a/packages/medusa/src/services/customer.js +++ b/packages/medusa/src/services/customer.ts @@ -1,16 +1,36 @@ import jwt from "jsonwebtoken" import _ from "lodash" import { MedusaError } from "medusa-core-utils" -import { BaseService } from "medusa-interfaces" import Scrypt from "scrypt-kdf" -import { Brackets, ILike } from "typeorm" +import { DeepPartial, EntityManager } from "typeorm" +import { StorePostCustomersCustomerAddressesAddressReq } from "../api" +import { TransactionBaseService } from "../interfaces" +import { Address, Customer, CustomerGroup } from "../models" +import { AddressRepository } from "../repositories/address" +import { CustomerRepository } from "../repositories/customer" +import { AddressCreatePayload, FindConfig, Selector } from "../types/common" +import { CreateCustomerInput, UpdateCustomerInput } from "../types/customers" +import { buildQuery, setMetadata } from "../utils" import { formatException } from "../utils/exception-formatter" +import EventBusService from "./event-bus" +type InjectedDependencies = { + manager: EntityManager + eventBusService: EventBusService + customerRepository: typeof CustomerRepository + addressRepository: typeof AddressRepository +} /** * Provides layer to manipulate customers. - * @implements {BaseService} */ -class CustomerService extends BaseService { +class CustomerService extends TransactionBaseService { + protected readonly customerRepository_: typeof CustomerRepository + protected readonly addressRepository_: typeof AddressRepository + protected readonly eventBusService_: EventBusService + + protected readonly manager_: EntityManager + protected readonly transactionManager_: EntityManager | undefined + static Events = { PASSWORD_RESET: "customer.password_reset", CREATED: "customer.created", @@ -22,39 +42,17 @@ class CustomerService extends BaseService { customerRepository, eventBusService, addressRepository, - }) { - super() + }: InjectedDependencies) { + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) - /** @private @const {EntityManager} */ this.manager_ = manager - /** @private @const {CustomerRepository} */ this.customerRepository_ = customerRepository - - /** @private @const {EventBus} */ - this.eventBus_ = eventBusService - - /** @private @const {AddressRepository} */ + this.eventBusService_ = eventBusService this.addressRepository_ = addressRepository } - withTransaction(transactionManager) { - if (!transactionManager) { - return this - } - - const cloned = new CustomerService({ - manager: transactionManager, - customerRepository: this.customerRepository_, - eventBusService: this.eventBus_, - addressRepository: this.addressRepository_, - }) - - cloned.transactionManager_ = transactionManager - - return cloned - } - /** * Generate a JSON Web token, that will be sent to a customer, that wishes to * reset password. @@ -64,38 +62,42 @@ class CustomerService extends BaseService { * @param {string} customerId - the customer to reset the password for * @return {string} the generated JSON web token */ - async generateResetPasswordToken(customerId) { - const customer = await this.retrieve(customerId, { - select: [ - "id", - "has_account", - "password_hash", - "email", - "first_name", - "last_name", - ], - }) + async generateResetPasswordToken(customerId: string): Promise { + return await this.atomicPhase_(async (manager) => { + const customer = await this.retrieve(customerId, { + select: [ + "id", + "has_account", + "password_hash", + "email", + "first_name", + "last_name", + ], + }) - if (!customer.has_account) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "You must have an account to reset the password. Create an account first" - ) - } + if (!customer.has_account) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You must have an account to reset the password. Create an account first" + ) + } - const secret = customer.password_hash - const expiry = Math.floor(Date.now() / 1000) + 60 * 15 // 15 minutes ahead - const payload = { customer_id: customer.id, exp: expiry } - const token = jwt.sign(payload, secret) - // Notify subscribers - this.eventBus_.emit(CustomerService.Events.PASSWORD_RESET, { - id: customerId, - email: customer.email, - first_name: customer.first_name, - last_name: customer.last_name, - token, + const secret = customer.password_hash + const expiry = Math.floor(Date.now() / 1000) + 60 * 15 // 15 minutes ahead + const payload = { customer_id: customer.id, exp: expiry } + const token = jwt.sign(payload, secret) + // Notify subscribers + this.eventBusService_ + .withTransaction(manager) + .emit(CustomerService.Events.PASSWORD_RESET, { + id: customerId, + email: customer.email, + first_name: customer.first_name, + last_name: customer.last_name, + token, + }) + return token }) - return token } /** @@ -103,40 +105,24 @@ class CustomerService extends BaseService { * @param {Object} config - the config object containing query settings * @return {Promise} the result of the find operation */ - async list(selector = {}, config = { relations: [], skip: 0, take: 50 }) { - const customerRepo = this.manager_.getCustomRepository( - this.customerRepository_ - ) + async list( + selector: Selector & { q?: string } = {}, + config: FindConfig = { relations: [], skip: 0, take: 50 } + ): Promise { + return await this.atomicPhase_(async (manager) => { + const customerRepo = manager.getCustomRepository(this.customerRepository_) - let q - if ("q" in selector) { - q = selector.q - delete selector.q - } - - const query = this.buildQuery_(selector, config) - - if (q) { - const where = query.where - - delete where.email - delete where.first_name - delete where.last_name - - query.where = (qb) => { - qb.where(where) - - qb.andWhere( - new Brackets((qb) => { - qb.where({ email: ILike(`%${q}%`) }) - .orWhere({ first_name: ILike(`%${q}%`) }) - .orWhere({ last_name: ILike(`%${q}%`) }) - }) - ) + let q + if ("q" in selector) { + q = selector.q + delete selector.q } - } - return customerRepo.find(query) + const query = buildQuery, Customer>(selector, config) + + const [customers] = await customerRepo.listAndCount(query, q) + return customers + }) } /** @@ -145,76 +131,58 @@ class CustomerService extends BaseService { * @return {Promise} the result of the find operation */ async listAndCount( - selector, - config = { relations: [], skip: 0, take: 50, order: { created_at: "DESC" } } - ) { - const customerRepo = this.manager_.getCustomRepository( - this.customerRepository_ - ) - - let q - if ("q" in selector) { - q = selector.q - delete selector.q + selector: Selector & { q?: string }, + config: FindConfig = { + relations: [], + skip: 0, + take: 50, + order: { created_at: "DESC" }, } + ): Promise<[Customer[], number]> { + return await this.atomicPhase_(async (manager) => { + const customerRepo = manager.getCustomRepository(this.customerRepository_) - const query = this.buildQuery_(selector, config) - - const groups = query.where.groups - delete query.where.groups - - if (q) { - const where = query.where - - delete where.email - delete where.first_name - delete where.last_name - - query.where = (qb) => { - qb.where(where) - qb.andWhere( - new Brackets((qb) => { - qb.where({ email: ILike(`%${q}%`) }) - .orWhere({ first_name: ILike(`%${q}%`) }) - .orWhere({ last_name: ILike(`%${q}%`) }) - }) - ) + let q + if ("q" in selector) { + q = selector.q + delete selector.q } - } - return await customerRepo.listAndCount(query, groups) + const query = buildQuery, Customer>(selector, config) + + return await customerRepo.listAndCount(query, q) + }) } /** * Return the total number of documents in database * @return {Promise} the result of the count operation */ - count() { - const customerRepo = this.manager_.getCustomRepository( - this.customerRepository_ - ) - return customerRepo.count({}) + async count(): Promise { + return await this.atomicPhase_(async (manager) => { + const customerRepo = manager.getCustomRepository(this.customerRepository_) + return await customerRepo.count({}) + }) } - /** - * Gets a customer by id. - * @param {string} customerId - the id of the customer to get. - * @param {Object} config - the config object containing query settings - * @return {Promise} the customer document. - */ - async retrieve(customerId, config = {}) { - const customerRepo = this.manager_.getCustomRepository( - this.customerRepository_ - ) + private async retrieve_( + selector: Selector, + config: FindConfig = {} + ): Promise { + const manager = this.transactionManager_ ?? this.manager_ - const validatedId = this.validateId_(customerId) - const query = this.buildQuery_({ id: validatedId }, config) + const customerRepo = manager.getCustomRepository(this.customerRepository_) + const query = buildQuery(selector, config) const customer = await customerRepo.findOne(query) + if (!customer) { + const selectorConstraints = Object.entries(selector) + .map((key, value) => `${key}: ${value}`) + .join(", ") throw new MedusaError( MedusaError.Types.NOT_FOUND, - `Customer with ${customerId} was not found` + `Customer with ${selectorConstraints} was not found` ) } @@ -227,22 +195,13 @@ class CustomerService extends BaseService { * @param {Object} config - the config object containing query settings * @return {Promise} the customer document. */ - async retrieveByEmail(email, config = {}) { - const customerRepo = this.manager_.getCustomRepository( - this.customerRepository_ - ) - - const query = this.buildQuery_({ email: email.toLowerCase() }, config) - const customer = await customerRepo.findOne(query) - - if (!customer) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Customer with email ${email} was not found` - ) - } - - return customer + async retrieveByEmail( + email: string, + config: FindConfig = {} + ): Promise { + return await this.atomicPhase_(async () => { + return await this.retrieve_({ email: email.toLowerCase() }, config) + }) } /** @@ -251,22 +210,28 @@ class CustomerService extends BaseService { * @param {Object} config - the config object containing query settings * @return {Promise} the customer document. */ - async retrieveByPhone(phone, config = {}) { - const customerRepo = this.manager_.getCustomRepository( - this.customerRepository_ - ) + async retrieveByPhone( + phone: string, + config: FindConfig = {} + ): Promise { + return await this.atomicPhase_(async () => { + return await this.retrieve_({ phone }, config) + }) + } - const query = this.buildQuery_({ phone }, config) - const customer = await customerRepo.findOne(query) - - if (!customer) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Customer with phone ${phone} was not found` - ) - } - - return customer + /** + * Gets a customer by id. + * @param {string} customerId - the id of the customer to get. + * @param {Object} config - the config object containing query settings + * @return {Promise} the customer document. + */ + async retrieve( + customerId: string, + config: FindConfig = {} + ): Promise { + return await this.atomicPhase_(async () => { + return this.retrieve_({ id: customerId }, config) + }) } /** @@ -274,7 +239,7 @@ class CustomerService extends BaseService { * @param {string} password - the value to hash * @return {Promise} hashed password */ - async hashPassword_(password) { + async hashPassword_(password: string): Promise { const buf = await Scrypt.kdf(password, { logN: 1, r: 1, p: 1 }) return buf.toString("base64") } @@ -287,17 +252,15 @@ class CustomerService extends BaseService { * @param {object} customer - the customer to create * @return {Promise} the result of create */ - async create(customer) { - return this.atomicPhase_(async (manager) => { + async create(customer: CreateCustomerInput): Promise { + return await this.atomicPhase_(async (manager) => { const customerRepository = manager.getCustomRepository( this.customerRepository_ ) const { email, password } = customer - const existing = await this.retrieveByEmail(email).catch( - (err) => undefined - ) + const existing = await this.retrieveByEmail(email).catch(() => undefined) if (existing && existing.has_account) { throw new MedusaError( @@ -314,7 +277,7 @@ class CustomerService extends BaseService { const toUpdate = { ...existing, ...customer } const updated = await customerRepository.save(toUpdate) - await this.eventBus_ + await this.eventBusService_ .withTransaction(manager) .emit(CustomerService.Events.UPDATED, updated) return updated @@ -328,7 +291,7 @@ class CustomerService extends BaseService { const created = await customerRepository.create(customer) const result = await customerRepository.save(created) - await this.eventBus_ + await this.eventBusService_ .withTransaction(manager) .emit(CustomerService.Events.CREATED, result) return result @@ -343,13 +306,15 @@ class CustomerService extends BaseService { * @param {object} update - an object with the update values. * @return {Promise} resolves to the update result. */ - async update(customerId, update) { - return this.atomicPhase_( + async update( + customerId: string, + update: UpdateCustomerInput + ): Promise { + return await this.atomicPhase_( async (manager) => { const customerRepository = manager.getCustomRepository( this.customerRepository_ ) - const addrRepo = manager.getCustomRepository(this.addressRepository_) const customer = await this.retrieve(customerId) @@ -363,13 +328,13 @@ class CustomerService extends BaseService { } = update if (metadata) { - customer.metadata = this.setMetadata_(customer, metadata) + customer.metadata = setMetadata(customer, metadata) } if ("billing_address_id" in update || "billing_address" in update) { const address = billing_address_id || billing_address if (typeof address !== "undefined") { - await this.updateBillingAddress_(customer, address, addrRepo) + await this.updateBillingAddress_(customer, address) } } @@ -382,12 +347,12 @@ class CustomerService extends BaseService { } if (groups) { - customer.groups = groups + customer.groups = groups as CustomerGroup[] } const updated = await customerRepository.save(customer) - await this.eventBus_ + await this.eventBusService_ .withTransaction(manager) .emit(CustomerService.Events.UPDATED, updated) return updated @@ -405,60 +370,88 @@ class CustomerService extends BaseService { * @param {Object} addrRepo - address repository * @return {Promise} the result of the update operation */ - async updateBillingAddress_(customer, addressOrId, addrRepo) { - if (addressOrId === null) { - customer.billing_address_id = null - return - } + async updateBillingAddress_( + customer: Customer, + addressOrId: string | DeepPartial
| undefined + ): Promise { + return await this.atomicPhase_(async (manager) => { + const addrRepo: AddressRepository = manager.getCustomRepository( + this.addressRepository_ + ) - if (typeof addressOrId === `string`) { - addressOrId = await addrRepo.findOne({ - where: { id: addressOrId }, - }) - } - - addressOrId.country_code = addressOrId.country_code.toLowerCase() - - if (addressOrId.id) { - customer.billing_address_id = addressOrId.id - } else { - if (customer.billing_address_id) { - const addr = await addrRepo.findOne({ - where: { id: customer.billing_address_id }, - }) - - await addrRepo.save({ ...addr, ...addressOrId }) - } else { - const created = addrRepo.create({ - ...addressOrId, - }) - const saved = await addrRepo.save(created) - customer.billing_address = saved + if (addressOrId === null || addressOrId === undefined) { + customer.billing_address_id = null + return } - } + + let address: DeepPartial
+ if (typeof addressOrId === `string`) { + const fetchedAddress = await addrRepo.findOne({ + where: { id: addressOrId }, + }) + + if (!fetchedAddress) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Address with id ${addressOrId} was not found` + ) + } + + address = fetchedAddress + } else { + address = addressOrId + } + + address.country_code = address.country_code?.toLowerCase() + + if (typeof address?.id !== "undefined") { + customer.billing_address_id = address.id + } else { + if (customer.billing_address_id) { + const addr = await addrRepo.findOne({ + where: { id: customer.billing_address_id }, + }) + + await addrRepo.save({ ...addr, ...address }) + } else { + const created = addrRepo.create(address) + const saved: Address = await addrRepo.save(created) + customer.billing_address = saved + } + } + }) } - async updateAddress(customerId, addressId, address) { - return this.atomicPhase_(async (manager) => { + async updateAddress( + customerId: string, + addressId: string, + address: StorePostCustomersCustomerAddressesAddressReq + ): Promise
{ + return await this.atomicPhase_(async (manager) => { const addressRepo = manager.getCustomRepository(this.addressRepository_) - address.country_code = address.country_code.toLowerCase() + address.country_code = address.country_code?.toLowerCase() const toUpdate = await addressRepo.findOne({ where: { id: addressId, customer_id: customerId }, }) + if (!toUpdate) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Could not find address for customer" + ) + } for (const [key, value] of Object.entries(address)) { toUpdate[key] = value } - const result = addressRepo.save(toUpdate) - return result + return addressRepo.save(toUpdate) }) } - async removeAddress(customerId, addressId) { - return this.atomicPhase_(async (manager) => { + async removeAddress(customerId: string, addressId: string): Promise { + return await this.atomicPhase_(async (manager) => { const addressRepo = manager.getCustomRepository(this.addressRepository_) // Should not fail, if user does not exist, since delete is idempotent @@ -467,17 +460,18 @@ class CustomerService extends BaseService { }) if (!address) { - return Promise.resolve() + return } await addressRepo.softRemove(address) - - return Promise.resolve() }) } - async addAddress(customerId, address) { - return this.atomicPhase_(async (manager) => { + async addAddress( + customerId: string, + address: AddressCreatePayload + ): Promise { + return await this.atomicPhase_(async (manager) => { const addressRepository = manager.getCustomRepository( this.addressRepository_ ) @@ -490,7 +484,8 @@ class CustomerService extends BaseService { const shouldAdd = !customer.shipping_addresses.find( (a) => - a.country_code.toLowerCase() === address.country_code.toLowerCase() && + a.country_code?.toLowerCase() === + address.country_code.toLowerCase() && a.address_1 === address.address_1 && a.address_2 === address.address_2 && a.city === address.city && @@ -503,8 +498,8 @@ class CustomerService extends BaseService { if (shouldAdd) { const created = await addressRepository.create({ - customer_id: customerId, ...address, + customer_id: customerId, }) const result = await addressRepository.save(created) return result @@ -520,37 +515,20 @@ class CustomerService extends BaseService { * castable as an ObjectId * @return {Promise} the result of the delete operation. */ - async delete(customerId) { - return this.atomicPhase_(async (manager) => { + async delete(customerId: string): Promise { + return await this.atomicPhase_(async (manager) => { const customerRepo = manager.getCustomRepository(this.customerRepository_) // Should not fail, if user does not exist, since delete is idempotent const customer = await customerRepo.findOne({ where: { id: customerId } }) if (!customer) { - return Promise.resolve() + return } - await customerRepo.softRemove(customer) - - return Promise.resolve() + return await customerRepo.softRemove(customer) }) } - - /** - * Decorates a customer. - * @param {Customer} customer - the cart to decorate. - * @param {string[]} fields - the fields to include. - * @param {string[]} expandFields - fields to expand. - * @return {Customer} return the decorated customer. - */ - async decorate(customer, fields = [], expandFields = []) { - const requiredFields = ["_id", "metadata"] - const decorated = _.pick(customer, fields.concat(requiredFields)) - - const final = await this.runDecorators_(decorated) - return final - } } export default CustomerService diff --git a/packages/medusa/src/types/customers.ts b/packages/medusa/src/types/customers.ts index 9bbfd4619e..873c12a237 100644 --- a/packages/medusa/src/types/customers.ts +++ b/packages/medusa/src/types/customers.ts @@ -1,4 +1,5 @@ import { IsOptional, IsString } from "class-validator" +import { AddressPayload } from "./common" export class AdminListCustomerSelector { @IsString() @@ -9,3 +10,28 @@ export class AdminListCustomerSelector { @IsString({ each: true }) groups?: string[] } + +export type CreateCustomerInput = { + email: string + password?: string + password_hash?: string + has_account?: boolean + + first_name?: string + last_name?: string + phone?: string + metadata?: Record +} + +export type UpdateCustomerInput = { + password?: string + metadata?: Record + billing_address?: AddressPayload | string + billing_address_id?: string + groups?: { id: string }[] + + email?: string + first_name?: string + last_name?: string + phone?: string +}