Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/app/(public)/apply/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex flex-1 flex-col">
<CenteredCard
title="가입 신청"
description="숭실대학교 재적생이면 누구나 지원할 수 있어요"
>
<SubmitApplicationForm />
</CenteredCard>
</main>
);
}
20 changes: 20 additions & 0 deletions src/entities/application/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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";
export {
type ApplicationFormInput,
applicationSchema,
ROLE_OPTIONS,
} from "./model/schema";
59 changes: 59 additions & 0 deletions src/entities/application/model/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use server";

import type { ActionResult } from "@/entities/session";
import {
type ApplicationInput,
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<boolean> {
if (!STUDENT_ID_REGEX.test(studentId)) return true;
try {
return !(await findApplicationByStudentId(studentId));
} catch (error) {
console.error(error);
return true;
}
}

// 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<ActionResult> {
const parsed = applicationSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, message: "입력값을 다시 확인해주세요" };
}

try {
await createApplication(parsed.data);
return { ok: true };
} catch (error) {
console.error(error);
const message =
error instanceof Error && error.message.includes("duplicate key")
? "이미 지원 내역이 있어요"
: "제출에 실패했어요. 잠시 후 다시 시도해주세요";
return { ok: false, message };
}
}
75 changes: 75 additions & 0 deletions src/entities/application/model/application.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<void> {
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);
}
}
82 changes: 82 additions & 0 deletions src/entities/application/model/college-department.ts
Original file line number Diff line number Diff line change
@@ -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 ?? []
);
}
86 changes: 86 additions & 0 deletions src/entities/application/model/schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof applicationSchema>;
1 change: 1 addition & 0 deletions src/features/submit-application/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SubmitApplicationForm } from "./ui/submit-application-form";
8 changes: 8 additions & 0 deletions src/features/submit-application/model/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// 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";
Loading
Loading