233 lines
7.3 KiB
TypeScript
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.";
|
|
}
|