나중에 바꾸기 가장 어려운 부분

데이터 구조

무엇을 저장하고 서로 어떻게 이어지는지 관계도와 함께.

31,724자 · 시스템이 만든 그대로입니다

사람이 손댄 곳 3군데
  • · 3단계 ‘추론 확인’에서 두 곳을 고쳤습니다. 인터뷰에 서류 만료 알림을 첫 버전에 넣겠다고 적었는데, 정작 서류 관리 자체는 후속 버전이었습니다. 그대로 두면 없는 데이터를 읽어 알림을 보내는 문서가 되므로, 첫 버전 알림을 시간표 변경 하나로 줄이고 필수 화면 목록에 알림함을 넣었습니다.
  • · 4단계에서 확인할 사용 흐름 세 가지를 다시 묶었습니다. 처음 제안된 묶음은 화면 열한 개를 순서대로 셋으로 나눈 것이라 운영자 화면과 강사 화면이 한 흐름에 섞여 있었습니다. 흐름은 “이 순서로 막힘없이 이어지는가”를 보는 자리이므로 역할별로 다시 나누고 이름도 업체가 실제로 하는 일로 바꿨습니다.
  • · 문서 본문은 손대지 않았습니다. 위 두 가지는 모두 인터뷰 답변과 화면 목록을 고친 것이고, 그 뒤 문서는 고쳐진 답변으로 다시 만들어졌습니다.

방과후 운영 한눈에 데이터 구조 문서

1. 설계 범위

이 데이터 구조는 첫 개발 범위인 다음 업무를 저장하고 조회하기 위한 것이다.

  • 위탁업체 계정과 운영자·강사 역할 관리
  • 학교·강사·학기·교시 템플릿 관리
  • 수업 계획 직접 등록과 엑셀 일괄 등록
  • 실제 시작·종료 시각 기준 시간 중복 검사
  • 같은 학교 시간 겹침 예외 사유 기록
  • 전체 시간표 확인
  • 확정 시간표 생성과 공개
  • 강사 초대, 개인 시간표, 알림함

서류 상태, 대체강사 탐색, 정산, 민원, 수업 회차, 재량휴업일, 단축수업별 운영, 직전 학기 복사는 첫 개발 범위에서 제외한다. 따라서 관련 표도 이번 데이터 구조에 넣지 않는다.

민감한 서류 원본 파일, 성범죄 경력·아동학대 전력 조회 회신서 원본, 카카오톡 대화 내용은 저장하지 않는다. (NFR-005)


2. 데이터 설계 원칙

원칙적용 방식관련 요구사항
위탁업체별 데이터 분리학교, 강사, 수업 계획, 공개본, 알림, 엑셀 임시 등록 데이터에 agencyId를 둔다. 조회·수정 시 로그인한 사용자의 위탁업체 식별값과 항상 비교한다.NFR-001, FR-002
이름이 아닌 고유 식별값 사용학교명·강사명이 같아도 schoolId, instructorId로 구분한다.FR-003, FR-004, FR-008
실제 시간으로 충돌 판단교시명은 화면 표시와 입력 편의를 위해 저장하되, 시간 중복 판단은 actualStartTime, actualEndTime으로 한다.FR-005, FR-009, FR-010, NFR-003
공개 시간표는 당시 모습 보존수업 계획을 나중에 수정해도 과거에 강사에게 공개한 시간표를 확인할 수 있도록 공개본에 수업 내용을 복사해 저장한다.FR-014, FR-019
엑셀은 저장 전 임시 검증업로드 파일을 즉시 수업 계획으로 저장하지 않는다. 행별 오류를 수정한 뒤 한 번에 확정한다.FR-007, FR-008, NFR-006
비밀번호·민감 파일 최소화비밀번호는 원문이 아니라 암호화 해시값만 저장한다. 민감 서류 원본은 저장하지 않는다.NFR-004, NFR-005

3. 주요 데이터와 관계

그림을 그리는 중…

  • ClassPlan은 실제 운영 시간표의 원본이다. 운영자는 이 데이터를 등록·수정하며, 강사는 공개 전에는 볼 수 없다.
  • ConfirmedTimetableItem은 특정 시점에 확정·공개한 수업 계획의 복사본이다. 이후 원본 수업 계획이 바뀌어도 이전 공개 이력을 유지한다.
  • ImportBatch, ImportRow는 엑셀을 올린 뒤 검토하는 임시 데이터다. 오류 없는 행만 수업 계획으로 확정한다.

표 이름과 사업 용어

