diff --git a/Dockerfile b/Dockerfile index 093bb5a3..2f035856 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,4 +10,4 @@ FROM node:14.15.3-alpine WORKDIR /app COPY --from=build /app /app EXPOSE 3000 -CMD ["yarn", "start"] +CMD ["yarn", "release"] diff --git a/api/controllers/FeedbackController.ts b/api/controllers/FeedbackController.ts index a1ed9b83..91db2921 100644 --- a/api/controllers/FeedbackController.ts +++ b/api/controllers/FeedbackController.ts @@ -1,4 +1,5 @@ -import { Body, ForbiddenError, Get, JsonController, Params, Patch, Post, UseBefore } from 'routing-controllers'; +import { Body, ForbiddenError, Get, JsonController, Params, + Patch, Post, UseBefore, QueryParams } from 'routing-controllers'; import { AuthenticatedUser } from '../decorators/AuthenticatedUser'; import { UserModel } from '../../models/UserModel'; import PermissionsService from '../../services/PermissionsService'; @@ -9,6 +10,7 @@ import { UserAuthentication } from '../middleware/UserAuthentication'; import { SubmitFeedbackRequest, UpdateFeedbackStatusRequest, + FeedbackSearchOptions, } from '../validators/FeedbackControllerRequests'; @UseBefore(UserAuthentication) @@ -21,9 +23,10 @@ export class FeedbackController { } @Get() - async getFeedback(@AuthenticatedUser() user: UserModel): Promise { - const canSeeAllFeedback = PermissionsService.canRespondToFeedback(user); - const feedback = await this.feedbackService.getFeedback(canSeeAllFeedback, user); + async getFeedback(@QueryParams() options: FeedbackSearchOptions, + @AuthenticatedUser() user: UserModel): Promise { + const canSeeAllFeedback = PermissionsService.canSeeAllFeedback(user); + const feedback = await this.feedbackService.getFeedback(canSeeAllFeedback, user, options); return { error: null, feedback }; } @@ -39,7 +42,7 @@ export class FeedbackController { async updateFeedbackStatus(@Params() params: UuidParam, @Body() updateFeedbackStatusRequest: UpdateFeedbackStatusRequest, @AuthenticatedUser() user: UserModel): Promise { - if (!PermissionsService.canRespondToFeedback(user)) throw new ForbiddenError(); + if (!PermissionsService.canSeeAllFeedback(user)) throw new ForbiddenError(); const feedback = await this.feedbackService.updateFeedbackStatus(params.uuid, updateFeedbackStatusRequest.status); return { error: null, feedback }; } diff --git a/api/controllers/MerchStoreController.ts b/api/controllers/MerchStoreController.ts index 4bcf915e..628d62e5 100644 --- a/api/controllers/MerchStoreController.ts +++ b/api/controllers/MerchStoreController.ts @@ -49,6 +49,7 @@ import { CompleteOrderPickupEventResponse, GetOrderPickupEventResponse, CancelOrderPickupEventResponse, + CancelMerchOrderResponse, } from '../../types'; import { UuidParam } from '../validators/GenericRequests'; import { AuthenticatedUser } from '../decorators/AuthenticatedUser'; @@ -309,10 +310,11 @@ export class MerchStoreController { } @Post('/order/:uuid/cancel') - async cancelMerchOrder(@Params() params: UuidParam, @AuthenticatedUser() user: UserModel) { + async cancelMerchOrder(@Params() params: UuidParam, + @AuthenticatedUser() user: UserModel): Promise { if (!PermissionsService.canAccessMerchStore(user)) throw new ForbiddenError(); const order = await this.merchStoreService.cancelMerchOrder(params.uuid, user); - return { error: null, order }; + return { error: null, order: order.getPublicOrderWithItems() }; } @Post('/order/:uuid/fulfill') diff --git a/api/controllers/UserController.ts b/api/controllers/UserController.ts index e80f4347..1ecb88e5 100644 --- a/api/controllers/UserController.ts +++ b/api/controllers/UserController.ts @@ -108,16 +108,15 @@ export class UserController { @AuthenticatedUser() user: UserModel): Promise { const userSocialMedia = await this.userSocialMediaService .insertSocialMediaForUser(user, insertSocialMediaRequest.socialMedia); - return { error: null, userSocialMedia: userSocialMedia.getPublicSocialMedia() }; + return { error: null, userSocialMedia: userSocialMedia.map((socialMedia) => socialMedia.getPublicSocialMedia()) }; } - @Patch('/socialMedia/:uuid') - async updateSocialMediaForUser(@Params() params: UuidParam, - @Body() updateSocialMediaRequest: UpdateSocialMediaRequest, + @Patch('/socialMedia') + async updateSocialMediaForUser(@Body() updateSocialMediaRequest: UpdateSocialMediaRequest, @AuthenticatedUser() user: UserModel): Promise { const userSocialMedia = await this.userSocialMediaService - .updateSocialMediaByUuid(user, params.uuid, updateSocialMediaRequest.socialMedia); - return { error: null, userSocialMedia: userSocialMedia.getPublicSocialMedia() }; + .updateSocialMediaByUuid(user, updateSocialMediaRequest.socialMedia); + return { error: null, userSocialMedia: userSocialMedia.map((socialMedia) => socialMedia.getPublicSocialMedia()) }; } @Delete('/socialMedia/:uuid') diff --git a/api/validators/FeedbackControllerRequests.ts b/api/validators/FeedbackControllerRequests.ts index c699b84c..da712713 100644 --- a/api/validators/FeedbackControllerRequests.ts +++ b/api/validators/FeedbackControllerRequests.ts @@ -1,22 +1,27 @@ import { Type } from 'class-transformer'; -import { IsDefined, IsNotEmpty, MinLength, ValidateNested } from 'class-validator'; +import { Allow, IsDefined, IsNotEmpty, MinLength, ValidateNested } from 'class-validator'; import { IsValidFeedbackType, IsValidFeedbackStatus } from '../decorators/Validators'; import { SubmitEventFeedbackRequest as ISubmitEventFeedbackRequest, SubmitFeedbackRequest as ISubmitFeedbackRequest, UpdateFeedbackStatusRequest as IUpdateFeedbackStatusRequest, + FeedbackSearchOptions as IFeedbackSearchOptions, Feedback as IFeedback, FeedbackType, FeedbackStatus, + Uuid, } from '../../types'; export class Feedback implements IFeedback { @IsDefined() @IsNotEmpty() - title: string; + event: Uuid; @IsDefined() - @MinLength(100) + source: string; + + @IsDefined() + @MinLength(20) description: string; @IsDefined() @@ -41,3 +46,17 @@ export class UpdateFeedbackStatusRequest implements IUpdateFeedbackStatusRequest @IsValidFeedbackStatus() status: FeedbackStatus; } + +export class FeedbackSearchOptions implements IFeedbackSearchOptions { + @Allow() + event?: string; + + @Allow() + status?: string; + + @Allow() + type?: string; + + @Allow() + user?: string; +} diff --git a/api/validators/UserSocialMediaControllerRequests.ts b/api/validators/UserSocialMediaControllerRequests.ts index 0a304e78..2f9a4672 100644 --- a/api/validators/UserSocialMediaControllerRequests.ts +++ b/api/validators/UserSocialMediaControllerRequests.ts @@ -20,20 +20,25 @@ export class SocialMedia implements ISocialMedia { } export class SocialMediaPatches implements ISocialMediaPatches { + @IsDefined() @IsNotEmpty() - url?: string; + uuid: string; + + @IsDefined() + @IsNotEmpty() + url: string; } export class InsertSocialMediaRequest implements IInsertUserSocialMediaRequest { @Type(() => SocialMedia) @ValidateNested() @IsDefined() - socialMedia: SocialMedia; + socialMedia: SocialMedia[]; } export class UpdateSocialMediaRequest implements IUpdateUserSocialMediaRequest { @Type(() => SocialMediaPatches) @ValidateNested() @IsDefined() - socialMedia: SocialMediaPatches; + socialMedia: SocialMediaPatches[]; } diff --git a/migrations/0042-remove-users-lastLogin-column.ts b/migrations/0042-remove-users-lastLogin-column.ts new file mode 100644 index 00000000..71ffbfd4 --- /dev/null +++ b/migrations/0042-remove-users-lastLogin-column.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +const TABLE_NAME = 'Users'; + +export class RemoveUsersLastLoginColumn1711518997063 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn(TABLE_NAME, 'lastLogin'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn(TABLE_NAME, new TableColumn({ + name: 'lastLogin', + type: 'timestamptz', + default: 'CURRENT_TIMESTAMP(6)', + })); + } +} diff --git a/migrations/0043-add-feedback-event-colum.ts b/migrations/0043-add-feedback-event-colum.ts new file mode 100644 index 00000000..f9ea6a22 --- /dev/null +++ b/migrations/0043-add-feedback-event-colum.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableIndex, TableForeignKey } from 'typeorm'; + +const TABLE_NAME = 'Feedback'; +const OLD_NAME = 'title'; +const NEW_NAME = 'source'; + +export class AddFeedbackEventColum1711860173561 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "${TABLE_NAME}" RENAME COLUMN "${OLD_NAME}" TO "${NEW_NAME}"`); + await queryRunner.query(`ALTER TABLE "${TABLE_NAME}" ALTER COLUMN "${NEW_NAME}" TYPE text`); + + await queryRunner.addColumn( + TABLE_NAME, + new TableColumn({ + name: 'event', + type: 'uuid', + isNullable: true, + }), + ); + + await queryRunner.createIndex( + TABLE_NAME, + new TableIndex({ + name: 'feedback_by_event_index', + columnNames: ['event'], + }), + ); + + await queryRunner.createForeignKey( + TABLE_NAME, + new TableForeignKey({ + columnNames: ['event'], + referencedTableName: 'Events', + referencedColumnNames: ['uuid'], + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropForeignKey(TABLE_NAME, new TableForeignKey({ + columnNames: ['event'], + referencedTableName: 'Events', + referencedColumnNames: ['uuid'], + onDelete: 'CASCADE', + })); + + await queryRunner.dropIndex(TABLE_NAME, 'feedback_by_event_index'); + await queryRunner.dropColumn(TABLE_NAME, 'event'); + + await queryRunner.query(`ALTER TABLE "${TABLE_NAME}" ALTER COLUMN "${NEW_NAME}" TYPE varchar(255)`); + await queryRunner.query(`ALTER TABLE "${TABLE_NAME}" RENAME COLUMN "${NEW_NAME}" TO "${OLD_NAME}"`); + } +} diff --git a/models/EventModel.ts b/models/EventModel.ts index 99f2d20e..e4a5f40f 100644 --- a/models/EventModel.ts +++ b/models/EventModel.ts @@ -2,6 +2,7 @@ import * as moment from 'moment'; import { BaseEntity, Column, Entity, Index, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; import { PublicEvent, Uuid } from '../types'; import { AttendanceModel } from './AttendanceModel'; +import { FeedbackModel } from './FeedbackModel'; import { ExpressCheckinModel } from './ExpressCheckinModel'; @Entity('Events') @@ -60,6 +61,9 @@ export class EventModel extends BaseEntity { @OneToMany((type) => AttendanceModel, (attendance) => attendance.event, { cascade: true }) attendances: AttendanceModel[]; + @OneToMany((type) => FeedbackModel, (feedback) => feedback.event, { cascade: true }) + feedback: FeedbackModel[]; + @OneToMany((type) => ExpressCheckinModel, (expressCheckin) => expressCheckin.event, { cascade: true }) expressCheckins: ExpressCheckinModel[]; diff --git a/models/FeedbackModel.ts b/models/FeedbackModel.ts index 1c9df5d8..2bc2348d 100644 --- a/models/FeedbackModel.ts +++ b/models/FeedbackModel.ts @@ -1,6 +1,7 @@ import { BaseEntity, Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { FeedbackStatus, FeedbackType, PublicFeedback, Uuid } from '../types'; import { UserModel } from './UserModel'; +import { EventModel } from './EventModel'; @Entity('Feedback') export class FeedbackModel extends BaseEntity { @@ -12,8 +13,15 @@ export class FeedbackModel extends BaseEntity { @Index('feedback_by_user_index') user: UserModel; - @Column('varchar', { length: 255 }) - title: string; + @ManyToOne((type) => EventModel, (event) => event.feedback, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'event' }) + @Index('feedback_by_event_index') + event: EventModel; + + // source refers to how the user heard about the event + // ie, Instagram, Portal, Discord, etc. + @Column('text') + source: string; @Column('text') description: string; @@ -31,7 +39,8 @@ export class FeedbackModel extends BaseEntity { return { uuid: this.uuid, user: this.user.getPublicProfile(), - title: this.title, + event: this.event.getPublicEvent(), + source: this.source, description: this.description, timestamp: this.timestamp, status: this.status, diff --git a/models/UserModel.ts b/models/UserModel.ts index 6f4e09cb..677ec3f1 100644 --- a/models/UserModel.ts +++ b/models/UserModel.ts @@ -65,9 +65,6 @@ export class UserModel extends BaseEntity { @Column('integer', { default: 0 }) credits: number; - @Column('timestamptz', { default: () => 'CURRENT_TIMESTAMP(6)' }) - lastLogin: Date; - @OneToMany((type) => ActivityModel, (activity) => activity.user, { cascade: true }) activities: ActivityModel[]; diff --git a/package.json b/package.json index ffa2d67f..e854d21b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "3.4.2", + "version": "3.5.1", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ @@ -12,6 +12,7 @@ "start": "node build/index.js", "test": "jest --config jest.config.json --runInBand", "dev": "nodemon -L -e ts -i node_modules -i .git -V index.ts", + "release": "ts-node ./node_modules/typeorm/cli.js migration:run && node build/index.js", "lint": "eslint ./ --ext .ts", "lint:fix": "eslint ./ --ext .ts --fix", "db:migrate:create": "ts-node ./node_modules/typeorm/cli.js migration:create -n", diff --git a/repositories/FeedbackRepository.ts b/repositories/FeedbackRepository.ts index d0a4f24d..aa6250d4 100644 --- a/repositories/FeedbackRepository.ts +++ b/repositories/FeedbackRepository.ts @@ -1,21 +1,30 @@ -import { EntityRepository } from 'typeorm'; +import { EntityRepository, SelectQueryBuilder } from 'typeorm'; import { FeedbackModel } from '../models/FeedbackModel'; import { UserModel } from '../models/UserModel'; +import { EventModel } from '../models/EventModel'; import { BaseRepository } from './BaseRepository'; -import { Uuid } from '../types'; +import { FeedbackSearchOptions, Uuid } from '../types'; @EntityRepository(FeedbackModel) export class FeedbackRepository extends BaseRepository { - public async getAllFeedback(): Promise { - return this.getBaseFindQuery().getMany(); + public async getAllFeedback(options: FeedbackSearchOptions): Promise { + return this.getBaseFindQuery(options).getMany(); } public async findByUuid(uuid: Uuid): Promise { - return this.getBaseFindQuery().where({ uuid }).getOne(); + return this.getBaseFindQuery({}).where({ uuid }).getOne(); } - public async getAllFeedbackForUser(user: UserModel): Promise { - return this.getBaseFindQuery().where({ user }).getMany(); + // temporary fix for getting feedback for a user for an event + public async getStandardUserFeedback(user: UserModel, options: FeedbackSearchOptions): Promise { + let query = this.getBaseFindQuery(options); + query = query.andWhere('feedback.user = :user', { user: user.uuid }); + + return query.getMany(); + } + + public async getAllFeedbackForUser(user: UserModel, options: FeedbackSearchOptions): Promise { + return this.getBaseFindQuery(options).where({ user }).getMany(); } public async upsertFeedback(feedback: FeedbackModel, @@ -24,9 +33,32 @@ export class FeedbackRepository extends BaseRepository { return this.repository.save(feedback); } - private getBaseFindQuery() { - return this.repository.createQueryBuilder('feedback') + public async hasUserSubmittedFeedback(user: UserModel, event: EventModel): Promise { + const count = await this.repository.count({ + where: { user, event }, + }); + return count > 0; + } + + private getBaseFindQuery(options: FeedbackSearchOptions): SelectQueryBuilder { + let query = this.repository.createQueryBuilder('feedback') .leftJoinAndSelect('feedback.user', 'user') + .leftJoinAndSelect('feedback.event', 'event') .orderBy('timestamp', 'DESC'); + + if (options.event) { + query = query.andWhere('event = :event').setParameter('event', options.event); + } + if (options.user) { + query = query.andWhere('"user" = :user').setParameter('user', options.user); + } + if (options.type) { + query = query.andWhere('type = :type').setParameter('type', options.type); + } + if (options.status) { + query = query.andWhere('status = :status').setParameter('status', options.status); + } + + return query; } } diff --git a/repositories/MerchOrderRepository.ts b/repositories/MerchOrderRepository.ts index 139677da..769ceefd 100644 --- a/repositories/MerchOrderRepository.ts +++ b/repositories/MerchOrderRepository.ts @@ -11,7 +11,7 @@ import { MerchandiseItemModel } from '../models/MerchandiseItemModel'; export class MerchOrderRepository extends BaseRepository { /** * Gets a single order. Returns the order joined with ordered items, - * user, pickup event, the ordered items' merch options, + * user, pickup event, the pickup event's linked event, the ordered items' merch options, * and those merch options' merch items. * * This is the same set of joins that gets executed for OrderPickupEventRepository::findByUuid() @@ -25,12 +25,13 @@ export class MerchOrderRepository extends BaseRepository { .leftJoinAndSelect('orderItem.option', 'option') .leftJoinAndSelect('option.item', 'merchItem') .leftJoinAndSelect('merchItem.merchPhotos', 'merchPhotos') + .leftJoinAndSelect('orderPickupEvent.linkedEvent', 'linkedEvent') .where('order.uuid = :uuid', { uuid }) .getOne(); } /** - * Gets all orders for all users. Returns the order joined with its pickup event. + * Gets all orders for all users. Returns the order joined with its pickup event and linked event. * Can optionally filter by order status. */ public async getAllOrdersForAllUsers(...statuses: OrderStatus[]): Promise { @@ -41,14 +42,25 @@ export class MerchOrderRepository extends BaseRepository { }, }); } - return this.repository.find(); + return this.repository + .createQueryBuilder('order') + .leftJoinAndSelect('order.pickupEvent', 'orderPickupEvent') + .leftJoinAndSelect('order.user', 'user') + .leftJoinAndSelect('orderPickupEvent.linkedEvent', 'linkedEvent') + .getMany(); } /** - * Gets all orders for a given user. Returns the order joined with its pickup event and user. + * Gets all orders for a given user. Returns the order joined with its pickup event, linked event, and user. */ public async getAllOrdersForUser(user: UserModel): Promise { - return this.repository.find({ user }); + return this.repository + .createQueryBuilder('order') + .leftJoinAndSelect('order.pickupEvent', 'orderPickupEvent') + .leftJoinAndSelect('order.user', 'user') + .leftJoinAndSelect('orderPickupEvent.linkedEvent', 'linkedEvent') + .where('order.user = :uuid', { uuid: user.uuid }) + .getMany(); } /** diff --git a/services/FeedbackService.ts b/services/FeedbackService.ts index 8b5690c6..f8d5f059 100644 --- a/services/FeedbackService.ts +++ b/services/FeedbackService.ts @@ -5,9 +5,8 @@ import { NotFoundError } from 'routing-controllers'; import { FeedbackModel } from '../models/FeedbackModel'; import { UserModel } from '../models/UserModel'; import Repositories, { TransactionsManager } from '../repositories'; -import { PublicFeedback, Feedback, Uuid, ActivityType, FeedbackStatus } from '../types'; +import { PublicFeedback, Feedback, Uuid, ActivityType, FeedbackStatus, FeedbackSearchOptions } from '../types'; import { UserError } from '../utils/Errors'; -import { Config } from '../config'; @Service() export default class FeedbackService { @@ -17,28 +16,41 @@ export default class FeedbackService { this.transactions = new TransactionsManager(entityManager); } - public async getFeedback(canSeeAllFeedback = false, user: UserModel): Promise { - const feedback = await this.transactions.readOnly(async (txn) => { + public async getFeedback(canSeeAllFeedback = false, user: UserModel, + options: FeedbackSearchOptions): Promise { + return this.transactions.readOnly(async (txn) => { const feedbackRepository = Repositories.feedback(txn); - if (canSeeAllFeedback) return feedbackRepository.getAllFeedback(); - return feedbackRepository.getAllFeedbackForUser(user); + if (canSeeAllFeedback) { + return (await feedbackRepository.getAllFeedback(options)) + .map((fb) => fb.getPublicFeedback()); + } + + const userFeedback = await feedbackRepository.getStandardUserFeedback(user, options); + return userFeedback.map((fb) => fb.getPublicFeedback()); }); - return feedback.map((fb) => fb.getPublicFeedback()); } public async submitFeedback(user: UserModel, feedback: Feedback): Promise { - const addedFeedback = await this.transactions.readWrite(async (txn) => { + return this.transactions.readWrite(async (txn) => { + const event = await Repositories.event(txn).findByUuid(feedback.event); + if (!event) throw new NotFoundError('Event not found!'); + + const feedbackRepository = Repositories.feedback(txn); + + const hasAlreadySubmittedFeedback = await feedbackRepository.hasUserSubmittedFeedback(user, event); + if (hasAlreadySubmittedFeedback) throw new UserError('You have already submitted feedback for this event!'); + await Repositories.activity(txn).logActivity({ user, type: ActivityType.SUBMIT_FEEDBACK, }); - return Repositories.feedback(txn).upsertFeedback(FeedbackModel.create({ ...feedback, user })); + const addedFeedback = await feedbackRepository.upsertFeedback(FeedbackModel.create({ ...feedback, user, event })); + return addedFeedback.getPublicFeedback(); }); - return addedFeedback.getPublicFeedback(); } public async updateFeedbackStatus(uuid: Uuid, status: FeedbackStatus) { - const acknowledgedFeedback = await this.transactions.readWrite(async (txn) => { + return this.transactions.readWrite(async (txn) => { const feedbackRepository = Repositories.feedback(txn); const feedback = await feedbackRepository.findByUuid(uuid); if (!feedback) throw new NotFoundError('Feedback not found'); @@ -47,18 +59,12 @@ export default class FeedbackService { } const { user } = feedback; - if (status === FeedbackStatus.ACKNOWLEDGED) { - const pointsEarned = Config.pointReward.FEEDBACK_POINT_REWARD; - await Repositories.activity(txn).logActivity({ - user, - type: ActivityType.FEEDBACK_ACKNOWLEDGED, - pointsEarned, - }); - await Repositories.user(txn).addPoints(user, pointsEarned); - } - - return feedbackRepository.upsertFeedback(feedback, { status }); + await Repositories.activity(txn).logActivity({ + user, + type: ActivityType.FEEDBACK_ACKNOWLEDGED, + }); + const updatedFeedback = await feedbackRepository.upsertFeedback(feedback, { status }); + return updatedFeedback.getPublicFeedback(); }); - return acknowledgedFeedback.getPublicFeedback(); } } diff --git a/services/MerchStoreService.ts b/services/MerchStoreService.ts index 01618694..b32839c3 100644 --- a/services/MerchStoreService.ts +++ b/services/MerchStoreService.ts @@ -761,7 +761,7 @@ export default class MerchStoreService { /** * Cancels a merch order, refunding the user of its credits if the user is the one who cancelled the order. */ - public async cancelMerchOrder(orderUuid: Uuid, user: UserModel): Promise { + public async cancelMerchOrder(orderUuid: Uuid, user: UserModel): Promise { return this.transactions.readWrite(async (txn) => { const orderRespository = Repositories.merchOrder(txn); const order = await orderRespository.findByUuid(orderUuid); @@ -785,6 +785,7 @@ export default class MerchStoreService { type: ActivityType.ORDER_CANCELLED, description: `Order ${order.uuid} cancelled and refunded to ${customer.uuid} by ${user.uuid}`, }); + return order; }); } diff --git a/services/PermissionsService.ts b/services/PermissionsService.ts index c8e0e19f..21cef53a 100644 --- a/services/PermissionsService.ts +++ b/services/PermissionsService.ts @@ -9,15 +9,15 @@ export default class PermissionsService { } public static canSeeEventAttendances(user: UserModel): boolean { - return user.isAdmin(); + return user.isAdmin() || user.isMarketing(); } public static canSubmitFeedback(user: UserModel): boolean { - return user.state === UserState.ACTIVE; + return user.state === UserState.ACTIVE || user.state === UserState.PASSWORD_RESET; } - public static canRespondToFeedback(user: UserModel): boolean { - return user.isAdmin(); + public static canSeeAllFeedback(user: UserModel): boolean { + return user.isAdmin() || user.isMarketing(); } public static canCreateMilestones(user: UserModel): boolean { diff --git a/services/UserSocialMediaService.ts b/services/UserSocialMediaService.ts index 6a13cf20..2f59e5e7 100644 --- a/services/UserSocialMediaService.ts +++ b/services/UserSocialMediaService.ts @@ -6,7 +6,7 @@ import { UserError } from '../utils/Errors'; import { UserSocialMediaModel } from '../models/UserSocialMediaModel'; import { UserModel } from '../models/UserModel'; import Repositories, { TransactionsManager } from '../repositories'; -import { Uuid, SocialMedia } from '../types'; +import { Uuid, SocialMedia, SocialMediaPatches } from '../types'; @Service() export default class UserSocialMediaService { @@ -22,29 +22,58 @@ export default class UserSocialMediaService { return userSocialMedia; } - public async insertSocialMediaForUser(user: UserModel, socialMedia: SocialMedia) { + public async insertSocialMediaForUser(user: UserModel, socialMedias: SocialMedia[]) { const addedSocialMedia = await this.transactions.readWrite(async (txn) => { + // checking duplicate + const setDuplicateType = new Set(); + socialMedias.forEach((socialMedia) => { + const { type } = socialMedia; + if (setDuplicateType.has(type)) { + throw new UserError(`Dupllicate type "${type}" found in the request`); + } + setDuplicateType.add(type); + }); + + // inserting social media const userSocialMediaRepository = Repositories.userSocialMedia(txn); - const isNewSocialMediaType = await userSocialMediaRepository.isNewSocialMediaTypeForUser(user, socialMedia.type); - if (!isNewSocialMediaType) { - throw new UserError('Social media URL of this type has already been created for this user'); - } - return userSocialMediaRepository.upsertSocialMedia(UserSocialMediaModel.create({ ...socialMedia, user })); + const upsertedSocialMedias = await Promise.all(socialMedias.map(async (socialMedia) => { + const isNewSocialMediaType = await userSocialMediaRepository + .isNewSocialMediaTypeForUser(user, socialMedia.type); + if (!isNewSocialMediaType) { + throw new UserError(`Social media URL of type "${socialMedia.type}" has already been created for this user`); + } + return userSocialMediaRepository.upsertSocialMedia(UserSocialMediaModel.create({ ...socialMedia, user })); + })); + return upsertedSocialMedias; }); return addedSocialMedia; } public async updateSocialMediaByUuid(user: UserModel, - uuid: Uuid, - changes: Partial): Promise { + changes: SocialMediaPatches[]): Promise { const updatedSocialMedia = await this.transactions.readWrite(async (txn) => { + // checking duplicate + const setDuplicateUuid = new Set(); + + changes.forEach((socialMediaPatch) => { + const { uuid } = socialMediaPatch; + if (setDuplicateUuid.has(uuid)) { + throw new UserError(`Dupllicate UUID "${uuid}" found in the request`); + } + setDuplicateUuid.add(uuid); + }); + + // patching social media const userSocialMediaRepository = Repositories.userSocialMedia(txn); - const socialMedia = await userSocialMediaRepository.findByUuid(uuid); - if (!socialMedia) throw new NotFoundError('Social media URL not found'); - if (user.uuid !== socialMedia.user.uuid) { - throw new ForbiddenError('User cannot update a social media URL of another user'); - } - return userSocialMediaRepository.upsertSocialMedia(socialMedia, changes); + const modifiedSocialMedia = await Promise.all(changes.map(async (socialMediaPatches) => { + const socialMedia = await userSocialMediaRepository.findByUuid(socialMediaPatches.uuid); + if (!socialMedia) throw new NotFoundError(`Social media of UUID "${socialMediaPatches.uuid}" not found`); + if (user.uuid !== socialMedia.user.uuid) { + throw new ForbiddenError('User cannot update a social media URL of another user'); + } + return userSocialMediaRepository.upsertSocialMedia(socialMedia, { url: socialMediaPatches.url }); + })); + return modifiedSocialMedia; }); return updatedSocialMedia; } diff --git a/tests/Seeds.ts b/tests/Seeds.ts index a3626228..979db0fc 100644 --- a/tests/Seeds.ts +++ b/tests/Seeds.ts @@ -1,7 +1,8 @@ import * as moment from 'moment'; -import { UserAccessType, SocialMediaType } from '../types'; +import { UserAccessType, SocialMediaType, FeedbackStatus, FeedbackType } from '../types'; import { DatabaseConnection, EventFactory, MerchFactory, PortalState, UserFactory, ResumeFactory, UserSocialMediaFactory } from './data'; +import { FeedbackFactory } from './data/FeedbackFactory'; function getGraduationYear(n: number) { return moment().year() + n; @@ -121,6 +122,11 @@ async function seed(): Promise { accessType: UserAccessType.SPONSORSHIP_MANAGER, }); + // Used for testing feedback + const USER_FEEDBACK_1 = UserFactory.fake({ firstName: 'FeedbackerOne', lastName: 'Jones' }); + const USER_FEEDBACK_2 = UserFactory.fake({ firstName: 'FeedbackerTwo', lastName: 'Patel' }); + const USER_FEEDBACK_3 = UserFactory.fake({ firstName: 'FeedbackerThree', lastName: 'Smith' }); + // Used for testing various User Social Media const USER_SOCIAL_MEDIA_1 = UserFactory.fake(); const USER_SOCIAL_MEDIA_1_FACEBOOK = UserSocialMediaFactory.fake( @@ -694,6 +700,41 @@ async function seed(): Promise { orderLimit: 10, }); + // FEEDBACK SEEDING + + // Event with multiple feedbacks: PAST_AI_WORKSHOP_1 + const FEEDBACK_SAME_EVENT_1 = FeedbackFactory.fake({ user: USER_FEEDBACK_1, + event: PAST_AI_WORKSHOP_1, + description: 'Man this #$%& sucks', + type: FeedbackType.AI }); + const FEEDBACK_SAME_EVENT_2 = FeedbackFactory.fake({ user: USER_FEEDBACK_2, + event: PAST_AI_WORKSHOP_1, + type: FeedbackType.AI }); + + // User with multiple feedbacks: USER_FEEDBACK_3 + const FEEDBACK_SAME_USER_1 = FeedbackFactory.fake({ user: USER_FEEDBACK_3, + event: PAST_AI_WORKSHOP_1, + type: FeedbackType.CYBER }); + + const FEEDBACK_SAME_USER_2 = FeedbackFactory.fake({ user: USER_FEEDBACK_3, + event: PAST_AI_WORKSHOP_2, + type: FeedbackType.GENERAL }); + + const FEEDBACK_SUBMITTED = FeedbackFactory.fake({ user: USER_FEEDBACK_3, + event: PAST_AI_WORKSHOP_1, + status: FeedbackStatus.SUBMITTED, + type: FeedbackType.INNOVATE }); + + const FEEDBACK_IGNORED = FeedbackFactory.fake({ user: USER_FEEDBACK_3, + event: PAST_AI_WORKSHOP_1, + status: FeedbackStatus.IGNORED, + type: FeedbackType.GENERAL }); + + const FEEDBACK_ACKNOWLEDGED = FeedbackFactory.fake({ user: USER_FEEDBACK_3, + event: PAST_AI_WORKSHOP_1, + status: FeedbackStatus.ACKNOWLEDGED, + type: FeedbackType.BIT_BYTE }); + await new PortalState() .createUsers( ADMIN, @@ -717,6 +758,9 @@ async function seed(): Promise { USER_SOCIAL_MEDIA_2, USER_SOCIAL_MEDIA_3, USER_SOCIAL_MEDIA_ALL, + USER_FEEDBACK_1, + USER_FEEDBACK_2, + USER_FEEDBACK_3, USER_VISIBLE_RESUME, USER_HIDDEN_RESUME, ...otherMembers, @@ -822,6 +866,8 @@ async function seed(): Promise { USER_SOCIAL_MEDIA_ALL_PORTFOLIO, USER_SOCIAL_MEDIA_ALL_EMAIL) .createResumes(USER_VISIBLE_RESUME, RESUME_1) .createResumes(USER_HIDDEN_RESUME, RESUME_2) + .createFeedback(FEEDBACK_SAME_EVENT_1, FEEDBACK_SAME_EVENT_2, FEEDBACK_SAME_USER_1, FEEDBACK_SAME_USER_2, + FEEDBACK_ACKNOWLEDGED, FEEDBACK_IGNORED, FEEDBACK_SUBMITTED) .write(); } diff --git a/tests/data/FeedbackFactory.ts b/tests/data/FeedbackFactory.ts index b20f6d6c..35e82ca2 100644 --- a/tests/data/FeedbackFactory.ts +++ b/tests/data/FeedbackFactory.ts @@ -1,22 +1,28 @@ import * as faker from 'faker'; -import { Feedback, FeedbackType } from '../../types'; +import { v4 as uuid } from 'uuid'; +import { FeedbackStatus, FeedbackType } from '../../types'; import FactoryUtils from './FactoryUtils'; +import { UserFactory } from './UserFactory'; +import { EventFactory } from './EventFactory'; +import { FeedbackModel } from '../../models/FeedbackModel'; export class FeedbackFactory { - public static create(n: number) { + public static create(n: number): FeedbackModel[] { return FactoryUtils.create(n, FeedbackFactory.fake); } - public static fake(substitute?: Partial): Feedback { - const fake = { - title: faker.datatype.hexaDecimal(10), + public static fake(substitute?: Partial): FeedbackModel { + const fake = FeedbackModel.create({ + uuid: uuid(), + user: UserFactory.fake(), + event: EventFactory.fake(), + source: FactoryUtils.pickRandomValue(['Discord', 'Instagram', 'Portal']), + timestamp: new Date(), description: faker.lorem.words(100), + status: FeedbackStatus.SUBMITTED, type: FeedbackFactory.randomFeedbackType(), - }; - return { - ...fake, - ...substitute, - }; + }); + return FeedbackModel.merge(fake, substitute); } private static randomFeedbackType(): FeedbackType { diff --git a/tests/data/PortalState.ts b/tests/data/PortalState.ts index 1bd1426f..f19af280 100644 --- a/tests/data/PortalState.ts +++ b/tests/data/PortalState.ts @@ -9,7 +9,7 @@ import { MerchandiseCollectionModel } from '../../models/MerchandiseCollectionMo import { OrderModel } from '../../models/OrderModel'; import { UserModel } from '../../models/UserModel'; import { ActivityModel } from '../../models/ActivityModel'; -import { ActivityScope, ActivityType, Feedback } from '../../types'; +import { ActivityScope, ActivityType } from '../../types'; import { MerchandiseItemOptionModel } from '../../models/MerchandiseItemOptionModel'; import { OrderItemModel } from '../../models/OrderItemModel'; import { FeedbackModel } from '../../models/FeedbackModel'; @@ -133,6 +133,15 @@ export class PortalState { return this; } + public createFeedback(...feedback: FeedbackModel[]): PortalState { + for (let f = 0; f < feedback.length; f += 1) { + const fb = feedback[f]; + + this.feedback.push(FeedbackModel.create({ ...fb })); + } + return this; + } + public attendEvents(users: UserModel[], events: EventModel[], includesStaff = false): PortalState { for (let e = 0; e < events.length; e += 1) { const event = events[e]; @@ -208,7 +217,7 @@ export class PortalState { }); } - public submitFeedback(user: UserModel, feedback: Feedback[]): PortalState { + public submitFeedback(user: UserModel, feedback: FeedbackModel[]): PortalState { for (let f = 0; f < feedback.length; f += 1) { const fb = feedback[f]; this.feedback.push(FeedbackModel.create({ ...fb, user })); diff --git a/tests/feedback.test.ts b/tests/feedback.test.ts index d593ff18..f2516307 100644 --- a/tests/feedback.test.ts +++ b/tests/feedback.test.ts @@ -1,12 +1,23 @@ import { validate } from 'class-validator'; import { plainToClass } from 'class-transformer'; -import { DatabaseConnection, PortalState, UserFactory } from './data'; +import { DatabaseConnection, EventFactory, PortalState, UserFactory } from './data'; import { FeedbackFactory } from './data/FeedbackFactory'; -import { ActivityScope, ActivityType, FeedbackStatus, UserAccessType } from '../types'; +import { ActivityScope, ActivityType, FeedbackStatus, FeedbackType, UserAccessType } from '../types'; import { Feedback } from '../api/validators/FeedbackControllerRequests'; -import { Config } from '../config'; import { ControllerFactory } from './controllers'; +function buildFeedbackRequest(feedback) { + return { + feedback: { + event: feedback.event.uuid, + source: feedback.source, + status: feedback.status, + type: feedback.type, + description: feedback.description, + }, + }; +} + beforeAll(async () => { await DatabaseConnection.connect(); }); @@ -21,31 +32,46 @@ afterAll(async () => { }); describe('feedback submission', () => { - test('properly persists on successful submission', async () => { + test('users can submit feedback', async () => { + const event = EventFactory.fake(); const conn = await DatabaseConnection.get(); const member = UserFactory.fake(); - const feedback = FeedbackFactory.fake(); + const feedback = FeedbackFactory.fake({ event }); await new PortalState() .createUsers(member) + .createEvents(event) .write(); const feedbackController = ControllerFactory.feedback(conn); + const submittedFeedbackResponse = await feedbackController.submitFeedback(buildFeedbackRequest(feedback), member); + + // check response + expect(submittedFeedbackResponse.feedback.description).toEqual(feedback.description); + expect(submittedFeedbackResponse.feedback.source).toEqual(feedback.source); + expect(submittedFeedbackResponse.feedback.type).toEqual(feedback.type); + expect(submittedFeedbackResponse.feedback.status).toEqual(FeedbackStatus.SUBMITTED); + expect(submittedFeedbackResponse.feedback.event.uuid).toEqual(feedback.event.uuid); - await feedbackController.submitFeedback({ feedback }, member); - const submittedFeedbackResponse = await feedbackController.getFeedback(member); + // check if it persists + const queriedFeedback = await feedbackController.getFeedback({}, member); + expect(queriedFeedback.feedback).toHaveLength(1); - expect(submittedFeedbackResponse.feedback).toHaveLength(1); - expect(submittedFeedbackResponse.feedback[0]).toStrictEqual({ - ...submittedFeedbackResponse.feedback[0], + expect(queriedFeedback.feedback[0]).toEqual({ + ...submittedFeedbackResponse.feedback, user: member.getPublicProfile(), + event: event.getPublicEvent(), + source: feedback.source, + description: feedback.description, status: FeedbackStatus.SUBMITTED, - ...feedback, + type: feedback.type, }); }); test('is invalidated when submission description is too short', async () => { - const feedback = FeedbackFactory.fake({ description: 'A short description' }); + const event = EventFactory.fake(); + + const feedback = FeedbackFactory.fake({ event, description: 'A short description' }); const errors = await validate(plainToClass(Feedback, feedback)); @@ -56,15 +82,30 @@ describe('feedback submission', () => { }); test('has proper activity scope and type', async () => { + const event = EventFactory.fake({ + title: 'AI: Intro to Neural Nets', + description: `Artificial neural networks (ANNs), usually simply called + neural networks (NNs), are computing systems vaguely inspired by the + biological neural networks that constitute animal brains. An ANN is based + on a collection of connected units or nodes called artificial neurons, + which loosely model the neurons in a biological brain.`, + committee: 'AI', + location: 'Qualcomm Room', + ...EventFactory.daysBefore(6), + attendanceCode: 'galaxybrain', + requiresStaff: true, + }); + const conn = await DatabaseConnection.get(); const member = UserFactory.fake(); - const feedback = FeedbackFactory.fake(); + const feedback = FeedbackFactory.fake({ event }); await new PortalState() .createUsers(member) + .createEvents(event) .write(); - await ControllerFactory.feedback(conn).submitFeedback({ feedback }, member); + await ControllerFactory.feedback(conn).submitFeedback(buildFeedbackRequest(feedback), member); const activityResponse = await ControllerFactory.user(conn).getCurrentUserActivityStream(member); const feedbackSubmissionActivity = activityResponse.activity[1]; @@ -73,127 +114,353 @@ describe('feedback submission', () => { }); test('admins can view feedback from any member', async () => { + const event = EventFactory.fake(); + const conn = await DatabaseConnection.get(); const [member1, member2] = UserFactory.create(2); const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); - const [feedback1, feedback2] = FeedbackFactory.create(2); + const feedback1 = FeedbackFactory.fake({ event }); + const feedback2 = FeedbackFactory.fake({ event }); await new PortalState() .createUsers(member1, member2, admin) + .createEvents(event) .write(); const feedbackController = ControllerFactory.feedback(conn); - const submittedFeedback1Response = await feedbackController.submitFeedback({ feedback: feedback1 }, member1); - const submittedFeedback2Response = await feedbackController.submitFeedback({ feedback: feedback2 }, member2); - const allSubmittedFeedbackResponse = await feedbackController.getFeedback(admin); + const submittedFeedback1Response = await feedbackController.submitFeedback( + buildFeedbackRequest(feedback1), member1, + ); + const submittedFeedback2Response = await feedbackController.submitFeedback( + buildFeedbackRequest(feedback2), member2, + ); + + const allSubmittedFeedback = await feedbackController.getFeedback({}, admin); + + expect(allSubmittedFeedback.feedback).toHaveLength(2); - expect(allSubmittedFeedbackResponse.feedback).toHaveLength(2); - expect(allSubmittedFeedbackResponse.feedback).toEqual( - expect.arrayContaining([submittedFeedback1Response.feedback, submittedFeedback2Response.feedback]), + expect(allSubmittedFeedback.feedback).toEqual( + expect.arrayContaining([ + submittedFeedback1Response.feedback, + submittedFeedback2Response.feedback, + ]), ); }); test('members can view only their own feedback', async () => { + const event = EventFactory.fake(); + const conn = await DatabaseConnection.get(); const [member1, member2] = UserFactory.create(2); - const [feedback1, feedback2] = FeedbackFactory.create(2); + const feedback1 = FeedbackFactory.fake({ event, user: member1 }); + const feedback2 = FeedbackFactory.fake({ event, user: member2 }); await new PortalState() .createUsers(member1, member2) + .createEvents(event) .write(); const feedbackController = ControllerFactory.feedback(conn); - await feedbackController.submitFeedback({ feedback: feedback1 }, member1); - await feedbackController.submitFeedback({ feedback: feedback2 }, member2); - const user1Feedback = await feedbackController.getFeedback(member1); + await feedbackController.submitFeedback(buildFeedbackRequest(feedback1), member1); + await feedbackController.submitFeedback(buildFeedbackRequest(feedback2), member2); + const user1Feedback = await feedbackController.getFeedback({ user: member1.uuid }, member1); expect(user1Feedback.feedback).toHaveLength(1); - expect(user1Feedback.feedback[0]).toMatchObject(feedback1); + + expect(user1Feedback.feedback[0]).toMatchObject({ + ...user1Feedback.feedback[0], + user: member1.getPublicProfile(), + event: event.getPublicEvent(), + source: feedback1.source, + description: feedback1.description, + status: FeedbackStatus.SUBMITTED, + type: feedback1.type, + }); }); - test('admin can acknowledge and reward points for feedback', async () => { + test('cannot be responded to after already being responded to', async () => { + const event = EventFactory.fake(); + const conn = await DatabaseConnection.get(); - const member = UserFactory.fake(); + const [member1, member2] = UserFactory.create(2); const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); - const feedback = FeedbackFactory.fake(); + const feedback1 = FeedbackFactory.fake({ event, user: member1 }); + const feedback2 = FeedbackFactory.fake({ event, user: member2 }); await new PortalState() - .createUsers(member, admin) + .createUsers(member1, member2, admin) + .createEvents(event) .write(); - const userController = ControllerFactory.user(conn); const feedbackController = ControllerFactory.feedback(conn); - const submittedFeedbackResponse = await feedbackController.submitFeedback({ feedback }, member); + const feedbackToAcknowledgeResponse = await feedbackController.submitFeedback( + buildFeedbackRequest(feedback1), member1, + ); + const feedbackToIgnoreResponse = await feedbackController.submitFeedback( + buildFeedbackRequest(feedback2), member2, + ); - const status = FeedbackStatus.ACKNOWLEDGED; - const uuid = submittedFeedbackResponse.feedback; - const acknowledgedFeedback = await feedbackController.updateFeedbackStatus(uuid, { status }, admin); + const feedbackToAcknowledgeParams = { uuid: feedbackToAcknowledgeResponse.feedback.uuid }; + const acknowledged = { status: FeedbackStatus.ACKNOWLEDGED }; - const persistedUserResponse = await userController.getUser({ uuid: member.uuid }, admin); + const feedbackToIgnoreParams = { uuid: feedbackToIgnoreResponse.feedback.uuid }; + const ignored = { status: FeedbackStatus.IGNORED }; - const feedbackPointReward = Config.pointReward.FEEDBACK_POINT_REWARD; + await feedbackController.updateFeedbackStatus(feedbackToAcknowledgeParams, acknowledged, admin); + await feedbackController.updateFeedbackStatus(feedbackToIgnoreParams, ignored, admin); + + const errorMessage = 'This feedback has already been responded to'; + await expect(feedbackController.updateFeedbackStatus(feedbackToAcknowledgeParams, acknowledged, admin)) + .rejects.toThrow(errorMessage); - expect(acknowledgedFeedback.feedback.status).toEqual(FeedbackStatus.ACKNOWLEDGED); - expect(persistedUserResponse.user.points).toEqual(member.points + feedbackPointReward); + await expect(feedbackController.updateFeedbackStatus(feedbackToIgnoreParams, ignored, admin)) + .rejects.toThrow(errorMessage); }); - test('admin can ignore and not reward points for feedback', async () => { + test('get all feedback for an event', async () => { + const event1 = EventFactory.fake({ + title: 'AI: Intro to Neural Nets', + description: `Artificial neural networks (ANNs), usually simply called + neural networks (NNs), are computing systems vaguely inspired by the + biological neural networks that constitute animal brains. An ANN is based + on a collection of connected units or nodes called artificial neurons, + which loosely model the neurons in a biological brain.`, + committee: 'AI', + location: 'Qualcomm Room', + ...EventFactory.daysBefore(6), + attendanceCode: 'galaxybrain', + requiresStaff: true, + cover: null, + thumbnail: null, + eventLink: null, + }); + + const event2 = EventFactory.fake({ + title: 'Not the right event!', + description: `Artificial neural networks (ANNs), usually simply called + neural networks (NNs), are computing systems vaguely inspired by the + biological neural networks that constitute animal brains. An ANN is based + on a collection of connected units or nodes called artificial neurons, + which loosely model the neurons in a biological brain.`, + committee: 'AI', + location: 'Qualcomm Room', + ...EventFactory.daysBefore(6), + attendanceCode: 'galxybrain', + requiresStaff: true, + cover: null, + thumbnail: null, + eventLink: null, + }); + const conn = await DatabaseConnection.get(); - const member = UserFactory.fake(); + const [member1, member2] = UserFactory.create(2); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const feedback1 = FeedbackFactory.fake({ event: event1, user: member1 }); + const feedback2 = FeedbackFactory.fake({ event: event1, user: member2 }); + const feedback3 = FeedbackFactory.fake({ event: event2, user: member1 }); + + await new PortalState() + .createUsers(member1, member2, admin) + .createEvents(event1, event2) + .write(); + + const feedbackController = ControllerFactory.feedback(conn); + const fb1Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback1), member1); + const fb2Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback2), member2); + const fb3Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback3), member1); + const event1Feedback = await feedbackController.getFeedback({ event: event1.uuid }, admin); + const event2Feedback = await feedbackController.getFeedback({ event: event2.uuid }, admin); + + expect(event1Feedback.feedback).toHaveLength(2); + expect(event2Feedback.feedback).toHaveLength(1); + + expect(event1Feedback.feedback).toEqual( + expect.arrayContaining([ + fb1Response.feedback, + fb2Response.feedback, + ]), + ); + + expect(event2Feedback.feedback).toEqual( + expect.arrayContaining([ + fb3Response.feedback, + ]), + ); + }); + + test('get all feedback by status', async () => { + const event1 = EventFactory.fake(); + + const conn = await DatabaseConnection.get(); + const [member1, member2, member3] = UserFactory.create(3); const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); - const feedback = FeedbackFactory.fake(); + const feedback1 = FeedbackFactory.fake({ event: event1, status: FeedbackStatus.ACKNOWLEDGED }); + const feedback2 = FeedbackFactory.fake({ event: event1, status: FeedbackStatus.ACKNOWLEDGED }); + const feedback3 = FeedbackFactory.fake({ event: event1, status: FeedbackStatus.SUBMITTED }); await new PortalState() - .createUsers(member, admin) + .createUsers(member1, member2, member3, admin) + .createEvents(event1) .write(); - const userController = ControllerFactory.user(conn); const feedbackController = ControllerFactory.feedback(conn); + const fb1Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback1), member1); + const fb2Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback2), member2); + const fb3Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback3), member3); + const status1Feedback = await feedbackController.getFeedback({ status: FeedbackStatus.ACKNOWLEDGED }, admin); + const status2Feedback = await feedbackController.getFeedback({ status: FeedbackStatus.SUBMITTED }, admin); + + expect(status1Feedback.feedback).toHaveLength(2); + expect(status2Feedback.feedback).toHaveLength(1); + + expect(status1Feedback.feedback).toEqual( + expect.arrayContaining([ + fb1Response.feedback, + fb2Response.feedback, + ]), + ); - const submittedFeedbackResponse = await feedbackController.submitFeedback({ feedback }, member); - const status = FeedbackStatus.IGNORED; - const uuid = submittedFeedbackResponse.feedback; - const ignoredFeedbackResponse = await feedbackController.updateFeedbackStatus(uuid, { status }, admin); + expect(status2Feedback.feedback).toEqual( + expect.arrayContaining([ + fb3Response.feedback, + ]), + ); + }); - const persistedUserResponse = await userController.getUser({ uuid: member.uuid }, admin); + test('get all feedback by type', async () => { + const event1 = EventFactory.fake(); + + const conn = await DatabaseConnection.get(); + const [member1, member2, member3] = UserFactory.create(3); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const feedback1 = FeedbackFactory.fake({ event: event1, type: FeedbackType.GENERAL, user: member1 }); + const feedback2 = FeedbackFactory.fake({ event: event1, type: FeedbackType.GENERAL, user: member2 }); + const feedback3 = FeedbackFactory.fake({ event: event1, type: FeedbackType.AI, user: member3 }); + + await new PortalState() + .createUsers(member1, member2, member3, admin) + .createEvents(event1) + .write(); - expect(ignoredFeedbackResponse.feedback.status).toEqual(FeedbackStatus.IGNORED); - expect(persistedUserResponse.user.points).toEqual(member.points); + const feedbackController = ControllerFactory.feedback(conn); + const fb1Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback1), member1); + const fb2Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback2), member2); + const fb3Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback3), member3); + const type1Feedback = await feedbackController.getFeedback({ type: FeedbackType.GENERAL }, admin); + const type2Feedback = await feedbackController.getFeedback({ type: FeedbackType.AI }, admin); + + expect(type1Feedback.feedback).toHaveLength(2); + expect(type2Feedback.feedback).toHaveLength(1); + + expect(type1Feedback.feedback).toEqual( + expect.arrayContaining([ + fb1Response.feedback, + fb2Response.feedback, + ]), + ); + + expect(type2Feedback.feedback).toEqual( + expect.arrayContaining([ + fb3Response.feedback, + ]), + ); }); - test('cannot be responded to after already being responded to', async () => { + test('get all feedback by member', async () => { + const event1 = EventFactory.fake(); + const conn = await DatabaseConnection.get(); const member = UserFactory.fake(); + const member2 = UserFactory.fake(); const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); - const [feedback1, feedback2] = FeedbackFactory.create(2); + const feedback1 = FeedbackFactory.fake({ event: event1, user: member }); + const feedback2 = FeedbackFactory.fake({ event: event1, user: member2 }); await new PortalState() - .createUsers(member, admin) + .createUsers(member, member2, admin) + .createEvents(event1) .write(); const feedbackController = ControllerFactory.feedback(conn); + const fb1Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback1), member); + const fb2Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback2), member2); - const feedbackToAcknowledgeResponse = await feedbackController.submitFeedback({ feedback: feedback1 }, member); - const feedbackToIgnoreResponse = await feedbackController.submitFeedback({ feedback: feedback2 }, member); + const type1Feedback = await feedbackController.getFeedback({ user: member.uuid }, admin); + const type2Feedback = await feedbackController.getFeedback({ user: member2.uuid }, admin); - const feedbackToAcknowledgeParams = { uuid: feedbackToAcknowledgeResponse.feedback.uuid }; - const feedbackToIgnoreParams = { uuid: feedbackToIgnoreResponse.feedback.uuid }; - const acknowledged = { status: FeedbackStatus.ACKNOWLEDGED }; - const ignored = { status: FeedbackStatus.IGNORED }; + expect(type1Feedback.feedback).toHaveLength(1); + expect(type2Feedback.feedback).toHaveLength(1); - await feedbackController.updateFeedbackStatus(feedbackToAcknowledgeParams, acknowledged, admin); - await feedbackController.updateFeedbackStatus(feedbackToIgnoreParams, ignored, admin); + expect(type1Feedback.feedback).toEqual( + expect.arrayContaining([ + fb1Response.feedback, + ]), + ); - const errorMessage = 'This feedback has already been responded to'; - await expect(feedbackController.updateFeedbackStatus(feedbackToAcknowledgeParams, acknowledged, admin)) - .rejects.toThrow(errorMessage); - await expect(feedbackController.updateFeedbackStatus(feedbackToAcknowledgeParams, ignored, admin)) - .rejects.toThrow(errorMessage); - await expect(feedbackController.updateFeedbackStatus(feedbackToIgnoreParams, acknowledged, admin)) - .rejects.toThrow(errorMessage); - await expect(feedbackController.updateFeedbackStatus(feedbackToIgnoreParams, ignored, admin)) - .rejects.toThrow(errorMessage); + expect(type2Feedback.feedback).toEqual( + expect.arrayContaining([ + fb2Response.feedback, + ]), + ); + }); + + test('get all feedback with multiple parameters', async () => { + const event1 = EventFactory.fake(); + + const event2 = EventFactory.fake(); + + const conn = await DatabaseConnection.get(); + const [member1, member2, member3] = UserFactory.create(3); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const feedback1 = FeedbackFactory.fake({ event: event1, + status: FeedbackStatus.ACKNOWLEDGED, + type: FeedbackType.GENERAL, + user: member1 }); + const feedback2 = FeedbackFactory.fake({ event: event1, + status: FeedbackStatus.IGNORED, + type: FeedbackType.GENERAL, + user: member2 }); + const feedback3 = FeedbackFactory.fake({ event: event1, + status: FeedbackStatus.SUBMITTED, + type: FeedbackType.INNOVATE, + user: member3 }); + const feedback4 = FeedbackFactory.fake({ event: event2, + status: FeedbackStatus.ACKNOWLEDGED, + type: FeedbackType.GENERAL, + user: member1 }); + + await new PortalState() + .createUsers(member1, member2, member3, admin) + .createEvents(event1, event2) + .write(); + + const feedbackController = ControllerFactory.feedback(conn); + const fb1Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback1), member1); + const fb2Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback2), member2); + await feedbackController.submitFeedback(buildFeedbackRequest(feedback3), member3); + const fb4Response = await feedbackController.submitFeedback(buildFeedbackRequest(feedback4), member1); + const query1Feedback = await feedbackController.getFeedback({ event: event1.uuid, + type: FeedbackType.GENERAL }, admin); + const query2Feedback = await feedbackController.getFeedback({ status: FeedbackStatus.ACKNOWLEDGED, + type: FeedbackType.GENERAL }, admin); + + expect(query1Feedback.feedback).toHaveLength(2); + expect(query2Feedback.feedback).toHaveLength(2); + + expect(query1Feedback.feedback).toEqual( + expect.arrayContaining([ + fb1Response.feedback, + fb2Response.feedback, + ]), + ); + + expect(query2Feedback.feedback).toEqual( + expect.arrayContaining([ + fb1Response.feedback, + fb4Response.feedback, + ]), + ); }); }); diff --git a/tests/merchOrder.test.ts b/tests/merchOrder.test.ts index 44c4a274..7dbcfa8e 100644 --- a/tests/merchOrder.test.ts +++ b/tests/merchOrder.test.ts @@ -445,7 +445,8 @@ describe('merch orders', () => { // cancel order const { uuid } = placedOrderResponse.order; - await merchController.cancelMerchOrder({ uuid }, member); + const cancelOrderResponse = await merchController.cancelMerchOrder({ uuid }, member); + expect(cancelOrderResponse.order.uuid).toEqual(uuid); // get order, making sure state was updated and user has been refunded const cancelledOrderResponse = await merchController.getOneMerchOrder({ uuid }, member); @@ -521,7 +522,8 @@ describe('merch orders', () => { // cancel the order const { uuid } = placedOrderResponse.order; - await merchController.cancelMerchOrder({ uuid }, member); + const cancelOrderResponse = await merchController.cancelMerchOrder({ uuid }, member); + expect(cancelOrderResponse.order.uuid).toEqual(uuid); // make sure user has only been refunded 1 option worth of points await member.reload(); @@ -893,7 +895,8 @@ describe('merch orders', () => { const merchController = ControllerFactory.merchStore(conn, instance(emailService)); const placedOrder = await conn.manager.findOne(OrderModel, { user: member }, { relations: ['items'] }); const cancelOrderParams = { uuid: placedOrder.uuid }; - await merchController.cancelMerchOrder(cancelOrderParams, member); + const cancelOrderResponse = await merchController.cancelMerchOrder(cancelOrderParams, member); + expect(cancelOrderResponse.order.uuid).toEqual(cancelOrderParams.uuid); // place order with 2 items again const secondOrder = [{ option: option.uuid, quantity: 2 }]; diff --git a/tests/sample.test.ts b/tests/sample.test.ts index e8d95aa5..7b1c89b9 100644 --- a/tests/sample.test.ts +++ b/tests/sample.test.ts @@ -42,7 +42,7 @@ describe('sample test', () => { discountPercentage: 0, }); const orderPickupEvent = MerchFactory.fakeOrderPickupEvent(); - const feedback = FeedbackFactory.fake(); + const feedback = FeedbackFactory.fake({ event }); const state = new PortalState() .createUsers(user1, user2) diff --git a/tests/userSocialMedia.test.ts b/tests/userSocialMedia.test.ts index f2b39778..5c70c72d 100644 --- a/tests/userSocialMedia.test.ts +++ b/tests/userSocialMedia.test.ts @@ -23,22 +23,31 @@ describe('social media URL submission', () => { test('properly persists on successful submission', async () => { const conn = await DatabaseConnection.get(); - let member = UserFactory.fake(); - const userSocialMedia = UserSocialMediaFactory.fake({ user: member }); + let member1 = UserFactory.fake(); + const socialMediaTypes = [SocialMediaType.FACEBOOK, SocialMediaType.TWITTER, SocialMediaType.LINKEDIN]; + const userSocialMediaList = socialMediaTypes.map((type) => UserSocialMediaFactory.fake({ user: member1, type })); + // sort by type to ensure order is consistent + userSocialMediaList.sort((a, b) => a.type.localeCompare(b.type)); + + // console.log("LIST", userSocialMediaList); await new PortalState() - .createUsers(member) + .createUsers(member1) .write(); const userController = ControllerFactory.user(conn); - await userController.insertSocialMediaForUser({ socialMedia: userSocialMedia }, member); - member = await conn.manager.findOne(UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }); - expect(member.userSocialMedia).toHaveLength(1); - expect(member.userSocialMedia[0]).toEqual({ - url: userSocialMedia.url, - type: userSocialMedia.type, - uuid: userSocialMedia.uuid, + await userController.insertSocialMediaForUser({ socialMedia: userSocialMediaList }, member1); + member1 = await conn.manager.findOne(UserModel, { uuid: member1.uuid }, { relations: ['userSocialMedia'] }); + + expect(member1.userSocialMedia).toHaveLength(3); + const userSocialMediaQuery = member1.userSocialMedia.sort((a, b) => a.type.localeCompare(b.type)); + userSocialMediaList.forEach((socialMedia, index) => { + expect(userSocialMediaQuery[index]).toEqual({ + url: socialMedia.url, + type: socialMedia.type, + uuid: socialMedia.uuid, + }); }); }); @@ -55,8 +64,9 @@ describe('social media URL submission', () => { const userController = ControllerFactory.user(conn); const userSocialMediaWithSameType = UserSocialMediaFactory.fake({ type: SocialMediaType.FACEBOOK }); - const errorMessage = 'Social media URL of this type has already been created for this user'; - await expect(userController.insertSocialMediaForUser({ socialMedia: userSocialMediaWithSameType }, member)) + const errorMessage = `Social media URL of type "${SocialMediaType.FACEBOOK}"` + + ' has already been created for this user'; + await expect(userController.insertSocialMediaForUser({ socialMedia: [userSocialMediaWithSameType] }, member)) .rejects.toThrow(errorMessage); }); }); @@ -68,22 +78,29 @@ describe('social media URL update', () => { test('is invalidated when updating social media URL of another user', async () => { const conn = await DatabaseConnection.get(); - let member = UserFactory.fake(); + let member1 = UserFactory.fake(); const unauthorizedMember = UserFactory.fake(); - const userSocialMedia = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.FACEBOOK }); + const socialMediaTypes = [SocialMediaType.FACEBOOK, SocialMediaType.TWITTER, SocialMediaType.LINKEDIN]; + const userSocialMediaList = socialMediaTypes.map((type) => UserSocialMediaFactory.fake({ user: member1, type })); + // sort by type to ensure order is consistent + userSocialMediaList.sort((a, b) => a.type.localeCompare(b.type)); await new PortalState() - .createUsers(member, unauthorizedMember) - .createUserSocialMedia(member, userSocialMedia) + .createUsers(member1, unauthorizedMember) .write(); const userController = ControllerFactory.user(conn); - member = await conn.manager.findOne(UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }); + await userController.insertSocialMediaForUser({ socialMedia: userSocialMediaList }, member1); + member1 = await conn.manager.findOne(UserModel, { uuid: member1.uuid }, { relations: ['userSocialMedia'] }); + + expect(member1.userSocialMedia).toHaveLength(3); const errorMessage = 'User cannot update a social media URL of another user'; - const uuidParams = { uuid: member.userSocialMedia[0].uuid }; - const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; - await expect(userController.updateSocialMediaForUser(uuidParams, socialMediaParams, unauthorizedMember)) + const socialMediaParams = { socialMedia: [{ + url: faker.internet.url(), + uuid: member1.userSocialMedia[0].uuid, + }] }; + await expect(userController.updateSocialMediaForUser(socialMediaParams, unauthorizedMember)) .rejects.toThrow(errorMessage); }); @@ -97,10 +114,12 @@ describe('social media URL update', () => { const userController = ControllerFactory.user(conn); - const errorMessage = 'Social media URL not found'; - const missingEntityUuid = { uuid: faker.datatype.uuid() }; - const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; - await expect(userController.updateSocialMediaForUser(missingEntityUuid, socialMediaParams, member)) + const socialMediaParams = { socialMedia: [{ + url: faker.internet.url(), + uuid: faker.datatype.uuid(), + }] }; + const errorMessage = `Social media of UUID "${socialMediaParams.socialMedia[0].uuid}" not found`; + await expect(userController.updateSocialMediaForUser(socialMediaParams, member)) .rejects.toThrow(errorMessage); }); }); @@ -130,7 +149,7 @@ describe('social media URL delete', () => { }); }); -describe('social media URL update', () => { +describe('social media URL update concurrency', () => { let conn : Connection; let member : UserModel; let userController : UserController; @@ -183,14 +202,16 @@ describe('social media URL update', () => { test.concurrent('concurrent updates properly persist on successful submission 0', async () => { await waitForFlag(); const index = 0; - const uuidParams = { uuid: member.userSocialMedia[index].uuid }; - const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; - await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member); + const socialMediaParams = { socialMedia: [{ + url: faker.internet.url(), + uuid: member.userSocialMedia[index].uuid, + }] }; + await userController.updateSocialMediaForUser(socialMediaParams, member); const expectedUserSocialMedia0 = { - url: socialMediaParams.socialMedia.url, + url: socialMediaParams.socialMedia[0].url, type: member.userSocialMedia[index].type, - uuid: uuidParams.uuid, + uuid: socialMediaParams.socialMedia[0].uuid, }; const updatedMember = await conn.manager.findOne( UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }, @@ -207,14 +228,16 @@ describe('social media URL update', () => { test.concurrent('concurrent updates properly persist on successful submission 1', async () => { await waitForFlag(); const index = 1; - const uuidParams = { uuid: member.userSocialMedia[index].uuid }; - const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; - await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member); + const socialMediaParams = { socialMedia: [{ + url: faker.internet.url(), + uuid: member.userSocialMedia[index].uuid, + }] }; + await userController.updateSocialMediaForUser(socialMediaParams, member); const expectedUserSocialMedia1 = { - url: socialMediaParams.socialMedia.url, + url: socialMediaParams.socialMedia[0].url, type: member.userSocialMedia[index].type, - uuid: uuidParams.uuid, + uuid: socialMediaParams.socialMedia[0].uuid, }; const updatedMember = await conn.manager.findOne( UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }, @@ -231,14 +254,16 @@ describe('social media URL update', () => { test.concurrent('concurrent updates properly persist on successful submission 2', async () => { await waitForFlag(); const index = 2; - const uuidParams = { uuid: member.userSocialMedia[index].uuid }; - const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; - await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member); + const socialMediaParams = { socialMedia: [{ + url: faker.internet.url(), + uuid: member.userSocialMedia[index].uuid, + }] }; + await userController.updateSocialMediaForUser(socialMediaParams, member); const expectedUserSocialMedia2 = { - url: socialMediaParams.socialMedia.url, + url: socialMediaParams.socialMedia[0].url, type: member.userSocialMedia[index].type, - uuid: uuidParams.uuid, + uuid: socialMediaParams.socialMedia[0].uuid, }; const updatedMember = await conn.manager.findOne( UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }, @@ -255,14 +280,16 @@ describe('social media URL update', () => { test.concurrent('concurrent updates properly persist on successful submission 3', async () => { await waitForFlag(); const index = 3; - const uuidParams = { uuid: member.userSocialMedia[index].uuid }; - const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; - await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member); + const socialMediaParams = { socialMedia: [{ + url: faker.internet.url(), + uuid: member.userSocialMedia[index].uuid, + }] }; + await userController.updateSocialMediaForUser(socialMediaParams, member); const expectedUserSocialMedia3 = { - url: socialMediaParams.socialMedia.url, + url: socialMediaParams.socialMedia[0].url, type: member.userSocialMedia[index].type, - uuid: uuidParams.uuid, + uuid: socialMediaParams.socialMedia[0].uuid, }; const updatedMember = await conn.manager.findOne( UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }, @@ -279,14 +306,16 @@ describe('social media URL update', () => { test.concurrent('concurrent updates properly persist on successful submission 4', async () => { await waitForFlag(); const index = 4; - const uuidParams = { uuid: member.userSocialMedia[index].uuid }; - const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; - await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member); + const socialMediaParams = { socialMedia: [{ + url: faker.internet.url(), + uuid: member.userSocialMedia[index].uuid, + }] }; + await userController.updateSocialMediaForUser(socialMediaParams, member); const expectedUserSocialMedia4 = { - url: socialMediaParams.socialMedia.url, + url: socialMediaParams.socialMedia[0].url, type: member.userSocialMedia[index].type, - uuid: uuidParams.uuid, + uuid: socialMediaParams.socialMedia[0].uuid, }; const updatedMember = await conn.manager.findOne( UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }, diff --git a/types/ApiRequests.ts b/types/ApiRequests.ts index 44447fb9..87a6b6dc 100644 --- a/types/ApiRequests.ts +++ b/types/ApiRequests.ts @@ -1,5 +1,5 @@ -import { FeedbackStatus, FeedbackType, SocialMediaType, UserAccessType } from './Enums'; import { Uuid } from '.'; +import { FeedbackStatus, FeedbackType, SocialMediaType, UserAccessType } from './Enums'; // REQUEST TYPES @@ -45,7 +45,8 @@ export interface RegistrationRequest { // USER export interface Feedback { - title: string; + event: Uuid; + source: string; description: string; type: FeedbackType; } @@ -82,16 +83,24 @@ export interface UpdateFeedbackStatusRequest { status: FeedbackStatus; } +export interface FeedbackSearchOptions { + event?: string; + type?: string; + status?: string; + user?: string; +} + export interface InsertUserSocialMediaRequest { - socialMedia: SocialMedia; + socialMedia: SocialMedia[]; } export interface SocialMediaPatches { - url?: string; + uuid: string; + url: string; } export interface UpdateUserSocialMediaRequest { - socialMedia: SocialMediaPatches; + socialMedia: SocialMediaPatches[]; } // LEADERBOARD diff --git a/types/ApiResponses.ts b/types/ApiResponses.ts index f449b2d5..4bc289d6 100644 --- a/types/ApiResponses.ts +++ b/types/ApiResponses.ts @@ -311,6 +311,10 @@ export interface VerifyMerchOrderResponse extends ApiResponse {} export interface EditMerchOrderResponse extends ApiResponse {} +export interface CancelMerchOrderResponse extends ApiResponse { + order: PublicOrderWithItems; +} + export interface GetCartResponse extends ApiResponse { cart: PublicOrderMerchItemOption[]; } @@ -353,7 +357,8 @@ export interface PrivateProfile extends PublicProfile { export interface PublicFeedback { uuid: Uuid, user: PublicProfile, - title: string; + event: PublicEvent, + source: string; description: string; timestamp: Date; status: FeedbackStatus; @@ -412,11 +417,11 @@ export interface GetUserSocialMediaResponse extends ApiResponse { } export interface InsertSocialMediaResponse extends ApiResponse { - userSocialMedia: PublicUserSocialMedia; + userSocialMedia: PublicUserSocialMedia[]; } export interface UpdateSocialMediaResponse extends ApiResponse { - userSocialMedia: PublicUserSocialMedia; + userSocialMedia: PublicUserSocialMedia[]; } export interface DeleteSocialMediaResponse extends ApiResponse {}