diff --git a/apps/admin/src/components/prompts/standard/redirect-prompt.vue b/apps/admin/src/components/prompts/standard/redirect-prompt.vue
index b063423ece..8088fb964c 100644
--- a/apps/admin/src/components/prompts/standard/redirect-prompt.vue
+++ b/apps/admin/src/components/prompts/standard/redirect-prompt.vue
@@ -20,14 +20,16 @@
>
-
+ @change="updateIdentifier"
+ >
@@ -83,7 +85,7 @@ export default defineComponent({
data() {
return {
- identifiers: ['userId', 'username', 'token', 'custom'].map((value) => ({
+ identifiers: ['userId', 'username', 'urlAuthToken'].map((value) => ({
text: this.$t(`survey-schemes.prompts.redirect-prompt.identifier.options.${value}`),
value,
})),
@@ -104,6 +106,10 @@ export default defineComponent({
const timerValue = parseInt(value, 10);
this.update('timer', Number.isNaN(timerValue) ? 0 : timerValue);
},
+
+ updateIdentifier(value: string | null | (typeof this.identifiers)[0]) {
+ this.update('identifier', !value || typeof value === 'string' ? value : value.value);
+ },
},
});
diff --git a/apps/api/src/http/controllers/survey-respondent.controller.ts b/apps/api/src/http/controllers/survey-respondent.controller.ts
index 32c477fee9..c12267277c 100644
--- a/apps/api/src/http/controllers/survey-respondent.controller.ts
+++ b/apps/api/src/http/controllers/survey-respondent.controller.ts
@@ -6,7 +6,6 @@ import type { SurveyState as SurveyStatus } from '@intake24/common/surveys';
import type { SurveyState } from '@intake24/common/types';
import type {
SurveyEntryResponse,
- SurveyFollowUpResponse,
SurveyRequestHelpInput,
SurveyUserInfoResponse,
SurveyUserSessionResponse,
@@ -182,7 +181,7 @@ const surveyRespondentController = ({
const submission = async (
req: Request<{ slug: string }, any, { submission: SurveyState }, { tzOffset: number }>,
- res: Response
+ res: Response
): Promise => {
const {
headers: { 'user-agent': userAgent },
diff --git a/apps/api/src/services/survey/survey-submission.service.ts b/apps/api/src/services/survey/survey-submission.service.ts
index 3f30c0638b..f44fc0d088 100644
--- a/apps/api/src/services/survey/survey-submission.service.ts
+++ b/apps/api/src/services/survey/survey-submission.service.ts
@@ -10,7 +10,7 @@ import type {
SurveyState,
WithKey,
} from '@intake24/common/types';
-import type { SurveyFollowUpResponse } from '@intake24/common/types/http';
+import type { SurveyUserInfoResponse } from '@intake24/common/types/http';
import type {
SurveySubmissionFieldCreationAttributes,
SurveySubmissionFoodCreationAttributes,
@@ -330,14 +330,14 @@ const surveySubmissionService = ({
* @param {User} user
* @param {SurveyState} state
* @param {number} tzOffset
- * @returns {Promise}
+ * @returns {Promise}
*/
const submit = async (
slug: string,
user: User,
state: SurveyState,
tzOffset: number
- ): Promise => {
+ ): Promise => {
const survey = await Survey.findOne({
where: { slug },
include: [{ association: 'surveyScheme', required: true }],
diff --git a/apps/api/src/services/survey/survey.service.ts b/apps/api/src/services/survey/survey.service.ts
index 283697d337..836cfe6b4d 100644
--- a/apps/api/src/services/survey/survey.service.ts
+++ b/apps/api/src/services/survey/survey.service.ts
@@ -1,15 +1,13 @@
+import { URL } from 'node:url';
+
import { addDays, addMinutes, startOfDay } from 'date-fns';
import ms from 'ms';
import type { IoC } from '@intake24/api/ioc';
import type { Prompts } from '@intake24/common/prompts';
import type { JobParams, SurveyState } from '@intake24/common/types';
-import type {
- CreateUserResponse,
- SurveyFollowUpResponse,
- SurveyUserInfoResponse,
-} from '@intake24/common/types/http';
-import type { FindOptions, SubmissionScope } from '@intake24/db';
+import type { CreateUserResponse, SurveyUserInfoResponse } from '@intake24/common/types/http';
+import type { FindOptions, Includeable, SubmissionScope } from '@intake24/db';
import { ApplicationError, ForbiddenError, NotFoundError } from '@intake24/api/http/errors';
import { jwt } from '@intake24/api/util';
import { randomString } from '@intake24/common/util';
@@ -31,8 +29,11 @@ export type RespondentWithPassword = {
const surveyService = ({
adminSurveyService,
+ logger: globalLogger,
scheduler,
-}: Pick) => {
+}: Pick) => {
+ const logger = globalLogger.child({ service: 'SurveyService' });
+
/**
* Generate random survey respondent
*
@@ -89,9 +90,6 @@ const surveyService = ({
if (!decoded || Object.prototype.toString.call(decoded) !== '[object Object]')
throw new ApplicationError('Malformed token payload');
- if (!decoded || typeof decoded === 'string')
- throw new ApplicationError('Malformed token payload');
-
const { user: username, redirect } = decoded;
if (!username || typeof username !== 'string')
throw new ApplicationError('Missing claim: user');
@@ -271,18 +269,19 @@ const surveyService = ({
);
if (!redirectPrompt) return null;
- const user = await User.findByPk(userId, {
- include: [
- { association: 'aliases', where: { surveyId }, required: false },
- { association: 'customFields', where: { name: 'redirect url' }, required: false },
- ],
- });
+ const { identifier, url } = redirectPrompt as Prompts['redirect-prompt'];
+
+ const include: Includeable[] = [
+ { association: 'aliases', where: { surveyId }, required: false },
+ ];
+ if (identifier)
+ include.push({ association: 'customFields', where: { name: identifier }, required: false });
+
+ const user = await User.findByPk(userId, { include });
if (!user) throw new NotFoundError();
const { aliases = [], customFields = [] } = user;
- const { identifier, url } = redirectPrompt as Prompts['redirect-prompt'];
-
let identifierValue: string | null;
switch (identifier) {
@@ -290,29 +289,29 @@ const surveyService = ({
identifierValue = user.id;
break;
case 'username':
- identifierValue = aliases.length ? aliases[0].username : null;
- break;
- case 'token':
- identifierValue = aliases.length ? aliases[0].urlAuthToken : null;
- break;
- case 'custom':
- identifierValue = customFields.length ? customFields[0].value : null;
+ case 'urlAuthToken':
+ identifierValue = aliases.length ? aliases[0][identifier] : null;
break;
default:
- identifierValue = null;
+ identifierValue = customFields.length ? customFields[0].value : null;
break;
}
- if (!identifierValue) return null;
+ if (!identifierValue || !url) return null;
- return url?.replace('{identifier}', identifierValue) ?? null;
+ try {
+ return new URL(url.replace('{identifier}', identifierValue)).href;
+ } catch (err) {
+ logger.error('Invalid follow-up URL', { err });
+ return null;
+ }
};
const followUp = async (
slug: string,
user: User,
tzOffset: number
- ): Promise => {
+ ): Promise => {
const survey = await Survey.findOne({
where: { slug },
include: [{ association: 'surveyScheme', required: true }],
diff --git a/apps/survey/src/components/handlers/standard/FinalPromptHandler.vue b/apps/survey/src/components/handlers/standard/FinalPromptHandler.vue
index a6f65ebe9b..3924b8c6f1 100644
--- a/apps/survey/src/components/handlers/standard/FinalPromptHandler.vue
+++ b/apps/survey/src/components/handlers/standard/FinalPromptHandler.vue
@@ -3,8 +3,7 @@
:is="prompt.component"
:key="prompt.id"
v-bind="{
- canShowFeedback,
- canRestart,
+ showFeedback,
prompt,
surveyId,
}"
@@ -17,13 +16,13 @@ import type { PropType } from 'vue';
import { defineComponent, ref } from 'vue';
import type { Prompts } from '@intake24/common/prompts';
-import { FinalPrompt /*RedirectPrompt*/ } from '@intake24/survey/components/prompts/standard';
+import { FinalPrompt } from '@intake24/survey/components/prompts/standard';
import { useSurvey } from '@intake24/survey/stores';
export default defineComponent({
name: 'FinalPromptHandler',
- components: { FinalPrompt /*, RedirectPrompt */ },
+ components: { FinalPrompt },
props: {
prompt: {
@@ -34,15 +33,16 @@ export default defineComponent({
emits: ['action'],
- setup() {
+ setup(props, { emit }) {
const { user } = useSurvey();
- const canRestart = ref(
- !user?.maximumDailySubmissionsReached && !user?.maximumTotalSubmissionsReached
- );
- const canShowFeedback = ref(user?.showFeedback);
+ const showFeedback = ref(user?.showFeedback);
- return { canRestart, canShowFeedback };
+ const action = (type: string, id?: string) => {
+ emit('action', type, id);
+ };
+
+ return { action, showFeedback };
},
computed: {
@@ -50,12 +50,6 @@ export default defineComponent({
return this.$route.params.surveyId;
},
},
-
- methods: {
- action(type: string, id?: string) {
- this.$emit('action', type, id);
- },
- },
});
diff --git a/apps/survey/src/components/handlers/standard/RedirectPromptHandler.vue b/apps/survey/src/components/handlers/standard/RedirectPromptHandler.vue
new file mode 100644
index 0000000000..e53292f061
--- /dev/null
+++ b/apps/survey/src/components/handlers/standard/RedirectPromptHandler.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
diff --git a/apps/survey/src/components/handlers/standard/index.ts b/apps/survey/src/components/handlers/standard/index.ts
index 9c45dd00d6..959f7bb967 100644
--- a/apps/survey/src/components/handlers/standard/index.ts
+++ b/apps/survey/src/components/handlers/standard/index.ts
@@ -6,6 +6,7 @@ import MealAddPromptHandler from './MealAddPromptHandler.vue';
import MealGapPromptHandler from './MealGapPromptHandler.vue';
import MealTimePromptHandler from './MealTimePromptHandler.vue';
import ReadyMealPromptHandler from './ReadyMealPromptHandler.vue';
+import RedirectPromptHandler from './RedirectPromptHandler.vue';
import ReviewConfirmPromptHandler from './ReviewConfirmPromptHandler.vue';
import SameAsBeforePromptHandler from './SameAsBeforePromptHandler.vue';
import SplitFoodPromptHandler from './SplitFoodPromptHandler.vue';
@@ -20,6 +21,7 @@ export default {
MealGapPromptHandler,
MealTimePromptHandler,
ReadyMealPromptHandler,
+ RedirectPromptHandler,
ReviewConfirmPromptHandler,
SameAsBeforePromptHandler,
SplitFoodPromptHandler,
diff --git a/apps/survey/src/components/prompts/standard/FinalPrompt.vue b/apps/survey/src/components/prompts/standard/FinalPrompt.vue
index 714dd9ee3d..d7efc83504 100644
--- a/apps/survey/src/components/prompts/standard/FinalPrompt.vue
+++ b/apps/survey/src/components/prompts/standard/FinalPrompt.vue
@@ -5,16 +5,18 @@
class="px-4"
color="secondary"
large
+ outlined
:to="{ name: 'survey-home', params: { surveyId } }"
>
$home
{{ $t('common.home') }}
$feedback
@@ -23,13 +25,15 @@
- {{ $t('common.home') }}
- $home
+ {{ $t('common.home') }}
+ $home
-
- {{ $t('recall.actions.nav.feedback') }}
- $feedback
+
+
+ {{ $t('recall.actions.nav.feedback') }}
+
+ $feedback
@@ -46,11 +50,7 @@ export default defineComponent({
mixins: [createBasePrompt<'final-prompt'>()],
props: {
- canRestart: {
- type: Boolean,
- default: false,
- },
- canShowFeedback: {
+ showFeedback: {
type: Boolean,
default: false,
},
diff --git a/apps/survey/src/components/prompts/standard/RedirectPrompt.vue b/apps/survey/src/components/prompts/standard/RedirectPrompt.vue
new file mode 100644
index 0000000000..a46cda39db
--- /dev/null
+++ b/apps/survey/src/components/prompts/standard/RedirectPrompt.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+ {{ timerSecs }}
+
+
+
+ $redirect
+ {{ $t(`prompts.${type}.goTo`) }}
+
+
+
+ {{ $t(`prompts.${type}.missingUrl`) }}
+
+
+
+
+ $home
+ {{ $t('common.home') }}
+
+
+ $feedback
+ {{ $t('recall.actions.feedback') }}
+
+
+
+
+
+ {{ $t('common.home') }}
+
+ $home
+
+
+
+
+ {{ $t('recall.actions.nav.feedback') }}
+
+ $feedback
+
+
+
+
+ {{ $t('recall.actions.nav.redirect') }}
+
+ $redirect
+
+
+
+
+
+
+
+
diff --git a/apps/survey/src/components/prompts/standard/index.ts b/apps/survey/src/components/prompts/standard/index.ts
index 45723dd607..81615a152d 100644
--- a/apps/survey/src/components/prompts/standard/index.ts
+++ b/apps/survey/src/components/prompts/standard/index.ts
@@ -8,6 +8,7 @@ export { default as MealGapPrompt } from './MealGapPrompt.vue';
export { default as MealTimePrompt } from './MealTimePrompt.vue';
export * from './ReadyMealPrompt.vue';
export { default as ReadyMealPrompt } from './ReadyMealPrompt.vue';
+export { default as RedirectPrompt } from './RedirectPrompt.vue';
export { default as ReviewConfirmPrompt } from './ReviewConfirmPrompt.vue';
export { default as SameAsBeforePrompt } from './SameAsBeforePrompt.vue';
export { default as SplitFoodPrompt } from './SplitFoodPrompt.vue';
diff --git a/apps/survey/src/plugins/vuetify.ts b/apps/survey/src/plugins/vuetify.ts
index 2b977f41fb..3d62b3d2a8 100644
--- a/apps/survey/src/plugins/vuetify.ts
+++ b/apps/survey/src/plugins/vuetify.ts
@@ -36,6 +36,7 @@ export default new Vuetify({
pause: 'fas fa-pause',
profile: 'fas fa-user-circle',
question: 'far fa-question-circle',
+ redirect: 'fas fa-up-right-from-square',
save: 'fas fa-save',
search: 'fas fa-magnifying-glass',
show: 'far fa-file',
diff --git a/apps/survey/src/services/survey.service.ts b/apps/survey/src/services/survey.service.ts
index 740f160a51..96123ec8ab 100644
--- a/apps/survey/src/services/survey.service.ts
+++ b/apps/survey/src/services/survey.service.ts
@@ -4,7 +4,6 @@ import type {
GenerateUserResponse,
PublicSurveyEntry,
SurveyEntryResponse,
- SurveyFollowUpResponse,
SurveyRequestHelpInput,
SurveyUserInfoResponse,
SurveyUserSessionResponse,
@@ -88,10 +87,10 @@ export default {
clearUserSession: async (surveyId: string): Promise =>
http.delete(`surveys/${surveyId}/session`),
- submit: async (surveyId: string, submission: SurveyState): Promise => {
+ submit: async (surveyId: string, submission: SurveyState): Promise => {
const tzOffset = new Date().getTimezoneOffset();
- const { data } = await http.post(
+ const { data } = await http.post(
`surveys/${surveyId}/submission`,
{ submission },
{ params: { tzOffset } }
diff --git a/apps/survey/src/stores/survey.ts b/apps/survey/src/stores/survey.ts
index c4d845535d..39c155c166 100644
--- a/apps/survey/src/stores/survey.ts
+++ b/apps/survey/src/stores/survey.ts
@@ -19,11 +19,7 @@ import type {
SurveyFlag,
SurveyState as CurrentSurveyState,
} from '@intake24/common/types';
-import type {
- SurveyEntryResponse,
- SurveyFollowUpResponse,
- SurveyUserInfoResponse,
-} from '@intake24/common/types/http';
+import type { SurveyEntryResponse, SurveyUserInfoResponse } from '@intake24/common/types/http';
import { recallLog } from '@intake24/survey/stores';
import {
findFood,
@@ -59,7 +55,7 @@ export type FoodUndo = {
export interface SurveyState {
parameters: SurveyEntryResponse | null;
- user: SurveyUserInfoResponse | SurveyFollowUpResponse | null;
+ user: SurveyUserInfoResponse | null;
data: CurrentSurveyState;
isSubmitting: boolean;
undo: MealUndo | FoodUndo | null;
diff --git a/docs/admin/surveys/question-types.md b/docs/admin/surveys/question-types.md
index e3981f8ad4..41cb7ad025 100644
--- a/docs/admin/surveys/question-types.md
+++ b/docs/admin/surveys/question-types.md
@@ -61,8 +61,8 @@ Allows user to be redirected to external URL with user identifier embedded into
- `userId` - internal intake24 user id
- `username` - survey-unique respondent username
- - `token` - authentication token
- - `custom` - custom identifier that can be set through `userCustomField`, name should be `redirect url`
+ - `urlAuthToken` - URL authentication token
+ - `custom` - custom identifier that can be set through `userCustomField`. Set `name` of the custom field to be looked up the `value`.
- `timer` - optional timer in seconds when automatic redirect should happen
@@ -164,6 +164,8 @@ Multi-select list of options.
Prompt to collect date information.
+- `futureDates` - allow future dates to be selected
+
### Info prompt
Informational prompt for acknowledging displayed information.
diff --git a/packages/common/src/prompts/prompts.ts b/packages/common/src/prompts/prompts.ts
index 948de7694b..28b0c2e07d 100644
--- a/packages/common/src/prompts/prompts.ts
+++ b/packages/common/src/prompts/prompts.ts
@@ -187,7 +187,7 @@ export type Prompts = {
'redirect-prompt': BasePrompt & {
component: 'redirect-prompt';
url: string | null;
- identifier: 'userId' | 'username' | 'token' | 'custom';
+ identifier: 'userId' | 'username' | 'urlAuthToken' | string | null;
timer: number;
};
'review-confirm-prompt': BasePrompt & { component: 'review-confirm-prompt' };
diff --git a/packages/common/src/types/http/surveys.ts b/packages/common/src/types/http/surveys.ts
index a7f7e14d2d..51761a22e4 100644
--- a/packages/common/src/types/http/surveys.ts
+++ b/packages/common/src/types/http/surveys.ts
@@ -58,14 +58,11 @@ export type SurveyUserInfoResponse = {
showFeedback: boolean;
maximumTotalSubmissionsReached: boolean;
maximumDailySubmissionsReached: boolean;
+ followUpUrl?: string | null;
};
export type SurveyUserSessionResponse = UserSurveySessionAttributes;
-export interface SurveyFollowUpResponse extends SurveyUserInfoResponse {
- followUpUrl: string | null;
-}
-
export type SurveyRequestHelpInput = {
name: string;
email: string;
diff --git a/packages/i18n/src/admin/en/survey-schemes.ts b/packages/i18n/src/admin/en/survey-schemes.ts
index 52a1bed40e..769d0e10b1 100644
--- a/packages/i18n/src/admin/en/survey-schemes.ts
+++ b/packages/i18n/src/admin/en/survey-schemes.ts
@@ -318,11 +318,11 @@ const surveySchemes: LocaleMessageObject = {
identifier: {
_: 'User identifier to embed into the URL',
subtitle: 'Specify which identifier to embed into the redirect URL',
+ hint: `Custom value will be looked up in 'user custom fields'`,
options: {
userId: 'User ID',
username: 'Username',
- token: 'Authentication token',
- custom: 'Custom field',
+ urlAuthToken: 'Authentication token',
},
},
timer: {
diff --git a/packages/i18n/src/shared/en/prompts.ts b/packages/i18n/src/shared/en/prompts.ts
index 9748e1594c..319fe662f6 100644
--- a/packages/i18n/src/shared/en/prompts.ts
+++ b/packages/i18n/src/shared/en/prompts.ts
@@ -142,6 +142,8 @@ const prompts: LocaleMessageObject = {
},
redirect: {
name: 'Redirect',
+ missingUrl: 'Missing redirection URL',
+ goTo: 'Go to the questionnaire',
},
reviewConfirm: {
name: 'Review and Confirm',
diff --git a/packages/i18n/src/survey/en/recall.ts b/packages/i18n/src/survey/en/recall.ts
index d9c80ecd0c..4aeb4d2c55 100644
--- a/packages/i18n/src/survey/en/recall.ts
+++ b/packages/i18n/src/survey/en/recall.ts
@@ -59,7 +59,7 @@ const recall: LocaleMessageObject = {
changeTime: 'Change time',
},
actions: {
- feedback: 'Go to feedback',
+ feedback: 'Feedback',
next: 'Continue',
submit: 'Submit the recall',
addMeal: 'Add meal',
@@ -73,6 +73,7 @@ const recall: LocaleMessageObject = {
confirm: 'Confirm',
feedback: 'Feedback',
next: 'Continue',
+ redirect: 'Continue',
remove: 'Remove',
review: 'Review',
submit: 'Submit',