영문 표 이름사업 용어용도관련 요구사항
Agency위탁업체서비스 이용 업체와 업체별 데이터 분리 기준FR-001, FR-002, NFR-001
User사용자 계정로그인 가능한 운영자·강사 계정FR-001, FR-002, FR-015, NFR-004
AgencyMember위탁업체 운영자 권한운영자 계정과 위탁업체의 연결, 역할 관리FR-001, FR-002
School학교위탁업체가 수업을 운영하는 초등학교·중학교FR-003
Instructor강사수업 담당 강사와 강사 계정 연결 정보FR-004, FR-015, FR-016
Semester학기학교별 시간표 운영 기간FR-005, FR-009, FR-013, FR-014
PeriodTemplate교시 템플릿특정 학기·적용 기간의 교시표 설정FR-005
PeriodEntry교시표 항목요일별 교시명과 실제 시작·종료 시각FR-005
ClassPlan수업 계획학교·강사·요일·교시·실제 시간으로 구성된 반복 수업FR-008~FR-014
SameSchoolOverlapException같은 학교 시간 겹침 예외같은 학교 안 시간 겹침의 사유와 처리 기록FR-012, FR-013, FR-014, FR-019
ConfirmedTimetable확정 시간표충돌 검사를 통과해 공개 가능한 수업 묶음FR-014, FR-019
ConfirmedTimetableItem확정 시간표 수업 항목공개 당시 수업 계획을 보존한 항목FR-014, FR-016, FR-019
TimetablePublication시간표 공개확정 시간표의 공개 시각과 공개 식별값FR-014, FR-017, FR-019
InstructorInvitation강사 초대강사 정보와 로그인 계정을 안전하게 연결하는 초대FR-015
Notification변경 알림시간표 공개·변경 사실을 강사 알림함에 전달FR-017, FR-018
ImportBatch엑셀 등록 묶음한 번의 엑셀 업로드 검증 작업FR-007, FR-008
ImportRow엑셀 행업로드된 개별 수업 행, 오류와 수정값FR-007, FR-008

4. 표별 데이터 구조

4.1 계정·위탁업체·권한

Agency — 위탁업체

필드형식필수설명
idUUID위탁업체 고유 식별값
name문자열위탁업체 표시 이름
createdAt일시업체 계정 시작 시각
updatedAt일시마지막 수정 시각
  • 위탁업체 생성과 대표 운영자 권한 부여는 하나의 처리로 완료해야 한다. 업체만 생성되고 운영자가 연결되지 않은 불완전 데이터가 남으면 안 된다. (FR-001)
  • 위탁업체 이름은 표시용이다. 다른 위탁업체와 이름이 같더라도 서비스 내부에서는 id로 구분한다.

User — 사용자 계정

필드형식필수설명
idUUID사용자 계정 고유 식별값
email문자열로그인과 초대 연결에 사용할 이메일
passwordHash문자열암호화된 비밀번호 해시값
displayName문자열화면에 표시할 이름
createdAt일시계정 생성 시각
updatedAt일시마지막 수정 시각
  • 원문 비밀번호는 저장하지 않는다.
  • 로그인 실패 시 이메일 존재 여부를 과도하게 알려주지 않는다. (FR-002, NFR-004)

AgencyMember — 위탁업체 운영자 권한

필드형식필수설명
idUUID운영자 권한 식별값
agencyIdUUID소속 위탁업체
userIdUUID운영자 사용자 계정
role열거형첫 버전에서는 OPERATOR만 사용
createdAt일시권한 생성 시각

제약 조건

  • 같은 사용자가 같은 위탁업체에 운영자로 중복 연결되지 않도록 agencyId + userId를 유일하게 관리한다.
  • 운영자 화면 요청 시 AgencyMember 연결 여부를 확인한다. (FR-002, NFR-001)

4.2 학교·강사·학기·교시표

School — 학교

필드형식필수설명
idUUID학교 고유 식별값
agencyIdUUID관리하는 위탁업체
name문자열학교명
createdAt일시등록 시각
updatedAt일시마지막 수정 시각

제약 조건

  • 학교명은 비어 있을 수 없다.
  • 같은 위탁업체 안에서 같은 학교명을 새로 등록하려 할 때 기존 학교인지 확인 경고를 보인다.
  • 학교명을 바꿔도 수업 계획은 schoolId로 연결되므로 관계가 끊기지 않는다. (FR-003)

Instructor — 강사

필드형식필수설명
idUUID강사 고유 식별값
agencyIdUUID소속 위탁업체
userIdUUID아니오초대 연결이 완료된 강사 계정
name문자열강사명
createdAt일시등록 시각
updatedAt일시마지막 수정 시각

제약 조건

  • 강사명은 비어 있을 수 없다.
  • 같은 이름의 강사는 허용한다. 엑셀 업로드 때 이름만으로 자동 연결하지 않고 운영자가 강사를 선택한다. (FR-004, FR-008)
  • userId가 비어 있으면 아직 강사 초대 연결이 끝나지 않은 상태다. (FR-015)

Semester — 학기

필드형식필수설명
idUUID학기 고유 식별값
schoolIdUUID학기를 운영하는 학교
name문자열예: 2026학년도 1학기
startDate날짜학기 시작일
endDate날짜학기 종료일
createdAt일시등록 시각
updatedAt일시마지막 수정 시각

검증 기준

  • endDatestartDate보다 빠를 수 없다.
  • 같은 학교 안에서 동일한 학기명이 중복되지 않도록 관리한다.
  • 수업 계획의 운영 기간은 원칙적으로 연결된 학기 범위 안에 있어야 한다. 학기 범위를 벗어나면 저장 전 운영자에게 확인할 항목으로 표시한다. (FR-005, FR-009)

