diff --git a/Dockerfile b/Dockerfile index 896ecb5..61a86cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # Stage 1: Install dependencies -FROM oven/bun:alpine AS deps +FROM oven/bun:1.3.14-alpine AS deps WORKDIR /app # Copy only package files to leverage Docker layer caching COPY package.json bun.lock ./ @@ -10,7 +10,7 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \ bun install --frozen-lockfile # Stage 2: Build the application -FROM oven/bun:alpine AS builder +FROM oven/bun:1.3.14-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/app/.next/cache \ bun run build # Stage 3: Production runner -FROM oven/bun:alpine AS runner +FROM oven/bun:1.3.14-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/README.md b/README.md index a4c02a4..3992ead 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,81 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Erika -## Getting Started +Erika is a Next.js application for a creator landing page, Discord-authenticated forms, admin form management, submission results, Discord notifications, and XP/voice leaderboards. -First, run the development server: +## Requirements + +- Bun 1.3+ +- PostgreSQL +- Redis for visitor counts and server-sent events +- Discord OAuth and bot credentials for authentication and role-based access + +## Development ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +bun install +bun run dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +The development server runs on port `4000`. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +Useful commands: -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +```bash +bun run lint # ESLint +bunx tsc --noEmit # Type checking +bun test # Unit and domain tests +bun run db:generate # Generate a Drizzle migration +bun run db:migrate # Apply migrations +``` -## Learn More +## Environment -To learn more about Next.js, take a look at the following resources: +Create `.env.local` for local development. The application uses these groups of variables: -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- `DATABASE_URL` — primary PostgreSQL connection +- `LEADERBOARD_DATABASE_URL` — optional read-only leaderboard database +- `REDIS_URL` — Redis connection for counters and SSE +- `NEXTAUTH_URL`, `NEXTAUTH_SECRET` — authentication configuration +- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` — Discord OAuth +- `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID` — Discord role and profile lookups +- `ADMIN_DISCORD_IDS` — comma-separated Discord IDs allowed into admin tools +- `BASE_URL` — canonical public URL used in links and OAuth callbacks +- platform credentials used by follower-count integrations -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +Never commit `.env` or `.env.local`, and never expose credentials through `NEXT_PUBLIC_` variables. Before deployment, run: -## Deploy on Vercel +```bash +bun run secrets:scan +``` -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Main areas -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- `/` — public profile and links +- `/form` — public form listing and submission +- `/admin` — protected administration dashboard +- `/leaderboard/xp` — XP leaderboard +- `/leaderboard/vc` — voice activity leaderboard +- `/sse/[topic]` — authenticated realtime updates +- `/api/upload` — authenticated image uploads stored in PostgreSQL -## Secret safety +Forms use server actions for authorization, persistence, validation, Discord webhooks, and cache invalidation. Public form drafts are stored locally in the browser. -- Keep credentials in `.env` or `.env.local`. Never prefix a credential with - `NEXT_PUBLIC_`. -- Restrict local access before starting the app: +## Docker - ```bash - chmod 600 .env .env.local - ``` +```bash +bun run up +bun run logs +bun run down +``` -- Scan tracked files and Git history before deployment: +The application listens on port `3000` inside the container. nginx shares the application network namespace and provides the public port configured in `docker-compose.yml`, including SSE proxy settings. - ```bash - bun run secrets:scan - ``` +The production image is built in separate dependency, build, and runner stages. Runtime environment variables are injected by Compose rather than copied into the image. -- Deploy with Docker Compose so `.env` is injected only when the container - starts. Environment files are excluded from the Docker build context. -- Give the Discord bot only the permissions it needs. Avoid the Administrator - permission. -- Rotate every credential from `.env` if an environment file, build context, - builder cache, log, or host may have been shared. If prior exposure cannot be - ruled out, treat the credentials as exposed. +## Security notes -# erika +- Admin server actions require the authenticated Discord ID to be in `ADMIN_DISCORD_IDS`. +- Form access checks allowed and denied Discord roles. +- Uploaded image contents are checked against their declared image type before storage. +- TikTok OAuth is a private setup utility and requires an admin session plus verified OAuth state. +- Rotate credentials if an environment file, build cache, logs, or deployment host may have been exposed. diff --git a/app/admin/form/[id]/client.tsx b/app/admin/form/[id]/client.tsx index 5d36327..a9096f4 100644 --- a/app/admin/form/[id]/client.tsx +++ b/app/admin/form/[id]/client.tsx @@ -18,7 +18,7 @@ import { deleteQuestion, bulkUpdateQuestions, } from "@/app/actions/questions"; -import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Image as ImageIcon, X, Check, GripVertical, AlertCircle } from "lucide-react"; +import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Image as ImageIcon, X } from "lucide-react"; import Image from 'next/image'; import { format, parseISO } from "date-fns"; import { DateTimePicker } from "@/components/date-time-picker"; @@ -43,13 +43,10 @@ import { SortableContext, arrayMove, sortableKeyboardCoordinates, - useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; import { useDebouncedAutoSave, - type AutoSaveStatus, } from "@/hooks/use-debounced-autosave"; import { stripHtml } from "@/lib/utils"; import { @@ -76,6 +73,10 @@ import { getQuestionResponseType, type QuestionResponseType, } from "@/lib/question-response-type"; +import { + SaveStatusIndicator, + SortableQuestion, +} from "@/components/admin-form-editor-parts"; interface Question { id: string; @@ -765,53 +766,6 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC ); } -function SaveStatusIndicator({ - status, - error, - onRetry, -}: { - status: AutoSaveStatus; - error: string | null; - onRetry: () => Promise; -}) { - if (status === "clean") return null; - - return ( - - {status === "dirty" && "Unsaved"} - {status === "saving" && ( - <> - - Saving - - )} - {status === "saved" && ( - <> - - Saved - - )} - {status === "error" && ( - <> - - {error || "Could not save."} - - - )} - - ); -} - function questionTypeLabel(type: Question["type"]) { return { text: "Short answer", @@ -820,47 +774,3 @@ function questionTypeLabel(type: Question["type"]) { checkbox: "Choose multiple", }[type]; } - -function SortableQuestion({ - id, - position, - children, -}: { - id: string; - position: number; - children: React.ReactNode; -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }); - - return ( -
- - {children} -
- ); -} diff --git a/app/api/auth/tiktok/callback/route.ts b/app/api/auth/tiktok/callback/route.ts index 51d1ee6..164ea1f 100644 --- a/app/api/auth/tiktok/callback/route.ts +++ b/app/api/auth/tiktok/callback/route.ts @@ -1,4 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { requireAdmin } from "@/lib/auth"; + +const TIKTOK_OAUTH_STATE_COOKIE = "tiktok-oauth-state"; + +function escapeHtml(value: unknown) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} /** * GET /api/auth/tiktok/callback @@ -7,10 +20,20 @@ import { NextRequest, NextResponse } from "next/server"; * Displays the tokens so you can copy them to your .env file. */ export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); const code = searchParams.get("code"); const error = searchParams.get("error"); + const cookieStore = await cookies(); + const expectedState = cookieStore.get(TIKTOK_OAUTH_STATE_COOKIE)?.value; + const state = searchParams.get("state"); + cookieStore.delete(TIKTOK_OAUTH_STATE_COOKIE); + + if (!expectedState || !state || state !== expectedState) { + return NextResponse.json({ error: "Invalid OAuth state" }, { status: 400 }); + } + if (error) { return NextResponse.json( { error, description: searchParams.get("error_description") }, @@ -52,11 +75,11 @@ export async function GET(request: NextRequest) { } ); - const tokenData = await tokenRes.json(); + const tokenData = (await tokenRes.json()) as Record; if (tokenData.error) { return NextResponse.json( - { error: tokenData.error, description: tokenData.error_description }, + { error: tokenData.error, description: tokenData.error_description }, { status: 400 } ); } @@ -71,20 +94,20 @@ export async function GET(request: NextRequest) {

Copy these values to your .env file:

-

TIKTOK_ACCESS_TOKEN=
${tokenData.access_token}

-

TIKTOK_REFRESH_TOKEN=
${tokenData.refresh_token}

-

TIKTOK_OPEN_ID=
${tokenData.open_id}

+

TIKTOK_ACCESS_TOKEN=
${escapeHtml(tokenData.access_token)}

+

TIKTOK_REFRESH_TOKEN=
${escapeHtml(tokenData.refresh_token)}

+

TIKTOK_OPEN_ID=
${escapeHtml(tokenData.open_id)}

- Access token expires in ${Math.floor(tokenData.expires_in / 3600)} hours.
- Refresh token expires in ${Math.floor(tokenData.refresh_expires_in / 86400)} days.
+ Access token expires in ${Math.floor(Number(tokenData.expires_in ?? 0) / 3600)} hours.
+ Refresh token expires in ${Math.floor(Number(tokenData.refresh_expires_in ?? 0) / 86400)} days.
The server will auto-refresh the access token using the refresh token.

Full response -
${JSON.stringify(tokenData, null, 2)}
+
${escapeHtml(JSON.stringify(tokenData, null, 2))}
`; diff --git a/app/api/auth/tiktok/route.ts b/app/api/auth/tiktok/route.ts index 261c71a..0b720cb 100644 --- a/app/api/auth/tiktok/route.ts +++ b/app/api/auth/tiktok/route.ts @@ -1,4 +1,9 @@ import { NextResponse } from "next/server"; +import { randomBytes } from "node:crypto"; +import { cookies } from "next/headers"; +import { requireAdmin } from "@/lib/auth"; + +const TIKTOK_OAUTH_STATE_COOKIE = "tiktok-oauth-state"; /** * GET /api/auth/tiktok @@ -6,6 +11,8 @@ import { NextResponse } from "next/server"; * Visit this URL once to start the OAuth flow and get your refresh token. */ export async function GET() { + + const clientKey = process.env.TIKTOK_CLIENT_KEY; const redirectUri = `${process.env.BASE_URL}/api/auth/tiktok/callback`; @@ -16,7 +23,7 @@ export async function GET() { ); } - const csrfState = Math.random().toString(36).substring(2); + const csrfState = randomBytes(32).toString("hex"); const params = new URLSearchParams({ client_key: clientKey, @@ -28,5 +35,14 @@ export async function GET() { const authUrl = `https://www.tiktok.com/v2/auth/authorize/?${params.toString()}`; - return NextResponse.redirect(authUrl); + const response = NextResponse.redirect(authUrl); + const cookieStore = await cookies(); + cookieStore.set(TIKTOK_OAUTH_STATE_COOKIE, csrfState, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 600, + path: "/api/auth/tiktok", + }); + return response; } diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index f53412d..ba749b7 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -20,12 +20,13 @@ export async function POST(req: Request) { return NextResponse.json({ error: "No file provided" }, { status: 400 }); } - validateImageUploadFile(file); + const data = Buffer.from(await file.arrayBuffer()); + validateImageUploadFile(file, data); const [record] = await db .insert(cdn) .values({ - data: Buffer.from(await file.arrayBuffer()), + data, name: file.name || null, type: file.type, size: file.size, diff --git a/app/form/client.tsx b/app/form/client.tsx index 905ed0e..01a61d2 100644 --- a/app/form/client.tsx +++ b/app/form/client.tsx @@ -54,10 +54,17 @@ import { } from "@/components/ui/field"; import { Spinner } from "@/components/ui/spinner"; import { - decodeCheckboxAnswer, type FormAnswerValue, } from "@/lib/form-answer-values"; import { isImageOnlyResponse } from "@/lib/question-response-type"; +import { + buildFormSchema, + parseStoredDraft, + prepareInitialState, + type FormFillerClientProps, + type FormValues, + type StoredDraft, +} from "@/lib/form-client"; export function FormLayoutClient({ userName, @@ -101,153 +108,7 @@ export function LoginButton() { ); } -interface Question { - id: string; - formId: string; - type: "text" | "textarea" | "radio" | "checkbox"; - label: string; - imageUrl: string | null; - displayOrder: number; - 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; - closesAt: string | null; - allowMultipleSubmissions: boolean; - }; - questions: Question[]; - initialAnswers?: Record; - hasSubmission?: boolean; - selectedSubmissionId?: string | null; - ownSubmissions?: { - id: string; - submittedAt: string; - answers: Record; - }[]; - totalAnswers: number; - queueNumber: number | null; - preview?: boolean; -} - const LS_KEY = (formId: string) => `form-draft-${formId}`; -type FormValues = Record; -type StoredDraft = { - version: 1; - answers: FormValues; - otherValues: Record; - imageAnswers?: Record; -}; - -const buildSchema = (questions: Question[]) => { - const shape: Record = {}; - questions.forEach(q => { - if (q.type === "checkbox") { - shape[q.id] = q.required - ? z.array(z.string()).min(1, `จำเป็นต้องตอบข้อนี้`) - : z.array(z.string()).optional(); - } else { - shape[q.id] = q.required - ? z.string().trim().min(1, `จำเป็นต้องตอบข้อนี้`) - : z.string().optional(); - } - }); - return z.object(shape); -}; - -function prepareInitialState( - questions: Question[], - answers: Record, - savedOtherValues: Record = {} -) { - const values: FormValues = {}; - const imageAnswers: Record = {}; - const otherValues = { ...savedOtherValues }; - - questions.forEach((question) => { - const answer = answers[question.id]?.value; - imageAnswers[question.id] = answers[question.id]?.imageCdnId ?? null; - - if (question.type === "checkbox") { - let selected = decodeCheckboxAnswer(answer); - if (question.allowOther && question.options) { - const customAnswers = selected.filter( - (value) => !question.options?.includes(value) && value !== "__OTHER__" - ); - if (customAnswers.length > 0) { - otherValues[question.id] = customAnswers.join(", "); - } - if (selected.includes("__OTHER__") || customAnswers.length > 0) { - selected = selected.filter((value) => question.options?.includes(value)); - selected.push("__OTHER__"); - } - } - values[question.id] = selected; - return; - } - - if (question.type === "radio") { - let selected = typeof answer === "string" ? answer : ""; - if ( - question.allowOther && - question.options && - selected && - !question.options.includes(selected) - ) { - if (selected !== "__OTHER__") { - otherValues[question.id] = selected; - } - selected = "__OTHER__"; - } - values[question.id] = selected; - return; - } - - values[question.id] = typeof answer === "string" ? answer : ""; - }); - - return { values, otherValues, imageAnswers }; -} - -function parseStoredDraft(raw: string): { - answers: FormValues; - otherValues: Record; - imageAnswers: Record; -} { - const parsed: unknown = JSON.parse(raw); - if ( - parsed && - typeof parsed === "object" && - "version" in parsed && - parsed.version === 1 && - "answers" in parsed - ) { - const draft = parsed as StoredDraft; - return { - answers: draft.answers ?? {}, - otherValues: draft.otherValues ?? {}, - imageAnswers: draft.imageAnswers ?? {}, - }; - } - - return { - answers: (parsed as FormValues) ?? {}, - otherValues: {}, - imageAnswers: {}, - }; -} export function FormFillerClient({ form, @@ -266,7 +127,7 @@ export function FormFillerClient({ ); const isUpdating = Boolean(selectedSubmissionId) || (!form.allowMultipleSubmissions && hasSubmission); - const formSchema = useMemo(() => buildSchema(questions), [questions]); + const formSchema = useMemo(() => buildFormSchema(questions), [questions]); const serverInitialState = useMemo( () => prepareInitialState(questions, initialAnswers), [initialAnswers, questions] diff --git a/app/page.tsx b/app/page.tsx index adab87c..bd93185 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,4 @@ -import { config, config_p2 } from "@/lib/config"; +import { config } from "@/lib/config"; import { Card, CardHeader } from "@/components/ui/card"; import Image from "next/image"; import { getFollowerCounts } from "@/lib/followers"; @@ -8,12 +8,6 @@ import { headers } from "next/headers"; import { connection } from "next/server"; import { Users } from "lucide-react"; import { Kbd } from "@/components/ui/kbd"; -import { Button } from "@/components/ui/button"; -import { ShieldCheck } from "lucide-react"; -import Link from "next/link"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth-options"; -import { getAdminDiscordIds, getSessionDiscordId } from "@/lib/auth"; import Background from "@/public/background/space.webp"; export default async function Home() { @@ -46,10 +40,6 @@ export default async function Home() { // Use configured profile data. const profileName = config.profile.name; const profileAvatar = config.profile.avatar; - const session = await getServerSession(authOptions); - const discordId = getSessionDiscordId(session); - const isAdmin = Boolean(discordId && getAdminDiscordIds().includes(discordId)); - const links1 = [...config.links]; const jsonLd = { @@ -107,7 +97,7 @@ export default async function Home() { - + ); diff --git a/components/admin-form-editor-parts.tsx b/components/admin-form-editor-parts.tsx new file mode 100644 index 0000000..9332522 --- /dev/null +++ b/components/admin-form-editor-parts.tsx @@ -0,0 +1,96 @@ +"use client"; + +import type React from "react"; +import { AlertCircle, Check, GripVertical, Loader2 } from "lucide-react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Button } from "@/components/ui/button"; +import type { AutoSaveStatus } from "@/hooks/use-debounced-autosave"; + +export function SaveStatusIndicator({ + status, + error, + onRetry, +}: { + status: AutoSaveStatus; + error: string | null; + onRetry: () => Promise; +}) { + if (status === "clean") return null; + + return ( + + {status === "dirty" && "Unsaved"} + {status === "saving" && ( + <> + + Saving + + )} + {status === "saved" && ( + <> + + Saved + + )} + {status === "error" && ( + <> + + {error || "Could not save."} + + + )} + + ); +} + +export function SortableQuestion({ + id, + position, + children, +}: { + id: string; + position: number; + children: React.ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + return ( +
+ + {children} +
+ ); +} diff --git a/lib/cdn-images.test.ts b/lib/cdn-images.test.ts new file mode 100644 index 0000000..c456e33 --- /dev/null +++ b/lib/cdn-images.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { validateImageUploadFile } from "./cdn-images"; + +const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + +describe("image upload validation", () => { + test("accepts a valid PNG signature", () => { + expect(() => + validateImageUploadFile( + { name: "avatar.png", type: "image/png", size: png.length }, + png + ) + ).not.toThrow(); + }); + + test("rejects content that does not match the declared type", () => { + expect(() => + validateImageUploadFile( + { name: "avatar.png", type: "image/png", size: 4 }, + new Uint8Array([1, 2, 3, 4]) + ) + ).toThrow("contents do not match"); + }); + + test("rejects empty files", () => { + expect(() => + validateImageUploadFile({ name: "avatar.png", type: "image/png", size: 0 }) + ).toThrow("cannot be empty"); + }); +}); diff --git a/lib/cdn-images.ts b/lib/cdn-images.ts index e287b9a..e2099d4 100644 --- a/lib/cdn-images.ts +++ b/lib/cdn-images.ts @@ -9,7 +9,10 @@ const ALLOWED_IMAGE_MIME_TYPES = new Set([ const ALLOWED_IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "gif"]); -export function validateImageUploadFile(file: Pick) { +export function validateImageUploadFile( + file: Pick, + bytes?: Uint8Array +) { if (!ALLOWED_IMAGE_MIME_TYPES.has(file.type)) { throw new Error("Only PNG, JPG, WebP, and GIF images are allowed"); } @@ -18,10 +21,43 @@ export function validateImageUploadFile(file: Pick= 8 && + bytes.slice(0, 8).every((value, index) => value === [137, 80, 78, 71, 13, 10, 26, 10][index]); + } + + if (type === "image/jpeg") { + return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + } + + if (type === "image/gif") { + return bytes.length >= 6 && + (String.fromCharCode(...bytes.slice(0, 6)) === "GIF87a" || + String.fromCharCode(...bytes.slice(0, 6)) === "GIF89a"); + } + + if (type === "image/webp") { + return bytes.length >= 12 && + String.fromCharCode(...bytes.slice(0, 4)) === "RIFF" && + String.fromCharCode(...bytes.slice(8, 12)) === "WEBP"; + } + + return false; } export function cdnUrl(id: string | null | undefined) { diff --git a/lib/config.ts b/lib/config.ts index 9c3c03c..6561a7d 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -50,12 +50,8 @@ export const config = { desc: "กดติดตามใน Youtube", icon: "youtube", } - ] as LinkConfig[] -}; - -export const config_p2 = { - profile: config.profile, - links: [ + ] as LinkConfig[], + secondaryLinks: [ { name: "Form", link: "/form", @@ -68,5 +64,5 @@ export const config_p2 = { desc: "อันดับ XP และเวลาในห้องเสียง", icon: "leaderboard", } - ] as LinkConfig[] + ] as LinkConfig[], }; diff --git a/lib/form-client.test.ts b/lib/form-client.test.ts new file mode 100644 index 0000000..8dc0ea8 --- /dev/null +++ b/lib/form-client.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { + buildFormSchema, + parseStoredDraft, + prepareInitialState, + type Question, +} from "./form-client"; + +const questions: Question[] = [ + { + id: "choice", + formId: "form", + type: "radio", + label: "Choice", + imageUrl: null, + displayOrder: 0, + required: true, + options: ["A", "B"], + allowOther: true, + imageAnswerMode: "none", + }, + { + id: "tags", + formId: "form", + type: "checkbox", + label: "Tags", + imageUrl: null, + displayOrder: 1, + required: false, + options: ["A", "B"], + allowOther: true, + imageAnswerMode: "none", + }, +]; + +describe("form client domain helpers", () => { + test("maps stored custom answers to the Other control", () => { + const state = prepareInitialState(questions, { + choice: { value: "Custom", imageCdnId: null }, + tags: { value: JSON.stringify(["A", "Custom tag"]), imageCdnId: null }, + }); + + expect(state.values.choice).toBe("__OTHER__"); + expect(state.values.tags).toEqual(["A", "__OTHER__"]); + expect(state.otherValues).toEqual({ choice: "Custom", tags: "Custom tag" }); + }); + + test("enforces required answers", () => { + const schema = buildFormSchema(questions); + expect(schema.safeParse({ choice: "", tags: [] }).success).toBe(false); + expect(schema.safeParse({ choice: "A", tags: [] }).success).toBe(true); + }); + + test("parses versioned drafts and rejects malformed JSON", () => { + expect( + parseStoredDraft( + JSON.stringify({ version: 1, answers: { choice: "A" }, otherValues: {} }) + ).answers.choice + ).toBe("A"); + expect(() => parseStoredDraft("not-json")).toThrow(); + }); +}); diff --git a/lib/form-client.ts b/lib/form-client.ts new file mode 100644 index 0000000..3cce2f5 --- /dev/null +++ b/lib/form-client.ts @@ -0,0 +1,147 @@ +import { z } from "zod"; +import type { FormAnswerValue } from "@/lib/form-answer-values"; +import { decodeCheckboxAnswer } from "@/lib/form-answer-values"; + +export type Question = { + id: string; + formId: string; + type: "text" | "textarea" | "radio" | "checkbox"; + label: string; + imageUrl: string | null; + displayOrder: number; + required: boolean; + options: string[] | null; + allowOther: boolean; + imageAnswerMode: "none" | "optional" | "required"; +}; + +export type AnswerDraft = { + value: FormAnswerValue; + imageCdnId: string | null; +}; + +export type FormFillerClientProps = { + form: { + id: string; + title: string; + description: string | null; + isOpen: boolean; + closesAt: string | null; + allowMultipleSubmissions: boolean; + }; + questions: Question[]; + initialAnswers?: Record; + hasSubmission?: boolean; + selectedSubmissionId?: string | null; + ownSubmissions?: { + id: string; + submittedAt: string; + answers: Record; + }[]; + totalAnswers: number; + queueNumber: number | null; + preview?: boolean; +}; + +export type FormValues = Record; + +export type StoredDraft = { + version: 1; + answers: FormValues; + otherValues: Record; + imageAnswers?: Record; +}; + +export function buildFormSchema(questions: Question[]) { + const shape: Record = {}; + questions.forEach((question) => { + if (question.type === "checkbox") { + shape[question.id] = question.required + ? z.array(z.string()).min(1, "จำเป็นต้องตอบข้อนี้") + : z.array(z.string()).optional(); + } else { + shape[question.id] = question.required + ? z.string().trim().min(1, "จำเป็นต้องตอบข้อนี้") + : z.string().optional(); + } + }); + return z.object(shape); +} + +export function prepareInitialState( + questions: Question[], + answers: Record, + savedOtherValues: Record = {} +) { + const values: FormValues = {}; + const imageAnswers: Record = {}; + const otherValues = { ...savedOtherValues }; + + questions.forEach((question) => { + const answer = answers[question.id]?.value; + imageAnswers[question.id] = answers[question.id]?.imageCdnId ?? null; + + if (question.type === "checkbox") { + let selected = decodeCheckboxAnswer(answer); + if (question.allowOther && question.options) { + const customAnswers = selected.filter( + (value) => !question.options?.includes(value) && value !== "__OTHER__" + ); + if (customAnswers.length > 0) { + otherValues[question.id] = customAnswers.join(", "); + } + if (selected.includes("__OTHER__") || customAnswers.length > 0) { + selected = selected.filter((value) => question.options?.includes(value)); + selected.push("__OTHER__"); + } + } + values[question.id] = selected; + return; + } + + if (question.type === "radio") { + let selected = typeof answer === "string" ? answer : ""; + if ( + question.allowOther && + question.options && + selected && + !question.options.includes(selected) + ) { + if (selected !== "__OTHER__") { + otherValues[question.id] = selected; + } + selected = "__OTHER__"; + } + values[question.id] = selected; + return; + } + + values[question.id] = typeof answer === "string" ? answer : ""; + }); + + return { values, otherValues, imageAnswers }; +} + +export function parseStoredDraft(raw: string) { + const parsed: unknown = JSON.parse(raw); + if ( + parsed && + typeof parsed === "object" && + "version" in parsed && + parsed.version === 1 && + "answers" in parsed + ) { + const draft = parsed as StoredDraft; + return { + answers: draft.answers ?? {}, + otherValues: draft.otherValues ?? {}, + imageAnswers: draft.imageAnswers ?? {}, + }; + } + + return { + answers: (parsed as FormValues) ?? {}, + otherValues: {}, + imageAnswers: {}, + }; +}