diff --git a/src/controller/productController.ts b/src/controller/productController.ts index cec902d..315caae 100644 --- a/src/controller/productController.ts +++ b/src/controller/productController.ts @@ -1,21 +1,26 @@ -import { Request } from 'express'; import { BaseController } from './baseController'; import { CustomResponseType } from '../types/customResponseType'; import { ResponseObject } from '../utils/responseObject'; import { ProductService } from '../service/productService'; import { NewProductDto } from '../dto/newProductDto'; import { NewProductVo } from '../vo/newProductVo'; -import { TCreateProductsReq } from '../types/product.type'; +import { TCreateProductsReq, TGetProductsReq } from '../types/product.type'; +import { ProductFilterDTO } from '../dto/productFilterDto'; +import { GetProductVo } from '../vo/getProductVo'; class ProductController extends BaseController { private readonly productService = new ProductService(); - public getProducts = async (req: Request): Promise => { - const products = await this.productService.findProducts(); + public getProducts = async ( + req: TGetProductsReq, + ): Promise => { + const productFilterDto = new ProductFilterDTO(req); + const { page, limit } = productFilterDto.getFilter; + const info = await this.productService.findProducts(productFilterDto); return this.formatResponse( CustomResponseType.OK_MESSAGE, CustomResponseType.OK, - products, + new GetProductVo(info, page, limit), ); }; diff --git a/src/dto/productFilterDto.ts b/src/dto/productFilterDto.ts new file mode 100644 index 0000000..277850f --- /dev/null +++ b/src/dto/productFilterDto.ts @@ -0,0 +1,135 @@ +import { IUser } from '../models/user'; +import { + MovieGenre, + ProductSortBy, + ProductType, + RecommendWeightRange, + TGetProductsReq, +} from '../types/product.type'; +import { AccountType } from '../types/user.type'; +import { throwError } from '../utils/errorHandler'; +import { CustomResponseType } from '../types/customResponseType'; +import { + checkDateOrder, + parseBoolean, + parseDate, + parsePositiveInteger, + parseValidEnums, +} from '../utils/common'; + +export class ProductFilterDTO { + public readonly title?: string; + public readonly types?: ProductType[]; + public readonly genres?: MovieGenre[]; + public readonly vendors?: string[]; + public readonly theaters?: string[]; + public readonly isPublic?: boolean; + public readonly isLaunched?: boolean; + public readonly startAtFrom?: Date; + public readonly startAtTo?: Date; + public readonly recommendWeights?: number[]; + public readonly sellStartAtFrom?: Date; + public readonly sellStartAtTo?: Date; + public readonly priceMax?: number; + public readonly priceMin?: number; + public readonly tags?: string[]; + public readonly page?: number; + public readonly limit?: number; + public readonly sortBy?: string; + public readonly accountType: AccountType = AccountType.member; + + get getFilter() { + return this; + } + + constructor(req: TGetProductsReq) { + const { + title, + types, + genres, + vendors, + theaters, + isLaunched, + isPublic, + startAtFrom, + startAtTo, + sellStartAtFrom, + recommendWeights, + sellStartAtTo, + priceMax, + priceMin, + tags, + page, + limit, + sortBy, + } = req.query; + + if ((req.user as IUser)?.accountType === AccountType.admin) { + this.accountType = AccountType.admin; + } + + // number + this.limit = parsePositiveInteger('limit', limit); + this.priceMax = parsePositiveInteger('priceMax', priceMax); + this.priceMin = parsePositiveInteger('priceMin', priceMin); + this.page = parsePositiveInteger('page', page); + + // string + this.title = title; + + // validate + this.types = parseValidEnums('type', ProductType, types); + this.genres = parseValidEnums('genre', MovieGenre, genres); + + const validRecommendWeights: number[] = []; + recommendWeights?.split(',').forEach((weight) => { + const weightNum = parsePositiveInteger('recommendWeight', weight); + if ( + weightNum && + Object.values(RecommendWeightRange).indexOf(weightNum) > -1 + ) { + validRecommendWeights.push(weightNum); + } + }); + + if (validRecommendWeights.length > 0) { + this.recommendWeights = validRecommendWeights; + } + + if (sortBy) { + const isValidSortBy = + Object.keys(ProductSortBy).indexOf(sortBy.trim().replace('-', '')) >= 0; + if (isValidSortBy) { + this.sortBy = sortBy as ProductSortBy; + } else { + throwError( + CustomResponseType.INVALID_PRODUCT_FILTER_MESSAGE + + `: sortBy 不得為 ${sortBy}`, + CustomResponseType.INVALID_PRODUCT_FILTER, + ); + } + } + + // array + this.vendors = vendors?.split(','); + this.theaters = theaters?.split(','); + this.tags = tags?.split(','); + + // boolean + this.isLaunched = parseBoolean('isLaunched', isLaunched); + this.isPublic = parseBoolean('isPublic', isPublic); + + // time + this.startAtTo = parseDate('startAtTo', startAtTo); + this.startAtFrom = parseDate('startAtFrom', startAtFrom); + this.sellStartAtFrom = parseDate('sellStartAtFrom', sellStartAtFrom); + this.sellStartAtTo = parseDate('sellStartAtTo', sellStartAtTo); + // 確認時間順序 + checkDateOrder( + { prop: 'sellStartAtFrom', value: this.sellStartAtFrom }, + { prop: 'sellStartAtTo', value: this.sellStartAtTo }, + { prop: 'startAtFrom', value: this.startAtFrom }, + { prop: 'startAtTo', value: this.startAtTo }, + ); + } +} diff --git a/src/middleware/userVerify.ts b/src/middleware/userVerify.ts index c4324c0..c80263a 100644 --- a/src/middleware/userVerify.ts +++ b/src/middleware/userVerify.ts @@ -1,11 +1,39 @@ -import { Request, Response, NextFunction } from 'express'; +import { Response, NextFunction } from 'express'; import { throwError } from '../utils/errorHandler'; import { CustomResponseType } from '../types/customResponseType'; import jwt, { TokenExpiredError } from 'jsonwebtoken'; import { UserRepository } from '../repository/userRepository'; +import { UserReq } from '../types/common.type'; + +/** + * @description 只取得身分,不限制行為 + */ +export const UserCheck = async ( + req: UserReq, + res: Response, + next: NextFunction, +) => { + const userRepository = new UserRepository(); + + let token: string = ''; + const authorization = req.headers.authorization; + try { + if (authorization && authorization.startsWith('Bearer ')) { + token = authorization.split(' ')[1]; + const payload = jwt.verify(token, process.env.JWT_SECRETS as any); + const user = await userRepository.findById((payload as any).id); + if (user) { + req.user = user; + } + } + return next(); + } catch (err) { + return next(err); + } +}; export const UserVerify = async ( - req: Request, + req: UserReq, res: Response, next: NextFunction, ) => { diff --git a/src/models/product.ts b/src/models/product.ts index 6779146..95601c6 100644 --- a/src/models/product.ts +++ b/src/models/product.ts @@ -21,7 +21,7 @@ export interface IProduct extends Document, ITimestamp { isPublic: boolean; isLaunched: boolean; tags?: [string]; - photoPath: string; + photoPath: string | null; notifications?: [string]; highlights?: [string]; introduction?: string; @@ -148,10 +148,12 @@ const schema = new Schema( min: 1, max: 10, required: true, + select: true, // 只有管理者可以看到 }, isPublic: { type: Boolean, required: true, + select: true, // 只有管理者可以看到 }, isLaunched: { type: Boolean, @@ -175,8 +177,8 @@ const schema = new Schema( ], }, photoPath: { - type: String, - default: '', + type: String || null, + default: null, trim: true, }, notifications: { diff --git a/src/models/user.ts b/src/models/user.ts index 9a30fe4..11a16b5 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -16,7 +16,7 @@ export interface IUser extends Document, ITimestamp { thirdPartyId: string; thirdPartyType: string; isThirdPartyVerified: boolean; - accountType: string; + accountType: AccountType; status: Status; groups: [Schema.Types.ObjectId]; collects: [Schema.Types.ObjectId]; diff --git a/src/repository/productRepository.ts b/src/repository/productRepository.ts index b439dab..5bcfc73 100644 --- a/src/repository/productRepository.ts +++ b/src/repository/productRepository.ts @@ -1,14 +1,84 @@ import { NewProductDto } from '../dto/newProductDto'; +import { ProductFilterDTO } from '../dto/productFilterDto'; import ProductModel, { IProduct } from '../models/product'; +import { AccountType } from '../types/user.type'; export class ProductRepository { + private createProductFilter(productFilterDto: ProductFilterDTO) { + const { + title, + types, + genres, + vendors, + theaters, + isLaunched, + isPublic, + startAtFrom, + startAtTo, + sellStartAtFrom, + recommendWeights, + sellStartAtTo, + priceMax, + priceMin, + tags, + } = productFilterDto.getFilter; + const titleRegex = title ? new RegExp(title) : undefined; + return { + ...(titleRegex && { title: { $regex: titleRegex } }), + ...(types && { type: { $in: types } }), + ...(genres && { genre: { $in: genres } }), + ...(vendors && { vendor: { $in: vendors } }), + ...(theaters && { theater: { $in: theaters } }), + ...(recommendWeights && { recommendWeight: { $in: recommendWeights } }), + ...(isLaunched !== undefined && { isLaunched }), + ...(isPublic !== undefined && { isPublic }), + ...((startAtFrom || startAtTo) && { + startAt: { + ...(startAtFrom && { $lte: startAtFrom }), + ...(startAtTo && { $gte: startAtTo }), + }, + }), + ...((sellStartAtFrom || sellStartAtTo) && { + sellStartAt: { + ...(sellStartAtFrom && { $lte: sellStartAtFrom }), + ...(sellStartAtTo && { $gte: sellStartAtTo }), + }, + }), + ...((priceMax || priceMin) && { + price: { + ...(priceMin && { $lte: priceMin }), + ...(priceMax && { $gte: priceMax }), + }, + }), + ...(tags && { tags: { $in: tags } }), + }; + } + public async createProducts( newProductsDto: NewProductDto, ): Promise { return ProductModel.insertMany(newProductsDto.getNewProducts); } - public async findProducts(): Promise { - return ProductModel.find({}); + public async findProducts( + productFilterDto: ProductFilterDTO, + ): Promise { + const { page, limit, sortBy, accountType } = productFilterDto.getFilter; + const filter = this.createProductFilter(productFilterDto); + const selection = + accountType === AccountType.admin ? '' : '-recommendWeight -isPublic'; + const options = { + ...(page && limit && { skip: (page - 1) * limit }), + ...(limit && { limit }), + sort: sortBy || '-createdAt', + }; + return ProductModel.find(filter, selection, options); + } + + public async countProducts( + productFilterDto: ProductFilterDTO, + ): Promise { + const filter = this.createProductFilter(productFilterDto); + return ProductModel.countDocuments(filter); } } diff --git a/src/routes/productRoute.ts b/src/routes/productRoute.ts index d6061aa..b11534c 100644 --- a/src/routes/productRoute.ts +++ b/src/routes/productRoute.ts @@ -1,6 +1,6 @@ import ProductController from '../controller/productController'; import { IsAdmin } from '../middleware/isAdmin'; -import { UserVerify } from '../middleware/userVerify'; +import { UserCheck, UserVerify } from '../middleware/userVerify'; import { BaseRoute } from './baseRoute'; export class ProductRoute extends BaseRoute { @@ -22,7 +22,180 @@ export class ProductRoute extends BaseRoute { /** * #swagger.tags = ['Product'] * #swagger.summary = '取得商品列表' + * #swagger.security=[{"Bearer": []}], */ + /* #swagger.parameters['title'] = { + in: 'query', + required: false, + description: '模糊搜尋:商品名稱', + type: 'string', + schema:{ + $ref:"#/definitions/CustomGetProductTitleQuery" + } + } + #swagger.parameters['types'] = { + in: 'query', + required: false, + description: '精準搜尋:商品類別 (多個則以逗號分開)', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductTypesQuery" + } + } + #swagger.parameters['genres'] = { + in: 'query', + required: false, + description: '精準搜尋:電影分類 (多個則以逗號分開)', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductGenresQuery" + } + } + #swagger.parameters['vendors'] = { + in: 'query', + required: false, + description: '精準搜尋:供應商 (多個則以逗號分開)', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductVendorsQuery" + } + } + #swagger.parameters['theaters'] = { + in: 'query', + required: false, + description: '精準搜尋:位置 (多個則以逗號分開)', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductTheatersQuery" + } + } + #swagger.parameters['isLaunched'] = { + in: 'query', + required: false, + description: '是否販售', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductIsLaunchedQuery" + } + } + #swagger.parameters['isPublic'] = { + in: 'query', + required: false, + description: '是否公開', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductIsPublicQuery" + } + } + #swagger.parameters['startAtFrom'] = { + in: 'query', + required: false, + description: '開始活動時間-起', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductStartAtFromQuery" + } + } + #swagger.parameters['startAtTo'] = { + in: 'query', + required: false, + description: '開始活動時間-迄', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductStartAtToQuery" + } + } + #swagger.parameters['sellStartAtFrom'] = { + in: 'query', + required: false, + description: '開始販售時間-迄', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductSellStartFromQuery" + } + } + #swagger.parameters['sellStartAtTo'] = { + in: 'query', + required: false, + description: '開始販售時間-迄', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductSellStartToQuery" + } + } + #swagger.parameters['recommendWeights'] = { + in: 'query', + required: false, + description: '精準搜尋:推薦權重 (多個則以逗號分開)', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductRecommendWeightQuery" + } + } + #swagger.parameters['priceMax'] = { + in: 'query', + required: false, + description: '價格區間-最大值', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductPriceMaxQuery" + } + } + #swagger.parameters['priceMin'] = { + in: 'query', + required: false, + description: '價格區間-最小值', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductPriceMinQuery" + } + } + #swagger.parameters['tags'] = { + in: 'query', + required: false, + description: '精準搜尋:標籤 (多個則以逗號分開),先不要用', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductTagQuery" + } + } + #swagger.parameters['page'] = { + in: 'query', + required: true, + description: '頁數', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductPageQuery" + } + } + #swagger.parameters['limit'] = { + in: 'query', + required: false, + description: '每頁資料數', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductLimitQuery" + } + } + #swagger.parameters['sortBy'] = { + in: 'query', + required: false, + description: '排序根據, e.g. startAt, price, sellStartAt, type, vendor, theater, title, _id, soldAmount,createdAt,降冪則在前面加上 - ', + type: 'string', + schema:{ + $ref: "#/definitions/CustomGetProductSortByQuery" + } + } + */ + /* + #swagger.responses[200] = { + description:'OK', + schema:{ + $ref: "#/definitions/CreateProductsSuccess" + } + } + */ + UserCheck, this.responseHandler(this.controller.getProducts), ); @@ -34,7 +207,7 @@ export class ProductRoute extends BaseRoute { * #swagger.security=[{"Bearer": []}], */ /* - #swagger.parameters['obj] ={ + #swagger.parameters['obj'] ={ in:'body', description:'欲新增之商品列表', schema:{ @@ -46,7 +219,7 @@ export class ProductRoute extends BaseRoute { #swagger.responses[200] = { description:'OK', schema:{ - $ref: "#/definitions/CustomCreateProductsSuccess" + $ref: "#/definitions/CreateProductsSuccess" } } */ diff --git a/src/service/productService.ts b/src/service/productService.ts index 7cf35f7..7b9467e 100644 --- a/src/service/productService.ts +++ b/src/service/productService.ts @@ -1,8 +1,10 @@ import log4js from '../config/log4js'; import { NewProductDto } from '../dto/newProductDto'; +import { ProductFilterDTO } from '../dto/productFilterDto'; import { IProduct } from '../models/product'; import { ProductRepository } from '../repository/productRepository'; import { CustomResponseType } from '../types/customResponseType'; +import { AccountType } from '../types/user.type'; import { createErrorMsg, throwError } from '../utils/errorHandler'; const logger = log4js.getLogger(`ProductRepository`); @@ -34,7 +36,79 @@ export class ProductService { }); } - public async findProducts(): Promise { - return this.productRepository.findProducts(); + public async findProducts(productFilterDto: ProductFilterDTO): Promise< + | { + products: IProduct[]; + totalCount: number; + } + | undefined + > { + const { + page, + priceMax, + priceMin, + accountType, + limit, + isPublic, + recommendWeights, + } = productFilterDto.getFilter; + + // 分頁 Check + if (!page) { + throwError( + CustomResponseType.INVALID_PRODUCT_FILTER_MESSAGE + + `: page 不得為 ${page}`, + CustomResponseType.INVALID_PRODUCT_FILTER, + ); + return; + } + + // price Check: priceMax 要大於 priceMin + if (priceMax && priceMin && priceMax < priceMin) { + throwError( + CustomResponseType.INVALID_PRODUCT_FILTER_MESSAGE + + ': priceMax 不得小於 priceMin', + CustomResponseType.INVALID_PRODUCT_FILTER, + ); + } + + // 使用者與管理者權限確認 + if (accountType !== AccountType.admin) { + // limit Check: 使用者只能一次取 100 則資料,管理者可以不限量或超過 100 筆 + if (!limit || limit > 100) { + throwError( + CustomResponseType.INVALID_PRODUCT_FILTER_MESSAGE + + ': 使用者只能一次取 100 則資料', + CustomResponseType.INVALID_PRODUCT_FILTER, + ); + } + // isLaunched Check: 使用者只能查公開的商品,不可以查非公開的商品 + if (!isPublic) { + throwError( + CustomResponseType.INVALID_PRODUCT_FILTER_MESSAGE + + ': 使用者只能查公開的商品', + CustomResponseType.INVALID_PRODUCT_FILTER, + ); + } + // recommendWeight Check: 使用者不能搜權重 + if (recommendWeights) { + throwError( + CustomResponseType.INVALID_PRODUCT_FILTER_MESSAGE + + ': 使用者不能搜權重', + CustomResponseType.INVALID_PRODUCT_FILTER, + ); + } + } + + const products = + await this.productRepository.findProducts(productFilterDto); + + const totalCount = + await this.productRepository.countProducts(productFilterDto); + + return { + products, + totalCount, + }; } } diff --git a/src/swagger-output.json b/src/swagger-output.json index 9a5955c..806d476 100644 --- a/src/swagger-output.json +++ b/src/swagger-output.json @@ -182,11 +182,206 @@ ], "summary": "取得商品列表", "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "type": "string" + }, + { + "name": "title", + "in": "query", + "required": false, + "description": "模糊搜尋:商品名稱", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductTitleQuery" + } + }, + { + "name": "types", + "in": "query", + "required": false, + "description": "精準搜尋:商品類別 (多個則以逗號分開)", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductTypesQuery" + } + }, + { + "name": "genres", + "in": "query", + "required": false, + "description": "精準搜尋:電影分類 (多個則以逗號分開)", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductGenresQuery" + } + }, + { + "name": "vendors", + "in": "query", + "required": false, + "description": "精準搜尋:供應商 (多個則以逗號分開)", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductVendorsQuery" + } + }, + { + "name": "theaters", + "in": "query", + "required": false, + "description": "精準搜尋:位置 (多個則以逗號分開)", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductTheatersQuery" + } + }, + { + "name": "isLaunched", + "in": "query", + "required": false, + "description": "是否販售", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductIsLaunchedQuery" + } + }, + { + "name": "isPublic", + "in": "query", + "required": false, + "description": "是否公開", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductIsPublicQuery" + } + }, + { + "name": "startAtFrom", + "in": "query", + "required": false, + "description": "開始活動時間-起", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductStartAtFromQuery" + } + }, + { + "name": "startAtTo", + "in": "query", + "required": false, + "description": "開始活動時間-迄", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductStartAtToQuery" + } + }, + { + "name": "sellStartAtFrom", + "in": "query", + "required": false, + "description": "開始販售時間-迄", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductSellStartFromQuery" + } + }, + { + "name": "sellStartAtTo", + "in": "query", + "required": false, + "description": "開始販售時間-迄", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductSellStartToQuery" + } + }, + { + "name": "recommendWeights", + "in": "query", + "required": false, + "description": "精準搜尋:推薦權重 (多個則以逗號分開)", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductRecommendWeightQuery" + } + }, + { + "name": "priceMax", + "in": "query", + "required": false, + "description": "價格區間-最大值", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductPriceMaxQuery" + } + }, + { + "name": "priceMin", + "in": "query", + "required": false, + "description": "價格區間-最小值", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductPriceMinQuery" + } + }, + { + "name": "tags", + "in": "query", + "required": false, + "description": "精準搜尋:標籤 (多個則以逗號分開),先不要用", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductTagQuery" + } + }, + { + "name": "page", + "in": "query", + "required": true, + "description": "頁數", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductPageQuery" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "每頁資料數", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductLimitQuery" + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "排序根據, e.g. startAt, price, sellStartAt, type, vendor, theater, title, _id, soldAmount,createdAt,降冪則在前面加上 - ", + "type": "string", + "schema": { + "$ref": "#/definitions/CustomGetProductSortByQuery" + } + } + ], "responses": { - "default": { - "description": "" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/CreateProductsSuccess" + } } - } + }, + "security": [ + { + "Bearer": [] + } + ] }, "post": { "tags": [ @@ -213,7 +408,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/CustomCreateProductsSuccess" + "$ref": "#/definitions/CreateProductsSuccess" } }, "6213": { @@ -658,7 +853,7 @@ "properties": { "title": { "type": "string", - "example": "string" + "example": "這是一個商品名稱" }, "type": { "type": "string", @@ -726,34 +921,33 @@ }, "sellEndAt": { "type": "string", - "example": "2024-05-09T15:42:53.661Z" + "example": "2024-05-10T11:53:57.214Z" }, "sellStartAt": { "type": "string", - "example": "2024-05-09T13:42:53.662Z" + "example": "2024-05-10T09:53:57.215Z" }, "endAt": { "type": "string", - "example": "2024-05-09T19:42:53.662Z" + "example": "2024-05-10T15:53:57.215Z" }, "startAt": { "type": "string", - "example": "2024-05-09T17:42:53.662Z" + "example": "2024-05-10T13:53:57.215Z" }, "tags": { "type": "array", - "example": [ - "A", - "B" - ], "items": { - "type": "string" + "type": "object", + "properties": { + "tagId": { + "type": "string", + "example": "123" + } + } } }, - "photoPath": { - "type": "string", - "example": "" - }, + "photoPath": {}, "notifications": { "type": "array", "example": [ @@ -834,8 +1028,26 @@ "startAt" ] } + }, + "page": { + "type": "number", + "example": 1 + }, + "limit": { + "type": "number", + "example": 10 + }, + "totalCount": { + "type": "number", + "example": 1 } - } + }, + "required": [ + "products", + "page", + "limit", + "totalCount" + ] } }, "required": [ @@ -892,7 +1104,7 @@ "properties": { "title": { "type": "string", - "example": "string", + "example": "這是一個商品名稱", "description": "商品名稱" }, "type": { @@ -947,7 +1159,7 @@ "type": "number", "example": 1100, "min": 100, - "description": "單買一張的票價" + "description": "單張票價" }, "amount": { "type": "number", @@ -963,6 +1175,7 @@ }, "plans": { "type": "array", + "description": "銷售方案", "items": { "type": "object", "required": [ @@ -1001,7 +1214,7 @@ "isLaunched": { "type": "boolean", "example": false, - "description": "是否開始販賣,尚未公開的情況下,商品不可以進行販賣" + "description": "是否開始販賣,尚未公開的情況下,商品不可以進行販賣,可販賣的商品須同時在販賣時間的區間且 isLaunched 為 true" }, "isPublic": { "type": "boolean", @@ -1017,37 +1230,38 @@ }, "sellEndAt": { "type": "Date", - "example": "2024-05-09T15:42:53.661Z", - "min": "2024-05-08T14:42:53.662Z", + "example": "2024-05-10T11:53:57.214Z", + "min": "2024-05-09T10:53:57.215Z", "description": "販賣結束時間,必須晚於販賣開始時間至少一個小時" }, "sellStartAt": { "type": "Date", - "example": "2024-05-09T13:42:53.662Z", - "min": "2024-05-08T13:42:53.662Z", + "example": "2024-05-10T09:53:57.215Z", + "min": "2024-05-09T09:53:57.216Z", "description": "販賣開始時間,必須晚於現在時間至少一天" }, "endAt": { "type": "Date", - "example": "2024-05-09T19:42:53.662Z", - "min": "2024-05-08T16:42:53.662Z", + "example": "2024-05-10T15:53:57.215Z", + "min": "2024-05-09T12:53:57.216Z", "description": "活動結束時間,必須晚於活動開始時間至少一個小時" }, "startAt": { "type": "Date", - "example": "2024-05-09T17:42:53.662Z", - "min": "2024-05-08T15:42:53.663Z", + "example": "2024-05-10T13:53:57.215Z", + "min": "2024-05-09T11:53:57.216Z", "description": "活動開始時間,必須晚於販售結束時間至少一個小時" }, "tags": { "type": "array", + "description": "標籤列表", "items": { "type": "object", "properties": { "tagId": { "type": "string", "example": "AAA", - "description": "標籤" + "description": "標籤,先把這個拿掉" } } } @@ -1055,7 +1269,7 @@ "photoPath": { "type": "string", "description": "商品圖片 Url", - "example": "" + "example": null }, "notifications": { "type": "array", @@ -1127,6 +1341,60 @@ } } } + }, + "CustomGetProductTitleQuery": { + "example": "很棒的特映會" + }, + "CustomGetProductTypesQuery": { + "example": "corporateBooking,openAir" + }, + "CustomGetProductGenresQuery": { + "example": "action,drama" + }, + "CustomGetProductVendorsQuery": { + "example": "貓咪影業,小狗影業" + }, + "CustomGetProductTheatersQuery": { + "example": "信義威秀,晶站威秀" + }, + "CustomGetProductIsLaunchedQuery": { + "example": "true" + }, + "CustomGetProductIsPublicQuery": { + "example": "true" + }, + "CustomGetProductStartAtFromQuery": { + "example": "2024-05-16T03:33:20.000+00:00" + }, + "CustomGetProductStartAtToQuery": { + "example": "2024-05-17T03:33:20.000+00:00" + }, + "CustomGetProductSellStartFromQuery": { + "example": "2024-05-18T03:33:20.000+00:00" + }, + "CustomGetProductSellStartToQuery": { + "example": "2024-05-19T03:33:20.000+00:00" + }, + "CustomGetProductRecommendWeightQuery": { + "example": "1,2,3" + }, + "CustomGetProductPriceMaxQuery": { + "example": "110" + }, + "CustomGetProductPriceMinQuery": { + "example": "10" + }, + "CustomGetProductTagQuery": { + "example": "日舞影展,金馬影展" + }, + "CustomGetProductPageQuery": { + "example": "1" + }, + "CustomGetProductLimitQuery": { + "example": "10" + }, + "CustomGetProductSortByQuery": { + "example": "-createdAt" } } } \ No newline at end of file diff --git a/src/swagger/definition/index.ts b/src/swagger/definition/index.ts index 3b51832..2b7504e 100644 --- a/src/swagger/definition/index.ts +++ b/src/swagger/definition/index.ts @@ -16,7 +16,28 @@ import { ValidateEmailError, CreateProductsError, } from './common'; -import { CustomCreateProductsObj, CreateProductsSuccess } from './product'; +import { + CustomCreateProductsObj, + CreateProductsSuccess, + CustomGetProductTitleQuery, + CustomGetProductTypesQuery, + CustomGetProductGenresQuery, + CustomGetProductTheatersQuery, + CustomGetProductVendorsQuery, + CustomGetProductIsPublicQuery, + CustomGetProductIsLaunchedQuery, + CustomGetProductSellStartFromQuery, + CustomGetProductStartAtFromQuery, + CustomGetProductStartAtToQuery, + CustomGetProductRecommendWeightQuery, + CustomGetProductPriceMaxQuery, + CustomGetProductSellStartToQuery, + CustomGetProductLimitQuery, + CustomGetProductPageQuery, + CustomGetProductPriceMinQuery, + CustomGetProductSortByQuery, + CustomGetProductTagQuery, +} from './product'; export const definitions = { Success, @@ -40,4 +61,22 @@ export const definitions = { export const customDefinitions = { CustomCreateProductsObj, + CustomGetProductTitleQuery, + CustomGetProductTypesQuery, + CustomGetProductGenresQuery, + CustomGetProductVendorsQuery, + CustomGetProductTheatersQuery, + CustomGetProductIsLaunchedQuery, + CustomGetProductIsPublicQuery, + CustomGetProductStartAtFromQuery, + CustomGetProductStartAtToQuery, + CustomGetProductSellStartFromQuery, + CustomGetProductSellStartToQuery, + CustomGetProductRecommendWeightQuery, + CustomGetProductPriceMaxQuery, + CustomGetProductPriceMinQuery, + CustomGetProductTagQuery, + CustomGetProductPageQuery, + CustomGetProductLimitQuery, + CustomGetProductSortByQuery, }; diff --git a/src/swagger/definition/product.ts b/src/swagger/definition/product.ts index f6c0142..e80ac44 100644 --- a/src/swagger/definition/product.ts +++ b/src/swagger/definition/product.ts @@ -2,6 +2,39 @@ import moment from 'moment'; import { CustomResponseType } from '../../types/customResponseType'; import { ProductType, MovieGenre } from '../../types/product.type'; +const propName = { + title: '商品名稱', + type: '商品類別', + genre: '電影分類', + vendor: '供應商', + theater: '位置', + price: '單張票價', + amount: '票券總量', + soldAmount: '已銷售數量', + plan: { + name: '方案名稱', + discount: '方案折扣數', + headCount: '方案包含人數', + }, + introduction: '商品介紹', + isLaunched: '是否開始販賣', + isPublic: '是否公開', + recommendWeight: '推薦權重', + sellEndAt: '販賣結束時間', + sellStartAt: '販賣開始時間', + endAt: '活動結束時間', + startAt: '活動開始時間', + tags: '標籤列表', + tag: '標籤', + photoPath: '商品圖片 Url', + notifications: '通知列表', + highlights: '活動亮點列表', + cautions: '注意事項列表', + confirmations: '確認詳情列表', + cancelPolicies: '取消政策列表', + certificates: '憑證類型列表', +}; + const CustomPlan = { name: '三人同行好棒棒', discount: 0.5, @@ -9,7 +42,7 @@ const CustomPlan = { }; const CustomProduct = { - $title: 'string', + $title: '這是一個商品名稱', $type: ProductType.premier, $genre: MovieGenre.action, $vendor: '貓咪影業', @@ -26,8 +59,8 @@ const CustomProduct = { $sellStartAt: moment().add(2, 'days').toISOString(), $endAt: moment().add(2, 'day').add(6, 'hour').toISOString(), $startAt: moment().add(2, 'day').add(4, 'hour').toISOString(), - tags: ['A', 'B'], - photoPath: '', + tags: [{ tagId: '123' }, { tagId: '123' }], + photoPath: null, notifications: ['通知一', '通知二'], highlights: ['亮點一', '亮點二'], cautions: ['事項一', '事項二'], @@ -40,7 +73,18 @@ export const CreateProductsSuccess = { $status: CustomResponseType.OK, $message: CustomResponseType.OK_MESSAGE, $data: { - products: [CustomProduct], + $products: [CustomProduct], + $page: 1, + $limit: 10, + $totalCount: 1, + }, +}; + +export const FindProductSuccess = { + $status: CustomResponseType.OK, + $message: CustomResponseType.OK_MESSAGE, + $data: { + $products: [CustomProduct], }, }; @@ -74,57 +118,58 @@ export const CustomCreateProductsObj = { title: { type: 'string', example: CustomProduct.$title, - description: '商品名稱', + description: propName.title, }, type: { type: 'string', example: CustomProduct.$type, enum: Object.values(ProductType), - description: '商品類別', + description: propName.type, }, genre: { type: 'string', example: CustomProduct.$genre, enum: Object.values(MovieGenre), - description: '電影分類', + description: propName.genre, }, vendor: { type: 'string', example: CustomProduct.$vendor, - description: '供應商', + description: propName.vendor, }, theater: { type: 'string', example: CustomProduct.$theater, - description: '位置', + description: propName.theater, }, price: { type: 'number', example: CustomProduct.$price, min: 100, - description: '單買一張的票價', + description: propName.price, }, amount: { type: 'number', example: CustomProduct.$amount, min: 0, - description: '票券總量,不得低於最大人數方案。', + description: `${propName.amount},不得低於最大人數方案。`, }, soldAmount: { type: 'number', example: CustomProduct.$soldAmount, min: 0, - description: '已銷售數量', + description: propName.soldAmount, }, plans: { type: 'array', + description: '銷售方案', items: { type: 'object', required: ['name', 'discount', 'headCount'], properties: { name: { type: 'string', - description: '方案名稱', + description: propName.plan.name, example: CustomPlan.name, min: 2, }, @@ -133,13 +178,13 @@ export const CustomCreateProductsObj = { max: 1, min: 0.1, example: CustomPlan.discount, - description: '方案折扣數', + description: propName.plan.discount, }, headCount: { type: 'number', min: 0, example: CustomPlan.headCount, - description: '方案包含人數', + description: propName.plan.headCount, }, }, }, @@ -147,70 +192,71 @@ export const CustomCreateProductsObj = { introduction: { type: 'string', example: CustomProduct.$introduction, - description: '商品介紹 (html)', + description: `${propName.introduction} (html)`, }, isLaunched: { type: 'boolean', example: CustomProduct.$isLaunched, - description: '是否開始販賣,尚未公開的情況下,商品不可以進行販賣', + description: `${propName.isLaunched},尚未公開的情況下,商品不可以進行販賣,可販賣的商品須同時在販賣時間的區間且 isLaunched 為 true`, }, isPublic: { type: 'boolean', example: CustomProduct.$isPublic, - description: '是否公開', + description: propName.isPublic, }, recommendWeight: { type: 'number', min: 1, max: 10, - example: CreateProductsSuccess.$data.products[0].$recommendWeight, - description: '推薦權重', + example: CreateProductsSuccess.$data.$products[0].$recommendWeight, + description: propName.recommendWeight, }, sellEndAt: { type: 'Date', example: CustomProduct.$sellEndAt, min: moment().add(1, 'day').add(1, 'hour'), - description: '販賣結束時間,必須晚於販賣開始時間至少一個小時', + description: `${propName.sellEndAt},必須晚於販賣開始時間至少一個小時`, }, sellStartAt: { type: 'Date', example: CustomProduct.$sellStartAt, min: moment().add(1, 'day'), - description: '販賣開始時間,必須晚於現在時間至少一天', + description: `${propName.sellStartAt},必須晚於現在時間至少一天`, }, endAt: { type: 'Date', example: CustomProduct.$endAt, min: moment().add(1, 'day').add(3, 'hour'), - description: '活動結束時間,必須晚於活動開始時間至少一個小時', + description: `${propName.endAt},必須晚於活動開始時間至少一個小時`, }, startAt: { type: 'Date', example: CustomProduct.$startAt, min: moment().add(1, 'day').add(2, 'hour'), - description: '活動開始時間,必須晚於販售結束時間至少一個小時', + description: `${propName.startAt},必須晚於販售結束時間至少一個小時`, }, tags: { type: 'array', + description: propName.tags, items: { type: 'object', properties: { tagId: { type: 'string', example: 'AAA', - description: '標籤', + description: `${propName.tag},先把這個拿掉`, }, }, }, }, photoPath: { type: 'string', - description: '商品圖片 Url', + description: propName.photoPath, example: CustomProduct.photoPath, }, notifications: { type: 'array', - description: '通知列表', + description: propName.notifications, example: CustomProduct.notifications, items: { type: 'string', @@ -218,7 +264,7 @@ export const CustomCreateProductsObj = { }, highlights: { type: 'array', - description: '活動亮點列表', + description: propName.highlights, example: CustomProduct.highlights, items: { type: 'string', @@ -226,7 +272,7 @@ export const CustomCreateProductsObj = { }, cautions: { type: 'array', - description: '注意事項列表', + description: propName.cautions, example: CustomProduct.cautions, items: { type: 'string', @@ -234,7 +280,7 @@ export const CustomCreateProductsObj = { }, confirmations: { type: 'array', - description: '確認詳情列表', + description: propName.confirmations, example: CustomProduct.confirmations, items: { type: 'string', @@ -242,7 +288,7 @@ export const CustomCreateProductsObj = { }, cancelPolicies: { type: 'array', - description: '取消政策列表', + description: propName.cancelPolicies, example: CustomProduct.cancelPolicies, items: { type: 'string', @@ -250,7 +296,7 @@ export const CustomCreateProductsObj = { }, certificates: { type: 'array', - description: '憑證類型列表', + description: propName.certificates, example: CustomProduct.certificates, items: { type: 'string', @@ -261,3 +307,63 @@ export const CustomCreateProductsObj = { }, }, }; + +export const CustomGetProductTitleQuery = { + example: '很棒的特映會', +}; + +export const CustomGetProductTypesQuery = { + example: `${ProductType.corporateBooking},${ProductType.openAir}`, +}; + +export const CustomGetProductGenresQuery = { + example: `${MovieGenre.action},${MovieGenre.drama}`, +}; + +export const CustomGetProductVendorsQuery = { + example: '貓咪影業,小狗影業', +}; + +export const CustomGetProductTheatersQuery = { + example: '信義威秀,晶站威秀', +}; +export const CustomGetProductIsLaunchedQuery = { + example: 'true', +}; +export const CustomGetProductIsPublicQuery = { + example: 'true', +}; +export const CustomGetProductStartAtFromQuery = { + example: '2024-05-16T03:33:20.000+00:00', +}; +export const CustomGetProductStartAtToQuery = { + example: '2024-05-17T03:33:20.000+00:00', +}; +export const CustomGetProductSellStartFromQuery = { + example: '2024-05-18T03:33:20.000+00:00', +}; +export const CustomGetProductSellStartToQuery = { + example: '2024-05-19T03:33:20.000+00:00', +}; + +export const CustomGetProductRecommendWeightQuery = { + example: '1,2,3', +}; +export const CustomGetProductPriceMaxQuery = { + example: '110', +}; +export const CustomGetProductPriceMinQuery = { + example: '10', +}; +export const CustomGetProductTagQuery = { + example: '日舞影展,金馬影展', +}; +export const CustomGetProductPageQuery = { + example: '1', +}; +export const CustomGetProductLimitQuery = { + example: '10', +}; +export const CustomGetProductSortByQuery = { + example: '-createdAt', +}; diff --git a/src/types/common.type.ts b/src/types/common.type.ts index d038f56..6c7e331 100644 --- a/src/types/common.type.ts +++ b/src/types/common.type.ts @@ -1,3 +1,10 @@ +import { IUser } from '../models/user'; +import { Request } from 'express'; + +export interface UserReq extends Request { + user?: IUser | Express.User; +} + export interface ITimestamp { createdAt: Date; updatedAt: Date; diff --git a/src/types/customResponseType.ts b/src/types/customResponseType.ts index f638b50..c2ac6ff 100644 --- a/src/types/customResponseType.ts +++ b/src/types/customResponseType.ts @@ -95,4 +95,19 @@ export const enum CustomResponseType { VALIDATE_EMAIL_ERROR = '6505', VALIDATE_EMAIL_ERROR_MESSAGE = '信箱驗證失敗', + + INVALID_PRODUCT_FILTER = '6506', + INVALID_PRODUCT_FILTER_MESSAGE = '無效的商品條件', + + INVALID_TIME = '6507', + INVALID_TIME_MESSAGE = '無效的時間', + + INVALID_NUMBER = '6508', + INVALID_NUMBER_MESSAGE = '無效的數字', + + INVALID_TIME_ORDER = '6509', + INVALID_TIME_ORDER_MESSAGE = '錯誤的時間順序', + + INVALID_BOOLEAN = '6510', + INVALID_BOOLEAN_MESSAGE = '無效的 Boolean 值', } diff --git a/src/types/product.type.ts b/src/types/product.type.ts index c9fb788..3f3356d 100644 --- a/src/types/product.type.ts +++ b/src/types/product.type.ts @@ -1,5 +1,6 @@ import { IProduct } from '../models/product'; import { Request } from 'express'; +import { UserReq } from './common.type'; export enum ProductType { premier = 'premier', @@ -31,14 +32,58 @@ export enum MovieGenre { historical = 'historical', } +export const RecommendWeightRange = { + 1: 1, + 2: 2, + 3: 3, + 4: 4, + 5: 5, +}; + export type TPlan = { name: string; // 方案名稱 discount: number; // 方案折扣數 headCount: number; // 該方案包含幾張票 }; -export type TCreateProductsReq = Request< - unknown, - unknown, - { products: [IProduct] } ->; +export interface TCreateProductsReq extends Request { + body: { + products: IProduct[]; + }; +} + +export interface TGetProductsReq extends UserReq { + query: { + title?: string; + types?: string; + genres?: string; + vendors?: string; + theaters?: string; + isPublic?: string; + isLaunched?: string; + startAtFrom?: string; + startAtTo?: string; + sellStartAtFrom?: string; + sellStartAtTo?: string; + recommendWeights?: string; + priceMax?: string; + priceMin?: string; + tags?: string; + page?: string; + limit?: string; + sortBy?: string; + }; +} + +export enum ProductSortBy { + startAt = 'startAt', + price = 'price', + sellStartAt = 'sellStartAt', + type = 'type', + vendor = 'vendor', + theater = 'theater', + title = 'title', + _id = '_id', + soldAmount = 'soldAmount', + createdAt = 'createdAt', +} diff --git a/src/utils/common.ts b/src/utils/common.ts new file mode 100644 index 0000000..b3a8712 --- /dev/null +++ b/src/utils/common.ts @@ -0,0 +1,94 @@ +import moment from 'moment'; +import { throwError } from './errorHandler'; +import { CustomResponseType } from '../types/customResponseType'; + +export const parseValidEnums = >( + prop: string, + enumType: T, + value?: string, +): T[keyof T][] | undefined => { + if (!value) return undefined; + const validValues: T[keyof T][] = []; + value.split(',').forEach((value: string) => { + if (Object.values(enumType).includes(value)) { + validValues.push(value as T[keyof T]); + } else { + throwError( + `${CustomResponseType.INVALID_PRODUCT_FILTER_MESSAGE}: ${prop} 不得為 ${value}`, + CustomResponseType.INVALID_PRODUCT_FILTER, + ); + } + }); + return validValues; +}; + +export const parseBoolean = (prop: string, value?: string) => { + if (!value) { + return undefined; + } + if (value === 'true') { + return true; + } else if (value === 'false') { + return false; + } else { + throwError( + CustomResponseType.INVALID_BOOLEAN_MESSAGE + `: ${prop} => ${value}`, + CustomResponseType.INVALID_BOOLEAN, + ); + } +}; + +export const parseDate = (prop: string, value?: string) => { + if (!value) { + return undefined; + } + const parseDate = moment(value); + if (parseDate.isValid()) { + return parseDate.toDate(); + } else { + throwError( + CustomResponseType.INVALID_TIME_MESSAGE + `: ${prop} => ${value}`, + CustomResponseType.INVALID_TIME, + ); + } +}; + +export const parsePositiveInteger = (prop: string, value?: string) => { + if (!value) { + return undefined; + } + const parseNum = Number(value); + if (!Number.isNaN(value) && parseNum >= 0 && Number.isInteger(parseNum)) { + return parseNum; + } else { + throwError( + CustomResponseType.INVALID_NUMBER_MESSAGE + `: ${prop} => ${value}`, + CustomResponseType.INVALID_NUMBER, + ); + } +}; + +/** + * @description 比較日期,如果其中一个日期是 undefined,則認為它比另一个日期大 + */ +const compareDates = (date1: Date | undefined, date2: Date | undefined) => { + if (!date1 || !date2) { + return 0; + } + return date1.getTime() - date2.getTime(); +}; + +/** + * @description 時間順序判斷 + */ +export const checkDateOrder = (...dates: { prop: string; value?: Date }[]) => { + for (let i = 0; i < dates.length - 1; i++) { + if (compareDates(dates[i].value, dates[i + 1].value) > 0) { + throwError( + CustomResponseType.INVALID_TIME_ORDER_MESSAGE + + `: ${dates[i].prop} => ${dates[i].value} / ${dates[i + 1].prop} => ${dates[i].value}`, + CustomResponseType.INVALID_TIME_ORDER, + ); + } + } +}; diff --git a/src/vo/getProductVo.ts b/src/vo/getProductVo.ts new file mode 100644 index 0000000..3eb685e --- /dev/null +++ b/src/vo/getProductVo.ts @@ -0,0 +1,23 @@ +import { IProduct } from '../models/product'; + +export class GetProductVo { + private products: IProduct[] = []; + private page: number = 1; + private limit: number = 0; + private totalCount: number = 0; + constructor( + info: + | { + products: IProduct[]; + totalCount: number; + } + | undefined, + page: number | undefined, + limit: number | undefined, + ) { + this.products = info?.products || []; + this.page = page || 1; + this.limit = limit || info?.totalCount || 0; + this.totalCount = info?.totalCount || 0; + } +}