+++ lib/data/repositories/student-member-repository.ts
... | ... | @@ -0,0 +1,208 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import type { StudentMember } from '@/lib/domain/student-member'; | |
| 3 | +import type { | |
| 4 | + StudentMemberQuery, | |
| 5 | + StudentMemberSearchField, | |
| 6 | +} from '@/lib/domain/student-member-query'; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * mock 학생 회원 Repository. | |
| 10 | + * | |
| 11 | + * 데이터 출처는 외부 시스템 「알콩」이며, 백엔드(edupay-backend)에 학생 회원 조회 API가 아직 | |
| 12 | + * 없어 mock으로 구현한다. 공개 시그니처(도메인 타입만 주고받음)는 백엔드 연동 후에도 유지한다 — | |
| 13 | + * 연동 시점에는 이 파일의 내부 구현만 실제 API 호출로 교체하고, 각 함수에 캐시 전략 | |
| 14 | + * (`fetchStudentMembers`/`fetchStudentMemberById`는 `cache: 'no-store'` — 개인정보를 담은 | |
| 15 | + * 목록이고 검색 조건이 매 요청 달라지며 사용여부가 자주 바뀌므로 재사용 캐시를 두지 않는다)을 | |
| 16 | + * 명시적으로 추가해야 한다. 지금은 실제 `fetch()` 호출이 없어(in-memory mock) 옵션을 걸 대상이 | |
| 17 | + * 없다는 점에 유의 — 위 주석은 연동 시점에 적용할 의도를 남겨두는 것이다. | |
| 18 | + * | |
| 19 | + * mock 데이터는 결정적(deterministic)으로 생성한다 — `Math.random()`을 쓰지 않고 인덱스 기반 | |
| 20 | + * 순환으로 이름·학교·학년 등을 만들어 매 요청 동일한 128건을 반환한다. 모듈이 처음 로드될 때 | |
| 21 | + * 한 번만 생성해(module-level 배열) 이후 요청 간 같은 데이터를 유지하고, | |
| 22 | + * `updateStudentMemberActiveStatus`가 반영한 변경도 프로세스 생존 동안 유지된다(재시작 시 | |
| 23 | + * 초기화 — 실제 영속 저장소가 아님에 유의). | |
| 24 | + */ | |
| 25 | + | |
| 26 | +const TOTAL_MOCK_COUNT = 128; | |
| 27 | +const MEMBER_CODE_PREFIX = 'ST'; | |
| 28 | + | |
| 29 | +const SURNAMES = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임']; | |
| 30 | +const GIVEN_NAMES = [ | |
| 31 | + '민준', | |
| 32 | + '서연', | |
| 33 | + '도윤', | |
| 34 | + '지우', | |
| 35 | + '하은', | |
| 36 | + '주원', | |
| 37 | + '수아', | |
| 38 | + '지호', | |
| 39 | + '예은', | |
| 40 | + '건우', | |
| 41 | + '서윤', | |
| 42 | + '연우', | |
| 43 | + '다은', | |
| 44 | +]; | |
| 45 | +const GUARDIAN_GIVEN_NAMES = ['영수', '순자', '동현', '미경', '재훈', '은영']; | |
| 46 | + | |
| 47 | +const SCHOOL_NAMES = [ | |
| 48 | + '한빛초등학교', | |
| 49 | + '늘푸른초등학교', | |
| 50 | + '서울중학교', | |
| 51 | + '대한중학교', | |
| 52 | + '한강고등학교', | |
| 53 | + '동산고등학교', | |
| 54 | + '중앙초등학교', | |
| 55 | + '푸른중학교', | |
| 56 | +]; | |
| 57 | + | |
| 58 | +/** 모듈 로드 시점에 1회만 고정한다 — 이후 모든 날짜 계산이 이 값 기준 오프셋이라 같은 | |
| 59 | + * 프로세스에서는 매 요청 동일한 결과를 반환한다(Math.random 없이 결정적). */ | |
| 60 | +const GENERATED_AT = new Date(); | |
| 61 | + | |
| 62 | +function formatDate(date: Date): string { | |
| 63 | + const year = date.getFullYear(); | |
| 64 | + const month = String(date.getMonth() + 1).padStart(2, '0'); | |
| 65 | + const day = String(date.getDate()).padStart(2, '0'); | |
| 66 | + return `${year}-${month}-${day}`; | |
| 67 | +} | |
| 68 | + | |
| 69 | +function subtractDays(base: Date, days: number): Date { | |
| 70 | + const result = new Date(base); | |
| 71 | + result.setDate(result.getDate() - days); | |
| 72 | + return result; | |
| 73 | +} | |
| 74 | + | |
| 75 | +function padDigits(value: number, length: number): string { | |
| 76 | + const max = 10 ** length; | |
| 77 | + const normalized = ((value % max) + max) % max; | |
| 78 | + return String(normalized).padStart(length, '0'); | |
| 79 | +} | |
| 80 | + | |
| 81 | +function buildPhoneNumber(seed: number): string { | |
| 82 | + const middle = padDigits(1000 + seed * 137, 4); | |
| 83 | + const last = padDigits(2000 + seed * 271, 4); | |
| 84 | + return `010-${middle}-${last}`; | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** | |
| 88 | + * 인덱스 하나로부터 학생 회원 1건을 결정적으로 만든다. 회원코드는 ST00001~ST00128을 | |
| 89 | + * 생성 순서(index) 그대로 부여하고, 가입일은 "최근 날짜부터 역순"(index가 커질수록 과거)으로 | |
| 90 | + * 채운다 — 즉 index 0(ST00001)이 가장 최근 가입자, index 127(ST00128)이 가장 오래된 | |
| 91 | + * 가입자다. | |
| 92 | + */ | |
| 93 | +function buildStudentMember(index: number): StudentMember { | |
| 94 | + const sequenceNumber = index + 1; | |
| 95 | + const memberCode = `${MEMBER_CODE_PREFIX}${padDigits(sequenceNumber, 5)}`; | |
| 96 | + | |
| 97 | + const surname = SURNAMES[index % SURNAMES.length]; | |
| 98 | + const givenName = | |
| 99 | + GIVEN_NAMES[Math.floor(index / SURNAMES.length) % GIVEN_NAMES.length]; | |
| 100 | + const name = `${surname}${givenName}`; | |
| 101 | + | |
| 102 | + const loginId = `stu${padDigits(sequenceNumber, 4)}`; | |
| 103 | + const grade = (index % 6) + 1; | |
| 104 | + const classNumber = (index % 10) + 1; | |
| 105 | + const studentNumber = (index % 30) + 1; | |
| 106 | + | |
| 107 | + const guardianGivenName = | |
| 108 | + GUARDIAN_GIVEN_NAMES[index % GUARDIAN_GIVEN_NAMES.length]; | |
| 109 | + | |
| 110 | + const joinedAt = formatDate(subtractDays(GENERATED_AT, index)); | |
| 111 | + const birthYear = GENERATED_AT.getFullYear() - (grade + 6); | |
| 112 | + const birthDate = `${birthYear}-${padDigits((index % 12) + 1, 2)}-${padDigits( | |
| 113 | + (index % 28) + 1, | |
| 114 | + 2 | |
| 115 | + )}`; | |
| 116 | + | |
| 117 | + return { | |
| 118 | + id: memberCode, | |
| 119 | + memberCode, | |
| 120 | + name, | |
| 121 | + loginId, | |
| 122 | + phoneNumber: buildPhoneNumber(index), | |
| 123 | + email: `${loginId}@example.com`, | |
| 124 | + role: '학생', | |
| 125 | + schoolName: SCHOOL_NAMES[index % SCHOOL_NAMES.length], | |
| 126 | + grade, | |
| 127 | + classNumber, | |
| 128 | + studentNumber, | |
| 129 | + guardianName: `${surname}${guardianGivenName}`, | |
| 130 | + guardianPhoneNumber: buildPhoneNumber(index + 500), | |
| 131 | + joinedAt, | |
| 132 | + birthDate, | |
| 133 | + // 11명 중 1명 꼴로 비활성 — 조회 팝업의 사용여부 라디오가 실제로 두 상태 모두를 | |
| 134 | + // 반영하는지 검증할 수 있도록 결정적으로 소수를 비활성화한다. | |
| 135 | + isActive: index % 11 !== 0, | |
| 136 | + }; | |
| 137 | +} | |
| 138 | + | |
| 139 | +let mockStudentMembers: StudentMember[] = Array.from( | |
| 140 | + { length: TOTAL_MOCK_COUNT }, | |
| 141 | + (_, index) => buildStudentMember(index) | |
| 142 | +); | |
| 143 | + | |
| 144 | +function matchesKeyword( | |
| 145 | + member: StudentMember, | |
| 146 | + field: StudentMemberSearchField, | |
| 147 | + keyword: string | |
| 148 | +): boolean { | |
| 149 | + return member[field].toLowerCase().includes(keyword); | |
| 150 | +} | |
| 151 | + | |
| 152 | +/** | |
| 153 | + * 검색·정렬·페이징이 적용된 학생 회원 목록을 조회한다. | |
| 154 | + * 캐시 전략: `no-store` 상당 — searchParams 기반이라 조건이 매 요청 달라지고 개인정보를 | |
| 155 | + * 포함하므로 재사용 캐시를 두지 않는다. | |
| 156 | + */ | |
| 157 | +export async function fetchStudentMembers( | |
| 158 | + query: StudentMemberQuery | |
| 159 | +): Promise<{ items: StudentMember[]; totalCount: number }> { | |
| 160 | + const keyword = query.keyword.trim().toLowerCase(); | |
| 161 | + | |
| 162 | + const filtered = keyword | |
| 163 | + ? mockStudentMembers.filter((member) => | |
| 164 | + matchesKeyword(member, query.searchField, keyword) | |
| 165 | + ) | |
| 166 | + : mockStudentMembers; | |
| 167 | + | |
| 168 | + const sorted = [...filtered].sort((a, b) => | |
| 169 | + query.sort === 'name' | |
| 170 | + ? a.name.localeCompare(b.name, 'ko') | |
| 171 | + : b.joinedAt.localeCompare(a.joinedAt) | |
| 172 | + ); | |
| 173 | + | |
| 174 | + const totalCount = sorted.length; | |
| 175 | + const start = (query.page - 1) * query.pageSize; | |
| 176 | + const items = sorted.slice(start, start + query.pageSize); | |
| 177 | + | |
| 178 | + return { items, totalCount }; | |
| 179 | +} | |
| 180 | + | |
| 181 | +/** | |
| 182 | + * 단건 조회 — Server Action의 입력 검증(존재 여부 확인)에 사용한다. | |
| 183 | + * 캐시 전략: `no-store` 상당(위와 동일한 이유). | |
| 184 | + */ | |
| 185 | +export async function fetchStudentMemberById( | |
| 186 | + id: string | |
| 187 | +): Promise<StudentMember | null> { | |
| 188 | + return mockStudentMembers.find((member) => member.id === id) ?? null; | |
| 189 | +} | |
| 190 | + | |
| 191 | +/** | |
| 192 | + * 사용여부만 갱신한다(조회 팝업에서 유일하게 수정 가능한 필드). mock 한정 — 모듈 레벨 배열을 | |
| 193 | + * 불변 갱신(map으로 새 배열 생성)하고 프로세스 생존 동안 유지한다. 쓰기 함수라 캐시 대상이 | |
| 194 | + * 아니다 — 호출부(Server Action)가 `revalidatePath('/students')`로 목록 화면을 재검증한다. | |
| 195 | + */ | |
| 196 | +export async function updateStudentMemberActiveStatus( | |
| 197 | + id: string, | |
| 198 | + isActive: boolean | |
| 199 | +): Promise<void> { | |
| 200 | + const exists = mockStudentMembers.some((member) => member.id === id); | |
| 201 | + if (!exists) { | |
| 202 | + throw new Error(`존재하지 않는 학생 회원입니다: ${id}`); | |
| 203 | + } | |
| 204 | + | |
| 205 | + mockStudentMembers = mockStudentMembers.map((member) => | |
| 206 | + member.id === id ? { ...member, isActive } : member | |
| 207 | + ); | |
| 208 | +} |
+++ lib/domain/student-member-query.ts
... | ... | @@ -0,0 +1,155 @@ |
| 1 | +/** | |
| 2 | + * 학생 회원 목록의 검색·정렬·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만 담는다. | |
| 3 | + * next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준 Web API). | |
| 4 | + * | |
| 5 | + * `app/(protected)/(basic)/students/page.tsx`가 `searchParams`를 `parseStudentMemberQuery`로 | |
| 6 | + * 정규화하는 지점이자 단일 진실원천이며, 검색바·툴바·페이지네이션은 모두 `buildStudentMemberHref`로 | |
| 7 | + * 같은 규칙에 따라 URL을 만들어 파라미터 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다. | |
| 8 | + */ | |
| 9 | + | |
| 10 | +/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */ | |
| 11 | +export const STUDENT_MEMBERS_PATH = '/students'; | |
| 12 | + | |
| 13 | +export type StudentMemberSearchField = | |
| 14 | + | 'name' | |
| 15 | + | 'loginId' | |
| 16 | + | 'phoneNumber' | |
| 17 | + | 'schoolName' | |
| 18 | + | 'memberCode'; | |
| 19 | + | |
| 20 | +export const STUDENT_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{ | |
| 21 | + value: StudentMemberSearchField; | |
| 22 | + label: string; | |
| 23 | +}> = [ | |
| 24 | + { value: 'name', label: '회원명' }, | |
| 25 | + { value: 'loginId', label: 'ID' }, | |
| 26 | + { value: 'phoneNumber', label: '휴대전화번호' }, | |
| 27 | + { value: 'schoolName', label: '학교명' }, | |
| 28 | + { value: 'memberCode', label: '회원코드' }, | |
| 29 | +]; | |
| 30 | + | |
| 31 | +export type StudentMemberSortOption = 'joinedAt' | 'name'; | |
| 32 | + | |
| 33 | +export const STUDENT_MEMBER_SORT_OPTIONS: ReadonlyArray<{ | |
| 34 | + value: StudentMemberSortOption; | |
| 35 | + label: string; | |
| 36 | +}> = [ | |
| 37 | + { value: 'joinedAt', label: '가입일순' }, | |
| 38 | + { value: 'name', label: '이름순' }, | |
| 39 | +]; | |
| 40 | + | |
| 41 | +export const STUDENT_MEMBER_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; | |
| 42 | +export type StudentMemberPageSize = | |
| 43 | + (typeof STUDENT_MEMBER_PAGE_SIZE_OPTIONS)[number]; | |
| 44 | + | |
| 45 | +export const DEFAULT_STUDENT_MEMBER_SEARCH_FIELD: StudentMemberSearchField = | |
| 46 | + 'name'; | |
| 47 | +export const DEFAULT_STUDENT_MEMBER_SORT: StudentMemberSortOption = 'joinedAt'; | |
| 48 | +export const DEFAULT_STUDENT_MEMBER_PAGE_SIZE: StudentMemberPageSize = 10; | |
| 49 | +const DEFAULT_PAGE = 1; | |
| 50 | +const MAX_KEYWORD_LENGTH = 100; | |
| 51 | + | |
| 52 | +export type StudentMemberQuery = { | |
| 53 | + searchField: StudentMemberSearchField; | |
| 54 | + keyword: string; | |
| 55 | + sort: StudentMemberSortOption; | |
| 56 | + page: number; | |
| 57 | + pageSize: StudentMemberPageSize; | |
| 58 | +}; | |
| 59 | + | |
| 60 | +/** Next.js `page.tsx`의 `searchParams`가 리졸브하는 값 형태를 그대로 옮긴 구조 타입 — | |
| 61 | + * next 패키지를 import하지 않고도 같은 shape을 표현해 domain 계층의 무의존 규칙을 지킨다. */ | |
| 62 | +type RawSearchParams = Record<string, string | string[] | undefined>; | |
| 63 | + | |
| 64 | +function readParam(params: RawSearchParams, key: string): string | undefined { | |
| 65 | + const value = params[key]; | |
| 66 | + return Array.isArray(value) ? value[0] : value; | |
| 67 | +} | |
| 68 | + | |
| 69 | +function isStudentMemberSearchField( | |
| 70 | + value: string | undefined | |
| 71 | +): value is StudentMemberSearchField { | |
| 72 | + return ( | |
| 73 | + value !== undefined && | |
| 74 | + STUDENT_MEMBER_SEARCH_FIELD_OPTIONS.some((option) => option.value === value) | |
| 75 | + ); | |
| 76 | +} | |
| 77 | + | |
| 78 | +function isStudentMemberSortOption( | |
| 79 | + value: string | undefined | |
| 80 | +): value is StudentMemberSortOption { | |
| 81 | + return ( | |
| 82 | + value !== undefined && | |
| 83 | + STUDENT_MEMBER_SORT_OPTIONS.some((option) => option.value === value) | |
| 84 | + ); | |
| 85 | +} | |
| 86 | + | |
| 87 | +function isStudentMemberPageSize( | |
| 88 | + value: number | |
| 89 | +): value is StudentMemberPageSize { | |
| 90 | + return (STUDENT_MEMBER_PAGE_SIZE_OPTIONS as readonly number[]).includes( | |
| 91 | + value | |
| 92 | + ); | |
| 93 | +} | |
| 94 | + | |
| 95 | +/** | |
| 96 | + * URL의 searchParams를 검증된 `StudentMemberQuery`로 정규화한다. 값이 없거나 허용 목록을 | |
| 97 | + * 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라 | |
| 98 | + * 신뢰하지 않는다. | |
| 99 | + */ | |
| 100 | +export function parseStudentMemberQuery( | |
| 101 | + searchParams: RawSearchParams | |
| 102 | +): StudentMemberQuery { | |
| 103 | + const searchFieldRaw = readParam(searchParams, 'searchField'); | |
| 104 | + const sortRaw = readParam(searchParams, 'sort'); | |
| 105 | + const keywordRaw = readParam(searchParams, 'keyword'); | |
| 106 | + const pageRaw = Number(readParam(searchParams, 'page')); | |
| 107 | + const pageSizeRaw = Number(readParam(searchParams, 'pageSize')); | |
| 108 | + | |
| 109 | + return { | |
| 110 | + searchField: isStudentMemberSearchField(searchFieldRaw) | |
| 111 | + ? searchFieldRaw | |
| 112 | + : DEFAULT_STUDENT_MEMBER_SEARCH_FIELD, | |
| 113 | + keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH), | |
| 114 | + sort: isStudentMemberSortOption(sortRaw) | |
| 115 | + ? sortRaw | |
| 116 | + : DEFAULT_STUDENT_MEMBER_SORT, | |
| 117 | + page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE, | |
| 118 | + pageSize: isStudentMemberPageSize(pageSizeRaw) | |
| 119 | + ? pageSizeRaw | |
| 120 | + : DEFAULT_STUDENT_MEMBER_PAGE_SIZE, | |
| 121 | + }; | |
| 122 | +} | |
| 123 | + | |
| 124 | +/** | |
| 125 | + * `StudentMemberQuery`(+ 부분 override)를 `/students` 링크로 직렬화한다. `parseStudentMemberQuery`의 | |
| 126 | + * 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다. | |
| 127 | + */ | |
| 128 | +export function buildStudentMemberHref( | |
| 129 | + query: StudentMemberQuery, | |
| 130 | + overrides: Partial<StudentMemberQuery> = {} | |
| 131 | +): string { | |
| 132 | + const merged = { ...query, ...overrides }; | |
| 133 | + const params = new URLSearchParams(); | |
| 134 | + | |
| 135 | + if (merged.searchField !== DEFAULT_STUDENT_MEMBER_SEARCH_FIELD) { | |
| 136 | + params.set('searchField', merged.searchField); | |
| 137 | + } | |
| 138 | + if (merged.keyword) { | |
| 139 | + params.set('keyword', merged.keyword); | |
| 140 | + } | |
| 141 | + if (merged.sort !== DEFAULT_STUDENT_MEMBER_SORT) { | |
| 142 | + params.set('sort', merged.sort); | |
| 143 | + } | |
| 144 | + if (merged.pageSize !== DEFAULT_STUDENT_MEMBER_PAGE_SIZE) { | |
| 145 | + params.set('pageSize', String(merged.pageSize)); | |
| 146 | + } | |
| 147 | + if (merged.page !== DEFAULT_PAGE) { | |
| 148 | + params.set('page', String(merged.page)); | |
| 149 | + } | |
| 150 | + | |
| 151 | + const queryString = params.toString(); | |
| 152 | + return queryString | |
| 153 | + ? `${STUDENT_MEMBERS_PATH}?${queryString}` | |
| 154 | + : STUDENT_MEMBERS_PATH; | |
| 155 | +} |
+++ lib/domain/student-member.ts
... | ... | @@ -0,0 +1,35 @@ |
| 1 | +/** | |
| 2 | + * 학생 회원 도메인 타입 — 순수 데이터 표현, 외부 의존 없음. | |
| 3 | + * | |
| 4 | + * 데이터 출처는 외부 시스템 「알콩」이다. 백엔드(edupay-backend)에 학생 회원 조회 API가 아직 | |
| 5 | + * 없는 구간이라 `student-member-repository.ts`가 결정적 mock 데이터로 이 타입을 채워 제공한다 | |
| 6 | + * — 백엔드 연동 후에도 이 타입 자체는 그대로 유지되고 Repository 내부 구현만 교체된다. | |
| 7 | + * | |
| 8 | + * 조회 전용 화면(등록/수정/삭제 없음)의 데이터라 `isActive`를 제외한 모든 필드는 읽기 전용으로 | |
| 9 | + * 취급한다(수정 가능 여부는 UI 계층의 책임이지 타입 자체의 제약은 아니다). | |
| 10 | + */ | |
| 11 | +export type StudentMember = { | |
| 12 | + /** 내부 식별자. mock 단계에서는 memberCode와 동일한 값을 쓰지만, 실제 백엔드 연동 시에는 | |
| 13 | + * 별도의 PK일 수 있어 memberCode와 분리된 필드로 둔다. */ | |
| 14 | + id: string; | |
| 15 | + /** 화면에 노출되는 회원코드 (예: ST00001). */ | |
| 16 | + memberCode: string; | |
| 17 | + name: string; | |
| 18 | + loginId: string; | |
| 19 | + phoneNumber: string; | |
| 20 | + email: string; | |
| 21 | + /** 회원 역할 — 본 화면(학생 회원 목록)에서는 항상 '학생'이다. */ | |
| 22 | + role: string; | |
| 23 | + schoolName: string; | |
| 24 | + grade: number; | |
| 25 | + classNumber: number; | |
| 26 | + studentNumber: number; | |
| 27 | + guardianName: string; | |
| 28 | + guardianPhoneNumber: string; | |
| 29 | + /** ISO 형식(YYYY-MM-DD) 문자열. */ | |
| 30 | + joinedAt: string; | |
| 31 | + /** ISO 형식(YYYY-MM-DD) 문자열. */ | |
| 32 | + birthDate: string; | |
| 33 | + /** 조회 팝업에서 유일하게 수정 가능한 필드(사용여부). */ | |
| 34 | + isActive: boolean; | |
| 35 | +}; |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?