From 3a7b43ab7044326e5bd4a01e7b929b7e72ad51eb Mon Sep 17 00:00:00 2001 From: RogerLi Date: Sun, 16 Jun 2024 00:05:44 +0800 Subject: [PATCH 1/3] feat: add get group detail and separate get groups api --- src/controller/groupController.ts | 9 ++++ src/controller/userController.ts | 19 ++++++++ src/dto/group/getUserGroupDto.ts | 55 ++++++++++++++++++++++++ src/dto/group/groupFilterDto.ts | 16 ++++--- src/repository/groupRepository.ts | 22 ++++++++++ src/routes/groupRoute.ts | 27 ++++++++++-- src/routes/userRoute.ts | 51 ++++++++++++++++++++++ src/service/groupService.ts | 4 ++ src/service/productService.ts | 24 +++++------ src/service/userService.ts | 55 +++++++++++++++++++++++- src/swagger/definition/group/custom.ts | 3 ++ src/swagger/definition/group/general.ts | 30 +++++++++++++ src/types/group.type.ts | 20 +++++++++ src/validator/group/getUserGroup.pipe.ts | 46 ++++++++++++++++++++ 14 files changed, 359 insertions(+), 22 deletions(-) create mode 100644 src/dto/group/getUserGroupDto.ts create mode 100644 src/validator/group/getUserGroup.pipe.ts diff --git a/src/controller/groupController.ts b/src/controller/groupController.ts index 26726b7..2e99588 100644 --- a/src/controller/groupController.ts +++ b/src/controller/groupController.ts @@ -15,6 +15,7 @@ import { LeaveGroupDto } from '../dto/group/leaveGroupDto'; import { GroupFilterDto } from '../dto/group/groupFilterDto'; import { GetGroupVo } from '../vo/group/getGroupVo'; import { Types } from 'mongoose'; +import { Request } from 'express'; import { IUser } from '../models/user'; import { TMethod } from '../types/common.type'; @@ -79,4 +80,12 @@ export class GroupController extends BaseController { new GetGroupVo(groups), ); }; + public getGroupDetail = async (req: Request) => { + const group = await this.groupService.findGroupDetail(req.params.groupId); + return this.formatResponse( + CustomResponseType.OK_MESSAGE, + CustomResponseType.OK, + group, + ); + }; } diff --git a/src/controller/userController.ts b/src/controller/userController.ts index d7a4096..8574a05 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -12,6 +12,11 @@ import { GetUserFavoriteDTO } from '../dto/user/getUserFavoriteDto'; import { GetFavoriteVO } from '../vo/user/getFavoriteVo'; import { SellTicketDto } from '../dto/ticket/sellTicketDto'; import { ISellTicketReq } from '../types/ticket.type'; +import { GetUserGroupDto } from '../dto/group/getUserGroupDto'; +import { GetGroupVo } from '../vo/group/getGroupVo'; +import { PaginateDocument, PaginateOptions, PaginateResult } from 'mongoose'; +import { IGroup } from '../models/group'; +import { IGetUserGroupsReq } from '../types/group.type'; class UserController extends BaseController { private readonly userService = new UserService(); @@ -87,6 +92,20 @@ class UserController extends BaseController { tickets, ); }; + + public getUserGroups: TMethod = async (req: IGetUserGroupsReq) => { + const getUserGroupDto = new GetUserGroupDto(req); + const groups = (await this.userService.getUserGroups( + getUserGroupDto, + )) as PaginateResult< + PaginateDocument, PaginateOptions> + >; + return this.formatResponse( + CustomResponseType.OK_MESSAGE, + CustomResponseType.OK, + new GetGroupVo(groups), + ); + }; } export default UserController; diff --git a/src/dto/group/getUserGroupDto.ts b/src/dto/group/getUserGroupDto.ts new file mode 100644 index 0000000..5610c54 --- /dev/null +++ b/src/dto/group/getUserGroupDto.ts @@ -0,0 +1,55 @@ +import { GroupSortField } from '../../types/group.type'; +import { SortOrder } from '../../types/common.type'; +import { Request } from 'express'; +import { IUser } from '../../models/user'; + +export class GetUserGroupDto { + private readonly _user: IUser; + private readonly _groupType: string; + private readonly _page: number; + private readonly _limit: number; + private readonly _sort: Record; + + get groupType(): string { + return this._groupType; + } + + get user(): IUser { + return this._user; + } + + get ownFilter() { + return { + ...(this._user && { userId: { $eq: this._user._id } }), + }; + } + + get ownOptions() { + return { + page: this._page, + limit: this._limit, + sort: this._sort, + }; + } + + get joinedOptions() { + return { + page: this._page, + limit: this._limit, + sort: this._sort, + }; + } + + constructor(req: Request) { + const { page, limit, sortField, sortOrder, groupType } = req.query; + + this._sort = { + [`${sortField || GroupSortField.createdAt}`]: + sortOrder === SortOrder.asc ? 1 : -1, + }; + this._limit = Number(limit); + this._page = Number(page); + this._user = req.user as IUser; + this._groupType = groupType as string; + } +} diff --git a/src/dto/group/groupFilterDto.ts b/src/dto/group/groupFilterDto.ts index 1efe041..34dae54 100644 --- a/src/dto/group/groupFilterDto.ts +++ b/src/dto/group/groupFilterDto.ts @@ -4,8 +4,6 @@ import { GroupStatus, IGetGroupsReq, } from '../../types/group.type'; -import { IUser } from '../../models/user'; -import { Types } from 'mongoose'; import { SortOrder } from '../../types/common.type'; export class GroupFilterDto { @@ -20,12 +18,10 @@ export class GroupFilterDto { private readonly _page: number; private readonly _limit: number; private readonly _sort: Record; - private readonly _userId: Types.ObjectId | undefined; get filter() { const titleRegex = this._title ? new RegExp(this._title) : undefined; return { - ...(this._userId && { userId: { $eq: this._userId } }), ...(titleRegex && { title: { $regex: titleRegex } }), ...(this._movieTitle && { movieTitle: { $in: this._movieTitle } }), ...(this._status && { status: { $eq: this._status } }), @@ -48,6 +44,17 @@ export class GroupFilterDto { page: this._page, limit: this._limit, sort: this._sort, + projection: { + title: 1, + placeholderImg: 1, + theater: 1, + movieTitle: 1, + status: 1, + time: 1, + amount: 1, + haveTicket: 1, + content: 1, + }, }; } @@ -82,6 +89,5 @@ export class GroupFilterDto { haveTicket === undefined ? undefined : haveTicket === 'true'; this._startAt = startAt ? moment(startAt).toDate() : undefined; this._endAt = endAt ? moment(endAt).toDate() : undefined; - this._userId = req.user !== undefined ? (req.user as IUser).id : undefined; } } diff --git a/src/repository/groupRepository.ts b/src/repository/groupRepository.ts index 42a55af..7b289a3 100644 --- a/src/repository/groupRepository.ts +++ b/src/repository/groupRepository.ts @@ -6,6 +6,7 @@ import { PaginateDocument, PaginateOptions, Types, + FilterQuery, } from 'mongoose'; import { JoinGroupDto } from '../dto/group/joinGroupDto'; import { LeaveGroupDto } from '../dto/group/leaveGroupDto'; @@ -21,6 +22,27 @@ export class GroupRepository { return GroupModel.findById(groupId); } + public async findByIds( + groupIds: string[], + option: PaginateOptions | undefined, + ) { + return GroupModel.paginate( + { + _id: { + $in: groupIds, + }, + }, + option, + ); + } + + public async findByUserId( + filter: FilterQuery | undefined, + option: PaginateOptions | undefined, + ) { + return GroupModel.paginate(filter, option); + } + public async updateGroup(updateGroupDto: UpdateGroupDto) { return GroupModel.findByIdAndUpdate( { _id: new Types.ObjectId(updateGroupDto.groupId) }, diff --git a/src/routes/groupRoute.ts b/src/routes/groupRoute.ts index 2cf96d3..34bd018 100644 --- a/src/routes/groupRoute.ts +++ b/src/routes/groupRoute.ts @@ -1,6 +1,6 @@ import { BaseRoute } from './baseRoute'; import { GroupController } from '../controller/groupController'; -import { UserCheck, UserVerify } from '../middleware/userVerify'; +import { UserVerify } from '../middleware/userVerify'; import { CreateGroupPipe } from '../validator/group/createGroup.pipe'; import { UpdateGroupPipe } from '../validator/group/updateGroup.pipe'; import { JoinGroupPipe } from '../validator/group/joinGroup.pipe'; @@ -163,12 +163,34 @@ export class GroupRoute extends BaseRoute { UserVerify, this.responseHandler(this.controller.deleteGroup), ); + this.router.get( + '/v1/group/:groupId', + /** + * #swagger.tags = ['Group'] + * #swagger.summary = '取得揪團詳細' + */ + /* + #swagger.parameters['groupId'] ={ + in:'path', + description:'揪團ID', + required: true, + type: 'string' + } + */ + /** + #swagger.responses[200] = { + description: 'OK', + schema: { + $ref: '#/definitions/GetGroupDetailSuccess' } + } + */ + this.responseHandler(this.controller.getGroupDetail), + ); this.router.get( '/v1/group', /** * #swagger.tags = ['Group'] * #swagger.summary = '取得揪團列表' - * #swagger.security=[{"Bearer": []}], */ /* #swagger.parameters['limit'] = { @@ -283,7 +305,6 @@ export class GroupRoute extends BaseRoute { } } */ - UserCheck, this.usePipe(GetGroupsPipe), this.responseHandler(this.controller.getGroups), ); diff --git a/src/routes/userRoute.ts b/src/routes/userRoute.ts index c7b529e..557a45b 100644 --- a/src/routes/userRoute.ts +++ b/src/routes/userRoute.ts @@ -2,6 +2,7 @@ import { BaseRoute } from './baseRoute'; import UserController from '../controller/userController'; import { UserVerify } from '../middleware/userVerify'; import { GetUserFavoritePipe } from '../validator/user/getUserFavorite.pipe'; +import { GetUserGroupPipe } from '../validator/group/getUserGroup.pipe'; export class UserRoute extends BaseRoute { protected controller!: UserController; @@ -223,5 +224,55 @@ export class UserRoute extends BaseRoute { UserVerify, this.responseHandler(this.controller.getTransferableTicket), ); + this.router.get( + '/v1/user/groups', + /** + * #swagger.tags = ['User'] + * #swagger.summary = '取得用戶我的揪團' + * #swagger.security=[{"Bearer": []}] + */ + /* + #swagger.parameters['groupType'] = { + in: 'query', + required: true, + description: '已建立 or 已參加', + type: 'string', + enum: ['own', 'joined'], + schema:{ + $ref: "#/definitions/CustomGetGroupTypeQuery" + } + } + #swagger.parameters['page'] = { + in: 'query', + required: true, + description: '頁數', + type: 'number', + schema:{ + $ref: "#/definitions/CustomPageQuery" + } + } + #swagger.parameters['sortOrder'] = { + in: 'query', + required: false, + description: '排序順序', + type: 'string', + enum: ["asc", "desc"], + schema:{ + $ref: "#/definitions/CustomSortOrderQuery" + } + } + */ + /* + #swagger.responses[200]={ + description:'OK', + schema:{ + $ref:'#/definitions/GetUserGroupSuccess' + } + } + */ + UserVerify, + this.usePipe(GetUserGroupPipe), + this.responseHandler(this.controller.getUserGroups), + ); } } diff --git a/src/service/groupService.ts b/src/service/groupService.ts index 15aadeb..1a784b3 100644 --- a/src/service/groupService.ts +++ b/src/service/groupService.ts @@ -146,4 +146,8 @@ export class GroupService { public async findGroups(groupFilterDto: GroupFilterDto) { return await this.groupRepository.findGroups(groupFilterDto); } + + public async findGroupDetail(groupId: string) { + return await this.groupRepository.findById(new Types.ObjectId(groupId)); + } } diff --git a/src/service/productService.ts b/src/service/productService.ts index e3519e0..de7ee8d 100644 --- a/src/service/productService.ts +++ b/src/service/productService.ts @@ -87,20 +87,18 @@ export class ProductService { favorite.productId.toString(), ); } - result.docs = await Promise.all( - result.docs.map(async (doc) => { - let isFavorite = false; - if (user) { - if (favoriteProductIds.includes(doc._id.toString())) { - isFavorite = true; - } + result.docs = result.docs.map((doc) => { + let isFavorite = false; + if (user) { + if (favoriteProductIds.includes(doc._id.toString())) { + isFavorite = true; } - return { - ...doc.toObject(), - isFavorite: isFavorite, - } as ProductDocumentWithFavorite; - }), - ); + } + return { + ...doc.toObject(), + isFavorite: isFavorite, + } as ProductDocumentWithFavorite; + }); return result; }; diff --git a/src/service/userService.ts b/src/service/userService.ts index 46fae02..25456eb 100644 --- a/src/service/userService.ts +++ b/src/service/userService.ts @@ -20,9 +20,18 @@ import { GoogleSignUpDTO } from '../dto/user/googleSignUpdDto'; import { SellTicketDto } from '../dto/ticket/sellTicketDto'; import { TicketRepository } from '../repository/ticketRepository'; import { ITicket } from '../models/ticket'; -import { Types } from 'mongoose'; +import { + PaginateDocument, + PaginateOptions, + PaginateResult, + Types, +} from 'mongoose'; import { IProduct } from '../models/product'; import { GetTransferableTicketVo } from '../vo/ticket/getTransferableTicketVo'; +import { GroupRepository } from '../repository/groupRepository'; +import { GroupDocument, IGroupId } from '../types/group.type'; +import { GetUserGroupDto } from '../dto/group/getUserGroupDto'; +import { IGroup } from '../models/group'; const logger = log4js.getLogger(`UserService`); @@ -33,6 +42,7 @@ export class UserService { new ProductRepository(); private readonly ticketRepository: TicketRepository = new TicketRepository(); + private readonly groupRepository: GroupRepository = new GroupRepository(); public async createUser({ pwd, email, account }: SignUpDTO) { const hashPwd = bcrypt.hashSync(pwd, 10); @@ -109,6 +119,29 @@ export class UserService { return user; } + public async getUserGroups(getUserGroupDto: GetUserGroupDto) { + switch (getUserGroupDto.groupType) { + case 'own': { + const ownResult = await this.groupRepository.findByUserId( + getUserGroupDto.ownFilter, + getUserGroupDto.ownOptions, + ); + return this.addVacancy(ownResult); + } + case 'joined': { + const groupIds = + getUserGroupDto.user.groups !== undefined + ? getUserGroupDto.user.groups + : ([] as IGroupId[]); + const joinedResult = await this.groupRepository.findByIds( + groupIds.map((id) => id.groupId.toString()), + getUserGroupDto.joinedOptions, + ); + return this.addVacancy(joinedResult); + } + } + } + public async forgotPwd(email: string) { const user = await this.userRepository.findByEmail(email); if (!user) { @@ -363,4 +396,24 @@ export class UserService { }); return Promise.all(map); }; + private addVacancy( + result: PaginateResult< + PaginateDocument< + IGroup, + NonNullable, + PaginateOptions | undefined + > + >, + ) { + result.docs = result.docs.map((doc) => { + const participants = + doc.participant != undefined ? doc.participant.length : 0; + const vacancy = doc.amount - participants; + return { + ...doc.toObject(), + vacancy: vacancy, + } as GroupDocument; + }); + return result; + } } diff --git a/src/swagger/definition/group/custom.ts b/src/swagger/definition/group/custom.ts index c5d8058..081b767 100644 --- a/src/swagger/definition/group/custom.ts +++ b/src/swagger/definition/group/custom.ts @@ -10,6 +10,9 @@ export const CustomGetGroupStatusQuery = { export const CustomGetGroupCountQuery = { example: 5, }; +export const CustomGetGroupTypeQuery = { + example: 'own', +}; import { CustomResponseType } from '../../../types/customResponseType'; diff --git a/src/swagger/definition/group/general.ts b/src/swagger/definition/group/general.ts index 4d70f52..8061c7e 100644 --- a/src/swagger/definition/group/general.ts +++ b/src/swagger/definition/group/general.ts @@ -15,6 +15,19 @@ export const GroupItem = { time: '2024-05-19', vacancy: 2, content: '參加參加', +}; + +export const UserGroupItem = { + $_id: 'asdfasdfasd', + title: '這是一個活動名稱', + movieTitle: '電影名稱', + amount: 10, + placeholderImg: 'imageUrl', + location: '信義威秀', + hasTicket: false, + time: '2024-05-19', + vacancy: 9, + content: '參加參加', participant: [ { userId: '123123aabb', @@ -36,3 +49,20 @@ export const GetGroupsSuccess = { ...PaginationSuccess, }, }; + +export const GetGroupDetailSuccess = { + $status: CustomResponseType.OK, + $message: CustomResponseType.OK_MESSAGE, + $data: { + UserGroupItem, + }, +}; + +export const GetUserGroupSuccess = { + $status: CustomResponseType.OK, + $message: CustomResponseType.OK_MESSAGE, + $data: { + $groups: [UserGroupItem], + ...PaginationSuccess, + }, +}; diff --git a/src/types/group.type.ts b/src/types/group.type.ts index c9f84e3..d442e0e 100644 --- a/src/types/group.type.ts +++ b/src/types/group.type.ts @@ -1,12 +1,17 @@ import { Types } from 'mongoose'; import { IUserReq, TPaginationQuery } from './common.type'; import { IUserId } from './user.type'; +import { IGroup } from '../models/group'; export enum GroupStatus { ongoing = 'ongoing', // 正在揪團 cancelled = 'cancelled', // 取消揪團 completed = 'completed', // 完成揪團 } +export enum GroupType { + own = 'own', // 是主揪的揪團 + joined = 'joined', // 是參加人的揪團 +} export interface IGroupId { groupId: Types.ObjectId; @@ -46,6 +51,21 @@ export interface IGetGroupsReq extends IUserReq { }; } +export interface IGetUserGroupsReq extends IUserReq { + query: TPaginationQuery & { + groupType?: string; + }; +} + +export interface IGetUserGroups extends IGroup { + vacancy: number; +} + +export type GroupDocument = Document & + IGetUserGroups & { + _id: Types.ObjectId; + }; + export interface TJoinGroupReq extends IUserReq { body: IParticipant; } diff --git a/src/validator/group/getUserGroup.pipe.ts b/src/validator/group/getUserGroup.pipe.ts new file mode 100644 index 0000000..eebdbc4 --- /dev/null +++ b/src/validator/group/getUserGroup.pipe.ts @@ -0,0 +1,46 @@ +import { PipeBase } from '../pipe.base'; +import { query } from 'express-validator'; +import { CustomResponseType } from '../../types/customResponseType'; +import { GroupType, IGetGroupsReq } from '../../types/group.type'; +import { OptionType, TCustomValidator } from '../index.type'; +import { SortOrder } from '../../types/common.type'; + +export class GetUserGroupPipe extends PipeBase { + private validateStartAt: TCustomValidator = (value, { req }) => { + const { endAt } = (req as IGetGroupsReq).query; + return this.validatePeriod(value, endAt, (a, b) => a.isBefore(b)); + }; + + private validateEndAt: TCustomValidator = (value, { req }) => { + const { startAt } = (req as IGetGroupsReq).query; + return this.validatePeriod(value, startAt, (a, b) => a.isAfter(b)); + }; + + public transform = () => [ + this.limitValidation( + query('limit'), + CustomResponseType.INVALID_GROUP_FILTER_MESSAGE + 'limit', + ), + this.positiveIntValidation( + query('page'), + CustomResponseType.INVALID_GROUP_FILTER_MESSAGE + 'page', + ), + query('groupType') + .exists() + .isIn(Object.keys(GroupType)) + .withMessage( + CustomResponseType.INVALID_GROUP_FILTER_MESSAGE + 'groupType', + ), + query('sortOrder') + .optional() + .custom(this.validateOption(OptionType.item, SortOrder)) + .withMessage( + CustomResponseType.INVALID_GROUP_FILTER_MESSAGE + 'sortOrder', + ), + this.validationHandler, + ]; + + constructor() { + super(); + } +} From aba961ed27c583676521fdf5bb7b7c8d8ee7010d Mon Sep 17 00:00:00 2001 From: RogerLi Date: Sun, 16 Jun 2024 00:06:50 +0800 Subject: [PATCH 2/3] fix: remove unused function --- src/validator/group/getUserGroup.pipe.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/validator/group/getUserGroup.pipe.ts b/src/validator/group/getUserGroup.pipe.ts index eebdbc4..37315ea 100644 --- a/src/validator/group/getUserGroup.pipe.ts +++ b/src/validator/group/getUserGroup.pipe.ts @@ -1,21 +1,11 @@ import { PipeBase } from '../pipe.base'; import { query } from 'express-validator'; import { CustomResponseType } from '../../types/customResponseType'; -import { GroupType, IGetGroupsReq } from '../../types/group.type'; -import { OptionType, TCustomValidator } from '../index.type'; +import { GroupType } from '../../types/group.type'; +import { OptionType } from '../index.type'; import { SortOrder } from '../../types/common.type'; export class GetUserGroupPipe extends PipeBase { - private validateStartAt: TCustomValidator = (value, { req }) => { - const { endAt } = (req as IGetGroupsReq).query; - return this.validatePeriod(value, endAt, (a, b) => a.isBefore(b)); - }; - - private validateEndAt: TCustomValidator = (value, { req }) => { - const { startAt } = (req as IGetGroupsReq).query; - return this.validatePeriod(value, startAt, (a, b) => a.isAfter(b)); - }; - public transform = () => [ this.limitValidation( query('limit'), From f5b0469f116f41d4343a74ec29909bd9483a64e4 Mon Sep 17 00:00:00 2001 From: RogerLi Date: Sun, 16 Jun 2024 00:19:47 +0800 Subject: [PATCH 3/3] doc: update swagger --- src/swagger/definition/group/general.ts | 29 ++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/swagger/definition/group/general.ts b/src/swagger/definition/group/general.ts index 8061c7e..f883df4 100644 --- a/src/swagger/definition/group/general.ts +++ b/src/swagger/definition/group/general.ts @@ -10,8 +10,8 @@ export const GroupItem = { movieTitle: '電影名稱', amount: 10, placeholderImg: 'imageUrl', - location: '信義威秀', - hasTicket: false, + theater: '信義威秀', + haveTicket: false, time: '2024-05-19', vacancy: 2, content: '參加參加', @@ -23,8 +23,9 @@ export const UserGroupItem = { movieTitle: '電影名稱', amount: 10, placeholderImg: 'imageUrl', - location: '信義威秀', - hasTicket: false, + theater: '信義威秀', + haveTicket: false, + status: 'ongoing', time: '2024-05-19', vacancy: 9, content: '參加參加', @@ -54,7 +55,25 @@ export const GetGroupDetailSuccess = { $status: CustomResponseType.OK, $message: CustomResponseType.OK_MESSAGE, $data: { - UserGroupItem, + $_id: 'asdfasdfasd', + title: '這是一個活動名稱', + movieTitle: '電影名稱', + amount: 10, + status: 'ongoing', + placeholderImg: 'imageUrl', + theater: '信義威秀', + haveTicket: false, + time: '2024-05-19', + vacancy: 9, + content: '參加參加', + participant: [ + { + userId: '123123aabb', + name: '阿明', + nickName: '小明', + lineId: '1234567', + }, + ], }, };