feat:update form

This commit is contained in:
2026-07-28 00:02:34 +07:00 Unverified
parent 0e8c816276
commit fa35f00f38
25 changed files with 919 additions and 149 deletions
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test";
import { cdnImageResponse } from "@/lib/cdn-response";
import {
MAX_FORM_ANSWER_IMAGE_BYTES,
validateImageUploadFile,
} from "@/lib/cdn-images";
function fileLike(
overrides: Partial<Pick<File, "name" | "type" | "size">> = {}
): Pick<File, "name" | "type" | "size"> {
return {
name: "answer.png",
type: "image/png",
size: 1024,
...overrides,
};
}
describe("form answer image uploads", () => {
test("accepts supported images", () => {
expect(() => validateImageUploadFile(fileLike())).not.toThrow();
});
test("rejects non-images, invalid extensions, and over-50MB files", () => {
expect(() =>
validateImageUploadFile(fileLike({ type: "text/plain", name: "answer.txt" }))
).toThrow("Only PNG, JPG, WebP, and GIF images are allowed");
expect(() =>
validateImageUploadFile(fileLike({ name: "answer.svg" }))
).toThrow("Invalid image extension");
expect(() =>
validateImageUploadFile(fileLike({ size: MAX_FORM_ANSWER_IMAGE_BYTES + 1 }))
).toThrow("Image must be 50MB or smaller");
});
});
describe("CDN image responses", () => {
test("returns stored bytes with immutable image headers", async () => {
const response = cdnImageResponse({
data: Buffer.from([1, 2, 3]),
type: "image/png",
size: 3,
});
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(response.headers.get("Content-Length")).toBe("3");
expect(response.headers.get("Cache-Control")).toBe(
"public, max-age=31536000, immutable"
);
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect([...new Uint8Array(await response.arrayBuffer())]).toEqual([1, 2, 3]);
});
});
+29
View File
@@ -0,0 +1,29 @@
export const MAX_FORM_ANSWER_IMAGE_BYTES = 50 * 1024 * 1024;
const ALLOWED_IMAGE_MIME_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
]);
const ALLOWED_IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "gif"]);
export function validateImageUploadFile(file: Pick<File, "name" | "type" | "size">) {
if (!ALLOWED_IMAGE_MIME_TYPES.has(file.type)) {
throw new Error("Only PNG, JPG, WebP, and GIF images are allowed");
}
if (file.size > MAX_FORM_ANSWER_IMAGE_BYTES) {
throw new Error("Image must be 50MB or smaller");
}
const extension = (file.name.split(".").pop() || "").toLowerCase();
if (!ALLOWED_IMAGE_EXTENSIONS.has(extension)) {
throw new Error("Invalid image extension");
}
}
export function cdnUrl(id: string | null | undefined) {
return id ? `/cdn/${id}` : "";
}
+16
View File
@@ -0,0 +1,16 @@
export type CdnResponseRecord = {
data: Buffer;
type: string;
size: number;
};
export function cdnImageResponse(record: CdnResponseRecord) {
return new Response(new Uint8Array(record.data), {
headers: {
"Content-Type": record.type,
"Content-Length": String(record.size),
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
}
+6
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
answerCellValue,
decodeCheckboxAnswer,
encodeAnswerValue,
formatStoredAnswer,
@@ -23,3 +24,8 @@ describe("form answer values", () => {
expect(formatStoredAnswer('["plain text"]', "text")).toBe('["plain text"]');
});
});
test("formats CSV answer cells with image URLs", () => {
expect(answerCellValue("", "text", "img-1")).toBe("/cdn/img-1");
expect(answerCellValue("Details", "text", "img-1")).toBe("Details\n/cdn/img-1");
});
+16
View File
@@ -1,6 +1,11 @@
import type { QuestionType } from "@/db/schema/form";
export type FormAnswerValue = string | string[];
export type FormAnswerPayload = {
questionId: string;
value: FormAnswerValue;
imageCdnId?: string | null;
};
export function decodeCheckboxAnswer(value: FormAnswerValue | undefined): string[] {
if (Array.isArray(value)) {
@@ -39,3 +44,14 @@ export function formatStoredAnswer(
? decodeCheckboxAnswer(value).join(", ")
: value;
}
export function answerCellValue(
value: string | undefined,
questionType: QuestionType | string,
imageCdnId?: string | null,
) {
const text = formatStoredAnswer(value, questionType);
const imageUrl = imageCdnId ? `/cdn/${imageCdnId}` : "";
if (text && imageUrl) return `${text}\n${imageUrl}`;
return text || imageUrl;
}
+39 -3
View File
@@ -43,9 +43,45 @@ describe("Submission intake", () => {
]);
expect(normalized).toEqual([
{ questionId: "q-name", value: "Erika", displayValue: "Erika" },
{ questionId: "q-skill", value: "Custom skill", displayValue: "Custom skill" },
{ questionId: "q-tags", value: '["Alpha","Gamma"]', displayValue: "Alpha, Gamma" },
{ questionId: "q-name", value: "Erika", imageCdnId: null, displayValue: "Erika" },
{ questionId: "q-skill", value: "Custom skill", imageCdnId: null, displayValue: "Custom skill" },
{ questionId: "q-tags", value: '["Alpha","Gamma"]', imageCdnId: null, displayValue: "Alpha, Gamma" },
]);
});
test("validates required image-only and text plus image answers", () => {
const imageQuestions: SubmissionQuestion[] = [
{
id: "q-image",
type: "text",
label: "Screenshot",
required: false,
options: [],
allowOther: false,
imageAnswerMode: "required",
},
{
id: "q-text-image",
type: "textarea",
label: "Proof",
required: true,
options: [],
allowOther: false,
imageAnswerMode: "required",
},
];
expect(() => normalizeSubmissionAnswers(imageQuestions, [
{ questionId: "q-image", value: "" },
{ questionId: "q-text-image", value: "Details", imageCdnId: "img-2" },
])).toThrow("จำเป็นต้องอัปโหลดรูปสำหรับ “Screenshot”");
expect(normalizeSubmissionAnswers(imageQuestions, [
{ questionId: "q-image", value: "", imageCdnId: "img-1" },
{ questionId: "q-text-image", value: "Details", imageCdnId: "img-2" },
])).toEqual([
{ questionId: "q-image", value: "", imageCdnId: "img-1", displayValue: "" },
{ questionId: "q-text-image", value: "Details", imageCdnId: "img-2", displayValue: "Details" },
]);
});
+71 -12
View File
@@ -1,4 +1,4 @@
import type { QuestionType } from "@/db/schema/form";
import type { ImageAnswerMode, QuestionType } from "@/db/schema/form";
import {
decodeCheckboxAnswer,
encodeAnswerValue,
@@ -14,19 +14,32 @@ export type SubmissionQuestion = {
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);
}
@@ -51,6 +64,7 @@ function ensureSubmittedQuestionIdsBelongToForm(
function normalizeCheckboxAnswer(
question: SubmissionQuestion,
rawValue: FormAnswerValue | undefined,
imageCdnId: string | null,
): NormalizedSubmissionAnswer {
if (
rawValue !== undefined &&
@@ -77,6 +91,7 @@ function normalizeCheckboxAnswer(
return {
questionId: question.id,
value: encodeAnswerValue(selectedValues),
imageCdnId,
displayValue: selectedValues.join(", "),
};
}
@@ -84,6 +99,7 @@ function normalizeCheckboxAnswer(
function normalizeScalarAnswer(
question: SubmissionQuestion,
rawValue: FormAnswerValue | undefined,
imageCdnId: string | null,
): NormalizedSubmissionAnswer {
if (rawValue !== undefined && typeof rawValue !== "string") {
throw new Error(`รูปแบบคำตอบของ “${questionTitle(question)}” ไม่ถูกต้อง`);
@@ -108,10 +124,30 @@ function normalizeScalarAnswer(
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[],
@@ -119,17 +155,23 @@ export function normalizeSubmissionAnswers(
ensureSubmittedQuestionIdsBelongToForm(questions, answersList);
const submittedAnswers = new Map(
answersList.map((answer) => [answer.questionId, answer.value]),
answersList.map((answer) => [answer.questionId, answer]),
);
return questions.map((question) => {
const rawValue = submittedAnswers.get(question.id);
const submittedAnswer = submittedAnswers.get(question.id);
const rawValue = submittedAnswer?.value;
const imageCdnId = normalizeImageAnswer(question, submittedAnswer?.imageCdnId);
return question.type === "checkbox"
? normalizeCheckboxAnswer(question, rawValue)
: normalizeScalarAnswer(question, rawValue);
? 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[],
@@ -141,30 +183,47 @@ export function answersByTemplateIndex(
return normalizedAnswers.reduce<Record<string, string>>((acc, answer) => {
const index = questionOrderMap[answer.questionId];
if (index) acc[index] = answer.displayValue;
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: Record<string, string>,
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 oldValue = oldAnswersByQuestionId[answer.questionId];
const oldSnapshot = normalizeStoredAnswerSnapshot(oldAnswersByQuestionId[answer.questionId]);
const oldDisplayValue = question
? formatStoredAnswer(oldValue, question.type)
: oldValue;
? formatStoredAnswer(oldSnapshot.value, question.type)
: oldSnapshot.value;
const oldValue = oldDisplayValue || (oldSnapshot.imageCdnId ? `/cdn/${oldSnapshot.imageCdnId}` : "");
const newValue = displayAnswerValue(answer);
if (oldDisplayValue !== answer.displayValue) {
if (oldValue !== newValue) {
const title = question ? questionTitle(question) : "Question";
changed.push(
`**${title}**: \`${oldDisplayValue || "Empty"}\` ➡️ \`${answer.displayValue || "Empty"}\``,
`**${title}**: \`${oldValue || "Empty"}\` ➡️ \`${newValue || "Empty"}\``,
);
}
}
+18
View File
@@ -20,10 +20,28 @@ const answers: NormalizedSubmissionAnswer[] = [
{
questionId: "name",
value: "Erika",
imageCdnId: null,
displayValue: "Erika",
},
];
test("uses an image URL for image-only placeholders", () => {
expect(submissionWebhookMessage({
template: "Image: {1}",
isUpdate: false,
userName: "Gunshiz",
discordId: "123",
questions,
answers: [{
questionId: "name",
value: "",
imageCdnId: "img-1",
displayValue: "",
}],
oldAnswersByQuestionId: {},
})).toBe("Image: /cdn/img-1");
});
test("uses the configured template", () => {
expect(submissionWebhookMessage({
template: "New answer: {1} by {username} ({id})",
+2 -1
View File
@@ -2,6 +2,7 @@ import {
answersByTemplateIndex,
submissionUpdateText,
type NormalizedSubmissionAnswer,
type OldAnswersByQuestionId,
type SubmissionQuestion,
} from "@/lib/submission-intake";
@@ -15,7 +16,7 @@ type SubmissionWebhookMessageOptions = {
discordId: string;
questions: SubmissionQuestion[];
answers: NormalizedSubmissionAnswer[];
oldAnswersByQuestionId: Record<string, string>;
oldAnswersByQuestionId: OldAnswersByQuestionId;
};
export function submissionWebhookMessage({