summaryrefslogtreecommitdiff
path: root/jb-ui/src/models
diff options
context:
space:
mode:
authorMax Nanis2026-02-18 20:42:03 -0500
committerMax Nanis2026-02-18 20:42:03 -0500
commit3eaa56f0306ead818f64c3d99fc6d230d9b970a4 (patch)
tree9fecc2f1456e6321572e0e65f57106916df173e2 /jb-ui/src/models
downloadamt-jb-3eaa56f0306ead818f64c3d99fc6d230d9b970a4.tar.gz
amt-jb-3eaa56f0306ead818f64c3d99fc6d230d9b970a4.zip
HERE WE GO, HERE WE GO, HERE WE GO
Diffstat (limited to 'jb-ui/src/models')
-rw-r--r--jb-ui/src/models/app.ts37
-rw-r--r--jb-ui/src/models/appSlice.ts188
-rw-r--r--jb-ui/src/models/grlStatsSlice.ts54
-rw-r--r--jb-ui/src/models/profilingQuestionsSlice.ts436
-rw-r--r--jb-ui/src/models/profilingUtils.ts177
5 files changed, 892 insertions, 0 deletions
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<string>) {
+ state.bpuid = action.payload;
+ },
+
+ setAssignmentID(state, action: PayloadAction<string>) {
+ // 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<string>) {
+ state.turkSubmitTo = action.payload;
+ },
+
+ setLOI(state, action: PayloadAction<number>) {
+ state.loi = action.payload;
+ },
+
+ setAvailabilityCount(state, action: PayloadAction<number>) {
+ state.availability_count = action.payload
+ },
+
+ setAttemptedLiveEligibleCount(state, action: PayloadAction<number>) {
+ state.attempted_live_eligible_count = action.payload
+ },
+
+ setCurrentBuckets(state, action: PayloadAction<TopNPlusBucket[] | undefined>) {
+ state.currentBucketRequested = moment.utc().unix();
+ state.currentBuckets = action.payload
+ },
+
+ setOfferwallReasons(state, action: PayloadAction<OfferwallReason[]>) {
+ 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<TaskStatusResponse>) {
+ state.taskStatus = action.payload;
+
+ state.bpuid = action.payload.product_user_id;
+ },
+
+ setUserWalletBalance(state, action: PayloadAction<UserWalletBalance>) {
+ state.userWalletBalance = action.payload;
+ },
+
+ setUserLedgerSummary(state, action: PayloadAction<UserLedgerTransactionTypesSummary>) {
+ state.userLedgerSummary = action.payload;
+ },
+
+ setUserLedgerTxs(state, action: PayloadAction<UserLedgerTransactionsResponseTransactionsInner[]>) {
+ // 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<PaginationState>) {
+ state.txPagination = action.payload;
+ },
+
+ setTxTotalItems(state, action: PayloadAction<number>) {
+ state.txTotalItems = action.payload;
+ },
+
+ setTxTotalPages(state, action: PayloadAction<number>) {
+ 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<number, string> = {
+ 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<StatsSnapshot>) {
+ 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<typeof profilingQuestionSlice.getInitialState>;
+
+
+// Helper function that contains the logic (outside the slice)
+function goToNextQuestionLogic(state: WritableDraft<ProfilingQuestionsState>) {
+ 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<ProfilingQuestionsState>,
+ 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<ProfilingQuestionsState>): 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<ProfileQuestion[]>) => {
+ setProfilingQuestionsLogic(state, action.payload);
+ },
+
+ metadataUpdated: (
+ state,
+ action: PayloadAction<{ question_id: string; metadata: Partial<UpkQuestionMetadata> }>
+ ) => {
+ 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<ValidationResult> }>
+ ) => {
+ 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<string>) => {
+ 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