PeriodTemplate — 교시 템플릿

필드형식필수설명
idUUID교시 템플릿 식별값
semesterIdUUID적용 학기
name문자열예: 기본 교시표
effectiveStartDate날짜적용 시작일
effectiveEndDate날짜적용 종료일
createdAt일시등록 시각
updatedAt일시마지막 수정 시각

PeriodEntry — 교시표 항목

필드형식필수설명
idUUID교시표 항목 식별값
periodTemplateIdUUID소속 교시 템플릿
dayOfWeek열거형월요일~일요일 중 적용 요일
periodName문자열예: 3교시
startTime시각실제 시작 시각
endTime시각실제 종료 시각
sortOrder숫자화면 정렬 순서

검증 기준

  • 종료 시각은 시작 시각보다 늦어야 한다.
  • 같은 교시 템플릿 안에서 동일 요일·교시명이 중복되지 않도록 한다.
  • 같은 학교·학기·요일·적용 기간에서 어느 교시 템플릿을 적용할지 하나로 정할 수 없으면 저장을 막는다. (FR-005)

4.3 수업 계획과 시간 중복 예외

ClassPlan — 수업 계획

필드형식필수설명
idUUID수업 계획 고유 식별값
agencyIdUUID위탁업체 데이터 분리 기준
schoolIdUUID수업 운영 학교
semesterIdUUID수업이 속한 학기
instructorIdUUID담당 강사
periodEntryIdUUID아니오교시표 항목으로 등록한 경우의 연결값
subject문자열과목
dayOfWeek열거형반복 수업 요일
operatingStartDate날짜운영 기간 시작일
operatingEndDate날짜운영 기간 종료일
periodName문자열아니오화면 표시용 교시명
actualStartTime시각시간 중복 판단 기준 시작 시각
actualEndTime시각시간 중복 판단 기준 종료 시각
timeSource열거형PERIOD_TEMPLATE 또는 MANUAL
status열거형TEMPORARY 또는 CONFIRMED
createdAt일시등록 시각
updatedAt일시마지막 수정 시각

사용 규칙

  1. 교시를 선택해 등록하면 교시 템플릿의 실제 시작·종료 시각을 수업 계획에 복사해 저장한다.
  2. 실제 시간을 직접 입력하면 timeSourceMANUAL로 기록한다.
  3. 시간 중복은 periodName이 아니라 actualStartTime, actualEndTime으로 판단한다.
  4. 14:00~14:40, 14:40~15:20처럼 앞 수업 종료 시각과 다음 수업 시작 시각이 같으면 시간 중복이 아니다.
  5. 수업 계획의 공개 여부는 원본 수업 계획에 단순 표시하지 않는다. 어떤 수업이 언제 공개되었는지는 확정 시간표와 시간표 공개 기록으로 판단한다. (FR-009, FR-010, FR-014)

SameSchoolOverlapException — 같은 학교 시간 겹침 예외

필드형식필수설명
idUUID예외 기록 식별값
agencyIdUUID위탁업체
schoolIdUUID시간이 겹치는 동일 학교
classPlanAIdUUID첫 번째 수업 계획
classPlanBIdUUID두 번째 수업 계획
reason문자열운영자가 입력한 예외 사유
createdByMemberIdUUID예외를 처리한 운영자 권한
createdAt일시예외 저장 시각

검증 기준

  • 두 수업 계획은 서로 달라야 한다.
  • 두 수업 계획은 같은 위탁업체, 같은 학교, 같은 강사여야 한다.
  • 운영 기간, 요일, 실제 시간이 실제로 겹칠 때만 예외 기록을 허용한다.
  • 서로 다른 학교의 시간 중복은 예외 사유를 입력해도 저장할 수 없다. (FR-010, FR-012)
  • 수업의 학교·강사·요일·운영 기간·실제 시간이 수정되면 기존 예외가 여전히 유효한지 재검사한다. 유효하지 않으면 예외를 해제하고 다시 충돌 검사를 수행한다.

4.4 확정 시간표·공개본·알림

ConfirmedTimetable — 확정 시간표

필드형식필수설명
idUUID확정 시간표 식별값
agencyIdUUID위탁업체
title문자열운영자가 구분할 수 있는 확정 시간표 이름
createdByMemberIdUUID확정 처리한 운영자
createdAt일시확정 시각
  • 확정 전에는 포함할 모든 수업 계획에 대해 시간 중복을 다시 검사한다.
  • 같은 학교 시간 겹침은 유효한 예외 기록이 있을 때만 확정할 수 있다. (FR-014)

ConfirmedTimetableItem — 확정 시간표 수업 항목

필드형식필수설명
idUUID항목 식별값
confirmedTimetableIdUUID소속 확정 시간표
classPlanIdUUID원본 수업 계획
schoolNameSnapshot문자열확정 당시 학교명
instructorIdUUID공개 대상 강사
subjectSnapshot문자열확정 당시 과목
dayOfWeekSnapshot열거형확정 당시 요일
periodNameSnapshot문자열아니오확정 당시 교시
actualStartTimeSnapshot시각확정 당시 시작 시각
actualEndTimeSnapshot시각확정 당시 종료 시각
operatingStartDateSnapshot날짜확정 당시 운영 기간 시작일
operatingEndDateSnapshot날짜확정 당시 운영 기간 종료일
  • 강사 개인 시간표에는 이 표의 공개된 최신 항목을 사용한다.
  • 수업 계획이 수정된 뒤 다시 공개하면 새 확정 시간표 항목을 생성한다. 이전 항목은 삭제하지 않는다. (FR-014, FR-016, FR-019)

