feat(medusa): Convert GiftCardService to Typescript (#1664)

This commit is contained in:
Philip Korsholm
2022-06-23 16:33:18 +02:00
committed by GitHub
parent 7b09b8c36c
commit 1585b7ae2b
11 changed files with 384 additions and 357 deletions

View File

@@ -77,10 +77,9 @@ export class AdminPostGiftCardsReq {
@IsBoolean()
is_disabled?: boolean
@IsOptional()
@IsString()
region_id?: string
region_id: string
@IsOptional()
metadata?: object
metadata?: Record<string, unknown>
}

View File

@@ -2,14 +2,23 @@ import { Router } from "express"
import "reflect-metadata"
import { GiftCard } from "../../../.."
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
import middlewares, { transformQuery } from "../../../middlewares"
import { AdminGetGiftCardsParams } from "./list-gift-cards"
const route = Router()
export default (app) => {
app.use("/gift-cards", route)
route.get("/", middlewares.wrap(require("./list-gift-cards").default))
route.get(
"/",
transformQuery(AdminGetGiftCardsParams, {
defaultFields: defaultAdminGiftCardFields,
defaultRelations: defaultAdminGiftCardRelations,
isList: true,
}),
middlewares.wrap(require("./list-gift-cards").default)
)
route.post("/", middlewares.wrap(require("./create-gift-card").default))
@@ -22,7 +31,7 @@ export default (app) => {
return app
}
export const defaultAdminGiftCardFields = [
export const defaultAdminGiftCardFields: (keyof GiftCard)[] = [
"id",
"code",
"value",

View File

@@ -1,5 +1,6 @@
import { Type } from "class-transformer"
import { IsInt, IsOptional, IsString } from "class-validator"
import { pickBy } from "lodash"
import { defaultAdminGiftCardFields, defaultAdminGiftCardRelations } from "."
import { GiftCardService } from "../../../../services"
import { validator } from "../../../../utils/validator"
@@ -27,21 +28,12 @@ import { validator } from "../../../../utils/validator"
export default async (req, res) => {
const validated = await validator(AdminGetGiftCardsParams, req.query)
const selector = {}
if (validated.q && typeof validated.q !== "undefined") {
selector["q"] = validated.q
}
const giftCardService: GiftCardService = req.scope.resolve("giftCardService")
const giftCards = await giftCardService.list(selector, {
select: defaultAdminGiftCardFields,
relations: defaultAdminGiftCardRelations,
order: { created_at: "DESC" },
limit: validated.limit,
skip: validated.offset,
})
const giftCards = await giftCardService.list(
pickBy(req.filterableFields, (val) => typeof val !== "undefined"),
req.listConfig
)
res.status(200).json({
gift_cards: giftCards,

View File

@@ -83,5 +83,5 @@ export class AdminPostGiftCardsGiftCardReq {
region_id?: string
@IsOptional()
metadata?: object
metadata?: Record<string, unknown>
}

View File

@@ -14,7 +14,12 @@ export default (app) => {
export const defaultStoreGiftCardRelations = ["region"]
export const defaultStoreGiftCardFields = ["id", "code", "value", "balance"]
export const defaultStoreGiftCardFields: (keyof GiftCard)[] = [
"id",
"code",
"value",
"balance",
]
export const allowedStoreGiftCardRelations = ["region"]

View File

@@ -1,15 +1,20 @@
import { flatten, groupBy, map, merge } from "lodash"
import { EntityRepository, FindManyOptions, Repository } from "typeorm"
import { flatten, groupBy, merge } from "lodash"
import {
Brackets,
EntityRepository,
FindManyOptions,
Repository,
} from "typeorm"
import { GiftCard } from "../models/gift-card"
import { ExtendedFindConfig, QuerySelector, Writable } from "../types/common"
@EntityRepository(GiftCard)
export class GiftCardRepository extends Repository<GiftCard> {
public async findWithRelations(
relations: Array<keyof GiftCard> = [],
idsOrOptionsWithoutRelations: Omit<
FindManyOptions<GiftCard>,
"relations"
> = {}
relations: (keyof GiftCard | string)[] = [],
idsOrOptionsWithoutRelations:
| Omit<FindManyOptions<GiftCard>, "relations">
| string[] = {}
): Promise<GiftCard[]> {
let entities
if (Array.isArray(idsOrOptionsWithoutRelations)) {
@@ -40,11 +45,47 @@ export class GiftCardRepository extends Repository<GiftCard> {
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id")
return map(entitiesAndRelationsById, entityAndRelations =>
merge({}, ...entityAndRelations)
return Object.values(entitiesAndRelationsById).map((v) => merge({}, ...v))
}
protected async queryGiftCards(
q: string,
where: Partial<Writable<QuerySelector<GiftCard>>>,
rels: (keyof GiftCard | string)[]
): Promise<GiftCard[]> {
const raw = await this.createQueryBuilder("gift_card")
.leftJoinAndSelect("gift_card.order", "order")
.select(["gift_card.id"])
.where(where)
.andWhere(
new Brackets((qb) => {
return qb
.where(`gift_card.code ILIKE :q`, { q: `%${q}%` })
.orWhere(`display_id::varchar(255) ILIKE :dId`, { dId: `${q}` })
})
)
.getMany()
return this.findWithRelations(
rels,
raw.map((i) => i.id)
)
}
public async listGiftCards(
query: ExtendedFindConfig<GiftCard, QuerySelector<GiftCard>>,
rels: (keyof GiftCard | string)[] = [],
q?: string
): Promise<GiftCard[]> {
if (q) {
const where = query.where
delete where.id
return await this.queryGiftCards(q, where, rels)
}
return this.findWithRelations(rels, query)
}
public async findOneWithRelations(
relations: Array<keyof GiftCard> = [],
optionsWithoutRelations: Omit<FindManyOptions<GiftCard>, "relations"> = {}

View File

@@ -5,23 +5,23 @@ import GiftCardService from "../gift-card"
describe("GiftCardService", () => {
const eventBusService = {
emit: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
describe("create", () => {
const giftCardRepo = MockRepository({
create: s => {
create: (s) => {
return Promise.resolve(s)
},
save: s => {
save: (s) => {
return Promise.resolve(s)
},
})
const regionService = {
withTransaction: function() {
withTransaction: function () {
return this
},
retrieve: () => {
@@ -59,18 +59,6 @@ describe("GiftCardService", () => {
code: expect.any(String),
})
})
it("fails to create giftcard if no region is provided", async () => {
const card = {
...giftCard,
}
card.region_id = undefined
await expect(giftCardService.create(card)).rejects.toThrow(
"Gift card is missing region_id"
)
})
})
describe("retrieve", () => {
@@ -152,16 +140,16 @@ describe("GiftCardService", () => {
}
const giftCardRepo = MockRepository({
findOneWithRelations: s => {
findOneWithRelations: (s) => {
return Promise.resolve(giftCard)
},
save: s => {
save: (s) => {
return Promise.resolve(s)
},
})
const regionService = {
withTransaction: function() {
withTransaction: function () {
return this
},
retrieve: () => {
@@ -198,7 +186,7 @@ describe("GiftCardService", () => {
it.each([[-100], [6000]])(
"fails to update balance with illegal input '%s'",
async input => {
async (input) => {
await expect(
giftCardService.update(IdMap.getId("giftcard-id"), {
balance: input,
@@ -215,7 +203,7 @@ describe("GiftCardService", () => {
}
const giftCardRepo = MockRepository({
findOne: s => {
findOne: (s) => {
switch (s.where.id) {
case IdMap.getId("gift-card"):
return Promise.resolve(giftCard)
@@ -223,7 +211,7 @@ describe("GiftCardService", () => {
return Promise.resolve()
}
},
softRemove: s => {
softRemove: (s) => {
return Promise.resolve()
},
})

View File

@@ -1,305 +0,0 @@
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.
* @extends BaseService
*/
class GiftCardService extends BaseService {
static Events = {
CREATED: "gift_card.created",
}
constructor({
manager,
giftCardRepository,
giftCardTransactionRepository,
regionService,
eventBusService,
}) {
super()
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {GiftCardRepository} */
this.giftCardRepository_ = giftCardRepository
/** @private @const {GiftCardRepository} */
this.giftCardTransactionRepo_ = giftCardTransactionRepository
/** @private @const {RegionService} */
this.regionService_ = regionService
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
}
withTransaction(transactionManager) {
if (!transactionManager) {
return this
}
const cloned = new GiftCardService({
manager: transactionManager,
giftCardRepository: this.giftCardRepository_,
giftCardTransactionRepository: this.giftCardTransactionRepo_,
regionService: this.regionService_,
eventBusService: this.eventBus_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Generates a 16 character gift card code
* @return {string} the generated gift card code
*/
generateCode_() {
const code = [
randomize("A0", 4),
randomize("A0", 4),
randomize("A0", 4),
randomize("A0", 4),
].join("-")
return code
}
/**
* @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} the result of the find operation
*/
async list(selector = {}, config = { relations: [], skip: 0, take: 10 }) {
const giftCardRepo = this.manager_.getCustomRepository(
this.giftCardRepository_
)
let q
if ("q" in selector) {
q = selector.q
delete selector.q
}
const query = this.buildQuery_(selector, config)
const rels = query.relations
delete query.relations
if (q) {
const where = query.where
delete where.id
const raw = await giftCardRepo
.createQueryBuilder("gift_card")
.leftJoinAndSelect("gift_card.order", "order")
.select(["gift_card.id"])
.where(where)
.andWhere(
new Brackets((qb) => {
return qb
.where(`gift_card.code ILIKE :q`, { q: `%${q}%` })
.orWhere(`display_id::varchar(255) ILIKE :dId`, { dId: `${q}` })
})
)
.getMany()
return giftCardRepo.findWithRelations(
rels,
raw.map((i) => i.id)
)
}
return giftCardRepo.findWithRelations(rels, query)
}
async createTransaction(data) {
return this.atomicPhase_(async (manager) => {
const gctRepo = manager.getCustomRepository(this.giftCardTransactionRepo_)
const created = gctRepo.create(data)
const saved = await gctRepo.save(created)
return saved.id
})
}
/**
* Creates a gift card with provided data given that the data is validated.
* @param {GiftCard} giftCard - the gift card data to create
* @return {Promise<GiftCard>} the result of the create operation
*/
async create(giftCard) {
return this.atomicPhase_(async (manager) => {
const giftCardRepo = manager.getCustomRepository(this.giftCardRepository_)
if (!giftCard.region_id) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Gift card is missing region_id`
)
}
// Will throw if region does not exist
const region = await this.regionService_.retrieve(giftCard.region_id)
const code = this.generateCode_()
const toCreate = {
code,
region_id: region.id,
...giftCard,
}
const created = await giftCardRepo.create(toCreate)
const result = await giftCardRepo.save(created)
await this.eventBus_
.withTransaction(manager)
.emit(GiftCardService.Events.CREATED, {
id: result.id,
})
return result
})
}
/**
* Gets a gift card by id.
* @param {string} giftCardId - id of gift card to retrieve
* @param {object} config - optional values to include with gift card query
* @return {Promise<GiftCard>} the gift card
*/
async retrieve(giftCardId, config = {}) {
const giftCardRepo = this.manager_.getCustomRepository(
this.giftCardRepository_
)
const validatedId = this.validateId_(giftCardId)
const query = {
where: { id: validatedId },
}
if (config.select) {
query.select = config.select
}
if (config.relations) {
query.relations = config.relations
}
const rels = query.relations
delete query.relations
const giftCard = await giftCardRepo.findOneWithRelations(rels, query)
if (!giftCard) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Gift card with ${giftCardId} was not found`
)
}
return giftCard
}
async retrieveByCode(code, config = {}) {
const giftCardRepo = this.manager_.getCustomRepository(
this.giftCardRepository_
)
const query = {
where: { code },
}
if (config.select) {
query.select = config.select
}
if (config.relations) {
query.relations = config.relations
}
const rels = query.relations
delete query.relations
const giftCard = await giftCardRepo.findOneWithRelations(rels, query)
if (!giftCard) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Gift card with ${code} was not found`
)
}
return giftCard
}
/**
* Updates a giftCard.
* @param {string} giftCardId - giftCard id of giftCard to update
* @param {GiftCard} update - the data to update the giftCard with
* @return {Promise} the result of the update operation
*/
async update(giftCardId, update) {
return this.atomicPhase_(async (manager) => {
const giftCardRepo = manager.getCustomRepository(this.giftCardRepository_)
const giftCard = await this.retrieve(giftCardId)
const { region_id, metadata, balance, ...rest } = update
if (region_id && region_id !== giftCard.region_id) {
const region = await this.regionService_.retrieve(region_id)
giftCard.region_id = region.id
}
if (metadata) {
giftCard.metadata = await this.setMetadata_(giftCard.id, metadata)
}
if (typeof balance !== "undefined") {
if (balance < 0 || giftCard.value < balance) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"new balance is invalid"
)
}
giftCard.balance = balance
}
for (const [key, value] of Object.entries(rest)) {
giftCard[key] = value
}
const updated = await giftCardRepo.save(giftCard)
return updated
})
}
/**
* Deletes a gift card idempotently
* @param {string} giftCardId - id of gift card to delete
* @return {Promise} the result of the delete operation
*/
async delete(giftCardId) {
return this.atomicPhase_(async (manager) => {
const giftCardRepo = manager.getCustomRepository(this.giftCardRepository_)
const giftCard = await giftCardRepo.findOne({ where: { id: giftCardId } })
if (!giftCard) {
return Promise.resolve()
}
await giftCardRepo.softRemove(giftCard)
return Promise.resolve()
})
}
}
export default GiftCardService

View File

@@ -0,0 +1,273 @@
import { MedusaError } from "medusa-core-utils"
import randomize from "randomatic"
import { Brackets, EntityManager, FindOneOptions } from "typeorm"
import { EventBusService } from "."
import { TransactionBaseService } from "../interfaces"
import { GiftCard } from "../models"
import { GiftCardRepository } from "../repositories/gift-card"
import { GiftCardTransactionRepository } from "../repositories/gift-card-transaction"
import {
ExtendedFindConfig,
FindConfig,
QuerySelector,
Selector,
} from "../types/common"
import {
CreateGiftCardInput,
CreateGiftCardTransactionInput,
UpdateGiftCardInput,
} from "../types/gift-card"
import { buildQuery, setMetadata } from "../utils"
import RegionService from "./region"
type InjectedDependencies = {
manager: EntityManager
giftCardRepository: typeof GiftCardRepository
giftCardTransactionRepository: typeof GiftCardTransactionRepository
regionService: RegionService
eventBusService: EventBusService
}
/**
* Provides layer to manipulate gift cards.
*/
class GiftCardService extends TransactionBaseService<GiftCardService> {
protected readonly giftCardRepository_: typeof GiftCardRepository
protected readonly giftCardTransactionRepo_: typeof GiftCardTransactionRepository
protected readonly regionService_: RegionService
protected readonly eventBus_: EventBusService
protected manager_: EntityManager
protected transactionManager_: EntityManager | undefined
static Events = {
CREATED: "gift_card.created",
}
constructor({
manager,
giftCardRepository,
giftCardTransactionRepository,
regionService,
eventBusService,
}: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
this.manager_ = manager
this.giftCardRepository_ = giftCardRepository
this.giftCardTransactionRepo_ = giftCardTransactionRepository
this.regionService_ = regionService
this.eventBus_ = eventBusService
}
/**
* Generates a 16 character gift card code
* @return the generated gift card code
*/
static generateCode(): string {
const code = [
randomize("A0", 4),
randomize("A0", 4),
randomize("A0", 4),
randomize("A0", 4),
].join("-")
return code
}
/**
* @param selector - the query object for find
* @param config - the configuration used to find the objects. contains relations, skip, and take.
* @return the result of the find operation
*/
async list(
selector: QuerySelector<GiftCard> = {},
config: FindConfig<GiftCard> = { relations: [], skip: 0, take: 10 }
): Promise<GiftCard[]> {
return await this.atomicPhase_(async (manager) => {
const giftCardRepo = manager.getCustomRepository(this.giftCardRepository_)
let q
if ("q" in selector) {
q = selector.q
delete selector.q
}
const query: ExtendedFindConfig<
GiftCard,
QuerySelector<GiftCard>
> = buildQuery<QuerySelector<GiftCard>, GiftCard>(selector, config)
const rels = query.relations
delete query.relations
return await giftCardRepo.listGiftCards(query, rels, q)
})
}
async createTransaction(
data: CreateGiftCardTransactionInput
): Promise<string> {
return await this.atomicPhase_(async (manager) => {
const gctRepo = manager.getCustomRepository(this.giftCardTransactionRepo_)
const created = gctRepo.create(data)
const saved = await gctRepo.save(created)
return saved.id
})
}
/**
* Creates a gift card with provided data given that the data is validated.
* @param giftCard - the gift card data to create
* @return the result of the create operation
*/
async create(giftCard: CreateGiftCardInput): Promise<GiftCard> {
return await this.atomicPhase_(async (manager) => {
const giftCardRepo = manager.getCustomRepository(this.giftCardRepository_)
// Will throw if region does not exist
const region = await this.regionService_
.withTransaction(manager)
.retrieve(giftCard.region_id)
const code = GiftCardService.generateCode()
const toCreate = {
code,
...giftCard,
region_id: region.id,
}
const created = giftCardRepo.create(toCreate)
const result = await giftCardRepo.save(created)
await this.eventBus_
.withTransaction(manager)
.emit(GiftCardService.Events.CREATED, {
id: result.id,
})
return result
})
}
protected async retrieve_(
selector: Selector<GiftCard>,
config: FindConfig<GiftCard> = {}
): Promise<GiftCard> {
return await this.atomicPhase_(async (manager) => {
const giftCardRepo = manager.getCustomRepository(this.giftCardRepository_)
const { relations, ...query } = buildQuery(selector, config)
const giftCard = await giftCardRepo.findOneWithRelations(
relations as (keyof GiftCard)[],
query
)
if (!giftCard) {
const selectorConstraints = Object.entries(selector)
.map((key, value) => `${key}: ${value}`)
.join(", ")
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Gift card with ${selectorConstraints} was not found`
)
}
return giftCard
})
}
/**
* Gets a gift card by id.
* @param giftCardId - id of gift card to retrieve
* @param config - optional values to include with gift card query
* @return the gift card
*/
async retrieve(
giftCardId: string,
config: FindConfig<GiftCard> = {}
): Promise<GiftCard> {
return await this.atomicPhase_(async () => {
return await this.retrieve_({ id: giftCardId }, config)
})
}
async retrieveByCode(
code: string,
config: FindConfig<GiftCard> = {}
): Promise<GiftCard> {
return await this.atomicPhase_(async () => {
return await this.retrieve_({ code }, config)
})
}
/**
* Updates a giftCard.
* @param giftCardId - giftCard id of giftCard to update
* @param update - the data to update the giftCard with
* @return the result of the update operation
*/
async update(
giftCardId: string,
update: UpdateGiftCardInput
): Promise<GiftCard> {
return await this.atomicPhase_(async (manager) => {
const giftCardRepo = manager.getCustomRepository(this.giftCardRepository_)
const giftCard = await this.retrieve(giftCardId)
const { region_id, metadata, balance, ...rest } = update
if (region_id && region_id !== giftCard.region_id) {
const region = await this.regionService_.retrieve(region_id)
giftCard.region_id = region.id
}
if (metadata) {
giftCard.metadata = setMetadata(giftCard, metadata)
}
if (typeof balance !== "undefined") {
if (balance < 0 || giftCard.value < balance) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"new balance is invalid"
)
}
giftCard.balance = balance
}
for (const [key, value] of Object.entries(rest)) {
giftCard[key] = value
}
return await giftCardRepo.save(giftCard)
})
}
/**
* Deletes a gift card idempotently
* @param giftCardId - id of gift card to delete
* @return the result of the delete operation
*/
async delete(giftCardId: string): Promise<GiftCard | void> {
return await this.atomicPhase_(async (manager) => {
const giftCardRepo = manager.getCustomRepository(this.giftCardRepository_)
const giftCard = await giftCardRepo.findOne({ where: { id: giftCardId } })
if (!giftCard) {
return
}
return await giftCardRepo.softRemove(giftCard)
})
}
}
export default GiftCardService

View File

@@ -46,6 +46,8 @@ export type ExtendedFindConfig<
relations?: string[]
}
export type QuerySelector<TEntity> = Selector<TEntity> & { q?: string }
export type Selector<TEntity> = {
[key in keyof TEntity]?:
| TEntity[key]

View File

@@ -0,0 +1,23 @@
export type CreateGiftCardInput = {
value?: number
balance?: number
ends_at?: Date
is_disabled?: boolean
region_id: string
metadata?: Record<string, unknown>
}
export type UpdateGiftCardInput = {
balance?: number
ends_at?: Date
is_disabled?: boolean
region_id?: string
metadata?: Record<string, unknown>
}
export type CreateGiftCardTransactionInput = {
gift_card_id: string
order_id: string
amount: number
created_at: Date
}