diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 8d2926fd..5f0ac058 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -38,6 +38,7 @@ import { ServerConnectionContext } from "./components/ServerConnection"; import { ServerDataContext } from "./components/ServerData"; import { AlertModal } from "./components/AlertModal"; import { Groups } from "./components/Groups"; +import { Achievements } from "./components/Achievements"; const routes = [ { @@ -64,6 +65,12 @@ const routes = [ icon: faLocationDot, name: "Challenges", }, + { + path: "/achievements", + element: , + icon: faTrophy, + name: "Achievements", + }, { path: "/users", element: , diff --git a/admin/src/all.dto.ts b/admin/src/all.dto.ts index 35059e48..239f7209 100644 --- a/admin/src/all.dto.ts +++ b/admin/src/all.dto.ts @@ -103,6 +103,10 @@ export interface RequestAchievementDataDto { achievements: string[]; } +export interface RequestAchievementTrackerDataDto { + achievements?: string[]; +} + export interface LoginDto { idToken: string; noRegister: boolean; @@ -121,9 +125,7 @@ export interface RefreshTokenDto { refreshToken: string; } -export interface CompletedChallengeDto { - challengeId: string; -} +export interface CompletedChallengeDto {} export interface ChallengeDto { id: string; @@ -230,7 +232,7 @@ export interface EventTrackerDto { eventId: string; isRanked: boolean; hintsUsed: number; - curChallengeId: string; + curChallengeId?: string; prevChallenges: PrevChallengeDto[]; } @@ -261,7 +263,7 @@ export interface GroupMemberDto { id: string; name: string; points: number; - curChallengeId: string; + curChallengeId?: string; } export interface GroupDto { @@ -293,6 +295,7 @@ export interface OrganizationDto { members?: string[]; events?: string[]; managers?: string[]; + achivements?: string[]; } export interface RequestOrganizationDataDto { diff --git a/admin/src/components/Achievements.tsx b/admin/src/components/Achievements.tsx new file mode 100644 index 00000000..f60ee56e --- /dev/null +++ b/admin/src/components/Achievements.tsx @@ -0,0 +1,287 @@ +import { useContext, useMemo, useState } from "react"; +import { DeleteModal } from "./DeleteModal"; +import { + EntryModal, + EntryForm, + NumberEntryForm, + OptionEntryForm, + FreeEntryForm, + DateEntryForm, +} from "./EntryModal"; +import { HButton } from "./HButton"; +import { + ButtonSizer, + CenterText, + ListCardBody, + ListCardBox, + ListCardButtons, + ListCardDescription, + ListCardTitle, +} from "./ListCard"; +import { SearchBar } from "./SearchBar"; +import { ServerDataContext } from "./ServerData"; + +import { compareTwoStrings } from "string-similarity"; +import { + AchievementDto, + AchievementTypeDto, + ChallengeLocationDto, +} from "../all.dto"; +import { AlertModal } from "./AlertModal"; + +const locationOptions = [ + ChallengeLocationDto.ENG_QUAD, + ChallengeLocationDto.ARTS_QUAD, + ChallengeLocationDto.AG_QUAD, + ChallengeLocationDto.NORTH_CAMPUS, + ChallengeLocationDto.WEST_CAMPUS, + ChallengeLocationDto.COLLEGETOWN, + ChallengeLocationDto.ITHACA_COMMONS, + ChallengeLocationDto.ANY, +]; + +const achievementOptions = [ + AchievementTypeDto.TOTAL_CHALLENGES, + AchievementTypeDto.TOTAL_CHALLENGES_OR_JOURNEYS, + AchievementTypeDto.TOTAL_JOURNEYS, + AchievementTypeDto.TOTAL_POINTS, +]; + +function AchiemementCard(props: { + achievement: AchievementDto; + onSelect: () => void; + onEdit: () => void; + onDelete: () => void; +}) { + return ( + <> + + + {props.achievement.name} + + + {props.achievement.eventId ? "UNLINK EVENT" : "LINK EVENT"} + + + + + {props.achievement.description} + + + Id: {props.achievement.id}
+ Required Points/Event Completions:{" "} + {props.achievement.requiredPoints}
+ Linked Event ID: {props.achievement.eventId ?? "NONE"}
+ Location Type: {props.achievement.locationType}
+ Achievement Type: {props.achievement.achievementType}
+
+ + DELETE + + EDIT + + +
+ + ); +} + +// Default Form Creation +function makeForm() { + return [ + { name: "Name", characterLimit: 256, value: "" }, + { name: "Description", characterLimit: 2048, value: "" }, + { + name: "Location Type", + options: locationOptions as string[], + value: 0, + }, + { + name: "Achievement Type", + options: achievementOptions as string[], + value: 0, + }, + { name: "Required Points", value: 1, min: 1, max: 999 }, + ] as EntryForm[]; +} + +// Form to DTO Conversion +function fromForm(form: EntryForm[], id: string): AchievementDto { + return { + id, + imageUrl: "", + name: (form[0] as FreeEntryForm).value, + description: (form[1] as FreeEntryForm).value, + locationType: locationOptions[(form[2] as OptionEntryForm).value], + achievementType: achievementOptions[(form[3] as OptionEntryForm).value], + requiredPoints: (form[4] as NumberEntryForm).value, + }; +} + +// DTO to Form Conversion +function toForm(achievement: AchievementDto) { + return [ + { name: "Name", characterLimit: 256, value: achievement.name! }, + { + name: "Description", + characterLimit: 2048, + value: achievement.description!, + }, + { + name: "Location Type", + options: locationOptions as string[], + value: locationOptions.indexOf(achievement.locationType!), + }, + { + name: "Achievement Type", + options: achievementOptions as string[], + value: achievementOptions.indexOf(achievement.achievementType!), + }, + { + name: "Required Points", + value: achievement.requiredPoints!, + min: 1, + max: 999, + }, + ] as EntryForm[]; +} + +export function Achievements() { + const serverData = useContext(ServerDataContext); + const [isCreateModalOpen, setCreateModalOpen] = useState(false); + const [isEditModalOpen, setEditModalOpen] = useState(false); + const [isDeleteModalOpen, setDeleteModalOpen] = useState(false); + const [selectModalOpen, setSelectModalOpen] = useState(false); + const [isLinkedModalOpen, setLinkedModalOpen] = useState(false); + const [form, setForm] = useState(() => makeForm()); + const [currentId, setCurrentId] = useState(""); + const [query, setQuery] = useState(""); + const selectedOrg = serverData.organizations.get(serverData.selectedOrg); + + return ( + <> + setSelectModalOpen(false)} + /> + setLinkedModalOpen(false)} + /> + { + serverData.updateAchievement({ + ...fromForm(form, ""), + initialOrganizationId: serverData.selectedOrg, + }); + setCreateModalOpen(false); + }} + onCancel={() => { + setCreateModalOpen(false); + }} + form={form} + /> + { + const oldAchievement = serverData.achievements.get(currentId)!; + serverData.updateAchievement({ + ...oldAchievement, + ...fromForm(form, currentId), + }); + setEditModalOpen(false); + }} + onCancel={() => { + setEditModalOpen(false); + }} + form={form} + /> + setDeleteModalOpen(false)} + onDelete={() => { + serverData.deleteAchievement(currentId); + setDeleteModalOpen(false); + }} + /> + { + if (!selectedOrg) { + setSelectModalOpen(true); + return; + } + setForm(makeForm()); + setCreateModalOpen(true); + }} + onSearch={(query) => setQuery(query)} + /> + {serverData.selectedOrg === "" ? ( + Select an organization to view achievements + ) : serverData.organizations.get(serverData.selectedOrg) ? ( + serverData.organizations?.get(serverData.selectedOrg)?.achivements + ?.length === 0 && ( + No achievements in organization + ) + ) : ( + Error getting achievements + )} + {Array.from( + serverData.organizations + .get(serverData.selectedOrg) + ?.achivements?.map( + (achId: string) => serverData.achievements.get(achId)! + ) + .filter((ach?: AchievementDto) => !!ach) ?? [] + ) + .sort( + (a: AchievementDto, b: AchievementDto) => + compareTwoStrings(b.name ?? "", query) - + compareTwoStrings(a.name ?? "", query) + + compareTwoStrings(b.description ?? "", query) - + compareTwoStrings(a.description ?? "", query) + ) + .map((ach) => ( + { + if (ach.eventId) { + serverData.updateAchievement({ + ...ach, + eventId: "", + }); + + return; + } + + if (serverData.selectedEvent === "") { + setLinkedModalOpen(true); + } else { + serverData.updateAchievement({ + ...ach, + eventId: serverData.selectedEvent, + }); + } + }} + onDelete={() => { + setCurrentId(ach.id); + setDeleteModalOpen(true); + }} + onEdit={() => { + setCurrentId(ach.id); + setForm(toForm(ach)); + setEditModalOpen(true); + }} + /> + ))} + + ); +} diff --git a/admin/src/components/ServerApi.tsx b/admin/src/components/ServerApi.tsx index 173d4602..8045bb0b 100644 --- a/admin/src/components/ServerApi.tsx +++ b/admin/src/components/ServerApi.tsx @@ -17,6 +17,10 @@ export class ServerApi { this.send("requestAchievementData", data); } + requestAchievementTrackerData(data: dto.RequestAchievementTrackerDataDto) { + this.send("requestAchievementTrackerData", data); + } + updateAchievementData(data: dto.UpdateAchievementDataDto) { this.send("updateAchievementData", data); } diff --git a/admin/src/components/ServerData.tsx b/admin/src/components/ServerData.tsx index e17a123c..d02539cb 100644 --- a/admin/src/components/ServerData.tsx +++ b/admin/src/components/ServerData.tsx @@ -13,6 +13,7 @@ import { GroupDto, UserDto, OrganizationDto, + AchievementDto, } from "../all.dto"; import { ServerApi } from "./ServerApi"; @@ -21,6 +22,7 @@ import { ServerConnectionContext } from "./ServerConnection"; /** object to store user data fetched from server */ const defaultData = { events: new Map(), + achievements: new Map(), challenges: new Map(), organizations: new Map(), users: new Map(), @@ -33,6 +35,8 @@ const defaultData = { setAdminStatus(id: string, granted: boolean) {}, updateChallenge(challenge: ChallengeDto) {}, deleteChallenge(id: string) {}, + updateAchievement(achievement: AchievementDto) {}, + deleteAchievement(id: string) {}, updateEvent(event: EventDto) {}, deleteEvent(id: string) {}, updateOrganization(organization: OrganizationDto) {}, @@ -89,6 +93,12 @@ export function ServerDataProvider(props: { children: ReactNode }) { deleteEvent(id: string) { sock.updateEventData({ event: { id }, deleted: true }); }, + updateAchievement(achievement: AchievementDto) { + sock.updateAchievementData({ achievement, deleted: false }); + }, + deleteAchievement(id: string) { + sock.updateAchievementData({ achievement: { id }, deleted: true }); + }, deleteError(id: string) { serverData.errors.delete(id); setTimeout(() => setServerData({ ...serverData }), 0); @@ -129,6 +139,18 @@ export function ServerDataProvider(props: { children: ReactNode }) { /** Update defaultData object when ServerApi websocket receives a response */ useEffect(() => { + sock.onUpdateAchievementData((data) => { + if (data.deleted) { + serverData.achievements.delete(data.achievement.id); + } else { + serverData.achievements.set( + (data.achievement as AchievementDto).id, + data.achievement as AchievementDto + ); + } + + setTimeout(() => setServerData({ ...serverData }), 0); + }); sock.onUpdateEventData((data) => { if (data.deleted) { serverData.events.delete(data.event.id); @@ -196,12 +218,25 @@ export function ServerDataProvider(props: { children: ReactNode }) { (data.organization as OrganizationDto).id )?.events ?? []; + const oldAchievements = + serverData.organizations.get( + (data.organization as OrganizationDto).id + )?.achivements ?? []; + sock.requestEventData({ events: (data.organization as OrganizationDto).events?.filter( (ev: string) => !(ev in oldEvents) ), }); + if (data.organization.achivements) { + sock.requestAchievementData({ + achievements: data.organization.achivements?.filter( + (achId) => !(achId in oldAchievements) + ), + }); + } + serverData.organizations.set( (data.organization as OrganizationDto).id, data.organization as OrganizationDto diff --git a/game/lib/achievements/achievement_cell.dart b/game/lib/achievements/achievement_cell.dart index 2abd76e1..81fd17e5 100644 --- a/game/lib/achievements/achievement_cell.dart +++ b/game/lib/achievements/achievement_cell.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -46,9 +48,11 @@ class LoadingBar extends StatelessWidget { ), Container( height: 3, - width: (totalTasks > 0 ? tasksFinished / totalTasks : 0) * - constraints.maxWidth - - 16, + width: max( + (totalTasks > 0 ? tasksFinished / totalTasks : 0) * + constraints.maxWidth - + 16, + 0), margin: EdgeInsets.only(left: 8, top: 3), alignment: Alignment.centerLeft, decoration: new BoxDecoration( @@ -143,7 +147,7 @@ class _AchievementCellState extends State { Spacer(), Align( alignment: Alignment.bottomCenter, - child: LoadingBar(3, 4)), + child: LoadingBar(this.tasksFinished, this.totalTasks)), ], ) ], diff --git a/game/lib/achievements/achievements_page.dart b/game/lib/achievements/achievements_page.dart index eb3f75ad..4aeece12 100644 --- a/game/lib/achievements/achievements_page.dart +++ b/game/lib/achievements/achievements_page.dart @@ -4,12 +4,14 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:game/achievements/achievement_cell.dart'; import 'package:game/api/game_api.dart'; import 'package:game/api/game_client_dto.dart'; +import 'package:game/model/achievement_model.dart'; import 'package:game/model/challenge_model.dart'; import 'package:game/model/event_model.dart'; import 'package:game/model/group_model.dart'; import 'package:game/utils/utility_functions.dart'; import 'package:game/model/tracker_model.dart'; import 'package:provider/provider.dart'; +import 'package:velocity_x/velocity_x.dart'; class AchievementCellDto { AchievementCellDto({ @@ -48,8 +50,6 @@ class _AchievementsPageState extends State { List selectedLocations = []; String selectedDifficulty = ''; - List eventData = []; - @override Widget build(BuildContext context) { return Container( @@ -72,124 +72,21 @@ class _AchievementsPageState extends State { padding: EdgeInsets.all(30), child: Column( children: [ - Expanded(child: Consumer5( - builder: (context, - myEventModel, - groupModel, - trackerModel, - challengeModel, - apiClient, - child) { - if (myEventModel.searchResults == null) { - myEventModel.searchEvents( - 0, - 1000, - [ - EventTimeLimitationDto.PERPETUAL, - EventTimeLimitationDto.LIMITED_TIME - ], - false, - false, - false); - } - final events = myEventModel.searchResults ?? []; - if (!events.any((element) => - element.id == groupModel.curEventId)) { - final curEvent = myEventModel - .getEventById(groupModel.curEventId ?? ""); - if (curEvent != null) events.add(curEvent); - } - eventData.clear(); - - for (EventDto event in events) { - var tracker = - trackerModel.trackerByEventId(event.id); - var numberCompleted = - tracker?.prevChallenges.length ?? 0; - var complete = - (numberCompleted == event.challenges?.length); - var locationCount = event.challenges?.length ?? 0; - DateTime now = DateTime.now(); - DateTime endtime = - HttpDate.parse(event.endTime ?? ""); - - Duration timeTillExpire = endtime.difference(now); - if (locationCount != 1) continue; - var challenge = challengeModel - .getChallengeById(event.challenges?[0] ?? ""); - - // print("Doing Event with now/endtime " + event.description.toString() + now.toString() + "/" + endtime.toString()); - if (challenge == null) { - // print("Challenge is null for event " + event.description.toString()); - - continue; - } - final challengeLocation = - challenge.location?.name ?? ""; - - bool eventMatchesDifficultySelection; - bool eventMatchesCategorySelection; - bool eventMatchesLocationSelection; - - if (selectedDifficulty.length == 0 || - selectedDifficulty == event.difficulty?.name) - eventMatchesDifficultySelection = true; - else - eventMatchesDifficultySelection = false; - - if (selectedLocations.length > 0) { - if (selectedLocations.contains(challengeLocation)) - eventMatchesLocationSelection = true; - else - eventMatchesLocationSelection = false; - } else - eventMatchesLocationSelection = true; - - if (selectedCategories.length > 0) { - if (selectedCategories - .contains(event.category?.name)) - eventMatchesCategorySelection = true; - else - eventMatchesCategorySelection = false; - } else - eventMatchesCategorySelection = true; - if (!complete && - !timeTillExpire.isNegative && - eventMatchesDifficultySelection && - eventMatchesCategorySelection && - eventMatchesLocationSelection) { - eventData.add(AchievementCellDto( - location: - friendlyLocation[challenge.location] ?? "", - name: event.name ?? "", - lat: challenge.latF ?? null, - long: challenge.longF ?? null, - thumbnail: SvgPicture.asset( - "assets/icons/achievementsilver.svg"), - complete: complete, - description: event.description ?? "", - difficulty: - friendlyDifficulty[event.difficulty] ?? "", - points: challenge.points ?? 0, - eventId: event.id, - )); - } else if (event.id == groupModel.curEventId) { - apiClient.serverApi?.setCurrentEvent( - SetCurrentEventDto(eventId: "")); - } - } + Expanded(child: Consumer2( + builder: (context, achModel, apiClient, child) { + final achList = achModel.getAvailableTrackerPairs(); return ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 3), - itemCount: eventData.length, + itemCount: achList.length, itemBuilder: (context, index) { return AchievementCell( key: UniqueKey(), - eventData[index].description, - eventData[index].thumbnail, - 3, - 4); + achList[index].$2.description ?? "", + SvgPicture.asset( + "assets/icons/achievementsilver.svg"), + achList[index].$1.progress, + achList[index].$2.requiredPoints ?? 0); }, physics: BouncingScrollPhysics(), separatorBuilder: (context, index) { diff --git a/game/lib/api/game_client_dto.dart b/game/lib/api/game_client_dto.dart index 940de9f1..5a0b3984 100644 --- a/game/lib/api/game_client_dto.dart +++ b/game/lib/api/game_client_dto.dart @@ -211,7 +211,7 @@ class AchievementTrackerDto { class UpdateAchievementDataDto { Map toJson() { Map fields = {}; - fields['achievement'] = achievement.toJson(); + fields['achievement'] = achievement!.toJson(); fields['deleted'] = deleted; return fields; } @@ -257,6 +257,33 @@ class RequestAchievementDataDto { late List achievements; } +class RequestAchievementTrackerDataDto { + Map toJson() { + Map fields = {}; + if (achievements != null) { + fields['achievements'] = achievements; + } + return fields; + } + + RequestAchievementTrackerDataDto.fromJson(Map fields) { + achievements = fields.containsKey('achievements') + ? (List.from(fields['achievements'])) + : null; + } + + void partialUpdate(RequestAchievementTrackerDataDto other) { + achievements = + other.achievements == null ? achievements : other.achievements; + } + + RequestAchievementTrackerDataDto({ + this.achievements, + }); + + late List? achievements; +} + class LoginDto { Map toJson() { Map fields = {}; @@ -286,7 +313,7 @@ class LoginDto { if (aud != null) { fields['aud'] = aud!.name; } - fields['enrollmentType'] = enrollmentType.name; + fields['enrollmentType'] = enrollmentType!.name; return fields; } @@ -373,23 +400,14 @@ class RefreshTokenDto { class CompletedChallengeDto { Map toJson() { Map fields = {}; - fields['challengeId'] = challengeId; return fields; } - CompletedChallengeDto.fromJson(Map fields) { - challengeId = fields["challengeId"]; - } - - void partialUpdate(CompletedChallengeDto other) { - challengeId = other.challengeId; - } + CompletedChallengeDto.fromJson(Map fields) {} - CompletedChallengeDto({ - required this.challengeId, - }); + void partialUpdate(CompletedChallengeDto other) {} - late String challengeId; + CompletedChallengeDto(); } class ChallengeDto { @@ -520,7 +538,7 @@ class RequestChallengeDataDto { class UpdateChallengeDataDto { Map toJson() { Map fields = {}; - fields['challenge'] = challenge.toJson(); + fields['challenge'] = challenge!.toJson(); fields['deleted'] = deleted; return fields; } @@ -751,7 +769,7 @@ class UpdateLeaderDataDto { fields['eventId'] = eventId; } fields['offset'] = offset; - fields['users'] = users + fields['users'] = users! .map>((dynamic val) => val!.toJson()) .toList(); return fields; @@ -1028,8 +1046,10 @@ class EventTrackerDto { fields['eventId'] = eventId; fields['isRanked'] = isRanked; fields['hintsUsed'] = hintsUsed; - fields['curChallengeId'] = curChallengeId; - fields['prevChallenges'] = prevChallenges + if (curChallengeId != null) { + fields['curChallengeId'] = curChallengeId; + } + fields['prevChallenges'] = prevChallenges! .map>((dynamic val) => val!.toJson()) .toList(); return fields; @@ -1039,7 +1059,9 @@ class EventTrackerDto { eventId = fields["eventId"]; isRanked = fields["isRanked"]; hintsUsed = fields["hintsUsed"]; - curChallengeId = fields["curChallengeId"]; + curChallengeId = fields.containsKey('curChallengeId') + ? (fields["curChallengeId"]) + : null; prevChallenges = fields["prevChallenges"] .map((dynamic val) => PrevChallengeDto.fromJson(val)) .toList(); @@ -1049,7 +1071,8 @@ class EventTrackerDto { eventId = other.eventId; isRanked = other.isRanked; hintsUsed = other.hintsUsed; - curChallengeId = other.curChallengeId; + curChallengeId = + other.curChallengeId == null ? curChallengeId : other.curChallengeId; prevChallenges = other.prevChallenges; } @@ -1057,21 +1080,21 @@ class EventTrackerDto { required this.eventId, required this.isRanked, required this.hintsUsed, - required this.curChallengeId, + this.curChallengeId, required this.prevChallenges, }); late String eventId; late bool isRanked; late int hintsUsed; - late String curChallengeId; + late String? curChallengeId; late List prevChallenges; } class UpdateEventTrackerDataDto { Map toJson() { Map fields = {}; - fields['tracker'] = tracker.toJson(); + fields['tracker'] = tracker!.toJson(); return fields; } @@ -1093,7 +1116,7 @@ class UpdateEventTrackerDataDto { class UpdateEventDataDto { Map toJson() { Map fields = {}; - fields['event'] = event.toJson(); + fields['event'] = event!.toJson(); fields['deleted'] = deleted; return fields; } @@ -1206,7 +1229,9 @@ class GroupMemberDto { fields['id'] = id; fields['name'] = name; fields['points'] = points; - fields['curChallengeId'] = curChallengeId; + if (curChallengeId != null) { + fields['curChallengeId'] = curChallengeId; + } return fields; } @@ -1214,27 +1239,30 @@ class GroupMemberDto { id = fields["id"]; name = fields["name"]; points = fields["points"]; - curChallengeId = fields["curChallengeId"]; + curChallengeId = fields.containsKey('curChallengeId') + ? (fields["curChallengeId"]) + : null; } void partialUpdate(GroupMemberDto other) { id = other.id; name = other.name; points = other.points; - curChallengeId = other.curChallengeId; + curChallengeId = + other.curChallengeId == null ? curChallengeId : other.curChallengeId; } GroupMemberDto({ required this.id, required this.name, required this.points, - required this.curChallengeId, + this.curChallengeId, }); late String id; late String name; late int points; - late String curChallengeId; + late String? curChallengeId; } class GroupDto { @@ -1298,7 +1326,7 @@ class GroupDto { class UpdateGroupDataDto { Map toJson() { Map fields = {}; - fields['group'] = group.toJson(); + fields['group'] = group!.toJson(); fields['deleted'] = deleted; return fields; } @@ -1390,6 +1418,9 @@ class OrganizationDto { if (managers != null) { fields['managers'] = managers; } + if (achivements != null) { + fields['achivements'] = achivements; + } return fields; } @@ -1407,6 +1438,9 @@ class OrganizationDto { managers = fields.containsKey('managers') ? (List.from(fields['managers'])) : null; + achivements = fields.containsKey('achivements') + ? (List.from(fields['achivements'])) + : null; } void partialUpdate(OrganizationDto other) { @@ -1416,6 +1450,7 @@ class OrganizationDto { members = other.members == null ? members : other.members; events = other.events == null ? events : other.events; managers = other.managers == null ? managers : other.managers; + achivements = other.achivements == null ? achivements : other.achivements; } OrganizationDto({ @@ -1425,6 +1460,7 @@ class OrganizationDto { this.members, this.events, this.managers, + this.achivements, }); late String id; @@ -1433,6 +1469,7 @@ class OrganizationDto { late List? members; late List? events; late List? managers; + late List? achivements; } class RequestOrganizationDataDto { @@ -1460,7 +1497,7 @@ class RequestOrganizationDataDto { class UpdateOrganizationDataDto { Map toJson() { Map fields = {}; - fields['organization'] = organization.toJson(); + fields['organization'] = organization!.toJson(); fields['deleted'] = deleted; return fields; } @@ -1527,7 +1564,7 @@ class BanUserDto { class SetAuthToOAuthDto { Map toJson() { Map fields = {}; - fields['provider'] = provider.name; + fields['provider'] = provider!.name; fields['authId'] = authId; return fields; } @@ -1792,7 +1829,7 @@ class UserDto { class UpdateUserDataDto { Map toJson() { Map fields = {}; - fields['user'] = user.toJson(); + fields['user'] = user!.toJson(); fields['deleted'] = deleted; return fields; } diff --git a/game/lib/api/game_server_api.dart b/game/lib/api/game_server_api.dart index b9c7a934..9bd3c3f1 100644 --- a/game/lib/api/game_server_api.dart +++ b/game/lib/api/game_server_api.dart @@ -3,6 +3,7 @@ // OTHERWISE YOUR CHANGES MAY BE OVERWRITTEN! import 'dart:async'; +import 'dart:convert'; import 'package:game/api/game_client_dto.dart'; import 'package:socket_io_client/socket_io_client.dart'; @@ -42,6 +43,9 @@ class GameServerApi { void requestAchievementData(RequestAchievementDataDto dto) => _invokeWithRefresh("requestAchievementData", dto.toJson()); + void requestAchievementTrackerData(RequestAchievementTrackerDataDto dto) => + _invokeWithRefresh("requestAchievementTrackerData", dto.toJson()); + void updateAchievementData(UpdateAchievementDataDto dto) => _invokeWithRefresh("updateAchievementData", dto.toJson()); diff --git a/game/lib/challenges/challenges_page.dart b/game/lib/challenges/challenges_page.dart index 3cf83b8b..769442b0 100644 --- a/game/lib/challenges/challenges_page.dart +++ b/game/lib/challenges/challenges_page.dart @@ -7,6 +7,7 @@ import 'package:game/model/challenge_model.dart'; import 'package:game/model/event_model.dart'; import 'package:game/model/group_model.dart'; import 'package:game/model/tracker_model.dart'; +import 'package:game/model/user_model.dart'; import 'package:game/utils/utility_functions.dart'; import 'package:provider/provider.dart'; import 'package:velocity_x/velocity_x.dart'; @@ -98,29 +99,17 @@ class _ChallengesPageState extends State { ), Column( children: [ - Expanded(child: Consumer5( - builder: (context, myEventModel, groupModel, + builder: (context, userModel, myEventModel, groupModel, trackerModel, challengeModel, apiClient, child) { - if (myEventModel.searchResults == null) { - myEventModel.searchEvents( - 0, - 1000, - [ - EventTimeLimitationDto.PERPETUAL, - EventTimeLimitationDto.LIMITED_TIME - ], - false, - false, - false); - } - final events = myEventModel.searchResults ?? []; - if (!events.any( - (element) => element.id == groupModel.curEventId)) { - final curEvent = myEventModel - .getEventById(groupModel.curEventId ?? ""); - if (curEvent != null) events.add(curEvent); - } + final allowedEventIds = userModel.getAvailableEventIds(); + final events = allowedEventIds + .map((id) => myEventModel.getEventById(id)) + .filter((element) => element != null) + .map((e) => e!) + .toList(); + eventData.clear(); for (EventDto event in events) { diff --git a/game/lib/gameplay/gameplay_map.dart b/game/lib/gameplay/gameplay_map.dart index 226c4edc..247a8942 100644 --- a/game/lib/gameplay/gameplay_map.dart +++ b/game/lib/gameplay/gameplay_map.dart @@ -339,7 +339,7 @@ class _GameplayMapState extends State { challengeModel, apiClient, child) { EventTrackerDto? tracker = trackerModel.trackerByEventId(groupModel.curEventId ?? ""); - if (tracker == null) { + if (tracker?.curChallengeId == null) { displayToast("Error getting event tracker", Status.error); } else { numHintsLeft = totalHints - tracker.hintsUsed; diff --git a/game/lib/gameplay/gameplay_page.dart b/game/lib/gameplay/gameplay_page.dart index b89079cf..e1513c18 100644 --- a/game/lib/gameplay/gameplay_page.dart +++ b/game/lib/gameplay/gameplay_page.dart @@ -89,7 +89,8 @@ class _GameplayPageState extends State { return CircularIndicator(); } - var challenge = challengeModel.getChallengeById(tracker.curChallengeId); + var challenge = + challengeModel.getChallengeById(tracker.curChallengeId ?? ""); if (challenge == null) { return Scaffold( diff --git a/game/lib/global_leaderboard/global_leaderboard_widget.dart b/game/lib/global_leaderboard/global_leaderboard_widget.dart index 027e9998..3f4c1b5c 100644 --- a/game/lib/global_leaderboard/global_leaderboard_widget.dart +++ b/game/lib/global_leaderboard/global_leaderboard_widget.dart @@ -1,7 +1,4 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:game/api/game_client_dto.dart'; import 'package:game/model/event_model.dart'; import 'package:game/model/group_model.dart'; @@ -9,7 +6,6 @@ import 'package:game/model/user_model.dart'; import 'package:game/global_leaderboard/podium_widgets.dart'; import 'package:game/widget/leaderboard_cell.dart'; import 'package:game/widget/podium_cell.dart'; -import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; /** diff --git a/game/lib/global_leaderboard/podium_widgets.dart b/game/lib/global_leaderboard/podium_widgets.dart index 921ff5ed..52213f07 100644 --- a/game/lib/global_leaderboard/podium_widgets.dart +++ b/game/lib/global_leaderboard/podium_widgets.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; diff --git a/game/lib/journeys/journeys_page.dart b/game/lib/journeys/journeys_page.dart index 6f5b3b01..1dae43b1 100644 --- a/game/lib/journeys/journeys_page.dart +++ b/game/lib/journeys/journeys_page.dart @@ -9,8 +9,10 @@ import 'package:game/model/event_model.dart'; import 'package:game/model/group_model.dart'; import 'package:game/model/tracker_model.dart'; import 'package:game/model/challenge_model.dart'; +import 'package:game/model/user_model.dart'; import 'package:game/utils/utility_functions.dart'; import 'package:provider/provider.dart'; +import 'package:velocity_x/velocity_x.dart'; class JourneyCellDto { JourneyCellDto({ @@ -116,29 +118,17 @@ class _JourneysPageState extends State { ), Column( children: [ - Expanded(child: Consumer5( - builder: (context, myEventModel, groupModel, trackerModel, - challengeModel, apiClient, child) { - if (myEventModel.searchResults == null) { - myEventModel.searchEvents( - 0, - 1000, - [ - EventTimeLimitationDto.PERPETUAL, - EventTimeLimitationDto.LIMITED_TIME - ], - false, - false, - false); - } - final events = myEventModel.searchResults ?? []; - if (!events.any( - (element) => element.id == groupModel.curEventId)) { - final curEvent = myEventModel - .getEventById(groupModel.curEventId ?? ""); - if (curEvent != null) events.add(curEvent); - } + builder: (context, userModel, myEventModel, groupModel, + trackerModel, challengeModel, apiClient, child) { + final allowedEventIds = userModel.getAvailableEventIds(); + + final events = allowedEventIds + .map((id) => myEventModel.getEventById(id)) + .filter((element) => element != null) + .map((e) => e!) + .toList(); eventData.clear(); for (EventDto event in events) { diff --git a/game/lib/main.dart b/game/lib/main.dart index 257a8dd9..bed7cc2b 100644 --- a/game/lib/main.dart +++ b/game/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_config/flutter_config.dart'; import 'package:game/loading_page/loading_page.dart'; +import 'package:game/model/achievement_model.dart'; // imports for google maps import 'dart:io' show Platform; @@ -92,6 +93,10 @@ class MyApp extends StatelessWidget { create: (_) => EventModel(client), lazy: false, ), + ChangeNotifierProvider( + create: (_) => AchievementModel(client), + lazy: false, + ), ChangeNotifierProvider( create: (_) => TrackerModel(client), lazy: false, diff --git a/game/lib/model/achievement_model.dart b/game/lib/model/achievement_model.dart index c7c7bbb8..0045f95c 100644 --- a/game/lib/model/achievement_model.dart +++ b/game/lib/model/achievement_model.dart @@ -1,12 +1,14 @@ import 'package:flutter/foundation.dart'; import 'package:game/api/game_api.dart'; import 'package:game/api/game_client_dto.dart'; +import 'package:velocity_x/velocity_x.dart'; /** * This file represents the model for the achievements. Whenever a achievement is updated, added or deleted from the backend, the model is updated and notifies the Consumer so that the front end can be modified. */ class AchievementModel extends ChangeNotifier { Map _achievementsById = {}; + Map _trackersByAchId = {}; ApiClient _client; AchievementModel(ApiClient client) : _client = client { @@ -22,9 +24,45 @@ class AchievementModel extends ChangeNotifier { notifyListeners(); }); + client.clientApi.connectedStream.listen((event) { + client.serverApi + ?.requestAchievementTrackerData(RequestAchievementTrackerDataDto()); + + notifyListeners(); + }); + + client.clientApi.updateAchievementTrackerDataStream.listen((event) { + _trackersByAchId[event.achievementId] = event; + notifyListeners(); + }); + client.clientApi.connectedStream.listen((event) { _achievementsById.clear(); notifyListeners(); }); } + + AchievementDto? getAchievementById(String id) { + if (_achievementsById.containsKey(id)) { + return _achievementsById[id]; + } else { + _client.serverApi?.requestAchievementData( + RequestAchievementDataDto(achievements: [id])); + return null; + } + } + + List getAchievementTrackers() { + return _trackersByAchId.valuesList(); + } + + List<(AchievementTrackerDto, AchievementDto)> getAvailableTrackerPairs() { + final achTrackers = getAchievementTrackers(); + + return achTrackers + .map((e) => (e, getAchievementById(e.achievementId))) + .filter((e) => e.$2 != null) + .map((e) => (e.$1, e.$2!)) + .toList(); + } } diff --git a/game/lib/model/user_model.dart b/game/lib/model/user_model.dart index ca51ec7b..15d2123d 100644 --- a/game/lib/model/user_model.dart +++ b/game/lib/model/user_model.dart @@ -7,6 +7,7 @@ import 'package:game/api/game_client_dto.dart'; */ class UserModel extends ChangeNotifier { UserDto? userData; + Map orgData = {}; ApiClient _client; UserModel(ApiClient client) : _client = client { @@ -23,7 +24,40 @@ class UserModel extends ChangeNotifier { client.clientApi.connectedStream.listen((event) { userData = null; client.serverApi?.requestUserData(RequestUserDataDto()); + client.serverApi + ?.requestOrganizationData(RequestOrganizationDataDto(admin: false)); }); + + client.clientApi.disconnectedStream.listen((event) { + userData = null; + orgData.clear(); + }); + + client.clientApi.updateOrganizationDataStream.listen((event) { + if (event.deleted) { + orgData.remove(event.organization.id); + } else { + if (!orgData.containsKey(event.organization.id)) { + orgData[event.organization.id] = event.organization; + } else { + orgData[event.organization.id]?.partialUpdate(event.organization); + } + } + + notifyListeners(); + }); + } + + List getAvailableEventIds() { + Set evIds = Set(); + + for (final org in orgData.values) { + if (org.events != null) { + evIds.addAll(org.events!); + } + } + + return evIds.toList(); } void updateUserData(String id, String? username, String? college, diff --git a/game/lib/profile/profile_page.dart b/game/lib/profile/profile_page.dart index d470303e..1a5cf5c0 100644 --- a/game/lib/profile/profile_page.dart +++ b/game/lib/profile/profile_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:game/achievements/achievements_page.dart'; import 'package:game/api/game_client_dto.dart'; +import 'package:game/model/achievement_model.dart'; import 'package:game/model/challenge_model.dart'; import 'package:game/model/event_model.dart'; import 'package:game/model/tracker_model.dart'; @@ -14,6 +15,7 @@ import 'package:intl/intl.dart' hide TextDirection; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; import 'package:game/profile/completed_feed.dart'; +import 'package:velocity_x/velocity_x.dart'; /** * The profile page of the app that is rendered for the user's profile @@ -31,14 +33,17 @@ class _ProfilePageState extends State { return Scaffold( backgroundColor: Color.fromARGB(255, 255, 245, 234), body: SafeArea(child: Container( - child: Consumer4( + child: Consumer5( builder: (context, userModel, eventModel, trackerModel, - challengeModel, child) { + challengeModel, achModel, child) { if (userModel.userData == null) { return Center( child: CircularProgressIndicator(), ); } + + final achList = achModel.getAvailableTrackerPairs(); var username = userModel.userData?.username; var isGuest = userModel.userData?.authType == UserAuthTypeDto.device; var score = userModel.userData?.score; @@ -47,8 +52,11 @@ class _ProfilePageState extends State { //Get completed events for (var eventId in userModel.userData!.trackedEvents!) { + if (completedEvents.length == 2) break; + var tracker = trackerModel.trackerByEventId(eventId); EventDto? event = eventModel.getEventById(eventId); + if (tracker == null || event == null) { continue; } @@ -161,19 +169,25 @@ class _ProfilePageState extends State { //To be replaced with real data Padding( padding: EdgeInsets.only(left: 30, right: 30), - child: Column(children: [ - AchievementCell( - "Complete three challenges", - SvgPicture.asset("assets/icons/achievementsilver.svg"), - 4, - 6), - SizedBox(height: 10), - AchievementCell( - "Complete three challenges", - SvgPicture.asset("assets/icons/achievementsilver.svg"), - 4, - 6), - ])), + child: Column( + children: (achList + .sortedBy((a, b) => (a + .$1.progress / // least completed first + (a.$2.requiredPoints ?? 1)) + .compareTo( + b.$1.progress / (b.$2.requiredPoints ?? 1))) + .take(2) + .map((e) => ([ + AchievementCell( + e.$2.description ?? "", + SvgPicture.asset( + "assets/icons/achievementsilver.svg"), + e.$1.progress, + e.$2.requiredPoints ?? 0), + SizedBox(height: 10), + ])) + .expand((el) => el) + .toList()))), //Completed Events Padding( padding: const EdgeInsets.only(left: 24, right: 24.0), diff --git a/game/lib/splash_page/splash_page.dart b/game/lib/splash_page/splash_page.dart index f5b07e49..2730c1f7 100644 --- a/game/lib/splash_page/splash_page.dart +++ b/game/lib/splash_page/splash_page.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:game/api/game_client_dto.dart'; import 'package:google_sign_in/google_sign_in.dart'; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7d26446c..0aa24ed4 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -186,8 +186,8 @@ model EventTracker { isRankedForEvent Boolean @default(true) event EventBase @relation(fields: [eventId], references: [id], onDelete: Cascade) eventId String - curChallenge Challenge @relation(fields: [curChallengeId], references: [id]) - curChallengeId String + curChallenge Challenge? @relation(fields: [curChallengeId], references: [id]) + curChallengeId String? completedChallenges PrevChallenge[] } @@ -216,6 +216,7 @@ model Organization { managers User[] @relation("orgManager") events EventBase[] @relation("eventOrgs") specialUsage OrganizationSpecialUsage + achievements Achievement[] } model SessionLogEntry { @@ -242,6 +243,7 @@ model Achievement { locationType LocationType achievementType AchievementType trackers AchievementTracker[] + organizations Organization[] } model AchievementTracker { @@ -249,9 +251,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 diff --git a/server/src/achievement/achievement.dto.ts b/server/src/achievement/achievement.dto.ts index b26c85da..5377a4b1 100644 --- a/server/src/achievement/achievement.dto.ts +++ b/server/src/achievement/achievement.dto.ts @@ -34,3 +34,7 @@ export interface UpdateAchievementDataDto { export interface RequestAchievementDataDto { achievements: string[]; } + +export interface RequestAchievementTrackerDataDto { + achievements?: string[]; +} diff --git a/server/src/achievement/achievement.e2e-spec.ts b/server/src/achievement/achievement.e2e-spec.ts index 8bbfb328..5cfc679b 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. @@ -60,6 +63,7 @@ describe('AchievementModule E2E', () => { EventService, ClientService, GroupService, + ChallengeService, OrganizationService, CaslAbilityFactory, ], @@ -69,6 +73,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); @@ -95,6 +100,7 @@ describe('AchievementModule E2E', () => { include: { memberOf: true }, }); + // tracker = await achievementService.getAchievementsByIdsForAbility(fullAbility, ) console.log = log; }); @@ -104,7 +110,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) @@ -126,12 +132,22 @@ describe('AchievementModule E2E', () => { fullAbility, achDto, ); - console.log(ach); 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 () => { @@ -155,7 +171,7 @@ describe('AchievementModule E2E', () => { it('should update an achievement: upsertAchievementFromDto', async () => { const achId = (await prisma.achievement.findFirstOrThrow()).id; const test = (await achievementService.getAchievementFromId(achId)) - .imageUrl; + ?.imageUrl; console.log('before: ' + test); const orgUsage = OrganizationSpecialUsage; const orgId = ( @@ -180,13 +196,100 @@ describe('AchievementModule E2E', () => { }); const testAfterUpdate = ( await achievementService.getAchievementFromId(achId) - ).imageUrl; + )?.imageUrl; console.log('after: ' + testAfterUpdate); expect(ach.imageUrl).toEqual('update test'); }); }); + 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); + + 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({ @@ -207,6 +310,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.gateway.ts b/server/src/achievement/achievement.gateway.ts index 5685d60b..dd28fc59 100644 --- a/server/src/achievement/achievement.gateway.ts +++ b/server/src/achievement/achievement.gateway.ts @@ -13,6 +13,7 @@ import { AchievementDto, AchievementTrackerDto, RequestAchievementDataDto, + RequestAchievementTrackerDataDto, UpdateAchievementDataDto, } from './achievement.dto'; import { AchievementService } from './achievement.service'; @@ -21,6 +22,7 @@ import { UserAbility } from '../casl/user-ability.decorator'; import { AppAbility } from '../casl/casl-ability.factory'; import { Action } from '../casl/action.enum'; import { subject } from '@casl/ability'; +import { OrganizationService } from '../organization/organization.service'; @WebSocketGateway({ cors: true }) @UseGuards(UserGuard, PoliciesGuard) @@ -28,11 +30,11 @@ export class AchievementGateway { constructor( private achievementService: AchievementService, private clientService: ClientService, + private orgService: OrganizationService, ) {} /** * request achievements by list of ids - * update achievement with a dto * @param user * @param data */ @@ -47,12 +49,34 @@ export class AchievementGateway { ability, data.achievements, ); - console.log(achs.length); + for (const ach of achs) { await this.achievementService.emitUpdateAchievementData(ach, false, user); } } + /** + * request achievement trackers by list of ids + * @param user + * @param data + */ + + @SubscribeMessage('requestAchievementTrackerData') + async requestAchievementTrackerData( + @UserAbility() ability: AppAbility, + @CallingUser() user: User, + @MessageBody() data: RequestAchievementTrackerDataDto, + ) { + const achs = await this.achievementService.getAchievementTrackersForUser( + user, + data.achievements, + ); + + for (const ach of achs) { + await this.achievementService.emitUpdateAchievementTracker(ach, user); + } + } + /** * Updates achievement data based on a provided DTO. * Triggered by the 'updateAchievementData' message. @@ -101,6 +125,11 @@ export class AchievementGateway { return; } + const org = await this.orgService.getOrganizationById( + data.achievement.initialOrganizationId!, + ); + + await this.orgService.emitUpdateOrganizationData(org, false); this.clientService.subscribe(user, achievement.id); await this.achievementService.emitUpdateAchievementData( achievement, diff --git a/server/src/achievement/achievement.module.ts b/server/src/achievement/achievement.module.ts index 51ea8cd4..6449f2a3 100644 --- a/server/src/achievement/achievement.module.ts +++ b/server/src/achievement/achievement.module.ts @@ -6,6 +6,7 @@ import { PrismaModule } from '../prisma/prisma.module'; import { AchievementGateway } from './achievement.gateway'; import { AchievementService } from './achievement.service'; import { CaslModule } from '../casl/casl.module'; +import { OrganizationModule } from '../organization/organization.module'; @Module({ imports: [ @@ -14,6 +15,7 @@ import { CaslModule } from '../casl/casl.module'; EventModule, PrismaModule, CaslModule, + OrganizationModule, ], exports: [AchievementService], providers: [AchievementGateway, AchievementService], diff --git a/server/src/achievement/achievement.service.ts b/server/src/achievement/achievement.service.ts index d9b9d926..38e1762e 100644 --- a/server/src/achievement/achievement.service.ts +++ b/server/src/achievement/achievement.service.ts @@ -8,6 +8,7 @@ import { LocationType, PrismaClient, User, + EventTracker, } from '@prisma/client'; import { ClientService } from '../client/client.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -15,7 +16,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, @@ -34,7 +38,7 @@ export class AchievementService { /** get an achievement by its ID */ async getAchievementFromId(id: string) { - return await this.prisma.achievement.findFirstOrThrow({ where: { id } }); + return await this.prisma.achievement.findFirst({ where: { id } }); } /** get list of achievements by IDs, based on ability */ @@ -71,6 +75,16 @@ export class AchievementService { }, })) > 0; + const canUpdateEv = + (await this.prisma.eventBase.count({ + where: { + AND: [ + accessibleBy(ability, Action.Update).EventBase, + { id: achievement.eventId ?? '' }, + ], + }, + })) > 0; + const canUpdateAch = (await this.prisma.achievement.count({ where: { @@ -89,6 +103,10 @@ export class AchievementService { imageUrl: achievement.imageUrl?.substring(0, 2048), locationType: achievement.locationType as LocationType, achievementType: achievement.achievementType as AchievementTypeDto, + linkedEventId: + achievement.eventId && achievement.eventId !== '' && canUpdateEv + ? achievement.eventId + : null, }; const data = await this.abilityFactory.filterInaccessible( ach.id, @@ -115,8 +133,8 @@ export class AchievementService { defaultAchievementData.imageUrl, locationType: achievement.locationType as LocationType, achievementType: achievement.achievementType as AchievementTypeDto, - // eventIndex: assignData.eventId ?? 0, // check - requiredPoints: achievement.requiredPoints ?? 0, + requiredPoints: achievement.requiredPoints ?? 1, + organizations: { connect: { id: achievement.initialOrganizationId } }, }; ach = await this.prisma.achievement.create({ @@ -127,6 +145,7 @@ export class AchievementService { } else { return null; } + if (ach) this.createAchievementTrackers(undefined, ach); return ach; } @@ -194,4 +213,230 @@ 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 getAchievementTrackersForUser(user: User, achievementIds?: string[]) { + return await this.prisma.achievementTracker.findMany({ + where: { + userId: user.id, + achievementId: achievementIds ? { in: achievementIds } : undefined, + }, + }); + } + + async dtoForAchievementTracker( + tracker: AchievementTracker, + ): Promise { + 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 ?? tracker.id, + dto, + { + id: tracker.id, + subject: 'AchievementTracker', + prismaStore: this.prisma.achievementTracker, + }, + ); + } + + /** checks for all achievements associated with a user for a given completed challenge. */ + async checkAchievementProgress( + user: User, + evTracker: EventTracker, + pointsAdded: number, + ) { + const ability = this.abilityFactory.createForUser(user); + + const isJourney = + (await this.prisma.challenge.count({ + where: { linkedEventId: evTracker.eventId }, + })) > 1; + + const locations = ( + await this.prisma.challenge.findMany({ + distinct: ['location'], + select: { location: true }, + }) + ).map(l => l.location); + + const uncompletedAchs = await this.prisma.achievement.findMany({ + where: { + AND: [ + accessibleBy(ability, Action.Read).Achievement, + { + // must either be for all events or for this one + OR: [{ linkedEventId: null }, { linkedEventId: evTracker.eventId }], + }, + { + // must either be any location of one of the ones here + OR: [ + { locationType: { in: locations } }, + { locationType: LocationType.ANY }, + ], + }, + { + // must either be both challenge + journey achievement + // total points achievement + // or journey/challenge achievement depending on what was completed + OR: [ + { achievementType: AchievementType.TOTAL_CHALLENGES_OR_JOURNEYS }, + { achievementType: AchievementType.TOTAL_POINTS }, + { + achievementType: isJourney + ? AchievementType.TOTAL_JOURNEYS + : AchievementType.TOTAL_CHALLENGES, + }, + ], + }, + { + // Only find non-completed achievements + OR: [ + { + trackers: { + some: { + userId: user.id, + dateComplete: null, + }, + }, + }, + { trackers: { none: { userId: user.id } } }, + ], + }, + ], + }, + include: { + trackers: { + where: { + userId: user.id, + }, + }, + }, + }); + + for (const ach of uncompletedAchs) { + let achTracker = ach.trackers[0]; + + // In case the above completion check fails + if (achTracker.progress < ach.requiredPoints) { + const deltaProgress = + ach.achievementType === AchievementType.TOTAL_POINTS + ? pointsAdded + : 1; + + achTracker = await this.prisma.achievementTracker.update({ + where: { id: achTracker.id }, + data: { + progress: { increment: deltaProgress }, + dateComplete: + achTracker.progress + deltaProgress >= ach.requiredPoints + ? new Date() + : null, + }, + }); + + if (achTracker.progress > ach.requiredPoints) { + achTracker = await this.prisma.achievementTracker.update({ + where: { id: achTracker.id }, + data: { + progress: { set: ach.requiredPoints }, + }, + }); + } + + await this.clientService.subscribe(user, achTracker.id); + await this.emitUpdateAchievementTracker(achTracker); + } + } + } + + async createAchievementTrackers(user?: User, achievement?: Achievement) { + if (user) { + const ability = this.abilityFactory.createForUser(user); + + const achsWithoutTrackers = await this.prisma.achievement.findMany({ + where: { + AND: [ + { id: achievement?.id }, + accessibleBy(ability).Achievement, + { trackers: { none: { userId: user.id } } }, + ], + }, + }); + + await this.prisma.achievementTracker.createMany({ + data: achsWithoutTrackers.map(ach => ({ + userId: user.id, + progress: 0, + achievementId: ach.id, + })), + }); + } else if (achievement) { + const usersWithoutTrackers = await this.prisma.user.findMany({ + where: { + memberOf: { + some: { achievements: { some: { id: achievement.id } } }, + }, + achievementTrackers: { + none: { achievementId: achievement.id }, + }, + }, + }); + + await this.prisma.achievementTracker.createMany({ + data: usersWithoutTrackers.map(usr => ({ + userId: usr.id, + progress: 0, + achievementId: achievement.id, + })), + }); + } else { + throw 'Cannot create all possible achievement trackers in one call!'; + } + } } diff --git a/server/src/casl/casl-ability.factory.ts b/server/src/casl/casl-ability.factory.ts index 42650332..0409585e 100644 --- a/server/src/casl/casl-ability.factory.ts +++ b/server/src/casl/casl-ability.factory.ts @@ -145,6 +145,10 @@ export class CaslAbilityFactory { can(Action.Read, 'AchievementTracker', { userId: user.id }); + can(Action.Manage, 'Achievement', { + organizations: { some: { managers: { some: { id: user.id } } } }, + }); + // Read challenges that belong to events you're allowed to access // And you must have completed them or are in the process can(Action.Read, 'Challenge', undefined, { diff --git a/server/src/challenge/challenge.dto.ts b/server/src/challenge/challenge.dto.ts index ebe3f8f0..ae36d5b5 100644 --- a/server/src/challenge/challenge.dto.ts +++ b/server/src/challenge/challenge.dto.ts @@ -1,7 +1,5 @@ /** DTO for completedChallenge */ -export interface CompletedChallengeDto { - challengeId: string; -} +export interface CompletedChallengeDto {} export enum ChallengeLocationDto { ENG_QUAD = 'ENG_QUAD', diff --git a/server/src/challenge/challenge.e2e-spec.ts b/server/src/challenge/challenge.e2e-spec.ts index 83694a6f..1f5b821f 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; @@ -55,6 +56,7 @@ describe('ChallengeModule E2E', () => { ClientService, GroupService, OrganizationService, + AchievementService, CaslAbilityFactory, ], }).compile(); @@ -104,10 +106,10 @@ describe('ChallengeModule E2E', () => { const trackerScore = tracker.score; let chal = await prisma.challenge.findFirstOrThrow({ - where: { id: tracker.curChallengeId }, + where: { id: tracker.curChallengeId! }, }); - await challengeService.completeChallenge(user, chal.id); + await challengeService.completeChallenge(user); const score2 = ( await prisma.user.findFirstOrThrow({ @@ -196,12 +198,6 @@ describe('ChallengeModule E2E', () => { }; await challengeService.upsertChallengeFromDto(fullAbility, secondChalDto); - const nextChal = await challengeService.nextChallenge( - await prisma.challenge.findFirstOrThrow({ - where: { linkedEventId: event.id, eventIndex: 0 }, - }), - ); - expect(nextChal.eventIndex).toEqual(1); const evchal = await challengeService.getFirstChallengeForEvent(event); expect(evchal.eventIndex).toEqual(0); }); diff --git a/server/src/challenge/challenge.gateway.ts b/server/src/challenge/challenge.gateway.ts index 5ff4bbc4..8f000f25 100644 --- a/server/src/challenge/challenge.gateway.ts +++ b/server/src/challenge/challenge.gateway.ts @@ -58,37 +58,12 @@ export class ChallengeGateway { } } - // Disabled for now to prevent any cheating - /* - @SubscribeMessage('setCurrentChallenge') - async setCurrentChallenge( - @CallingUser() user: User, - @MessageBody() data: SetCurrentChallengeDto, - ) { - if ( - await this.challengeService.setCurrentChallenge(user, data.challengeId) - ) { - const group = await this.groupService.getGroupForUser(user); - const tracker = await this.eventService.getCurrentEventTrackerForUser( - user, - ); - - await this.groupService.emitUpdateGroupData(group, false); - await this.eventService.emitUpdateEventTracker(tracker); - } else { - await this.clientService.emitErrorData( - user, - 'Challenge is not valid (Challenge is not in event)', - ); - } - }*/ - @SubscribeMessage('completedChallenge') async completedChallenge( @CallingUser() user: User, @MessageBody() data: CompletedChallengeDto, ) { - if (await this.challengeService.completeChallenge(user, data.challengeId)) { + if (await this.challengeService.completeChallenge(user)) { const group = await this.groupService.getGroupForUser(user); const tracker = await this.eventService.getCurrentEventTrackerForUser( user, 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 848a8ac8..75bd5e5c 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, @@ -22,6 +25,7 @@ import { accessibleBy } from '@casl/prisma'; import { Action } from '../casl/action.enum'; import { subject } from '@casl/ability'; import { defaultChallengeData } from '../organization/organization.service'; +import { connect } from 'http2'; @Injectable() export class ChallengeService { @@ -29,6 +33,7 @@ export class ChallengeService { private log: SessionLogService, private readonly prisma: PrismaService, private eventService: EventService, + private achievementService: AchievementService, private clientService: ClientService, private abilityFactory: CaslAbilityFactory, ) {} @@ -76,19 +81,23 @@ export class ChallengeService { } /** Get next challenge in a sequence of challenges */ - async nextChallenge(chal: Challenge) { - return ( - (await this.prisma.challenge.findFirst({ - where: { - eventIndex: chal.eventIndex + 1, - linkedEventId: chal.linkedEventId, - }, - })) ?? chal - ); + async nextChallenge(evTracker: EventTracker) { + const nextChal = await this.prisma.challenge.findFirst({ + where: { + linkedEventId: evTracker.eventId, + completions: { none: { userId: evTracker.userId } }, + }, + orderBy: { + eventIndex: 'asc', + }, + }); + + return nextChal; } /** Progress user through challenges, ensuring challengeId is current */ - async completeChallenge(user: User, challengeId: string) { + // async completeChallenge(user: User, challengeId: string, ability: AppAbility) { + async completeChallenge(user: User) { const groupMembers = await this.prisma.user.findMany({ where: { groupId: user.groupId }, }); @@ -96,6 +105,8 @@ export class ChallengeService { const eventTracker: EventTracker = await this.eventService.getCurrentEventTrackerForUser(user); + if (!eventTracker.curChallengeId) return false; + const alreadyDone = (await this.prisma.prevChallenge.count({ where: { @@ -106,7 +117,7 @@ export class ChallengeService { })) > 0; // Ensure that the correct challenge is marked complete - if (challengeId !== eventTracker.curChallengeId || alreadyDone) { + if (alreadyDone) { return false; } @@ -126,30 +137,47 @@ export class ChallengeService { where: { id: eventTracker.curChallengeId }, }); - const nextChallenge = await this.nextChallenge(curChallenge); + const nextChallenge = await this.nextChallenge(eventTracker); - const totalScore = curChallenge.points - 25 * eventTracker.hintsUsed; + const deltaScore = curChallenge.points - 25 * eventTracker.hintsUsed; const newUser = await this.prisma.user.update({ where: { id: user.id }, - data: { score: { increment: totalScore } }, + data: { score: { increment: deltaScore } }, }); const newEvTracker = await this.prisma.eventTracker.update({ where: { id: eventTracker.id }, data: { - score: { increment: totalScore }, + score: { increment: deltaScore }, hintsUsed: 0, - curChallenge: { connect: { id: nextChallenge.id } }, + curChallengeId: nextChallenge?.id ?? null, }, }); await this.log.logEvent( SessionLogEvent.COMPLETE_CHALLENGE, - challengeId, + curChallenge.id, user.id, ); + // check if the completed challenge is completing a journey + const isJourneyCompleted = + (await this.prisma.challenge.count({ + where: { + linkedEvent: { id: eventTracker.eventId }, + completions: { none: { userId: user.id } }, + }, + })) === 0; + + if (isJourneyCompleted) { + await this.achievementService.checkAchievementProgress( + user, + eventTracker, + deltaScore, + ); + } + await this.eventService.emitUpdateLeaderPosition({ playerId: newUser.id, newTotalScore: newUser.score, @@ -189,46 +217,6 @@ export class ChallengeService { }); } - // Disabled for now - /* - async setCurrentChallenge(user: User, challengeId: string) { - const group = await this.prisma.group.findUniqueOrThrow({ - where: { id: user.groupId }, - include: { curEvent: true, members: true }, - }); - - const isChallengeValid = await this.eventService.isChallengeInEvent( - challengeId, - group.curEventId, - ); - - if (!isChallengeValid) { - return false; - } - - const eventTracker: EventTracker = - await this.eventService.getCurrentEventTrackerForUser(user); - - const challenge = await this.getChallengeById(challengeId); - - if (!challenge) return false; - - await this.prisma.eventTracker.update({ - where: { id: eventTracker.id }, - data: { - curChallengeId: challenge.id, - }, - }); - - await this.log.logEvent( - SessionLogEvent.SET_CHALLENGE, - challengeId, - user.id, - ); - - return true; - }*/ - async emitUpdateChallengeData( challenge: Challenge, deleted: boolean, @@ -355,6 +343,8 @@ export class ChallengeService { return null; } + await this.eventService.fixEventTrackers(chal.linkedEventId ?? undefined); + return chal; } @@ -373,37 +363,16 @@ export class ChallengeService { if (!challenge) return false; - // checks for any eventTracker entries that reference the challenge - const usedTrackers = await this.prisma.eventTracker.findMany({ - where: { - curChallengeId: challengeId, - }, - }); - - // finds replacement challenge within the same event as the one being deleted - const replacementChal = await this.prisma.challenge.findFirstOrThrow({ - where: { linkedEventId: challenge.linkedEventId }, - select: { id: true }, - }); - - // updates all affected trackers to reference the replacement challenge - for (const tracker of usedTrackers) { - await this.prisma.eventTracker.update({ - where: { id: tracker.id }, - data: { - curChallenge: { - connect: { id: replacementChal.id }, - }, - }, - }); - } - await this.prisma.challenge.delete({ where: { id: challengeId, }, }); + await this.eventService.fixEventTrackers( + challenge.linkedEventId ?? undefined, + ); + console.log(`Deleted challenge ${challengeId}`); return true; } diff --git a/server/src/client/client.service.ts b/server/src/client/client.service.ts index b84fc3b0..0b26c33b 100644 --- a/server/src/client/client.service.ts +++ b/server/src/client/client.service.ts @@ -51,14 +51,23 @@ export class ClientService { ) {} public subscribe(user: User, resourceId: string) { + if (process.env.TESTING_E2E === 'true') { + return; + } this.gateway.server.in(user.id).socketsJoin(resourceId); } public unsubscribe(user: User, resourceId: string) { + if (process.env.TESTING_E2E === 'true') { + return; + } this.gateway.server.in(user.id).socketsLeave(resourceId); } public unsubscribeAll(resourceId: string) { + if (process.env.TESTING_E2E === 'true') { + return; + } this.gateway.server.socketsLeave([resourceId]); } @@ -72,6 +81,9 @@ export class ClientService { } async getAffectedUsers(target: string) { + if (process.env.TESTING_E2E === 'true') { + return []; + } // Get list of targeted sockets const socks = await this.gateway.server.in(target).fetchSockets(); @@ -119,9 +131,13 @@ export class ClientService { const room = target instanceof Object ? 'user/' + target.id : target; if (!resource) { - this.gateway.server.to(room).emit(event, dto); + if (process.env.TESTING_E2E !== 'true') { + this.gateway.server.to(room).emit(event, dto); + } } else { - this.gateway.server.in(room).socketsJoin(resource.id); + if (process.env.TESTING_E2E !== 'true') { + this.gateway.server.in(room).socketsJoin(resource.id); + } // Find all targeted users const users = await this.getAffectedUsers(room); diff --git a/server/src/event/event.dto.ts b/server/src/event/event.dto.ts index b1624325..9c04c68c 100644 --- a/server/src/event/event.dto.ts +++ b/server/src/event/event.dto.ts @@ -83,7 +83,7 @@ export interface EventTrackerDto { eventId: string; isRanked: boolean; hintsUsed: number; - curChallengeId: string; + curChallengeId?: string; prevChallenges: PrevChallengeDto[]; } diff --git a/server/src/event/event.e2e-spec.ts b/server/src/event/event.e2e-spec.ts index 5ad5459f..dfa95b1c 100644 --- a/server/src/event/event.e2e-spec.ts +++ b/server/src/event/event.e2e-spec.ts @@ -125,18 +125,7 @@ describe('EventModule E2E', () => { ); expect(tracker1.eventId).toEqual(exJourney1.id); expect(tracker1.score).toEqual(0); - expect( - await challengeService.completeChallenge( - exPlayer, - tracker1.curChallengeId, - ), - ).toBeTruthy(); - expect( - await challengeService.completeChallenge( - exPlayer, - tracker1.curChallengeId, - ), - ).toBeFalsy(); + expect(await challengeService.completeChallenge(exPlayer)).toBeTruthy(); const tracker2 = await eventService.getCurrentEventTrackerForUser( exPlayer, @@ -150,47 +139,19 @@ describe('EventModule E2E', () => { ); expect(tracker3.eventId).toEqual(exJourney2.id); expect(tracker3.score).toEqual(0); - expect( - await challengeService.completeChallenge( - exPlayer, - tracker2.curChallengeId, - ), - ).toBeFalsy(); - expect( - await challengeService.completeChallenge( - exPlayer, - tracker3.curChallengeId, - ), - ).toBeTruthy(); - expect( - await challengeService.completeChallenge( - exPlayer, - tracker3.curChallengeId, - ), - ).toBeFalsy(); + expect(await challengeService.completeChallenge(exPlayer)).toBeTruthy(); const tracker4 = await eventService.getCurrentEventTrackerForUser( exPlayer, ); expect(tracker4.curChallengeId).toEqual(journey2Chal.id); expect(tracker4.score).toEqual(1); - expect( - await challengeService.completeChallenge( - exPlayer, - tracker4.curChallengeId, - ), - ).toBeTruthy(); - expect( - await challengeService.completeChallenge( - exPlayer, - tracker4.curChallengeId, - ), - ).toBeFalsy(); + expect(await challengeService.completeChallenge(exPlayer)).toBeTruthy(); const tracker5 = await eventService.getCurrentEventTrackerForUser( exPlayer, ); - expect(tracker5.curChallengeId).toEqual(journey2Chal.id); + expect(tracker5.curChallengeId).toEqual(null); expect(tracker5.score).toEqual(2); await groupGateway.setCurrentEvent(exPlayer, { @@ -202,23 +163,13 @@ describe('EventModule E2E', () => { ); expect(tracker6.eventId).toEqual(exChallenge1.id); expect(tracker6.score).toEqual(0); - expect( - await challengeService.completeChallenge( - exPlayer, - tracker6.curChallengeId, - ), - ).toBeTruthy(); - expect( - await challengeService.completeChallenge( - exPlayer, - tracker6.curChallengeId, - ), - ).toBeFalsy(); + expect(await challengeService.completeChallenge(exPlayer)).toBeTruthy(); + expect(await challengeService.completeChallenge(exPlayer)).toBeFalsy(); const tracker7 = await eventService.getCurrentEventTrackerForUser( exPlayer, ); - expect(tracker7.curChallengeId).toEqual(tracker6.curChallengeId); + expect(tracker7.curChallengeId).toEqual(null); expect(tracker7.score).toEqual(1); await groupGateway.setCurrentEvent(exPlayer, { eventId: exJourney1.id }); @@ -229,25 +180,15 @@ describe('EventModule E2E', () => { expect(tracker8.eventId).toEqual(exJourney1.id); expect(tracker8.curChallengeId).toEqual(journey1Chal.id); expect(tracker8.score).toEqual(1); - expect( - await challengeService.completeChallenge( - exPlayer, - tracker8.curChallengeId, - ), - ).toBeTruthy(); - expect( - await challengeService.completeChallenge( - exPlayer, - tracker8.curChallengeId, - ), - ).toBeFalsy(); + expect(await challengeService.completeChallenge(exPlayer)).toBeTruthy(); const tracker9 = await eventService.getCurrentEventTrackerForUser( exPlayer, ); expect(tracker9.eventId).toEqual(exJourney1.id); - expect(tracker9.curChallengeId).toEqual(journey1Chal.id); + expect(tracker9.curChallengeId).toEqual(null); expect(tracker9.score).toEqual(2); + expect(await challengeService.completeChallenge(exPlayer)).toBeFalsy(); const latestUserData = await userService.byId(exPlayer.id); expect(latestUserData?.score).toEqual(5); diff --git a/server/src/event/event.service.ts b/server/src/event/event.service.ts index 57ea6779..84ade958 100644 --- a/server/src/event/event.service.ts +++ b/server/src/event/event.service.ts @@ -298,7 +298,7 @@ export class EventService { eventId: tracker.eventId, isRanked: tracker.isRankedForEvent, hintsUsed: tracker.hintsUsed, - curChallengeId: tracker.curChallengeId, + curChallengeId: tracker.curChallengeId ?? undefined, prevChallenges: prevChallenges.map(pc => ({ challengeId: pc.challengeId, hintsUsed: pc.hintsUsed, @@ -511,9 +511,49 @@ export class EventService { } } + await this.fixEventTrackers(event.id); + return ev; } + async fixEventTrackers(eventId?: string) { + if (!eventId) return; + + const trackers = await this.prisma.eventTracker.findMany({ + where: { + eventId: eventId, + curChallengeId: null, + }, + }); + + const newTrackers = await Promise.all( + trackers.map(async tracker => { + const nextChal = await this.prisma.challenge.findFirst({ + where: { + linkedEventId: tracker.id, + completions: { none: { userId: tracker.userId } }, + }, + orderBy: { + eventIndex: 'asc', + }, + }); + + if (!nextChal) return null; + + return await this.prisma.eventTracker.update({ + where: { id: tracker.id }, + data: { curChallengeId: nextChal?.id }, + }); + }), + ); + + await Promise.all( + newTrackers.map(tracker => { + if (tracker) this.emitUpdateEventTracker(tracker); + }), + ); + } + async removeEvent(ability: AppAbility, eventId: string) { if ( await this.prisma.eventBase.findFirst({ @@ -533,6 +573,8 @@ export class EventService { }, }); + await this.fixEventTrackers(eventId); + console.log(`Deleted event ${eventId}`); return true; } diff --git a/server/src/group/group.dto.ts b/server/src/group/group.dto.ts index 3ba98ab2..86e8b2c1 100644 --- a/server/src/group/group.dto.ts +++ b/server/src/group/group.dto.ts @@ -19,7 +19,7 @@ export interface GroupMemberDto { id: string; name: string; points: number; - curChallengeId: string; + curChallengeId?: string; } export interface GroupDto { diff --git a/server/src/group/group.service.ts b/server/src/group/group.service.ts index c129aa6c..82585bfe 100644 --- a/server/src/group/group.service.ts +++ b/server/src/group/group.service.ts @@ -289,7 +289,7 @@ export class GroupService { name: mem.username, points: tracker.score, host: mem.id === group.hostId, - curChallengeId: tracker.curChallengeId, + curChallengeId: tracker.curChallengeId ?? undefined, }; }), ); diff --git a/server/src/organization/organization.dto.ts b/server/src/organization/organization.dto.ts index 5efc3639..62265d6a 100644 --- a/server/src/organization/organization.dto.ts +++ b/server/src/organization/organization.dto.ts @@ -5,6 +5,7 @@ export interface OrganizationDto { members?: string[]; events?: string[]; managers?: string[]; + achivements?: string[]; } export interface RequestOrganizationDataDto { diff --git a/server/src/organization/organization.e2e-spec.ts b/server/src/organization/organization.e2e-spec.ts index cc84a1d1..c020f497 100644 --- a/server/src/organization/organization.e2e-spec.ts +++ b/server/src/organization/organization.e2e-spec.ts @@ -184,7 +184,7 @@ describe('OrganizationModule E2E', () => { defaultChal = (await challengeService.getChallengeById( ( await eventService.getCurrentEventTrackerForUser(basicUser) - ).curChallengeId, + ).curChallengeId!, ))!; managerGroup = await groupService.getGroupForUser(managerUser); diff --git a/server/src/organization/organization.service.ts b/server/src/organization/organization.service.ts index 4780d687..6e0079df 100644 --- a/server/src/organization/organization.service.ts +++ b/server/src/organization/organization.service.ts @@ -175,6 +175,9 @@ export class OrganizationService { members: (await org.members({ select: { id: true } })).map(e => e.id), events: (await org.events({ select: { id: true } })).map(e => e.id), managers: (await org.managers({ select: { id: true } })).map(e => e.id), + achivements: (await org.achievements({ select: { id: true } })).map( + e => e.id, + ), accessCode: organization.accessCode, }; } @@ -225,6 +228,9 @@ export class OrganizationService { events: { connect: organization.events?.map(id => ({ id })), }, + achievements: { + connect: organization.achivements?.map(id => ({ id })), + }, specialUsage: OrganizationSpecialUsage.NONE, }; @@ -370,10 +376,5 @@ export class OrganizationService { where: { id: org.id }, data: { members: { connect: { id: user.id } } }, }); - - await this.prisma.user.update({ - where: { id: user.id }, - data: { memberOf: { connect: { id: org.id } } }, - }); } } diff --git a/server/src/user/user.module.ts b/server/src/user/user.module.ts index ffc69184..bde72264 100644 --- a/server/src/user/user.module.ts +++ b/server/src/user/user.module.ts @@ -9,6 +9,7 @@ import { PrismaModule } from '../prisma/prisma.module'; import { UserGateway } from './user.gateway'; import { UserService } from './user.service'; import { CaslModule } from '../casl/casl.module'; +import { AchievementModule } from '../achievement/achievement.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { CaslModule } from '../casl/casl.module'; PrismaModule, EventModule, OrganizationModule, + AchievementModule, CaslModule, ], providers: [UserService, UserGateway], diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index d88abcbd..e8f3dbb5 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -22,6 +22,7 @@ import { Action } from '../casl/action.enum'; import { subject } from '@casl/ability'; import { accessibleBy } from '@casl/prisma'; import { join } from 'path'; +import { AchievementService } from '../achievement/achievement.service'; @Injectable() export class UserService { @@ -34,6 +35,7 @@ export class UserService { private orgService: OrganizationService, private clientService: ClientService, private abilityFactory: CaslAbilityFactory, + private achievementService: AchievementService, ) {} /** Find a user by their authentication token */ @@ -68,12 +70,12 @@ export class UserService { username = 'guest' + (count + 921); } - const defOrg = await this.orgService.getDefaultOrganization( + const allOrg = await this.orgService.getDefaultOrganization( OrganizationSpecialUsage.DEVICE_LOGIN, ); const group: Group = await this.groupsService.createFromEvent( - await this.orgService.getDefaultEvent(defOrg), + await this.orgService.getDefaultEvent(allOrg), ); const user: User = await this.prisma.user.create({ @@ -81,7 +83,7 @@ export class UserService { score: 0, group: { connect: { id: group.id } }, hostOf: { connect: { id: group.id } }, - memberOf: { connect: { id: defOrg.id } }, + memberOf: { connect: { id: allOrg.id } }, username: username ?? email?.split('@')[0], year, college, @@ -105,13 +107,15 @@ export class UserService { await this.log.logEvent(SessionLogEvent.CREATE_USER, user.id, user.id); if (authType === AuthType.GOOGLE) { - const allOrg = await this.orgService.getDefaultOrganization( + const cornellOrg = await this.orgService.getDefaultOrganization( OrganizationSpecialUsage.CORNELL_LOGIN, ); - await this.orgService.joinOrganization(user, allOrg.accessCode); + await this.orgService.joinOrganization(user, cornellOrg.accessCode); } + await this.achievementService.createAchievementTrackers(user); + return user; }