Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/onboarding tasks #454

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
6 changes: 6 additions & 0 deletions api/controllers/UserController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export class UserController {
return { error: null, user: userProfile };
}

@Patch('/onboarding/collect')
async collectOnboarding(@AuthenticatedUser() user: UserModel): Promise<GetCurrentUserResponse> {
const userProfile = await this.userAccountService.collectOnboarding(user);
return { error: null, user: userProfile };
}

@Patch()
async patchCurrentUser(@Body() patchUserRequest: PatchUserRequest,
@AuthenticatedUser() user: UserModel): Promise<PatchUserResponse> {
Expand Down
3 changes: 3 additions & 0 deletions api/validators/UserControllerRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export class UserPatches implements IUserPatches {
@Allow()
isAttendancePublic?: boolean;

@Allow()
onboardingSeen?: boolean;

@Type(() => PasswordUpdate)
@ValidateNested()
@HasMatchingPasswords()
Expand Down
39 changes: 39 additions & 0 deletions migrations/0046-add-onboarding-task.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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,
}),
]);
}
}
14 changes: 14 additions & 0 deletions models/UserModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down Expand Up @@ -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;
}
}
20 changes: 20 additions & 0 deletions services/UserAccountService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,25 @@ export default class UserAccountService {
});
}

public async collectOnboarding(user: UserModel): Promise<UserModel> {
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!');
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prior to returning, we should probably also log this in the activity table

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I just add the points bonus to activities or should I do the update to the user model as well?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Points bonus should just be fine–can you check if we log to activity any time there's a user object edit? I know we do it for user access updates but if there's more instances of it besides that then we can log the update to the user model

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);
Expand Down Expand Up @@ -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;
});
}
Expand Down
87 changes: 87 additions & 0 deletions tests/onboardingReward.test.ts
Original file line number Diff line number Diff line change
@@ -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!');
});
});
1 change: 1 addition & 0 deletions types/ApiRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface UserPatches {
graduationYear?: number;
bio?: string;
isAttendancePublic?: boolean;
onboardingSeen?: boolean;
maxwn04 marked this conversation as resolved.
Show resolved Hide resolved
passwordChange?: PasswordUpdate;
}

Expand Down
3 changes: 3 additions & 0 deletions types/ApiResponses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,9 @@ export interface PrivateProfile extends PublicProfile {
state: string,
credits: number,
resumes?: PublicResume[],
attendanceCount?: number,
onboardingSeen: boolean,
firstTasksCompleted: boolean,
}

export interface PublicFeedback {
Expand Down
Loading