From 030be50668b0d5317271a7f21e333b71495c9084 Mon Sep 17 00:00:00 2001 From: kenzaelk98 <254484110+kenzaelk98@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:45:38 +0200 Subject: [PATCH 1/4] feat: add in-app questionnaire popup and management endpoints --- api/server/index.js | 2 + api/server/routes/admin/questionnaires.js | 33 ++ api/server/routes/index.js | 4 + api/server/routes/questionnaire.js | 76 +++ client/package.json | 2 + .../Questionnaire/QuestionnaireModal.tsx | 498 ++++++++++++++++++ client/src/components/Questionnaire/index.ts | 1 + client/src/data-provider/Misc/queries.ts | 19 +- client/src/data-provider/mutations.ts | 51 ++ client/src/locales/en/translation.json | 7 + client/src/routes/Root.tsx | 2 + config/delete-questionnaire.js | 97 ++++ config/questionnaire.example.json | 54 ++ config/update-questionnaire.js | 94 ++++ package-lock.json | 19 + package.json | 2 + packages/api/src/admin/index.ts | 2 + packages/api/src/admin/questionnaires.ts | 170 ++++++ .../src/components/OGDialogTemplate.tsx | 5 + packages/data-provider/src/api-endpoints.ts | 3 + packages/data-provider/src/data-service.ts | 16 + packages/data-provider/src/keys.ts | 1 + packages/data-provider/src/schemas.ts | 28 + packages/data-provider/src/types.ts | 31 ++ packages/data-provider/src/types/mutations.ts | 10 + .../data-schemas/src/admin/capabilities.ts | 5 + packages/data-schemas/src/index.ts | 2 + packages/data-schemas/src/methods/index.ts | 11 + .../data-schemas/src/methods/questionnaire.ts | 440 ++++++++++++++++ packages/data-schemas/src/models/index.ts | 9 + .../data-schemas/src/models/questionnaire.ts | 14 + .../src/models/questionnaireDismissal.ts | 14 + .../src/models/questionnaireResponse.ts | 14 + packages/data-schemas/src/schema/index.ts | 3 + .../data-schemas/src/schema/questionnaire.ts | 89 ++++ .../src/schema/questionnaireDismissal.ts | 34 ++ .../src/schema/questionnaireResponse.ts | 47 ++ packages/data-schemas/src/types/index.ts | 1 + .../data-schemas/src/types/questionnaire.ts | 96 ++++ 39 files changed, 2005 insertions(+), 1 deletion(-) create mode 100644 api/server/routes/admin/questionnaires.js create mode 100644 api/server/routes/questionnaire.js create mode 100644 client/src/components/Questionnaire/QuestionnaireModal.tsx create mode 100644 client/src/components/Questionnaire/index.ts create mode 100644 config/delete-questionnaire.js create mode 100644 config/questionnaire.example.json create mode 100644 config/update-questionnaire.js create mode 100644 packages/api/src/admin/questionnaires.ts create mode 100644 packages/data-schemas/src/methods/questionnaire.ts create mode 100644 packages/data-schemas/src/models/questionnaire.ts create mode 100644 packages/data-schemas/src/models/questionnaireDismissal.ts create mode 100644 packages/data-schemas/src/models/questionnaireResponse.ts create mode 100644 packages/data-schemas/src/schema/questionnaire.ts create mode 100644 packages/data-schemas/src/schema/questionnaireDismissal.ts create mode 100644 packages/data-schemas/src/schema/questionnaireResponse.ts create mode 100644 packages/data-schemas/src/types/questionnaire.ts diff --git a/api/server/index.js b/api/server/index.js index a6be1e68aed..b8973a5c819 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -394,6 +394,7 @@ const startServer = async () => { app.use('/api/admin/skills', routes.adminSkills); app.use('/api/admin/users', routes.adminUsers); app.use('/api/admin/audit-log', routes.adminAuditLog); + app.use('/api/admin/questionnaires', routes.adminQuestionnaires); app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/api-keys', routes.apiKeys); @@ -424,6 +425,7 @@ const startServer = async () => { app.use('/api/agents/chat', rejectChatStartsUntilReady); app.use('/api/agents', routes.agents); app.use('/api/banner', routes.banner); + app.use('/api/questionnaire', routes.questionnaire); app.use('/api/memories', routes.memories); app.use('/api/schedules', rejectScheduleWritesUntilReady, routes.schedules); app.use('/api/permissions', routes.accessPermissions); diff --git a/api/server/routes/admin/questionnaires.js b/api/server/routes/admin/questionnaires.js new file mode 100644 index 00000000000..dc4775c4437 --- /dev/null +++ b/api/server/routes/admin/questionnaires.js @@ -0,0 +1,33 @@ +const express = require('express'); +const { createAdminQuestionnairesHandlers } = require('@librechat/api'); +const { SystemCapabilities } = require('@librechat/data-schemas'); +const { requireCapability } = require('~/server/middleware/roles/capabilities'); +const { requireJwtAuth } = require('~/server/middleware'); +const db = require('~/models'); + +const router = express.Router(); + +const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN); +const requireRead = requireCapability(SystemCapabilities.READ_QUESTIONNAIRES); +const requireManage = requireCapability(SystemCapabilities.MANAGE_QUESTIONNAIRES); + +const handlers = createAdminQuestionnairesHandlers({ + listQuestionnaires: db.listQuestionnaires, + getQuestionnaireById: db.getQuestionnaireById, + saveQuestionnaire: db.saveQuestionnaire, + setQuestionnaireStatus: db.setQuestionnaireStatus, + duplicateQuestionnaire: db.duplicateQuestionnaire, + deleteQuestionnaire: db.deleteQuestionnaire, +}); + +router.use(requireJwtAuth, requireAdminAccess); + +router.get('/', requireRead, handlers.listQuestionnaires); +router.get('/:id', requireRead, handlers.getQuestionnaire); +router.post('/', requireManage, handlers.createQuestionnaire); +router.put('/:id', requireManage, handlers.updateQuestionnaire); +router.patch('/:id/status', requireManage, handlers.setStatus); +router.post('/:id/duplicate', requireManage, handlers.duplicateQuestionnaire); +router.delete('/:id', requireManage, handlers.deleteQuestionnaire); + +module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index c3b6c4bb8e7..6822ef3a25b 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -12,6 +12,7 @@ const adminRoles = require('./admin/roles'); const adminSkills = require('./admin/skills'); const adminUsers = require('./admin/users'); const adminAuditLog = require('./admin/audit'); +const adminQuestionnaires = require('./admin/questionnaires'); const endpoints = require('./endpoints'); const staticRoute = require('./static'); const messages = require('./messages'); @@ -25,6 +26,7 @@ const balance = require('./balance'); const actions = require('./actions'); const apiKeys = require('./apiKeys'); const banner = require('./banner'); +const questionnaire = require('./questionnaire'); const search = require('./search'); const models = require('./models'); const convos = require('./convos'); @@ -58,6 +60,7 @@ module.exports = { adminSkills, adminUsers, adminAuditLog, + adminQuestionnaires, keys, apiKeys, user, @@ -67,6 +70,7 @@ module.exports = { files, share, banner, + questionnaire, agents, convos, search, diff --git a/api/server/routes/questionnaire.js b/api/server/routes/questionnaire.js new file mode 100644 index 00000000000..c7bc331f0aa --- /dev/null +++ b/api/server/routes/questionnaire.js @@ -0,0 +1,76 @@ +const express = require('express'); +const { logger } = require('@librechat/data-schemas'); +const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth'); +const { requireJwtAuth } = require('~/server/middleware'); +const { + getActiveQuestionnaire, + submitQuestionnaireResponse, + dismissQuestionnaire, +} = require('~/models'); + +const router = express.Router(); + +router.get('/', optionalJwtAuth, async (req, res) => { + try { + const { questionnaire, completed, dismissedAt } = await getActiveQuestionnaire(req.user); + res.status(200).send({ questionnaire, completed, dismissedAt }); + } catch (error) { + logger.error('[getQuestionnaire] Error getting questionnaire', error); + res.status(500).json({ message: 'Error getting questionnaire' }); + } +}); + +router.post('/dismiss', requireJwtAuth, async (req, res) => { + try { + const { questionnaireId } = req.body; + if (!questionnaireId) { + return res.status(400).json({ message: 'questionnaireId is required' }); + } + + const { questionnaire } = await getActiveQuestionnaire(req.user); + if (!questionnaire || questionnaire.questionnaireId !== questionnaireId) { + return res.status(409).json({ message: 'This questionnaire is no longer active' }); + } + + const dismissal = await dismissQuestionnaire(req.user.id, questionnaireId); + res.status(200).json({ success: true, dismissedAt: dismissal.dismissedAt }); + } catch (error) { + logger.error('[dismissQuestionnaire] Error dismissing questionnaire', error); + res.status(500).json({ message: 'Error dismissing questionnaire' }); + } +}); + +router.post('/response', requireJwtAuth, async (req, res) => { + try { + const { questionnaireId, answers } = req.body; + + if (!questionnaireId || !Array.isArray(answers) || answers.length === 0) { + return res.status(400).json({ message: 'questionnaireId and answers are required' }); + } + + const { questionnaire } = await getActiveQuestionnaire(req.user); + if (!questionnaire || questionnaire.questionnaireId !== questionnaireId) { + return res.status(409).json({ message: 'This questionnaire is no longer active' }); + } + + const requiredIds = questionnaire.questions + .filter((question) => question.required) + .map((question) => question.id); + const answeredIds = new Set(answers.map((answer) => answer.questionId)); + const missing = requiredIds.filter((id) => !answeredIds.has(id)); + if (missing.length > 0) { + return res.status(400).json({ message: 'Missing required answers', missing }); + } + + await submitQuestionnaireResponse(req.user.id, questionnaireId, answers); + res.status(200).json({ success: true }); + } catch (error) { + logger.error('[submitQuestionnaireResponse] Error submitting response', error); + if (error.message && error.message.includes('already responded')) { + return res.status(409).json({ message: error.message }); + } + res.status(500).json({ message: 'Error submitting questionnaire response' }); + } +}); + +module.exports = router; diff --git a/client/package.json b/client/package.json index 669107f62bc..b336004be8f 100644 --- a/client/package.json +++ b/client/package.json @@ -61,6 +61,7 @@ "@react-spring/web": "^9.7.5", "@tanstack/react-query": "^4.28.0", "@tanstack/react-table": "^8.11.7", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", @@ -143,6 +144,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", + "@types/canvas-confetti": "^1.9.0", "@types/jest": "^29.5.14", "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.17.15", diff --git a/client/src/components/Questionnaire/QuestionnaireModal.tsx b/client/src/components/Questionnaire/QuestionnaireModal.tsx new file mode 100644 index 00000000000..06e13d134ef --- /dev/null +++ b/client/src/components/Questionnaire/QuestionnaireModal.tsx @@ -0,0 +1,498 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Clock } from 'lucide-react'; +import confetti from 'canvas-confetti'; +import { + OGDialog, + DialogTemplate, + Checkbox, + TextareaAutosize, + cn, + useToastContext, +} from '@librechat/client'; +import type { TQuestionnaireAnswer, TQuestionnaireQuestion } from 'librechat-data-provider'; +import { + useGetQuestionnaireQuery, + useSubmitQuestionnaireResponseMutation, + useDismissQuestionnaireMutation, +} from '~/data-provider'; +import { useLocalize } from '~/hooks'; + +const REPROMPT_FALLBACK_HOURS = 24; +const FALLBACK_QUESTIONS_PER_PAGE = 4; + +type AnswerValue = string | number | string[]; + +type QuestionnairePage = { + section?: string; + questions: TQuestionnaireQuestion[]; +}; + +const formatReprompt = (hours: number): string => { + if (hours % 24 === 0) { + const days = hours / 24; + return days === 1 ? '1 day' : `${days} days`; + } + return hours === 1 ? '1 hour' : `${hours} hours`; +}; + +const isAnswered = (value: AnswerValue | undefined): boolean => { + if (value == null || value === '') { + return false; + } + return !Array.isArray(value) || value.length > 0; +}; + +const buildPages = (questions: TQuestionnaireQuestion[]): QuestionnairePage[] => { + if (questions.every((question) => !question.section)) { + const pages: QuestionnairePage[] = []; + for (let i = 0; i < questions.length; i += FALLBACK_QUESTIONS_PER_PAGE) { + pages.push({ questions: questions.slice(i, i + FALLBACK_QUESTIONS_PER_PAGE) }); + } + return pages; + } + + return questions.reduce((pages, question) => { + const current = pages[pages.length - 1]; + if (current && current.section === question.section) { + current.questions.push(question); + } else { + pages.push({ section: question.section, questions: [question] }); + } + return pages; + }, []); +}; + +const ScaleQuestion = ({ + question, + value, + onChange, +}: { + question: TQuestionnaireQuestion; + value: number | undefined; + onChange: (value: number) => void; +}) => { + const min = question.min ?? 1; + const max = question.max ?? 5; + const options = Array.from({ length: max - min + 1 }, (_, i) => min + i); + + return ( +
+
+ {options.map((option) => ( + + ))} +
+ {(question.minLabel || question.maxLabel) && ( +
+ {question.minLabel} + {question.maxLabel} +
+ )} +
+ ); +}; + +const SingleChoiceQuestion = ({ + question, + value, + onChange, +}: { + question: TQuestionnaireQuestion; + value: string | undefined; + onChange: (value: string) => void; +}) => ( +
+ {(question.options ?? []).map((option) => ( + + ))} +
+); + +const MultipleChoiceQuestion = ({ + question, + value, + onChange, +}: { + question: TQuestionnaireQuestion; + value: string[]; + onChange: (value: string[]) => void; +}) => { + const toggle = (option: string) => { + if (value.includes(option)) { + onChange(value.filter((v) => v !== option)); + return; + } + onChange([...value, option]); + }; + + return ( +
+ {(question.options ?? []).map((option) => { + const checked = value.includes(option); + return ( + + ); + })} +
+ ); +}; + +const TextQuestion = ({ + question, + value, + onChange, +}: { + question: TQuestionnaireQuestion; + value: string | undefined; + onChange: (value: string) => void; +}) => ( + onChange(e.target.value)} + minRows={2} + maxRows={8} + className="w-full resize-none rounded-lg border border-border-medium bg-surface-secondary px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary" + /> +); + +const NumericQuestion = ({ + question, + value, + onChange, +}: { + question: TQuestionnaireQuestion; + value: number | undefined; + onChange: (value: number) => void; +}) => ( + onChange(Number(e.target.value))} + className="w-32 rounded-lg border border-border-medium bg-surface-secondary px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary" + /> +); + +export default function QuestionnaireModal() { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const { data } = useGetQuestionnaireQuery(); + const [open, setOpen] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [pageIndex, setPageIndex] = useState(0); + const [showErrors, setShowErrors] = useState(false); + const [answers, setAnswers] = useState>({}); + const scrollRef = useRef(null); + + const questionnaire = data?.questionnaire ?? null; + const completed = data?.completed ?? false; + + const pages = useMemo( + () => (questionnaire ? buildPages(questionnaire.questions) : []), + [questionnaire], + ); + + const shouldShow = useMemo(() => { + if (!questionnaire || completed) { + return false; + } + if (!data?.dismissedAt) { + return true; + } + const repromptMs = + (questionnaire.repromptIntervalHours ?? REPROMPT_FALLBACK_HOURS) * 60 * 60 * 1000; + return Date.now() - new Date(data.dismissedAt).getTime() >= repromptMs; + }, [questionnaire, completed, data?.dismissedAt]); + + useEffect(() => { + if (shouldShow) { + setOpen(true); + setSubmitted(false); + setAnswers({}); + setPageIndex(0); + setShowErrors(false); + } + }, [shouldShow]); + + /* Each page starts at the top rather than inheriting the previous page's scroll position. */ + useEffect(() => { + scrollRef.current?.scrollTo({ top: 0 }); + }, [pageIndex]); + + const submitMutation = useSubmitQuestionnaireResponseMutation({ + onSuccess: () => { + setSubmitted(true); + confetti({ + particleCount: 150, + spread: 80, + origin: { y: 0.6 }, + }); + }, + onError: () => { + showToast({ message: localize('com_ui_questionnaire_submit_error'), status: 'error' }); + }, + }); + + const dismissMutation = useDismissQuestionnaireMutation(); + + if (!questionnaire || !open || pages.length === 0) { + return null; + } + + const repromptLabel = formatReprompt( + questionnaire.repromptIntervalHours ?? REPROMPT_FALLBACK_HOURS, + ); + const currentPage = pages[Math.min(pageIndex, pages.length - 1)]; + const isLastPage = pageIndex >= pages.length - 1; + const requiredCount = questionnaire.questions.filter((question) => question.required).length; + const answeredRequired = questionnaire.questions.filter( + (question) => question.required && isAnswered(answers[question.id]), + ).length; + const progress = requiredCount > 0 ? answeredRequired / requiredCount : pageIndex / pages.length; + + const handleDismiss = () => { + setOpen(false); + dismissMutation.mutate({ questionnaireId: questionnaire.questionnaireId }); + showToast({ + message: localize('com_ui_questionnaire_dismissed_toast', { 0: repromptLabel }), + status: 'info', + }); + }; + + const handleChange = (questionId: string, value: AnswerValue) => { + setAnswers((prev) => ({ ...prev, [questionId]: value })); + }; + + const missingOnPage = currentPage.questions.filter( + (question) => question.required === true && !isAnswered(answers[question.id]), + ); + + const handleSubmit = () => { + const payloadAnswers: TQuestionnaireAnswer[] = Object.entries(answers).map( + ([questionId, value]) => ({ questionId, value }), + ); + + submitMutation.mutate({ + questionnaireId: questionnaire.questionnaireId, + answers: payloadAnswers, + }); + }; + + const handleNext = () => { + if (missingOnPage.length > 0) { + setShowErrors(true); + showToast({ message: localize('com_ui_questionnaire_required'), status: 'error' }); + return; + } + setShowErrors(false); + if (isLastPage) { + handleSubmit(); + return; + } + setPageIndex((prev) => prev + 1); + }; + + const secondaryButtonClasses = + 'inline-flex h-10 items-center justify-center rounded-lg border border-border-medium bg-surface-secondary px-4 py-2 text-sm text-text-primary transition-colors hover:bg-surface-active focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary'; + + return ( + !isOpen && handleDismiss()}> + event.preventDefault()} + main={ + submitted ? ( +
+
🎉
+

+ {questionnaire.thankYouMessage || + localize('com_ui_questionnaire_thank_you_default')} +

+
+ ) : ( +
+
+
+ {currentPage.section != null && ( + + {currentPage.section} + + )} + + {localize('com_ui_questionnaire_step', { + 0: String(pageIndex + 1), + 1: String(pages.length), + })} + +
+
+
+
+
+ + {pageIndex === 0 && questionnaire.intro && ( +

{questionnaire.intro}

+ )} + +
+ {currentPage.questions.map((question) => { + const missing = + showErrors === true && + question.required === true && + !isAnswered(answers[question.id]); + return ( +
+ + {question.description && ( +

{question.description}

+ )} + {question.type === 'scale' && ( + handleChange(question.id, value)} + /> + )} + {question.type === 'numeric' && ( + handleChange(question.id, value)} + /> + )} + {question.type === 'single_choice' && ( + handleChange(question.id, value)} + /> + )} + {question.type === 'multiple_choice' && ( + handleChange(question.id, value)} + /> + )} + {question.type === 'text' && ( + handleChange(question.id, value)} + /> + )} + {missing && ( +

+ {localize('com_ui_questionnaire_required')} +

+ )} +
+ ); + })} +
+ +
+ + +
+
+ ) + } + buttons={ + submitted ? ( + + ) : ( + <> + + {pageIndex > 0 && ( + + )} + + + ) + } + /> + + ); +} diff --git a/client/src/components/Questionnaire/index.ts b/client/src/components/Questionnaire/index.ts new file mode 100644 index 00000000000..710a6b40c3b --- /dev/null +++ b/client/src/components/Questionnaire/index.ts @@ -0,0 +1 @@ +export { default as QuestionnaireModal } from './QuestionnaireModal'; diff --git a/client/src/data-provider/Misc/queries.ts b/client/src/data-provider/Misc/queries.ts index 112b36a1902..92f6382b65f 100644 --- a/client/src/data-provider/Misc/queries.ts +++ b/client/src/data-provider/Misc/queries.ts @@ -1,6 +1,6 @@ import { useRecoilValue } from 'recoil'; -import { QueryKeys, dataService } from 'librechat-data-provider'; import { useQuery } from '@tanstack/react-query'; +import { QueryKeys, dataService } from 'librechat-data-provider'; import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query'; import type t from 'librechat-data-provider'; import store from '~/store'; @@ -31,6 +31,23 @@ export const useGetUserBalance = ( }); }; +export const useGetQuestionnaireQuery = ( + config?: UseQueryOptions, +): QueryObserverResult => { + const queriesEnabled = useRecoilValue(store.queriesEnabled); + return useQuery( + [QueryKeys.questionnaire], + () => dataService.getQuestionnaire(), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + enabled: (config?.enabled ?? true) === true && queriesEnabled, + }, + ); +}; + export const useGetSearchEnabledQuery = ( config?: UseQueryOptions, ): QueryObserverResult => { diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index cb7e14f2d2b..8151b4795ab 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -1363,3 +1363,54 @@ export const useAcceptTermsMutation = ( onMutate: options?.onMutate, }); }; + +export const useDismissQuestionnaireMutation = ( + options?: t.DismissQuestionnaireMutationOptions, +): UseMutationResult< + t.TDismissQuestionnaireResponse, + unknown, + t.TDismissQuestionnaireRequest, + unknown +> => { + const queryClient = useQueryClient(); + return useMutation( + (payload: t.TDismissQuestionnaireRequest) => dataService.dismissQuestionnaire(payload), + { + onSuccess: (data, variables, context) => { + queryClient.setQueryData([QueryKeys.questionnaire], (previous) => + previous + ? { ...previous, dismissedAt: data.dismissedAt } + : { questionnaire: null, completed: false, dismissedAt: data.dismissedAt }, + ); + options?.onSuccess?.(data, variables, context); + }, + onError: options?.onError, + onMutate: options?.onMutate, + }, + ); +}; + +export const useSubmitQuestionnaireResponseMutation = ( + options?: t.SubmitQuestionnaireResponseMutationOptions, +): UseMutationResult< + t.TSubmitQuestionnaireResponseResponse, + unknown, + t.TSubmitQuestionnaireResponseRequest, + unknown +> => { + const queryClient = useQueryClient(); + return useMutation( + (payload: t.TSubmitQuestionnaireResponseRequest) => + dataService.submitQuestionnaireResponse(payload), + { + onSuccess: (data, variables, context) => { + queryClient.setQueryData([QueryKeys.questionnaire], (previous) => + previous ? { ...previous, completed: true } : { questionnaire: null, completed: true }, + ); + options?.onSuccess?.(data, variables, context); + }, + onError: options?.onError, + onMutate: options?.onMutate, + }, + ); +}; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index f3d904c6898..31ca99b6c37 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1852,6 +1852,13 @@ "com_ui_asking_questions_one": "Asking {{0}} question", "com_ui_asking_questions": "Asking {{0}} questions", "com_ui_question_unanswered": "No answer was given", + "com_ui_questionnaire_dismiss": "Maybe later", + "com_ui_questionnaire_dismissed_toast": "We'll ask again in {{0}}.", + "com_ui_questionnaire_remind_later": "We'll ask again in {{0}} if you skip this", + "com_ui_questionnaire_step": "Step {{0}} of {{1}}", + "com_ui_questionnaire_required": "This question is required", + "com_ui_questionnaire_submit_error": "There was an error submitting your response. Please try again.", + "com_ui_questionnaire_thank_you_default": "Thank you! Your feedback genuinely helps us make this product better for everyone.", "com_ui_queue": "Queue", "com_ui_queue_send": "Queue message for after the response", "com_ui_queued_attachment_count": "{{0}} attachments queued with this message", diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index 274cfd29d2c..1b932390e75 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -30,6 +30,7 @@ import KeyboardDeleteDialog from '~/components/Nav/KeyboardDeleteDialog'; import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider'; import { MobileDrawerScrim } from '~/components/UnifiedSidebar/mobile'; import useKeyboardShortcuts from '~/hooks/useKeyboardShortcuts'; +import { QuestionnaireModal } from '~/components/Questionnaire'; import useDrawerDismiss from '~/hooks/Nav/useDrawerDismiss'; import useSidebarToggle from '~/hooks/Nav/useSidebarToggle'; import useSidebarState from '~/hooks/Nav/useSidebarState'; @@ -207,6 +208,7 @@ export default function Root() { modalContent={config.interface.termsOfService.modalContent} /> )} + diff --git a/config/delete-questionnaire.js b/config/delete-questionnaire.js new file mode 100644 index 00000000000..319eb6fb325 --- /dev/null +++ b/config/delete-questionnaire.js @@ -0,0 +1,97 @@ +const path = require('path'); +require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); +const { QuestionnaireValidationError } = require('@librechat/data-schemas'); +require('~/db/models'); +const { listQuestionnaires, getQuestionnaireById, deleteQuestionnaire } = require('~/models'); +const { askQuestion, silentExit } = require('./helpers'); +const connect = require('./connect'); + +function argValue(name) { + const arg = process.argv.find((a) => a.startsWith(`--${name}=`)); + return arg ? arg.replace(`--${name}=`, '') : undefined; +} + +(async () => { + await connect(); + + console.purple('--------------------------'); + console.purple('Delete the questionnaire!'); + console.purple('--------------------------'); + + try { + const targetId = argValue('id'); + const force = process.argv.includes('--force'); + + const all = await listQuestionnaires(); + if (all.length === 0) { + console.yellow('No questionnaire found to delete.'); + silentExit(0); + } + + if (!targetId) { + console.purple(`Rounds (${all.length}):`); + for (const round of all) { + console.log( + ` [${round.status}] ${round.label ?? round.title} — ${round.questionnaireId} (${round.responseCount} responses)`, + ); + } + console.orange('\nUsage: npm run delete-questionnaire -- --id='); + console.orange(' --force also discard existing responses'); + silentExit(1); + } + + const questionnaire = await getQuestionnaireById(targetId); + if (!questionnaire) { + console.red(`No questionnaire found with id "${targetId}".`); + silentExit(1); + } + + const summary = all.find((round) => round.questionnaireId === targetId); + + console.purple('Questionnaire to delete:'); + console.log(`Label: ${questionnaire.label ?? '(none)'}`); + console.log(`Status: ${questionnaire.status}`); + console.log(`Title: ${questionnaire.title}`); + console.log(`Questions: ${questionnaire.questions.length}`); + console.log(`Responses: ${summary?.responseCount ?? 0}`); + console.log(`Display From: ${questionnaire.displayFrom}`); + console.log(`Display To: ${questionnaire.displayTo || 'Not specified'}`); + + const confirmDelete = await askQuestion('Do you want to delete this questionnaire? (y/N): '); + if (confirmDelete.toLowerCase() !== 'y') { + console.yellow('Questionnaire deletion cancelled.'); + silentExit(0); + } + + try { + await deleteQuestionnaire(targetId, { force }); + console.green('Questionnaire deleted successfully!'); + } catch (error) { + if (error instanceof QuestionnaireValidationError) { + console.red('Error: ' + error.message); + console.orange('Re-run with --force to delete it and its responses anyway.'); + silentExit(1); + } + throw error; + } + } catch (error) { + console.red('Error: ' + error.message); + console.error(error); + silentExit(1); + } + + silentExit(0); +})(); + +process.on('uncaughtException', (err) => { + if (!err.message.includes('fetch failed')) { + console.error('There was an uncaught error:'); + console.error(err); + } + + if (err.message.includes('fetch failed')) { + return; + } else { + process.exit(1); + } +}); diff --git a/config/questionnaire.example.json b/config/questionnaire.example.json new file mode 100644 index 00000000000..2b9582cd1b9 --- /dev/null +++ b/config/questionnaire.example.json @@ -0,0 +1,54 @@ +{ + "label": "Q3 2026", + "title": "Quick feedback survey", + "intro": "A few short questions about how things are going. This takes about a minute.", + "thankYouMessage": "Thanks for the feedback!", + "displayFrom": "2026-07-01T00:00:00Z", + "displayTo": null, + "repromptIntervalHours": 24, + "questions": [ + { + "id": "satisfaction", + "type": "scale", + "section": "Your experience", + "title": "How satisfied are you so far?", + "required": true, + "min": 1, + "max": 5, + "minLabel": "Not satisfied", + "maxLabel": "Very satisfied" + }, + { + "id": "frequency", + "type": "single_choice", + "section": "Your experience", + "title": "How often do you use it?", + "required": true, + "options": ["Daily", "Weekly", "Monthly", "Less than monthly"] + }, + { + "id": "tasks", + "type": "multiple_choice", + "section": "Your experience", + "title": "Which tasks do you use it for?", + "required": false, + "options": ["Writing", "Summarising", "Coding", "Learning", "Other"] + }, + { + "id": "sessions_per_week", + "type": "numeric", + "section": "Anything else", + "title": "Roughly how many sessions per week?", + "required": false, + "min": 0, + "max": 100 + }, + { + "id": "comments", + "type": "text", + "section": "Anything else", + "title": "Anything you would change?", + "required": false + } + ] +} diff --git a/config/update-questionnaire.js b/config/update-questionnaire.js new file mode 100644 index 00000000000..8f19c3a31ac --- /dev/null +++ b/config/update-questionnaire.js @@ -0,0 +1,94 @@ +const fs = require('fs'); +const path = require('path'); +require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); +const { QuestionnaireValidationError } = require('@librechat/data-schemas'); +require('~/db/models'); +const { saveQuestionnaire, listQuestionnaires } = require('~/models'); +const { silentExit } = require('./helpers'); +const connect = require('./connect'); + +function argValue(name) { + const arg = process.argv.find((a) => a.startsWith(`--${name}=`)); + return arg ? arg.replace(`--${name}=`, '') : undefined; +} + +(async () => { + await connect(); + + console.purple('--------------------------'); + console.purple('Update the questionnaire!'); + console.purple('--------------------------'); + + const fileValue = argValue('file'); + if (!fileValue) { + console.orange('Usage: npm run update-questionnaire -- --file='); + console.orange('Options:'); + console.orange(' --id= update an existing round instead of creating one'); + console.orange(' --status=draft|active|closed (activating closes any other active round)'); + console.orange('See config/questionnaire.example.json for the expected shape.'); + silentExit(1); + } + + const filePath = path.resolve(process.cwd(), fileValue); + if (!fs.existsSync(filePath)) { + console.red(`Error: File not found: ${filePath}`); + silentExit(1); + } + + let config; + try { + config = JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + console.red('Error: Could not parse JSON file: ' + error.message); + silentExit(1); + } + + const targetId = argValue('id'); + const status = argValue('status') ?? config.status; + + let result; + try { + result = await saveQuestionnaire({ ...config, status }, targetId); + } catch (error) { + if (error instanceof QuestionnaireValidationError) { + console.red('Error: ' + error.message); + silentExit(1); + } + console.red('Error: ' + error.message); + console.error(error); + silentExit(1); + } + + console.green('Questionnaire saved successfully!'); + console.purple(`questionnaireId: ${result.questionnaireId}`); + console.purple(`label: ${result.label} (Q${result.quarter} ${result.year})`); + console.purple(`status: ${result.status}`); + console.purple(`title: ${result.title}`); + console.purple(`questions: ${result.questions.length}`); + console.purple(`from: ${result.displayFrom}`); + console.purple(`to: ${result.displayTo || 'not specified'}`); + console.purple(`repromptIntervalHours: ${result.repromptIntervalHours}`); + + const all = await listQuestionnaires(); + console.purple(`\nAll rounds (${all.length}):`); + for (const round of all) { + const marker = round.questionnaireId === result.questionnaireId ? ' <- saved' : ''; + console.log( + ` [${round.status}] ${round.label ?? round.title} — ${round.questionnaireId}${marker}`, + ); + } + silentExit(0); +})(); + +process.on('uncaughtException', (err) => { + if (!err.message.includes('fetch failed')) { + console.error('There was an uncaught error:'); + console.error(err); + } + + if (err.message.includes('fetch failed')) { + return; + } else { + process.exit(1); + } +}); diff --git a/package-lock.json b/package-lock.json index bc6867163fc..d0d8e3ec423 100644 --- a/package-lock.json +++ b/package-lock.json @@ -918,6 +918,7 @@ "@react-spring/web": "^9.7.5", "@tanstack/react-query": "^4.28.0", "@tanstack/react-table": "^8.11.7", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", @@ -1000,6 +1001,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", + "@types/canvas-confetti": "^1.9.0", "@types/jest": "^29.5.14", "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.17.15", @@ -18277,6 +18279,13 @@ "bun-types": "1.2.15" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -21387,6 +21396,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", diff --git a/package.json b/package.json index b462b64cc24..2ad304b54c0 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "reset-meili-sync": "node config/reset-meili-sync.js", "update-banner": "node config/update-banner.js", "delete-banner": "node config/delete-banner.js", + "update-questionnaire": "node config/update-questionnaire.js", + "delete-questionnaire": "node config/delete-questionnaire.js", "backend": "cross-env NODE_ENV=production node api/server/index.js", "backend:inspect": "cross-env NODE_ENV=production node --inspect --expose-gc api/server/index.js", "backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js", diff --git a/packages/api/src/admin/index.ts b/packages/api/src/admin/index.ts index 84f565c03f6..a22aa22b967 100644 --- a/packages/api/src/admin/index.ts +++ b/packages/api/src/admin/index.ts @@ -6,6 +6,7 @@ export { createAdminRolesHandlers } from './roles'; export { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } from './skills'; export { createAdminUsersHandlers } from './users'; export { createAdminAuditLogHandlers } from './auditLog'; +export { createAdminQuestionnairesHandlers } from './questionnaires'; export { createAdminCodeEnvironmentHandlers } from './code'; export { buildAuditContext } from './context'; export { resolveConfigSecret, redactConfigSecretMaps } from './secrets'; @@ -17,4 +18,5 @@ export type { AdminRolesDeps } from './roles'; export type { AdminSkillSyncAccessDeps, AdminSkillSyncDeps } from './skills'; export type { AdminUsersDeps } from './users'; export type { AdminAuditLogDeps } from './auditLog'; +export type { AdminQuestionnairesDeps } from './questionnaires'; export type { AdminCodeEnvironmentDeps } from './code'; diff --git a/packages/api/src/admin/questionnaires.ts b/packages/api/src/admin/questionnaires.ts new file mode 100644 index 00000000000..2d43879fc90 --- /dev/null +++ b/packages/api/src/admin/questionnaires.ts @@ -0,0 +1,170 @@ +import { logger, QuestionnaireValidationError } from '@librechat/data-schemas'; +import type { + IQuestionnaire, + QuestionnaireInput, + QuestionnaireStatus, + QuestionnaireSummary, +} from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types/http'; + +export interface AdminQuestionnairesDeps { + listQuestionnaires: () => Promise; + getQuestionnaireById: (questionnaireId: string) => Promise; + saveQuestionnaire: ( + input: QuestionnaireInput, + questionnaireId?: string, + ) => Promise; + setQuestionnaireStatus: ( + questionnaireId: string, + status: QuestionnaireStatus, + ) => Promise; + duplicateQuestionnaire: (questionnaireId: string) => Promise; + deleteQuestionnaire: ( + questionnaireId: string, + options?: { force?: boolean }, + ) => Promise; +} + +function inputFrom(body: unknown): QuestionnaireInput { + const source = (body ?? {}) as Record; + return { + label: source.label as string | undefined, + status: source.status as QuestionnaireStatus | undefined, + title: source.title as string, + intro: source.intro as string | undefined, + thankYouMessage: source.thankYouMessage as string | undefined, + questions: source.questions as QuestionnaireInput['questions'], + displayFrom: source.displayFrom as string | undefined, + displayTo: source.displayTo as string | null | undefined, + repromptIntervalHours: source.repromptIntervalHours as number | undefined, + }; +} + +export function createAdminQuestionnairesHandlers(deps: AdminQuestionnairesDeps): { + listQuestionnaires: (req: ServerRequest, res: Response) => Promise; + getQuestionnaire: (req: ServerRequest, res: Response) => Promise; + createQuestionnaire: (req: ServerRequest, res: Response) => Promise; + updateQuestionnaire: (req: ServerRequest, res: Response) => Promise; + setStatus: (req: ServerRequest, res: Response) => Promise; + duplicateQuestionnaire: (req: ServerRequest, res: Response) => Promise; + deleteQuestionnaire: (req: ServerRequest, res: Response) => Promise; +} { + async function listQuestionnairesHandler(_req: ServerRequest, res: Response) { + try { + const questionnaires = await deps.listQuestionnaires(); + return res.status(200).json({ questionnaires, total: questionnaires.length }); + } catch (error) { + logger.error('[adminQuestionnaires] listQuestionnaires error:', error); + return res.status(500).json({ error: 'Failed to list questionnaires' }); + } + } + + async function getQuestionnaireHandler(req: ServerRequest, res: Response) { + try { + const { id } = req.params as { id: string }; + const questionnaire = await deps.getQuestionnaireById(id); + if (!questionnaire) { + return res.status(404).json({ error: 'Questionnaire not found' }); + } + return res.status(200).json({ questionnaire }); + } catch (error) { + logger.error('[adminQuestionnaires] getQuestionnaire error:', error); + return res.status(500).json({ error: 'Failed to fetch questionnaire' }); + } + } + + async function createQuestionnaireHandler(req: ServerRequest, res: Response) { + try { + const questionnaire = await deps.saveQuestionnaire(inputFrom(req.body)); + return res.status(201).json({ questionnaire }); + } catch (error) { + if (error instanceof QuestionnaireValidationError) { + return res.status(400).json({ error: error.message }); + } + logger.error('[adminQuestionnaires] createQuestionnaire error:', error); + return res.status(500).json({ error: 'Failed to create questionnaire' }); + } + } + + async function updateQuestionnaireHandler(req: ServerRequest, res: Response) { + try { + const { id } = req.params as { id: string }; + const existing = await deps.getQuestionnaireById(id); + if (!existing) { + return res.status(404).json({ error: 'Questionnaire not found' }); + } + const questionnaire = await deps.saveQuestionnaire(inputFrom(req.body), id); + return res.status(200).json({ questionnaire }); + } catch (error) { + if (error instanceof QuestionnaireValidationError) { + return res.status(400).json({ error: error.message }); + } + logger.error('[adminQuestionnaires] updateQuestionnaire error:', error); + return res.status(500).json({ error: 'Failed to update questionnaire' }); + } + } + + async function setStatusHandler(req: ServerRequest, res: Response) { + try { + const { id } = req.params as { id: string }; + const { status } = (req.body ?? {}) as { status?: QuestionnaireStatus }; + if (!status) { + return res.status(400).json({ error: '"status" is required' }); + } + const questionnaire = await deps.setQuestionnaireStatus(id, status); + if (!questionnaire) { + return res.status(404).json({ error: 'Questionnaire not found' }); + } + return res.status(200).json({ questionnaire }); + } catch (error) { + if (error instanceof QuestionnaireValidationError) { + return res.status(400).json({ error: error.message }); + } + logger.error('[adminQuestionnaires] setStatus error:', error); + return res.status(500).json({ error: 'Failed to update status' }); + } + } + + async function duplicateQuestionnaireHandler(req: ServerRequest, res: Response) { + try { + const { id } = req.params as { id: string }; + const questionnaire = await deps.duplicateQuestionnaire(id); + if (!questionnaire) { + return res.status(404).json({ error: 'Questionnaire not found' }); + } + return res.status(201).json({ questionnaire }); + } catch (error) { + logger.error('[adminQuestionnaires] duplicateQuestionnaire error:', error); + return res.status(500).json({ error: 'Failed to duplicate questionnaire' }); + } + } + + async function deleteQuestionnaireHandler(req: ServerRequest, res: Response) { + try { + const { id } = req.params as { id: string }; + const force = req.query.force === 'true'; + const deleted = await deps.deleteQuestionnaire(id, { force }); + if (!deleted) { + return res.status(404).json({ error: 'Questionnaire not found' }); + } + return res.status(200).json({ message: 'Questionnaire deleted successfully' }); + } catch (error) { + if (error instanceof QuestionnaireValidationError) { + return res.status(409).json({ error: error.message }); + } + logger.error('[adminQuestionnaires] deleteQuestionnaire error:', error); + return res.status(500).json({ error: 'Failed to delete questionnaire' }); + } + } + + return { + listQuestionnaires: listQuestionnairesHandler, + getQuestionnaire: getQuestionnaireHandler, + createQuestionnaire: createQuestionnaireHandler, + updateQuestionnaire: updateQuestionnaireHandler, + setStatus: setStatusHandler, + duplicateQuestionnaire: duplicateQuestionnaireHandler, + deleteQuestionnaire: deleteQuestionnaireHandler, + }; +} diff --git a/packages/client/src/components/OGDialogTemplate.tsx b/packages/client/src/components/OGDialogTemplate.tsx index b42570278a1..a4c32c19772 100644 --- a/packages/client/src/components/OGDialogTemplate.tsx +++ b/packages/client/src/components/OGDialogTemplate.tsx @@ -67,6 +67,9 @@ type DialogTemplateProps = { showCloseButton?: boolean; showCancelButton?: boolean; onClose?: () => void; + /** Forwarded to the underlying dialog content; call `preventDefault()` to skip auto-focusing + * the first focusable child when the dialog opens. */ + onOpenAutoFocus?: (event: Event) => void; }; const OGDialogTemplate: ForwardRefExoticComponent< @@ -87,6 +90,7 @@ const OGDialogTemplate: ForwardRefExoticComponent< showCloseButton = false, overlayClassName, showCancelButton = true, + onOpenAutoFocus, } = props; const isLegacySelection = isSelectionProps(selection); const legacySelection = isLegacySelection ? selection : null; @@ -128,6 +132,7 @@ const OGDialogTemplate: ForwardRefExoticComponent< className ?? '', )} onClick={(e) => e.stopPropagation()} + onOpenAutoFocus={onOpenAutoFocus} > {title} diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 3a0d1b08bb6..9441fda9bf1 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -532,6 +532,9 @@ export const addTagToConversation = (conversationId: string) => export const userTerms = () => `${BASE_URL}/api/user/terms`; export const acceptUserTerms = () => `${BASE_URL}/api/user/terms/accept`; export const banner = () => `${BASE_URL}/api/banner`; +export const questionnaire = () => `${BASE_URL}/api/questionnaire`; +export const questionnaireResponse = () => `${BASE_URL}/api/questionnaire/response`; +export const questionnaireDismiss = () => `${BASE_URL}/api/questionnaire/dismiss`; // Message Feedback export const feedback = (conversationId: string, messageId: string) => diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 2c24a51a0f3..167bf83831f 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -1459,6 +1459,22 @@ export function getBanner(): Promise { return request.get(endpoints.banner()); } +export function getQuestionnaire(): Promise { + return request.get(endpoints.questionnaire()); +} + +export function submitQuestionnaireResponse( + payload: t.TSubmitQuestionnaireResponseRequest, +): Promise { + return request.post(endpoints.questionnaireResponse(), payload); +} + +export function dismissQuestionnaire( + payload: t.TDismissQuestionnaireRequest, +): Promise { + return request.post(endpoints.questionnaireDismiss(), payload); +} + export function updateFeedback( conversationId: string, messageId: string, diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 5cdfd3397cd..9c6258d3471 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -62,6 +62,7 @@ export enum QueryKeys { health = 'health', userTerms = 'userTerms', banner = 'banner', + questionnaire = 'questionnaire', /* Memories */ memories = 'memories', principalSearch = 'principalSearch', diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 7dc914837e0..ee9190cf309 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -1722,6 +1722,34 @@ export const tBannerSchema = z.object({ }); export type TBanner = z.infer; +export const tQuestionnaireQuestionSchema = z.object({ + id: z.string(), + type: z.enum(['text', 'scale', 'numeric', 'single_choice', 'multiple_choice']), + title: z.string(), + description: z.string().optional(), + required: z.boolean().optional(), + section: z.string().optional(), + min: z.number().optional(), + max: z.number().optional(), + minLabel: z.string().optional(), + maxLabel: z.string().optional(), + options: z.array(z.string()).optional(), + maxSelections: z.number().optional(), +}); +export type TQuestionnaireQuestion = z.infer; + +export const tQuestionnaireSchema = z.object({ + questionnaireId: z.string(), + title: z.string(), + intro: z.string().optional(), + thankYouMessage: z.string().optional(), + questions: z.array(tQuestionnaireQuestionSchema), + displayFrom: z.string(), + displayTo: z.string().nullable().optional(), + repromptIntervalHours: z.number(), +}); +export type TQuestionnaire = z.infer; + export const compactAgentsBaseSchema = tConversationSchema.pick({ chatProjectId: true, spec: true, diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 798b12237a8..0a59f24eac2 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -7,6 +7,7 @@ import type { TAttachment, TMessage, TBanner, + TQuestionnaire, ReasoningResponseKey, ReasoningParameterFormat, } from './schemas'; @@ -930,6 +931,36 @@ export type TAcceptTermsResponse = { export type TBannerResponse = TBanner | null; +export type TQuestionnaireResponse = { + questionnaire: TQuestionnaire | null; + completed: boolean; + /** ISO date string of when the current user last dismissed this questionnaire, if ever. */ + dismissedAt: string | null; +}; + +export type TQuestionnaireAnswer = { + questionId: string; + value: string | number | string[]; +}; + +export type TSubmitQuestionnaireResponseRequest = { + questionnaireId: string; + answers: TQuestionnaireAnswer[]; +}; + +export type TSubmitQuestionnaireResponseResponse = { + success: boolean; +}; + +export type TDismissQuestionnaireRequest = { + questionnaireId: string; +}; + +export type TDismissQuestionnaireResponse = { + success: boolean; + dismissedAt: string; +}; + export type TUpdateFeedbackRequest = { feedback?: TMinimalFeedback; }; diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index 414d479b5c1..e8c5660fa73 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -444,6 +444,16 @@ export type AcceptTermsMutationOptions = MutationOptions< void >; +export type SubmitQuestionnaireResponseMutationOptions = MutationOptions< + types.TSubmitQuestionnaireResponseResponse, + types.TSubmitQuestionnaireResponseRequest +>; + +export type DismissQuestionnaireMutationOptions = MutationOptions< + types.TDismissQuestionnaireResponse, + types.TDismissQuestionnaireRequest +>; + /* Tools */ export type UpdatePluginAuthOptions = MutationOptions; diff --git a/packages/data-schemas/src/admin/capabilities.ts b/packages/data-schemas/src/admin/capabilities.ts index c820255275c..a20395a113e 100644 --- a/packages/data-schemas/src/admin/capabilities.ts +++ b/packages/data-schemas/src/admin/capabilities.ts @@ -37,6 +37,8 @@ export const SystemCapabilities = { MANAGE_SKILLS: 'manage:skills', READ_SHARED_LINKS: 'read:sharedlinks', MANAGE_SHARED_LINKS: 'manage:sharedlinks', + READ_QUESTIONNAIRES: 'read:questionnaires', + MANAGE_QUESTIONNAIRES: 'manage:questionnaires', /** Reserved — not yet enforced by any middleware. */ READ_ASSISTANTS: 'read:assistants', MANAGE_ASSISTANTS: 'manage:assistants', @@ -96,6 +98,7 @@ export const CapabilityImplications: Partial(); + for (const question of questions as IQuestionnaireQuestion[]) { + if (!question?.id || typeof question.id !== 'string') { + throw new QuestionnaireValidationError('Every question needs a string "id"'); + } + if (seen.has(question.id)) { + throw new QuestionnaireValidationError(`Duplicate question id "${question.id}"`); + } + seen.add(question.id); + + if (!QUESTION_TYPES.includes(question.type)) { + throw new QuestionnaireValidationError( + `Question "${question.id}" has invalid type "${question.type}". Must be one of: ${QUESTION_TYPES.join(', ')}`, + ); + } + if (!question.title || typeof question.title !== 'string') { + throw new QuestionnaireValidationError(`Question "${question.id}" is missing a "title"`); + } + if (question.type === 'single_choice' || question.type === 'multiple_choice') { + if (!Array.isArray(question.options) || question.options.length === 0) { + throw new QuestionnaireValidationError( + `Question "${question.id}" of type "${question.type}" needs a non-empty "options" array`, + ); + } + } + if ( + question.min != null && + question.max != null && + Number(question.min) >= Number(question.max) + ) { + throw new QuestionnaireValidationError( + `Question "${question.id}" needs "max" greater than "min"`, + ); + } + } + + return questions as IQuestionnaireQuestion[]; +} + +export function createQuestionnaireMethods(mongoose: typeof import('mongoose')): { + getActiveQuestionnaire: (user?: IUser | null) => Promise<{ + questionnaire: IQuestionnaire | null; + completed: boolean; + dismissedAt: Date | null; + }>; + submitQuestionnaireResponse: ( + userId: string, + questionnaireId: string, + answers: IQuestionnaireAnswer[], + ) => Promise; + dismissQuestionnaire: ( + userId: string, + questionnaireId: string, + ) => Promise; + listQuestionnaires: () => Promise; + getQuestionnaireById: (questionnaireId: string) => Promise; + saveQuestionnaire: ( + input: QuestionnaireInput, + questionnaireId?: string, + ) => Promise; + setQuestionnaireStatus: ( + questionnaireId: string, + status: QuestionnaireStatus, + ) => Promise; + duplicateQuestionnaire: (questionnaireId: string) => Promise; + deleteQuestionnaire: ( + questionnaireId: string, + options?: { force?: boolean }, + ) => Promise; +} { + async function getActiveQuestionnaire(user?: IUser | null): Promise<{ + questionnaire: IQuestionnaire | null; + completed: boolean; + dismissedAt: Date | null; + }> { + try { + const Questionnaire = mongoose.models.Questionnaire as Model; + const now = new Date(); + const questionnaire = (await Questionnaire.findOne({ + status: 'active', + displayFrom: { $lte: now }, + $or: [{ displayTo: { $gte: now } }, { displayTo: null }], + }) + .sort({ displayFrom: -1 }) + .lean()) as IQuestionnaire | null; + + if (!questionnaire || !user) { + return { questionnaire, completed: false, dismissedAt: null }; + } + + const userId = new mongoose.Types.ObjectId(user._id?.toString?.() ?? String(user._id)); + + const QuestionnaireResponse = mongoose.models + .QuestionnaireResponse as Model; + const QuestionnaireDismissal = mongoose.models + .QuestionnaireDismissal as Model; + + const [existingResponse, existingDismissal] = await Promise.all([ + QuestionnaireResponse.findOne({ + questionnaireId: questionnaire.questionnaireId, + user: userId, + }).lean(), + QuestionnaireDismissal.findOne({ + questionnaireId: questionnaire.questionnaireId, + user: userId, + }).lean(), + ]); + + return { + questionnaire, + completed: existingResponse != null, + dismissedAt: (existingDismissal as IQuestionnaireDismissal | null)?.dismissedAt ?? null, + }; + } catch (error) { + logger.error('[getActiveQuestionnaire] Error getting questionnaire', error); + throw new Error('Error getting questionnaire'); + } + } + + async function submitQuestionnaireResponse( + userId: string, + questionnaireId: string, + answers: IQuestionnaireAnswer[], + ): Promise { + const QuestionnaireResponse = mongoose.models + .QuestionnaireResponse as Model; + try { + return await QuestionnaireResponse.create({ + questionnaireId, + user: new mongoose.Types.ObjectId(userId), + answers, + submittedAt: new Date(), + }); + } catch (error) { + const mongoError = error as { code?: number }; + if (mongoError.code === 11000) { + throw new Error('You have already responded to this questionnaire'); + } + logger.error('[submitQuestionnaireResponse] Error submitting response', error); + throw new Error('Error submitting questionnaire response'); + } + } + + async function dismissQuestionnaire( + userId: string, + questionnaireId: string, + ): Promise { + const QuestionnaireDismissal = mongoose.models + .QuestionnaireDismissal as Model; + try { + return (await QuestionnaireDismissal.findOneAndUpdate( + { questionnaireId, user: new mongoose.Types.ObjectId(userId) }, + { $set: { dismissedAt: new Date() } }, + { new: true, upsert: true }, + )) as IQuestionnaireDismissal; + } catch (error) { + logger.error('[dismissQuestionnaire] Error dismissing questionnaire', error); + throw new Error('Error dismissing questionnaire'); + } + } + + function models() { + return { + Questionnaire: mongoose.models.Questionnaire as Model, + QuestionnaireResponse: mongoose.models.QuestionnaireResponse as Model, + QuestionnaireDismissal: mongoose.models + .QuestionnaireDismissal as Model, + }; + } + + async function closeOtherActive(keepId: string): Promise { + const { Questionnaire } = models(); + const result = await Questionnaire.updateMany( + { questionnaireId: { $ne: keepId }, status: 'active' }, + { $set: { status: 'closed' } }, + ); + return result.modifiedCount ?? 0; + } + + function normalize(input: QuestionnaireInput) { + if (!input?.title || typeof input.title !== 'string' || !input.title.trim()) { + throw new QuestionnaireValidationError('"title" is required'); + } + + const questions = validateQuestionnaireQuestions(input.questions); + const displayFrom = parseDate(input.displayFrom) ?? new Date(); + const displayTo = parseDate(input.displayTo); + + if (displayTo && displayTo <= displayFrom) { + throw new QuestionnaireValidationError('"displayTo" must be after "displayFrom"'); + } + + const repromptIntervalHours = input.repromptIntervalHours ?? 24; + if (!Number.isFinite(repromptIntervalHours) || repromptIntervalHours < 1) { + throw new QuestionnaireValidationError('"repromptIntervalHours" must be a positive number'); + } + + if (input.status && !STATUSES.includes(input.status)) { + throw new QuestionnaireValidationError(`"status" must be one of: ${STATUSES.join(', ')}`); + } + + const year = displayFrom.getFullYear(); + const quarter = Math.floor(displayFrom.getMonth() / 3) + 1; + + return { + label: input.label?.trim() || `Q${quarter} ${year}`, + title: input.title.trim(), + intro: input.intro?.trim() || undefined, + thankYouMessage: input.thankYouMessage?.trim() || undefined, + questions, + displayFrom, + displayTo, + repromptIntervalHours, + year, + quarter, + }; + } + + async function listQuestionnaires(): Promise { + try { + const { Questionnaire, QuestionnaireResponse, QuestionnaireDismissal } = models(); + const questionnaires = await Questionnaire.find() + .sort({ year: -1, quarter: -1, displayFrom: -1 }) + .lean(); + + if (questionnaires.length === 0) { + return []; + } + + const ids = questionnaires.map((q) => q.questionnaireId); + const [responses, dismissals] = await Promise.all([ + QuestionnaireResponse.aggregate<{ _id: string; count: number }>([ + { $match: { questionnaireId: { $in: ids } } }, + { $group: { _id: '$questionnaireId', count: { $sum: 1 } } }, + ]), + QuestionnaireDismissal.aggregate<{ _id: string; count: number }>([ + { $match: { questionnaireId: { $in: ids } } }, + { $group: { _id: '$questionnaireId', count: { $sum: 1 } } }, + ]), + ]); + + const responseCounts = new Map(responses.map((r) => [r._id, r.count])); + const dismissalCounts = new Map(dismissals.map((d) => [d._id, d.count])); + + return questionnaires.map((questionnaire) => ({ + questionnaireId: questionnaire.questionnaireId, + label: questionnaire.label, + status: questionnaire.status ?? 'draft', + year: questionnaire.year, + quarter: questionnaire.quarter, + title: questionnaire.title, + questionCount: questionnaire.questions?.length ?? 0, + displayFrom: questionnaire.displayFrom, + displayTo: questionnaire.displayTo ?? null, + repromptIntervalHours: questionnaire.repromptIntervalHours, + responseCount: responseCounts.get(questionnaire.questionnaireId) ?? 0, + dismissalCount: dismissalCounts.get(questionnaire.questionnaireId) ?? 0, + createdAt: (questionnaire as { createdAt?: Date }).createdAt, + updatedAt: (questionnaire as { updatedAt?: Date }).updatedAt, + })); + } catch (error) { + logger.error('[listQuestionnaires] Error listing questionnaires', error); + throw new Error('Error listing questionnaires'); + } + } + + async function getQuestionnaireById(questionnaireId: string): Promise { + const { Questionnaire } = models(); + return (await Questionnaire.findOne({ questionnaireId }).lean()) as IQuestionnaire | null; + } + + async function saveQuestionnaire( + input: QuestionnaireInput, + questionnaireId?: string, + ): Promise { + const { Questionnaire } = models(); + const data = normalize(input); + + const existing = questionnaireId + ? await Questionnaire.findOne({ questionnaireId }).lean() + : null; + if (questionnaireId && !existing) { + throw new QuestionnaireValidationError(`No questionnaire found with id "${questionnaireId}"`); + } + + const status = input.status ?? existing?.status ?? 'draft'; + const saved = existing + ? ((await Questionnaire.findOneAndUpdate( + { questionnaireId }, + { $set: { ...data, status } }, + { new: true }, + ).lean()) as IQuestionnaire) + : (( + await Questionnaire.create({ ...data, status, questionnaireId: randomUUID() }) + ).toObject() as IQuestionnaire); + + if (status === 'active') { + await closeOtherActive(saved.questionnaireId); + } + + return saved; + } + + async function setQuestionnaireStatus( + questionnaireId: string, + status: QuestionnaireStatus, + ): Promise { + if (!STATUSES.includes(status)) { + throw new QuestionnaireValidationError(`"status" must be one of: ${STATUSES.join(', ')}`); + } + + const { Questionnaire } = models(); + const updated = (await Questionnaire.findOneAndUpdate( + { questionnaireId }, + { $set: { status } }, + { new: true }, + ).lean()) as IQuestionnaire | null; + + if (updated && status === 'active') { + await closeOtherActive(questionnaireId); + } + + return updated; + } + + async function duplicateQuestionnaire(questionnaireId: string): Promise { + const { Questionnaire } = models(); + const source = await Questionnaire.findOne({ questionnaireId }).lean(); + if (!source) { + return null; + } + + const displayFrom = new Date(source.displayFrom); + displayFrom.setMonth(displayFrom.getMonth() + 3); + + let displayTo: Date | null = null; + if (source.displayTo) { + displayTo = new Date(source.displayTo); + displayTo.setMonth(displayTo.getMonth() + 3); + } + + const created = await Questionnaire.create({ + questionnaireId: randomUUID(), + label: `Q${Math.floor(displayFrom.getMonth() / 3) + 1} ${displayFrom.getFullYear()}`, + status: 'draft', + year: displayFrom.getFullYear(), + quarter: Math.floor(displayFrom.getMonth() / 3) + 1, + title: source.title, + intro: source.intro, + thankYouMessage: source.thankYouMessage, + questions: source.questions, + displayFrom, + displayTo, + repromptIntervalHours: source.repromptIntervalHours, + }); + + return created.toObject() as IQuestionnaire; + } + + async function deleteQuestionnaire( + questionnaireId: string, + options?: { force?: boolean }, + ): Promise { + const { Questionnaire, QuestionnaireResponse, QuestionnaireDismissal } = models(); + const force = options?.force === true; + + const responseCount = await QuestionnaireResponse.countDocuments({ questionnaireId }); + if (responseCount > 0 && !force) { + throw new QuestionnaireValidationError( + `This questionnaire has ${responseCount} response(s). Close it instead, or confirm deletion to discard them.`, + ); + } + + const deleted = (await Questionnaire.findOneAndDelete({ + questionnaireId, + }).lean()) as IQuestionnaire | null; + if (!deleted) { + return null; + } + + if (force) { + await Promise.all([ + QuestionnaireResponse.deleteMany({ questionnaireId }), + QuestionnaireDismissal.deleteMany({ questionnaireId }), + ]); + } + + return deleted; + } + + return { + getActiveQuestionnaire, + submitQuestionnaireResponse, + dismissQuestionnaire, + listQuestionnaires, + getQuestionnaireById, + saveQuestionnaire, + setQuestionnaireStatus, + duplicateQuestionnaire, + deleteQuestionnaire, + }; +} + +export type QuestionnaireMethods = ReturnType; diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index d309b041010..a15769e5a17 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -1,5 +1,7 @@ import { createAgentQueuedTurnModel, createAgentQueuedTurnSequenceModel } from './queuedTurn'; +import { createQuestionnaireDismissalModel } from './questionnaireDismissal'; import { createAgentTriggerLaneSequenceModel } from './triggerLaneSequence'; +import { createQuestionnaireResponseModel } from './questionnaireResponse'; import { createScheduleModel, createScheduleRunModel } from './schedule'; import { createSkillSyncCredentialModel } from './skillSyncCredential'; import { createOpenIDRefreshFlightModel } from './openidRefreshFlight'; @@ -10,6 +12,7 @@ import { createSkillSyncStatusModel } from './skillSyncStatus'; import { createConversationTagModel } from './conversationTag'; import { createCodeEnvironmentModel } from './codeEnvironment'; import { createAgentCategoryModel } from './agentCategory'; +import { createQuestionnaireModel } from './questionnaire'; import { createChatProjectModel } from './chatProject'; import { createAgentApiKeyModel } from './agentApiKey'; import { createTransactionModel } from './transaction'; @@ -66,6 +69,9 @@ export function createModels(mongoose: typeof import('mongoose')): { Assistant: ReturnType; File: ReturnType; Banner: ReturnType; + Questionnaire: ReturnType; + QuestionnaireResponse: ReturnType; + QuestionnaireDismissal: ReturnType; Key: ReturnType; PluginAuth: ReturnType; Transaction: ReturnType; @@ -115,6 +121,9 @@ export function createModels(mongoose: typeof import('mongoose')): { Assistant: createAssistantModel(mongoose), File: createFileModel(mongoose), Banner: createBannerModel(mongoose), + Questionnaire: createQuestionnaireModel(mongoose), + QuestionnaireResponse: createQuestionnaireResponseModel(mongoose), + QuestionnaireDismissal: createQuestionnaireDismissalModel(mongoose), Key: createKeyModel(mongoose), PluginAuth: createPluginAuthModel(mongoose), Transaction: createTransactionModel(mongoose), diff --git a/packages/data-schemas/src/models/questionnaire.ts b/packages/data-schemas/src/models/questionnaire.ts new file mode 100644 index 00000000000..dcb6b1248de --- /dev/null +++ b/packages/data-schemas/src/models/questionnaire.ts @@ -0,0 +1,14 @@ +import { Model } from 'mongoose'; +import type { IQuestionnaire } from '~/types'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; +import questionnaireSchema from '~/schema/questionnaire'; + +export function createQuestionnaireModel( + mongoose: typeof import('mongoose'), +): Model { + applyTenantIsolation(questionnaireSchema); + return ( + mongoose.models.Questionnaire || + mongoose.model('Questionnaire', questionnaireSchema) + ); +} diff --git a/packages/data-schemas/src/models/questionnaireDismissal.ts b/packages/data-schemas/src/models/questionnaireDismissal.ts new file mode 100644 index 00000000000..b9a4b6dd0fe --- /dev/null +++ b/packages/data-schemas/src/models/questionnaireDismissal.ts @@ -0,0 +1,14 @@ +import { Model } from 'mongoose'; +import type { IQuestionnaireDismissal } from '~/types'; +import questionnaireDismissalSchema from '~/schema/questionnaireDismissal'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; + +export function createQuestionnaireDismissalModel( + mongoose: typeof import('mongoose'), +): Model { + applyTenantIsolation(questionnaireDismissalSchema); + return ( + mongoose.models.QuestionnaireDismissal || + mongoose.model('QuestionnaireDismissal', questionnaireDismissalSchema) + ); +} diff --git a/packages/data-schemas/src/models/questionnaireResponse.ts b/packages/data-schemas/src/models/questionnaireResponse.ts new file mode 100644 index 00000000000..ee77a2d3b90 --- /dev/null +++ b/packages/data-schemas/src/models/questionnaireResponse.ts @@ -0,0 +1,14 @@ +import { Model } from 'mongoose'; +import type { IQuestionnaireResponse } from '~/types'; +import questionnaireResponseSchema from '~/schema/questionnaireResponse'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; + +export function createQuestionnaireResponseModel( + mongoose: typeof import('mongoose'), +): Model { + applyTenantIsolation(questionnaireResponseSchema); + return ( + mongoose.models.QuestionnaireResponse || + mongoose.model('QuestionnaireResponse', questionnaireResponseSchema) + ); +} diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts index 21f77ee052b..c9aca94796f 100644 --- a/packages/data-schemas/src/schema/index.ts +++ b/packages/data-schemas/src/schema/index.ts @@ -6,6 +6,9 @@ export { default as agentCategorySchema } from './agentCategory'; export { default as assistantSchema } from './assistant'; export { default as balanceSchema } from './balance'; export { default as bannerSchema } from './banner'; +export { default as questionnaireSchema } from './questionnaire'; +export { default as questionnaireResponseSchema } from './questionnaireResponse'; +export { default as questionnaireDismissalSchema } from './questionnaireDismissal'; export { default as categoriesSchema } from './categories'; export { default as chatProjectSchema } from './chatProject'; export { default as codeEnvironmentSchema } from './codeEnvironment'; diff --git a/packages/data-schemas/src/schema/questionnaire.ts b/packages/data-schemas/src/schema/questionnaire.ts new file mode 100644 index 00000000000..a6ae98eaaf1 --- /dev/null +++ b/packages/data-schemas/src/schema/questionnaire.ts @@ -0,0 +1,89 @@ +import { Schema } from 'mongoose'; +import type { IQuestionnaire, IQuestionnaireQuestion } from '~/types'; + +const questionSchema = new Schema( + { + id: { type: String, required: true }, + type: { + type: String, + enum: ['text', 'scale', 'numeric', 'single_choice', 'multiple_choice'], + required: true, + }, + title: { type: String, required: true }, + description: { type: String }, + required: { type: Boolean, default: false }, + section: { type: String }, + min: { type: Number }, + max: { type: Number }, + minLabel: { type: String }, + maxLabel: { type: String }, + options: { type: [String], default: undefined }, + maxSelections: { type: Number }, + }, + { _id: false }, +); + +const questionnaireSchema: Schema = new Schema( + { + questionnaireId: { + type: String, + required: true, + index: true, + }, + label: { + type: String, + }, + status: { + type: String, + enum: ['draft', 'active', 'closed'], + required: true, + default: 'draft', + index: true, + }, + year: { + type: Number, + }, + quarter: { + type: Number, + min: 1, + max: 4, + }, + title: { + type: String, + required: true, + }, + intro: { + type: String, + }, + thankYouMessage: { + type: String, + }, + questions: { + type: [questionSchema], + required: true, + default: [], + }, + displayFrom: { + type: Date, + required: true, + default: Date.now, + }, + displayTo: { + type: Date, + }, + repromptIntervalHours: { + type: Number, + required: true, + default: 24, + }, + tenantId: { + type: String, + index: true, + }, + }, + { timestamps: true }, +); + +questionnaireSchema.index({ status: 1, displayFrom: -1, tenantId: 1 }); + +export default questionnaireSchema; diff --git a/packages/data-schemas/src/schema/questionnaireDismissal.ts b/packages/data-schemas/src/schema/questionnaireDismissal.ts new file mode 100644 index 00000000000..8b15f7c3350 --- /dev/null +++ b/packages/data-schemas/src/schema/questionnaireDismissal.ts @@ -0,0 +1,34 @@ +import { Schema } from 'mongoose'; +import type { IQuestionnaireDismissal } from '~/types'; + +const questionnaireDismissalSchema: Schema = + new Schema( + { + questionnaireId: { + type: String, + required: true, + index: true, + }, + user: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + dismissedAt: { + type: Date, + required: true, + default: Date.now, + }, + tenantId: { + type: String, + index: true, + }, + }, + { timestamps: true }, + ); + +// One dismissal record per user per questionnaire; re-dismissing updates dismissedAt. +questionnaireDismissalSchema.index({ questionnaireId: 1, user: 1, tenantId: 1 }, { unique: true }); + +export default questionnaireDismissalSchema; diff --git a/packages/data-schemas/src/schema/questionnaireResponse.ts b/packages/data-schemas/src/schema/questionnaireResponse.ts new file mode 100644 index 00000000000..584ab1697df --- /dev/null +++ b/packages/data-schemas/src/schema/questionnaireResponse.ts @@ -0,0 +1,47 @@ +import { Schema } from 'mongoose'; +import type { IQuestionnaireResponse, IQuestionnaireAnswer } from '~/types'; + +const answerSchema = new Schema( + { + questionId: { type: String, required: true }, + value: { type: Schema.Types.Mixed, required: true }, + }, + { _id: false }, +); + +const questionnaireResponseSchema: Schema = + new Schema( + { + questionnaireId: { + type: String, + required: true, + index: true, + }, + user: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + answers: { + type: [answerSchema], + required: true, + default: [], + }, + submittedAt: { + type: Date, + required: true, + default: Date.now, + }, + tenantId: { + type: String, + index: true, + }, + }, + { timestamps: true }, + ); + +// A user may only answer a given questionnaire once. +questionnaireResponseSchema.index({ questionnaireId: 1, user: 1, tenantId: 1 }, { unique: true }); + +export default questionnaireResponseSchema; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 92ace38060f..d1b1d2b4b5b 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -13,6 +13,7 @@ export * from './chatProject'; export * from './session'; export * from './balance'; export * from './banner'; +export * from './questionnaire'; export * from './transaction'; export * from './message'; export * from './agent'; diff --git a/packages/data-schemas/src/types/questionnaire.ts b/packages/data-schemas/src/types/questionnaire.ts new file mode 100644 index 00000000000..2a7b88f2cf7 --- /dev/null +++ b/packages/data-schemas/src/types/questionnaire.ts @@ -0,0 +1,96 @@ +import type { Document, Types } from 'mongoose'; + +export type QuestionnaireQuestionType = + | 'text' + | 'scale' + | 'numeric' + | 'single_choice' + | 'multiple_choice'; + +export interface IQuestionnaireQuestion { + id: string; + type: QuestionnaireQuestionType; + title: string; + description?: string; + required?: boolean; + /** Consecutive questions sharing a section are grouped onto the same page. */ + section?: string; + /** scale/numeric */ + min?: number; + max?: number; + minLabel?: string; + maxLabel?: string; + /** single_choice/multiple_choice */ + options?: string[]; + maxSelections?: number; +} + +/** Only 'active' questionnaires are served to users. */ +export type QuestionnaireStatus = 'draft' | 'active' | 'closed'; + +export interface IQuestionnaire extends Document { + questionnaireId: string; + label?: string; + status: QuestionnaireStatus; + /** Derived from displayFrom on save. */ + year?: number; + quarter?: number; + title: string; + intro?: string; + thankYouMessage?: string; + questions: IQuestionnaireQuestion[]; + displayFrom: Date; + displayTo?: Date; + repromptIntervalHours: number; + tenantId?: string; +} + +/** `questionnaireId`/`year`/`quarter` are derived on save. */ +export interface QuestionnaireInput { + label?: string; + status?: QuestionnaireStatus; + title: string; + intro?: string; + thankYouMessage?: string; + questions: IQuestionnaireQuestion[]; + displayFrom?: Date | string | null; + displayTo?: Date | string | null; + repromptIntervalHours?: number; +} + +export interface QuestionnaireSummary { + questionnaireId: string; + label?: string; + status: QuestionnaireStatus; + year?: number; + quarter?: number; + title: string; + questionCount: number; + displayFrom: Date; + displayTo?: Date | null; + repromptIntervalHours: number; + responseCount: number; + dismissalCount: number; + createdAt?: Date; + updatedAt?: Date; +} + +export interface IQuestionnaireAnswer { + questionId: string; + value: string | number | string[]; +} + +export interface IQuestionnaireResponse extends Document { + questionnaireId: string; + user: Types.ObjectId; + answers: IQuestionnaireAnswer[]; + submittedAt: Date; + tenantId?: string; +} + +export interface IQuestionnaireDismissal extends Document { + questionnaireId: string; + user: Types.ObjectId; + dismissedAt: Date; + tenantId?: string; +} From 8cfbbf574bab3737f5a2229f512a861e946f1406 Mon Sep 17 00:00:00 2001 From: kenzaelk98 <254484110+kenzaelk98@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:36:01 +0200 Subject: [PATCH 2/4] feat: make questionnaire dismiss and confetti configurable --- api/server/routes/questionnaire.js | 3 + .../Questionnaire/QuestionnaireModal.tsx | 78 +++++++++++++------ client/src/locales/en/translation.json | 2 + config/questionnaire.example.json | 2 + config/update-questionnaire.js | 6 +- packages/api/src/admin/questionnaires.ts | 4 +- packages/data-provider/src/schemas.ts | 4 +- .../data-schemas/src/methods/questionnaire.ts | 30 +++++-- .../data-schemas/src/schema/questionnaire.ts | 9 ++- .../src/schema/questionnaireDismissal.ts | 1 - .../src/schema/questionnaireResponse.ts | 1 - .../data-schemas/src/types/questionnaire.ts | 18 ++--- 12 files changed, 116 insertions(+), 42 deletions(-) diff --git a/api/server/routes/questionnaire.js b/api/server/routes/questionnaire.js index c7bc331f0aa..24c9b6f7b7f 100644 --- a/api/server/routes/questionnaire.js +++ b/api/server/routes/questionnaire.js @@ -31,6 +31,9 @@ router.post('/dismiss', requireJwtAuth, async (req, res) => { if (!questionnaire || questionnaire.questionnaireId !== questionnaireId) { return res.status(409).json({ message: 'This questionnaire is no longer active' }); } + if (questionnaire.dismissible === false) { + return res.status(403).json({ message: 'This questionnaire cannot be dismissed' }); + } const dismissal = await dismissQuestionnaire(req.user.id, questionnaireId); res.status(200).json({ success: true, dismissedAt: dismissal.dismissedAt }); diff --git a/client/src/components/Questionnaire/QuestionnaireModal.tsx b/client/src/components/Questionnaire/QuestionnaireModal.tsx index 06e13d134ef..fc153a859cb 100644 --- a/client/src/components/Questionnaire/QuestionnaireModal.tsx +++ b/client/src/components/Questionnaire/QuestionnaireModal.tsx @@ -225,6 +225,9 @@ export default function QuestionnaireModal() { const questionnaire = data?.questionnaire ?? null; const completed = data?.completed ?? false; + const dismissible = questionnaire?.dismissible !== false; + const showConfetti = questionnaire?.showConfetti !== false; + const permanentDismiss = questionnaire?.repromptIntervalHours == null; const pages = useMemo( () => (questionnaire ? buildPages(questionnaire.questions) : []), @@ -238,8 +241,11 @@ export default function QuestionnaireModal() { if (!data?.dismissedAt) { return true; } + if (questionnaire.repromptIntervalHours == null) { + return false; + } const repromptMs = - (questionnaire.repromptIntervalHours ?? REPROMPT_FALLBACK_HOURS) * 60 * 60 * 1000; + (questionnaire.repromptIntervalHours || REPROMPT_FALLBACK_HOURS) * 60 * 60 * 1000; return Date.now() - new Date(data.dismissedAt).getTime() >= repromptMs; }, [questionnaire, completed, data?.dismissedAt]); @@ -253,7 +259,6 @@ export default function QuestionnaireModal() { } }, [shouldShow]); - /* Each page starts at the top rather than inheriting the previous page's scroll position. */ useEffect(() => { scrollRef.current?.scrollTo({ top: 0 }); }, [pageIndex]); @@ -261,11 +266,13 @@ export default function QuestionnaireModal() { const submitMutation = useSubmitQuestionnaireResponseMutation({ onSuccess: () => { setSubmitted(true); - confetti({ - particleCount: 150, - spread: 80, - origin: { y: 0.6 }, - }); + if (showConfetti) { + confetti({ + particleCount: 150, + spread: 80, + origin: { y: 0.6 }, + }); + } }, onError: () => { showToast({ message: localize('com_ui_questionnaire_submit_error'), status: 'error' }); @@ -278,9 +285,9 @@ export default function QuestionnaireModal() { return null; } - const repromptLabel = formatReprompt( - questionnaire.repromptIntervalHours ?? REPROMPT_FALLBACK_HOURS, - ); + const repromptLabel = permanentDismiss + ? null + : formatReprompt(questionnaire.repromptIntervalHours ?? REPROMPT_FALLBACK_HOURS); const currentPage = pages[Math.min(pageIndex, pages.length - 1)]; const isLastPage = pageIndex >= pages.length - 1; const requiredCount = questionnaire.questions.filter((question) => question.required).length; @@ -290,14 +297,33 @@ export default function QuestionnaireModal() { const progress = requiredCount > 0 ? answeredRequired / requiredCount : pageIndex / pages.length; const handleDismiss = () => { + if (!dismissible) { + return; + } setOpen(false); dismissMutation.mutate({ questionnaireId: questionnaire.questionnaireId }); showToast({ - message: localize('com_ui_questionnaire_dismissed_toast', { 0: repromptLabel }), + message: permanentDismiss + ? localize('com_ui_questionnaire_dismissed_permanent_toast') + : localize('com_ui_questionnaire_dismissed_toast', { 0: repromptLabel ?? '' }), status: 'info', }); }; + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + setOpen(true); + return; + } + if (submitted) { + setOpen(false); + return; + } + if (dismissible) { + handleDismiss(); + } + }; + const handleChange = (questionId: string, value: AnswerValue) => { setAnswers((prev) => ({ ...prev, [questionId]: value })); }; @@ -335,7 +361,7 @@ export default function QuestionnaireModal() { 'inline-flex h-10 items-center justify-center rounded-lg border border-border-medium bg-surface-secondary px-4 py-2 text-sm text-text-primary transition-colors hover:bg-surface-active focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary'; return ( - !isOpen && handleDismiss()}> + -
🎉
+ {showConfetti &&
🎉
}

{questionnaire.thankYouMessage || localize('com_ui_questionnaire_thank_you_default')} @@ -452,12 +478,18 @@ export default function QuestionnaireModal() { })}

-
- - -
+ {dismissible && ( +
+ + +
+ )} ) } @@ -468,9 +500,11 @@ export default function QuestionnaireModal() { ) : ( <> - + {dismissible && ( + + )} {pageIndex > 0 && (