Skip to content

Commit

Permalink
feat: add access to applet for prolific respondent (#572)
Browse files Browse the repository at this point in the history
* feat: add access to prolific respondent

Few changes in this commit:
- Looks for prolific URL parameters on the public link
- Grant access to the applet if the prolific integration exists
- Grant access to activity if the participant hasn't submitted answers for the study yet. Blocks them otherwise
- New prolific account is created on submission
- User redirected to prolific on submission acceptance.
  • Loading branch information
hamzace authored Feb 27, 2025
1 parent 6dd436a commit 14f55b3
Show file tree
Hide file tree
Showing 26 changed files with 359 additions and 20 deletions.
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,4 @@ export default [
},
},
eslintConfigPrettier,
];
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ProlificService, QueryOptions, ReturnAwaited, useBaseQuery } from '~/shared/api';

type FetchFn = typeof ProlificService.getStudyCompletionCodes;
type Options<TData> = QueryOptions<FetchFn, TData>;

type Params = {
appletId: string;
studyId: string;
};

export const useProlificCompletionCodeQuery = <TData = ReturnAwaited<FetchFn>>(
params: Params,
options?: Options<TData>,
) => {
return useBaseQuery(
['prolificCompletionCode', params],
() => ProlificService.getStudyCompletionCodes(params.appletId, params.studyId),
options,
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ProlificService, QueryOptions, ReturnAwaited, useBaseQuery } from '~/shared/api';

type FetchFn = typeof ProlificService.isProlificStudyValidated;
type Options<TData> = QueryOptions<FetchFn, TData>;

type PublicParams = {
isPublic: true;
publicAppletKey: string;
};

type PrivateParams = {
isPublic: false;
appletId: string;
};

export const useProlificStudyStateQuery = <TData = ReturnAwaited<FetchFn>>(
params: (PublicParams | PrivateParams) & { prolificStudyId: null | string },
options?: Options<TData>,
) => {
return useBaseQuery(
['prolificStudyValidated', params],
() => {
if (params.isPublic) {
return ProlificService.isProlificStudyValidated(
params.publicAppletKey,
params.prolificStudyId ?? undefined,
);
}
return ProlificService.isProlificStudyValidated(params.appletId);
},
options,
);
};
20 changes: 20 additions & 0 deletions src/entities/applet/api/integrations/useProlificUserExistsQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ProlificService, QueryOptions, ReturnAwaited, useBaseQuery } from '~/shared/api';

type FetchFn = typeof ProlificService.prolificUserExists;
type Options<TData> = QueryOptions<FetchFn, TData>;

type Params = {
prolificPid: string;
studyId: string;
};

export const useProlificUserExistsQuery = <TData = ReturnAwaited<FetchFn>>(
params: Params,
options?: Options<TData>,
) => {
return useBaseQuery(
['prolificUserExists', params],
() => ProlificService.prolificUserExists(params.prolificPid, params.studyId),
options,
);
};
53 changes: 52 additions & 1 deletion src/entities/applet/model/hooks/useEntityComplete.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { useCallback } from 'react';

import { t } from 'i18next';
import type { NavigateOptions } from 'react-router/dist/lib/context';

import { useUpdateProlificParams } from './useSaveProlificParams';

import { ActivityPipelineType } from '~/abstract/lib';
import { appletModel } from '~/entities/applet';
import { useProlificCompletionCodeQuery } from '~/entities/applet/api/integrations/useProlificCompletionCodeQuery';
import { prolificParamsSelector } from '~/entities/applet/model/selectors';
import { useBanners } from '~/entities/banner/model';
import { ActivityFlowDTO } from '~/shared/api';
import ROUTES from '~/shared/constants/routes';
import { useCustomNavigation } from '~/shared/utils';
import { useAppSelector, useCustomNavigation } from '~/shared/utils';

type CompletionType = 'regular' | 'autoCompletion';

