diff --git a/api/controllers/UserController.ts b/api/controllers/UserController.ts index 1ecb88e5..2e2c6106 100644 --- a/api/controllers/UserController.ts +++ b/api/controllers/UserController.ts @@ -96,6 +96,12 @@ export class UserController { return { error: null, user: userProfile }; } + @Patch('/onboarding/collect') + async collectOnboarding(@AuthenticatedUser() user: UserModel): Promise { + const userProfile = await this.userAccountService.collectOnboarding(user); + return { error: null, user: userProfile }; + } + @Patch() async patchCurrentUser(@Body() patchUserRequest: PatchUserRequest, @AuthenticatedUser() user: UserModel): Promise { diff --git a/api/validators/UserControllerRequests.ts b/api/validators/UserControllerRequests.ts index f0b03f9b..f3ba77ce 100644 --- a/api/validators/UserControllerRequests.ts +++ b/api/validators/UserControllerRequests.ts @@ -47,6 +47,9 @@ export class UserPatches implements IUserPatches { @Allow() isAttendancePublic?: boolean; + @Allow() + onboardingSeen?: boolean; + @Type(() => PasswordUpdate) @ValidateNested() @HasMatchingPasswords() diff --git a/migrations/0046-add-onboarding-task.ts b/migrations/0046-add-onboarding-task.ts new file mode 100644 index 00000000..29d426b5 --- /dev/null +++ b/migrations/0046-add-onboarding-task.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +const TABLE_NAME = 'Users'; + +export class AddOnboardingTask1727933494169 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns(TABLE_NAME, [ + new TableColumn({ + name: 'onboardingSeen', + type: 'boolean', + isNullable: true, + default: false, + }), + new TableColumn({ + name: 'firstTasksCompleted', + type: 'boolean', + isNullable: true, + default: false, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumns(TABLE_NAME, [ + new TableColumn({ + name: 'onboardingSeen', + type: 'boolean', + isNullable: false, + default: false, + }), + new TableColumn({ + name: 'firstTasksCompleted', + type: 'boolean', + default: false, + isNullable: false, + }), + ]); + } +} diff --git a/models/UserModel.ts b/models/UserModel.ts index 677ec3f1..d2194a8d 100644 --- a/models/UserModel.ts +++ b/models/UserModel.ts @@ -65,6 +65,12 @@ export class UserModel extends BaseEntity { @Column('integer', { default: 0 }) credits: number; + @Column('boolean', { default: false }) + onboardingSeen: boolean; + + @Column('boolean', { default: false }) + firstTasksCompleted: boolean; + @OneToMany((type) => ActivityModel, (activity) => activity.user, { cascade: true }) activities: ActivityModel[]; @@ -154,10 +160,18 @@ export class UserModel extends BaseEntity { points: this.points, credits: this.credits, isAttendancePublic: this.isAttendancePublic, + onboardingSeen: this.onboardingSeen, + firstTasksCompleted: this.firstTasksCompleted, }; + if (this.attendances) { + fullUserProfile.attendanceCount = this.attendances.length; + } if (this.userSocialMedia) { fullUserProfile.userSocialMedia = this.userSocialMedia.map((sm) => sm.getPublicSocialMedia()); } + if (this.resumes) { + fullUserProfile.resumes = this.resumes.map((rm) => rm.getPublicResume()); + } return fullUserProfile; } } diff --git a/services/UserAccountService.ts b/services/UserAccountService.ts index ab0dc926..e738787c 100644 --- a/services/UserAccountService.ts +++ b/services/UserAccountService.ts @@ -166,6 +166,25 @@ export default class UserAccountService { }); } + public async collectOnboarding(user: UserModel): Promise { + const userProfile = await this.getFullUserProfile(user); + if (userProfile.attendanceCount < 5 + || userProfile.resumes.length < 1 + || userProfile.profilePicture == null + || userProfile.bio == null) { + throw new BadRequestError('Onboarding tasks not completed!'); + } + if (userProfile.firstTasksCompleted) { + throw new BadRequestError('Onboarding reward already collected!'); + } + return this.transactions.readWrite(async (txn) => { + const userRepository = Repositories.user(txn); + await userRepository.addPoints(user, 10); + Repositories.activity(txn).logBonus([user], 'First tasks reward', 10); + return userRepository.upsertUser(user, { firstTasksCompleted: true }); + }); + } + public async grantBonusPoints(emails: string[], description: string, points: number) { return this.transactions.readWrite(async (txn) => { const userRepository = Repositories.user(txn); @@ -197,6 +216,7 @@ export default class UserAccountService { const userProfile = user.getFullUserProfile(); userProfile.resumes = await Repositories.resume(txn).findAllByUser(user); userProfile.userSocialMedia = await Repositories.userSocialMedia(txn).getSocialMediaForUser(user); + userProfile.attendanceCount = (await Repositories.attendance(txn).getAttendancesForUser(user)).length; return userProfile; }); } diff --git a/tests/onboardingReward.test.ts b/tests/onboardingReward.test.ts new file mode 100644 index 00000000..b1b8ca65 --- /dev/null +++ b/tests/onboardingReward.test.ts @@ -0,0 +1,87 @@ +import { EventModel } from '../models/EventModel'; +import { DatabaseConnection, UserFactory, PortalState, EventFactory, ResumeFactory } from './data'; +import { ControllerFactory } from './controllers'; + +beforeAll(async () => { + await DatabaseConnection.connect(); +}); + +beforeEach(async () => { + await DatabaseConnection.clear(); +}); + +afterAll(async () => { + await DatabaseConnection.clear(); + await DatabaseConnection.close(); +}); + +describe('collect onboarding reward', () => { + test('can collect onboarding reward', async () => { + const conn = await DatabaseConnection.get(); + const member = UserFactory.fake({ + bio: 'this is a bio', + profilePicture: 'https://pfp.com', + }); + const resume = ResumeFactory.fake({ user: member, isResumeVisible: true }); + + const events: EventModel[] = []; + for (let i = 0; i < 5; i += 1) { + events.push(EventFactory.fake({ pointValue: 10 })); + } + + await new PortalState() + .createUsers(member) + .createEvents(events[0], events[1], events[2], events[3], events[4]) + .attendEvents([member], events) + .createResumes(member, resume) + .write(); + + const userController = ControllerFactory.user(conn); + const response = await userController.collectOnboarding(member); + const userProfile = response.user; + + expect(userProfile.firstTasksCompleted).toBe(true); + expect(userProfile.points).toBe(60); + }); + + test('conditions not fulfilled', async () => { + const conn = await DatabaseConnection.get(); + const member = UserFactory.fake(); + + await new PortalState() + .createUsers(member) + .write(); + + const userController = ControllerFactory.user(conn); + + await expect(userController.collectOnboarding(member)) + .rejects.toThrow('Onboarding tasks not completed'); + }); + + test('can collect onboarding reward', async () => { + const conn = await DatabaseConnection.get(); + const member = UserFactory.fake({ + bio: 'this is a bio', + profilePicture: 'https://pfp.com', + firstTasksCompleted: true, + }); + const resume = ResumeFactory.fake({ user: member, isResumeVisible: true }); + + const events: EventModel[] = []; + for (let i = 0; i < 5; i += 1) { + events.push(EventFactory.fake({ pointValue: 10 })); + } + + await new PortalState() + .createUsers(member) + .createEvents(events[0], events[1], events[2], events[3], events[4]) + .attendEvents([member], events) + .createResumes(member, resume) + .write(); + + const userController = ControllerFactory.user(conn); + + await expect(userController.collectOnboarding(member)) + .rejects.toThrow('Onboarding reward already collected!'); + }); +}); diff --git a/types/ApiRequests.ts b/types/ApiRequests.ts index 816b59f7..4af0f22f 100644 --- a/types/ApiRequests.ts +++ b/types/ApiRequests.ts @@ -68,6 +68,7 @@ export interface UserPatches { graduationYear?: number; bio?: string; isAttendancePublic?: boolean; + onboardingSeen?: boolean; passwordChange?: PasswordUpdate; } diff --git a/types/ApiResponses.ts b/types/ApiResponses.ts index d3dfa353..aef0277b 100644 --- a/types/ApiResponses.ts +++ b/types/ApiResponses.ts @@ -358,6 +358,9 @@ export interface PrivateProfile extends PublicProfile { state: string, credits: number, resumes?: PublicResume[], + attendanceCount?: number, + onboardingSeen: boolean, + firstTasksCompleted: boolean, } export interface PublicFeedback {