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