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 { db } from "@/db";
import { unlink } from "fs/promises"; import { unlink } from "fs/promises";
import path from "path"; import path from "path";
import { questions } from "@/db/schema"; import { cdn, questions } from "@/db/schema";
import type { QuestionType } from "@/db/schema"; import type { ImageAnswerMode, QuestionType } from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/auth"; import { requireAdmin } from "@/lib/auth";
@@ -55,6 +55,7 @@ export async function updateQuestion(
options: string[]; options: string[];
imageUrl: string | null; imageUrl: string | null;
allowOther: boolean; allowOther: boolean;
imageAnswerMode: ImageAnswerMode;
}> }>
) { ) {
await requireAdmin(); await requireAdmin();
@@ -91,6 +92,7 @@ export async function bulkUpdateQuestions(
displayOrder: number; displayOrder: number;
options: string[]; options: string[];
allowOther: boolean; allowOther: boolean;
imageAnswerMode: ImageAnswerMode;
}[] }[]
) { ) {
await requireAdmin(); await requireAdmin();
@@ -107,6 +109,7 @@ export async function bulkUpdateQuestions(
displayOrder: u.displayOrder, displayOrder: u.displayOrder,
options: u.options ?? [], options: u.options ?? [],
allowOther: u.allowOther, allowOther: u.allowOther,
imageAnswerMode: u.imageAnswerMode,
}) })
.where(eq(questions.id, u.id)) .where(eq(questions.id, u.id))
) )
@@ -123,6 +126,14 @@ export async function bulkUpdateQuestions(
async function deleteImageFile(imageUrl: string | null) { async function deleteImageFile(imageUrl: string | null) {
if (!imageUrl) return; 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 { try {
const filename = imageUrl.split("/").pop(); const filename = imageUrl.split("/").pop();
if (filename) { if (filename) {
+71 -18
View File
@@ -8,13 +8,17 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options"; import { authOptions } from "@/lib/auth-options";
import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth"; import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth";
import { sse } from "@/lib/sse"; import { sse } from "@/lib/sse";
import type { FormAnswerValue } from "@/lib/form-answer-values"; import type { FormAnswerPayload } from "@/lib/form-answer-values";
import { normalizeSubmissionAnswers } from "@/lib/submission-intake"; import {
normalizeSubmissionAnswers,
type StoredAnswerSnapshot,
} from "@/lib/submission-intake";
import { submissionWebhookMessage } from "@/lib/submission-webhook"; import { submissionWebhookMessage } from "@/lib/submission-webhook";
export async function submitForm( export async function submitForm(
formId: string, formId: string,
answersList: { questionId: string; value: FormAnswerValue }[] answersList: FormAnswerPayload[],
targetSubmissionId?: string | null
) { ) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) throw new Error("Not authenticated"); if (!session?.user) throw new Error("Not authenticated");
@@ -33,17 +37,49 @@ export async function submitForm(
} }
const normalizedAnswers = normalizeSubmissionAnswers(questionsList, answersList); 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 result = await db.transaction(async (tx) => {
const existingSubmission = await tx.query.submissions.findFirst({ const existingSubmission = form.allowMultipleSubmissions
where: (submission, { eq, and }) => ? targetSubmissionId
and(eq(submission.formId, formId), eq(submission.userDiscordId, discordId)), ? 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 submissionId = "";
let submittedAt = new Date(); let submittedAt = new Date();
const isUpdate = Boolean(existingSubmission); const isUpdate = Boolean(existingSubmission);
let oldAnswersDict: Record<string, string> = {}; let oldAnswersDict: Record<string, StoredAnswerSnapshot> = {};
if (existingSubmission) { if (existingSubmission) {
submissionId = existingSubmission.id; submissionId = existingSubmission.id;
@@ -53,8 +89,11 @@ export async function submitForm(
where: (answer, { eq }) => eq(answer.submissionId, submissionId), where: (answer, { eq }) => eq(answer.submissionId, submissionId),
}); });
oldAnswersDict = oldAnswersList.reduce<Record<string, string>>((acc, curr) => { oldAnswersDict = oldAnswersList.reduce<Record<string, StoredAnswerSnapshot>>((acc, curr) => {
acc[curr.questionId] = curr.value; acc[curr.questionId] = {
value: curr.value,
imageCdnId: curr.imageCdnId ?? null,
};
return acc; return acc;
}, {}); }, {});
@@ -91,6 +130,7 @@ export async function submitForm(
submissionId, submissionId,
questionId: answer.questionId, questionId: answer.questionId,
value: answer.value, value: answer.value,
imageCdnId: answer.imageCdnId,
})) }))
); );
} }
@@ -207,19 +247,32 @@ export async function deleteAllSubmissions(formId: string) {
return { ok: true }; return { ok: true };
} }
export async function deleteOwnSubmission(formId: string) { export async function deleteOwnSubmission(formId: string, submissionId?: string | null) {
const discordId = await requireDiscordId(); 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( const where = form.allowMultipleSubmissions
and( ? and(
eq(submissions.formId, formId), eq(submissions.id, submissionId!),
eq(submissions.userDiscordId, discordId) 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", { void sse.submissions.pub("update", {
formId, formId,
submissionId: submissionId ?? undefined,
action: "deleted", action: "deleted",
}); });
revalidatePath(`/form`); revalidatePath(`/form`);
+72 -12
View File
@@ -61,6 +61,12 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
interface Question { interface Question {
id: string; id: string;
@@ -72,6 +78,7 @@ interface Question {
required: boolean; required: boolean;
options: string[] | null; options: string[] | null;
allowOther: boolean; allowOther: boolean;
imageAnswerMode: "none" | "optional" | "required";
} }
interface FormEditorClientProps { interface FormEditorClientProps {
@@ -80,6 +87,7 @@ interface FormEditorClientProps {
title: string; title: string;
description: string | null; description: string | null;
isOpen: boolean; isOpen: boolean;
allowMultipleSubmissions: boolean;
}; };
initialQuestions: Question[]; initialQuestions: Question[];
} }
@@ -89,6 +97,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const [formTitle, setFormTitle] = useState(form.title); const [formTitle, setFormTitle] = useState(form.title);
const [formDescription, setFormDescription] = useState(form.description || ""); const [formDescription, setFormDescription] = useState(form.description || "");
const [formIsOpen, setFormIsOpen] = useState(form.isOpen); const [formIsOpen, setFormIsOpen] = useState(form.isOpen);
const [allowMultipleSubmissions, setAllowMultipleSubmissions] = useState(
form.allowMultipleSubmissions
);
const [questions, setQuestions] = useState<Question[]>(initialQuestions); const [questions, setQuestions] = useState<Question[]>(initialQuestions);
const [uploadingId, setUploadingId] = useState<string | null>(null); const [uploadingId, setUploadingId] = useState<string | null>(null);
const [addingQuestion, setAddingQuestion] = useState(false); const [addingQuestion, setAddingQuestion] = useState(false);
@@ -97,14 +108,16 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const formTitleRef = useRef(formTitle); const formTitleRef = useRef(formTitle);
const formDescriptionRef = useRef(formDescription); const formDescriptionRef = useRef(formDescription);
const formIsOpenRef = useRef(formIsOpen); const formIsOpenRef = useRef(formIsOpen);
const allowMultipleSubmissionsRef = useRef(allowMultipleSubmissions);
const questionsRef = useRef(questions); const questionsRef = useRef(questions);
useEffect(() => { useEffect(() => {
formTitleRef.current = formTitle; formTitleRef.current = formTitle;
formDescriptionRef.current = formDescription; formDescriptionRef.current = formDescription;
formIsOpenRef.current = formIsOpen; formIsOpenRef.current = formIsOpen;
allowMultipleSubmissionsRef.current = allowMultipleSubmissions;
questionsRef.current = questions; questionsRef.current = questions;
}, [formDescription, formIsOpen, formTitle, questions]); }, [allowMultipleSubmissions, formDescription, formIsOpen, formTitle, questions]);
const saveForm = useCallback(async () => { const saveForm = useCallback(async () => {
const title = formTitleRef.current; const title = formTitleRef.current;
@@ -115,6 +128,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
title: title.trim(), title: title.trim(),
description: formDescriptionRef.current, description: formDescriptionRef.current,
isOpen: formIsOpenRef.current, isOpen: formIsOpenRef.current,
allowMultipleSubmissions: allowMultipleSubmissionsRef.current,
}); });
router.refresh(); router.refresh();
}, [form.id, router]); }, [form.id, router]);
@@ -125,6 +139,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
...q, ...q,
options: q.options === null ? [] : q.options, options: q.options === null ? [] : q.options,
allowOther: q.allowOther, allowOther: q.allowOther,
imageAnswerMode: q.imageAnswerMode,
})); }));
await bulkUpdateQuestions(form.id, formattedUpdates); await bulkUpdateQuestions(form.id, formattedUpdates);
router.refresh(); router.refresh();
@@ -154,6 +169,12 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
formAutoSave.schedule(); formAutoSave.schedule();
}; };
const handleAllowMultipleSubmissionsChange = (val: boolean) => {
allowMultipleSubmissionsRef.current = val;
setAllowMultipleSubmissions(val);
formAutoSave.schedule();
};
const handleAddQuestion = async () => { const handleAddQuestion = async () => {
setAddingQuestion(true); setAddingQuestion(true);
try { try {
@@ -334,9 +355,10 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
/> />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent>
<div className="space-y-2"> <FieldGroup>
<Label htmlFor="form-title">Form Title</Label> <Field>
<FieldLabel htmlFor="form-title">Form Title</FieldLabel>
<Input <Input
id="form-title" id="form-title"
value={formTitle} value={formTitle}
@@ -347,26 +369,42 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
{!formTitle.trim() && ( {!formTitle.trim() && (
<p className="text-sm text-destructive">Form title is required.</p> <p className="text-sm text-destructive">Form title is required.</p>
)} )}
</div> </Field>
<div className="space-y-2"> <Field>
<Label htmlFor="form-description">Description</Label> <FieldLabel htmlFor="form-description">Description</FieldLabel>
<RichTextEditor <RichTextEditor
value={formDescription} value={formDescription}
onChange={(html) => handleFormDescriptionChange(html)} onChange={(html) => handleFormDescriptionChange(html)}
placeholder="Provide a description..." placeholder="Provide a description..."
className="min-h-20 w-full" className="min-h-20 w-full"
/> />
</div> </Field>
<div className="flex items-center space-x-2 pt-2"> <Field orientation="horizontal">
<Switch <Switch
id="form-is-open" id="form-is-open"
checked={formIsOpen} checked={formIsOpen}
onCheckedChange={handleFormIsOpenChange} 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 Accepting Submissions
</Label> </FieldLabel>
</div> </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> </CardContent>
</Card> </Card>
@@ -566,6 +604,28 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
Required Required
</Label> </Label>
</div> </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> </div>
{(q.type === "radio" || q.type === "checkbox") && ( {(q.type === "radio" || q.type === "checkbox") && (
@@ -26,6 +26,7 @@ import Image from 'next/image';
import { stripHtml } from "@/lib/utils"; import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display"; import { HtmlDisplay } from "@/components/html-display";
import { formatStoredAnswer } from "@/lib/form-answer-values"; import { formatStoredAnswer } from "@/lib/form-answer-values";
import { normalizeStoredAnswerSnapshot } from "@/lib/submission-intake";
interface Question { interface Question {
id: string; id: string;
@@ -40,10 +41,10 @@ interface SubmissionDetailClientProps {
userName: string | null; userName: string | null;
userDiscordId: string | null; userDiscordId: string | null;
submittedAt: Date; 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[]; questions: Question[];
answers: Record<string, string>; answers: Record<string, { value: string; imageCdnId: string | null }>;
} }
export default function SubmissionDetailClient({ export default function SubmissionDetailClient({
@@ -103,16 +104,25 @@ export default function SubmissionDetailClient({
{/* Answers List */} {/* Answers List */}
<div className="space-y-4"> <div className="space-y-4">
{questions.map((q) => { {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 answerValue = formatStoredAnswer(rawAnswerValue, q.type);
const imageUrl = rawAnswer.imageCdnId ? `/cdn/${rawAnswer.imageCdnId}` : "";
const wasEdited = editHistory.some( 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) => ({ const qAnswerHistory = editHistory.map((edit) => ({
editedAt: edit.editedAt, 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 ( return (
@@ -171,8 +181,8 @@ export default function SubmissionDetailClient({
</div> </div>
)} )}
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="flex flex-col gap-4">
{answerValue ? ( {answerValue && (
<div className="flex items-start justify-between gap-4 p-3 bg-muted rounded-md group"> <div className="flex items-start justify-between gap-4 p-3 bg-muted rounded-md group">
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
{wasEdited && ( {wasEdited && (
@@ -198,7 +208,11 @@ export default function SubmissionDetailClient({
)} )}
</Button> </Button>
</div> </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"> <span className="text-sm text-muted-foreground italic pl-3 border-l-2 border-muted block">
No response provided No response provided
</span> </span>
@@ -255,3 +269,53 @@ export default function SubmissionDetailClient({
</div> </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), 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) { 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({ const submissionsList = await db.query.submissions.findMany({
where: (s, { eq }) => eq(s.formId, formId), where: (s, { eq }) => eq(s.formId, formId),
+25 -6
View File
@@ -7,7 +7,7 @@ import {
getFilteredSubmissions, getFilteredSubmissions,
parseResultFilters, parseResultFilters,
} from "@/lib/admin-form-results"; } from "@/lib/admin-form-results";
import { formatStoredAnswer } from "@/lib/form-answer-values"; import { answerCellValue } from "@/lib/form-answer-values";
import { stripHtml } from "@/lib/utils"; import { stripHtml } from "@/lib/utils";
function csvCell(value: unknown) { 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( export async function GET(
request: Request, request: Request,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
@@ -59,7 +77,7 @@ export async function GET(
const answerMap = new Map( const answerMap = new Map(
answerRows.map((answer) => [ answerRows.map((answer) => [
`${answer.submissionId}:${answer.questionId}`, `${answer.submissionId}:${answer.questionId}`,
answer.value, { value: answer.value, imageCdnId: answer.imageCdnId ?? null },
]) ])
); );
const header = [ const header = [
@@ -83,9 +101,10 @@ export async function GET(
? submission.editHistory.length ? submission.editHistory.length
: 0, : 0,
...questions.map((question) => ...questions.map((question) =>
formatStoredAnswer( answerCellValue(
answerMap.get(`${submission.id}:${question.id}`) || "", answerMap.get(`${submission.id}:${question.id}`)?.value || "",
question.type 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")}`, { return new NextResponse(`\uFEFF${lines.join("\r\n")}`, {
headers: { headers: {
"Content-Type": "text/csv; charset=utf-8", "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} />} {total > 0 && <DeleteAllSubmissionsButton formId={id} />}
</div> </div>
<ResultsFilters formId={id} filters={filters} /> <ResultsFilters key={listQuery} formId={id} filters={filters} />
{rows.length === 0 ? ( {rows.length === 0 ? (
<Card> <Card>
+1 -1
View File
@@ -47,7 +47,7 @@ export async function createDefaultForm() {
redirect(`/admin/form/${form.id}`); 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(); await requireAdmin();
if (data.title !== undefined && !data.title.trim()) { if (data.title !== undefined && !data.title.trim()) {
throw new Error("Form title is required."); throw new Error("Form title is required.");
+26 -32
View File
@@ -1,47 +1,41 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth"; import { db } from "@/db";
import { cdn } from "@/db/schema";
const MAX_UPLOAD_BYTES = 1024 * 1024; import { requireDiscordId } from "@/lib/auth";
import { validateImageUploadFile, cdnUrl } from "@/lib/cdn-images";
export async function POST(req: Request) { export async function POST(req: Request) {
let discordId: string;
try { try {
try { discordId = await requireDiscordId();
await requireAdmin(); } catch {
} catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); }
}
try {
const formData = await req.formData(); const formData = await req.formData();
const file = formData.get("file") as File | null; const file = formData.get("file");
if (!file) { if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 }); return NextResponse.json({ error: "No file provided" }, { status: 400 });
} }
if (!file.type.startsWith("image/")) { validateImageUploadFile(file);
return NextResponse.json({ error: "Only image files are allowed" }, { status: 400 });
}
if (file.size > MAX_UPLOAD_BYTES) { const [record] = await db
return NextResponse.json({ error: "Image must be 1MB or smaller" }, { status: 400 }); .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"]; return NextResponse.json({ id: record.id, url: cdnUrl(record.id) });
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 });
} catch (error) { } catch (error) {
console.error("Upload error:", error);
const message = error instanceof Error ? error.message : "Internal server 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( export default async function FormsSubmitPage(
props: { props: {
params: Promise<{ id: string }>; 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 session = await getServerSession(authOptions);
const discordId = getSessionDiscordId(session); const discordId = getSessionDiscordId(session);
@@ -44,10 +45,11 @@ export default async function FormsSubmitPage(
orderBy: (q, { asc }) => [asc(q.displayOrder)], orderBy: (q, { asc }) => [asc(q.displayOrder)],
}); });
const [existingSubmission, submissionStats] = await Promise.all([ const [ownSubmissions, submissionStats] = await Promise.all([
db.query.submissions.findFirst({ db.query.submissions.findMany({
where: (s, { eq, and }) => where: (s, { eq, and }) =>
and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)), and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)),
orderBy: (s, { desc }) => [desc(s.submittedAt)],
}), }),
getFormSubmissionStats([form.id], discordId), getFormSubmissionStats([form.id], discordId),
]); ]);
@@ -56,19 +58,36 @@ export default async function FormsSubmitPage(
queueNumber: null, queueNumber: null,
}; };
const initialAnswers: Record<string, FormAnswerValue> = {}; const requestedSubmissionId = Array.isArray(query.submission)
if (existingSubmission) { ? 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({ 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])); const questionTypes = new Map(questionsList.map((question) => [question.id, question.type]));
for (const answer of answersList) { for (const answer of answersList) {
initialAnswers[answer.questionId] = answersBySubmissionId[answer.submissionId] ??= {};
answersBySubmissionId[answer.submissionId][answer.questionId] = {
value:
questionTypes.get(answer.questionId) === "checkbox" questionTypes.get(answer.questionId) === "checkbox"
? decodeCheckboxAnswer(answer.value) ? decodeCheckboxAnswer(answer.value)
: answer.value; : answer.value,
imageCdnId: answer.imageCdnId ?? null,
};
} }
} }
const initialAnswers = existingSubmission
? answersBySubmissionId[existingSubmission.id] ?? {}
: {};
return ( return (
<> <>
@@ -79,6 +98,12 @@ export default async function FormsSubmitPage(
questions={questionsList} questions={questionsList}
initialAnswers={initialAnswers} initialAnswers={initialAnswers}
hasSubmission={Boolean(existingSubmission)} 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} totalAnswers={stats.totalAnswers}
queueNumber={stats.queueNumber} queueNumber={stats.queueNumber}
/> />
+260 -14
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect, useMemo } from "react"; import React, { useRef, useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Card, Card,
@@ -92,18 +92,31 @@ interface Question {
required: boolean; required: boolean;
options: string[] | null; options: string[] | null;
allowOther: boolean; allowOther: boolean;
imageAnswerMode: "none" | "optional" | "required";
} }
type AnswerDraft = {
value: FormAnswerValue;
imageCdnId: string | null;
};
interface FormFillerClientProps { interface FormFillerClientProps {
form: { form: {
id: string; id: string;
title: string; title: string;
description: string | null; description: string | null;
isOpen: boolean; isOpen: boolean;
allowMultipleSubmissions: boolean;
}; };
questions: Question[]; questions: Question[];
initialAnswers?: Record<string, FormAnswerValue>; initialAnswers?: Record<string, AnswerDraft>;
hasSubmission?: boolean; hasSubmission?: boolean;
selectedSubmissionId?: string | null;
ownSubmissions?: {
id: string;
submittedAt: string;
answers: Record<string, AnswerDraft>;
}[];
totalAnswers: number; totalAnswers: number;
queueNumber: number | null; queueNumber: number | null;
preview?: boolean; preview?: boolean;
@@ -115,6 +128,7 @@ type StoredDraft = {
version: 1; version: 1;
answers: FormValues; answers: FormValues;
otherValues: Record<string, string>; otherValues: Record<string, string>;
imageAnswers?: Record<string, string | null>;
}; };
const buildSchema = (questions: Question[]) => { const buildSchema = (questions: Question[]) => {
@@ -133,16 +147,26 @@ const buildSchema = (questions: Question[]) => {
return z.object(shape); return z.object(shape);
}; };
function isImageOnlyQuestion(question: Question) {
return (
question.imageAnswerMode === "required" &&
!question.required &&
(question.type === "text" || question.type === "textarea")
);
}
function prepareInitialState( function prepareInitialState(
questions: Question[], questions: Question[],
answers: Record<string, FormAnswerValue | undefined>, answers: Record<string, AnswerDraft | undefined>,
savedOtherValues: Record<string, string> = {} savedOtherValues: Record<string, string> = {}
) { ) {
const values: FormValues = {}; const values: FormValues = {};
const imageAnswers: Record<string, string | null> = {};
const otherValues = { ...savedOtherValues }; const otherValues = { ...savedOtherValues };
questions.forEach((question) => { 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") { if (question.type === "checkbox") {
let selected = decodeCheckboxAnswer(answer); let selected = decodeCheckboxAnswer(answer);
@@ -182,12 +206,13 @@ function prepareInitialState(
values[question.id] = typeof answer === "string" ? answer : ""; values[question.id] = typeof answer === "string" ? answer : "";
}); });
return { values, otherValues }; return { values, otherValues, imageAnswers };
} }
function parseStoredDraft(raw: string): { function parseStoredDraft(raw: string): {
answers: FormValues; answers: FormValues;
otherValues: Record<string, string>; otherValues: Record<string, string>;
imageAnswers: Record<string, string | null>;
} { } {
const parsed: unknown = JSON.parse(raw); const parsed: unknown = JSON.parse(raw);
if ( if (
@@ -201,12 +226,14 @@ function parseStoredDraft(raw: string): {
return { return {
answers: draft.answers ?? {}, answers: draft.answers ?? {},
otherValues: draft.otherValues ?? {}, otherValues: draft.otherValues ?? {},
imageAnswers: draft.imageAnswers ?? {},
}; };
} }
return { return {
answers: (parsed as FormValues) ?? {}, answers: (parsed as FormValues) ?? {},
otherValues: {}, otherValues: {},
imageAnswers: {},
}; };
} }
@@ -215,12 +242,17 @@ export function FormFillerClient({
questions, questions,
initialAnswers = {}, initialAnswers = {},
hasSubmission = false, hasSubmission = false,
selectedSubmissionId: initialSelectedSubmissionId = null,
ownSubmissions = [],
totalAnswers: initialTotalAnswers, totalAnswers: initialTotalAnswers,
queueNumber: initialQueueNumber, queueNumber: initialQueueNumber,
preview = false, preview = false,
}: FormFillerClientProps) { }: FormFillerClientProps) {
const router = useRouter(); 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 formSchema = useMemo(() => buildSchema(questions), [questions]);
const serverInitialState = useMemo( const serverInitialState = useMemo(
@@ -231,6 +263,12 @@ export function FormFillerClient({
const [otherValues, setOtherValues] = useState<Record<string, string>>( const [otherValues, setOtherValues] = useState<Record<string, string>>(
serverInitialState.otherValues 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 [draftReady, setDraftReady] = useState(preview);
const [draftSaved, setDraftSaved] = useState(false); const [draftSaved, setDraftSaved] = useState(false);
const [showSuccessDialog, setShowSuccessDialog] = useState(false); const [showSuccessDialog, setShowSuccessDialog] = useState(false);
@@ -270,11 +308,17 @@ export function FormFillerClient({
const draft = parseStoredDraft(raw); const draft = parseStoredDraft(raw);
const restored = prepareInitialState( const restored = prepareInitialState(
questions, questions,
draft.answers, Object.fromEntries(
Object.entries(draft.answers).map(([questionId, value]) => [
questionId,
{ value: value as FormAnswerValue, imageCdnId: draft.imageAnswers[questionId] ?? null },
])
),
draft.otherValues draft.otherValues
); );
reset(restored.values); reset(restored.values);
setOtherValues(restored.otherValues); setOtherValues(restored.otherValues);
setImageAnswers(restored.imageAnswers);
setDraftSaved(true); setDraftSaved(true);
} }
} catch { } catch {
@@ -297,13 +341,94 @@ export function FormFillerClient({
version: 1, version: 1,
answers: getValues() as FormValues, answers: getValues() as FormValues,
otherValues, otherValues,
imageAnswers,
}; };
localStorage.setItem(LS_KEY(form.id), JSON.stringify(draft)); localStorage.setItem(LS_KEY(form.id), JSON.stringify(draft));
queueMicrotask(() => setDraftSaved(true)); queueMicrotask(() => setDraftSaved(true));
} catch { } catch {
queueMicrotask(() => setDraftSaved(false)); 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>) => { const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (preview) return; if (preview) return;
@@ -328,6 +453,10 @@ export function FormFillerClient({
} }
} }
if (!validateRequiredImages()) {
return;
}
try { try {
const formattedAnswers = questions.map((q) => { const formattedAnswers = questions.map((q) => {
let val = values[q.id] as FormAnswerValue | undefined; let val = values[q.id] as FormAnswerValue | undefined;
@@ -342,10 +471,11 @@ export function FormFillerClient({
return { return {
questionId: q.id, questionId: q.id,
value: val ?? (q.type === "checkbox" ? [] : ""), 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) { if (res.ok) {
setTotalAnswers(res.totalAnswers); setTotalAnswers(res.totalAnswers);
setQueueNumber(res.queueNumber); setQueueNumber(res.queueNumber);
@@ -362,15 +492,21 @@ export function FormFillerClient({
} }
}; };
const onInvalidSubmit = () => {
validateRequiredImages();
};
const handleDelete = async () => { const handleDelete = async () => {
setIsDeleting(true); setIsDeleting(true);
try { try {
const res = await deleteOwnSubmission(form.id); const res = await deleteOwnSubmission(form.id, selectedSubmissionId);
if (res.ok) { if (res.ok) {
toast.success("ลบคำตอบแล้ว"); toast.success("ลบคำตอบแล้ว");
localStorage.removeItem(LS_KEY(form.id)); localStorage.removeItem(LS_KEY(form.id));
reset({}); reset({});
setOtherValues({}); setOtherValues({});
setImageAnswers({});
setSelectedSubmissionId(null);
setDraftSaved(false); setDraftSaved(false);
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
router.refresh(); router.refresh();
@@ -405,7 +541,7 @@ export function FormFillerClient({
<span>{questions.length} คำถาม</span> <span>{questions.length} คำถาม</span>
<span>{totalAnswers} คำตอบทั้งหมด</span> <span>{totalAnswers} คำตอบทั้งหมด</span>
{queueNumber !== null && ( {queueNumber !== null && (
<Badge variant="outline">คิวของคุณ #{queueNumber}</Badge> <Badge variant="outline">ลำดับของคุณ #{queueNumber}</Badge>
)} )}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -439,8 +575,39 @@ export function FormFillerClient({
</CardFooter> </CardFooter>
</Card> </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 {...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 ? ( {questions.length === 0 ? (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -519,7 +686,7 @@ export function FormFillerClient({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<FormItem> <FormItem>
{q.type === "text" && ( {q.type === "text" && !isImageOnlyQuestion(q) && (
<Field data-invalid={fieldState.invalid || undefined}> <Field data-invalid={fieldState.invalid || undefined}>
<FormControl <FormControl
aria-describedby={ aria-describedby={
@@ -539,7 +706,7 @@ export function FormFillerClient({
</Field> </Field>
)} )}
{q.type === "textarea" && ( {q.type === "textarea" && !isImageOnlyQuestion(q) && (
<Field data-invalid={fieldState.invalid || undefined}> <Field data-invalid={fieldState.invalid || undefined}>
<FormControl <FormControl
aria-describedby={ aria-describedby={
@@ -753,6 +920,85 @@ export function FormFillerClient({
<FormMessage id={questionErrorId} /> <FormMessage id={questionErrorId} />
</FieldSet> </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> </FormItem>
</CardContent> </CardContent>
</Card> </Card>
+1 -1
View File
@@ -55,7 +55,7 @@ export default async function FormsListingPage() {
</Badge> </Badge>
{submissionStats.get(form.id)?.queueNumber != null && ( {submissionStats.get(form.id)?.queueNumber != null && (
<Badge variant="outline"> <Badge variant="outline">
คิวของคุณ #{submissionStats.get(form.id)?.queueNumber} ลำดับของคุณ #{submissionStats.get(form.id)?.queueNumber}
</Badge> </Badge>
)} )}
</div> </div>
+28 -21
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -19,7 +19,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import type { ResultFilters } from "@/lib/admin-form-results"; 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"; import { format, parseISO } from "date-fns";
function DateFilterPicker({ function DateFilterPicker({
@@ -80,7 +80,7 @@ export function ResultsFilters({
const [values, setValues] = useState(filters); const [values, setValues] = useState(filters);
const base = `/admin/form/${formId}/result`; const base = `/admin/form/${formId}/result`;
const apply = () => { const filterQuery = useMemo(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (values.q) params.set("q", values.q); if (values.q) params.set("q", values.q);
if (values.sort !== "submittedAt") params.set("sort", values.sort); 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.edited !== "all") params.set("edited", values.edited);
if (values.dateFrom) params.set("dateFrom", values.dateFrom); if (values.dateFrom) params.set("dateFrom", values.dateFrom);
if (values.dateTo) params.set("dateTo", values.dateTo); if (values.dateTo) params.set("dateTo", values.dateTo);
router.push(`${base}?${params}`); return params.toString();
}; }, [values]);
const exportParams = new URLSearchParams({ const appliedQuery = useMemo(() => {
q: values.q, const params = new URLSearchParams();
sort: values.sort, if (filters.q) params.set("q", filters.q);
direction: values.direction, if (filters.sort !== "submittedAt") params.set("sort", filters.sort);
edited: values.edited, if (filters.direction !== "desc") params.set("direction", filters.direction);
dateFrom: values.dateFrom, if (filters.edited !== "all") params.set("edited", filters.edited);
dateTo: values.dateTo, 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 ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -108,9 +122,6 @@ export function ResultsFilters({
onChange={(event) => onChange={(event) =>
setValues((current) => ({ ...current, q: event.target.value })) setValues((current) => ({ ...current, q: event.target.value }))
} }
onKeyDown={(event) => {
if (event.key === "Enter") apply();
}}
placeholder="Search name or Discord ID" placeholder="Search name or Discord ID"
aria-label="Search responses" aria-label="Search responses"
/> />
@@ -167,12 +178,8 @@ export function ResultsFilters({
setValues((current) => ({ ...current, dateTo })) setValues((current) => ({ ...current, dateTo }))
} }
/> />
<Button onClick={apply}>
<Search data-icon="inline-start" />
Apply
</Button>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href={`${base}/export?${exportParams}`}> <a href={`${base}/export${filterQuery ? `?${filterQuery}` : ""}`}>
<Download data-icon="inline-start" /> <Download data-icon="inline-start" />
CSV CSV
</a> </a>
+35 -6
View File
@@ -5,12 +5,23 @@ import {
boolean, boolean,
integer, integer,
jsonb, jsonb,
uniqueIndex,
index, index,
customType,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { uuidv7 } from "uuidv7"; import { uuidv7 } from "uuidv7";
export const formSchema = pgSchema("form"); 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", { export const forms = formSchema.table("form", {
id: text("id") id: text("id")
@@ -25,9 +36,11 @@ export const forms = formSchema.table("form", {
discordWebhookUpdateTemplate: text("discord_webhook_update_template"), discordWebhookUpdateTemplate: text("discord_webhook_update_template"),
allowedRoles: jsonb("allowed_roles").$type<string[]>().default([]), allowedRoles: jsonb("allowed_roles").$type<string[]>().default([]),
deniedRoles: jsonb("denied_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 QuestionType = "text" | "textarea" | "radio" | "checkbox";
export type ImageAnswerMode = "none" | "optional" | "required";
export const questions = formSchema.table("question", { export const questions = formSchema.table("question", {
id: text("id") id: text("id")
@@ -43,6 +56,22 @@ export const questions = formSchema.table("question", {
required: boolean("required").notNull().default(false), required: boolean("required").notNull().default(false),
options: jsonb("options").$type<string[]>().default([]), options: jsonb("options").$type<string[]>().default([]),
allowOther: boolean("allow_other").notNull().default(false), 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( export const submissions = formSchema.table(
@@ -58,14 +87,10 @@ export const submissions = formSchema.table(
userName: text("user_name"), userName: text("user_name"),
submittedAt: timestamp("submitted_at", { mode: "date" }).defaultNow().notNull(), submittedAt: timestamp("submitted_at", { mode: "date" }).defaultNow().notNull(),
editHistory: jsonb("edit_history") editHistory: jsonb("edit_history")
.$type<{ editedAt: string; oldAnswers: Record<string, string> }[]>() .$type<{ editedAt: string; oldAnswers: Record<string, { value: string; imageCdnId: string | null }> }[]>()
.default([]), .default([]),
}, },
(submission) => [ (submission) => [
uniqueIndex("submission_form_user_discord_id_unique").on(
submission.formId,
submission.userDiscordId
),
index("submission_form_submitted_at_id_idx").on( index("submission_form_submitted_at_id_idx").on(
submission.formId, submission.formId,
submission.submittedAt, submission.submittedAt,
@@ -85,4 +110,8 @@ export const answers = formSchema.table("answer", {
.notNull() .notNull()
.references(() => questions.id, { onDelete: "cascade" }), .references(() => questions.id, { onDelete: "cascade" }),
value: text("value").notNull().default(""), 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, "when": 1785152117830,
"tag": "0012_clammy_the_stranger", "tag": "0012_clammy_the_stranger",
"breakpoints": true "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 { describe, expect, test } from "bun:test";
import { import {
answerCellValue,
decodeCheckboxAnswer, decodeCheckboxAnswer,
encodeAnswerValue, encodeAnswerValue,
formatStoredAnswer, formatStoredAnswer,
@@ -23,3 +24,8 @@ describe("form answer values", () => {
expect(formatStoredAnswer('["plain text"]', "text")).toBe('["plain text"]'); 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"; import type { QuestionType } from "@/db/schema/form";
export type FormAnswerValue = string | string[]; export type FormAnswerValue = string | string[];
export type FormAnswerPayload = {
questionId: string;
value: FormAnswerValue;
imageCdnId?: string | null;
};
export function decodeCheckboxAnswer(value: FormAnswerValue | undefined): string[] { export function decodeCheckboxAnswer(value: FormAnswerValue | undefined): string[] {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -39,3 +44,14 @@ export function formatStoredAnswer(
? decodeCheckboxAnswer(value).join(", ") ? decodeCheckboxAnswer(value).join(", ")
: value; : 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([ expect(normalized).toEqual([
{ questionId: "q-name", value: "Erika", displayValue: "Erika" }, { questionId: "q-name", value: "Erika", imageCdnId: null, displayValue: "Erika" },
{ questionId: "q-skill", value: "Custom skill", displayValue: "Custom skill" }, { questionId: "q-skill", value: "Custom skill", imageCdnId: null, displayValue: "Custom skill" },
{ questionId: "q-tags", value: '["Alpha","Gamma"]', displayValue: "Alpha, Gamma" }, { 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 { import {
decodeCheckboxAnswer, decodeCheckboxAnswer,
encodeAnswerValue, encodeAnswerValue,
@@ -14,19 +14,32 @@ export type SubmissionQuestion = {
required: boolean; required: boolean;
options?: string[] | null; options?: string[] | null;
allowOther: boolean; allowOther: boolean;
imageAnswerMode?: ImageAnswerMode;
}; };
export type SubmittedFormAnswer = { export type SubmittedFormAnswer = {
questionId: string; questionId: string;
value: FormAnswerValue; value: FormAnswerValue;
imageCdnId?: string | null;
}; };
export type NormalizedSubmissionAnswer = { export type NormalizedSubmissionAnswer = {
questionId: string; questionId: string;
value: string; value: string;
imageCdnId: string | null;
displayValue: string; displayValue: string;
}; };
export type StoredAnswerSnapshot = {
value: string;
imageCdnId: string | null;
};
export type OldAnswersByQuestionId = Record<
string,
string | StoredAnswerSnapshot | undefined
>;
function questionTitle(question: Pick<SubmissionQuestion, "label">) { function questionTitle(question: Pick<SubmissionQuestion, "label">) {
return stripHtml(question.label); return stripHtml(question.label);
} }
@@ -51,6 +64,7 @@ function ensureSubmittedQuestionIdsBelongToForm(
function normalizeCheckboxAnswer( function normalizeCheckboxAnswer(
question: SubmissionQuestion, question: SubmissionQuestion,
rawValue: FormAnswerValue | undefined, rawValue: FormAnswerValue | undefined,
imageCdnId: string | null,
): NormalizedSubmissionAnswer { ): NormalizedSubmissionAnswer {
if ( if (
rawValue !== undefined && rawValue !== undefined &&
@@ -77,6 +91,7 @@ function normalizeCheckboxAnswer(
return { return {
questionId: question.id, questionId: question.id,
value: encodeAnswerValue(selectedValues), value: encodeAnswerValue(selectedValues),
imageCdnId,
displayValue: selectedValues.join(", "), displayValue: selectedValues.join(", "),
}; };
} }
@@ -84,6 +99,7 @@ function normalizeCheckboxAnswer(
function normalizeScalarAnswer( function normalizeScalarAnswer(
question: SubmissionQuestion, question: SubmissionQuestion,
rawValue: FormAnswerValue | undefined, rawValue: FormAnswerValue | undefined,
imageCdnId: string | null,
): NormalizedSubmissionAnswer { ): NormalizedSubmissionAnswer {
if (rawValue !== undefined && typeof rawValue !== "string") { if (rawValue !== undefined && typeof rawValue !== "string") {
throw new Error(`รูปแบบคำตอบของ “${questionTitle(question)}” ไม่ถูกต้อง`); throw new Error(`รูปแบบคำตอบของ “${questionTitle(question)}” ไม่ถูกต้อง`);
@@ -108,10 +124,30 @@ function normalizeScalarAnswer(
return { return {
questionId: question.id, questionId: question.id,
value, value,
imageCdnId,
displayValue: value, 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( export function normalizeSubmissionAnswers(
questions: SubmissionQuestion[], questions: SubmissionQuestion[],
answersList: SubmittedFormAnswer[], answersList: SubmittedFormAnswer[],
@@ -119,17 +155,23 @@ export function normalizeSubmissionAnswers(
ensureSubmittedQuestionIdsBelongToForm(questions, answersList); ensureSubmittedQuestionIdsBelongToForm(questions, answersList);
const submittedAnswers = new Map( const submittedAnswers = new Map(
answersList.map((answer) => [answer.questionId, answer.value]), answersList.map((answer) => [answer.questionId, answer]),
); );
return questions.map((question) => { 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" return question.type === "checkbox"
? normalizeCheckboxAnswer(question, rawValue) ? normalizeCheckboxAnswer(question, rawValue, imageCdnId)
: normalizeScalarAnswer(question, rawValue); : normalizeScalarAnswer(question, rawValue, imageCdnId);
}); });
} }
export function displayAnswerValue(answer: Pick<NormalizedSubmissionAnswer, "displayValue" | "imageCdnId">) {
return answer.displayValue || (answer.imageCdnId ? `/cdn/${answer.imageCdnId}` : "");
}
export function answersByTemplateIndex( export function answersByTemplateIndex(
questions: Pick<SubmissionQuestion, "id">[], questions: Pick<SubmissionQuestion, "id">[],
normalizedAnswers: NormalizedSubmissionAnswer[], normalizedAnswers: NormalizedSubmissionAnswer[],
@@ -141,30 +183,47 @@ export function answersByTemplateIndex(
return normalizedAnswers.reduce<Record<string, string>>((acc, answer) => { return normalizedAnswers.reduce<Record<string, string>>((acc, answer) => {
const index = questionOrderMap[answer.questionId]; const index = questionOrderMap[answer.questionId];
if (index) acc[index] = answer.displayValue; if (index) acc[index] = displayAnswerValue(answer);
return acc; 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( export function submissionUpdateText(
questions: SubmissionQuestion[], questions: SubmissionQuestion[],
normalizedAnswers: NormalizedSubmissionAnswer[], normalizedAnswers: NormalizedSubmissionAnswer[],
oldAnswersByQuestionId: Record<string, string>, oldAnswersByQuestionId: OldAnswersByQuestionId,
) { ) {
const questionsById = new Map(questions.map((question) => [question.id, question])); const questionsById = new Map(questions.map((question) => [question.id, question]));
const changed: string[] = []; const changed: string[] = [];
for (const answer of normalizedAnswers) { for (const answer of normalizedAnswers) {
const question = questionsById.get(answer.questionId); const question = questionsById.get(answer.questionId);
const oldValue = oldAnswersByQuestionId[answer.questionId]; const oldSnapshot = normalizeStoredAnswerSnapshot(oldAnswersByQuestionId[answer.questionId]);
const oldDisplayValue = question const oldDisplayValue = question
? formatStoredAnswer(oldValue, question.type) ? formatStoredAnswer(oldSnapshot.value, question.type)
: oldValue; : 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"; const title = question ? questionTitle(question) : "Question";
changed.push( 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", questionId: "name",
value: "Erika", value: "Erika",
imageCdnId: null,
displayValue: "Erika", 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", () => { test("uses the configured template", () => {
expect(submissionWebhookMessage({ expect(submissionWebhookMessage({
template: "New answer: {1} by {username} ({id})", template: "New answer: {1} by {username} ({id})",
+2 -1
View File
@@ -2,6 +2,7 @@ import {
answersByTemplateIndex, answersByTemplateIndex,
submissionUpdateText, submissionUpdateText,
type NormalizedSubmissionAnswer, type NormalizedSubmissionAnswer,
type OldAnswersByQuestionId,
type SubmissionQuestion, type SubmissionQuestion,
} from "@/lib/submission-intake"; } from "@/lib/submission-intake";
@@ -15,7 +16,7 @@ type SubmissionWebhookMessageOptions = {
discordId: string; discordId: string;
questions: SubmissionQuestion[]; questions: SubmissionQuestion[];
answers: NormalizedSubmissionAnswer[]; answers: NormalizedSubmissionAnswer[];
oldAnswersByQuestionId: Record<string, string>; oldAnswersByQuestionId: OldAnswersByQuestionId;
}; };
export function submissionWebhookMessage({ export function submissionWebhookMessage({