TimetablePublication — 시간표 공개

필드형식필수설명
idUUID공개본 식별값
agencyIdUUID위탁업체
confirmedTimetableIdUUID공개한 확정 시간표
publishedAt일시실제 공개 시각
publishedByMemberIdUUID공개 처리한 운영자
  • 하나의 확정 시간표는 첫 버전에서 한 번만 공개한다.
  • 수정된 시간표를 강사에게 다시 보이게 하려면 새 확정 시간표와 새 시간표 공개 기록을 생성한다.
  • 공개 처리와 알림 생성은 하나의 데이터베이스 처리로 완료한다. 일부 강사에게만 공개되거나 일부 알림만 생성되는 상태가 남으면 안 된다. (FR-014, FR-017)

Notification — 변경 알림

필드형식필수설명
idUUID알림 식별값
agencyIdUUID위탁업체
instructorIdUUID알림 수신 강사
publicationIdUUID알림을 만든 시간표 공개
type열거형TIMETABLE_PUBLISHED 또는 TIMETABLE_CHANGED
title문자열알림 제목
message문자열강사가 읽을 알림 내용
readAt일시아니오강사가 읽은 시각
createdAt일시알림 생성 시각
  • 첫 공개 시 영향을 받는 강사에게 TIMETABLE_PUBLISHED 알림을 만든다.
  • 공개된 시간표가 변경되어 다시 공개되면 변경된 수업의 강사에게 TIMETABLE_CHANGED 알림을 만든다.
  • 강사는 자신에게 연결된 instructorId의 알림만 조회할 수 있다. (FR-017, FR-018)

4.5 강사 초대

InstructorInvitation — 강사 초대

필드형식필수설명
idUUID초대 식별값
agencyIdUUID위탁업체
instructorIdUUID초대 대상 강사
tokenHash문자열초대 링크 토큰의 해시값
status열거형PENDING, ACCEPTED, EXPIRED, REVOKED
expiresAt일시초대 링크 만료 시각
acceptedAt일시아니오강사가 초대를 수락한 시각
createdByMemberIdUUID초대를 만든 운영자
createdAt일시초대 생성 시각

처리 규칙

  • 초대 링크에는 원문 토큰을 사용하되 데이터베이스에는 tokenHash만 저장한다.
  • 강사가 초대를 수락하면 Instructor.userId를 로그인 계정과 연결하고 초대 상태를 ACCEPTED로 바꾼다.
  • 이미 다른 사용자 계정에 연결된 강사에게는 중복 연결하지 않는다.
  • 초대 링크 전달은 운영자가 링크를 복사해 직접 전달하는 방식까지 지원한다. 카카오톡 자동 발송은 첫 개발 범위에 넣지 않는다. (FR-015)

4.6 엑셀 임시 등록

ImportBatch — 엑셀 등록 묶음

필드형식필수설명
idUUID업로드 작업 식별값
agencyIdUUID위탁업체
uploadedByMemberIdUUID업로드한 운영자
originalFilename문자열사용자가 확인할 파일명
status열거형UPLOADED, VALIDATING, READY, CONFIRMED, DISCARDED, FAILED
createdAt일시업로드 시각
confirmedAt일시아니오수업 계획 확정 시각

ImportRow — 엑셀 행

필드형식필수설명
idUUID행 식별값
importBatchIdUUID소속 엑셀 등록 묶음
rowNumber숫자원본 엑셀 행 번호
schoolNameInput문자열아니오업로드·수정된 학교명
semesterNameInput문자열아니오업로드·수정된 학기명
instructorNameInput문자열아니오업로드·수정된 강사명
subjectInput문자열아니오업로드·수정된 과목
dayOfWeekInput열거형아니오업로드·수정된 요일
operatingStartDateInput날짜아니오운영 기간 시작일
operatingEndDateInput날짜아니오운영 기간 종료일
periodNameInput문자열아니오입력 교시명
actualStartTimeInput시각아니오입력 시작 시각
actualEndTimeInput시각아니오입력 종료 시각
selectedSchoolIdUUID아니오운영자가 확정한 학교
selectedSemesterIdUUID아니오운영자가 확정한 학기
selectedInstructorIdUUID아니오운영자가 확정한 강사
validationStatus열거형VALID, WARNING, ERROR
validationErrorsJSON아니오오류 코드, 오류 문구, 대상 필드
updatedAt일시마지막 수정 시각

