From a285a1a20c07dea0d1469cced5f6dcdb68bc2a41 Mon Sep 17 00:00:00 2001 From: Jasmine Li Date: Sun, 28 Apr 2024 16:45:14 -0400 Subject: [PATCH 1/4] implement achievements tracking logic --- server/src/achievement/achievement.dto.ts | 5 + .../src/achievement/achievement.e2e-spec.ts | 101 +++++++++++- server/src/achievement/achievement.service.ts | 155 +++++++++++++++++- server/src/challenge/challenge.e2e-spec.ts | 1 + server/src/challenge/challenge.module.ts | 2 + server/src/challenge/challenge.service.ts | 22 +++ server/src/event/event.service.ts | 2 + 7 files changed, 286 insertions(+), 2 deletions(-) diff --git a/server/src/achievement/achievement.dto.ts b/server/src/achievement/achievement.dto.ts index b26c85da..4b4cf882 100644 --- a/server/src/achievement/achievement.dto.ts +++ b/server/src/achievement/achievement.dto.ts @@ -26,6 +26,11 @@ export interface AchievementTrackerDto { dateComplete?: string; } +/** DTO for updateAchievementTrackerData */ +export interface UpdateAchievementTrackerDataDto { + tracker: AchievementTrackerDto; +} + export interface UpdateAchievementDataDto { achievement: AchievementDto; deleted: boolean; diff --git a/server/src/achievement/achievement.e2e-spec.ts b/server/src/achievement/achievement.e2e-spec.ts index 2509ea8c..872a8d7f 100644 --- a/server/src/achievement/achievement.e2e-spec.ts +++ b/server/src/achievement/achievement.e2e-spec.ts @@ -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, @@ -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. @@ -92,6 +95,7 @@ describe('AchievementModule E2E', () => { include: { memberOf: true }, }); + // tracker = await achievementService.getAchievementsByIdsForAbility(fullAbility, ) console.log = log; }); @@ -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) @@ -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 () => { @@ -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({ @@ -204,6 +301,8 @@ describe('AchievementModule E2E', () => { id: user.id, }, }); + await prisma.achievementTracker.deleteMany({}); + await prisma.achievement.deleteMany({}); await app.close(); }); }); diff --git a/server/src/achievement/achievement.service.ts b/server/src/achievement/achievement.service.ts index e0bb21c9..5732ea1e 100644 --- a/server/src/achievement/achievement.service.ts +++ b/server/src/achievement/achievement.service.ts @@ -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, @@ -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 */ @@ -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 { + 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, + }, + }); + } + } } diff --git a/server/src/challenge/challenge.e2e-spec.ts b/server/src/challenge/challenge.e2e-spec.ts index db83b5f8..02738a95 100644 --- a/server/src/challenge/challenge.e2e-spec.ts +++ b/server/src/challenge/challenge.e2e-spec.ts @@ -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; diff --git a/server/src/challenge/challenge.module.ts b/server/src/challenge/challenge.module.ts index 3a297a40..08ee56a9 100644 --- a/server/src/challenge/challenge.module.ts +++ b/server/src/challenge/challenge.module.ts @@ -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({ @@ -20,6 +21,7 @@ import { CaslModule } from '../casl/casl.module'; PrismaModule, SessionLogModule, CaslModule, + AchievementModule, ], providers: [ChallengeGateway, ChallengeService], }) diff --git a/server/src/challenge/challenge.service.ts b/server/src/challenge/challenge.service.ts index 18842368..78bf45ec 100644 --- a/server/src/challenge/challenge.service.ts +++ b/server/src/challenge/challenge.service.ts @@ -8,9 +8,12 @@ import { SessionLogEvent, User, LocationType, + Achievement, + AchievementTracker, } from '@prisma/client'; import { ClientService } from '../client/client.service'; import { EventService } from '../event/event.service'; +import { AchievementService } from '../achievement/achievement.service'; import { PrismaService } from '../prisma/prisma.service'; import { ChallengeDto, @@ -29,6 +32,7 @@ export class ChallengeService { private log: SessionLogService, private readonly prisma: PrismaService, private eventService: EventService, + private achievementService: AchievementService, private clientService: ClientService, private abilityFactory: CaslAbilityFactory, ) {} @@ -88,6 +92,7 @@ export class ChallengeService { } /** Progress user through challenges, ensuring challengeId is current */ + // async completeChallenge(user: User, challengeId: string, ability: AppAbility) { async completeChallenge(user: User, challengeId: string) { const groupMembers = await this.prisma.user.findMany({ where: { groupId: user.groupId }, @@ -96,6 +101,9 @@ export class ChallengeService { const eventTracker: EventTracker = await this.eventService.getCurrentEventTrackerForUser(user); + // const achievementTracker : AchievementTracker = + // await this.achievementService.getAchievementsByIdsForAbility(user.ability, [eventTracker.id]); + const alreadyDone = (await this.prisma.prevChallenge.count({ where: { @@ -150,6 +158,20 @@ export class ChallengeService { user.id, ); + // check if the challenge is part of a journey + const isJourney = (await this.prisma.prevChallenge.count({ + where: { + userId: user.id, + challengeId: eventTracker.curChallengeId, + trackerId: eventTracker.id, + }, + })) === (await this.prisma.eventTracker.count({ + where: { id: eventTracker.id }, // CHECK + })); + + await this.achievementService.checkAchievementProgress(user, challengeId, isJourney); + + return true; } diff --git a/server/src/event/event.service.ts b/server/src/event/event.service.ts index 3ec5d1ab..2f2faca3 100644 --- a/server/src/event/event.service.ts +++ b/server/src/event/event.service.ts @@ -171,6 +171,8 @@ export class EventService { }, }); + + return progress; } From 47ce5db3a11ce8e94017ddb0cb11747c5bcafa96 Mon Sep 17 00:00:00 2001 From: neketka Date: Sun, 28 Apr 2024 17:44:16 -0400 Subject: [PATCH 2/4] Fix achievement service import --- server/src/challenge/challenge.e2e-spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/challenge/challenge.e2e-spec.ts b/server/src/challenge/challenge.e2e-spec.ts index e1404456..6c9b95fa 100644 --- a/server/src/challenge/challenge.e2e-spec.ts +++ b/server/src/challenge/challenge.e2e-spec.ts @@ -56,6 +56,7 @@ describe('ChallengeModule E2E', () => { ClientService, GroupService, OrganizationService, + AchievementService, CaslAbilityFactory, ], }).compile(); From 8a509c04c333534e2282d1f3cabf4df36aef9d54 Mon Sep 17 00:00:00 2001 From: neketka Date: Sun, 28 Apr 2024 17:49:05 -0400 Subject: [PATCH 3/4] Add cascading delete to achievement tracker --- server/prisma/schema.prisma | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 5b3d408a..6bd9cd22 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -246,9 +246,9 @@ model AchievementTracker { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt() subject String @default("AchievementTracker") - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) progress Int - achievement Achievement @relation(fields: [achievementId], references: [id]) + achievement Achievement @relation(fields: [achievementId], references: [id], onDelete: Cascade) dateComplete DateTime? achievementId String userId String From 22864a3b55a71e07e9d0c9b224d4671c8287efda Mon Sep 17 00:00:00 2001 From: Jasmine Li Date: Mon, 29 Apr 2024 08:07:18 -0400 Subject: [PATCH 4/4] fixed bugs, still need to pass tests --- .../src/achievement/achievement.e2e-spec.ts | 1 + server/src/achievement/achievement.service.ts | 57 +++++++++++++++---- server/src/challenge/challenge.service.ts | 10 ++-- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/server/src/achievement/achievement.e2e-spec.ts b/server/src/achievement/achievement.e2e-spec.ts index 39122bb7..9addb976 100644 --- a/server/src/achievement/achievement.e2e-spec.ts +++ b/server/src/achievement/achievement.e2e-spec.ts @@ -72,6 +72,7 @@ describe('AchievementModule E2E', () => { console.log = function () {}; achievementService = module.get(AchievementService); + challengeService = module.get(ChallengeService); prisma = module.get(PrismaService); userService = module.get(UserService); eventService = module.get(EventService); diff --git a/server/src/achievement/achievement.service.ts b/server/src/achievement/achievement.service.ts index 60ba2868..713aa077 100644 --- a/server/src/achievement/achievement.service.ts +++ b/server/src/achievement/achievement.service.ts @@ -266,27 +266,40 @@ export class AchievementService { async checkAchievementProgress( user: User, challengeId: string, - isJourney: boolean, + isJourneyCompleted: boolean, ) { // find challenge corresponding to challengeId const curChallenge = await this.prisma.challenge.findUniqueOrThrow({ where: { id: challengeId }, + include: {linkedEvent: true} }); 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: challengeId }, // achievements linked to the specific event of the challenge + { linkedEventId: curChallenge.linkedEventId }, // achievements linked to the specific event of the challenge { linkedEventId: null }, // achievements not linked to any specific event + { achievementType : AchievementType.}, + { locationType : LocationType.ANY }, ], AND: [ accessibleBy(ability, Action.Read).Achievement, - {locationType: curChallenge.location}, + // {locationType: curChallenge.location}, ], + trackers: { + every: { // + OR: [ + { userId: { not: user.id } }, // Trackers not belonging to the user + { dateComplete: { not: null } } // Trackers that are completed + ] + }, + }, }, include: { trackers: { @@ -319,22 +332,46 @@ export class AchievementService { const journeyOrChalAchShouldProgress = ach.achievementType === AchievementTypeDto.TOTAL_CHALLENGES_OR_JOURNEYS || - (isJourney && + (isJourneyCompleted && ach.achievementType === AchievementTypeDto.TOTAL_JOURNEYS) || - (!isJourney && + (!isJourneyCompleted && ach.achievementType === AchievementTypeDto.TOTAL_CHALLENGES); if (ach.achievementType === AchievementTypeDto.TOTAL_POINTS) { - tracker.progress += curChallenge.points; + const updatedTracker = await this.prisma.achievementTracker.update({ + where: { id: tracker.id }, + data: { + progress: { + increment: curChallenge.points // increment tracker progress by points of current challenge + } + }, + }); if (tracker.progress >= ach.requiredPoints) { // ach is newly completed; update tracker with completion date - tracker.dateComplete = new Date(); + await this.prisma.achievementTracker.update({ + where: { id: tracker.id }, + data: { + dateComplete: new Date() // add new date + } + }); } } else if (journeyOrChalAchShouldProgress) { - tracker.progress += 1; - if (tracker.progress >= ach.requiredPoints) { - tracker.dateComplete = new Date(); + const updatedTracker = await this.prisma.achievementTracker.update({ + where: { id: tracker.id }, + data: { + progress: { + increment: 1 // increment tracker progress by 1 + } + }, + }); + if (updatedTracker.progress >= ach.requiredPoints) { + await this.prisma.achievementTracker.update({ + where: { id: tracker.id }, + data: { + dateComplete: new Date() // add new date + } + }); } } diff --git a/server/src/challenge/challenge.service.ts b/server/src/challenge/challenge.service.ts index 4352d17d..c0261677 100644 --- a/server/src/challenge/challenge.service.ts +++ b/server/src/challenge/challenge.service.ts @@ -158,8 +158,8 @@ export class ChallengeService { user.id, ); - // check if the challenge is part of a journey - const isJourney = + // check if the completed challenge is completing a journey + const isJourneyCompleted = (await this.prisma.prevChallenge.count({ where: { userId: user.id, @@ -167,14 +167,14 @@ export class ChallengeService { trackerId: eventTracker.id, }, })) === - (await this.prisma.eventTracker.count({ - where: { id: eventTracker.id }, // CHECK + (await this.prisma.challenge.count({ + where: { linkedEventId: eventTracker.eventId }, })); await this.achievementService.checkAchievementProgress( user, challengeId, - isJourney, + isJourneyCompleted, ); await this.eventService.emitUpdateLeaderPosition({