From 03a4c3b1fec6125ea188c43da227d31ed5ff9895 Mon Sep 17 00:00:00 2001 From: Max Weng <73797155+maxwn04@users.noreply.github.com> Date: Sun, 31 Dec 2023 10:29:04 -0800 Subject: [PATCH] Get another user's event attendance (#358) * attendences from user uuid * lint and bugfix * check same user * controller factory changes * lint fixes * unit test for get attendance by uuid * lint * add permision * add types * add everything else * rename migrtion * test when permision is off * lint * forgor to add * change permission name and fix logic a bit * rename permission, change patch user * lint fix * lint fix * oops * check user exists * lint * rename tests * public profile change * change user model * lint * tests * lint * updated api version --------- Co-authored-by: Nikhil Dange --- api/controllers/AttendanceController.ts | 12 ++++- api/validators/UserControllerRequests.ts | 3 ++ ...d-userAttendancePermission-to-userTable.ts | 17 ++++++ models/UserModel.ts | 5 ++ package.json | 2 +- services/AttendanceService.ts | 14 ++++- tests/attendance.test.ts | 54 +++++++++++++++++++ tests/auth.test.ts | 1 + tests/data/UserFactory.ts | 1 + types/ApiRequests.ts | 1 + types/ApiResponses.ts | 1 + 11 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 migrations/0037-add-userAttendancePermission-to-userTable.ts diff --git a/api/controllers/AttendanceController.ts b/api/controllers/AttendanceController.ts index 50e5b17c..5487c98a 100644 --- a/api/controllers/AttendanceController.ts +++ b/api/controllers/AttendanceController.ts @@ -27,7 +27,17 @@ export class AttendanceController { @Get() async getAttendancesForCurrentUser(@AuthenticatedUser() user: UserModel): Promise { - const attendances = await this.attendanceService.getAttendancesForUser(user); + const attendances = await this.attendanceService.getAttendancesForCurrentUser(user); + return { error: null, attendances }; + } + + @Get('/user/:uuid') + async getAttendancesForUser(@Params() params: UuidParam, + @AuthenticatedUser() currentUser: UserModel): Promise { + if (params.uuid === currentUser.uuid) { + return this.getAttendancesForCurrentUser(currentUser); + } + const attendances = await this.attendanceService.getAttendancesForUser(params.uuid); return { error: null, attendances }; } diff --git a/api/validators/UserControllerRequests.ts b/api/validators/UserControllerRequests.ts index e21267ca..f0b03f9b 100644 --- a/api/validators/UserControllerRequests.ts +++ b/api/validators/UserControllerRequests.ts @@ -44,6 +44,9 @@ export class UserPatches implements IUserPatches { @Allow() bio?: string; + @Allow() + isAttendancePublic?: boolean; + @Type(() => PasswordUpdate) @ValidateNested() @HasMatchingPasswords() diff --git a/migrations/0037-add-userAttendancePermission-to-userTable.ts b/migrations/0037-add-userAttendancePermission-to-userTable.ts new file mode 100644 index 00000000..dfd053fe --- /dev/null +++ b/migrations/0037-add-userAttendancePermission-to-userTable.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +const TABLE_NAME = 'Users'; + +export class AddUserAttendancePermissionToUserTable1691286073346 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn(TABLE_NAME, new TableColumn({ + name: 'isAttendancePublic', + type: 'boolean', + default: true, + })); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn(TABLE_NAME, 'isAttendancePublic'); + } +} diff --git a/models/UserModel.ts b/models/UserModel.ts index 334d5575..a2cd0114 100644 --- a/models/UserModel.ts +++ b/models/UserModel.ts @@ -55,6 +55,9 @@ export class UserModel extends BaseEntity { }) bio: string; + @Column('boolean', { default: true }) + isAttendancePublic: boolean; + @Column('integer', { default: 0 }) @Index('leaderboard_index') points: number; @@ -126,6 +129,7 @@ export class UserModel extends BaseEntity { major: this.major, bio: this.bio, points: this.points, + isAttendancePublic: this.isAttendancePublic, }; if (this.userSocialMedia) { publicProfile.userSocialMedia = this.userSocialMedia.map((sm) => sm.getPublicSocialMedia()); @@ -148,6 +152,7 @@ export class UserModel extends BaseEntity { bio: this.bio, points: this.points, credits: this.credits, + isAttendancePublic: this.isAttendancePublic, }; if (this.userSocialMedia) { fullUserProfile.userSocialMedia = this.userSocialMedia.map((sm) => sm.getPublicSocialMedia()); diff --git a/package.json b/package.json index c765afe9..542e7dab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "2.11.1", + "version": "2.12.0", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ diff --git a/services/AttendanceService.ts b/services/AttendanceService.ts index 6f0e171b..332d1069 100644 --- a/services/AttendanceService.ts +++ b/services/AttendanceService.ts @@ -1,6 +1,6 @@ import { Service } from 'typedi'; import { InjectManager } from 'typeorm-typedi-extensions'; -import { BadRequestError, NotFoundError } from 'routing-controllers'; +import { BadRequestError, ForbiddenError, NotFoundError } from 'routing-controllers'; import { EntityManager } from 'typeorm'; import * as moment from 'moment'; import { ActivityType, PublicAttendance, Uuid } from '../types'; @@ -27,13 +27,23 @@ export default class AttendanceService { return attendances.map((attendance) => attendance.getPublicAttendance()); } - public async getAttendancesForUser(user: UserModel): Promise { + public async getAttendancesForCurrentUser(user: UserModel): Promise { const attendances = await this.transactions.readOnly(async (txn) => Repositories .attendance(txn) .getAttendancesForUser(user)); return attendances.map((attendance) => attendance.getPublicAttendance()); } + public async getAttendancesForUser(uuid: Uuid): Promise { + return this.transactions.readOnly(async (txn) => { + const user = await Repositories.user(txn).findByUuid(uuid); + if (!user) throw new NotFoundError('User does not exist'); + if (!user.isAttendancePublic) throw new ForbiddenError(); + const attendances = await Repositories.attendance(txn).getAttendancesForUser(user); + return attendances.map((attendance) => attendance.getPublicAttendance()); + }); + } + public async attendEvent(user: UserModel, attendanceCode: string, asStaff = false): Promise { return this.transactions.readWrite(async (txn) => { const event = await Repositories.event(txn).findByAttendanceCode(attendanceCode); diff --git a/tests/attendance.test.ts b/tests/attendance.test.ts index a8b8f583..62b1b55a 100644 --- a/tests/attendance.test.ts +++ b/tests/attendance.test.ts @@ -272,4 +272,58 @@ describe('attendance', () => { expect(attendance.user.uuid).toEqual(staff.uuid); expect(attendance.event.uuid).toEqual(event.uuid); }); + + test('get another user attendance by uuid', async () => { + const conn = await DatabaseConnection.get(); + const member1 = UserFactory.fake(); + const member2 = UserFactory.fake(); + const event1 = EventFactory.fake({ requiresStaff: true }); + const event2 = EventFactory.fake({ requiresStaff: true }); + + await new PortalState() + .createUsers(member1, member2) + .createEvents(event1, event2) + .attendEvents([member1], [event1, event2]) + .write(); + + const attendanceController = ControllerFactory.attendance(conn); + const params = { uuid: member1.uuid }; + + // returns all attendances for uuid + const getAttendancesForUserUuid = await attendanceController.getAttendancesForUser(params, member2); + const attendancesForEvent = getAttendancesForUserUuid.attendances.map((a) => ({ + user: a.user.uuid, + event: a.event.uuid, + asStaff: a.asStaff, + })); + const expectedAttendances = [ + { event: event1.uuid, user: member1.uuid, asStaff: false }, + { event: event2.uuid, user: member1.uuid, asStaff: false }, + ]; + expect(attendancesForEvent).toEqual(expect.arrayContaining(expectedAttendances)); + }); + + test('throws error when isAttendancePublic is false', async () => { + const conn = await DatabaseConnection.get(); + const member1 = UserFactory.fake(); + const member2 = UserFactory.fake(); + const event1 = EventFactory.fake({ requiresStaff: true }); + const event2 = EventFactory.fake({ requiresStaff: true }); + + await new PortalState() + .createUsers(member1, member2) + .createEvents(event1, event2) + .attendEvents([member1, member2], [event1, event2]) + .write(); + + const attendanceController = ControllerFactory.attendance(conn); + const userController = ControllerFactory.user(conn); + const params = { uuid: member1.uuid }; + + const changePublicAttendancePatch = { user: { isAttendancePublic: false } }; + await userController.patchCurrentUser(changePublicAttendancePatch, member1); + + await expect(attendanceController.getAttendancesForUser(params, member2)) + .rejects.toThrow(ForbiddenError); + }); }); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 3fb30542..79476dc6 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -59,6 +59,7 @@ describe('account registration', () => { uuid: registerResponse.user.uuid, profilePicture: null, userSocialMedia: [], + isAttendancePublic: true, }); // check that email verification is sent diff --git a/tests/data/UserFactory.ts b/tests/data/UserFactory.ts index 69c60bae..17017e01 100644 --- a/tests/data/UserFactory.ts +++ b/tests/data/UserFactory.ts @@ -43,6 +43,7 @@ export class UserFactory { points: 0, credits: 0, handle: UserAccountService.generateDefaultHandle(firstName, lastName), + isAttendancePublic: true, }); return UserModel.merge(fake, substitute); } diff --git a/types/ApiRequests.ts b/types/ApiRequests.ts index 2f5b50aa..ff271f3b 100644 --- a/types/ApiRequests.ts +++ b/types/ApiRequests.ts @@ -66,6 +66,7 @@ export interface UserPatches { major?: string; graduationYear?: number; bio?: string; + isAttendancePublic?: boolean; passwordChange?: PasswordUpdate; } diff --git a/types/ApiResponses.ts b/types/ApiResponses.ts index c508f7ff..97d25732 100644 --- a/types/ApiResponses.ts +++ b/types/ApiResponses.ts @@ -310,6 +310,7 @@ export interface PublicProfile { bio: string, points: number, userSocialMedia?: PublicUserSocialMedia[]; + isAttendancePublic: boolean, } export interface PrivateProfile extends PublicProfile {