저장 방식

  • 원본 엑셀 파일 자체는 장기 보관하지 않는다.
  • 서비스는 읽어낸 행과 검증 결과만 임시로 저장한다.
  • 운영자가 확정하면 행별 데이터를 ClassPlan으로 저장하고 등록 묶음 상태를 CONFIRMED로 바꾼다.
  • 사용자가 취소하거나 업로드 처리에 실패하면 임시 행을 폐기한다.
  • 장기간 미확정 상태인 임시 데이터의 보관 기간은 실제 운영 정책을 정한 뒤 적용해야 한다. 확인 필요. (FR-007, FR-008, NFR-005, NFR-006)

5. 수업 계획 생명주기

그림을 그리는 중…

  • Temporary는 강사에게 아직 보이지 않는 임시 배정이다.
  • Confirmed는 서로 다른 학교 간 시간 중복이 없고, 필요한 같은 학교 시간 겹침 예외 사유가 확인된 상태다.
  • Published는 원본 수업 계획 상태가 아니라 ConfirmedTimetable, TimetablePublication으로 남기는 공개 이력이다. 수업이 바뀌면 새 공개본을 만들고, 이전 공개본은 Archived 성격의 이력으로 조회한다. (FR-014, FR-019)

6. 시간 중복 검사와 데이터베이스 구현 기준

6.1 판정 조건

같은 강사의 두 수업 계획이 아래 조건을 모두 만족하면 시간 중복이다.

  1. 같은 위탁업체에 속한다.
  2. 같은 강사 고유 식별값을 가진다.
  3. 요일이 같다.
  4. 운영 기간이 겹친다.
  5. 실제 시작·종료 시각이 겹친다.
기존 수업 시작 < 새 수업 종료
그리고
새 수업 시작 < 기존 수업 종료

서로 다른 학교에서 발생한 시간 중복은 저장할 수 없다. (FR-010)

6.2 저장 처리 기준

상황처리관련 요구사항
다른 학교, 같은 강사, 실제 시간 겹침저장 차단 후 충돌한 두 수업을 나란히 표시FR-010, FR-011
같은 학교, 같은 강사, 실제 시간 겹침사유를 입력한 같은 학교 시간 겹침 예외가 있을 때만 저장FR-012
시간이 맞닿기만 함예: 14:00~14:40, 14:40~15:20은 저장 허용FR-010
운영 기간이 겹치지 않음저장 허용FR-010
이름은 같지만 다른 강사 식별값저장 허용FR-004, FR-010
공개 직전 새 충돌 발견확정·공개 차단, 충돌 수업 수정 화면으로 이동FR-014

6.3 서버와 데이터베이스 이중 차단

운영자 화면의 경고만으로는 동시에 저장하는 상황을 막을 수 없다. 따라서 다음 두 단계를 모두 적용한다.

  1. 서버 검증
    수업 계획 저장 요청마다 충돌 후보를 조회하고, 충돌 수업과 수정 가능한 항목을 화면에 반환한다.

  2. 데이터베이스 검증
    저장 직전에도 동일한 충돌 검사를 수행한다. 동시에 두 운영자가 저장해도 서로 다른 학교의 중복 수업이 남지 않게 한다.

Prisma 모델만으로는 “날짜 범위와 시각 범위가 겹치는 행 금지” 및 “같은 학교 예외 사유가 있을 때만 허용” 조건을 완전히 표현하기 어렵다. PostgreSQL 데이터베이스에서는 별도 마이그레이션으로 트리거 또는 제약 조건을 추가해야 한다. (NFR-002)


7. 주요 인덱스와 유일성 조건

대상 표인덱스 또는 제약 조건이유관련 요구사항
AgencyMember유일: agencyId + userId운영자 권한 중복 방지FR-001, FR-002
School유일: agencyId + name같은 위탁업체 안 학교명 중복 경고FR-003
Instructor인덱스: agencyId + name강사 목록 검색과 엑셀 이름 후보 검색FR-004, FR-008
Semester유일: schoolId + name학교별 학기 중복 방지FR-005
PeriodEntry유일: periodTemplateId + dayOfWeek + periodName같은 교시 템플릿 안 교시명 중복 방지FR-005
ClassPlan인덱스: agencyId + instructorId + dayOfWeek동일 강사 시간 중복 후보 조회FR-010, NFR-002
ClassPlan인덱스: schoolId + semesterId + dayOfWeek학교별 시간표 조회FR-009, FR-013
ClassPlan인덱스: agencyId + semesterId + schoolId + instructorId전체 시간표 필터링FR-013
SameSchoolOverlapException유일: 수업 계획 쌍의 정규화된 조합같은 두 수업의 예외 기록 중복 방지FR-012, FR-019
ConfirmedTimetableItem유일: confirmedTimetableId + classPlanId한 확정 시간표에 같은 수업 중복 포함 방지FR-014
Notification인덱스: instructorId + readAt + createdAt강사 알림함의 읽지 않은 알림·최신순 조회FR-018
InstructorInvitation인덱스: instructorId + status강사 목록에서 초대 상태 조회FR-015
ImportRow유일: importBatchId + rowNumber엑셀 행 번호 중복 방지FR-007, FR-008

8. Prisma 모델 초안