Expand Down Expand Up @@ -36,6 +42,22 @@ export const useEntityComplete = (props: Props) => {
const { entityCompleted, flowUpdated, getGroupProgress } =
appletModel.hooks.useGroupProgressStateManager();

const prolificParams = useAppSelector(prolificParamsSelector);
const { data: completionCodesReponse, isError: isCompletionCodesReponseError } =
useProlificCompletionCodeQuery(
{
appletId: props.appletId,
studyId: prolificParams?.studyId ?? '',
},
{
enabled: !!prolificParams,
},
);

const { addErrorBanner } = useBanners();

const { clearProlificParams } = useUpdateProlificParams();

const completeEntityAndRedirect = useCallback(
(completionType: CompletionType) => {
entityCompleted({
Expand All @@ -50,6 +72,30 @@ export const useEntityComplete = (props: Props) => {
return;
}

if (prolificParams && props.publicAppletKey) {
if (!isCompletionCodesReponseError && completionCodesReponse) {
clearProlificParams(); // Resetting redux state after completion

const { completionCodes } = completionCodesReponse.data;
for (const code of completionCodes) {
if (code.codeType === 'COMPLETED') {
window.location.replace(
`https://app.prolific.com/submissions/complete?cc=${code.code}`,
);
return;
}
}

addErrorBanner({ children: t('prolific.nocode'), duration: null });
return navigator.navigate(
ROUTES.publicJoin.navigateTo(props.publicAppletKey, prolificParams),
{
replace: true,
},
);
}
}

if (props.publicAppletKey) {
return navigator.navigate(ROUTES.publicJoin.navigateTo(props.publicAppletKey), {
replace: true,
Expand All @@ -70,6 +116,11 @@ export const useEntityComplete = (props: Props) => {
entityCompleted,
isInMultiInformantFlow,
navigator,
prolificParams,
completionCodesReponse,
isCompletionCodesReponseError,
clearProlificParams,
addErrorBanner,
props.activityId,
props.appletId,
props.eventId,
Expand Down
23 changes: 23 additions & 0 deletions src/entities/applet/model/hooks/useSaveProlificParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useCallback } from 'react';

import { actions } from '../slice';
import type { ProlificUrlParamsPayload } from '../types';

import { useAppDispatch } from '~/shared/utils';

export const useUpdateProlificParams = () => {
const dispatch = useAppDispatch();

const saveProlificParams = useCallback(
(payload: ProlificUrlParamsPayload) => {
dispatch(actions.saveProlificParams(payload));
},
[dispatch],
);

const clearProlificParams = useCallback(() => {
dispatch(actions.clearProlificParams());
}, [dispatch]);

return { saveProlificParams, clearProlificParams };
};
5 changes: 5 additions & 0 deletions src/entities/applet/model/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ export const multiInformantStateSelector = createSelector(
(state) => state.multiInformantState,
);

export const prolificParamsSelector = createSelector(
appletsSelector,
(state) => state.prolificParams,
);

export const selectConsents = createSelector(appletsSelector, (applets) => applets.consents);
10 changes: 10 additions & 0 deletions src/entities/applet/model/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
UpdateUserEventByIndexPayload,
RemoveGroupProgressPayload,
SaveSummaryDataInContext,
ProlificUrlParamsPayload,
} from './types';

import {
Expand All @@ -41,6 +42,7 @@ type InitialState = {
completions: CompletedEventEntities;
multiInformantState: MultiInformantState;
consents: ActivityConsents;
prolificParams?: ProlificUrlParamsPayload;
};

const initialState: InitialState = {
Expand Down Expand Up @@ -371,6 +373,14 @@ const appletsSlice = createSlice({

consents.shareMediaToPublic = !currentValue;
},

saveProlificParams: (state, action: PayloadAction<ProlificUrlParamsPayload>) => {
state.prolificParams = action.payload;
},

clearProlificParams: (state) => {
state.prolificParams = undefined;
},
},
});

Expand Down
6 changes: 6 additions & 0 deletions src/entities/applet/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ export type UpdateUserEventByIndexPayload = {
userEvent: UserEvent;
};

export type ProlificUrlParamsPayload = {
prolificPid: string;
studyId: string;
sessionId: string;
};

export type SupportableActivities = Record<string, boolean>;

export type CompletedEntitiesState = Record<string, number>;
Expand Down
4 changes: 4 additions & 0 deletions src/features/PassSurvey/hooks/useAnswers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SurveyContext } from '../lib';
import AnswersConstructService from '../model/AnswersConstructService';

import { appletModel } from '~/entities/applet';
import { ProlificUrlParamsPayload as ProlificUrlParams } from '~/entities/applet/model';
import { ActivityFlowDTO, AnswerPayload, EncryptionDTO, ScheduleEventDto } from '~/shared/api';

export type BuildAnswerParams = {
Expand All @@ -19,6 +20,8 @@ export type BuildAnswerParams = {
publicAppletKey?: string | null;
encryption?: EncryptionDTO | null;
flow?: ActivityFlowDTO | null;

prolificParams?: ProlificUrlParams;
};

export interface AnswerBuilder {
Expand Down Expand Up @@ -61,6 +64,7 @@ export const useAnswerBuilder = (): AnswerBuilder => {
encryption,
publicAppletKey: params.publicAppletKey ?? context.publicAppletKey,
isFlowCompleted: params.isFlowCompleted,
prolificParams: params.prolificParams,
});

const answer = answerConstructService.construct();
Expand Down
13 changes: 11 additions & 2 deletions src/features/PassSurvey/hooks/useSubmitAnswersMutation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useContext } from 'react';

import { AxiosResponse } from 'axios';
import { AxiosError, AxiosResponse } from 'axios';

import { SurveyContext } from '..';

Expand All @@ -20,9 +20,10 @@ import {
type Props = {
isPublic: boolean;
onSubmitSuccess?: (variables: AnswerPayload) => void;
onSubmitError?: () => void;
};

export const useSubmitAnswersMutations = ({ isPublic, onSubmitSuccess }: Props) => {
export const useSubmitAnswersMutations = ({ isPublic, onSubmitSuccess, onSubmitError }: Props) => {
const { isInMultiInformantFlow, getMultiInformantState, updateMultiInformantState } =
appletModel.hooks.useMultiInformantState();

Expand Down Expand Up @@ -71,6 +72,14 @@ export const useSubmitAnswersMutations = ({ isPublic, onSubmitSuccess }: Props)
mutateAsync: publicSubmitAsync,
} = usePublicSaveAnswerMutation({
onSuccess,
onError: (error) => {
if (error instanceof AxiosError && error.response.status === 400) {
onSubmitError?.();
return;
} else if (error instanceof Error) {
throw error;
}
},
});

return {
Expand Down
7 changes: 7 additions & 0 deletions src/features/PassSurvey/model/AnswersConstructService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ItemAnswer, mapAlerts, mapToAnswers } from '../helpers';

import { ActivityPipelineType, GroupProgress } from '~/abstract/lib';
import { appletModel } from '~/entities/applet';
import { ProlificUrlParamsPayload } from '~/entities/applet/model';
import { userModel } from '~/entities/user';
import {
ActivityFlowDTO,
Expand Down Expand Up @@ -47,6 +48,8 @@ type Input = {
publicAppletKey: string | null;

isFlowCompleted?: boolean;

prolificParams?: ProlificUrlParamsPayload;
};

export default class AnswersConstructService implements ICompletionConstructService {
Expand All @@ -72,6 +75,8 @@ export default class AnswersConstructService implements ICompletionConstructServ

private isFlowCompleted: boolean | undefined; // We can enforce this to be defined if needed

private prolificParams?: ProlificUrlParamsPayload;

constructor(input: Input) {
this.activityId = input.activityId;
this.event = input.event;
Expand All @@ -84,6 +89,7 @@ export default class AnswersConstructService implements ICompletionConstructServ
this.items = input.items;
this.userEvents = input.userEvents;
this.isFlowCompleted = input.isFlowCompleted;
this.prolificParams = input.prolificParams;
}

public construct(): AnswerPayload {
Expand Down Expand Up @@ -115,6 +121,7 @@ export default class AnswersConstructService implements ICompletionConstructServ
version: this.appletVersion,
createdAt: new Date().getTime(),
isFlowCompleted: isSurveyCompleted,
prolificParams: this.prolificParams,
answer: {
answer: encryptedAnswers,
itemIds: answersDictionary.itemIds,
Expand Down
5 changes: 4 additions & 1 deletion src/features/SaveAssessmentAndExit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import ButtonBase from '@mui/material/ButtonBase';

import { SurveyContext } from '../PassSurvey';

import { prolificParamsSelector } from '~/entities/applet/model/selectors';
import { ROUTES, Theme } from '~/shared/constants';
import Text from '~/shared/ui/Text';
import {
addSurveyPropsToEvent,
Mixpanel,
MixpanelEventType,
useAppSelector,
useCustomNavigation,
useCustomTranslation,
} from '~/shared/utils';
Expand All @@ -24,6 +26,7 @@ export const SaveAndExitButton = ({ appletId, publicAppletKey }: Props) => {
const { applet, activityId, flow } = useContext(SurveyContext);

const navigator = useCustomNavigation();
const prolificParams = useAppSelector(prolificParamsSelector);

const onSaveAndExitClick = () => {
Mixpanel.track(
Expand All @@ -35,7 +38,7 @@ export const SaveAndExitButton = ({ appletId, publicAppletKey }: Props) => {

return navigator.navigate(
publicAppletKey
? ROUTES.publicJoin.navigateTo(publicAppletKey)
? ROUTES.publicJoin.navigateTo(publicAppletKey, prolificParams)
: ROUTES.appletDetails.navigateTo(appletId),
);
};
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/el/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,10 @@
"timeRemaining": "Απομένει(-ουν) {{time}}",
"forItem": "για το στοιχείο αυτό",
"noApplets": "Δεν υπάρχουν βοηθητικές εφαρμογές",
"noActivities": "Δεν υπάρχουν διαθέσιμες δραστηριότητες για να ολοκληρώσετε αυτήν τη στιγμή"
"noActivities": "Δεν υπάρχουν διαθέσιμες δραστηριότητες για να ολοκληρώσετε αυτήν τη στιγμή",
"prolific": {
"nocode": "Παρουσιάστηκε σφάλμα κατά την ανάκτηση του κωδικού ολοκλήρωσης. Παρακαλώ χρησιμοποιήστε την πολιτική NOCODE στο Prolific.",
"alreadyAnswered": "Έχετε ήδη απαντήσει σε αυτήν τη δραστηριότητα. Παρακαλώ επιστρέψτε στο Prolific και υποβάλετε τον κωδικό ολοκλήρωσής σας."
}
}
}
Loading

0 comments on commit 14f55b3

Please sign in to comment.