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 @@ @@ -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 @@ + + + + + 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',