diff --git a/frontend/src/components/participant_view/cohort_landing.scss b/frontend/src/components/participant_view/cohort_landing.scss index 9787005e8..6ecb5a236 100644 --- a/frontend/src/components/participant_view/cohort_landing.scss +++ b/frontend/src/components/participant_view/cohort_landing.scss @@ -1,6 +1,6 @@ -@use "../../sass/colors"; -@use "../../sass/common"; -@use "../../sass/typescale"; +@use '../../sass/colors'; +@use '../../sass/common'; +@use '../../sass/typescale'; :host { @include common.flex-column; @@ -31,4 +31,11 @@ h1 { .action-buttons { @include common.flex-row; justify-content: center; -} \ No newline at end of file +} + +.resume-dialog-buttons { + display: flex; + gap: 12px; + justify-content: center; + margin-top: 12px; +} diff --git a/frontend/src/components/participant_view/cohort_landing.ts b/frontend/src/components/participant_view/cohort_landing.ts index c3c3f0e62..16d91008d 100644 --- a/frontend/src/components/participant_view/cohort_landing.ts +++ b/frontend/src/components/participant_view/cohort_landing.ts @@ -9,11 +9,9 @@ import {AnalyticsService, ButtonClick} from '../../services/analytics.service'; import {ExperimentService} from '../../services/experiment.service'; import {FirebaseService} from '../../services/firebase.service'; import {Pages, RouterService} from '../../services/router.service'; +import {ExperimentManager} from '../../services/experiment.manager'; -import {StageKind} from '@deliberation-lab/utils'; - -import {createParticipantCallable} from '../../shared/callables'; -import {requiresAnonymousProfiles} from '../../shared/participant.utils'; +import {bootParticipantCallable} from '../../shared/callables'; import {styles} from './cohort_landing.scss'; @@ -28,6 +26,8 @@ export class CohortLanding extends MobxLitElement { private readonly routerService = core.getService(RouterService); @state() isLoading = false; + @state() showResumeDialog = false; + @state() resumeParticipantId: string = ''; override render() { const isLockedCohort = () => { @@ -56,12 +56,30 @@ export class CohortLanding extends MobxLitElement {
Join experiment
+ ${this.showResumeDialog + ? html`
+
+ A previous session was found for your Prolific ID.
+ Would you like to resume your previous session or start over? +
+
+ Resume + Start Over +
+
` + : nothing} `; } @@ -71,14 +89,11 @@ export class CohortLanding extends MobxLitElement { this.analyticsService.trackButtonClick(ButtonClick.PARTICIPANT_JOIN); const params = this.routerService.activeRoute.params; - const isAnonymous = requiresAnonymousProfiles( - this.experimentService.stages, - ); const prolificIdMatch = window.location.href.match( /[?&]PROLIFIC_PID=([^&]+)/, ); - const prolificId = prolificIdMatch ? prolificIdMatch[1] : null; + const prolificId = prolificIdMatch ? prolificIdMatch[1] : undefined; if ( this.experimentService.experiment!.prolificConfig! .enableProlificIntegration && @@ -89,22 +104,63 @@ export class CohortLanding extends MobxLitElement { ); } - const response = await createParticipantCallable( - this.firebaseService.functions, - { - experimentId: params['experiment'], - cohortId: params['cohort'], - isAnonymous, - prolificId, - }, - ); + // Use ExperimentManager's createParticipant + const response = await core + .getService(ExperimentManager) + .createParticipant(params['cohort'], prolificId); + + if (response.exists && response.participant) { + // Existing participant found, show dialog + this.resumeParticipantId = response.participant.privateId || ''; + this.showResumeDialog = true; + this.isLoading = false; + return; + } + + // New participant created + this.routerService.navigate(Pages.PARTICIPANT, { + experiment: params['experiment'], + participant: response.id!, + }); + this.isLoading = false; + } - // Route to participant page + private async handleResume() { + // Resume with existing participant + const params = this.routerService.activeRoute.params; + this.routerService.navigate(Pages.PARTICIPANT, { + experiment: params['experiment'], + participant: this.resumeParticipantId!, + }); + this.showResumeDialog = false; + } + + private async handleStartOver() { + // Boot old participant, then create new one + this.isLoading = true; + const params = this.routerService.activeRoute.params; + await bootParticipantCallable(this.firebaseService.functions, { + experimentId: params['experiment'], + participantId: this.resumeParticipantId!, + }); + // Create new participant (forceNew) + const prolificIdMatch = window.location.href.match( + /[?&]PROLIFIC_PID=([^&]+)/, + ); + const prolificId = prolificIdMatch ? prolificIdMatch[1] : undefined; + const response = await core.getService(ExperimentManager).createParticipant( + params['cohort'], + prolificId, + true, // forceNew + ); this.routerService.navigate(Pages.PARTICIPANT, { experiment: params['experiment'], - participant: response.id, + participant: response.participant + ? response.participant.privateId! + : response.id!, }); this.isLoading = false; + this.showResumeDialog = false; } } diff --git a/frontend/src/services/experiment.manager.ts b/frontend/src/services/experiment.manager.ts index 34afa6afe..554856dd1 100644 --- a/frontend/src/services/experiment.manager.ts +++ b/frontend/src/services/experiment.manager.ts @@ -1,11 +1,8 @@ import {computed, makeObservable, observable} from 'mobx'; import { collection, - doc, - getDocs, onSnapshot, query, - Timestamp, Unsubscribe, where, } from 'firebase/firestore'; @@ -22,7 +19,6 @@ import {Service} from './service'; import JSZip from 'jszip'; import { - DEFAULT_AGENT_MODEL_SETTINGS, AlertMessage, AlertStatus, AgentPersonaConfig, @@ -32,8 +28,6 @@ import { CohortConfig, CohortParticipantConfig, CreateChatMessageData, - Experiment, - ExperimentDownload, MediatorProfile, MetadataConfig, ParticipantProfileExtended, @@ -50,6 +44,7 @@ import { createChatMessageCallable, createCohortCallable, createParticipantCallable, + checkOrCreateParticipantCallable, deleteCohortCallable, deleteExperimentCallable, initiateParticipantTransferCallable, @@ -65,8 +60,6 @@ import { hasMaxParticipantsInCohort, } from '../shared/cohort.utils'; import { - downloadCSV, - downloadJSON, getChatHistoryData, getChipNegotiationCSV, getChipNegotiationData, @@ -703,21 +696,35 @@ export class ExperimentManager extends Service { } /** Create human participant. */ - async createParticipant(cohortId: string) { + async createParticipant( + cohortId: string, + prolificId?: string, + forceNew?: boolean, + ): Promise<{ + exists?: boolean; + participant?: ParticipantProfileExtended; + id?: string; + }> { this.isWritingParticipant = true; - let response = {}; + let response: { + exists?: boolean; + participant?: ParticipantProfileExtended; + id?: string; + } = {}; if (this.experimentId) { const isAnonymous = requiresAnonymousProfiles( this.sp.experimentService.stages, ); - response = await createParticipantCallable( + response = await checkOrCreateParticipantCallable( this.sp.firebaseService.functions, { experimentId: this.experimentId, cohortId, isAnonymous, + prolificId: prolificId || undefined, + forceNew: forceNew || false, }, ); } diff --git a/frontend/src/shared/callables.ts b/frontend/src/shared/callables.ts index 44c175bf0..fadcfec74 100644 --- a/frontend/src/shared/callables.ts +++ b/frontend/src/shared/callables.ts @@ -11,10 +11,8 @@ import { ExperimentCohortLockData, ExperimentCreationData, ExperimentDeletionData, - ExperimentDownloadResponse, InitiateParticipantTransferData, ParticipantNextStageResponse, - ParticipantProfile, SendAlertMessageData, SendChipOfferData, SendChipResponseData, @@ -137,6 +135,18 @@ export const createParticipantCallable = async ( return data; }; +/** Generic endpoint to check or create participant. */ +export const checkOrCreateParticipantCallable = async ( + functions: Functions, + config: CreateParticipantData, +) => { + const {data} = await httpsCallable( + functions, + 'checkOrCreateParticipant', + )(config); + return data; +}; + /** Generic endpoint to update participant's TOS response */ export const updateParticipantAcceptedTOSCallable = async ( functions: Functions, diff --git a/functions/src/participant.endpoints.ts b/functions/src/participant.endpoints.ts index 85b56c54d..52b9f3957 100644 --- a/functions/src/participant.endpoints.ts +++ b/functions/src/participant.endpoints.ts @@ -19,7 +19,6 @@ import { import * as functions from 'firebase-functions'; import {onCall} from 'firebase-functions/v2/https'; - import {app} from './app'; import {AuthGuard} from './utils/auth-guard'; import { @@ -47,66 +46,7 @@ export const createParticipant = onCall(async (request) => { handleCreateParticipantValidationErrors(data); } - // Create initial participant config - const participantConfig = createParticipantProfileExtended({ - currentCohortId: data.cohortId, - prolificId: data.prolificId, - }); - - // Temporarily always mark participants as connected (PR #537) - participantConfig.connected = true; // TODO: Remove this line - - // If agent config is specified, add to participant config - if (data.agentConfig) { - participantConfig.agentConfig = data.agentConfig; - participantConfig.connected = true; // agent is always connected - } - - // Define document reference - const document = app - .firestore() - .collection('experiments') - .doc(data.experimentId) - .collection('participants') - .doc(participantConfig.privateId); - - // Set random timeout to avoid data contention with transaction - await new Promise((resolve) => { - setTimeout(resolve, Math.random() * 2000); - }); - - // Run document write as transaction to ensure consistency - await app.firestore().runTransaction(async (transaction) => { - // TODO: Confirm that cohort is not at max capacity - - // Confirm that cohort is not locked - const experiment = ( - await app.firestore().doc(`experiments/${data.experimentId}`).get() - ).data() as Experiment; - if (experiment.cohortLockMap[data.cohortId]) { - // TODO: Return failure and handle accordingly on frontend - return; - } - - // Set participant profile fields - const numParticipants = ( - await app - .firestore() - .collection(`experiments/${data.experimentId}/participants`) - .count() - .get() - ).data().count; - - setProfile(numParticipants, participantConfig, data.isAnonymous); - - // Set current stage ID in participant config - participantConfig.currentStageId = experiment.stageIds[0]; - - // Write new participant document - transaction.set(document, participantConfig); - }); - - return {id: document.id}; + return await createParticipantInternal(data); }); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -619,3 +559,111 @@ export const initiateParticipantTransfer = onCall(async (request) => { return {success: true}; }); + +// ************************************************************************* // +// checkOrCreateParticipant endpoint // +// Checks for existing participant with prolificId, or creates new one // +// ************************************************************************* // +export const checkOrCreateParticipant = onCall(async (request) => { + const participantData: CreateParticipantData = request.data; + // Validate input + const validInput = Value.Check(CreateParticipantData, participantData); + if (!validInput) { + handleCreateParticipantValidationErrors(participantData); + } + + // If prolificId is provided, check for existing participant + if (participantData.prolificId) { + const querySnap = await app + .firestore() + .collection('experiments') + .doc(participantData.experimentId) + .collection('participants') + .where('prolificId', '==', participantData.prolificId) + .get(); + if (!querySnap.empty && !participantData.forceNew) { + // Find the first participant in a valid active state + const validStates = [ + ParticipantStatus.IN_PROGRESS, + ParticipantStatus.ATTENTION_CHECK, + ParticipantStatus.TRANSFER_PENDING, + ]; + const validDoc = querySnap.docs.find((doc) => + validStates.includes(doc.data().currentStatus), + ); + if (validDoc) { + const existing = validDoc.data(); + return {exists: true, participant: existing}; + } + // If none are valid, fall through to create new + } + } + + // No existing participant, create as normal + const result = await createParticipantInternal(participantData); + return {exists: false, id: result.id}; +}); + +async function createParticipantInternal(data: CreateParticipantData) { + // Create initial participant config + const participantConfig = createParticipantProfileExtended({ + currentCohortId: data.cohortId, + prolificId: data.prolificId, + }); + + // Temporarily always mark participants as connected (PR #537) + participantConfig.connected = true; // TODO: Remove this line + + // If agent config is specified, add to participant config + if (data.agentConfig) { + participantConfig.agentConfig = + data.agentConfig as ParticipantProfileExtended['agentConfig']; + participantConfig.connected = true; // agent is always connected + } + + // Define document reference + const document = app + .firestore() + .collection('experiments') + .doc(data.experimentId) + .collection('participants') + .doc(participantConfig.privateId); + + // Set random timeout to avoid data contention with transaction + await new Promise((resolve) => { + setTimeout(resolve, Math.random() * 2000); + }); + + // Run document write as transaction to ensure consistency + await app.firestore().runTransaction(async (transaction) => { + // TODO: Confirm that cohort is not at max capacity + + // Confirm that cohort is not locked + const experiment = ( + await app.firestore().doc(`experiments/${data.experimentId}`).get() + ).data() as Experiment; + if (experiment.cohortLockMap[data.cohortId]) { + // TODO: Return failure and handle accordingly on frontend + return; + } + + // Set participant profile fields + const numParticipants = ( + await app + .firestore() + .collection(`experiments/${data.experimentId}/participants`) + .count() + .get() + ).data().count; + + setProfile(numParticipants, participantConfig, data.isAnonymous); + + // Set current stage ID in participant config + participantConfig.currentStageId = experiment.stageIds[0]; + + // Write new participant document + transaction.set(document, participantConfig); + }); + + return {id: document.id}; +} diff --git a/utils/src/participant.validation.ts b/utils/src/participant.validation.ts index 4f8c050db..a9b331ce7 100644 --- a/utils/src/participant.validation.ts +++ b/utils/src/participant.validation.ts @@ -155,6 +155,7 @@ export const CreateParticipantData = Type.Object( }), ), prolificId: Type.Optional(Type.Union([Type.Null(), Type.String()])), + forceNew: Type.Optional(Type.Boolean()), }, strict, );