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 {
+ ${this.showResumeDialog
+ ? html`
+
+ A previous session was found for your Prolific ID.
+ Would you like to resume your previous session or 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,
);