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
+13 -2
View File
@@ -3,8 +3,8 @@
import { db } from "@/db";
import { unlink } from "fs/promises";
import path from "path";
import { questions } from "@/db/schema";
import type { QuestionType } from "@/db/schema";
import { cdn, questions } from "@/db/schema";
import type { ImageAnswerMode, QuestionType } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/auth";
@@ -55,6 +55,7 @@ export async function updateQuestion(
options: string[];
imageUrl: string | null;
allowOther: boolean;
imageAnswerMode: ImageAnswerMode;
}>
) {
await requireAdmin();
@@ -91,6 +92,7 @@ export async function bulkUpdateQuestions(
displayOrder: number;
options: string[];
allowOther: boolean;
imageAnswerMode: ImageAnswerMode;
}[]
) {
await requireAdmin();
@@ -107,6 +109,7 @@ export async function bulkUpdateQuestions(
displayOrder: u.displayOrder,
options: u.options ?? [],
allowOther: u.allowOther,
imageAnswerMode: u.imageAnswerMode,
})
.where(eq(questions.id, u.id))
)
@@ -123,6 +126,14 @@ export async function bulkUpdateQuestions(
async function deleteImageFile(imageUrl: string | null) {
if (!imageUrl) return;
if (imageUrl.startsWith("/cdn/")) {
const id = imageUrl.split("/").pop();
if (id) {
await db.delete(cdn).where(eq(cdn.id, id));
}
return;
}
try {
const filename = imageUrl.split("/").pop();
if (filename) {
+71 -18
View File
@@ -8,13 +8,17 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth";
import { sse } from "@/lib/sse";
import type { FormAnswerValue } from "@/lib/form-answer-values";
import { normalizeSubmissionAnswers } from "@/lib/submission-intake";
import type { FormAnswerPayload } from "@/lib/form-answer-values";
import {
normalizeSubmissionAnswers,
type StoredAnswerSnapshot,
} from "@/lib/submission-intake";
import { submissionWebhookMessage } from "@/lib/submission-webhook";
export async function submitForm(
formId: string,
answersList: { questionId: string; value: FormAnswerValue }[]
answersList: FormAnswerPayload[],
targetSubmissionId?: string | null
) {
const session = await getServerSession(authOptions);
if (!session?.user) throw new Error("Not authenticated");
@@ -33,17 +37,49 @@ export async function submitForm(
}
const normalizedAnswers = normalizeSubmissionAnswers(questionsList, answersList);
const imageCdnIds = [
...new Set(normalizedAnswers.map((answer) => answer.imageCdnId).filter(Boolean)),
] as string[];
if (imageCdnIds.length > 0) {
const ownedImages = await db.query.cdn.findMany({
where: (record, { and, eq, inArray }) =>
and(
inArray(record.id, imageCdnIds),
eq(record.ownerDiscordId, discordId)
),
columns: { id: true },
});
if (ownedImages.length !== imageCdnIds.length) {
throw new Error("ข้อมูลรูปภาพไม่ถูกต้อง");
}
}
const result = await db.transaction(async (tx) => {
const existingSubmission = await tx.query.submissions.findFirst({
where: (submission, { eq, and }) =>
and(eq(submission.formId, formId), eq(submission.userDiscordId, discordId)),
});
const existingSubmission = form.allowMultipleSubmissions
? targetSubmissionId
? await tx.query.submissions.findFirst({
where: (submission, { eq, and }) =>
and(
eq(submission.id, targetSubmissionId),
eq(submission.formId, formId),
eq(submission.userDiscordId, discordId)
),
})
: null
: await tx.query.submissions.findFirst({
where: (submission, { eq, and }) =>
and(eq(submission.formId, formId), eq(submission.userDiscordId, discordId)),
});
if (form.allowMultipleSubmissions && targetSubmissionId && !existingSubmission) {
throw new Error("ไม่พบคำตอบที่ต้องการแก้ไข");
}
let submissionId = "";
let submittedAt = new Date();
const isUpdate = Boolean(existingSubmission);
let oldAnswersDict: Record<string, string> = {};
let oldAnswersDict: Record<string, StoredAnswerSnapshot> = {};
if (existingSubmission) {
submissionId = existingSubmission.id;
@@ -53,8 +89,11 @@ export async function submitForm(
where: (answer, { eq }) => eq(answer.submissionId, submissionId),
});
oldAnswersDict = oldAnswersList.reduce<Record<string, string>>((acc, curr) => {
acc[curr.questionId] = curr.value;
oldAnswersDict = oldAnswersList.reduce<Record<string, StoredAnswerSnapshot>>((acc, curr) => {
acc[curr.questionId] = {
value: curr.value,
imageCdnId: curr.imageCdnId ?? null,
};
return acc;
}, {});
@@ -91,6 +130,7 @@ export async function submitForm(
submissionId,
questionId: answer.questionId,
value: answer.value,
imageCdnId: answer.imageCdnId,
}))
);
}
@@ -207,19 +247,32 @@ export async function deleteAllSubmissions(formId: string) {
return { ok: true };
}
export async function deleteOwnSubmission(formId: string) {
export async function deleteOwnSubmission(formId: string, submissionId?: string | null) {
const discordId = await requireDiscordId();
const { and } = await import("drizzle-orm");
const form = await requireFormAccess(formId, discordId);
if (form.allowMultipleSubmissions && !submissionId) {
throw new Error("ไม่พบคำตอบที่ต้องการลบ");
}
await db.delete(submissions).where(
and(
eq(submissions.formId, formId),
eq(submissions.userDiscordId, discordId)
)
);
const where = form.allowMultipleSubmissions
? and(
eq(submissions.id, submissionId!),
eq(submissions.formId, formId),
eq(submissions.userDiscordId, discordId)
)
: and(
eq(submissions.formId, formId),
eq(submissions.userDiscordId, discordId)
);
const deleted = await db.delete(submissions).where(where).returning({ id: submissions.id });
if (form.allowMultipleSubmissions && deleted.length === 0) {
throw new Error("ไม่พบคำตอบที่ต้องการลบ");
}
void sse.submissions.pub("update", {
formId,
submissionId: submissionId ?? undefined,
action: "deleted",
});
revalidatePath(`/form`);
+72 -12
View File
@@ -61,6 +61,12 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
interface Question {
id: string;
@@ -72,6 +78,7 @@ interface Question {
required: boolean;
options: string[] | null;
allowOther: boolean;
imageAnswerMode: "none" | "optional" | "required";
}
interface FormEditorClientProps {
@@ -80,6 +87,7 @@ interface FormEditorClientProps {
title: string;
description: string | null;
isOpen: boolean;
allowMultipleSubmissions: boolean;
};
initialQuestions: Question[];
}
@@ -89,6 +97,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const [formTitle, setFormTitle] = useState(form.title);
const [formDescription, setFormDescription] = useState(form.description || "");
const [formIsOpen, setFormIsOpen] = useState(form.isOpen);
const [allowMultipleSubmissions, setAllowMultipleSubmissions] = useState(
form.allowMultipleSubmissions
);
const [questions, setQuestions] = useState<Question[]>(initialQuestions);
const [uploadingId, setUploadingId] = useState<string | null>(null);
const [addingQuestion, setAddingQuestion] = useState(false);
@@ -97,14 +108,16 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const formTitleRef = useRef(formTitle);
const formDescriptionRef = useRef(formDescription);
const formIsOpenRef = useRef(formIsOpen);
const allowMultipleSubmissionsRef = useRef(allowMultipleSubmissions);
const questionsRef = useRef(questions);
useEffect(() => {
formTitleRef.current = formTitle;
formDescriptionRef.current = formDescription;
formIsOpenRef.current = formIsOpen;
allowMultipleSubmissionsRef.current = allowMultipleSubmissions;
questionsRef.current = questions;
}, [formDescription, formIsOpen, formTitle, questions]);
}, [allowMultipleSubmissions, formDescription, formIsOpen, formTitle, questions]);
const saveForm = useCallback(async () => {
const title = formTitleRef.current;
@@ -115,6 +128,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
title: title.trim(),
description: formDescriptionRef.current,
isOpen: formIsOpenRef.current,
allowMultipleSubmissions: allowMultipleSubmissionsRef.current,
});
router.refresh();
}, [form.id, router]);
@@ -125,6 +139,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
...q,
options: q.options === null ? [] : q.options,
allowOther: q.allowOther,
imageAnswerMode: q.imageAnswerMode,
}));
await bulkUpdateQuestions(form.id, formattedUpdates);
router.refresh();
@@ -154,6 +169,12 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
formAutoSave.schedule();
};
const handleAllowMultipleSubmissionsChange = (val: boolean) => {
allowMultipleSubmissionsRef.current = val;
setAllowMultipleSubmissions(val);
formAutoSave.schedule();
};
const handleAddQuestion = async () => {
setAddingQuestion(true);
try {
@@ -334,9 +355,10 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="form-title">Form Title</Label>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel htmlFor="form-title">Form Title</FieldLabel>
<Input
id="form-title"
value={formTitle}
@@ -347,26 +369,42 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
{!formTitle.trim() && (
<p className="text-sm text-destructive">Form title is required.</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="form-description">Description</Label>
</Field>
<Field>
<FieldLabel htmlFor="form-description">Description</FieldLabel>
<RichTextEditor
value={formDescription}
onChange={(html) => handleFormDescriptionChange(html)}
placeholder="Provide a description..."
className="min-h-20 w-full"
/>
</div>
<div className="flex items-center space-x-2 pt-2">
</Field>
<Field orientation="horizontal">
<Switch
id="form-is-open"
checked={formIsOpen}
onCheckedChange={handleFormIsOpenChange}
/>
<Label htmlFor="form-is-open" className="text-sm font-normal text-muted-foreground">
<FieldLabel htmlFor="form-is-open" className="font-normal">
Accepting Submissions
</Label>
</div>
</FieldLabel>
</Field>
<Field orientation="horizontal">
<Switch
id="allow-multiple-submissions"
checked={allowMultipleSubmissions}
onCheckedChange={handleAllowMultipleSubmissionsChange}
/>
<div className="flex flex-col gap-1">
<FieldLabel htmlFor="allow-multiple-submissions" className="font-normal">
Allow multiple submissions
</FieldLabel>
<FieldDescription>
Users can create separate responses instead of only editing their current response.
</FieldDescription>
</div>
</Field>
</FieldGroup>
</CardContent>
</Card>
@@ -566,6 +604,28 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
Required
</Label>
</div>
<Field className="w-full max-w-xs">
<FieldLabel>Answer image</FieldLabel>
<Select
value={q.imageAnswerMode}
onValueChange={(val) =>
handleUpdateQuestion(q.id, {
imageAnswerMode: val as Question["imageAnswerMode"],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="none">Off</SelectItem>
<SelectItem value="optional">Optional</SelectItem>
<SelectItem value="required">Required</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
{(q.type === "radio" || q.type === "checkbox") && (
@@ -26,6 +26,7 @@ import Image from 'next/image';
import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
import { formatStoredAnswer } from "@/lib/form-answer-values";
import { normalizeStoredAnswerSnapshot } from "@/lib/submission-intake";
interface Question {
id: string;
@@ -40,10 +41,10 @@ interface SubmissionDetailClientProps {
userName: string | null;
userDiscordId: string | null;
submittedAt: Date;
editHistory?: { editedAt: string; oldAnswers: Record<string, string> }[] | null;
editHistory?: { editedAt: string; oldAnswers: Record<string, string | { value: string; imageCdnId: string | null }> }[] | null;
};
questions: Question[];
answers: Record<string, string>;
answers: Record<string, { value: string; imageCdnId: string | null }>;
}
export default function SubmissionDetailClient({
@@ -103,16 +104,25 @@ export default function SubmissionDetailClient({
{/* Answers List */}
<div className="space-y-4">
{questions.map((q) => {
const rawAnswerValue = answers[q.id] || "";
const rawAnswer = answers[q.id] ?? { value: "", imageCdnId: null };
const rawAnswerValue = rawAnswer.value;
const answerValue = formatStoredAnswer(rawAnswerValue, q.type);
const imageUrl = rawAnswer.imageCdnId ? `/cdn/${rawAnswer.imageCdnId}` : "";
const wasEdited = editHistory.some(
(edit) => (edit.oldAnswers[q.id] ?? "") !== rawAnswerValue
(edit) => {
const oldSnapshot = normalizeStoredAnswerSnapshot(edit.oldAnswers[q.id]);
return oldSnapshot.value !== rawAnswerValue || oldSnapshot.imageCdnId !== rawAnswer.imageCdnId;
}
);
const qAnswerHistory = editHistory.map((edit) => ({
editedAt: edit.editedAt,
answer: formatStoredAnswer(edit.oldAnswers[q.id], q.type),
answer: (() => {
const snapshot = normalizeStoredAnswerSnapshot(edit.oldAnswers[q.id]);
const text = formatStoredAnswer(snapshot.value, q.type);
return text || (snapshot.imageCdnId ? `/cdn/${snapshot.imageCdnId}` : "");
})(),
}));
return (
@@ -171,8 +181,8 @@ export default function SubmissionDetailClient({
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
{answerValue ? (
<CardContent className="flex flex-col gap-4">
{answerValue && (
<div className="flex items-start justify-between gap-4 p-3 bg-muted rounded-md group">
<div className="flex flex-col min-w-0">
{wasEdited && (
@@ -198,7 +208,11 @@ export default function SubmissionDetailClient({
)}
</Button>
</div>
) : (
)}
{imageUrl && (
<AnswerImagePreview src={imageUrl} label={stripHtml(q.label)} />
)}
{!answerValue && !imageUrl && (
<span className="text-sm text-muted-foreground italic pl-3 border-l-2 border-muted block">
No response provided
</span>
@@ -255,3 +269,53 @@ export default function SubmissionDetailClient({
</div>
);
}
function AnswerImagePreview({ src, label }: { src: string; label: string }) {
const [unavailable, setUnavailable] = useState(false);
if (unavailable) {
return (
<span className="text-sm text-muted-foreground italic">
Image unavailable
</span>
);
}
return (
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
className="h-auto w-fit p-2"
aria-label={`Open image answer for ${label}`}
>
<Image
src={src}
alt=""
width={224}
height={160}
unoptimized
className="max-h-40 max-w-56 rounded object-contain"
onError={() => setUnavailable(true)}
/>
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Image answer</DialogTitle>
<DialogDescription>{label}</DialogDescription>
</DialogHeader>
<Image
src={src}
alt=""
width={1200}
height={800}
unoptimized
className="max-h-[70vh] w-full rounded-md object-contain"
onError={() => setUnavailable(true)}
/>
</DialogContent>
</Dialog>
);
}
@@ -47,9 +47,12 @@ export default async function SubmissionDetailPage({
where: (a, { eq }) => eq(a.submissionId, submission.id),
});
const answersMap: Record<string, string> = {};
const answersMap: Record<string, { value: string; imageCdnId: string | null }> = {};
for (const ans of answersList) {
answersMap[ans.questionId] = ans.value;
answersMap[ans.questionId] = {
value: ans.value,
imageCdnId: ans.imageCdnId ?? null,
};
}
const submissionsList = await db.query.submissions.findMany({
where: (s, { eq }) => eq(s.formId, formId),
+25 -6
View File
@@ -7,7 +7,7 @@ import {
getFilteredSubmissions,
parseResultFilters,
} from "@/lib/admin-form-results";
import { formatStoredAnswer } from "@/lib/form-answer-values";
import { answerCellValue } from "@/lib/form-answer-values";
import { stripHtml } from "@/lib/utils";
function csvCell(value: unknown) {
@@ -25,6 +25,24 @@ function safeFilename(value: string) {
);
}
function safeAsciiFilename(value: string) {
return (
value
.normalize("NFKD")
.replace(/[^\x00-\x7F]/g, "")
.replace(/[^A-Za-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 60) || "form"
);
}
function csvContentDisposition(title: string, date: string) {
const unicodeFilename = `${safeFilename(title)}-responses-${date}.csv`;
const asciiFilename = `${safeAsciiFilename(title)}-responses-${date}.csv`;
return `attachment; filename="${asciiFilename}"; filename*=UTF-8''${encodeURIComponent(unicodeFilename)}`;
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -59,7 +77,7 @@ export async function GET(
const answerMap = new Map(
answerRows.map((answer) => [
`${answer.submissionId}:${answer.questionId}`,
answer.value,
{ value: answer.value, imageCdnId: answer.imageCdnId ?? null },
])
);
const header = [
@@ -83,9 +101,10 @@ export async function GET(
? submission.editHistory.length
: 0,
...questions.map((question) =>
formatStoredAnswer(
answerMap.get(`${submission.id}:${question.id}`) || "",
question.type
answerCellValue(
answerMap.get(`${submission.id}:${question.id}`)?.value || "",
question.type,
answerMap.get(`${submission.id}:${question.id}`)?.imageCdnId
)
),
]
@@ -98,7 +117,7 @@ export async function GET(
return new NextResponse(`\uFEFF${lines.join("\r\n")}`, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${safeFilename(form.title)}-responses-${date}.csv"`,
"Content-Disposition": csvContentDisposition(form.title, date),
},
});
}
+1 -1
View File
@@ -73,7 +73,7 @@ export default async function ResultsListPage({
{total > 0 && <DeleteAllSubmissionsButton formId={id} />}
</div>
<ResultsFilters formId={id} filters={filters} />
<ResultsFilters key={listQuery} formId={id} filters={filters} />
{rows.length === 0 ? (
<Card>
+1 -1
View File
@@ -47,7 +47,7 @@ export async function createDefaultForm() {
redirect(`/admin/form/${form.id}`);
}
export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) {
export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; allowMultipleSubmissions?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) {
await requireAdmin();
if (data.title !== undefined && !data.title.trim()) {
throw new Error("Form title is required.");
+26 -32
View File
@@ -1,47 +1,41 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth";
const MAX_UPLOAD_BYTES = 1024 * 1024;
import { db } from "@/db";
import { cdn } from "@/db/schema";
import { requireDiscordId } from "@/lib/auth";
import { validateImageUploadFile, cdnUrl } from "@/lib/cdn-images";
export async function POST(req: Request) {
let discordId: string;
try {
try {
await requireAdmin();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
discordId = await requireDiscordId();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only image files are allowed" }, { status: 400 });
}
validateImageUploadFile(file);
if (file.size > MAX_UPLOAD_BYTES) {
return NextResponse.json({ error: "Image must be 1MB or smaller" }, { status: 400 });
}
const [record] = await db
.insert(cdn)
.values({
data: Buffer.from(await file.arrayBuffer()),
name: file.name || null,
type: file.type,
size: file.size,
ownerDiscordId: discordId,
})
.returning({ id: cdn.id });
const allowedExtensions = ["png", "jpg", "jpeg", "webp", "gif"];
const originalExt = (file.name.split(".").pop() || "").toLowerCase();
if (!allowedExtensions.includes(originalExt)) {
return NextResponse.json({ error: "Invalid image extension" }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const base64 = buffer.toString("base64");
const mimeType = file.type;
const dataUri = `data:${mimeType};base64,${base64}`;
return NextResponse.json({ url: dataUri });
return NextResponse.json({ id: record.id, url: cdnUrl(record.id) });
} catch (error) {
console.error("Upload error:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return NextResponse.json({ error: message }, { status: 400 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { db } from "@/db";
import { cdnImageResponse } from "@/lib/cdn-response";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const record = await db.query.cdn.findFirst({
where: (cdn, { eq }) => eq(cdn.id, id),
});
if (!record) {
return new Response("Not found", { status: 404 });
}
return cdnImageResponse(record);
}
+33 -8
View File
@@ -17,9 +17,10 @@ export const dynamic = "force-dynamic";
export default async function FormsSubmitPage(
props: {
params: Promise<{ id: string }>;
searchParams: Promise<{ submission?: string | string[] }>;
}
) {
const params = await props.params;
const [params, query] = await Promise.all([props.params, props.searchParams]);
const session = await getServerSession(authOptions);
const discordId = getSessionDiscordId(session);
@@ -44,10 +45,11 @@ export default async function FormsSubmitPage(
orderBy: (q, { asc }) => [asc(q.displayOrder)],
});
const [existingSubmission, submissionStats] = await Promise.all([
db.query.submissions.findFirst({
const [ownSubmissions, submissionStats] = await Promise.all([
db.query.submissions.findMany({
where: (s, { eq, and }) =>
and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)),
orderBy: (s, { desc }) => [desc(s.submittedAt)],
}),
getFormSubmissionStats([form.id], discordId),
]);
@@ -56,19 +58,36 @@ export default async function FormsSubmitPage(
queueNumber: null,
};
const initialAnswers: Record<string, FormAnswerValue> = {};
if (existingSubmission) {
const requestedSubmissionId = Array.isArray(query.submission)
? query.submission[0]
: query.submission;
const existingSubmission = form.allowMultipleSubmissions
? ownSubmissions.find((submission) => submission.id === requestedSubmissionId) ?? null
: ownSubmissions[0] ?? null;
const answersBySubmissionId: Record<
string,
Record<string, { value: FormAnswerValue; imageCdnId: string | null }>
> = {};
if (ownSubmissions.length > 0) {
const answersList = await db.query.answers.findMany({
where: (a, { eq }) => eq(a.submissionId, existingSubmission.id),
where: (a, { inArray }) => inArray(a.submissionId, ownSubmissions.map((submission) => submission.id)),
});
const questionTypes = new Map(questionsList.map((question) => [question.id, question.type]));
for (const answer of answersList) {
initialAnswers[answer.questionId] =
answersBySubmissionId[answer.submissionId] ??= {};
answersBySubmissionId[answer.submissionId][answer.questionId] = {
value:
questionTypes.get(answer.questionId) === "checkbox"
? decodeCheckboxAnswer(answer.value)
: answer.value;
: answer.value,
imageCdnId: answer.imageCdnId ?? null,
};
}
}
const initialAnswers = existingSubmission
? answersBySubmissionId[existingSubmission.id] ?? {}
: {};
return (
<>
@@ -79,6 +98,12 @@ export default async function FormsSubmitPage(
questions={questionsList}
initialAnswers={initialAnswers}
hasSubmission={Boolean(existingSubmission)}
selectedSubmissionId={existingSubmission?.id ?? null}
ownSubmissions={ownSubmissions.map((submission) => ({
id: submission.id,
submittedAt: submission.submittedAt.toISOString(),
answers: answersBySubmissionId[submission.id] ?? {},
}))}
totalAnswers={stats.totalAnswers}
queueNumber={stats.queueNumber}
/>
+260 -14
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import React, { useRef, useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import {
Card,
@@ -92,18 +92,31 @@ interface Question {
required: boolean;
options: string[] | null;
allowOther: boolean;
imageAnswerMode: "none" | "optional" | "required";
}
type AnswerDraft = {
value: FormAnswerValue;
imageCdnId: string | null;
};
interface FormFillerClientProps {
form: {
id: string;
title: string;
description: string | null;
isOpen: boolean;
allowMultipleSubmissions: boolean;
};
questions: Question[];
initialAnswers?: Record<string, FormAnswerValue>;
initialAnswers?: Record<string, AnswerDraft>;
hasSubmission?: boolean;
selectedSubmissionId?: string | null;
ownSubmissions?: {
id: string;
submittedAt: string;
answers: Record<string, AnswerDraft>;
}[];
totalAnswers: number;
queueNumber: number | null;
preview?: boolean;
@@ -115,6 +128,7 @@ type StoredDraft = {
version: 1;
answers: FormValues;
otherValues: Record<string, string>;
imageAnswers?: Record<string, string | null>;
};
const buildSchema = (questions: Question[]) => {
@@ -133,16 +147,26 @@ const buildSchema = (questions: Question[]) => {
return z.object(shape);
};
function isImageOnlyQuestion(question: Question) {
return (
question.imageAnswerMode === "required" &&
!question.required &&
(question.type === "text" || question.type === "textarea")
);
}
function prepareInitialState(
questions: Question[],
answers: Record<string, FormAnswerValue | undefined>,
answers: Record<string, AnswerDraft | undefined>,
savedOtherValues: Record<string, string> = {}
) {
const values: FormValues = {};
const imageAnswers: Record<string, string | null> = {};
const otherValues = { ...savedOtherValues };
questions.forEach((question) => {
const answer = answers[question.id];
const answer = answers[question.id]?.value;
imageAnswers[question.id] = answers[question.id]?.imageCdnId ?? null;
if (question.type === "checkbox") {
let selected = decodeCheckboxAnswer(answer);
@@ -182,12 +206,13 @@ function prepareInitialState(
values[question.id] = typeof answer === "string" ? answer : "";
});
return { values, otherValues };
return { values, otherValues, imageAnswers };
}
function parseStoredDraft(raw: string): {
answers: FormValues;
otherValues: Record<string, string>;
imageAnswers: Record<string, string | null>;
} {
const parsed: unknown = JSON.parse(raw);
if (
@@ -201,12 +226,14 @@ function parseStoredDraft(raw: string): {
return {
answers: draft.answers ?? {},
otherValues: draft.otherValues ?? {},
imageAnswers: draft.imageAnswers ?? {},
};
}
return {
answers: (parsed as FormValues) ?? {},
otherValues: {},
imageAnswers: {},
};
}
@@ -215,12 +242,17 @@ export function FormFillerClient({
questions,
initialAnswers = {},
hasSubmission = false,
selectedSubmissionId: initialSelectedSubmissionId = null,
ownSubmissions = [],
totalAnswers: initialTotalAnswers,
queueNumber: initialQueueNumber,
preview = false,
}: FormFillerClientProps) {
const router = useRouter();
const isUpdating = hasSubmission;
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(
initialSelectedSubmissionId
);
const isUpdating = Boolean(selectedSubmissionId) || (!form.allowMultipleSubmissions && hasSubmission);
const formSchema = useMemo(() => buildSchema(questions), [questions]);
const serverInitialState = useMemo(
@@ -231,6 +263,12 @@ export function FormFillerClient({
const [otherValues, setOtherValues] = useState<Record<string, string>>(
serverInitialState.otherValues
);
const [imageAnswers, setImageAnswers] = useState<Record<string, string | null>>(
serverInitialState.imageAnswers
);
const [imageErrors, setImageErrors] = useState<Record<string, string | null>>({});
const imageInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const [uploadingAnswerId, setUploadingAnswerId] = useState<string | null>(null);
const [draftReady, setDraftReady] = useState(preview);
const [draftSaved, setDraftSaved] = useState(false);
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
@@ -270,11 +308,17 @@ export function FormFillerClient({
const draft = parseStoredDraft(raw);
const restored = prepareInitialState(
questions,
draft.answers,
Object.fromEntries(
Object.entries(draft.answers).map(([questionId, value]) => [
questionId,
{ value: value as FormAnswerValue, imageCdnId: draft.imageAnswers[questionId] ?? null },
])
),
draft.otherValues
);
reset(restored.values);
setOtherValues(restored.otherValues);
setImageAnswers(restored.imageAnswers);
setDraftSaved(true);
}
} catch {
@@ -297,13 +341,94 @@ export function FormFillerClient({
version: 1,
answers: getValues() as FormValues,
otherValues,
imageAnswers,
};
localStorage.setItem(LS_KEY(form.id), JSON.stringify(draft));
queueMicrotask(() => setDraftSaved(true));
} catch {
queueMicrotask(() => setDraftSaved(false));
}
}, [draftReady, form.id, form.isOpen, getValues, otherValues, preview, watchedValues]);
}, [draftReady, form.id, form.isOpen, getValues, imageAnswers, otherValues, preview, watchedValues]);
const switchToSubmission = (submissionId: string | null) => {
const submission = submissionId
? ownSubmissions.find((item) => item.id === submissionId)
: null;
const nextState = prepareInitialState(questions, submission?.answers ?? {});
reset(nextState.values);
setOtherValues(nextState.otherValues);
setImageAnswers(nextState.imageAnswers);
setImageErrors({});
Object.values(imageInputRefs.current).forEach((input) => {
if (input) input.value = "";
});
setSelectedSubmissionId(submissionId);
setDraftSaved(false);
};
const clearAnswerImage = (questionId: string) => {
setImageAnswers((current) => ({
...current,
[questionId]: null,
}));
setImageErrors((current) => ({
...current,
[questionId]: null,
}));
const input = imageInputRefs.current[questionId];
if (input) input.value = "";
};
const validateRequiredImages = () => {
const nextErrors: Record<string, string | null> = {};
let firstInvalidId: string | null = null;
for (const question of questions) {
if (question.imageAnswerMode === "required" && !imageAnswers[question.id]) {
nextErrors[question.id] = "จำเป็นต้องอัปโหลดรูปสำหรับข้อนี้";
firstInvalidId ??= question.id;
} else {
nextErrors[question.id] = null;
}
}
setImageErrors((current) => ({ ...current, ...nextErrors }));
if (firstInvalidId) {
document.getElementById(`answer-image-${firstInvalidId}`)?.focus();
return false;
}
return true;
};
const handleAnswerImageUpload = async (
questionId: string,
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
setUploadingAnswerId(questionId);
try {
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok || !data.id) {
throw new Error(data.error || "ไม่สามารถอัปโหลดรูปได้");
}
setImageAnswers((current) => ({ ...current, [questionId]: data.id }));
setImageErrors((current) => ({ ...current, [questionId]: null }));
toast.success("อัปโหลดรูปแล้ว");
} catch (error) {
toast.error(getErrorMessage(error, "ไม่สามารถอัปโหลดรูปได้"));
} finally {
setUploadingAnswerId(null);
}
};
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (preview) return;
@@ -328,6 +453,10 @@ export function FormFillerClient({
}
}
if (!validateRequiredImages()) {
return;
}
try {
const formattedAnswers = questions.map((q) => {
let val = values[q.id] as FormAnswerValue | undefined;
@@ -342,10 +471,11 @@ export function FormFillerClient({
return {
questionId: q.id,
value: val ?? (q.type === "checkbox" ? [] : ""),
imageCdnId: q.imageAnswerMode === "none" ? null : imageAnswers[q.id] ?? null,
};
});
const res = await submitForm(form.id, formattedAnswers);
const res = await submitForm(form.id, formattedAnswers, selectedSubmissionId);
if (res.ok) {
setTotalAnswers(res.totalAnswers);
setQueueNumber(res.queueNumber);
@@ -362,15 +492,21 @@ export function FormFillerClient({
}
};
const onInvalidSubmit = () => {
validateRequiredImages();
};
const handleDelete = async () => {
setIsDeleting(true);
try {
const res = await deleteOwnSubmission(form.id);
const res = await deleteOwnSubmission(form.id, selectedSubmissionId);
if (res.ok) {
toast.success("ลบคำตอบแล้ว");
localStorage.removeItem(LS_KEY(form.id));
reset({});
setOtherValues({});
setImageAnswers({});
setSelectedSubmissionId(null);
setDraftSaved(false);
setDeleteDialogOpen(false);
router.refresh();
@@ -405,7 +541,7 @@ export function FormFillerClient({
<span>{questions.length} คำถาม</span>
<span>{totalAnswers} คำตอบทั้งหมด</span>
{queueNumber !== null && (
<Badge variant="outline">คิวของคุณ #{queueNumber}</Badge>
<Badge variant="outline">ลำดับของคุณ #{queueNumber}</Badge>
)}
</CardDescription>
</CardHeader>
@@ -439,8 +575,39 @@ export function FormFillerClient({
</CardFooter>
</Card>
{form.allowMultipleSubmissions && ownSubmissions.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">คำตอบของคุณ</CardTitle>
<CardDescription>เลือกคำตอบเดิมเพื่อแก้ไข หรือเริ่มส่งคำตอบใหม่</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button
type="button"
variant={selectedSubmissionId ? "outline" : "default"}
onClick={() => switchToSubmission(null)}
>
คำตอบใหม่
</Button>
{ownSubmissions.map((submission, index) => (
<Button
key={submission.id}
type="button"
variant={selectedSubmissionId === submission.id ? "default" : "outline"}
onClick={() => switchToSubmission(submission.id)}
>
#{ownSubmissions.length - index} · {new Date(submission.submittedAt).toLocaleString("th-TH", {
timeZone: "Asia/Bangkok",
hour12: false,
})}
</Button>
))}
</CardContent>
</Card>
)}
<Form {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
<form onSubmit={handleSubmit(onSubmit, onInvalidSubmit)} className="flex flex-col gap-6">
{questions.length === 0 ? (
<Card>
<CardHeader>
@@ -519,7 +686,7 @@ export function FormFillerClient({
</CardHeader>
<CardContent>
<FormItem>
{q.type === "text" && (
{q.type === "text" && !isImageOnlyQuestion(q) && (
<Field data-invalid={fieldState.invalid || undefined}>
<FormControl
aria-describedby={
@@ -539,7 +706,7 @@ export function FormFillerClient({
</Field>
)}
{q.type === "textarea" && (
{q.type === "textarea" && !isImageOnlyQuestion(q) && (
<Field data-invalid={fieldState.invalid || undefined}>
<FormControl
aria-describedby={
@@ -753,6 +920,85 @@ export function FormFillerClient({
<FormMessage id={questionErrorId} />
</FieldSet>
)}
{q.imageAnswerMode !== "none" && (
<Field data-invalid={Boolean(imageErrors[q.id]) || undefined}>
<FieldLabel htmlFor={`answer-image-${q.id}`}>
รูปภาพคำตอบ
{q.imageAnswerMode === "required" && (
<Badge variant="destructive">จำเป็น</Badge>
)}
{q.imageAnswerMode === "optional" && (
<Badge variant="outline">ไม่บังคับ</Badge>
)}
</FieldLabel>
<div className="flex flex-wrap items-center gap-3">
<Input
id={`answer-image-${q.id}`}
ref={(input) => {
imageInputRefs.current[q.id] = input;
}}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
disabled={!form.isOpen || isSubmitting || uploadingAnswerId !== null}
onChange={(event) => handleAnswerImageUpload(q.id, event)}
className="max-w-sm"
aria-invalid={Boolean(imageErrors[q.id])}
aria-describedby={
imageErrors[q.id]
? `answer-image-error-${q.id}`
: undefined
}
/>
{uploadingAnswerId === q.id && (
<Badge variant="secondary">
<Spinner data-icon="inline-start" />
กำลังอัปโหลด
</Badge>
)}
{imageAnswers[q.id] && (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline">
ดูรูป
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogTitle>รูปภาพคำตอบ</DialogTitle>
<Image
src={`/cdn/${imageAnswers[q.id]}`}
alt=""
width={1200}
height={800}
unoptimized
className="max-h-[70vh] w-full rounded-md object-contain"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
</DialogContent>
</Dialog>
)}
{imageAnswers[q.id] && (
<Button
type="button"
variant="ghost"
onClick={() => clearAnswerImage(q.id)}
disabled={!form.isOpen || isSubmitting}
>
ลบรูป
</Button>
)}
</div>
{imageErrors[q.id] && (
<p
id={`answer-image-error-${q.id}`}
className="text-sm text-destructive"
>
{imageErrors[q.id]}
</p>
)}
</Field>
)}
</FormItem>
</CardContent>
</Card>
+1 -1
View File
@@ -55,7 +55,7 @@ export default async function FormsListingPage() {
</Badge>
{submissionStats.get(form.id)?.queueNumber != null && (
<Badge variant="outline">
คิวของคุณ #{submissionStats.get(form.id)?.queueNumber}
ลำดับของคุณ #{submissionStats.get(form.id)?.queueNumber}
</Badge>
)}
</div>
+28 -21
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -19,7 +19,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import type { ResultFilters } from "@/lib/admin-form-results";
import { Download, Search, X, CalendarDays } from "lucide-react";
import { Download, X, CalendarDays } from "lucide-react";
import { format, parseISO } from "date-fns";
function DateFilterPicker({
@@ -80,7 +80,7 @@ export function ResultsFilters({
const [values, setValues] = useState(filters);
const base = `/admin/form/${formId}/result`;
const apply = () => {
const filterQuery = useMemo(() => {
const params = new URLSearchParams();
if (values.q) params.set("q", values.q);
if (values.sort !== "submittedAt") params.set("sort", values.sort);
@@ -88,17 +88,31 @@ export function ResultsFilters({
if (values.edited !== "all") params.set("edited", values.edited);
if (values.dateFrom) params.set("dateFrom", values.dateFrom);
if (values.dateTo) params.set("dateTo", values.dateTo);
router.push(`${base}?${params}`);
};
return params.toString();
}, [values]);
const exportParams = new URLSearchParams({
q: values.q,
sort: values.sort,
direction: values.direction,
edited: values.edited,
dateFrom: values.dateFrom,
dateTo: values.dateTo,
});
const appliedQuery = useMemo(() => {
const params = new URLSearchParams();
if (filters.q) params.set("q", filters.q);
if (filters.sort !== "submittedAt") params.set("sort", filters.sort);
if (filters.direction !== "desc") params.set("direction", filters.direction);
if (filters.edited !== "all") params.set("edited", filters.edited);
if (filters.dateFrom) params.set("dateFrom", filters.dateFrom);
if (filters.dateTo) params.set("dateTo", filters.dateTo);
return params.toString();
}, [filters]);
useEffect(() => {
if (filterQuery === appliedQuery) return;
const timeout = window.setTimeout(() => {
router.replace(`${base}${filterQuery ? `?${filterQuery}` : ""}`, {
scroll: false,
});
}, 350);
return () => window.clearTimeout(timeout);
}, [appliedQuery, base, filterQuery, router]);
return (
<div className="flex flex-col gap-3">
@@ -108,9 +122,6 @@ export function ResultsFilters({
onChange={(event) =>
setValues((current) => ({ ...current, q: event.target.value }))
}
onKeyDown={(event) => {
if (event.key === "Enter") apply();
}}
placeholder="Search name or Discord ID"
aria-label="Search responses"
/>
@@ -167,12 +178,8 @@ export function ResultsFilters({
setValues((current) => ({ ...current, dateTo }))
}
/>
<Button onClick={apply}>
<Search data-icon="inline-start" />
Apply
</Button>
<Button variant="outline" asChild>
<a href={`${base}/export?${exportParams}`}>
<a href={`${base}/export${filterQuery ? `?${filterQuery}` : ""}`}>
<Download data-icon="inline-start" />
CSV
</a>
+35 -6
View File
@@ -5,12 +5,23 @@ import {
boolean,
integer,
jsonb,
uniqueIndex,
index,
customType,
} from "drizzle-orm/pg-core";
import { uuidv7 } from "uuidv7";
export const formSchema = pgSchema("form");
export const bytea = customType<{
data: Buffer;
driverData: Buffer;
}>({
dataType() {
return "bytea";
},
fromDriver(value) {
return Buffer.isBuffer(value) ? value : Buffer.from(value);
},
});
export const forms = formSchema.table("form", {
id: text("id")
@@ -25,9 +36,11 @@ export const forms = formSchema.table("form", {
discordWebhookUpdateTemplate: text("discord_webhook_update_template"),
allowedRoles: jsonb("allowed_roles").$type<string[]>().default([]),
deniedRoles: jsonb("denied_roles").$type<string[]>().default([]),
allowMultipleSubmissions: boolean("allow_multiple_submissions").notNull().default(false),
});
export type QuestionType = "text" | "textarea" | "radio" | "checkbox";
export type ImageAnswerMode = "none" | "optional" | "required";
export const questions = formSchema.table("question", {
id: text("id")
@@ -43,6 +56,22 @@ export const questions = formSchema.table("question", {
required: boolean("required").notNull().default(false),
options: jsonb("options").$type<string[]>().default([]),
allowOther: boolean("allow_other").notNull().default(false),
imageAnswerMode: text("image_answer_mode")
.$type<ImageAnswerMode>()
.notNull()
.default("none"),
});
export const cdn = formSchema.table("cdn", {
id: text("id")
.primaryKey()
.$defaultFn(() => uuidv7()),
data: bytea("data").notNull(),
name: text("name"),
type: text("type").notNull(),
size: integer("size").notNull(),
ownerDiscordId: text("owner_discord_id"),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
});
export const submissions = formSchema.table(
@@ -58,14 +87,10 @@ export const submissions = formSchema.table(
userName: text("user_name"),
submittedAt: timestamp("submitted_at", { mode: "date" }).defaultNow().notNull(),
editHistory: jsonb("edit_history")
.$type<{ editedAt: string; oldAnswers: Record<string, string> }[]>()
.$type<{ editedAt: string; oldAnswers: Record<string, { value: string; imageCdnId: string | null }> }[]>()
.default([]),
},
(submission) => [
uniqueIndex("submission_form_user_discord_id_unique").on(
submission.formId,
submission.userDiscordId
),
index("submission_form_submitted_at_id_idx").on(
submission.formId,
submission.submittedAt,
@@ -85,4 +110,8 @@ export const answers = formSchema.table("answer", {
.notNull()
.references(() => questions.id, { onDelete: "cascade" }),
value: text("value").notNull().default(""),
imageCdnId: text("image_cdn_id").references(() => cdn.id, {
onUpdate: "cascade",
onDelete: "set null",
}),
});
+8 -1
View File
@@ -92,6 +92,13 @@
"when": 1785152117830,
"tag": "0012_clammy_the_stranger",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1785169071234,
"tag": "0013_regular_unus",
"breakpoints": true
}
]
}
}
+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({