아래 초안은 PostgreSQL과 Prisma를 기준으로 한다. 시간 중복의 최종 차단 규칙은 Prisma 스키마 외에 데이터베이스 마이그레이션으로 구현한다. (NFR-002)

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum AgencyRole {
  OPERATOR
}

enum DayOfWeek {
  MON
  TUE
  WED
  THU
  FRI
  SAT
  SUN
}

enum ClassPlanStatus {
  TEMPORARY
  CONFIRMED
}

enum TimeSource {
  PERIOD_TEMPLATE
  MANUAL
}

enum InvitationStatus {
  PENDING
  ACCEPTED
  EXPIRED
  REVOKED
}

enum NotificationType {
  TIMETABLE_PUBLISHED
  TIMETABLE_CHANGED
}

enum ImportBatchStatus {
  UPLOADED
  VALIDATING
  READY
  CONFIRMED
  DISCARDED
  FAILED
}

enum ImportRowStatus {
  VALID
  WARNING
  ERROR
}

model Agency {
  id        String   @id @default(uuid())
  name      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  members              AgencyMember[]
  schools              School[]
  instructors          Instructor[]
  classPlans           ClassPlan[]
  overlapExceptions    SameSchoolOverlapException[]
  confirmedTimetables  ConfirmedTimetable[]
  publications         TimetablePublication[]
  notifications        Notification[]
  invitations          InstructorInvitation[]
  importBatches        ImportBatch[]
}

model User {
  id           String   @id @default(uuid())
  email        String   @unique
  passwordHash String
  displayName  String
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  memberships     AgencyMember[]
  instructorLinks Instructor[]
}

model AgencyMember {
  id        String     @id @default(uuid())
  agencyId  String
  userId    String
  role      AgencyRole @default(OPERATOR)
  createdAt DateTime   @default(now())

  agency              Agency                      @relation(fields: [agencyId], references: [id], onDelete: Cascade)
  user                User                        @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdTimetables   ConfirmedTimetable[]
  createdPublications TimetablePublication[]
  createdExceptions   SameSchoolOverlapException[]
  createdInvitations  InstructorInvitation[]
  uploadedBatches     ImportBatch[]

  @@unique([agencyId, userId])
  @@index([userId])
}

model School {
  id        String   @id @default(uuid())
  agencyId  String
  name      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  agency     Agency      @relation(fields: [agencyId], references: [id], onDelete: Cascade)
  semesters  Semester[]
  classPlans ClassPlan[]

  @@unique([agencyId, name])
  @@index([agencyId])
}

model Instructor {
  id        String   @id @default(uuid())
  agencyId  String
  userId    String?
  name      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  agency        Agency                 @relation(fields: [agencyId], references: [id], onDelete: Cascade)
  user          User?                  @relation(fields: [userId], references: [id], onDelete: SetNull)
  classPlans    ClassPlan[]
  invitations   InstructorInvitation[]
  notifications Notification[]
  timetableItems ConfirmedTimetableItem[]

  @@index([agencyId, name])
  @@index([userId])
}

model Semester {
  id        String   @id @default(uuid())
  schoolId  String
  name      String
  startDate DateTime @db.Date
  endDate   DateTime @db.Date
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  school          School           @relation(fields: [schoolId], references: [id], onDelete: Cascade)
  periodTemplates PeriodTemplate[]
  classPlans      ClassPlan[]

  @@unique([schoolId, name])
  @@index([schoolId, startDate, endDate])
}

model PeriodTemplate {
  id                 String   @id @default(uuid())
  semesterId         String
  name               String
  effectiveStartDate DateTime @db.Date
  effectiveEndDate   DateTime @db.Date
  createdAt          DateTime @default(now())
  updatedAt          DateTime @updatedAt

  semester Semester     @relation(fields: [semesterId], references: [id], onDelete: Cascade)
  entries  PeriodEntry[]

  @@index([semesterId, effectiveStartDate, effectiveEndDate])
}

model PeriodEntry {
  id               String    @id @default(uuid())
  periodTemplateId String
  dayOfWeek        DayOfWeek
  periodName       String
  startTime        DateTime  @db.Time(0)
  endTime          DateTime  @db.Time(0)
  sortOrder        Int

  periodTemplate PeriodTemplate @relation(fields: [periodTemplateId], references: [id], onDelete: Cascade)
  classPlans     ClassPlan[]

  @@unique([periodTemplateId, dayOfWeek, periodName])
  @@index([periodTemplateId, dayOfWeek, sortOrder])
}

