Skip to content

Commit

Permalink
implement achievements tracking logic
Browse files Browse the repository at this point in the history
  • Loading branch information
jasmineee-li committed Apr 28, 2024
1 parent ff6aaac commit a285a1a
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 2 deletions.
5 changes: 5 additions & 0 deletions server/src/achievement/achievement.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export interface AchievementTrackerDto {
dateComplete?: string;
}

/** DTO for updateAchievementTrackerData */
export interface UpdateAchievementTrackerDataDto {
tracker: AchievementTrackerDto;
}

export interface UpdateAchievementDataDto {
achievement: AchievementDto;
deleted: boolean;
Expand Down
101 changes: 100 additions & 1 deletion server/src/achievement/achievement.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { AchievementService } from './achievement.service';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { UserService } from '../user/user.service';
import { ChallengeService } from '../challenge/challenge.service';

import {
AuthType,
User,
Expand Down Expand Up @@ -39,6 +41,7 @@ describe('AchievementModule E2E', () => {
let abilityFactory: CaslAbilityFactory;
let fullAbility: AppAbility;
let orgUsage: OrganizationSpecialUsage;
let challengeService : ChallengeService;

/** beforeAll runs before anything else. It adds new users and prerequisites.
* afterAll runs after all the tests. It removes lingering values in the database.
Expand Down Expand Up @@ -92,6 +95,7 @@ describe('AchievementModule E2E', () => {
include: { memberOf: true },
});

// tracker = await achievementService.getAchievementsByIdsForAbility(fullAbility, )
console.log = log;
});

Expand All @@ -101,7 +105,7 @@ describe('AchievementModule E2E', () => {
});

describe('Create and read functions', () => {
it('should add an achievement: upsertAchievementFromDto', async () => {
it('should add an achievement: upsertAchievementFromDto; should create a tracker with progress 0', async () => {
const orgUsage = OrganizationSpecialUsage;
const orgId = (
await organizationService.getDefaultOrganization(orgUsage.DEVICE_LOGIN)
Expand All @@ -128,7 +132,16 @@ describe('AchievementModule E2E', () => {
const findAch = await prisma.achievement.findFirstOrThrow({
where: { id: ach!.id },
});

if (ach) {
tracker = await achievementService.createAchievementTracker(user, ach.id);
console.log(tracker);
}

expect(findAch.description).toEqual('ach dto');
expect(tracker.progress).toEqual(0);
expect(tracker.dateComplete).toEqual(null);

});

it('should read achievements: getAchievementFromId, getAchievementsByIdsForAbility', async () => {
Expand Down Expand Up @@ -184,6 +197,90 @@ describe('AchievementModule E2E', () => {
});
});

describe('Testing achievement tracker', () => {
it('should update tracker progress when a challenge is completed', async () => {
// Assuming a challenge completion would update an existing tracker
const initialProgress = tracker.progress;
await challengeService.completeChallenge(user, 'challengeId');

const updatedTracker = await prisma.achievementTracker.findUnique({
where: { id: tracker.id },
});
if (updatedTracker) {
expect(updatedTracker.progress).toBeGreaterThan(initialProgress);
}
});
});

describe('Achievement tracker functions', () => {
it('should create a tracker when an achievement is added and applicable', async () => {
const achId = (await prisma.achievement.findFirstOrThrow()).id;
const orgUsage = OrganizationSpecialUsage;
const orgId = (
await organizationService.getDefaultOrganization(orgUsage.DEVICE_LOGIN)
).id;
const achDto: AchievementDto = {
id: achId,
eventId: 'event123',
name: 'test',
description: 'ach dto',
requiredPoints: 1,
imageUrl: 'tracker test',
locationType: ChallengeLocationDto.ENG_QUAD,
achievementType: AchievementTypeDto.TOTAL_CHALLENGES_OR_JOURNEYS,
initialOrganizationId: orgId,
};

await achievementService.upsertAchievementFromDto(fullAbility, achDto);
const ach = await prisma.achievement.findFirstOrThrow({
where: { id: achId },
});

expect(ach).toBeDefined();

// Simulate challenge completion
await achievementService.checkAchievementProgress(user, 'event123', false);

// Check if tracker was created
const tracker = await prisma.achievementTracker.findFirst({
where: { achievementId: ach.id, userId: user.id },
});
expect(tracker).toBeDefined();
expect(tracker?.progress).toBe(1);
});

// it('should create an achievement tracker', async () => {
// const achId = (await prisma.achievement.findFirstOrThrow()).id;
// const achTrackerDto: AchievementTrackerDto = {
// userId: user.id,
// achievementId: achId,
// progress: 0,
// };

// const achTracker = await achievementService.upsertAchievementTrackerFromDto(
// fullAbility,
// achTrackerDto,
// );

// const findAchTracker = await prisma.achievementTracker.findFirstOrThrow({
// where: { id: achTracker.id },
// });
// expect(findAchTracker.points).toEqual(0);
// });

it('should mark tracker as complete when achievement criteria are met', async () => {
// Complete a challenge that gives the final point needed
await challengeService.completeChallenge(user, 'event123');

const completedTracker = await prisma.achievementTracker.findUnique({
where: { id: tracker.id },
});
expect(completedTracker).not.toBeNull();
expect(completedTracker!.dateComplete).not.toBeNull();
});
});


describe('Delete functions', () => {
it('should remove achievement: removeAchievement', async () => {
const ach = await prisma.achievement.findFirstOrThrow({
Expand All @@ -204,6 +301,8 @@ describe('AchievementModule E2E', () => {
id: user.id,
},
});
await prisma.achievementTracker.deleteMany({});
await prisma.achievement.deleteMany({});
await app.close();
});
});
155 changes: 154 additions & 1 deletion server/src/achievement/achievement.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { accessibleBy } from '@casl/prisma';
import { AppAbility, CaslAbilityFactory } from '../casl/casl-ability.factory';
import { Action } from '../casl/action.enum';
import { subject } from '@casl/ability';
import { defaultAchievementData } from '../organization/organization.service';
import {
defaultAchievementData,
OrganizationService,
} from '../organization/organization.service';
import {
AchievementTypeDto,
AchievementDto,
Expand All @@ -30,6 +33,7 @@ export class AchievementService {
private readonly prisma: PrismaService,
private clientService: ClientService,
private abilityFactory: CaslAbilityFactory,
// private orgService: OrganizationService,
) {}

/** get an achievement by its ID */
Expand Down Expand Up @@ -194,4 +198,153 @@ export class AchievementService {
achievementType: ach.achievementType as AchievementTypeDto,
};
}

/** AchievementTracker functions */

/** Creates an achievement tracker */
async createAchievementTracker(user: User, achievementId: string) {
const existing = await this.prisma.achievementTracker.findFirst({
where: { userId: user.id, achievementId },
});

if (existing) {
return existing;
}

const progress = await this.prisma.achievementTracker.create({
data: {
userId: user.id,
progress: 0,
achievementId,
},
});

return progress;
}

async getAchievementTrackerByAchievementId(
user: User,
achievementId: string,
) {
return await this.prisma.achievementTracker.findFirst({
where: { userId: user.id, achievementId },
});
}

async dtoForAchievementTracker(
tracker: AchievementTracker,
): Promise<AchievementTrackerDto> {
const achievement = await this.getAchievementFromId(tracker.achievementId);
return {
userId: tracker.userId,
progress: tracker.progress,
achievementId: tracker.achievementId,
dateComplete: tracker.dateComplete?.toISOString(),
};
}

/** Emits & updates an achievement tracker */
async emitUpdateAchievementTracker(
tracker: AchievementTracker,
target?: User,
) {
const dto = await this.dtoForAchievementTracker(tracker);

await this.clientService.sendProtected(
'updateAchievementTrackerData',
target?.id ?? tracker.userId,
dto,
{
id: dto.achievementId,
subject: 'AchievementTracker',
prismaStore: this.prisma.achievementTracker,
},
);
}

/** checks for all achievements associated with a user for a given completed challenge. */
async checkAchievementProgress(
user: User,
challengeId: string,
isJourney: boolean,
) {
// find challenge corresponding to challengeId
const curChallenge = await this.prisma.challenge.findUniqueOrThrow({
where: { id: challengeId },
});

const ability = await this.abilityFactory.createForUser(user);

// find all achievements associated with the challenge that are accessible
// by user and have incomplete trackers; joins tracker to resulting query
const achs = await this.prisma.achievement.findMany({
where: {
OR: [
{ linkedEventId: challengeId }, // achievements linked to the specific event of the challenge
{ linkedEventId: null }, // achievements not linked to any specific event
],
AND: [
accessibleBy(ability, Action.Read).Achievement,
{locationType: curChallenge.location},
],
},
include: {
trackers: {
where: {
userId: user.id,
dateComplete: null, // trackers for achievements that are not complete
},
},
},
});

// iterate through each achievement and update progress
for (const achId in achs) {
// find tracker associated with ach
let tracker = await this.prisma.achievementTracker.findFirst({
where: {
userId: user.id,
achievementId: achId,
},
});

// if tracker doesn't exist, create a new tracker associated with ach
if (tracker == null) {
tracker = await this.createAchievementTracker(user, achId);
}

// update tracker with new progress; complete tracker if necessary
const ach = await this.getAchievementFromId(achId);

const journeyOrChalAchShouldProgress =
ach.achievementType ===
AchievementTypeDto.TOTAL_CHALLENGES_OR_JOURNEYS ||
(isJourney &&
ach.achievementType === AchievementTypeDto.TOTAL_JOURNEYS) ||
(!isJourney &&
ach.achievementType === AchievementTypeDto.TOTAL_CHALLENGES);


if (ach.achievementType === AchievementTypeDto.TOTAL_POINTS) {
tracker.progress += curChallenge.points;
if (tracker.progress >= ach.requiredPoints) {
// ach is newly completed; update tracker with completion date
tracker.dateComplete = new Date();
}
} else if (journeyOrChalAchShouldProgress) {
tracker.progress += 1;
if (tracker.progress >= ach.requiredPoints) {
tracker.dateComplete = new Date();
}
}

await this.prisma.achievementTracker.update({
where: { id: tracker.id },
data: {
progress: tracker.progress,
dateComplete: tracker.dateComplete,
},
});
}
}
}
1 change: 1 addition & 0 deletions server/src/challenge/challenge.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { OrganizationService } from '../organization/organization.service';
import { ClientModule } from '../client/client.module';
import { ChallengeDto, ChallengeLocationDto } from './challenge.dto';
import { AppAbility, CaslAbilityFactory } from '../casl/casl-ability.factory';
import { AchievementService } from '../achievement/achievement.service';

describe('ChallengeModule E2E', () => {
let app: INestApplication;
Expand Down
2 changes: 2 additions & 0 deletions server/src/challenge/challenge.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PrismaModule } from '../prisma/prisma.module';
import { UserModule } from '../user/user.module';
import { ChallengeGateway } from './challenge.gateway';
import { ChallengeService } from './challenge.service';
import { AchievementModule } from '../achievement/achievement.module';
import { CaslModule } from '../casl/casl.module';

@Module({
Expand All @@ -20,6 +21,7 @@ import { CaslModule } from '../casl/casl.module';
PrismaModule,
SessionLogModule,
CaslModule,
AchievementModule,
],
providers: [ChallengeGateway, ChallengeService],
})
Expand Down
Loading

0 comments on commit a285a1a

Please sign in to comment.