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