model ClassPlan {
  id                 String          @id @default(uuid())
  agencyId           String
  schoolId           String
  semesterId         String
  instructorId       String
  periodEntryId      String?
  subject            String
  dayOfWeek          DayOfWeek
  operatingStartDate DateTime        @db.Date
  operatingEndDate   DateTime        @db.Date
  periodName         String?
  actualStartTime    DateTime        @db.Time(0)
  actualEndTime      DateTime        @db.Time(0)
  timeSource         TimeSource
  status             ClassPlanStatus @default(TEMPORARY)
  createdAt          DateTime        @default(now())
  updatedAt          DateTime        @updatedAt

  agency          Agency                     @relation(fields: [agencyId], references: [id], onDelete: Cascade)
  school          School                     @relation(fields: [schoolId], references: [id], onDelete: Restrict)
  semester        Semester                   @relation(fields: [semesterId], references: [id], onDelete: Restrict)
  instructor      Instructor                 @relation(fields: [instructorId], references: [id], onDelete: Restrict)
  periodEntry     PeriodEntry?               @relation(fields: [periodEntryId], references: [id], onDelete: SetNull)
  exceptionAsA    SameSchoolOverlapException[] @relation("ExceptionClassPlanA")
  exceptionAsB    SameSchoolOverlapException[] @relation("ExceptionClassPlanB")
  timetableItems  ConfirmedTimetableItem[]

  @@index([agencyId, instructorId, dayOfWeek])
  @@index([schoolId, semesterId, dayOfWeek])
  @@index([agencyId, semesterId, schoolId, instructorId])
}

model SameSchoolOverlapException {
  id                  String   @id @default(uuid())
  agencyId            String
  schoolId            String
  classPlanAId        String
  classPlanBId        String
  reason              String
  createdByMemberId   String
  createdAt           DateTime @default(now())

  agency          Agency       @relation(fields: [agencyId], references: [id], onDelete: Cascade)
  school          School       @relation(fields: [schoolId], references: [id], onDelete: Restrict)
  classPlanA      ClassPlan    @relation("ExceptionClassPlanA", fields: [classPlanAId], references: [id], onDelete: Cascade)
  classPlanB      ClassPlan    @relation("ExceptionClassPlanB", fields: [classPlanBId], references: [id], onDelete: Cascade)
  createdByMember AgencyMember @relation(fields: [createdByMemberId], references: [id], onDelete: Restrict)

  @@index([agencyId, schoolId])
  @@index([classPlanAId])
  @@index([classPlanBId])
}

model ConfirmedTimetable {
  id                String   @id @default(uuid())
  agencyId          String
  title             String
  createdByMemberId String
  createdAt         DateTime @default(now())

  agency          Agency                @relation(fields: [agencyId], references: [id], onDelete: Cascade)
  createdByMember AgencyMember          @relation(fields: [createdByMemberId], references: [id], onDelete: Restrict)
  items           ConfirmedTimetableItem[]
  publication     TimetablePublication?
}

model ConfirmedTimetableItem {
  id                         String    @id @default(uuid())
  confirmedTimetableId       String
  classPlanId                String
  instructorId               String
  schoolNameSnapshot         String
  subjectSnapshot            String
  dayOfWeekSnapshot          DayOfWeek
  periodNameSnapshot         String?
  actualStartTimeSnapshot    DateTime  @db.Time(0)
  actualEndTimeSnapshot      DateTime  @db.Time(0)
  operatingStartDateSnapshot DateTime  @db.Date
  operatingEndDateSnapshot   DateTime  @db.Date

  confirmedTimetable ConfirmedTimetable @relation(fields: [confirmedTimetableId], references: [id], onDelete: Cascade)
  classPlan          ClassPlan          @relation(fields: [classPlanId], references: [id], onDelete: Restrict)
  instructor         Instructor         @relation(fields: [instructorId], references: [id], onDelete: Restrict)

  @@unique([confirmedTimetableId, classPlanId])
  @@index([instructorId])
}

model TimetablePublication {
  id                   String   @id @default(uuid())
  agencyId             String
  confirmedTimetableId String   @unique
  publishedByMemberId  String
  publishedAt          DateTime @default(now())

  agency             Agency              @relation(fields: [agencyId], references: [id], onDelete: Cascade)
  confirmedTimetable ConfirmedTimetable  @relation(fields: [confirmedTimetableId], references: [id], onDelete: Restrict)
  publishedByMember  AgencyMember        @relation(fields: [publishedByMemberId], references: [id], onDelete: Restrict)
  notifications      Notification[]

  @@index([agencyId, publishedAt])
}

model Notification {
  id            String           @id @default(uuid())
  agencyId      String
  instructorId  String
  publicationId String
  type          NotificationType
  title         String
  message       String
  readAt        DateTime?
  createdAt     DateTime         @default(now())

  agency      Agency               @relation(fields: [agencyId], references: [id], onDelete: Cascade)
  instructor  Instructor           @relation(fields: [instructorId], references: [id], onDelete: Cascade)
  publication TimetablePublication @relation(fields: [publicationId], references: [id], onDelete: Cascade)

  @@index([instructorId, readAt, createdAt])
  @@index([agencyId, createdAt])
}

model InstructorInvitation {
  id                String           @id @default(uuid())
  agencyId          String
  instructorId      String
  tokenHash         String           @unique
  status            InvitationStatus @default(PENDING)
  expiresAt         DateTime
  acceptedAt        DateTime?
  createdByMemberId String
  createdAt         DateTime         @default(now())

  agency          Agency       @relation(fields: [agencyId], references: [id], onDelete: Cascade)
  instructor      Instructor   @relation(fields: [instructorId], references: [id], onDelete: Cascade)
  createdByMember AgencyMember @relation(fields: [createdByMemberId], references: [id], onDelete: Restrict)

  @@index([instructorId, status])
  @@index([agencyId, status])
}

