feat:update form
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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}` : "";
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"}\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
Reference in New Issue
Block a user