From 85646644ffea9ab696a7ce32d1f257a9047c8324 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Fri, 4 Sep 2026 17:32:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=88=AD=EC=8B=A4=EB=8C=80=20?= =?UTF-8?q?=EC=9E=AC=EC=A0=81=EC=83=9D=20=EA=B0=80=EC=9E=85=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=ED=8F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blocker 최소화 UX(단일 스크롤 폼, 조건부 노출, 임시저장, 실시간 마스킹)로 개인/팀 지원 신청서를 받는 새 지원서(applications) 테이블과 폼을 추가. 기존 참가자/팀 데모데이 스키마와는 분리해 추후 검토 후 등록하는 흐름으로 설계. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AwDeTJKVg5t63jqNDVeiUV --- src/app/(public)/apply/page.tsx | 18 ++ src/entities/application/index.ts | 15 ++ src/entities/application/model/actions.ts | 39 ++++ src/entities/application/model/application.ts | 75 +++++++ .../application/model/college-department.ts | 82 +++++++ src/features/submit-application/index.ts | 1 + .../submit-application/model/schema.ts | 83 +++++++ .../submit-application/ui/apply-form-draft.ts | 45 ++++ .../ui/college-department-select.tsx | 108 ++++++++++ .../ui/formatted-fields.tsx | 154 +++++++++++++ .../ui/submit-application-form.tsx | 204 ++++++++++++++++++ .../ui/submit-application-result.tsx | 23 ++ .../ui/team-members-field.tsx | 157 ++++++++++++++ src/shared/lib/supabase/database.types.ts | 77 +++++++ .../20260904120000_applications.sql | 38 ++++ 15 files changed, 1119 insertions(+) create mode 100644 src/app/(public)/apply/page.tsx create mode 100644 src/entities/application/index.ts create mode 100644 src/entities/application/model/actions.ts create mode 100644 src/entities/application/model/application.ts create mode 100644 src/entities/application/model/college-department.ts create mode 100644 src/features/submit-application/index.ts create mode 100644 src/features/submit-application/model/schema.ts create mode 100644 src/features/submit-application/ui/apply-form-draft.ts create mode 100644 src/features/submit-application/ui/college-department-select.tsx create mode 100644 src/features/submit-application/ui/formatted-fields.tsx create mode 100644 src/features/submit-application/ui/submit-application-form.tsx create mode 100644 src/features/submit-application/ui/submit-application-result.tsx create mode 100644 src/features/submit-application/ui/team-members-field.tsx create mode 100644 supabase/migrations/20260904120000_applications.sql diff --git a/src/app/(public)/apply/page.tsx b/src/app/(public)/apply/page.tsx new file mode 100644 index 0000000..47c69d1 --- /dev/null +++ b/src/app/(public)/apply/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { SubmitApplicationForm } from "@/features/submit-application"; +import { CenteredCard } from "@/shared/ui/centered-card"; + +export const metadata: Metadata = { title: "가입 신청" }; + +export default function ApplyPage() { + return ( +
+ + + +
+ ); +} diff --git a/src/entities/application/index.ts b/src/entities/application/index.ts new file mode 100644 index 0000000..adcf764 --- /dev/null +++ b/src/entities/application/index.ts @@ -0,0 +1,15 @@ +export { + checkStudentIdAvailableAction, + submitApplicationAction, +} from "./model/actions"; +export type { + ApplicationInput, + ApplicationRole, + ApplicationType, + TeamMemberInput, +} from "./model/application"; +export { + COLLEGE_DEPARTMENTS, + type College, + departmentsFor, +} from "./model/college-department"; diff --git a/src/entities/application/model/actions.ts b/src/entities/application/model/actions.ts new file mode 100644 index 0000000..86c17fa --- /dev/null +++ b/src/entities/application/model/actions.ts @@ -0,0 +1,39 @@ +"use server"; + +import type { ActionResult } from "@/entities/session"; +import { + type ApplicationInput, + createApplication, + findApplicationByStudentId, +} from "./application"; + +// Non-blocking early warning (called on studentId blur) — the authoritative +// check is the unique constraint hit inside submitApplicationAction, so a +// failure here should never stop the applicant from continuing to fill out +// the form. +export async function checkStudentIdAvailableAction( + studentId: string, +): Promise { + try { + return !(await findApplicationByStudentId(studentId)); + } catch (error) { + console.error(error); + return true; + } +} + +export async function submitApplicationAction( + input: ApplicationInput, +): Promise { + try { + await createApplication(input); + return { ok: true }; + } catch (error) { + console.error(error); + const message = + error instanceof Error && error.message.includes("duplicate key") + ? "이미 지원 내역이 있어요" + : "제출에 실패했어요. 잠시 후 다시 시도해주세요"; + return { ok: false, message }; + } +} diff --git a/src/entities/application/model/application.ts b/src/entities/application/model/application.ts new file mode 100644 index 0000000..99eba59 --- /dev/null +++ b/src/entities/application/model/application.ts @@ -0,0 +1,75 @@ +import { createAdminClient } from "@/shared/lib/supabase/admin"; +import { throwIfError } from "@/shared/lib/supabase/query"; + +export type ApplicationRole = "pm" | "design" | "developer"; +export type ApplicationType = "individual" | "team"; + +export interface TeamMemberInput { + name: string; + college: string; + department: string; +} + +export interface ApplicationInput { + name: string; + studentId: string; + college: string; + department: string; + phone: string; + birthDate: string; + role: ApplicationRole; + applicationType: ApplicationType; + teamMembers: TeamMemberInput[]; +} + +// applications has no public SELECT policy (contains PII) — reads go +// through the service-role client, same as participants. +export async function findApplicationByStudentId( + studentId: string, +): Promise { + const supabase = createAdminClient(); + const { data, error } = await supabase + .from("applications") + .select("id") + .eq("student_id", studentId) + .maybeSingle(); + throwIfError(error); + return !!data; +} + +export async function createApplication( + input: ApplicationInput, +): Promise { + const supabase = createAdminClient(); + const { data, error } = await supabase + .from("applications") + .insert({ + name: input.name, + student_id: input.studentId, + college: input.college, + department: input.department, + phone: input.phone, + birth_date: input.birthDate, + role: input.role, + application_type: input.applicationType, + }) + .select("id") + .single(); + throwIfError(error); + if (!data) throw new Error("지원서 생성에 실패했어요"); + + if (input.applicationType === "team" && input.teamMembers.length > 0) { + const { error: membersError } = await supabase + .from("application_team_members") + .insert( + input.teamMembers.map((member, index) => ({ + application_id: data.id, + position: index, + name: member.name, + college: member.college, + department: member.department, + })), + ); + throwIfError(membersError); + } +} diff --git a/src/entities/application/model/college-department.ts b/src/entities/application/model/college-department.ts new file mode 100644 index 0000000..fc3abd9 --- /dev/null +++ b/src/entities/application/model/college-department.ts @@ -0,0 +1,82 @@ +// Best-effort snapshot of 숭실대학교's 단과대/학과 structure, compiled from +// public sources (not scraped from an official, always-current listing) — +// verify against https://iphak.ssu.ac.kr before relying on this for a real +// application cycle, and update this single array if departments changed. +export const COLLEGE_DEPARTMENTS = [ + { + college: "IT대학", + departments: [ + "글로벌미디어학부", + "컴퓨터학부", + "전자정보공학부", + "디지털미디어학과", + ], + }, + { college: "AI대학", departments: ["AI소프트웨어학부", "정보보호학과"] }, + { + college: "인문대학", + departments: [ + "기독교학과", + "국어국문학과", + "영어영문학과", + "독어독문학과", + "불어불문학과", + "중어중문학과", + "일어일문학과", + "철학과", + "사학과", + "예술창작학부", + "스포츠학부", + ], + }, + { + college: "자연과학대학", + departments: [ + "수학과", + "물리학과", + "화학과", + "정보통계·보험수리학과", + "의생명시스템학부", + ], + }, + { college: "법과대학", departments: ["법학과", "국제법무학과"] }, + { + college: "사회과학대학", + departments: [ + "사회복지학부", + "행정학부", + "정치외교학과", + "정보사회학과", + "언론홍보학과", + "평생교육학과", + ], + }, + { + college: "경제통상대학", + departments: ["경제학과", "글로벌통상학과", "금융경제학과", "국제무역학과"], + }, + { + college: "경영대학", + departments: ["경영학부", "벤처중소기업학과", "회계학과", "금융학부"], + }, + { + college: "공과대학", + departments: [ + "화학공학과", + "신소재공학과", + "전기공학부", + "기계공학부", + "산업정보시스템공학과", + "건축학부", + ], + }, + { college: "베어드학부대학", departments: ["자유전공학부"] }, +] as const; + +export type College = (typeof COLLEGE_DEPARTMENTS)[number]["college"]; + +export function departmentsFor(college: string): readonly string[] { + return ( + COLLEGE_DEPARTMENTS.find((c) => c.college === college)?.departments ?? [] + ); +} diff --git a/src/features/submit-application/index.ts b/src/features/submit-application/index.ts new file mode 100644 index 0000000..83ea27a --- /dev/null +++ b/src/features/submit-application/index.ts @@ -0,0 +1 @@ +export { SubmitApplicationForm } from "./ui/submit-application-form"; diff --git a/src/features/submit-application/model/schema.ts b/src/features/submit-application/model/schema.ts new file mode 100644 index 0000000..e34c9c5 --- /dev/null +++ b/src/features/submit-application/model/schema.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; + +export const ROLE_OPTIONS = [ + { value: "pm", label: "PM" }, + { value: "design", label: "Design" }, + { value: "developer", label: "Developer" }, +] as const; + +const PHONE_REGEX = /^\d{2,3}-\d{3,4}-\d{4}$/; +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +function isRealDate(value: string): boolean { + if (!DATE_REGEX.test(value)) return false; + const [year, month, day] = value.split("-").map(Number); + const date = new Date(year, month - 1, day); + return ( + date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day + ); +} + +// No .min(1) here on purpose — item validity is only enforced in +// superRefine below, gated on applicationType === "team". A plain +// z.array(schema-with-min) would validate leftover rows even after +// switching back to "individual" (the section holding the error is +// unmounted, so submit would silently fail with no visible feedback). +const teamMemberSchema = z.object({ + name: z.string().trim(), + college: z.string(), + department: z.string(), +}); + +export const applicationSchema = z + .object({ + name: z.string().trim().min(1, "이름을 입력해주세요"), + studentId: z.string().regex(/^\d{8}$/, "학번 8자리를 입력해주세요"), + college: z.string().min(1, "단과대를 선택해주세요"), + department: z.string().min(1, "학과를 선택해주세요"), + phone: z.string().regex(PHONE_REGEX, "전화번호를 정확히 입력해주세요"), + birthDate: z.string().refine(isRealDate, "생년월일을 정확히 입력해주세요"), + role: z.enum(["pm", "design", "developer"], { + message: "역할을 선택해주세요", + }), + applicationType: z.enum(["individual", "team"]), + teamMembers: z.array(teamMemberSchema).max(4), + }) + .superRefine((data, ctx) => { + if (data.applicationType !== "team") return; + if (data.teamMembers.length < 1) { + ctx.addIssue({ + code: "custom", + path: ["teamMembers"], + message: "팀원을 1명 이상 추가해주세요 (본인 포함 2~5명)", + }); + return; + } + data.teamMembers.forEach((member, index) => { + if (!member.name) { + ctx.addIssue({ + code: "custom", + path: ["teamMembers", index, "name"], + message: "이름을 입력해주세요", + }); + } + if (!member.college) { + ctx.addIssue({ + code: "custom", + path: ["teamMembers", index, "college"], + message: "단과대를 선택해주세요", + }); + } + if (!member.department) { + ctx.addIssue({ + code: "custom", + path: ["teamMembers", index, "department"], + message: "학과를 선택해주세요", + }); + } + }); + }); + +export type ApplicationFormInput = z.infer; diff --git a/src/features/submit-application/ui/apply-form-draft.ts b/src/features/submit-application/ui/apply-form-draft.ts new file mode 100644 index 0000000..f3566ec --- /dev/null +++ b/src/features/submit-application/ui/apply-form-draft.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { UseFormWatch } from "react-hook-form"; +import type { ApplicationFormInput } from "../model/schema"; + +const DRAFT_KEY = "submit-application-draft"; + +export function loadApplicationDraft(): Partial | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(DRAFT_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +export function clearApplicationDraft(): void { + window.localStorage.removeItem(DRAFT_KEY); +} + +// Losing a half-filled form to an accidental refresh/back-navigation is one +// of the biggest blockers for a form this long, so every change gets +// persisted to localStorage without the user doing anything. +export function useApplicationDraftAutosave( + watch: UseFormWatch, +): void { + const timeoutRef = useRef | undefined>( + undefined, + ); + + useEffect(() => { + const subscription = watch((value) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + window.localStorage.setItem(DRAFT_KEY, JSON.stringify(value)); + }, 500); + }); + return () => { + subscription.unsubscribe(); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [watch]); +} diff --git a/src/features/submit-application/ui/college-department-select.tsx b/src/features/submit-application/ui/college-department-select.tsx new file mode 100644 index 0000000..f899f03 --- /dev/null +++ b/src/features/submit-application/ui/college-department-select.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { + type Control, + Controller, + type UseFormSetValue, + useWatch, +} from "react-hook-form"; +import { + SelectContent, + SelectGroup, + SelectItem, + SelectRoot, + SelectTrigger, +} from "seed-design/ui/select"; +import { COLLEGE_DEPARTMENTS, departmentsFor } from "@/entities/application"; +import type { ApplicationFormInput } from "../model/schema"; + +type CollegePath = "college" | `teamMembers.${number}.college`; +type DepartmentPath = "department" | `teamMembers.${number}.department`; + +// Reused for both the applicant's own 단과대/학과 and each team member row — +// the department list depends on the selected college, so picking a college +// resets whatever department was previously selected. +export function CollegeDepartmentFields({ + control, + setValue, + collegeName, + departmentName, + collegeErrorMessage, + departmentErrorMessage, +}: { + control: Control; + setValue: UseFormSetValue; + collegeName: CollegePath; + departmentName: DepartmentPath; + collegeErrorMessage?: string; + departmentErrorMessage?: string; +}) { + const college = useWatch({ control, name: collegeName }) || ""; + + return ( + <> + ( + { + field.onChange(values[0] ?? ""); + // Not shouldValidate: true — that would immediately flash a + // "select a department" error the instant the college + // changes, before the applicant has had a chance to open the + // now-enabled department dropdown. + setValue(departmentName, ""); + }} + > + + + + {COLLEGE_DEPARTMENTS.map((c) => ( + + ))} + + + + )} + /> + ( + field.onChange(values[0] ?? "")} + > + + + + {departmentsFor(college).map((d) => ( + + ))} + + + + )} + /> + + ); +} diff --git a/src/features/submit-application/ui/formatted-fields.tsx b/src/features/submit-application/ui/formatted-fields.tsx new file mode 100644 index 0000000..63b9ec0 --- /dev/null +++ b/src/features/submit-application/ui/formatted-fields.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { IconExclamationmarkCircleFill } from "@karrotmarket/react-monochrome-icon"; +import { VStack } from "@seed-design/react"; +import { useState } from "react"; +import { type Control, Controller } from "react-hook-form"; +import { Callout } from "seed-design/ui/callout"; +import { TextField, TextFieldInput } from "seed-design/ui/text-field"; +import { checkStudentIdAvailableAction } from "@/entities/application"; +import type { ApplicationFormInput } from "../model/schema"; + +// "Custom Input" per SEED Design's TextField docs: TextFieldInput takes a +// plain controlled value/onChange, so formatting is just intercepting +// onChange before it reaches react-hook-form — no masking library needed. +function formatPhone(raw: string): string { + const digits = raw.replace(/\D/g, "").slice(0, 11); + // Seoul's area code is 2 digits (02-XXXX-XXXX); every other area code and + // every mobile prefix is 3 digits — grouping everything as 3 would print + // "021-234-5678" for a Seoul landline instead of "02-1234-5678". + const areaLength = digits.startsWith("02") ? 2 : 3; + if (digits.length <= areaLength) return digits; + + const area = digits.slice(0, areaLength); + const rest = digits.slice(areaLength); + if (rest.length <= 4) return `${area}-${rest}`; + + const last = rest.slice(-4); + const middle = rest.slice(0, rest.length - 4); + return `${area}-${middle}-${last}`; +} + +function formatDate(raw: string): string { + const digits = raw.replace(/\D/g, "").slice(0, 8); + if (digits.length < 5) return digits; + if (digits.length < 7) return `${digits.slice(0, 4)}-${digits.slice(4)}`; + return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`; +} + +export function PhoneField({ + control, + name, + label, + description, +}: { + control: Control; + name: "phone"; + label: string; + description?: string; +}) { + return ( + ( + + + field.onChange(formatPhone(event.target.value)) + } + onBlur={field.onBlur} + /> + + )} + /> + ); +} + +export function BirthDateField({ + control, +}: { + control: Control; +}) { + return ( + ( + + field.onChange(formatDate(event.target.value))} + onBlur={field.onBlur} + /> + + )} + /> + ); +} + +export function StudentIdField({ + control, +}: { + control: Control; +}) { + const [duplicate, setDuplicate] = useState(false); + + return ( + ( + + + { + setDuplicate(false); + field.onChange( + event.target.value.replace(/\D/g, "").slice(0, 8), + ); + }} + onBlur={async () => { + field.onBlur(); + if (/^\d{8}$/.test(field.value)) { + const available = await checkStudentIdAvailableAction( + field.value, + ); + setDuplicate(!available); + } + }} + /> + + {duplicate && ( + } + description="이미 이 학번으로 지원 내역이 있어요. 다시 제출해도 반영되지 않아요." + /> + )} + + )} + /> + ); +} diff --git a/src/features/submit-application/ui/submit-application-form.tsx b/src/features/submit-application/ui/submit-application-form.tsx new file mode 100644 index 0000000..387ab43 --- /dev/null +++ b/src/features/submit-application/ui/submit-application-form.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Field, Text, VStack } from "@seed-design/react"; +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { ActionButton } from "seed-design/ui/action-button"; +import { + SegmentedControl, + SegmentedControlItem, +} from "seed-design/ui/segmented-control"; +import { Snackbar, useSnackbarAdapter } from "seed-design/ui/snackbar"; +import { TextField, TextFieldInput } from "seed-design/ui/text-field"; +import { submitApplicationAction } from "@/entities/application"; +import { + type ApplicationFormInput, + applicationSchema, + ROLE_OPTIONS, +} from "../model/schema"; +import { + clearApplicationDraft, + loadApplicationDraft, + useApplicationDraftAutosave, +} from "./apply-form-draft"; +import { CollegeDepartmentFields } from "./college-department-select"; +import { BirthDateField, PhoneField, StudentIdField } from "./formatted-fields"; +import { SubmitApplicationResult } from "./submit-application-result"; +import { TeamMembersField } from "./team-members-field"; + +const DEFAULT_VALUES: Partial = { + name: "", + studentId: "", + college: "", + department: "", + phone: "", + birthDate: "", + role: "pm", + applicationType: "individual", + teamMembers: [], +}; + +export function SubmitApplicationForm() { + const adapter = useSnackbarAdapter(); + const [submitted, setSubmitted] = useState(false); + const { + register, + control, + handleSubmit, + watch, + reset, + setValue, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(applicationSchema), + defaultValues: DEFAULT_VALUES, + }); + + // Runs client-side only, after the SSR-matching empty form has hydrated — + // avoids a hydration mismatch between the server render and a + // localStorage-backed draft that only exists in the browser. + useEffect(() => { + const draft = loadApplicationDraft(); + if (draft) reset({ ...DEFAULT_VALUES, ...draft }); + }, [reset]); + + useApplicationDraftAutosave(watch); + + const applicationType = watch("applicationType"); + + const onSubmit = handleSubmit(async (data) => { + const result = await submitApplicationAction({ + ...data, + teamMembers: data.applicationType === "team" ? data.teamMembers : [], + }); + if (!result.ok) { + adapter.create({ + onClose: () => {}, + render: () => , + }); + return; + } + clearApplicationDraft(); + setSubmitted(true); + }); + + if (submitted) { + return ; + } + + return ( +
+ + + + + + + + + + + + + + ( + + + 역할 + + {ROLE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + {errors.role && ( + + {errors.role.message} + + )} + + + )} + /> + + ( + + + 지원 방식 + { + field.onChange(value); + if (value === "team" && watch("teamMembers").length === 0) { + setValue("teamMembers", [ + { name: "", college: "", department: "" }, + ]); + } else if (value === "individual") { + // Otherwise a row added while "팀 지원" was selected + // lingers in the hidden array and fails validation + // silently — submit just does nothing with no visible + // error, since the section that would show it is gone. + setValue("teamMembers", []); + } + }} + > + + 개인 지원 + + + 팀 지원 + + + + + )} + /> + + {applicationType === "team" && ( + + )} + + + 지원하기 + + +
+ ); +} diff --git a/src/features/submit-application/ui/submit-application-result.tsx b/src/features/submit-application/ui/submit-application-result.tsx new file mode 100644 index 0000000..7a781c8 --- /dev/null +++ b/src/features/submit-application/ui/submit-application-result.tsx @@ -0,0 +1,23 @@ +import IconCheckmarkCircleFill from "@karrotmarket/react-monochrome-icon/IconCheckmarkCircleFill"; +import { Box, Icon, Text, VStack } from "@seed-design/react"; + +export function SubmitApplicationResult() { + return ( + + + } + color="fg.positive" + /> + 지원이 접수됐어요 + + 검토 후 등록하신 연락처로 개별 안내드릴게요 + + + + ); +} diff --git a/src/features/submit-application/ui/team-members-field.tsx b/src/features/submit-application/ui/team-members-field.tsx new file mode 100644 index 0000000..ee3d127 --- /dev/null +++ b/src/features/submit-application/ui/team-members-field.tsx @@ -0,0 +1,157 @@ +"use client"; + +import IconQuestionmarkCircleLine from "@karrotmarket/react-monochrome-icon/IconQuestionmarkCircleLine"; +import IconXmarkLine from "@karrotmarket/react-monochrome-icon/IconXmarkLine"; +import { Box, HStack, Text, VStack } from "@seed-design/react"; +import { useState } from "react"; +import { + type Control, + type FieldErrors, + type UseFormRegister, + type UseFormSetValue, + useFieldArray, +} from "react-hook-form"; +import { ActionButton } from "seed-design/ui/action-button"; +import { HelpBubbleTrigger } from "seed-design/ui/help-bubble"; +import { TextField, TextFieldInput } from "seed-design/ui/text-field"; +import type { ApplicationFormInput } from "../model/schema"; +import { CollegeDepartmentFields } from "./college-department-select"; + +const MAX_TEAM_MEMBERS = 4; // 본인 포함 최대 5명 + +export function TeamMembersField({ + control, + register, + setValue, + errors, +}: { + control: Control; + register: UseFormRegister; + setValue: UseFormSetValue; + errors: FieldErrors; +}) { + const { fields, append, remove } = useFieldArray({ + control, + name: "teamMembers", + }); + // Opens the moment "팀 지원" is selected (this component only mounts + // then) instead of waiting for the "?" to be tapped — the whole point is + // that applicants easily miss this rule, so surface it immediately. + const [helpOpen, setHelpOpen] = useState(true); + + return ( + + + + 팀원 정보 + + + + + + 본인 포함 {fields.length + 1}/5명 + + + + {errors.teamMembers?.message && ( + + {errors.teamMembers.message} + + )} + + {fields.map((field, index) => ( + + + + + + + + + + + + + + ))} + + = MAX_TEAM_MEMBERS} + onClick={() => append({ name: "", college: "", department: "" })} + > + + 팀원 추가 + + {fields.length >= MAX_TEAM_MEMBERS && ( + + 최대 5명(본인 포함)까지 지원할 수 있어요 + + )} + + ); +} diff --git a/src/shared/lib/supabase/database.types.ts b/src/shared/lib/supabase/database.types.ts index e51f20d..911d711 100644 --- a/src/shared/lib/supabase/database.types.ts +++ b/src/shared/lib/supabase/database.types.ts @@ -60,6 +60,83 @@ export type Database = { }; Relationships: []; }; + application_team_members: { + Row: { + application_id: string; + college: string; + department: string; + id: string; + name: string; + position: number; + }; + Insert: { + application_id: string; + college: string; + department: string; + id?: string; + name: string; + position: number; + }; + Update: { + application_id?: string; + college?: string; + department?: string; + id?: string; + name?: string; + position?: number; + }; + Relationships: [ + { + foreignKeyName: "application_team_members_application_id_fkey"; + columns: ["application_id"]; + isOneToOne: false; + referencedRelation: "applications"; + referencedColumns: ["id"]; + }, + ]; + }; + applications: { + Row: { + application_type: string; + birth_date: string; + college: string; + created_at: string; + department: string; + id: string; + name: string; + phone: string; + role: string; + status: string; + student_id: string; + }; + Insert: { + application_type: string; + birth_date: string; + college: string; + created_at?: string; + department: string; + id?: string; + name: string; + phone: string; + role: string; + status?: string; + student_id: string; + }; + Update: { + application_type?: string; + birth_date?: string; + college?: string; + created_at?: string; + department?: string; + id?: string; + name?: string; + phone?: string; + role?: string; + status?: string; + student_id?: string; + }; + Relationships: []; + }; booth_markers: { Row: { kind: string; diff --git a/supabase/migrations/20260904120000_applications.sql b/supabase/migrations/20260904120000_applications.sql new file mode 100644 index 0000000..bef2568 --- /dev/null +++ b/supabase/migrations/20260904120000_applications.sql @@ -0,0 +1,38 @@ +-- applications: public recruitment intake form. Deliberately separate from +-- participants/teams (the currently-running demo-day roster) — this is +-- reviewed manually by admins and successful applicants get seeded into +-- participants/teams afterwards, same as today's manual onboarding. +create table applications ( + id uuid primary key default gen_random_uuid(), + name text not null, + student_id text not null, + college text not null, + department text not null, + phone text not null, + birth_date date not null, + role text not null check (role in ('pm', 'design', 'developer')), + application_type text not null check (application_type in ('individual', 'team')), + status text not null default 'submitted' check (status in ('submitted', 'reviewing', 'accepted', 'rejected')), + created_at timestamptz not null default now() +); +create unique index applications_student_id_key on applications (student_id); + +-- Reference-only roster of teammates the applicant named for matching — +-- each teammate is expected to submit their own application separately, so +-- these rows carry no contact info, just enough to identify/cross-check them. +create table application_team_members ( + id uuid primary key default gen_random_uuid(), + application_id uuid not null references applications(id) on delete cascade, + position int not null, + name text not null, + college text not null, + department text not null +); +create index application_team_members_application_id_idx on application_team_members (application_id); + +alter table applications enable row level security; +alter table application_team_members enable row level security; + +-- No policies by design — every read/write goes through Server Actions using +-- createAdminClient() (service role, bypasses RLS), same as +-- participants/investors (see 20260814055714_tighten_rls_and_trade_lock.sql). From 5d0884ceeb29f253f313cc00556aeca328d18e71 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Fri, 4 Sep 2026 17:33:21 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EB=8B=A4=EB=93=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AwDeTJKVg5t63jqNDVeiUV --- src/features/submit-application/ui/team-members-field.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/submit-application/ui/team-members-field.tsx b/src/features/submit-application/ui/team-members-field.tsx index ee3d127..6b86052 100644 --- a/src/features/submit-application/ui/team-members-field.tsx +++ b/src/features/submit-application/ui/team-members-field.tsx @@ -46,7 +46,7 @@ export function TeamMembersField({ 팀원 정보 Date: Fri, 4 Sep 2026 17:37:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=A7=80=EC=9B=90=EC=84=9C=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20=EC=95=A1=EC=85=98=EC=97=90=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=82=AC=EC=9D=B4=EB=93=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 보안 리뷰에서 지적된 문제 대응: Server Action은 폼을 거치지 않고도 직접 호출 가능한 엔드포인트라, 클라이언트 zodResolver만으로는 검증이 안 됨. applicationSchema를 엔티티 레이어로 옮겨 서버 액션에서도 동일한 스키마로 재검증하도록 하고, 학번 중복 확인 액션은 8자리 형식이 아니면 DB 조회 없이 거부하도록 함. 팀원 정보 라벨 크기도 TextField description과 통일. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AwDeTJKVg5t63jqNDVeiUV --- src/entities/application/index.ts | 5 + src/entities/application/model/actions.ts | 22 ++++- src/entities/application/model/schema.ts | 86 ++++++++++++++++++ .../submit-application/model/schema.ts | 91 ++----------------- .../ui/team-members-field.tsx | 4 +- 5 files changed, 123 insertions(+), 85 deletions(-) create mode 100644 src/entities/application/model/schema.ts diff --git a/src/entities/application/index.ts b/src/entities/application/index.ts index adcf764..554bafd 100644 --- a/src/entities/application/index.ts +++ b/src/entities/application/index.ts @@ -13,3 +13,8 @@ export { type College, departmentsFor, } from "./model/college-department"; +export { + type ApplicationFormInput, + applicationSchema, + ROLE_OPTIONS, +} from "./model/schema"; diff --git a/src/entities/application/model/actions.ts b/src/entities/application/model/actions.ts index 86c17fa..169351c 100644 --- a/src/entities/application/model/actions.ts +++ b/src/entities/application/model/actions.ts @@ -6,14 +6,25 @@ import { createApplication, findApplicationByStudentId, } from "./application"; +import { applicationSchema } from "./schema"; + +const STUDENT_ID_REGEX = /^\d{8}$/; // Non-blocking early warning (called on studentId blur) — the authoritative // check is the unique constraint hit inside submitApplicationAction, so a // failure here should never stop the applicant from continuing to fill out // the form. +// +// This is also a public existence-check ("has this student ID already +// applied?"), so it doubles as an enumeration oracle — rejecting anything +// that isn't a well-formed student ID at least keeps it from being a free +// probe for arbitrary input, though it doesn't fully close off enumerating +// real 8-digit IDs. There's no rate limiting here (none exists anywhere +// else in this app's login actions either); revisit if abuse shows up. export async function checkStudentIdAvailableAction( studentId: string, ): Promise { + if (!STUDENT_ID_REGEX.test(studentId)) return true; try { return !(await findApplicationByStudentId(studentId)); } catch (error) { @@ -22,11 +33,20 @@ export async function checkStudentIdAvailableAction( } } +// A Server Action is a callable HTTP endpoint, not just a function the form +// happens to call — anything reaching this point over the network may not +// have gone through the client's zodResolver at all, so the same schema is +// re-applied here before touching the database. export async function submitApplicationAction( input: ApplicationInput, ): Promise { + const parsed = applicationSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, message: "입력값을 다시 확인해주세요" }; + } + try { - await createApplication(input); + await createApplication(parsed.data); return { ok: true }; } catch (error) { console.error(error); diff --git a/src/entities/application/model/schema.ts b/src/entities/application/model/schema.ts new file mode 100644 index 0000000..f4701bc --- /dev/null +++ b/src/entities/application/model/schema.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +export const ROLE_OPTIONS = [ + { value: "pm", label: "PM" }, + { value: "design", label: "Design" }, + { value: "developer", label: "Developer" }, +] as const; + +const PHONE_REGEX = /^\d{2,3}-\d{3,4}-\d{4}$/; +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +function isRealDate(value: string): boolean { + if (!DATE_REGEX.test(value)) return false; + const [year, month, day] = value.split("-").map(Number); + const date = new Date(year, month - 1, day); + return ( + date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day + ); +} + +// No .min(1) here on purpose — item validity is only enforced in +// superRefine below, gated on applicationType === "team". A plain +// z.array(schema-with-min) would validate leftover rows even after +// switching back to "individual" (the section holding the error is +// unmounted, so submit would silently fail with no visible feedback). +const teamMemberSchema = z.object({ + name: z.string().trim(), + college: z.string(), + department: z.string(), +}); + +// Shared between the client form (react-hook-form resolver) and the server +// action (re-validated there since a Server Action is a callable endpoint — +// nothing stops a request from bypassing the form entirely). +export const applicationSchema = z + .object({ + name: z.string().trim().min(1, "이름을 입력해주세요"), + studentId: z.string().regex(/^\d{8}$/, "학번 8자리를 입력해주세요"), + college: z.string().min(1, "단과대를 선택해주세요"), + department: z.string().min(1, "학과를 선택해주세요"), + phone: z.string().regex(PHONE_REGEX, "전화번호를 정확히 입력해주세요"), + birthDate: z.string().refine(isRealDate, "생년월일을 정확히 입력해주세요"), + role: z.enum(["pm", "design", "developer"], { + message: "역할을 선택해주세요", + }), + applicationType: z.enum(["individual", "team"]), + teamMembers: z.array(teamMemberSchema).max(4), + }) + .superRefine((data, ctx) => { + if (data.applicationType !== "team") return; + if (data.teamMembers.length < 1) { + ctx.addIssue({ + code: "custom", + path: ["teamMembers"], + message: "팀원을 1명 이상 추가해주세요 (본인 포함 2~5명)", + }); + return; + } + data.teamMembers.forEach((member, index) => { + if (!member.name) { + ctx.addIssue({ + code: "custom", + path: ["teamMembers", index, "name"], + message: "이름을 입력해주세요", + }); + } + if (!member.college) { + ctx.addIssue({ + code: "custom", + path: ["teamMembers", index, "college"], + message: "단과대를 선택해주세요", + }); + } + if (!member.department) { + ctx.addIssue({ + code: "custom", + path: ["teamMembers", index, "department"], + message: "학과를 선택해주세요", + }); + } + }); + }); + +export type ApplicationFormInput = z.infer; diff --git a/src/features/submit-application/model/schema.ts b/src/features/submit-application/model/schema.ts index e34c9c5..52d3eda 100644 --- a/src/features/submit-application/model/schema.ts +++ b/src/features/submit-application/model/schema.ts @@ -1,83 +1,8 @@ -import { z } from "zod"; - -export const ROLE_OPTIONS = [ - { value: "pm", label: "PM" }, - { value: "design", label: "Design" }, - { value: "developer", label: "Developer" }, -] as const; - -const PHONE_REGEX = /^\d{2,3}-\d{3,4}-\d{4}$/; -const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; - -function isRealDate(value: string): boolean { - if (!DATE_REGEX.test(value)) return false; - const [year, month, day] = value.split("-").map(Number); - const date = new Date(year, month - 1, day); - return ( - date.getFullYear() === year && - date.getMonth() === month - 1 && - date.getDate() === day - ); -} - -// No .min(1) here on purpose — item validity is only enforced in -// superRefine below, gated on applicationType === "team". A plain -// z.array(schema-with-min) would validate leftover rows even after -// switching back to "individual" (the section holding the error is -// unmounted, so submit would silently fail with no visible feedback). -const teamMemberSchema = z.object({ - name: z.string().trim(), - college: z.string(), - department: z.string(), -}); - -export const applicationSchema = z - .object({ - name: z.string().trim().min(1, "이름을 입력해주세요"), - studentId: z.string().regex(/^\d{8}$/, "학번 8자리를 입력해주세요"), - college: z.string().min(1, "단과대를 선택해주세요"), - department: z.string().min(1, "학과를 선택해주세요"), - phone: z.string().regex(PHONE_REGEX, "전화번호를 정확히 입력해주세요"), - birthDate: z.string().refine(isRealDate, "생년월일을 정확히 입력해주세요"), - role: z.enum(["pm", "design", "developer"], { - message: "역할을 선택해주세요", - }), - applicationType: z.enum(["individual", "team"]), - teamMembers: z.array(teamMemberSchema).max(4), - }) - .superRefine((data, ctx) => { - if (data.applicationType !== "team") return; - if (data.teamMembers.length < 1) { - ctx.addIssue({ - code: "custom", - path: ["teamMembers"], - message: "팀원을 1명 이상 추가해주세요 (본인 포함 2~5명)", - }); - return; - } - data.teamMembers.forEach((member, index) => { - if (!member.name) { - ctx.addIssue({ - code: "custom", - path: ["teamMembers", index, "name"], - message: "이름을 입력해주세요", - }); - } - if (!member.college) { - ctx.addIssue({ - code: "custom", - path: ["teamMembers", index, "college"], - message: "단과대를 선택해주세요", - }); - } - if (!member.department) { - ctx.addIssue({ - code: "custom", - path: ["teamMembers", index, "department"], - message: "학과를 선택해주세요", - }); - } - }); - }); - -export type ApplicationFormInput = z.infer; +// Re-exported from the entity layer so the server action can validate +// against the exact same schema the client form uses — see +// src/entities/application/model/schema.ts for the source of truth. +export { + type ApplicationFormInput, + applicationSchema, + ROLE_OPTIONS, +} from "@/entities/application"; diff --git a/src/features/submit-application/ui/team-members-field.tsx b/src/features/submit-application/ui/team-members-field.tsx index 6b86052..b5e76c7 100644 --- a/src/features/submit-application/ui/team-members-field.tsx +++ b/src/features/submit-application/ui/team-members-field.tsx @@ -43,7 +43,9 @@ export function TeamMembersField({ - 팀원 정보 + + 팀원 정보 +