From 3eaa56f0306ead818f64c3d99fc6d230d9b970a4 Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Wed, 18 Feb 2026 20:42:03 -0500 Subject: HERE WE GO, HERE WE GO, HERE WE GO --- jb-ui/src/models/app.ts | 37 +++ jb-ui/src/models/appSlice.ts | 188 ++++++++++++ jb-ui/src/models/grlStatsSlice.ts | 54 ++++ jb-ui/src/models/profilingQuestionsSlice.ts | 436 ++++++++++++++++++++++++++++ jb-ui/src/models/profilingUtils.ts | 177 +++++++++++ 5 files changed, 892 insertions(+) create mode 100644 jb-ui/src/models/app.ts create mode 100644 jb-ui/src/models/appSlice.ts create mode 100644 jb-ui/src/models/grlStatsSlice.ts create mode 100644 jb-ui/src/models/profilingQuestionsSlice.ts create mode 100644 jb-ui/src/models/profilingUtils.ts (limited to 'jb-ui/src/models') diff --git a/jb-ui/src/models/app.ts b/jb-ui/src/models/app.ts new file mode 100644 index 0000000..95b4c11 --- /dev/null +++ b/jb-ui/src/models/app.ts @@ -0,0 +1,37 @@ +import { + UserWalletBalance, TopNPlusBucket, TaskStatusResponse, + UserLedgerTransactionTypesSummary, + UserLedgerTransactionsResponseTransactionsInner, + OfferwallReason +} from "@/api_fsb"; +import { PaginationState } from '@tanstack/react-table'; + +export interface App { + // Global IDs needed by GRL + MTurk + bpuid?: string; + assignment_id?: string; + turkSubmitTo?: string; + + // Global UI settings + loi: number; + availability_count?: number; + attempted_live_eligible_count?: number; + offerwall_reasons: OfferwallReason[]; + + // The timestamp of when the currently requested bucket was requested. We want + // to save this to prevent it from sitting for longer than 120 seconds. + currentBucketRequested?: number; // Date.now() is a timestamp in ms + currentBuckets?: TopNPlusBucket[]; + currentBucketEntered?: number; // Date.now() is a timestamp in ms + taskStatus?: TaskStatusResponse; + + // Wallet stuff + userWalletBalance?: UserWalletBalance; + userLedgerSummary?: UserLedgerTransactionTypesSummary; + userLedgerTxCount?: number; + userLedgerTxs: UserLedgerTransactionsResponseTransactionsInner[]; + + txPagination: PaginationState; + txTotalItems?: number; + txTotalPages?: number; +} diff --git a/jb-ui/src/models/appSlice.ts b/jb-ui/src/models/appSlice.ts new file mode 100644 index 0000000..87952d9 --- /dev/null +++ b/jb-ui/src/models/appSlice.ts @@ -0,0 +1,188 @@ +import { + TaskStatusResponse, + TopNPlusBucket, + UserLedgerTransactionsResponseTransactionsInner, + UserLedgerTransactionTypesSummary, + UserWalletBalance, + OfferwallReason +} from "@/api_fsb"; +import { App } from "@/models/app"; +import { RootState } from "@/store"; +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { PaginationState } from '@tanstack/react-table'; +import moment from "moment"; + +const initialState: App = { + bpuid: undefined, + assignment_id: undefined, + loi: 1800, + currentBuckets: undefined, + currentBucketEntered: undefined, + taskStatus: undefined, + offerwall_reasons: [], + + userWalletBalance: undefined, + userLedgerSummary: undefined, + userLedgerTxCount: undefined, + userLedgerTxs: [], + + txPagination: { pageIndex: 0, pageSize: 10 }, + txTotalItems: undefined, + txTotalPages: undefined +} as App + +const appSlice = createSlice({ + name: 'app', + initialState, + reducers: { + + setProductUserID(state, action: PayloadAction) { + state.bpuid = action.payload; + }, + + setAssignmentID(state, action: PayloadAction) { + // This is really so silly. Amazon should simply not send + // anything if it's unavailable. + if (action.payload === "ASSIGNMENT_ID_NOT_AVAILABLE") { + state.assignment_id = undefined; + } + state.assignment_id = action.payload; + }, + + setTurkSubmitTo(state, action: PayloadAction) { + state.turkSubmitTo = action.payload; + }, + + setLOI(state, action: PayloadAction) { + state.loi = action.payload; + }, + + setAvailabilityCount(state, action: PayloadAction) { + state.availability_count = action.payload + }, + + setAttemptedLiveEligibleCount(state, action: PayloadAction) { + state.attempted_live_eligible_count = action.payload + }, + + setCurrentBuckets(state, action: PayloadAction) { + state.currentBucketRequested = moment.utc().unix(); + state.currentBuckets = action.payload + }, + + setOfferwallReasons(state, action: PayloadAction) { + state.offerwall_reasons = action.payload; + }, + + setEnteredTimestamp(state) { + // Go back by 2 seconds to account for any time drift + state.currentBucketEntered = moment.utc().unix() - 2; + }, + + setTaskStatus(state, action: PayloadAction) { + state.taskStatus = action.payload; + + state.bpuid = action.payload.product_user_id; + }, + + setUserWalletBalance(state, action: PayloadAction) { + state.userWalletBalance = action.payload; + }, + + setUserLedgerSummary(state, action: PayloadAction) { + state.userLedgerSummary = action.payload; + }, + + setUserLedgerTxs(state, action: PayloadAction) { + // We're not appending the transaction details, it's only going + // to reassign the current page of Transactions. + state.userLedgerTxs = action.payload; + }, + + setTxPagination(state, action: PayloadAction) { + state.txPagination = action.payload; + }, + + setTxTotalItems(state, action: PayloadAction) { + state.txTotalItems = action.payload; + }, + + setTxTotalPages(state, action: PayloadAction) { + state.txTotalPages = action.payload; + } + + } +}) + +export const { + setProductUserID, + setAssignmentID, + setTurkSubmitTo, + setLOI, + setAvailabilityCount, + setAttemptedLiveEligibleCount, + setOfferwallReasons, + setCurrentBuckets, + setEnteredTimestamp, + setTaskStatus, + + setUserWalletBalance, + + setUserLedgerSummary, + setUserLedgerTxs, + + setTxPagination, + setTxTotalItems, + setTxTotalPages +} = appSlice.actions; + +export default appSlice.reducer + +export const getLOIText = createSelector( + [(state: RootState) => state.app], + (app_config): string => { + + const lookup: Record = { + 600: "ten", + 1200: "twenty", + 1800: "thirty", + }; + + return lookup[app_config.loi] || " – "; + } +); + +export const getAvailabilityCount = createSelector( + [(state: RootState) => state.app], + (app_config): number | undefined => { + return app_config.availability_count; + } +); + +export const isLowBalance = createSelector( + [(state: RootState) => state.app], + (app_config): boolean => { + const bal_amt = app_config.userWalletBalance?.amount ?? 0 + return bal_amt <= -90; + } +); + + +export const selectBucket = createSelector( + [ + (state: RootState) => state.app.currentBuckets, + ], + (buckets): TopNPlusBucket | null => { + if (buckets && buckets.length >= 1) { + return buckets[0] + } + return null; + } +); + +export const getSurveyURL = createSelector( + [selectBucket], + (bucket): string | null => { + return bucket?.uri || null; + } +); \ No newline at end of file diff --git a/jb-ui/src/models/grlStatsSlice.ts b/jb-ui/src/models/grlStatsSlice.ts new file mode 100644 index 0000000..9385256 --- /dev/null +++ b/jb-ui/src/models/grlStatsSlice.ts @@ -0,0 +1,54 @@ +import { StatsSnapshot } from "@/api_fsb"; +import { RootState } from "@/store"; +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; + + +const initialState: StatsSnapshot[] = [] + +const grlStatsSlice = createSlice({ + name: 'grlStats', + initialState, + reducers: { + + addStatsData(state, action: PayloadAction) { + state.push(action.payload); + } + } +}) + +export const { + addStatsData +} = grlStatsSlice.actions; +export default grlStatsSlice.reducer + + +export const selectRecentStats = createSelector( + [ + (state: RootState) => state.stats, + ], + (stats): StatsSnapshot | null => { + const lastStat = stats[stats.length - 1]; + return lastStat ?? null; + } +); + +export const activeUsers = createSelector( + [selectRecentStats], + (recentStats): number | null => { + return recentStats?.active_users_last_24h ?? null; + } +); + +export const activeSurveys = createSelector( + [selectRecentStats], + (recentStats): number | null => { + return recentStats?.live_task_count?.total ?? null; + } +); + +export const maxPayout = createSelector( + [selectRecentStats], + (recentStats): number | null => { + return recentStats?.live_tasks_max_payout?.value ?? null; + } +); diff --git a/jb-ui/src/models/profilingQuestionsSlice.ts b/jb-ui/src/models/profilingQuestionsSlice.ts new file mode 100644 index 0000000..cc36f17 --- /dev/null +++ b/jb-ui/src/models/profilingQuestionsSlice.ts @@ -0,0 +1,436 @@ +import { + BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost, + ProfilingQuestionsApi, + StatusResponse, + UpkQuestion, UserQuestionAnswerIn +} from "@/api_fsb"; +import { assert, bpid } from "@/lib/utils"; +import { questionUtils } from "@/models/profilingUtils"; +import type { RootState } from '@/store'; +import { + createAsyncThunk, + createEntityAdapter, createSlice, + PayloadAction, + WritableDraft +} from '@reduxjs/toolkit'; + +interface UpkQuestionMetadata { + // The data we need to conduct profiling questions and manager user + // submission along with the User Interfaces + + // Only one Question can be active at a time, which is the one + // being shown to the Respondent + isActive: boolean; + + answers: string[]; + + // If it's actively being submitted to the server + isProcessing: boolean; + + // If we recieved a successful response from the server after submitting + isSaved: boolean; +} + +interface ValidationResult { + // If isValid or isComplete are undefined, that means the question + // hasn't been validated yet. This is different from false, which means + // it has been validated and is not valid. + isValid?: boolean; + isComplete?: boolean; + errors: ValidationError[]; +}; + +export interface ValidationError { + path: string; // e.g., "user.email" + message: string; + severity: 'error' | 'warning'; +}; + +export interface ProfileQuestion extends UpkQuestion { + _metadata: UpkQuestionMetadata; + _validation: ValidationResult; +} + +// Entity adapter for normalized storage +const questionsAdapter = createEntityAdapter({ + selectId: (model: ProfileQuestion) => model.question_id!, + sortComparer: (a, b) => { + return (b.importance?.task_score ?? 0) - (a.importance?.task_score ?? 0); + }, +}); + + +function getDefaultMetadata(): UpkQuestionMetadata { + return { + isActive: false, + answers: [], + isProcessing: false, + isSaved: false, + }; +} + +function getDefaultValidation(): ValidationResult { + + return { + isValid: undefined, + isComplete: undefined, + errors: [], + }; +} + +// Create the async thunk (outside your slice) +export const saveAnswer = createAsyncThunk( + 'profilingQuestions/saveAnswer', + async ({ questionId, bpuid }: { questionId: string; bpuid: string }, { getState, dispatch }) => { + const state = getState() as RootState; + const question = state.profilingQuestions.entities[questionId]; + + // Validations + const answers = { + 'question_id': question.question_id!, + 'answer': question._metadata.answers + } as UserQuestionAnswerIn; + + const answers_body = { + 'answers': [answers] + } as BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost; + + // Make API call + const res = await new ProfilingQuestionsApi() + .submitProfilingQuestionsProductIdProfilingQuestionsPost( + bpid, + bpuid, + answers_body, + undefined, + true + ); + + const response = res.data as StatusResponse; + + // Check if we need to fetch new questions BEFORE returning + if (response.status === "success" && isFirstNonGR(state.profilingQuestions)) { + // NOW dispatch it + dispatch(fetchNewQuestions(bpuid)); + } + + return { questionId, response }; + + } +) + +export const fetchNewQuestions = createAsyncThunk( + 'profilingQuestions/fetchNewQuestions', + async (bpuid: string) => { + + console.log("profilingQuestions/fetchNewQuestions") + const res = await new ProfilingQuestionsApi() + .getProfilingQuestionsProductIdProfilingQuestionsGet( + bpid, + bpuid, + undefined, // "104.9.125.144", // ip + undefined, // countryIso + undefined, // languageIso + 2_500 + ); + return res.data.questions; + } +); + +type ProfilingQuestionsState = ReturnType; + + +// Helper function that contains the logic (outside the slice) +function goToNextQuestionLogic(state: WritableDraft) { + const allQuestions = questionsAdapter.getSelectors().selectAll(state); + const currentIndex = allQuestions.findIndex(q => q._metadata.isActive); + + if (currentIndex === -1) return; + + const totalQuestions = allQuestions.length; + let searchIndex = (currentIndex + 1) % totalQuestions; + let iterations = 0; + + while (iterations < totalQuestions) { + if (!allQuestions[searchIndex]._metadata.isSaved) { + questionsAdapter.updateMany(state, [ + { + id: allQuestions[currentIndex].question_id!, + changes: { + _metadata: { + ...allQuestions[currentIndex]._metadata, + isActive: false + } + } + }, + { + id: allQuestions[searchIndex].question_id!, + changes: { + _metadata: { + ...allQuestions[searchIndex]._metadata, + isActive: true + } + } + } + ]); + return; + } + + searchIndex = (searchIndex + 1) % totalQuestions; + iterations++; + } +} + +function setProfilingQuestionsLogic( + state: WritableDraft, + questions: ProfileQuestion[]) { + + const hasActiveQuestion = Object.values(state.entities).some( + entity => entity?._metadata.isActive + ); + + const entities = questions.map((serverQuestion, index) => { + const existing = state.entities[serverQuestion.question_id!]; + const shouldActivate = !hasActiveQuestion && index === 0; + + return { + ...serverQuestion, + _metadata: existing?._metadata || { + ...getDefaultMetadata(), + isActive: shouldActivate, + }, + _validation: existing?._validation || getDefaultValidation(), + }; + }); + + questionsAdapter.setAll(state, entities); +} + + +function isGR(ext_question_id: string): boolean { + return ext_question_id.startsWith("gr:") +} + +function isFirstNonGR(state: WritableDraft): boolean { + // We want to identify if the next question is the first non-GR question + // because will want to trigger a full profile question refresh. + + const allQuestions = questionsAdapter.getSelectors().selectAll(state); + const currentIndex = allQuestions.findIndex(q => q._metadata.isActive); + + if (currentIndex === -1) false; + + const totalQuestions = allQuestions.length; + let searchIndex = (currentIndex + 1) % totalQuestions; + let iterations = 0; + + while (iterations < totalQuestions) { + if (!allQuestions[searchIndex]._metadata.isSaved) { + + const current = isGR(allQuestions[currentIndex].ext_question_id!) + const next = isGR(allQuestions[searchIndex].ext_question_id!) + + // We want to identify transitions from GR to non-GR questions, + // as we use GR questions to pre-calculate some non-GR questions, + // we should force a refresh. + return current && !next; + } + + searchIndex = (searchIndex + 1) % totalQuestions; + iterations++; + } + + return false; +} + +const profilingQuestionSlice = createSlice({ + name: 'profilingQuestions', + initialState: questionsAdapter.getInitialState(), + reducers: { + + setProfilingQuestions: (state, action: PayloadAction) => { + setProfilingQuestionsLogic(state, action.payload); + }, + + metadataUpdated: ( + state, + action: PayloadAction<{ question_id: string; metadata: Partial }> + ) => { + const { question_id, metadata } = action.payload; + const entity = state.entities[question_id]; + if (entity) { + entity._metadata = { ...entity._metadata, ...metadata }; + } + }, + + validationUpdated: ( + state, + action: PayloadAction<{ question_id: string; validation: Partial }> + ) => { + const { question_id, validation } = action.payload; + const entity = state.entities[question_id]; + if (entity) { + entity._validation = { ...entity._validation, ...validation }; + } + }, + + // Set one question as active, deactivate all others + setActiveQuestion: (state, action: PayloadAction) => { + const questionId = action.payload; + + // Deactivate all questions + Object.values(state.entities).forEach(entity => { + if (entity) { + entity._metadata.isActive = false; + } + }); + + // Activate the selected one + const entity = state.entities[questionId]; + if (entity) { + entity._metadata.isActive = true; + } + }, + + // Clear active state from all questions + clearActiveQuestion: (state) => { + Object.values(state.entities).forEach(entity => { + if (entity) { + entity._metadata.isActive = false; + } + }); + }, + + goToNextQuestion: (state) => { + goToNextQuestionLogic(state); + }, + + // ----------------------- + + addAnswers(state, action: PayloadAction<{ questionId: string, answers: string[] }>) { + /* When changing the answers in anyway, we want to: + 1. Add the new answers to the question metadata + + This does not perform validation, it simply reassigns + the new answers to the question's _metadata.answers + field based on the question type and selector. + + This "looses" the ability to track choice selection + sequence and timing which may be useful for MC questions + and security validation in the future. + + Validation is handled separately in the isValid + function. + + 2. Re-run validation to generate new ValidationError list + 3. Re-calculate the isComplete and isValid state + */ + + // let new_question = questionUtils.addAnswer(action.payload.question, action.payload.answers) + let entity = state.entities[action.payload.questionId]; + const newMetadata = { ...entity._metadata, answers: action.payload.answers }; + + if (entity) { + entity._metadata = newMetadata; + + entity = questionUtils.validate(entity) + entity = questionUtils.assignValidationState(entity) + + state.entities[action.payload.questionId] = entity + } + }, + }, + + extraReducers: (builder) => { + builder + .addCase(saveAnswer.pending, (state, action) => { + const questionId = action.meta.arg.questionId; + const bpuid = action.meta.arg.bpuid; + assert(bpuid, "Worker must be defined"); + + const question = state.entities[questionId]; + if (question) { + assert(question._validation.isComplete, "Must submit Completed Questions"); + assert(!question._metadata.isProcessing, "Can't submit processing Answer"); + assert(!question._metadata.isSaved, "Can't submit completed Answer"); + question._metadata.isProcessing = true; + } + }) + .addCase(saveAnswer.fulfilled, (state, action) => { + const { questionId, response } = action.payload; + const question = state.entities[questionId]; + + if (question) { + question._metadata.isProcessing = false; + if (response.status === "success") { + question._metadata.isSaved = true; + + // fetchNewQuestions is already being dispatched from + // the THUNK + goToNextQuestionLogic(state); + + } else { + question._metadata.isSaved = false; + } + } + }) + .addCase(saveAnswer.rejected, (state, action) => { + const questionId = action.meta.arg.questionId; + const question = state.entities[questionId]; + + if (question) { + question._metadata.isProcessing = false; + question._metadata.isSaved = false; + } + }) + .addCase(fetchNewQuestions.fulfilled, (state, action) => { + console.log(".addCase(fetchNewQuestions.fulfilled, (state, action) => {") + setProfilingQuestionsLogic(state, action.payload as ProfileQuestion[]); + }); + ; + } + +}) + +export const { + setProfilingQuestions, + metadataUpdated, + setActiveQuestion, + goToNextQuestion, + addAnswers, +} = profilingQuestionSlice.actions; + +// Selectors +export const questionsSelectors = questionsAdapter.getSelectors( + (state: RootState) => state.profilingQuestions +); + +// Custom selector to get the active question +export const selectAllQuestions = (state: RootState): ProfileQuestion[] => { + return questionsSelectors.selectAll(state) ?? []; +}; + +// Custom selector to get the active question +export const selectActiveQuestion = (state: RootState): ProfileQuestion | null => { + const allQuestions = questionsSelectors.selectAll(state); + return allQuestions.find(q => q._metadata.isActive) || null; +}; + +// Custom selector to get active question ID +export const selectActiveQuestionId = (state: RootState): string | null => { + const activeQuestion = selectActiveQuestion(state); + return activeQuestion?.question_id || null; +}; + +export const selectActiveQuestionMetadata = (state: RootState): UpkQuestionMetadata | null => { + const activeQuestion = selectActiveQuestion(state); + return activeQuestion?._metadata || null; +}; + +export const selectActiveQuestionValidation = (state: RootState): ValidationResult | null => { + const activeQuestion = selectActiveQuestion(state); + return activeQuestion?._validation || null; +}; + + +export default profilingQuestionSlice.reducer + diff --git a/jb-ui/src/models/profilingUtils.ts b/jb-ui/src/models/profilingUtils.ts new file mode 100644 index 0000000..104a5bb --- /dev/null +++ b/jb-ui/src/models/profilingUtils.ts @@ -0,0 +1,177 @@ +import { PatternValidation, UpkQuestionConfigurationMC, UpkQuestionConfigurationTE } from "@/api_fsb"; +import { assert } from "@/lib/utils"; +import { ProfileQuestion, ValidationError } from '@/models/profilingQuestionsSlice'; +import { Filter } from 'bad-words'; + +const filter = new Filter(); + +export const questionUtils = { + + assignValidationState: (question: ProfileQuestion): ProfileQuestion => { + /* This function performs validation on the question's current answers and + assigns any error messages to question._metadata.error_msg. + + If the question is MC, validate: + */ + + const errors = question._validation.errors ?? [] + + question._validation.isComplete = errors.length === 0 + question._validation.isValid = errors.filter(e => e.severity === 'error').length === 0 + + return question; + }, + + validate: (question: ProfileQuestion): ProfileQuestion => { + /* If the question is MC, validate: + + - validate selector SA vs MA (1 selected vs >1 selected) + - the answers match actual codes in the choices + - validate configuration.max_select + - validate choices.exclusive + + If the question is TE, validate that: + - configuration.max_length + - validation.patterns + */ + + assert(question.question_id, "Question must have question_id") + // assert(question.configuration, "Question must have configuration") + const answers = question._metadata.answers ?? [] + + // Declare it here, and they we'll reassign any specific + // configuration types based on the question type. + + // Start fresh without any error messages before validating + const errors: ValidationError[] = [] + + switch (question.question_type) { + case "TE": + + answers.length === 0 && errors.push({ + path: `question_id:${question.question_id}`, + message: "No answer provided", + severity: "warning", + } as ValidationError) + + answers.length > 1 && errors.push({ + path: `question_id:${question.question_id}`, + message: "Only one answer allowed", + severity: "error", + } as ValidationError) + + let answer_text: string = answers[0] + + const te_config = question.configuration as UpkQuestionConfigurationTE + const min_length = te_config.min_length ?? 0 + const max_length = te_config.max_length ?? 100_000 + + answer_text.length < min_length && errors.push({ + path: `question_id:${question.question_id}`, + message: "Answer shorter than allowed", + severity: "warning", + } as ValidationError) + + answer_text.length > max_length && errors.push({ + path: `question_id:${question.question_id}`, + message: "Answer longer than allowed", + severity: "error", + } as ValidationError) + + filter.isProfane(answer_text) && errors.push({ + path: `question_id:${question.question_id}`, + message: "Answer has inappropriate content", + severity: "error", + } as ValidationError) + + const patterns: PatternValidation[] = (question.validation ?? {})["patterns"] ?? [] + patterns.forEach((pv) => { + let re = new RegExp(pv.pattern) + answer_text.search(re) == -1 && errors.push({ + path: `question_id:${question.question_id}`, + message: pv.message, + severity: "error", + } as ValidationError) + + }) + + break; + + case "MC": + + answers.length === 0 && errors.push({ + path: `question_id:${question.question_id}`, + message: "Multiple Choice question with no selected answers", + severity: "warning", + } as ValidationError) + + const choice_codes: string[] = question.choices?.map((c) => c.choice_id) ?? []; + + const mc_config = (question.configuration ?? {}) as UpkQuestionConfigurationMC + const max_select = mc_config.max_select ?? choice_codes.length + + switch (question.selector) { + case "SA": + answers.length > 1 && errors.push({ + path: `question_id:${question.question_id}`, + message: "Single Answer MC question with >1 selected answers", + severity: "error", + } as ValidationError) + + break; + + case "MA": + answers.length > max_select && errors.push({ + path: `question_id:${question.question_id}`, + message: "More options selected than allowed", + severity: "error", + } as ValidationError) + + answers.length === max_select && errors.push({ + path: `question_id:${question.question_id}`, + message: "Selected all the options", + severity: "error", + } as ValidationError) + + break; + } + + // Now validate Multiple Choice answers regardless of if + // they're Single Answer (SA) or Multiple Answer (MA) + !answers.every(item => choice_codes.includes(item)) && errors.push({ + path: `question_id:${question.question_id}`, + message: "Invalid options selected", + severity: "error", + }) + + // const max_select: number = question.configuration?.max_select ?? choice_codes.length + // if (answer.values.length > max_select) { + // answer.error_msg = "More options selected than allowed" + // } + + /* + exclusive_choice = next((x for x in question["choices"] if x.get("exclusive")), None) + if exclusive_choice: + exclusive_choice_id = exclusive_choice["choice_id"] + assert answer == [exclusive_choice_id] or \ + exclusive_choice_id not in answer, "Invalid exclusive selection" + */ + + break; + + default: + errors.push({ + path: `question_id:${question.question_id}`, + message: "Incorrect Question Type provided", + severity: "error", + } as ValidationError) + } + + // Assign the errors back to the question's _validation field and + // then return the question + // TODO: does this edit in place, maybe I don't need to return it + question._validation.errors = errors + return question; + }, + +}; \ No newline at end of file -- cgit v1.2.3