Files
erika/lib/form/submission-intake.ts
T

233 lines
7.3 KiB
TypeScript

import type { ImageAnswerMode, QuestionType } from "@/db/schema/form";
import {
decodeCheckboxAnswer,
encodeAnswerValue,
formatStoredAnswer,
type FormAnswerValue,
} from "@/lib/form/form-answer-values";
import { stripHtml } from "@/lib/utils";
export type SubmissionQuestion = {
id: string;
type: QuestionType;
label: string;
required: boolean;
options?: string[] | null;
allowOther: boolean;
imageAnswerMode?: ImageAnswerMode;
};
export type SubmittedFormAnswer = {
questionId: string;
value: FormAnswerValue;
imageCdnId?: string | null;
};
export type NormalizedSubmissionAnswer = {
questionId: string;
value: string;
imageCdnId: string | null;
displayValue: string;
};
export type StoredAnswerSnapshot = {
value: string;
imageCdnId: string | null;
};
export type OldAnswersByQuestionId = Record<
string,
string | StoredAnswerSnapshot | undefined
>;
function questionTitle(question: Pick<SubmissionQuestion, "label">) {
return stripHtml(question.label);
}
function ensureSubmittedQuestionIdsBelongToForm(
questions: SubmissionQuestion[],
answersList: SubmittedFormAnswer[],
) {
const questionsById = new Map(questions.map((question) => [question.id, question]));
const submittedQuestionIds = new Set<string>();
for (const answer of answersList) {
if (!questionsById.has(answer.questionId) || submittedQuestionIds.has(answer.questionId)) {
throw new Error("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม");
}
submittedQuestionIds.add(answer.questionId);
}
return questionsById;
}
function normalizeCheckboxAnswer(
question: SubmissionQuestion,
rawValue: FormAnswerValue | undefined,
imageCdnId: string | null,
): NormalizedSubmissionAnswer {
if (
rawValue !== undefined &&
typeof rawValue !== "string" &&
(!Array.isArray(rawValue) || rawValue.some((value) => typeof value !== "string"))
) {
throw new Error(`รูปแบบคำตอบของ “${questionTitle(question)}” ไม่ถูกต้อง`);
}
const selectedValues = [...new Set(decodeCheckboxAnswer(rawValue))];
if (selectedValues.some((value) => !value.trim() || value === "__OTHER__")) {
throw new Error(`โปรดระบุคำตอบสำหรับ “${questionTitle(question)}”`);
}
if (question.required && selectedValues.length === 0) {
throw new Error(`จำเป็นต้องตอบ “${questionTitle(question)}”`);
}
const standardOptions = question.options ?? [];
const customValues = selectedValues.filter((value) => !standardOptions.includes(value));
if ((!question.allowOther && customValues.length > 0) || customValues.length > 1) {
throw new Error(`ตัวเลือกของ “${questionTitle(question)}” ไม่ถูกต้อง`);
}
return {
questionId: question.id,
value: encodeAnswerValue(selectedValues),
imageCdnId,
displayValue: selectedValues.join(", "),
};
}
function normalizeScalarAnswer(
question: SubmissionQuestion,
rawValue: FormAnswerValue | undefined,
imageCdnId: string | null,
): NormalizedSubmissionAnswer {
if (rawValue !== undefined && typeof rawValue !== "string") {
throw new Error(`รูปแบบคำตอบของ “${questionTitle(question)}” ไม่ถูกต้อง`);
}
const value = rawValue ?? "";
if (value === "__OTHER__") {
throw new Error(`โปรดระบุคำตอบสำหรับ “${questionTitle(question)}”`);
}
if (question.required && !value.trim()) {
throw new Error(`จำเป็นต้องตอบ “${questionTitle(question)}”`);
}
if (
question.type === "radio" &&
value &&
!(question.options ?? []).includes(value) &&
!question.allowOther
) {
throw new Error(`ตัวเลือกของ “${questionTitle(question)}” ไม่ถูกต้อง`);
}
return {
questionId: question.id,
value,
imageCdnId,
displayValue: value,
};
}
function normalizeImageAnswer(
question: SubmissionQuestion,
rawImageCdnId: string | null | undefined,
) {
const imageAnswerMode = question.imageAnswerMode ?? "none";
const imageCdnId = typeof rawImageCdnId === "string" && rawImageCdnId.trim()
? rawImageCdnId.trim()
: null;
if (imageAnswerMode === "none") {
return null;
}
if (imageAnswerMode === "required" && !imageCdnId) {
throw new Error(`จำเป็นต้องอัปโหลดรูปสำหรับ “${questionTitle(question)}”`);
}
return imageCdnId;
}
export function normalizeSubmissionAnswers(
questions: SubmissionQuestion[],
answersList: SubmittedFormAnswer[],
) {
ensureSubmittedQuestionIdsBelongToForm(questions, answersList);
const submittedAnswers = new Map(
answersList.map((answer) => [answer.questionId, answer]),
);
return questions.map((question) => {
const submittedAnswer = submittedAnswers.get(question.id);
const rawValue = submittedAnswer?.value;
const imageCdnId = normalizeImageAnswer(question, submittedAnswer?.imageCdnId);
return question.type === "checkbox"
? normalizeCheckboxAnswer(question, rawValue, imageCdnId)
: normalizeScalarAnswer(question, rawValue, imageCdnId);
});
}
export function displayAnswerValue(answer: Pick<NormalizedSubmissionAnswer, "displayValue" | "imageCdnId">) {
return answer.displayValue || (answer.imageCdnId ? `/cdn/${answer.imageCdnId}` : "");
}
export function answersByTemplateIndex(
questions: Pick<SubmissionQuestion, "id">[],
normalizedAnswers: NormalizedSubmissionAnswer[],
) {
const questionOrderMap = questions.reduce<Record<string, string>>((acc, question, index) => {
acc[question.id] = String(index + 1);
return acc;
}, {});
return normalizedAnswers.reduce<Record<string, string>>((acc, answer) => {
const index = questionOrderMap[answer.questionId];
if (index) acc[index] = displayAnswerValue(answer);
return acc;
}, {});
}
export function normalizeStoredAnswerSnapshot(
snapshot: string | StoredAnswerSnapshot | undefined,
): StoredAnswerSnapshot {
if (!snapshot) {
return { value: "", imageCdnId: null };
}
if (typeof snapshot === "string") {
return { value: snapshot, imageCdnId: null };
}
return {
value: snapshot.value ?? "",
imageCdnId: snapshot.imageCdnId ?? null,
};
}
export function submissionUpdateText(
questions: SubmissionQuestion[],
normalizedAnswers: NormalizedSubmissionAnswer[],
oldAnswersByQuestionId: OldAnswersByQuestionId,
) {
const questionsById = new Map(questions.map((question) => [question.id, question]));
const changed: string[] = [];
for (const answer of normalizedAnswers) {
const question = questionsById.get(answer.questionId);
const oldSnapshot = normalizeStoredAnswerSnapshot(oldAnswersByQuestionId[answer.questionId]);
const oldDisplayValue = question
? formatStoredAnswer(oldSnapshot.value, question.type)
: oldSnapshot.value;
const oldValue = oldDisplayValue || (oldSnapshot.imageCdnId ? `/cdn/${oldSnapshot.imageCdnId}` : "");
const newValue = displayAnswerValue(answer);
if (oldValue !== newValue) {
const title = question ? questionTitle(question) : "Question";
changed.push(
`**${title}**: \`${oldValue || "Empty"}\` ➡️ \`${newValue || "Empty"}\``,
);
}
}
return changed.length > 0 ? changed.join("\n") : "No changes made.";
}