diff --git a/client/src/api/study/request.ts b/client/src/api/study/request.ts index ac775888b..de6756305 100644 --- a/client/src/api/study/request.ts +++ b/client/src/api/study/request.ts @@ -1,5 +1,5 @@ import { url } from "@port-of-mars/client/util"; -import { ProlificParticipantStatus } from "@port-of-mars/shared/types"; +import { ProlificStudyData, ProlificParticipantStatus } from "@port-of-mars/shared/types"; import { TStore } from "@port-of-mars/client/plugins/tstore"; import { AjaxRequest } from "@port-of-mars/client/plugins/ajax"; @@ -7,14 +7,40 @@ export class StudyAPI { constructor(public store: TStore, public ajax: AjaxRequest) {} async getProlificParticipantStatus(): Promise { - return await this.ajax.get(url("/study/prolific/status"), ({ data }) => { + return this.ajax.get(url("/study/prolific/status"), ({ data }) => { return data; }); } async completeProlificStudy(): Promise { - return await this.ajax.get(url("/study/prolific/complete"), ({ data }) => { + return this.ajax.get(url("/study/prolific/complete"), ({ data }) => { return data; }); } + + async getAllProlificStudies(): Promise { + return this.ajax.get(url("/study/prolific/studies"), ({ data }) => { + return data; + }); + } + + async addProlificStudy(study: ProlificStudyData): Promise { + return this.ajax.post( + url("/study/prolific/add"), + ({ data }) => { + return data; + }, + study + ); + } + + async updateProlificStudy(study: ProlificStudyData): Promise { + return this.ajax.post( + url(`/study/prolific/update/?studyId=${study.studyId}`), + ({ data }) => { + return data; + }, + study + ); + } } diff --git a/client/src/router.ts b/client/src/router.ts index 7728db3f5..b6036ac6d 100644 --- a/client/src/router.ts +++ b/client/src/router.ts @@ -6,6 +6,7 @@ import Games from "@port-of-mars/client/views/admin/Games.vue"; import Rooms from "@port-of-mars/client/views/admin/Rooms.vue"; import Reports from "@port-of-mars/client/views/admin/Reports.vue"; import Settings from "@port-of-mars/client/views/admin/Settings.vue"; +import Studies from "@port-of-mars/client/views/admin/Studies.vue"; import Login from "@port-of-mars/client/views/Login.vue"; import Leaderboard from "@port-of-mars/client/views/Leaderboard.vue"; import PlayerHistory from "@port-of-mars/client/views/PlayerHistory.vue"; @@ -63,6 +64,7 @@ const router = new VueRouter({ { path: "rooms", name: "AdminRooms", component: Rooms, meta: ADMIN_META }, { path: "reports", name: "AdminReports", component: Reports, meta: ADMIN_META }, { path: "settings", name: "AdminSettings", component: Settings, meta: ADMIN_META }, + { path: "studies", name: "AdminStudies", component: Studies, meta: ADMIN_META }, ], }, { ...PAGE_META[LOGIN_PAGE], component: Login }, diff --git a/client/src/views/Admin.vue b/client/src/views/Admin.vue index 32adf73ba..7031e9007 100644 --- a/client/src/views/Admin.vue +++ b/client/src/views/Admin.vue @@ -19,6 +19,9 @@ Settings + + Studies + + +
+ + +
+

Prolific Studies

+
+
+ +

New Study

+
+
+
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + Show participants with $0.00 bonus payments + + + + +
+
+
+
+ + + diff --git a/server/src/routes/study.ts b/server/src/routes/study.ts index 70800bf34..1ac50c3e1 100644 --- a/server/src/routes/study.ts +++ b/server/src/routes/study.ts @@ -1,12 +1,11 @@ -import { Router, Request, Response } from "express"; +import { Router, Request, Response, NextFunction } from "express"; import passport from "passport"; import { getServices } from "@port-of-mars/server/services"; import { toUrl } from "@port-of-mars/server/util"; import { PROLIFIC_STUDY_PAGE } from "@port-of-mars/shared/routes"; import { User } from "@port-of-mars/server/entity"; -// import { settings } from "@port-of-mars/server/settings"; - -// const logger = settings.logging.getLogger(__filename); +import { ProlificStudyData } from "@port-of-mars/shared/types"; +import { isAdminAuthenticated } from "@port-of-mars/server/routes/middleware"; export const studyRouter = Router(); @@ -14,7 +13,7 @@ export const studyRouter = Router(); // https://portofmars.asu.edu/study/prolific?prolificId=&studyId= studyRouter.get( "/prolific", - async (req: Request, res: Response, next) => { + async (req: Request, res: Response, next: NextFunction) => { const prolificId = String(req.query.prolificId || ""); if (!prolificId) { return res.status(403).json({ @@ -40,7 +39,7 @@ studyRouter.get( } ); -studyRouter.get("/prolific/status", async (req: Request, res: Response, next) => { +studyRouter.get("/prolific/status", async (req: Request, res: Response, next: NextFunction) => { try { const services = getServices(); const user = req.user as User; @@ -51,7 +50,7 @@ studyRouter.get("/prolific/status", async (req: Request, res: Response, next) => } }); -studyRouter.get("/prolific/complete", async (req: Request, res: Response, next) => { +studyRouter.get("/prolific/complete", async (req: Request, res: Response, next: NextFunction) => { try { const services = getServices(); const user = req.user as User; @@ -61,3 +60,67 @@ studyRouter.get("/prolific/complete", async (req: Request, res: Response, next) next(e); } }); + +studyRouter.get( + "/prolific/studies", + isAdminAuthenticated, + async (req: Request, res: Response, next: NextFunction) => { + try { + const studies = await getServices().study.getAllProlificStudies(); + for (const study of studies) { + study.participantPoints = await getServices().study.getAllParticipantPoints(study.studyId); + } + res.json(studies); + } catch (error) { + next(error); + } + } +); + +studyRouter.post( + "/prolific/add", + isAdminAuthenticated, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { description, studyId, completionCode, isActive } = req.body as ProlificStudyData; + if (!description || !studyId || !completionCode) { + return res.status(400).json({ + message: "Missing required fields: description, studyId, or completionCode.", + }); + } + const savedStudy = await getServices().study.createProlificStudy( + studyId, + completionCode, + description, + isActive ?? true + ); + res.status(201).json(savedStudy); + } catch (error) { + console.error("Error adding new study:", error); + next(error); + } + } +); + +studyRouter.post( + "/prolific/update", + isAdminAuthenticated, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { studyId, description, completionCode, isActive } = req.body as ProlificStudyData; + const updatedStudy = await getServices().study.updateProlificStudy( + studyId, + completionCode, + description, + isActive ?? true + ); + if (!updatedStudy) { + return res.status(404).json({ message: `Study with ID ${studyId} not found.` }); + } + res.status(200).json(updatedStudy); + } catch (error) { + console.error("Error updating study:", error); + res.status(500).json({ message: "Internal Server Error", error }); + } + } +); diff --git a/server/src/services/study.ts b/server/src/services/study.ts index 2c3a1632f..47c0b6089 100644 --- a/server/src/services/study.ts +++ b/server/src/services/study.ts @@ -10,7 +10,11 @@ import { settings } from "@port-of-mars/server/settings"; import { BaseService } from "@port-of-mars/server/services/db"; import { generateUsername, getRandomIntInclusive, ServerError } from "@port-of-mars/server/util"; import { SoloGameType } from "@port-of-mars/shared/sologame"; -import { ProlificParticipantStatus } from "@port-of-mars/shared/types"; +import { + ProlificParticipantPointData, + ProlificParticipantStatus, + ProlificStudyData, +} from "@port-of-mars/shared/types"; const logger = settings.logging.getLogger(__filename); @@ -155,9 +159,7 @@ export class StudyService extends BaseService { return this.getParticipantRepository().save(participant); } - async getAllParticipantPoints( - studyId: string - ): Promise> { + async getAllParticipantPoints(studyId: string): Promise> { const study = await this.getProlificStudy(studyId); if (!study) { throw new ServerError({ @@ -248,6 +250,26 @@ export class StudyService extends BaseService { return this.getRepository().findOneBy({ studyId }); } + async getAllProlificStudies(): Promise { + try { + const studies = await this.getRepository().find(); + + return studies.map((study: ProlificStudy) => ({ + description: study.description || "", + studyId: study.studyId, + completionCode: study.completionCode, + isActive: study.isActive, + })); + } catch (error) { + console.error("Error fetching all prolific studies:", error); + throw new ServerError({ + code: 500, + message: "Failed to fetch all prolific studies", + displayMessage: "Unable to retrieve studies at this time.", + }); + } + } + async getProlificCompletionUrl(user: User): Promise { const participant = await this.getParticipantRepository().findOne({ where: { userId: user.id }, @@ -278,14 +300,42 @@ export class StudyService extends BaseService { async createProlificStudy( studyId: string, completionCode: string, - description?: string + description?: string, + isActive = true ): Promise { const repo = this.getRepository(); const study = repo.create({ studyId, completionCode, description, + isActive, }); return repo.save(study); } + + async deleteProlificStudy(studyId: string): Promise { + const study = await this.getRepository().findOneBy({ studyId }); + if (!study) { + throw new Error(`Study with ID ${studyId} not found.`); + } + await this.getRepository().remove(study); + } + + async updateProlificStudy( + studyId: string, + completionCode: string, + description?: string, + isActive = true + ): Promise { + logger.debug(`Updating study ${studyId}`); + const repo = this.getRepository(); + const study = await repo.findOneBy({ studyId }); + if (!study) { + throw new Error(`Study with ID ${studyId} not found.`); + } + study.description = description || study.description; + study.completionCode = completionCode; + study.isActive = isActive; + return repo.save(study); + } } diff --git a/shared/src/types.ts b/shared/src/types.ts index 3bfe85c06..a05848fca 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -533,3 +533,16 @@ export interface ProlificParticipantStatus { label: string; }; } + +export interface ProlificStudyData { + description: string; + studyId: string; + completionCode: string; + isActive: boolean; + participantPoints?: Array; +} + +export interface ProlificParticipantPointData { + prolificId: string; + points: number; +}