model ImportBatch {
  id                 String            @id @default(uuid())
  agencyId           String
  uploadedByMemberId String
  originalFilename   String
  status             ImportBatchStatus @default(UPLOADED)
  createdAt          DateTime          @default(now())
  confirmedAt        DateTime?

  agency           Agency       @relation(fields: [agencyId], references: [id], onDelete: Cascade)
  uploadedByMember AgencyMember @relation(fields: [uploadedByMemberId], references: [id], onDelete: Restrict)
  rows             ImportRow[]

  @@index([agencyId, status, createdAt])
}

model ImportRow {
  id                      String          @id @default(uuid())
  importBatchId           String
  rowNumber               Int
  schoolNameInput         String?
  semesterNameInput       String?
  instructorNameInput     String?
  subjectInput            String?
  dayOfWeekInput          DayOfWeek?
  operatingStartDateInput DateTime?       @db.Date
  operatingEndDateInput   DateTime?       @db.Date
  periodNameInput         String?
  actualStartTimeInput    DateTime?       @db.Time(0)
  actualEndTimeInput      DateTime?       @db.Time(0)
  selectedSchoolId        String?
  selectedSemesterId      String?
  selectedInstructorId    String?
  validationStatus        ImportRowStatus @default(ERROR)
  validationErrors        Json?
  updatedAt               DateTime        @updatedAt

  importBatch        ImportBatch @relation(fields: [importBatchId], references: [id], onDelete: Cascade)
  selectedSchool     School?     @relation(fields: [selectedSchoolId], references: [id], onDelete: SetNull)
  selectedSemester   Semester?   @relation(fields: [selectedSemesterId], references: [id], onDelete: SetNull)
  selectedInstructor Instructor? @relation(fields: [selectedInstructorId], references: [id], onDelete: SetNull)

  @@unique([importBatchId, rowNumber])
  @@index([importBatchId, validationStatus])
}

9. 화면별 사용 데이터

화면읽는 데이터생성·수정 데이터관련 요구사항
운영자 로그인 및 업체 시작 화면User, AgencyMemberUser, Agency, AgencyMemberUIR-001, FR-001, FR-002
운영자 화면Agency, School, Instructor, ClassPlan, Notification 요약없음UIR-002, FR-002, FR-013
학교·강사 목록 관리 화면School, Instructor, InstructorInvitationSchool, Instructor, InstructorInvitationUIR-003, FR-003, FR-004, FR-015
학교별 학기·교시표 설정 화면School, Semester, PeriodTemplate, PeriodEntrySemester, PeriodTemplate, PeriodEntryUIR-004, FR-005
학교별 수업 및 강사 배정 등록 화면School, Semester, Instructor, PeriodEntry, ClassPlanClassPlanUIR-005, FR-009~FR-012
엑셀 업로드 확인 및 수정 화면ImportBatch, ImportRow, School, Semester, Instructor, PeriodEntryImportBatch, ImportRow, 확정 시 ClassPlanUIR-006, FR-006~FR-008
전체 시간표와 중복 배정 경고 화면ClassPlan, SameSchoolOverlapException, School, Instructor, SemesterClassPlan, SameSchoolOverlapException, ConfirmedTimetable, TimetablePublicationUIR-007, FR-010~FR-014, FR-019
강사 로그인 및 초대 연결 화면InstructorInvitation, Instructor, UserUser, Instructor, InstructorInvitationUIR-008, FR-002, FR-015
강사 개인 화면ConfirmedTimetableItem, TimetablePublication, Notification없음UIR-009, FR-016~FR-018
강사 개인 시간표 화면최신 TimetablePublicationConfirmedTimetableItem없음UIR-010, FR-016
강사 알림함 화면NotificationNotification.readAtUIR-011, FR-017, FR-018

10. 개인정보와 보관 기준

데이터저장 여부저장 목적처리 기준관련 요구사항
운영자·강사 이메일저장로그인, 강사 초대 연결계정 식별과 초대에 필요한 최소 범위만 저장FR-001, FR-015, NFR-004
사용자 표시 이름·강사명저장시간표 표시, 운영자 식별수업 운영에 필요한 범위만 저장FR-004, FR-016
비밀번호원문 저장 금지로그인 인증passwordHash만 저장NFR-004
학교·수업·시간표 정보저장배정, 중복 검사, 공개위탁업체별로 분리NFR-001
민감 서류 원본저장하지 않음해당 없음후속 서류 기능이 생겨도 원본 저장 여부는 별도 검토 필요NFR-005
엑셀 원본 파일장기 저장하지 않음업로드 행 검증파일명과 읽어낸 행 데이터만 임시 관리FR-007, NFR-005
공개 시간표 이력·예외 사유저장운영 이력 확인공개본과 예외 기록 조회에 사용FR-019

계정 탈퇴, 위탁업체 이용 종료, 엑셀 임시 데이터 삭제 시점은 실제 고객 계약과 운영 방식에 맞춰 별도 보관 정책으로 확정해야 한다. 확인 필요.

데이터 구조 — 방과후·늘봄 강사 배정 관리 서비스 | Prometheon