diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..9f22dc6 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,303 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "ko-KR" +tone_instructions: "프래그먼트 MVP의 PRD/SPEC/stack 문서를 기준으로 구체적이고 실행 가능한 피드백을 제공하세요. 제품 범위 밖 확장 요구는 지양하고, 단일 조직 근무 스케줄 관리 흐름과 프로젝트 컨벤션 위반을 명확하게 지적하세요." +early_access: false + +reviews: + profile: "assertive" + request_changes_workflow: false + high_level_summary: true + high_level_summary_in_walkthrough: false + poem: false + review_status: true + collapse_walkthrough: false + sequence_diagrams: false + estimate_code_review_effort: true + assess_linked_issues: false + related_issues: false + related_prs: false + suggested_labels: false + auto_apply_labels: false + suggested_reviewers: false + + auto_review: + enabled: true + auto_incremental_review: true + drafts: false + base_branches: + - "develop" + - "main" + ignore_title_keywords: + - "WIP" + - "[WIP]" + - "DO NOT MERGE" + + labeling_instructions: + - label: "feat" + instructions: "새로운 기능이 추가된 PR" + - label: "fix" + instructions: "버그를 수정하는 PR" + - label: "refactor" + instructions: "기능 변경 없이 코드 구조를 개선하는 PR" + - label: "chore" + instructions: "빌드 설정, 패키지 관리, CI 수정 등 PR" + - label: "docs" + instructions: "문서를 추가하거나 수정하는 PR" + - label: "test" + instructions: "테스트 코드를 추가하거나 수정하는 PR" + + path_filters: + - "!**/node_modules/**" + - "!**/.next/**" + - "!**/dist/**" + - "!**/.expo/**" + - "!**/build/**" + - "!**/coverage/**" + - "!**/*.lock" + - "!**/.turbo/**" + + path_instructions: + - path: "apps/api/src/**/*.ts" + instructions: | + Express 백엔드 코드 리뷰 기준: + + [제품 범위] + - PRD/SPEC의 MVP 범위(회원가입/로그인, 단일 조직, 인력, 가능 시간, 최소 인원 조건, 스케줄 추천/조정/확정, 보관함/export)와 맞는지 확인 + - MVP는 계정 1개당 조직 1개만 허용하며, 조직이 없는 계정은 조직 생성 외 운영 API에 접근할 수 없어야 함 + - 운영 화면 API는 인증 및 조직 존재 여부를 확인해야 함 + + [라우터 구조] + - Express Router → Service → Prisma 계층을 기본으로 하는지 확인 + - Router는 요청/응답 처리와 미들웨어 연결만 담당하고, 도메인 로직은 service 함수에 위치해야 함 + - 별도 Repository 추상화는 중복 제거 필요성이 명확할 때만 허용 + - 공통 미들웨어는 middlewares/, 공통 에러 클래스는 errors/, 공통 상수는 common/ 아래에 모아 재사용 + + [Prisma 사용] + - apps/api 내부에 Prisma 관련 파일(schema, migration, seed) 직접 생성 금지 + - apps/api에서는 packages/database에서 export하는 `createPrismaClient()`만 사용 + - `prisma` singleton 직접 import/use 금지 + - 앱 시작점에서 `createPrismaClient()`로 client를 생성하고 app/router/service에 주입 + - Prisma 생성 타입을 그대로 사용; 별도 타입 재정의 금지 + - 스키마/마이그레이션 변경은 packages/database에서 관리 + + [인증 및 인가] + - JWT Access Token 유효기간 15분, Refresh Token 7일 HTTP-only 쿠키 방식 준수 확인 + - 브라우저 저장소(localStorage/sessionStorage)에 토큰 저장 금지 + - 단일 조직 범위를 벗어난 데이터 접근이 불가능한지 확인 + + [유효성 검증] + - 요청 body, params, query는 Zod schema로 검증 + - 공유 가능한 입력/응답 schema는 packages/shared에서 정의하고 웹/API가 함께 사용 + - 날짜, 시간, 인원 수, 이메일, 비밀번호 확인 등 SPEC의 필수 검증 누락 여부 확인 + + [에러 응답] + - 에러 응답 형식 준수 여부 확인: { statusCode, errorCode, message } + - errorCode는 반드시 common/constants/error-codes.ts 상수 사용; 하드코딩 금지 + + [스케줄 도메인] + - 스케줄 상태는 DB에 `DRAFT`, `CONFIRMED`만 저장; `스케줄 없음`은 UI 표시값으로만 사용 + - 추천 생성 전 조직, 인력 1명 이상, 선택 기간의 가능 시간 1개 이상, 최소 인원 조건 1개 이상을 검증 + - 같은 입력 조건으로 이미 생성된 DRAFT가 있으면 중복 생성하지 않아야 함 + - 추천 생성은 미충족 조건(날짜/시간대)을 기록할 수 있어야 함 + - DRAFT는 추가/수정/삭제 가능, CONFIRMED는 대시보드/보관함 조회 및 CSV export 대상 + - 종료 시간이 시작 시간보다 빠르면 익일 종료로 처리하고, 같으면 저장 차단 + - 가능 시간은 조직 운영 시간 기준 30분 단위이며, 휴무일에는 입력/저장할 수 없어야 함 + + - path: "apps/api/src/**/*.spec.ts" + instructions: | + Express/Jest 단위 테스트 리뷰 기준: + - Service 단위 테스트는 Prisma 등 외부 의존성을 Jest mock으로 격리 + - describe/it 블록 명칭이 테스트 의도를 명확히 표현하는지 확인 + - 스케줄 추천 조건 검증, DRAFT/CONFIRMED 상태 전환, 가능 시간/최소 인원 조건 검증을 우선 커버 + - 날짜/시간 경계(익일 종료, 30분 단위, 휴무일)를 테스트하는지 확인 + + - path: "apps/api/test/**/*.ts" + instructions: | + API 통합 테스트 리뷰 기준: + - Supertest로 Express app의 HTTP 요청/응답을 검증 + - Prisma는 fake/mocked client를 주입해 프로덕션 DB 접근을 차단 + - 실제 DB를 사용하는 테스트를 추가하는 경우 DATABASE_URL_TEST 환경 변수와 테스트 전후 DB 초기화 로직 포함 여부 확인 + - 인증 후 조직 존재 여부에 따른 접근 제어와 리다이렉트/오류 흐름 확인 + + - path: "apps/web/**/*.tsx" + instructions: | + Next.js 운영 웹 컴포넌트 리뷰 기준: + + [제품 범위] + - PRD/SPEC의 화면 범위(`/signup`, `/login`, `/organization/new`, `/dashboard`, `/organization`, `/workers`, `/availability`, `/staffing-rules`, `/schedules`, `/schedule-history`)와 흐름을 준수하는지 확인 + - MVP에서는 웹이 전체 사용자 화면을 담당하므로 모바일/태블릿/데스크톱에서 핵심 기능이 가능해야 함 + - 데스크톱 전용 액션이나 모바일에서 가로로 깨지는 달력/타임테이블/테이블 UI를 지적 + + [라우팅 및 렌더링] + - pages/ 디렉터리 사용 금지; App Router(app/)만 허용 + - "use client" 선언은 필요한 최소 범위에만 적용 + - 초기 데이터 로드는 서버 컴포넌트(RSC)에서 처리; 이후 인터랙션은 TanStack Query + + [상태 관리] + - 서버 데이터(조직, 인력, 가능 시간, 최소 인원 조건, 스케줄)는 TanStack Query 사용 + - UI 전역 상태(필터 조건, 선택된 조직 ID, 모달 상태 등)만 Zustand 사용 + - 서버 데이터를 Zustand에 저장하거나 폼 상태를 전역 store에 두는 패턴 지적 + + [폼 및 유효성 검증] + - 모든 폼은 React Hook Form + Zod 조합 사용 + - Zod 스키마는 packages/shared에서 import; 웹 내부 별도 정의 금지 + - SPEC의 입력 검증(필수값, 이메일 형식, 비밀번호 확인, 날짜 범위, 시작/종료 시간, 1명 이상/1 이상의 숫자)을 UI와 schema가 함께 처리하는지 확인 + + [스타일링] + - Tailwind CSS 유틸리티 클래스만 사용; 인라인 style 속성 금지 + - @moyeorak/design-system을 1순위로 사용하고, 없는 컴포넌트만 shadcn/ui/Radix로 보완 + - 반복되는 arbitrary pixel value는 globals.css token 승격을 권장 + - 같은 역할의 버튼/입력 컴포넌트를 한 화면에서 디자인 시스템과 shadcn으로 중복 혼용하지 않도록 확인 + + [인증] + - JWT는 HTTP-only 쿠키에 저장; localStorage 저장 절대 금지 + - 미인증 접근 처리는 Next.js middleware에서 처리 + - 조직이 없는 로그인 사용자는 `/organization/new` 흐름으로 보내야 함 + + [UI 컴포넌트] + - shadcn/ui는 components/ui/ 소스 복사 방식만 사용; npm 패키지 직접 import 금지 + - 도메인 특화 컴포넌트(스케줄 달력, 가능 시간 타임테이블, 최소 인원 조건 폼)는 features/ 아래 위치 + - 가능 시간 UI는 조직 운영 시간 기준 30분 단위와 휴무일 비활성화를 표현해야 함 + - 보관함과 대시보드의 확정 스케줄은 조회/export 중심이며 CONFIRMED 최종본임을 흐름에서 보장 + + [에러 처리] + - API 에러는 TanStack Query onError 또는 error boundary에서 처리 + - 사용자에게 보이는 검증/예외 메시지는 SPEC의 예외 처리 의미와 어긋나지 않아야 함 + + - path: "apps/web/**/*.ts" + instructions: | + Next.js 웹 TypeScript 코드 리뷰 기준: + - any 타입 사용 자제; 구체적인 타입 또는 packages/shared 타입 사용 + - API 요청/응답 타입은 packages/shared/src/types/api/에서 import + - 날짜/시간 계산은 시작/종료 경계, 익일 종료, 30분 단위, 휴무일 처리를 명시적으로 다룰 것 + - 서버 데이터 캐시/Mutation 로직은 TanStack Query 패턴을 따를 것 + + - path: "packages/database/prisma/schema.prisma" + instructions: | + Prisma 스키마 리뷰 기준: + - 단일 조직 MVP에 필요한 계정, 조직, 운영 시간/휴무일, 인력, 가능 시간, 최소 인원 조건, 스케줄/DRAFT/CONFIRMED 모델 관계 확인 + - 스케줄 상태는 `DRAFT`, `CONFIRMED`만 저장하고 UI 표시값인 `스케줄 없음`을 enum/model에 추가하지 않도록 확인 + - 인력 사번은 시스템 자동 생성 기준으로 설계되어야 하며 사용자 직접 입력 의존 금지 + - 가능 시간/스케줄/최소 인원 조건은 시작 시간이 종료 시간과 같을 수 없고, 익일 종료 표현이 가능한 datetime 구조인지 확인 + - 마이그레이션 안전성: 기존 데이터 손실 위험이 없는지 확인 + - 인덱스: 자주 조회되는 필드(외래키, 복합 조건)에 @@index 적용 여부 + - 관계 정합성: 올바른 onDelete/onUpdate 정책 적용 여부 + - 모든 모델에 createdAt, updatedAt 필드 포함 여부 확인 + + - path: "packages/database/prisma/migrations/**" + instructions: | + Prisma 마이그레이션 파일 리뷰 기준: + - 파일명에 변경 목적이 명확히 기재되어 있는지 확인 + - DROP, ALTER COLUMN 등 데이터 손실 가능 작업 경고 + - NOT NULL 컬럼 추가 시 기존 데이터 처리 방식(DEFAULT 값 등) 확인 + - 롤백 시나리오 고려 여부 + + - path: "packages/shared/src/**/*.ts" + instructions: | + 공유 패키지 코드 리뷰 기준: + - 새로운 타입/스키마/유틸은 반드시 packages/shared/src/index.ts에서 re-export + - Zod 스키마에서 타입을 z.infer<>로 추출; 별도 interface/type 선언 금지 + - 순수 함수로만 구현 (사이드 이펙트 없음, 외부 의존성 없음) + - 웹·API 모두에서 사용 가능한 범용적 구현인지 확인 + - PRD/SPEC의 공통 입력 검증(회원가입, 조직 운영 시간, 인력, 가능 시간, 최소 인원 조건, 스케줄 수정)을 schema로 재사용 가능하게 정의 + - 날짜/시간 유틸은 timezone/익일 종료/30분 단위 경계를 호출자가 오해하지 않도록 명확한 이름과 테스트를 둘 것 + + - path: ".github/workflows/**/*.yml" + instructions: | + GitHub Actions 워크플로우 리뷰 기준: + - 시크릿 하드코딩 금지; secrets.* 또는 vars.* 참조 사용 + - actions/* 버전 고정 (SHA 또는 semver 태그 명시) + - pnpm 워크스페이스 명령어 형식 확인: pnpm --filter @fragment/ + - develop 브랜치와 main 브랜치 보호 규칙 일치 여부 확인 + + - path: "packages/database/prisma/seed.ts" + instructions: | + 시드 파일 리뷰 기준: + - 개발/테스트 전용 데이터만 포함; 실제 사용자 데이터 포함 금지 + - 프래그먼트 MVP의 단일 조직/인력/가능 시간/최소 인원 조건/샘플 스케줄 도메인과 맞는 데이터만 포함 + - upsert 사용으로 멱등성 보장 여부 확인 (중복 실행 안전) + + tools: + # TypeScript / JavaScript + eslint: + enabled: true + oxc: + enabled: true + + # 보안 스캐닝 + gitleaks: + enabled: true + trufflehog: + enabled: true + semgrep: + enabled: true + trivy: + enabled: true + + # DB / Prisma + prismaLint: + enabled: true + + # 인프라 / DevOps + actionlint: + enabled: true + hadolint: + enabled: true + shellcheck: + enabled: true + dotenvLint: + enabled: true + + # 문서 / 마크업 + markdownlint: + enabled: true + yamllint: + enabled: true + + # CI 연동 + github-checks: + enabled: true + timeout_ms: 300000 + + finishing_touches: + docstrings: + enabled: false + unit_tests: + enabled: true + +chat: + auto_reply: true + +knowledge_base: + opt_out: false + web_search: + enabled: false + code_guidelines: + enabled: true + filePatterns: + - "CLAUDE.md" + - "apps/api/CLAUDE.md" + - "apps/web/CLAUDE.md" + - "docs/stack.md" + - "docs/PRD.md" + - "docs/SPEC.md" + - "docs/API.md" + - "docs/ERD.md" + - "docs/SWAGGER.md" + - "docs/openapi.yaml" + learnings: + scope: local + issues: + scope: local + pull_requests: + scope: local + +code_generation: + docstrings: + enabled: false + unit_tests: + path_instructions: + - path: "apps/api/src/**/*.ts" + instructions: "Jest와 Supertest 사용. Service 단위 테스트는 Prisma 등 외부 의존성을 Jest mock으로 격리하고, API 통합 테스트는 Express app의 요청/응답을 검증." + - path: "apps/web/**/*.tsx" + instructions: "Vitest와 React Testing Library 사용. 테스트 파일은 대상 파일 옆에 코로케이션하고, API mocking은 테스트 범위가 확정된 경우 MSW 도입 여부를 함께 판단." diff --git a/.env.example b/.env.example index 5def33a..3a344ad 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,14 @@ DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/DB_NAME" +WEB_APP_ORIGIN="http://localhost:3000" +JWT_ACCESS_SECRET="replace-with-local-access-secret" +JWT_ACCESS_EXPIRES_IN="15m" +REFRESH_TOKEN_EXPIRES_DAYS="7" + +# Web app +# Same-origin API proxy/path in production. Use an absolute URL for separate local API server if needed. +NEXT_PUBLIC_API_BASE_URL="/api" + +# GitHub MCP Server +# https://github.com/settings/tokens → Fine-grained token +# 필요 권한: Contents (read), Pull requests (read/write), Issues (read/write), Metadata (read) +GITHUB_PERSONAL_ACCESS_TOKEN="" diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4fefa61..79b6cc1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,25 +2,31 @@ name: 🐛 버그 리포트 about: 버그를 발견했을 때 사용하세요 labels: bug -assignees: '' +assignees: "" --- ## 버그 설명 + ## 재현 방법 -1. -2. -3. + +1. +2. +3. ## 기대 동작 + ## 실제 동작 + ## 스크린샷 / 에러 로그 + ## 추가 정보 - \ No newline at end of file + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 1c377d5..5cfd9fd 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,28 +2,34 @@ name: ✨ 기능 요청 about: 새 기능이나 개선 사항을 제안할 때 사용하세요 labels: feature -assignees: '' +assignees: "" --- ## 목표 + ## 배경 / 필요성 + ## 작업 범위 -- [ ] -- [ ] + +- [ ] +- [ ] ## 완료 조건 (Acceptance Criteria) -- [ ] -- [ ] -- [ ] + +- [ ] +- [ ] +- [ ] ## 제안하는 구현 방식 + ## 관련 이슈 - \ No newline at end of file + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 38a5395..b1eef6a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,5 @@ ## 개요 + Closes #이슈번호 @@ -6,14 +7,15 @@ Closes #이슈번호 --- ## 변경 사항 + -- -- -- ---- +- +- +- *** ## 변경 유형 + - [ ] `feat` — 새로운 기능 - [ ] `fix` — 버그 수정 - [ ] `refactor` — 리팩터링 (기능 변경 없음) @@ -25,37 +27,43 @@ Closes #이슈번호 --- ## 영향 범위 + + - [ ] `web` — `apps/web` - [ ] `api` — `apps/api` - [ ] `db` — `packages/database` - [ ] `shared` — `packages/shared` -- [ ] `mobile` — `apps/mobile` - [ ] `repo` — 루트 설정·CI/CD·문서 --- ## 테스트 방법 + -1. -2. + +1. +2. --- ## 체크리스트 + - [ ] CI (테스트 + 린트) 통과 확인 - [ ] 관련 테스트 코드 작성 또는 업데이트 - [ ] 상태 분류 규칙(서버/전역/로컬/폼 상태) 준수 - [ ] 에러 처리 및 Fallback UI 확인 (해당 시) -- [ ] Swagger 문서 업데이트 (API 변경 시) +- [ ] API 명세 업데이트 (API 변경 시) - [ ] 공유 타입·스키마(`packages/shared`) 업데이트 (타입 변경 시) --- ## 스크린샷 / 영상 + --- ## 리뷰 포인트 - \ No newline at end of file + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8037f7f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + pull_request: + branches: [main, develop] + +jobs: + ci: + name: Lint & Type Check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Format check + run: pnpm format:check + + - name: Type check — shared + run: pnpm --filter @fragment/shared build + + - name: Generate Prisma client + run: pnpm --filter @fragment/database db:generate + + - name: Type check — database + run: pnpm --filter @fragment/database build + + - name: Lint — web + run: pnpm --filter @fragment/web lint + + - name: Type check — web + run: pnpm --filter @fragment/web typecheck + + - name: Test — web + run: pnpm --filter @fragment/web test + + - name: Lint — api + run: pnpm --filter @fragment/api lint + + - name: Type check — api + run: pnpm --filter @fragment/api typecheck + + - name: Test — api + run: pnpm --filter @fragment/api test diff --git a/.gitignore b/.gitignore index 2f76e5f..8f04582 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,17 @@ coverage/ .DS_Store Thumbs.db +# ── 로컬 도구 설정 ─────────────────────────────────────────── +.codex/ +.vscode/ + # ── 개인 참고 문서 ────────────────────────────────────────── docs/erd.dbml docs/monorepo-refactoring.md + +# ── AI 도구 자동 생성 ──────────────────────────────────────── +AGENTS.md +.codex/ + +# ── 에디터 설정 ────────────────────────────────────────────── +.vscode/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..8987cd3 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +set -e + +pnpm format diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..4eee0f0 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} diff --git a/.prettierignore b/.prettierignore index 2a61605..cf0bd37 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,20 @@ -*.yaml \ No newline at end of file +node_modules +dist +.next +.expo +.expo-export +build +coverage +pnpm-lock.yaml + +# shadcn/ui auto-generated components +apps/web/src/components/ui/ + +# Technical documentation (complex tables) +docs/ + +*.png +*.jpg +*.jpeg +*.svg +*.ico diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..6798398 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always" +} diff --git a/CLAUDE.md b/CLAUDE.md index 6b4db90..2f65665 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,10 +2,10 @@ ## 프로젝트 개요 -소·중형 단일 매장부터 다점포 사업장까지, 근무 스케줄 편성과 급여 계산을 자동화하는 SaaS +단일 조직의 인력 정보, 가능 시간, 최소 인원 조건을 기반으로 스케줄 초안을 추천하고, 조정·확정·보관까지 지원하는 근무 스케줄 관리 도구 MVP입니다. -- **관리자(웹)**: 스케줄 편성, 근태 관리, 급여 정산 -- **근무자(모바일 앱)**: 희망 근무 신청, 스케줄 확인, 대타 요청, 급여 명세 확인 +- **운영 웹 (`apps/web`)**: 회원가입/로그인, 단일 조직 생성·수정, 인력 관리, 가능 시간 관리, 최소 인원 조건 관리, 스케줄 추천·조정·확정, 확정 스케줄 보관함 조회/export. MVP에서는 별도 앱 없이 웹이 전체 사용자 화면을 담당하므로 반응형으로 구현한다. +- **백엔드 API (`apps/api`)**: 인증, 단일 조직 관리, 인력 관리, 가능 시간 관리, 최소 인원 조건 관리, 스케줄 추천·확정·조회 API --- @@ -15,17 +15,18 @@ `docs/stack.md`에 명시된 라이브러리 버전을 임의로 변경하거나 업그레이드하지 않는다. 버전 변경이 필요한 경우 반드시 팀과 협의 후 `docs/stack.md`를 먼저 업데이트한다. 앱별 상세 규칙은 각 앱의 `CLAUDE.md`를 참조한다. -| 영역 | 핵심 기술 | -| ---- | --------- | -| 관리자 웹 (`apps/web`) | Next.js (App Router), React, TypeScript, Tailwind CSS, shadcn/ui | -| 근무자 앱 (`apps/mobile`) | Expo (React Native), TypeScript, Expo Router, NativeWind, Gluestack UI | -| 백엔드 (`apps/api`) | NestJS, TypeScript, PostgreSQL, Prisma | -| 공통 (웹·앱) | Zustand (전역 상태), TanStack Query (서버 상태), React Hook Form + Zod (폼) | -| 테스트 | Vitest + React Testing Library + MSW (Frontend), Jest + Supertest (Backend) | -| 인프라 | AWS EC2 (백엔드 API + Next.js SSR), S3 + CloudFront (정적 에셋 CDN), GitHub Actions (CI/CD), pnpm Workspaces | +| 영역 | 핵심 기술 | +| -------------------- | ------------------------------------------------------------------------------------------------------- | +| 운영 웹 (`apps/web`) | Next.js (App Router), React, TypeScript, Tailwind CSS, @moyeorak/design-system(1순위) + shadcn/ui(보조) | +| 백엔드 (`apps/api`) | Express, TypeScript, PostgreSQL, Prisma | +| 공통 | Zustand (전역 상태), TanStack Query (서버 상태), React Hook Form + Zod (폼) | +| 테스트 | Web: Vitest + React Testing Library, API: Jest + Supertest | +| 인프라 | MVP: Vercel(Next.js 운영 웹) + Railway(Express API + PostgreSQL) | **상태 분류 원칙**: 서버 데이터 → TanStack Query / UI 전역 상태 → Zustand / 폼 상태 → React Hook Form / 컴포넌트 로컬 상태 → useState +상태/폼/인증/테스트 라이브러리는 해당 기능 구현 코드 또는 테스트 설정 파일과 함께 `package.json`에 추가한다. + --- ## 프로젝트 구조 @@ -33,30 +34,27 @@ ``` final-project/ ├── apps/ -│ ├── web/ # Next.js — 관리자 웹 (데스크탑 전용) -│ ├── mobile/ # Expo — 근무자 네이티브 앱 (iOS·Android) -│ └── api/ # NestJS — 백엔드 API 서버 +│ ├── web/ # Next.js — 운영 웹 (반응형) +│ └── api/ # Express — 백엔드 API 서버 ├── packages/ -│ ├── shared/ # 공유 Zod 스키마, API 타입, 유틸 (@ilgam/shared) -│ └── database/ # Prisma 스키마·마이그레이션·PrismaService (@ilgam/database) -└── docs/ # PRD, 기능 명세, 기술 스택 정의서, 아키텍처 결정 기록 등 +│ ├── shared/ # 공유 Zod 스키마, API 타입, 유틸 (@fragment/shared) +│ └── database/ # Prisma 스키마·마이그레이션·클라이언트 헬퍼 (@fragment/database) +└── docs/ # PRD, 기능 명세, 기술 스택 정의서, 디자인 참고 문서, ADR 등 ``` **패키지 역할 구분** -- `packages/shared` — 웹·앱·API가 공유하는 Zod 스키마, API 요청·응답 타입, 순수 유틸(GPS 계산 등). 앱 간 공유가 필요한 타입은 `packages/shared/src/index.ts`에서 export한다. -- `packages/database` — Prisma schema, migrations, seed, `PrismaService` 전담. `apps/api`는 여기서 export하는 `DatabaseModule`만 import한다. + +- `packages/database` — Prisma schema, migrations, seed, Prisma client helper 전담. `apps/api`는 여기서 export하는 `createPrismaClient()`로 앱 시작점에서 Prisma client를 생성하고 app/router/service에 주입한다. `prisma` singleton 직접 import는 금지한다. +- `packages/shared` — 웹·API가 공유하는 Zod 스키마, API 요청·응답 타입, 순수 유틸. 공유가 필요한 타입은 `packages/shared/src/index.ts`에서 export한다. --- ## 개발 명령어 ```bash -pnpm dev # Next.js 웹만 실행 -pnpm dev:api # DB 빌드 후 NestJS API 실행 -pnpm dev:all # 웹 + API 병렬 실행 -pnpm dev:mobile # Expo Metro 번들러 시작 -pnpm dev:mobile:ios # iOS 시뮬레이터 -pnpm dev:mobile:android # Android 에뮬레이터 +pnpm dev # Next.js 웹만 실행 +pnpm dev:api # DB 빌드 후 Express API 실행 +pnpm dev:all # 웹 + API 병렬 실행 pnpm db:generate # Prisma 클라이언트 재생성 (스키마 변경 후 필수) pnpm db:migrate # 마이그레이션 실행 @@ -67,6 +65,17 @@ pnpm db:reset # DB 초기화 --- +## 테스트 작성 기준 + +테스트는 처음부터 모든 케이스를 촘촘히 작성하기보다, 새로 만든 핵심 로직이 깨졌는지 확인하는 범위부터 작성한다. + +- API service의 권한, DB 접근 조건, 비즈니스 규칙은 테스트를 작성한다. +- API route는 인증, 요청 검증, 성공 흐름 중 대표 케이스를 테스트한다. +- Web은 기본적으로 typecheck/lint를 우선하고, 복잡한 유틸 또는 hook을 추가할 때만 테스트를 작성한다. +- 버그 수정이나 리뷰 반영으로 검증 로직을 수정한 경우 재발 방지 테스트를 1개 이상 추가한다. + +--- + ## Git 명령어 실행 위치 모노레포에서는 여러 패키지의 코드가 동시에 변경되는 경우가 잦다. **모든 `git` 명령어(`add`, `commit`, `push`, `status` 등)는 반드시 프로젝트 루트 디렉토리에서 실행한다.** @@ -107,20 +116,19 @@ type(scope): 한 줄 설명 (50자 이내) 어떤 모듈이 변경되었는지 타입 뒤 괄호에 명시한다. 여러 패키지가 복합적으로 변경된 경우 스코프를 생략한다. -| 스코프 | 해당 경로 | -| -------- | ------------------------------------------------ | -| `web` | `apps/web` | -| `api` | `apps/api` | -| `db` | `packages/database` (스키마·마이그레이션) | -| `shared` | `packages/shared` (공유 타입·Zod 스키마·유틸) | -| `mobile` | `apps/mobile` | -| `repo` | 루트 설정, CI/CD, 문서 전체 | +| 스코프 | 해당 경로 | +| -------- | --------------------------------------------- | +| `web` | `apps/web` | +| `api` | `apps/api` | +| `db` | `packages/database` (스키마·마이그레이션) | +| `shared` | `packages/shared` (공유 타입·Zod 스키마·유틸) | +| `repo` | 루트 설정, CI/CD, 문서 전체 | **예시** - `feat(web): 조직 생성 페이지 UI 구현` - `feat(api): 조직 생성 API 구현` -- `fix(db): 근로자 테이블 상태 컬럼명 수정` +- `fix(db): 인력 테이블 상태 컬럼명 수정` - `chore(repo): pnpm 의존성 업데이트` - `feat: 회원가입 기능 및 DB 스키마 연동` ← 여러 패키지 복합 변경 시 스코프 생략 @@ -136,12 +144,12 @@ type(scope): 한 줄 설명 (50자 이내) **1 이슈 = 1 PR** 원칙. 레이어(DB / API / UI)가 다르면 이슈를 분리한다. -| 레이어 | 스코프 | 이슈 제목 예시 | -| ------------ | -------- | --------------------------------------- | -| DB 스키마 | `db` | `feat(db): 조직·근로자 테이블 설계` | -| 공유 타입 | `shared` | `feat(shared): 조직 API 응답 타입 정의` | -| 백엔드 API | `api` | `feat(api): 조직 생성 API 구현` | -| 관리자 웹 UI | `web` | `feat(web): 조직 생성 페이지 UI 구현` | +| 레이어 | 스코프 | 이슈 제목 예시 | +| ---------- | -------- | --------------------------------------- | +| DB 스키마 | `db` | `feat(db): 조직·인력 테이블 설계` | +| 공유 타입 | `shared` | `feat(shared): 조직 API 응답 타입 정의` | +| 백엔드 API | `api` | `feat(api): 조직 생성 API 구현` | +| 운영 웹 UI | `web` | `feat(web): 조직 생성 페이지 UI 구현` | 이슈 제목 형식은 커밋 컨벤션과 동일하다(`type(scope): 설명`). 이슈 본문 작성 형식 → `.github/ISSUE_TEMPLATE/feature_request.md` @@ -164,9 +172,24 @@ main - `main`, `develop`에 직접 push 금지 - 모든 작업은 이슈를 먼저 생성하고 브랜치명에 이슈 번호 포함 권장 (`feature/12-login-api`) - `feature → develop`: **Squash and merge** -- `develop → main`: **Merge commit** +- `develop → main`: **Merge commit** + 버전 태그 (`v0.1.0`) - PR은 가능하면 **500줄 이내**로 분리 +### develop → main 머지 기준 + +`develop → main` 머지는 **일정이 아닌 배포 이벤트**에 맞춘다. **마일스톤(FR) 단위**로 관련 기능이 모두 완료되고 `develop`에서 검증이 끝났을 때 머지한다. + +``` +예시 +feat(db): 회원·조직 테이블 설계 ─┐ +feat(api): 조직 생성 API ─┤→ develop 검증 → main 머지 → v0.1.0 태그 +feat(web): 조직 생성 UI ─┘ +``` + +- `develop`은 항상 실행 가능한 상태를 유지한다. CI가 깨진 채로 두지 않는다. +- 기능이 절반만 구현된 상태로 `develop`에 머지하지 않는다. +- `main` 머지 후 반드시 버전 태그를 달아 롤백 기준점을 남긴다. + ### 브랜치 생성 의무 조건 다음 조건 중 하나라도 해당하면 반드시 새 브랜치를 생성한다. @@ -210,9 +233,7 @@ git push origin feature/fr-1-org-management - 제품 요구사항 → `docs/PRD.md` - 기능 명세 → `docs/SPEC.md` - 기술 스택 정의 (버전·호환성) → `docs/stack.md` -- 검증 계획 및 기술 리스크 → `docs/TEST_PLAN.md` - API 서버 개발 규칙 → `apps/api/CLAUDE.md` -- 관리자 웹 개발 규칙 → `apps/web/CLAUDE.md` -- 근무자 앱 개발 규칙 → `apps/mobile/CLAUDE.md` +- 운영 웹 개발 규칙 → `apps/web/CLAUDE.md` - PR 작성 형식 → `.github/pull_request_template.md` - 이슈 작성 형식 → `.github/ISSUE_TEMPLATE/feature_request.md` diff --git a/README.md b/README.md index 0f57bd2..98003ec 100644 --- a/README.md +++ b/README.md @@ -18,4 +18,4 @@ ## 실행 방법 -## 관련 링크 \ No newline at end of file +## 관련 링크 diff --git a/apps/api/CLAUDE.md b/apps/api/CLAUDE.md index 5497715..ccd96da 100644 --- a/apps/api/CLAUDE.md +++ b/apps/api/CLAUDE.md @@ -2,92 +2,115 @@ ## 역할 및 범위 -**NestJS 기반 REST API 서버**입니다. -인증, 조직 관리, 스케줄 엔진, 근태 처리, 급여 계산, Push 알림 발송을 담당합니다. -모든 비즈니스 로직의 원천이며, 웹·앱 양쪽에서 소비합니다. +**Express 기반 REST API 서버**입니다. +MVP에서는 운영 웹이 소비하는 인증, 단일 조직 관리, 인력 관리, 가능 시간 관리, 최소 인원 조건 관리, 스케줄 추천·조정·확정·보관함 조회 API를 담당합니다. ## 기술 스택 -| 항목 | 기술 | 버전 | -|------|------|------| -| Framework | NestJS | 11.x | -| Language | TypeScript | 5.8.x | -| Runtime | Node.js | 22.x LTS | -| Database | PostgreSQL | 17.x | -| ORM | Prisma | 6.8.x (`packages/database`에서 관리) | -| Validation | class-validator + class-transformer | 0.14.x / 0.5.x | -| Auth | @nestjs/jwt + @nestjs/passport | 10.x / 10.x | -| API Docs | @nestjs/swagger | 11.x | -| Task Scheduler | @nestjs/schedule | 4.x | -| Queue (선택적) | @nestjs/bull + bull | 4.x / 4.x (급여 계산·알림 대량 발송 비동기화 시 도입) | -| Testing (Unit) | Jest | 29.x | -| Testing (E2E) | Supertest | 7.x | +기술 스택과 버전은 루트 `docs/stack.md`를 단일 출처로 따른다. 이 문서에는 API 서버에서 사용하는 기술의 역할과 적용 규칙만 기록한다. + +- Framework: Express +- Language: TypeScript +- Runtime: Node.js +- Database: PostgreSQL +- ORM: Prisma (`packages/database`에서 관리) +- Validation: Zod (`packages/shared` 우선 활용) +- Auth: JWT +- API Docs: Markdown API 명세 + OpenAPI +- Testing: Jest + Supertest ## 디렉터리 구조 ``` # apps/api/ ├── src/ -│ ├── auth/ # JWT 발급, 소셜 OAuth, 가드 -│ ├── organizations/ # 조직 CRUD (FR-1.3~1.6) -│ ├── members/ # 근로자 초대·등록·퇴사 (FR-5) -│ ├── schedules/ # 스케줄 생성·확정·교환 (FR-2) -│ ├── attendance/ # 출퇴근 체크, 근태 수정 (FR-3) -│ ├── payroll/ # 급여 계산 엔진, 명세서 발급 (FR-4) -│ ├── notifications/ # Expo Push 알림 발송 -│ └── common/ # 공통 필터, 인터셉터, 데코레이터, 파이프 -└── test/ # E2E 테스트 +│ ├── app.ts # Express 앱 설정 +│ ├── main.ts # 서버 엔트리포인트 +│ ├── routes/ # Express router 등록 +│ ├── modules/ # 도메인별 handler/service +│ ├── middlewares/ # 공통 Express middleware +│ ├── errors/ # 공통 error class +│ ├── common/ # 공통 상수 +│ ├── utils/ # API 내부 유틸 +│ └── types/ # API 타입 확장 # packages/database/ ← apps/api와 동일 레벨, 별도 패키지 ├── prisma/ -│ ├── schema.prisma -│ ├── migrations/ -│ └── seed.ts +│ └── schema.prisma ├── src/ -│ ├── prisma.service.ts -│ └── database.module.ts # DatabaseModule로 export +│ ├── prisma.ts +│ └── index.ts └── package.json ``` +API 도메인 구현이 추가되면 `src/routes`와 `src/modules` 아래에 도메인 단위 파일/디렉터리를 생성합니다. + +```txt +src/ +├── routes/ +│ ├── auth.routes.ts +│ ├── organization.routes.ts +│ └── staffing-rules.routes.ts +└── modules/ + ├── auth/ + │ ├── auth.handlers.ts + │ └── auth.service.ts + ├── organization/ + │ ├── organization.handlers.ts + │ └── organization.service.ts + └── staffing-rules/ + ├── staffing-rules.handlers.ts + └── staffing-rules.service.ts +``` + ## 주요 규칙 -### 모듈 구조 (NestJS) -- **도메인 단위로 모듈을 분리**합니다. 하나의 모듈이 다른 모듈의 내부 서비스를 직접 import하지 않고, 필요한 경우 해당 모듈을 `exports`에 노출합니다. -- Controller → Service → PrismaService 계층을 엄격히 지킵니다. 별도 Repository 클래스를 두지 않으며, Service가 `PrismaService`를 직접 주입받아 쿼리를 작성합니다. -- Controller는 요청/응답 처리만 담당합니다. 비즈니스 로직은 Service에 작성합니다. +### 라우터 구조 (Express) + +- **도메인 단위로 라우터를 분리**합니다. 라우터는 HTTP 요청/응답 처리만 담당하고, 비즈니스 로직은 service 함수에 작성합니다. +- Router → Service → Prisma 계층을 기본으로 합니다. 별도 Repository 추상화는 중복이 명확해질 때만 도입합니다. +- 공통 미들웨어는 `middlewares/`, 공통 에러 클래스는 `errors/`, 공통 상수는 `common/` 아래에 둡니다. ### Prisma 사용 규칙 -- Prisma schema, migrations, `PrismaService`, `seed.ts`는 모두 **`packages/database`에서 관리**합니다. `apps/api`에 Prisma 관련 파일을 직접 두지 않습니다. -- `apps/api`에서는 `packages/database`가 export하는 `DatabaseModule`을 `AppModule`에 import해서 사용합니다. + +- Prisma schema, migrations, client helper, `seed.ts`는 모두 **`packages/database`에서 관리**합니다. `apps/api`에 Prisma 관련 파일을 직접 두지 않습니다. +- `apps/api`에서는 `packages/database`가 export하는 `createPrismaClient()`만 사용합니다. +- `prisma` singleton 직접 import/use는 금지합니다. +- 앱 시작점에서 `createPrismaClient()`로 Prisma client를 한 번 생성하고, `createApp({ prisma })` 형태로 app/router/service에 주입합니다. +- 테스트에서는 실제 client 대신 mock/fake Prisma client를 주입할 수 있게 구현합니다. - 쿼리 결과 타입은 Prisma가 생성하는 타입을 활용합니다. 별도로 타입을 재정의하지 않습니다. - 마이그레이션은 `packages/database`에서 `prisma migrate dev`로 생성하고, 파일명에 목적을 명확히 기재합니다. -- **소프트 딜리트**(FR-1.5, FR-5.2)는 `deletedAt` 필드로 구현합니다. 모든 조회 쿼리에서 `deletedAt: null` 필터를 기본으로 포함합니다. ### 인증 및 인가 + - **JWT Access Token** (유효기간 15분) + **Refresh Token** (7일, HTTP-only 쿠키) 방식을 사용합니다. -- 관리자 전용 엔드포인트에는 `@Roles('ADMIN')` 가드를 적용합니다. -- 근로자 전용 엔드포인트에는 `@Roles('WORKER')` 가드를 적용합니다. +- MVP에서는 단일 사용자 계정만 사용합니다. +- 운영 화면 엔드포인트에는 인증 확인 미들웨어를 적용합니다. +- 단일 조직 생성 전에는 조직 생성 API만 허용하고, 운영 화면 API는 조직 존재 여부를 확인합니다. ### 유효성 검증 -- 모든 DTO는 **class-validator + class-transformer**로 정의합니다. -- 전역 `ValidationPipe`를 `whitelist: true`, `forbidNonWhitelisted: true` 옵션으로 설정합니다. -### 급여 계산 엔진 (FR-4) -- 급여 계산 로직은 `payroll/` 모듈의 `PayrollCalculatorService`에 순수 함수로 구현합니다. -- 계산 결과는 항상 **감사 로그**(`PayrollAuditLog`)에 스냅샷(임금명세서 템플릿 포함)과 함께 저장합니다. -- 성능 목표: 1초 이하 응답 (FR-4.1). 계산이 복잡한 경우 Bull Queue로 비동기 처리합니다. +- 요청 body, params, query는 **Zod schema**로 검증합니다. +- 공유 가능한 입력/응답 스키마는 `packages/shared`에 정의하고 API와 클라이언트에서 함께 사용합니다. + +### 스케줄 추천 및 확정 -### 스케줄 자동화 (FR-2.2, FR-3.3) -- `@nestjs/schedule`의 `@Cron` 데코레이터로 주기 작업을 구현합니다. -- Cron 표현식은 상수로 분리하여 관리합니다. -- 퇴근 미처리 자동화 타임라인(예정 퇴근 +30분, +2시간, +12시간)은 `AttendanceScheduler`에서 처리합니다. +- 추천 생성 API는 조직의 인력, 가능 시간, 최소 인원 조건, 근무 시작/종료 날짜를 기준으로 `DRAFT` 스케줄을 생성합니다. +- 같은 입력 조건의 DRAFT가 이미 있으면 중복 생성하지 않습니다. +- 사용자는 DRAFT 스케줄을 직접 추가·수정·삭제할 수 있습니다. +- 확정 API는 DRAFT를 `CONFIRMED`로 변경하고, 이후 보관함 조회/export 대상으로 사용합니다. +- 확정된 스케줄은 조회/export 대상으로 사용합니다. ### API 문서화 -- 모든 Controller와 DTO에 `@ApiTags`, `@ApiOperation`, `@ApiResponse` 데코레이터를 작성합니다. -- Swagger UI: `http://localhost:4000/api` (개발 환경만 활성화) + +- 엔드포인트별 요청/응답 계약은 `docs/API.md`에 기록합니다. +- `docs/openapi.yaml`에 포함된 엔드포인트를 변경하는 경우 OpenAPI 문서도 함께 갱신합니다. +- Zod schema를 사용하는 경우 문서와 schema가 어긋나지 않도록 변경 시 함께 갱신합니다. ### 에러 응답 형식 + 모든 에러 응답은 다음 형식을 따릅니다: + ```json { "statusCode": 400, @@ -95,52 +118,41 @@ "message": "이미 사용 중인 이메일입니다" } ``` -- `errorCode`는 `common/constants/error-codes.ts`에서 상수로 관리합니다. -### 데이터 보존 정책 -- 근태·급여 이력은 **3년 보존** (근로기준법 제42조)합니다. 실제 삭제 로직을 구현하지 않습니다. -- 탈퇴 근로자 데이터는 익명화(`anonymize()`) 처리 후 보존합니다. +- `errorCode`는 `common/constants/error-codes.ts`에서 상수로 관리합니다. ## 테스트 -```bash -pnpm --filter @ilgam/api test # 단위 테스트 (Jest) -pnpm --filter @ilgam/api test:e2e # E2E 테스트 (Supertest) -pnpm --filter @ilgam/api test:cov # 커버리지 -``` +API 테스트는 새로 만든 핵심 로직이 깨졌는지 확인하는 범위부터 작성합니다. -- **Service 단위 테스트**: 의존성은 Jest mock으로 처리합니다. Prisma는 `mockDeep()`로 모킹합니다. -- **E2E 테스트**: 테스트 전용 PostgreSQL DB(`DATABASE_URL_TEST`)를 사용합니다. 각 테스트 전후로 DB를 초기화합니다. -- 커버리지 목표: Service 레이어 80% 이상. +- **Service 단위 테스트**: `src/**/*.spec.ts`에 작성합니다. 권한, DB 접근 조건, 비즈니스 규칙을 Jest mock으로 검증합니다. +- **API 통합 테스트**: `test/**/*.test.ts`에 작성합니다. Supertest로 인증, 요청 검증, 성공 흐름 중 대표 케이스를 검증합니다. +- 버그 수정이나 리뷰 반영으로 검증 로직을 수정한 경우 재발 방지 테스트를 1개 이상 추가합니다. +- 테스트 전용 DB가 필요한 시점에 `DATABASE_URL_TEST`와 초기화 전략을 함께 정의합니다. ## 개발 실행 ```bash -pnpm --filter @ilgam/api start:dev # http://localhost:4000 (watch 모드) -pnpm --filter @ilgam/api build -pnpm --filter @ilgam/api lint +pnpm --filter @fragment/api start:dev # http://localhost:3001 (watch 모드) +pnpm --filter @fragment/api build +pnpm --filter @fragment/api lint # Prisma (packages/database에서 실행) -pnpm --filter @ilgam/database db:migrate # prisma migrate dev -pnpm --filter @ilgam/database db:studio # Prisma Studio (DB GUI) -pnpm --filter @ilgam/database db:seed # 시드 데이터 삽입 +pnpm --filter @fragment/database db:migrate # prisma migrate dev +pnpm --filter @fragment/database db:studio # Prisma Studio (DB GUI) +pnpm --filter @fragment/database db:seed # 시드 데이터 삽입 ``` ## 환경 변수 (.env.example) -`DATABASE_URL`, `DATABASE_URL_TEST`는 `packages/database/.env`에서 관리합니다. +`DATABASE_URL`은 `packages/database/.env`에서 관리합니다. 나머지 변수는 `apps/api/.env`에서 관리합니다. ``` # packages/database/.env -DATABASE_URL=postgresql://user:password@localhost:5432/ilgam -DATABASE_URL_TEST=postgresql://user:password@localhost:5432/ilgam_test +DATABASE_URL=postgresql://user:password@localhost:5432/fragment # apps/api/.env JWT_ACCESS_SECRET= JWT_REFRESH_SECRET= -KAKAO_CLIENT_ID= -NAVER_CLIENT_ID= -NAVER_CLIENT_SECRET= -EXPO_ACCESS_TOKEN= # Expo Push 알림 발송용 ``` diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs new file mode 100644 index 0000000..d2c3286 --- /dev/null +++ b/apps/api/eslint.config.mjs @@ -0,0 +1,17 @@ +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + files: ['src/**/*.ts', 'test/**/*.ts'], + extends: [...tseslint.configs.recommended], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + { + ignores: ['dist/'], + }, +); diff --git a/apps/api/jest.config.js b/apps/api/jest.config.js new file mode 100644 index 0000000..2a6dd5b --- /dev/null +++ b/apps/api/jest.config.js @@ -0,0 +1,11 @@ +/** @type {import('jest').Config} */ +module.exports = { + moduleNameMapper: { + "^@/(.*)$": "/src/$1", + "^@fragment/database$": "/../../packages/database/src", + "^@fragment/shared$": "/../../packages/shared/src", + }, + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["/src/**/*.spec.ts", "/test/**/*.test.ts"], +}; diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json deleted file mode 100644 index 2f068a1..0000000 --- a/apps/api/nest-cli.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true, - "tsConfigPath": "tsconfig.build.json" - } -} diff --git a/apps/api/package.json b/apps/api/package.json index 0358325..fb47d07 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,30 +1,44 @@ { - "name": "@ilgam/api", + "name": "@fragment/api", "version": "0.1.0", "private": true, "scripts": { - "start:dev": "nest start --watch", - "dev": "nest start --watch", - "build": "nest build", + "start:dev": "pnpm --filter @fragment/shared build && node --env-file=../../.env --watch -r ts-node/register -r tsconfig-paths/register src/main.ts", + "dev": "pnpm --filter @fragment/shared build && node --env-file=../../.env --watch -r ts-node/register -r tsconfig-paths/register src/main.ts", + "build": "pnpm --filter @fragment/shared build && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", "start": "node dist/main", - "lint": "eslint \"{src,test}/**/*.ts\"" + "lint": "eslint \"{src,test}/**/*.ts\"", + "test": "jest --runInBand", + "test:watch": "jest --watch", + "typecheck": "pnpm --filter @fragment/shared build && tsc --noEmit" }, "dependencies": { - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "@nestjs/platform-express": "^11.0.0", - "@ilgam/database": "workspace:*", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1" + "@fragment/database": "workspace:*", + "@fragment/shared": "workspace:*", + "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "express": "^5.1.0", + "jsonwebtoken": "^9.0.3", + "zod": "^3.25.76" }, "devDependencies": { - "@nestjs/cli": "^11.0.0", - "@nestjs/schematics": "^11.0.0", + "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", "@types/express": "^5.0.0", + "@types/jest": "^29.5.14", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20", + "@types/supertest": "^6.0.3", + "eslint": "^9", + "jest": "^29.7.0", + "supertest": "^7.2.2", + "ts-jest": "^29.4.11", "ts-loader": "^9.0.0", "ts-node": "^10.9.1", + "tsc-alias": "^1.8.17", "tsconfig-paths": "^4.2.0", - "typescript": "^5" + "typescript": "^5", + "typescript-eslint": "^8" } } diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts deleted file mode 100644 index 2c045b0..0000000 --- a/apps/api/src/app.controller.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller, Get } from '@nestjs/common' -import { AppService } from './app.service' - -@Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get('health') - healthCheck() { - return this.appService.healthCheck() - } -} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts deleted file mode 100644 index add8cca..0000000 --- a/apps/api/src/app.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common' -import { AppController } from './app.controller' -import { AppService } from './app.service' -import { DatabaseModule } from '@ilgam/database' - -@Module({ - imports: [DatabaseModule], - controllers: [AppController], - providers: [AppService], -}) -export class AppModule {} diff --git a/apps/api/src/app.service.ts b/apps/api/src/app.service.ts deleted file mode 100644 index 8fae97e..0000000 --- a/apps/api/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common' - -@Injectable() -export class AppService { - healthCheck() { - return { status: 'ok' } - } -} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..f2cb9ef --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,43 @@ +import cors from "cors"; +import cookieParser from "cookie-parser"; +import express from "express"; +import type { PrismaClient } from "@fragment/database"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { errorHandler } from "@/middlewares/error-handler"; +import { apiRoutes } from "@/routes"; + +type AppDependencies = { + prisma: PrismaClient; +}; + +export const createApp = ({ prisma }: AppDependencies) => { + const app = express(); + const webAppOrigin = process.env.WEB_APP_ORIGIN; + + if (process.env.NODE_ENV === "production" && !webAppOrigin) { + throw new Error("WEB_APP_ORIGIN is required in production"); + } + + app.locals.prisma = prisma; + + app.use( + cors({ + origin: webAppOrigin ?? true, + credentials: true, + }), + ); + app.use(express.json()); + app.use(cookieParser()); + + app.use("/api", apiRoutes); + + app.use((_req, _res, next) => { + next(new HttpError(404, ERROR_CODES.NOT_FOUND, "요청한 API를 찾을 수 없습니다.")); + }); + + app.use(errorHandler); + + return app; +}; diff --git a/apps/api/src/common/constants/error-codes.ts b/apps/api/src/common/constants/error-codes.ts new file mode 100644 index 0000000..385d7dd --- /dev/null +++ b/apps/api/src/common/constants/error-codes.ts @@ -0,0 +1,4 @@ +import { errorCodeSchema } from "@fragment/shared"; + +export const ERROR_CODES = errorCodeSchema.enum; +export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; diff --git a/apps/api/src/errors/http-error.ts b/apps/api/src/errors/http-error.ts new file mode 100644 index 0000000..1dd76a7 --- /dev/null +++ b/apps/api/src/errors/http-error.ts @@ -0,0 +1,16 @@ +import type { ErrorCode } from "@/common/constants/error-codes"; + +export class HttpError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: ErrorCode, + message: string, + ) { + super(message); + this.name = "HttpError"; + } +} + +export function isHttpError(error: unknown): error is HttpError { + return error instanceof HttpError; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 470208a..8029493 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,11 +1,20 @@ -import 'reflect-metadata' -import { NestFactory } from '@nestjs/core' -import { AppModule } from './app.module' - -async function bootstrap() { - const app = await NestFactory.create(AppModule) - app.setGlobalPrefix('api') - app.enableCors() - await app.listen(process.env.PORT ?? 3001) -} -bootstrap() +import { createPrismaClient } from "@fragment/database"; +import { createApp } from "@/app"; + +const prisma = createPrismaClient(); +const app = createApp({ prisma }); +const port = Number(process.env.PORT ?? 3001); + +const server = app.listen(port, () => { + console.log(`API server listening on port ${port}`); +}); + +const shutdown = async () => { + await prisma.$disconnect(); + server.close(() => { + process.exit(0); + }); +}; + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/apps/api/src/middlewares/error-handler.ts b/apps/api/src/middlewares/error-handler.ts new file mode 100644 index 0000000..ade2852 --- /dev/null +++ b/apps/api/src/middlewares/error-handler.ts @@ -0,0 +1,24 @@ +import type { ErrorRequestHandler } from "express"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { isHttpError } from "@/errors/http-error"; + +export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => { + void _next; + + if (isHttpError(error)) { + res.status(error.statusCode).json({ + statusCode: error.statusCode, + errorCode: error.code, + message: error.message, + }); + + return; + } + + res.status(500).json({ + statusCode: 500, + errorCode: ERROR_CODES.INTERNAL_SERVER_ERROR, + message: "서버 오류가 발생했습니다.", + }); +}; diff --git a/apps/api/src/middlewares/require-auth.ts b/apps/api/src/middlewares/require-auth.ts new file mode 100644 index 0000000..07aaa7e --- /dev/null +++ b/apps/api/src/middlewares/require-auth.ts @@ -0,0 +1,54 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express"; +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import type { AuthenticatedUser } from "@/types/express"; +import { toApiId, type DatabaseId } from "@/utils/mapper"; + +export type AccessTokenPayload = { + userId: DatabaseId; +}; + +export type VerifyAccessToken = (token: string) => AccessTokenPayload | Promise; + +export type RequireAuthDependencies = { + verifyAccessToken: VerifyAccessToken; +}; + +export const extractBearerToken = (authorizationHeader: string | undefined): string | null => { + if (!authorizationHeader) { + return null; + } + + const [scheme, token] = authorizationHeader.split(" "); + + if (scheme !== "Bearer" || !token) { + return null; + } + + return token; +}; + +const toAuthenticatedUser = (payload: AccessTokenPayload): AuthenticatedUser => ({ + id: toApiId(payload.userId), +}); + +export const createRequireAuth = ({ + verifyAccessToken, +}: RequireAuthDependencies): RequestHandler => { + return async (req: Request, _res: Response, next: NextFunction) => { + const token = extractBearerToken(req.headers.authorization); + + if (!token) { + next(new HttpError(401, ERROR_CODES.UNAUTHORIZED, "인증이 필요합니다.")); + return; + } + + try { + const payload = await verifyAccessToken(token); + req.user = toAuthenticatedUser(payload); + next(); + } catch { + next(new HttpError(401, ERROR_CODES.UNAUTHORIZED, "유효하지 않은 인증 토큰입니다.")); + } + }; +}; diff --git a/apps/api/src/middlewares/require-organization.ts b/apps/api/src/middlewares/require-organization.ts new file mode 100644 index 0000000..6640273 --- /dev/null +++ b/apps/api/src/middlewares/require-organization.ts @@ -0,0 +1,46 @@ +import type { PrismaClient } from "@fragment/database"; +import type { NextFunction, Request, RequestHandler, Response } from "express"; +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { toApiId, toPrismaId } from "@/utils/mapper"; + +export type RequireOrganizationDependencies = { + prisma: PrismaClient; +}; + +export const createRequireOrganization = ({ + prisma, +}: RequireOrganizationDependencies): RequestHandler => { + return async (req: Request, _res: Response, next: NextFunction) => { + if (!req.user) { + next(new HttpError(401, ERROR_CODES.UNAUTHORIZED, "인증이 필요합니다.")); + return; + } + + try { + const organization = await prisma.organization.findUnique({ + where: { + userId: toPrismaId(req.user.id), + }, + select: { + id: true, + }, + }); + + if (!organization) { + next( + new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다."), + ); + return; + } + + req.organization = { + id: toApiId(organization.id), + }; + + next(); + } catch (error) { + next(error); + } + }; +}; diff --git a/apps/api/src/middlewares/require-same-origin.ts b/apps/api/src/middlewares/require-same-origin.ts new file mode 100644 index 0000000..844e6a9 --- /dev/null +++ b/apps/api/src/middlewares/require-same-origin.ts @@ -0,0 +1,44 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; + +const getHeaderOrigin = (value: string | undefined): string | null => { + if (!value) { + return null; + } + + try { + return new URL(value).origin; + } catch { + return null; + } +}; + +export const createRequireSameOrigin = (allowedOrigin?: string): RequestHandler => { + const normalizedAllowedOrigin = getHeaderOrigin(allowedOrigin); + + if (allowedOrigin && !normalizedAllowedOrigin) { + throw new Error("WEB_APP_ORIGIN must be a valid URL origin"); + } + + return (req: Request, _res: Response, next: NextFunction) => { + if (!normalizedAllowedOrigin) { + next(); + return; + } + + const requestOrigin = + getHeaderOrigin(req.headers.origin) ?? getHeaderOrigin(req.headers.referer); + const hasOriginHeader = Boolean(req.headers.origin || req.headers.referer); + + // TODO: Before production, reject requests that omit both Origin and Referer. + // This temporary allowance keeps local Postman/curl auth testing usable. + if (!hasOriginHeader || requestOrigin === normalizedAllowedOrigin) { + next(); + return; + } + + next(new HttpError(403, ERROR_CODES.FORBIDDEN, "허용되지 않은 요청 출처입니다.")); + }; +}; diff --git a/apps/api/src/middlewares/validate.ts b/apps/api/src/middlewares/validate.ts new file mode 100644 index 0000000..2670bba --- /dev/null +++ b/apps/api/src/middlewares/validate.ts @@ -0,0 +1,44 @@ +import type { RequestHandler } from "express"; +import type { z } from "zod"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; + +type RequestSchema = z.ZodType<{ + body?: unknown; + query?: unknown; + params?: unknown; +}>; + +export function validate(schema: RequestSchema): RequestHandler { + return (req, _res, next) => { + const result = schema.safeParse({ + body: req.body, + query: req.query, + params: req.params, + }); + + if (!result.success) { + next(new HttpError(400, ERROR_CODES.VALIDATION_ERROR, "요청 형식이 올바르지 않습니다.")); + return; + } + + if ("body" in result.data) { + req.body = result.data.body; + } + + if ("query" in result.data) { + for (const key of Object.keys(req.query)) { + delete req.query[key]; + } + + Object.assign(req.query, result.data.query); + } + + if ("params" in result.data) { + req.params = result.data.params as typeof req.params; + } + + next(); + }; +} diff --git a/apps/api/src/modules/auth/auth.cookies.ts b/apps/api/src/modules/auth/auth.cookies.ts new file mode 100644 index 0000000..dd2ea86 --- /dev/null +++ b/apps/api/src/modules/auth/auth.cookies.ts @@ -0,0 +1,21 @@ +import type { Response } from "express"; + +const REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + +const refreshTokenCookieOptions = { + httpOnly: true, + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + secure: process.env.NODE_ENV === "production", + path: "/", +} as const; + +export function setRefreshTokenCookie(res: Response, token: string, expiresAt: Date) { + res.cookie(REFRESH_TOKEN_COOKIE_NAME, token, { + ...refreshTokenCookieOptions, + expires: expiresAt, + }); +} + +export function clearRefreshTokenCookie(res: Response) { + res.clearCookie(REFRESH_TOKEN_COOKIE_NAME, refreshTokenCookieOptions); +} diff --git a/apps/api/src/modules/auth/auth.handlers.ts b/apps/api/src/modules/auth/auth.handlers.ts new file mode 100644 index 0000000..f5ad4cd --- /dev/null +++ b/apps/api/src/modules/auth/auth.handlers.ts @@ -0,0 +1,75 @@ +import type { PrismaClient } from "@fragment/database"; +import type { RequestHandler } from "express"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { clearRefreshTokenCookie, setRefreshTokenCookie } from "./auth.cookies"; +import { getMe, login, logout, refreshSession, signup } from "./auth.service"; + +export const signupHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await signup(prisma, req.body); + + setRefreshTokenCookie(res, result.refreshToken, result.refreshTokenExpiresAt); + res.status(201).json(result.response); + } catch (error) { + next(error); + } +}; + +export const loginHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await login(prisma, req.body); + + setRefreshTokenCookie(res, result.refreshToken, result.refreshTokenExpiresAt); + res.status(200).json(result.response); + } catch (error) { + next(error); + } +}; + +export const logoutHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const refreshToken = + typeof req.cookies?.refreshToken === "string" ? req.cookies.refreshToken : undefined; + + await logout(prisma, refreshToken); + clearRefreshTokenCookie(res); + res.status(204).send(); + } catch (error) { + next(error); + } +}; + +export const refreshHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const refreshToken = + typeof req.cookies?.refreshToken === "string" ? req.cookies.refreshToken : undefined; + const result = await refreshSession(prisma, refreshToken); + + setRefreshTokenCookie(res, result.refreshToken, result.refreshTokenExpiresAt); + res.status(200).json(result.response); + } catch (error) { + next(error); + } +}; + +export const meHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + + if (!req.user) { + throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, "인증이 필요합니다."); + } + + const result = await getMe(prisma, req.user.id); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..2e682e7 --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.ts @@ -0,0 +1,241 @@ +import { Prisma, type PrismaClient } from "@fragment/database"; +import { compare, hash } from "bcryptjs"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { + createAccessToken, + createRefreshToken, + getRefreshTokenExpiresAt, + hashRefreshToken, +} from "./auth.tokens"; + +type SignupInput = { + name: string; + email: string; + password: string; +}; + +type LoginInput = { + email: string; + password: string; +}; + +type AuthUser = { + id: bigint; + email: string; + name: string; +}; + +type AuthDatabaseClient = PrismaClient | Prisma.TransactionClient; + +const createInvalidRefreshTokenError = () => + new HttpError(401, ERROR_CODES.UNAUTHORIZED, "Refresh token이 유효하지 않습니다."); + +async function issueAuthSession(client: AuthDatabaseClient, user: AuthUser) { + const accessToken = createAccessToken(user.id); + const refreshToken = createRefreshToken(); + const refreshTokenHash = hashRefreshToken(refreshToken); + const refreshTokenExpiresAt = getRefreshTokenExpiresAt(); + const organization = await client.organization.findUnique({ + where: { userId: user.id }, + select: { id: true }, + }); + + await client.refreshToken.create({ + data: { + userId: user.id, + tokenHash: refreshTokenHash, + expiresAt: refreshTokenExpiresAt, + }, + }); + + return { + response: { + accessToken, + user: { + id: user.id.toString(), + email: user.email, + name: user.name, + }, + hasOrganization: Boolean(organization), + }, + refreshToken, + refreshTokenExpiresAt, + }; +} + +async function revokeActiveRefreshTokens( + client: AuthDatabaseClient, + userId: bigint, + revokedAt: Date, +) { + await client.refreshToken.updateMany({ + where: { + userId, + revokedAt: null, + }, + data: { + revokedAt, + }, + }); +} + +async function createAuthSession(prisma: PrismaClient, user: AuthUser) { + return prisma.$transaction(async (transaction) => { + await revokeActiveRefreshTokens(transaction, user.id, new Date()); + return issueAuthSession(transaction, user); + }); +} + +async function consumeRefreshToken(client: AuthDatabaseClient, tokenId: bigint, consumedAt: Date) { + const { count } = await client.refreshToken.updateMany({ + where: { + id: tokenId, + revokedAt: null, + expiresAt: { + gt: consumedAt, + }, + }, + data: { + revokedAt: consumedAt, + }, + }); + + if (count !== 1) { + throw createInvalidRefreshTokenError(); + } +} + +export async function signup(prisma: PrismaClient, input: SignupInput) { + const existingUser = await prisma.user.findUnique({ + where: { email: input.email }, + }); + + if (existingUser) { + throw new HttpError(409, ERROR_CODES.DUPLICATE_EMAIL, "이미 사용 중인 이메일입니다."); + } + + const passwordHash = await hash(input.password, 12); + + let user: AuthUser; + + try { + user = await prisma.user.create({ + data: { + name: input.name, + email: input.email, + passwordHash, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + throw new HttpError(409, ERROR_CODES.DUPLICATE_EMAIL, "이미 사용 중인 이메일입니다."); + } + + throw error; + } + + return createAuthSession(prisma, user); +} + +export async function login(prisma: PrismaClient, input: LoginInput) { + const user = await prisma.user.findUnique({ + where: { email: input.email }, + }); + + if (!user) { + throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."); + } + + const isPasswordValid = await compare(input.password, user.passwordHash); + + if (!isPasswordValid) { + throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."); + } + + return createAuthSession(prisma, user); +} + +export async function logout(prisma: PrismaClient, refreshToken?: string) { + if (!refreshToken) { + throw createInvalidRefreshTokenError(); + } + + const tokenHash = hashRefreshToken(refreshToken); + const storedToken = await prisma.refreshToken.findUnique({ + where: { tokenHash }, + }); + + if (!storedToken || storedToken.revokedAt || storedToken.expiresAt <= new Date()) { + throw createInvalidRefreshTokenError(); + } + + await prisma.refreshToken.update({ + where: { id: storedToken.id }, + data: { revokedAt: new Date() }, + }); +} + +export async function refreshSession(prisma: PrismaClient, refreshToken?: string) { + if (!refreshToken) { + throw createInvalidRefreshTokenError(); + } + + const tokenHash = hashRefreshToken(refreshToken); + + return prisma.$transaction(async (transaction) => { + const now = new Date(); + const storedToken = await transaction.refreshToken.findUnique({ + where: { tokenHash }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); + + if (!storedToken) { + throw createInvalidRefreshTokenError(); + } + + await consumeRefreshToken(transaction, storedToken.id, now); + return issueAuthSession(transaction, storedToken.user); + }); +} + +export async function getMe(prisma: PrismaClient, userId: string) { + const user = await prisma.user.findUnique({ + where: { id: BigInt(userId) }, + include: { + organization: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + if (!user) { + throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, "인증이 필요합니다."); + } + + return { + user: { + id: user.id.toString(), + email: user.email, + name: user.name, + }, + organization: user.organization + ? { + id: user.organization.id.toString(), + name: user.organization.name, + } + : null, + }; +} diff --git a/apps/api/src/modules/auth/auth.tokens.ts b/apps/api/src/modules/auth/auth.tokens.ts new file mode 100644 index 0000000..9b81cba --- /dev/null +++ b/apps/api/src/modules/auth/auth.tokens.ts @@ -0,0 +1,69 @@ +import { createHash, randomBytes } from "node:crypto"; +import jwt from "jsonwebtoken"; +import type { SignOptions } from "jsonwebtoken"; + +const DEFAULT_ACCESS_TOKEN_EXPIRES_IN: SignOptions["expiresIn"] = "15m"; +const DEFAULT_REFRESH_TOKEN_EXPIRES_DAYS = 7; + +type AccessTokenPayload = { + sub: string; +}; + +function getRequiredEnv(name: string) { + const value = process.env[name]; + + if (!value) { + throw new Error(`${name} is required`); + } + + return value; +} + +export function createAccessToken(userId: bigint) { + const payload: AccessTokenPayload = { + sub: userId.toString(), + }; + const expiresIn = (process.env.JWT_ACCESS_EXPIRES_IN ?? + DEFAULT_ACCESS_TOKEN_EXPIRES_IN) as SignOptions["expiresIn"]; + + return jwt.sign(payload, getRequiredEnv("JWT_ACCESS_SECRET"), { + expiresIn, + }); +} + +export function verifyAccessToken(token: string) { + const payload = jwt.verify(token, getRequiredEnv("JWT_ACCESS_SECRET")); + + if (typeof payload === "string" || typeof payload.sub !== "string") { + throw new Error("Invalid access token payload"); + } + + return { + userId: BigInt(payload.sub), + }; +} + +export function createRefreshToken() { + return randomBytes(64).toString("base64url"); +} + +export function hashRefreshToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +export function getRefreshTokenExpiresAt() { + const days = + process.env.REFRESH_TOKEN_EXPIRES_DAYS === undefined + ? DEFAULT_REFRESH_TOKEN_EXPIRES_DAYS + : Number(process.env.REFRESH_TOKEN_EXPIRES_DAYS); + + if (!Number.isInteger(days) || days <= 0) { + throw new Error("REFRESH_TOKEN_EXPIRES_DAYS must be a positive integer"); + } + + const expiresAt = new Date(); + + expiresAt.setDate(expiresAt.getDate() + days); + + return expiresAt; +} diff --git a/apps/api/src/modules/availability/availability.handlers.ts b/apps/api/src/modules/availability/availability.handlers.ts new file mode 100644 index 0000000..5bb548b --- /dev/null +++ b/apps/api/src/modules/availability/availability.handlers.ts @@ -0,0 +1,45 @@ +import type { PrismaClient } from "@fragment/database"; +import type { AvailabilityQuery, ReplaceAvailabilityRequest } from "@fragment/shared"; +import type { Request, RequestHandler } from "express"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { listAvailability, replaceAvailability } from "./availability.service"; + +const getAuthenticatedUserId = (req: Request) => { + if (!req.user) { + throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, "인증이 필요합니다."); + } + + return req.user.id; +}; + +export const listAvailabilityHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await listAvailability( + prisma, + getAuthenticatedUserId(req), + req.query as AvailabilityQuery, + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const replaceAvailabilityHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await replaceAvailability( + prisma, + getAuthenticatedUserId(req), + req.body as ReplaceAvailabilityRequest, + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/modules/availability/availability.service.spec.ts b/apps/api/src/modules/availability/availability.service.spec.ts new file mode 100644 index 0000000..badc47c --- /dev/null +++ b/apps/api/src/modules/availability/availability.service.spec.ts @@ -0,0 +1,542 @@ +import type { PrismaClient } from "@fragment/database"; +import { describe, expect, it, jest } from "@jest/globals"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { listAvailability, replaceAvailability } from "./availability.service"; + +const dateOnly = (date: string) => new Date(`${date}T00:00:00.000Z`); +const timestamp = new Date("2026-06-25T00:00:00.000Z"); +const timeOfDay = (time: string) => { + const [hour, minute] = time.split(":").map(Number); + + return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); +}; + +const createAvailabilityRow = ({ + id = BigInt(10), + workerId = BigInt(1), + availableDate = dateOnly("2026-07-01"), + startsAt = new Date("2026-07-01T10:00:00.000Z"), + endsAt = new Date("2026-07-01T14:00:00.000Z"), +} = {}) => ({ + id, + workerId, + availableDate, + startsAt, + endsAt, + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createBusinessHourRow = ({ + id = BigInt(1), + organizationId = BigInt(1), + dayOfWeek = "WED", + isClosed = false, + openTime = timeOfDay("09:00"), + closeTime = timeOfDay("18:00"), + closesNextDay = false, +} = {}) => ({ + id, + organizationId, + dayOfWeek, + isClosed, + openTime, + closeTime, + closesNextDay, + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createMinimumStaffingRuleRow = ({ + id = BigInt(1), + organizationId = BigInt(1), + dayOfWeek = "WED", + startTime = timeOfDay("09:00"), + endTime = timeOfDay("18:00"), + endsNextDay = false, + requiredCount = 1, +} = {}) => ({ + id, + organizationId, + dayOfWeek, + startTime, + endTime, + endsNextDay, + requiredCount, + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createPrismaMock = ({ + organization = { + id: BigInt(1), + businessHours: [createBusinessHourRow()], + minimumStaffingRules: [createMinimumStaffingRuleRow()], + }, + worker = { id: BigInt(1) }, + workerAvailableTime = {}, +}: { + organization?: { + businessHours: ReturnType[]; + id: bigint; + minimumStaffingRules: ReturnType[]; + } | null; + worker?: { id: bigint } | null; + workerAvailableTime?: { + createMany?: jest.Mock; + deleteMany?: jest.Mock; + findMany?: jest.Mock; + }; +} = {}) => { + const organizationFindUnique = jest + .fn<() => Promise<{ id: bigint } | null>>() + .mockResolvedValue(organization); + const workerFindFirst = jest.fn<() => Promise<{ id: bigint } | null>>().mockResolvedValue(worker); + const workerAvailableTimeCreateMany = + workerAvailableTime.createMany ?? + jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ count: 0 }); + const workerAvailableTimeDeleteMany = + workerAvailableTime.deleteMany ?? + jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ count: 0 }); + const workerAvailableTimeFindMany = + workerAvailableTime.findMany ?? + jest.fn<() => Promise[]>>().mockResolvedValue([]); + + return { + prisma: { + activeSchedulePlanningPeriod: { + findUnique: jest.fn<() => Promise>().mockResolvedValue(null), + }, + $transaction: jest.fn(async (callback: (transaction: unknown) => unknown) => + callback({ + workerAvailableTime: { + createMany: workerAvailableTimeCreateMany, + deleteMany: workerAvailableTimeDeleteMany, + findMany: workerAvailableTimeFindMany, + }, + }), + ), + organization: { + findUnique: organizationFindUnique, + }, + worker: { + findFirst: workerFindFirst, + }, + workerAvailableTime: { + createMany: workerAvailableTimeCreateMany, + deleteMany: workerAvailableTimeDeleteMany, + findMany: workerAvailableTimeFindMany, + }, + } as unknown as PrismaClient, + organizationFindUnique, + workerFindFirst, + }; +}; + +describe("listAvailability", () => { + it("rejects users without an organization", async () => { + const availableTimeFindMany = jest.fn(); + const { prisma } = createPrismaMock({ + organization: null, + workerAvailableTime: { + findMany: availableTimeFindMany, + }, + }); + + await expect( + listAvailability(prisma, "1", { + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.ORGANIZATION_REQUIRED, + statusCode: 403, + }); + expect(availableTimeFindMany).not.toHaveBeenCalled(); + }); + + it("rejects workers outside the user's organization", async () => { + const availableTimeFindMany = jest.fn(); + const { prisma, workerFindFirst } = createPrismaMock({ + worker: null, + workerAvailableTime: { + findMany: availableTimeFindMany, + }, + }); + + await expect( + listAvailability(prisma, "1", { + workerId: "10", + startDate: "2026-07-01", + endDate: "2026-07-31", + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.WORKER_NOT_FOUND, + statusCode: 404, + }); + expect(workerFindFirst).toHaveBeenCalledWith({ + where: { + id: BigInt(10), + organizationId: BigInt(1), + }, + select: { + id: true, + }, + }); + expect(availableTimeFindMany).not.toHaveBeenCalled(); + }); + + it("lists availability in the selected date range ordered by date and start time", async () => { + const availableTimeFindMany = jest + .fn<() => Promise[]>>() + .mockResolvedValue([ + createAvailabilityRow({ + id: BigInt(11), + availableDate: dateOnly("2026-07-01"), + startsAt: new Date("2026-07-01T10:00:00.000Z"), + endsAt: new Date("2026-07-01T14:00:00.000Z"), + }), + createAvailabilityRow({ + id: BigInt(12), + availableDate: dateOnly("2026-07-02"), + startsAt: new Date("2026-07-02T09:00:00.000Z"), + endsAt: new Date("2026-07-02T12:00:00.000Z"), + }), + ]); + const { prisma } = createPrismaMock({ + workerAvailableTime: { + findMany: availableTimeFindMany, + }, + }); + + await expect( + listAvailability(prisma, "1", { + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + }), + ).resolves.toEqual({ + items: [ + { + id: "11", + workerId: "1", + availableDate: "2026-07-01", + startsAt: "2026-07-01T10:00:00.000Z", + endsAt: "2026-07-01T14:00:00.000Z", + }, + { + id: "12", + workerId: "1", + availableDate: "2026-07-02", + startsAt: "2026-07-02T09:00:00.000Z", + endsAt: "2026-07-02T12:00:00.000Z", + }, + ], + }); + expect(availableTimeFindMany).toHaveBeenCalledWith({ + where: { + workerId: BigInt(1), + availableDate: { + gte: dateOnly("2026-07-01"), + lte: dateOnly("2026-07-31"), + }, + }, + orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }], + }); + }); +}); + +describe("replaceAvailability", () => { + it("rejects workers outside the user's organization", async () => { + const availableTimeCreateMany = jest.fn(); + const availableTimeDeleteMany = jest.fn(); + const { prisma } = createPrismaMock({ + worker: null, + workerAvailableTime: { + createMany: availableTimeCreateMany, + deleteMany: availableTimeDeleteMany, + }, + }); + + await expect( + replaceAvailability(prisma, "1", { + workerId: "10", + startDate: "2026-07-01", + endDate: "2026-07-31", + items: [], + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.WORKER_NOT_FOUND, + statusCode: 404, + }); + expect(availableTimeDeleteMany).not.toHaveBeenCalled(); + expect(availableTimeCreateMany).not.toHaveBeenCalled(); + }); + + it("replaces the selected date range with requested availability", async () => { + const availableTimeDeleteMany = jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ + count: 2, + }); + const availableTimeCreateMany = jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ + count: 1, + }); + const availableTimeFindMany = jest + .fn<() => Promise[]>>() + .mockResolvedValue([ + createAvailabilityRow({ + id: BigInt(20), + availableDate: dateOnly("2026-07-01"), + startsAt: new Date("2026-07-01T09:00:00.000Z"), + endsAt: new Date("2026-07-01T12:00:00.000Z"), + }), + ]); + const { prisma } = createPrismaMock({ + workerAvailableTime: { + createMany: availableTimeCreateMany, + deleteMany: availableTimeDeleteMany, + findMany: availableTimeFindMany, + }, + }); + + await expect( + replaceAvailability(prisma, "1", { + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + items: [ + { + availableDate: "2026-07-01", + startsAt: "2026-07-01T09:00:00.000Z", + endsAt: "2026-07-01T12:00:00.000Z", + }, + ], + }), + ).resolves.toEqual({ + items: [ + { + id: "20", + workerId: "1", + availableDate: "2026-07-01", + startsAt: "2026-07-01T09:00:00.000Z", + endsAt: "2026-07-01T12:00:00.000Z", + }, + ], + }); + expect(availableTimeDeleteMany).toHaveBeenCalledWith({ + where: { + workerId: BigInt(1), + availableDate: { + gte: dateOnly("2026-07-01"), + lte: dateOnly("2026-07-31"), + }, + }, + }); + expect(availableTimeCreateMany).toHaveBeenCalledWith({ + data: [ + { + workerId: BigInt(1), + availableDate: dateOnly("2026-07-01"), + startsAt: new Date("2026-07-01T09:00:00.000Z"), + endsAt: new Date("2026-07-01T12:00:00.000Z"), + }, + ], + }); + }); + + it("rejects items outside the selected date range", async () => { + const availableTimeDeleteMany = jest.fn(); + const { prisma } = createPrismaMock({ + workerAvailableTime: { + deleteMany: availableTimeDeleteMany, + }, + }); + + await expect( + replaceAvailability(prisma, "1", { + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + items: [ + { + availableDate: "2026-08-01", + startsAt: "2026-08-01T10:00:00.000Z", + endsAt: "2026-08-01T12:00:00.000Z", + }, + ], + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_TIME_RANGE, + statusCode: 400, + }); + expect(availableTimeDeleteMany).not.toHaveBeenCalled(); + }); + + it("rejects availability on closed days", async () => { + const availableTimeCreateMany = jest.fn(); + const { prisma } = createPrismaMock({ + organization: { + id: BigInt(1), + businessHours: [ + createBusinessHourRow({ + isClosed: true, + openTime: null, + closeTime: null, + }), + ], + minimumStaffingRules: [createMinimumStaffingRuleRow()], + }, + workerAvailableTime: { + createMany: availableTimeCreateMany, + }, + }); + + await expect( + replaceAvailability(prisma, "1", { + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + items: [ + { + availableDate: "2026-07-01", + startsAt: "2026-07-01T10:00:00.000Z", + endsAt: "2026-07-01T12:00:00.000Z", + }, + ], + }), + ).resolves.toEqual({ + items: [], + }); + expect(availableTimeCreateMany).not.toHaveBeenCalled(); + }); + + it("trims availability to schedulable ranges", async () => { + const availableTimeCreateMany = jest.fn(); + const { prisma } = createPrismaMock({ + workerAvailableTime: { + createMany: availableTimeCreateMany, + }, + }); + + await expect( + replaceAvailability(prisma, "1", { + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + items: [ + { + availableDate: "2026-07-01", + startsAt: "2026-07-01T08:30:00.000Z", + endsAt: "2026-07-01T10:00:00.000Z", + }, + ], + }), + ).resolves.toEqual({ + items: [], + }); + expect(availableTimeCreateMany).toHaveBeenCalledWith({ + data: [ + { + workerId: BigInt(1), + availableDate: dateOnly("2026-07-01"), + startsAt: new Date("2026-07-01T09:00:00.000Z"), + endsAt: new Date("2026-07-01T10:00:00.000Z"), + }, + ], + }); + }); + + it("rejects availability with a reversed or empty time range", async () => { + const availableTimeDeleteMany = jest.fn(); + const { prisma } = createPrismaMock({ + workerAvailableTime: { + deleteMany: availableTimeDeleteMany, + }, + }); + + await expect( + replaceAvailability(prisma, "1", { + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + items: [ + { + availableDate: "2026-07-01", + startsAt: "2026-07-01T12:00:00.000Z", + endsAt: "2026-07-01T12:00:00.000Z", + }, + ], + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_TIME_RANGE, + statusCode: 400, + }); + expect(availableTimeDeleteMany).not.toHaveBeenCalled(); + }); + + it("allows availability inside overnight business hours", async () => { + const availableTimeDeleteMany = jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ + count: 0, + }); + const availableTimeCreateMany = jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ + count: 1, + }); + const availableTimeFindMany = jest + .fn<() => Promise[]>>() + .mockResolvedValue([]); + const { prisma } = createPrismaMock({ + organization: { + id: BigInt(1), + businessHours: [ + createBusinessHourRow({ + dayOfWeek: "FRI", + openTime: timeOfDay("22:00"), + closeTime: timeOfDay("02:00"), + closesNextDay: true, + }), + ], + minimumStaffingRules: [ + createMinimumStaffingRuleRow({ + dayOfWeek: "FRI", + startTime: timeOfDay("22:00"), + endTime: timeOfDay("02:00"), + endsNextDay: true, + }), + ], + }, + workerAvailableTime: { + createMany: availableTimeCreateMany, + deleteMany: availableTimeDeleteMany, + findMany: availableTimeFindMany, + }, + }); + + await expect( + replaceAvailability(prisma, "1", { + workerId: "1", + startDate: "2026-07-03", + endDate: "2026-07-03", + items: [ + { + availableDate: "2026-07-03", + startsAt: "2026-07-04T00:00:00.000Z", + endsAt: "2026-07-04T01:30:00.000Z", + }, + ], + }), + ).resolves.toEqual({ + items: [], + }); + expect(availableTimeCreateMany).toHaveBeenCalledWith({ + data: [ + { + workerId: BigInt(1), + availableDate: dateOnly("2026-07-03"), + startsAt: new Date("2026-07-04T00:00:00.000Z"), + endsAt: new Date("2026-07-04T01:30:00.000Z"), + }, + ], + }); + }); +}); diff --git a/apps/api/src/modules/availability/availability.service.ts b/apps/api/src/modules/availability/availability.service.ts new file mode 100644 index 0000000..71fe07e --- /dev/null +++ b/apps/api/src/modules/availability/availability.service.ts @@ -0,0 +1,260 @@ +import type { + Prisma, + PrismaClient, + WorkerAvailableTime as PrismaWorkerAvailableTime, +} from "@fragment/database"; +import type { + Availability, + AvailabilityListResponse, + AvailabilityQuery, + ReplaceAvailabilityRequest, +} from "@fragment/shared"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { + pruneAvailabilityTimesToSchedulableRanges, + type PrunedAvailabilityTime, +} from "@/modules/scheduling-time-policy"; +import { dateStringToDate, dateTimeStringToDate, dateToDateString } from "@/utils/date-time"; +import { toApiId, toPrismaId } from "@/utils/mapper"; + +type AvailabilityDatabaseClient = PrismaClient | Prisma.TransactionClient; + +type OrganizationForAvailability = Prisma.OrganizationGetPayload<{ + include: { businessHours: true; minimumStaffingRules: true }; +}>; + +const createInvalidTimeRangeError = () => + new HttpError(400, ERROR_CODES.INVALID_TIME_RANGE, "가능 시간이 조직 운영시간을 벗어났습니다."); + +const findOrganizationByUserId = async ( + prisma: PrismaClient, + userId: string, +): Promise => { + const organization = await prisma.organization.findUnique({ + where: { + userId: toPrismaId(userId), + }, + include: { + businessHours: true, + minimumStaffingRules: true, + }, + }); + + if (!organization) { + throw new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다."); + } + + return organization; +}; + +const findWorkerByOrganization = async ( + client: AvailabilityDatabaseClient, + organizationId: bigint, + workerId: bigint, +) => + client.worker.findFirst({ + where: { + id: workerId, + organizationId, + }, + select: { + id: true, + }, + }); + +const toAvailability = (availability: PrismaWorkerAvailableTime): Availability => ({ + id: toApiId(availability.id), + workerId: toApiId(availability.workerId), + availableDate: dateToDateString(availability.availableDate), + startsAt: availability.startsAt.toISOString(), + endsAt: availability.endsAt.toISOString(), +}); + +const isPrismaClient = (client: AvailabilityDatabaseClient): client is PrismaClient => + "$transaction" in client; + +const assertAvailabilityItemsInDateRange = (input: ReplaceAvailabilityRequest) => { + for (const item of input.items) { + if (item.availableDate < input.startDate || item.availableDate > input.endDate) { + throw createInvalidTimeRangeError(); + } + + if (dateTimeStringToDate(item.startsAt) >= dateTimeStringToDate(item.endsAt)) { + throw createInvalidTimeRangeError(); + } + } +}; + +const createPrunedAvailabilityItems = ( + organization: OrganizationForAvailability, + availableTimes: Pick< + PrismaWorkerAvailableTime, + "availableDate" | "endsAt" | "startsAt" | "workerId" + >[], +): PrunedAvailabilityTime[] => + pruneAvailabilityTimesToSchedulableRanges({ + availableTimes, + businessHours: organization.businessHours, + rules: organization.minimumStaffingRules, + }); + +export async function listAvailability( + prisma: PrismaClient, + userId: string, + query: AvailabilityQuery, +): Promise { + const organization = await findOrganizationByUserId(prisma, userId); + const organizationId = organization.id; + const workerId = toPrismaId(query.workerId); + const worker = await findWorkerByOrganization(prisma, organizationId, workerId); + + if (!worker) { + throw new HttpError(404, ERROR_CODES.WORKER_NOT_FOUND, "근무자를 찾을 수 없습니다."); + } + + await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organizationId); + + const items = await prisma.workerAvailableTime.findMany({ + where: { + workerId, + availableDate: { + gte: dateStringToDate(query.startDate), + lte: dateStringToDate(query.endDate), + }, + }, + orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }], + }); + + return { + items: items.map(toAvailability), + }; +} + +export async function replaceAvailability( + prisma: PrismaClient, + userId: string, + input: ReplaceAvailabilityRequest, +): Promise { + const organization = await findOrganizationByUserId(prisma, userId); + const organizationId = organization.id; + const workerId = toPrismaId(input.workerId); + const worker = await findWorkerByOrganization(prisma, organizationId, workerId); + + if (!worker) { + throw new HttpError(404, ERROR_CODES.WORKER_NOT_FOUND, "근무자를 찾을 수 없습니다."); + } + + assertAvailabilityItemsInDateRange(input); + + const prunedItems = createPrunedAvailabilityItems( + organization, + input.items.map((item) => ({ + workerId, + availableDate: dateStringToDate(item.availableDate), + startsAt: dateTimeStringToDate(item.startsAt), + endsAt: dateTimeStringToDate(item.endsAt), + })), + ); + + return prisma.$transaction(async (transaction) => { + const dateRange = { + gte: dateStringToDate(input.startDate), + lte: dateStringToDate(input.endDate), + }; + + await transaction.workerAvailableTime.deleteMany({ + where: { + workerId, + availableDate: dateRange, + }, + }); + + if (prunedItems.length > 0) { + await transaction.workerAvailableTime.createMany({ + data: prunedItems, + }); + } + + const items = await transaction.workerAvailableTime.findMany({ + where: { + workerId, + availableDate: dateRange, + }, + orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }], + }); + + return { + items: items.map(toAvailability), + }; + }); +} + +export async function pruneOrganizationAvailabilityForActivePlanningPeriod( + client: AvailabilityDatabaseClient, + organizationId: bigint, +): Promise { + const activePeriod = await client.activeSchedulePlanningPeriod.findUnique({ + where: { + organizationId, + }, + }); + + if (!activePeriod) { + return; + } + + const organization = await client.organization.findUnique({ + where: { + id: organizationId, + }, + include: { + businessHours: true, + minimumStaffingRules: true, + }, + }); + + if (!organization) { + return; + } + + const dateRange = { + gte: activePeriod.startDate, + lte: activePeriod.endDate, + }; + const existingItems = await client.workerAvailableTime.findMany({ + where: { + worker: { + organizationId, + }, + availableDate: dateRange, + }, + orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }], + }); + const prunedItems = createPrunedAvailabilityItems(organization, existingItems); + + const replaceAvailability = async (transaction: AvailabilityDatabaseClient) => { + await transaction.workerAvailableTime.deleteMany({ + where: { + worker: { + organizationId, + }, + availableDate: dateRange, + }, + }); + + if (prunedItems.length > 0) { + await transaction.workerAvailableTime.createMany({ + data: prunedItems, + }); + } + }; + + if (isPrismaClient(client)) { + await client.$transaction(replaceAvailability); + return; + } + + await replaceAvailability(client); +} diff --git a/apps/api/src/modules/organization/organization.handlers.ts b/apps/api/src/modules/organization/organization.handlers.ts new file mode 100644 index 0000000..74b7b5f --- /dev/null +++ b/apps/api/src/modules/organization/organization.handlers.ts @@ -0,0 +1,48 @@ +import type { PrismaClient } from "@fragment/database"; +import type { Request, RequestHandler } from "express"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { createOrganization, getOrganization, updateOrganization } from "./organization.service"; + +const getAuthenticatedUserId = (req: Request) => { + // requireAuth 이후 실행되지만, 타입 좁히기와 라우터 누락 방지를 위해 확인합니다. + if (!req.user) { + throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, "인증이 필요합니다."); + } + + return req.user.id; +}; + +export const createOrganizationHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await createOrganization(prisma, getAuthenticatedUserId(req), req.body); + + res.status(201).json(result); + } catch (error) { + next(error); + } +}; + +export const getOrganizationHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await getOrganization(prisma, getAuthenticatedUserId(req)); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const updateOrganizationHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await updateOrganization(prisma, getAuthenticatedUserId(req), req.body); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/modules/organization/organization.service.ts b/apps/api/src/modules/organization/organization.service.ts new file mode 100644 index 0000000..6094e30 --- /dev/null +++ b/apps/api/src/modules/organization/organization.service.ts @@ -0,0 +1,153 @@ +import { Prisma, type DayOfWeek, type PrismaClient } from "@fragment/database"; +import type { + CreateOrganizationRequest, + OrganizationDetail, + UpdateOrganizationRequest, +} from "@fragment/shared"; +import { DAY_OF_WEEK_VALUES } from "@fragment/shared"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { nullableDateToTimeString, nullableTimeStringToDate } from "@/utils/date-time"; +import { toApiId, toPrismaId } from "@/utils/mapper"; + +type OrganizationDatabaseClient = PrismaClient | Prisma.TransactionClient; + +type OrganizationWithBusinessHours = Prisma.OrganizationGetPayload<{ + include: { + businessHours: true; + }; +}>; + +const getBusinessHourOrder = (dayOfWeek: DayOfWeek) => DAY_OF_WEEK_VALUES.indexOf(dayOfWeek); + +const getClosesNextDay = ({ + closeTime, + isClosed, + openTime, +}: CreateOrganizationRequest["businessHours"][number]) => + !isClosed && openTime !== null && closeTime !== null && openTime > closeTime; + +const toOrganizationDetail = (organization: OrganizationWithBusinessHours): OrganizationDetail => ({ + id: toApiId(organization.id), + name: organization.name, + businessHours: [...organization.businessHours] + .sort((a, b) => getBusinessHourOrder(a.dayOfWeek) - getBusinessHourOrder(b.dayOfWeek)) + .map((businessHour) => ({ + id: toApiId(businessHour.id), + dayOfWeek: businessHour.dayOfWeek, + isClosed: businessHour.isClosed, + openTime: nullableDateToTimeString(businessHour.openTime), + closeTime: nullableDateToTimeString(businessHour.closeTime), + closesNextDay: businessHour.closesNextDay, + })), +}); + +const createBusinessHoursData = (businessHours: CreateOrganizationRequest["businessHours"]) => + businessHours.map((businessHour) => ({ + dayOfWeek: businessHour.dayOfWeek, + isClosed: businessHour.isClosed, + openTime: nullableTimeStringToDate(businessHour.openTime), + closeTime: nullableTimeStringToDate(businessHour.closeTime), + closesNextDay: getClosesNextDay(businessHour), + })); + +async function findOrganizationByUserId(client: OrganizationDatabaseClient, userId: bigint) { + return client.organization.findUnique({ + where: { userId }, + include: { + businessHours: true, + }, + }); +} + +export async function createOrganization( + prisma: PrismaClient, + userId: string, + input: CreateOrganizationRequest, +) { + const userDatabaseId = toPrismaId(userId); + + const existingOrganization = await prisma.organization.findUnique({ + where: { userId: userDatabaseId }, + select: { id: true }, + }); + + if (existingOrganization) { + throw new HttpError(409, ERROR_CODES.ORGANIZATION_ALREADY_EXISTS, "이미 조직이 존재합니다."); + } + + try { + const organization = await prisma.organization.create({ + data: { + userId: userDatabaseId, + name: input.name, + businessHours: { + create: createBusinessHoursData(input.businessHours), + }, + }, + include: { + businessHours: true, + }, + }); + + return toOrganizationDetail(organization); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + throw new HttpError(409, ERROR_CODES.ORGANIZATION_ALREADY_EXISTS, "이미 조직이 존재합니다."); + } + + throw error; + } +} + +export async function getOrganization(prisma: PrismaClient, userId: string) { + const organization = await findOrganizationByUserId(prisma, toPrismaId(userId)); + + if (!organization) { + throw new HttpError(404, ERROR_CODES.NOT_FOUND, "조직을 찾을 수 없습니다."); + } + + return toOrganizationDetail(organization); +} + +export async function updateOrganization( + prisma: PrismaClient, + userId: string, + input: UpdateOrganizationRequest, +) { + const userDatabaseId = toPrismaId(userId); + + return prisma.$transaction(async (transaction) => { + const organization = await findOrganizationByUserId(transaction, userDatabaseId); + + if (!organization) { + throw new HttpError(404, ERROR_CODES.NOT_FOUND, "조직을 찾을 수 없습니다."); + } + + if (input.businessHours) { + await transaction.organizationBusinessHour.deleteMany({ + where: { organizationId: organization.id }, + }); + } + + const updatedOrganization = await transaction.organization.update({ + where: { id: organization.id }, + data: { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.businessHours + ? { + businessHours: { + create: createBusinessHoursData(input.businessHours), + }, + } + : {}), + }, + include: { + businessHours: true, + }, + }); + + return toOrganizationDetail(updatedOrganization); + }); +} diff --git a/apps/api/src/modules/organization/planning-period.handlers.ts b/apps/api/src/modules/organization/planning-period.handlers.ts new file mode 100644 index 0000000..6d9dd23 --- /dev/null +++ b/apps/api/src/modules/organization/planning-period.handlers.ts @@ -0,0 +1,56 @@ +import type { PrismaClient } from "@fragment/database"; +import type { Request, RequestHandler } from "express"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { + deleteActiveSchedulePlanningPeriod, + getActiveSchedulePlanningPeriod, + upsertActiveSchedulePlanningPeriod, +} from "./planning-period.service"; + +const getAuthenticatedUserId = (req: Request) => { + if (!req.user) { + throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, "인증이 필요합니다."); + } + + return req.user.id; +}; + +export const getActiveSchedulePlanningPeriodHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await getActiveSchedulePlanningPeriod(prisma, getAuthenticatedUserId(req)); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const upsertActiveSchedulePlanningPeriodHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await upsertActiveSchedulePlanningPeriod( + prisma, + getAuthenticatedUserId(req), + req.body, + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const deleteActiveSchedulePlanningPeriodHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + + await deleteActiveSchedulePlanningPeriod(prisma, getAuthenticatedUserId(req)); + + res.status(204).send(); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/modules/organization/planning-period.service.spec.ts b/apps/api/src/modules/organization/planning-period.service.spec.ts new file mode 100644 index 0000000..dec31e7 --- /dev/null +++ b/apps/api/src/modules/organization/planning-period.service.spec.ts @@ -0,0 +1,174 @@ +import type { PrismaClient } from "@fragment/database"; +import { describe, expect, it, jest } from "@jest/globals"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { + deleteActiveSchedulePlanningPeriod, + getActiveSchedulePlanningPeriod, + upsertActiveSchedulePlanningPeriod, +} from "./planning-period.service"; + +const dateOnly = (date: string) => new Date(`${date}T00:00:00.000Z`); +const timestamp = new Date("2026-06-25T00:00:00.000Z"); + +const createPeriodRow = ({ + id = BigInt(10), + organizationId = BigInt(1), + startDate = dateOnly("2026-07-01"), + endDate = dateOnly("2026-07-31"), +} = {}) => ({ + id, + organizationId, + startDate, + endDate, + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createPrismaMock = ({ + activeSchedulePlanningPeriod = {}, + organization = { id: BigInt(1) }, +}: { + activeSchedulePlanningPeriod?: { + deleteMany?: jest.Mock; + findUnique?: jest.Mock; + upsert?: jest.Mock; + }; + organization?: { id: bigint } | null; +} = {}) => { + const organizationFindUnique = jest + .fn<() => Promise<{ id: bigint } | null>>() + .mockResolvedValue(organization); + + return { + prisma: { + activeSchedulePlanningPeriod: { + deleteMany: activeSchedulePlanningPeriod.deleteMany ?? jest.fn(), + findUnique: activeSchedulePlanningPeriod.findUnique ?? jest.fn(), + upsert: activeSchedulePlanningPeriod.upsert ?? jest.fn(), + }, + organization: { + findUnique: organizationFindUnique, + }, + } as unknown as PrismaClient, + organizationFindUnique, + }; +}; + +describe("getActiveSchedulePlanningPeriod", () => { + it("rejects users without an organization", async () => { + const periodFindUnique = jest.fn(); + const { prisma } = createPrismaMock({ + activeSchedulePlanningPeriod: { + findUnique: periodFindUnique, + }, + organization: null, + }); + + await expect(getActiveSchedulePlanningPeriod(prisma, "1")).rejects.toMatchObject({ + code: ERROR_CODES.ORGANIZATION_REQUIRED, + statusCode: 403, + }); + expect(periodFindUnique).not.toHaveBeenCalled(); + }); + + it("returns null when the organization has no active period", async () => { + const periodFindUnique = jest.fn<() => Promise>().mockResolvedValue(null); + const { prisma } = createPrismaMock({ + activeSchedulePlanningPeriod: { + findUnique: periodFindUnique, + }, + }); + + await expect(getActiveSchedulePlanningPeriod(prisma, "1")).resolves.toEqual({ + period: null, + }); + expect(periodFindUnique).toHaveBeenCalledWith({ + where: { + organizationId: BigInt(1), + }, + }); + }); + + it("returns the active period with API id and date strings", async () => { + const periodFindUnique = jest + .fn<() => Promise>>() + .mockResolvedValue(createPeriodRow()); + const { prisma } = createPrismaMock({ + activeSchedulePlanningPeriod: { + findUnique: periodFindUnique, + }, + }); + + await expect(getActiveSchedulePlanningPeriod(prisma, "1")).resolves.toEqual({ + period: { + id: "10", + startDate: "2026-07-01", + endDate: "2026-07-31", + }, + }); + }); +}); + +describe("upsertActiveSchedulePlanningPeriod", () => { + it("upserts the period scoped to the user's organization", async () => { + const periodUpsert = jest + .fn<() => Promise>>() + .mockResolvedValue( + createPeriodRow({ + startDate: dateOnly("2026-08-01"), + endDate: dateOnly("2026-08-15"), + }), + ); + const { prisma } = createPrismaMock({ + activeSchedulePlanningPeriod: { + upsert: periodUpsert, + }, + }); + + await expect( + upsertActiveSchedulePlanningPeriod(prisma, "1", { + startDate: "2026-08-01", + endDate: "2026-08-15", + }), + ).resolves.toEqual({ + id: "10", + startDate: "2026-08-01", + endDate: "2026-08-15", + }); + expect(periodUpsert).toHaveBeenCalledWith({ + where: { + organizationId: BigInt(1), + }, + create: { + organizationId: BigInt(1), + startDate: dateOnly("2026-08-01"), + endDate: dateOnly("2026-08-15"), + }, + update: { + startDate: dateOnly("2026-08-01"), + endDate: dateOnly("2026-08-15"), + }, + }); + }); +}); + +describe("deleteActiveSchedulePlanningPeriod", () => { + it("deletes the period scoped to the user's organization", async () => { + const periodDeleteMany = jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ + count: 1, + }); + const { prisma } = createPrismaMock({ + activeSchedulePlanningPeriod: { + deleteMany: periodDeleteMany, + }, + }); + + await expect(deleteActiveSchedulePlanningPeriod(prisma, "1")).resolves.toBeUndefined(); + expect(periodDeleteMany).toHaveBeenCalledWith({ + where: { + organizationId: BigInt(1), + }, + }); + }); +}); diff --git a/apps/api/src/modules/organization/planning-period.service.ts b/apps/api/src/modules/organization/planning-period.service.ts new file mode 100644 index 0000000..6483ad8 --- /dev/null +++ b/apps/api/src/modules/organization/planning-period.service.ts @@ -0,0 +1,92 @@ +import type { + ActiveSchedulePlanningPeriod as PrismaActiveSchedulePlanningPeriod, + PrismaClient, +} from "@fragment/database"; +import type { + ActiveSchedulePlanningPeriod, + ActiveSchedulePlanningPeriodResponse, + UpsertActiveSchedulePlanningPeriodRequest, +} from "@fragment/shared"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { dateStringToDate, dateToDateString } from "@/utils/date-time"; +import { toApiId, toPrismaId } from "@/utils/mapper"; + +const findOrganizationIdByUserId = async (prisma: PrismaClient, userId: string) => { + const organization = await prisma.organization.findUnique({ + where: { + userId: toPrismaId(userId), + }, + select: { + id: true, + }, + }); + + if (!organization) { + throw new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다."); + } + + return organization.id; +}; + +const toActiveSchedulePlanningPeriod = ( + period: PrismaActiveSchedulePlanningPeriod, +): ActiveSchedulePlanningPeriod => ({ + id: toApiId(period.id), + startDate: dateToDateString(period.startDate), + endDate: dateToDateString(period.endDate), +}); + +export async function getActiveSchedulePlanningPeriod( + prisma: PrismaClient, + userId: string, +): Promise { + const organizationId = await findOrganizationIdByUserId(prisma, userId); + const period = await prisma.activeSchedulePlanningPeriod.findUnique({ + where: { + organizationId, + }, + }); + + return { + period: period ? toActiveSchedulePlanningPeriod(period) : null, + }; +} + +export async function upsertActiveSchedulePlanningPeriod( + prisma: PrismaClient, + userId: string, + input: UpsertActiveSchedulePlanningPeriodRequest, +): Promise { + const organizationId = await findOrganizationIdByUserId(prisma, userId); + const period = await prisma.activeSchedulePlanningPeriod.upsert({ + where: { + organizationId, + }, + create: { + organizationId, + startDate: dateStringToDate(input.startDate), + endDate: dateStringToDate(input.endDate), + }, + update: { + startDate: dateStringToDate(input.startDate), + endDate: dateStringToDate(input.endDate), + }, + }); + + return toActiveSchedulePlanningPeriod(period); +} + +export async function deleteActiveSchedulePlanningPeriod( + prisma: PrismaClient, + userId: string, +): Promise { + const organizationId = await findOrganizationIdByUserId(prisma, userId); + + await prisma.activeSchedulePlanningPeriod.deleteMany({ + where: { + organizationId, + }, + }); +} diff --git a/apps/api/src/modules/schedules/schedules.handlers.ts b/apps/api/src/modules/schedules/schedules.handlers.ts new file mode 100644 index 0000000..bf7617d --- /dev/null +++ b/apps/api/src/modules/schedules/schedules.handlers.ts @@ -0,0 +1,133 @@ +import type { PrismaClient } from "@fragment/database"; +import type { DraftScheduleQuery } from "@fragment/shared"; +import type { Request, RequestHandler } from "express"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { + confirmSchedule, + createScheduleAssignment, + deleteScheduleAssignment, + getDraftSchedule, + recommendSchedule, + updateScheduleAssignment, +} from "./schedules.service"; + +const getRequestOrganizationId = (req: Request) => { + if (!req.organization) { + throw new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다."); + } + + return req.organization.id; +}; + +const getScheduleIdParam = (req: Request) => { + const scheduleId = req.params.scheduleId; + + if (typeof scheduleId !== "string") { + throw new HttpError(400, ERROR_CODES.VALIDATION_ERROR, "요청 형식이 올바르지 않습니다."); + } + + return scheduleId; +}; + +const getAssignmentIdParam = (req: Request) => { + const assignmentId = req.params.assignmentId; + + if (typeof assignmentId !== "string") { + throw new HttpError(400, ERROR_CODES.VALIDATION_ERROR, "요청 형식이 올바르지 않습니다."); + } + + return assignmentId; +}; + +export const recommendScheduleHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await recommendSchedule(prisma, getRequestOrganizationId(req), req.body); + + res.status(201).json(result); + } catch (error) { + next(error); + } +}; + +export const getDraftScheduleHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await getDraftSchedule( + prisma, + getRequestOrganizationId(req), + req.query as DraftScheduleQuery, + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const createScheduleAssignmentHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await createScheduleAssignment( + prisma, + getRequestOrganizationId(req), + getScheduleIdParam(req), + req.body, + ); + + res.status(201).json(result); + } catch (error) { + next(error); + } +}; + +export const updateScheduleAssignmentHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await updateScheduleAssignment( + prisma, + getRequestOrganizationId(req), + getScheduleIdParam(req), + getAssignmentIdParam(req), + req.body, + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const deleteScheduleAssignmentHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + + await deleteScheduleAssignment( + prisma, + getRequestOrganizationId(req), + getScheduleIdParam(req), + getAssignmentIdParam(req), + ); + + res.status(204).send(); + } catch (error) { + next(error); + } +}; + +export const confirmScheduleHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await confirmSchedule( + prisma, + getRequestOrganizationId(req), + getScheduleIdParam(req), + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/modules/schedules/schedules.mapper.ts b/apps/api/src/modules/schedules/schedules.mapper.ts new file mode 100644 index 0000000..9a67655 --- /dev/null +++ b/apps/api/src/modules/schedules/schedules.mapper.ts @@ -0,0 +1,104 @@ +import type { + Prisma, + Schedule, + ScheduleAssignment as PrismaScheduleAssignment, + ScheduleCandidate as PrismaScheduleCandidate, + ScheduleWorkerShortage as PrismaScheduleWorkerShortage, +} from "@fragment/database"; +import type { + ScheduleAssignment, + ScheduleCandidate, + ScheduleDetail, + ScheduleWorkerShortage, +} from "@fragment/shared"; + +import { dateToDateString, dateToTimeString } from "@/utils/date-time"; +import { toApiId } from "@/utils/mapper"; + +type ScheduleWithDetails = Schedule & { + assignments: PrismaScheduleAssignment[]; + candidates: PrismaScheduleCandidate[]; + workerShortages: PrismaScheduleWorkerShortage[]; +}; + +export const scheduleDetailInclude = { + assignments: { + orderBy: [{ workDate: "asc" }, { startsAt: "asc" }, { id: "asc" }], + }, + candidates: { + orderBy: [{ workDate: "asc" }, { startsAt: "asc" }, { employeeCodeSnapshot: "asc" }], + }, + workerShortages: { + orderBy: [{ workDate: "asc" }, { startTime: "asc" }, { id: "asc" }], + }, +} satisfies Prisma.ScheduleInclude; + +export const toScheduleAssignment = (assignment: PrismaScheduleAssignment): ScheduleAssignment => ({ + id: toApiId(assignment.id), + scheduleId: toApiId(assignment.scheduleId), + workerId: assignment.workerId === null ? null : toApiId(assignment.workerId), + workDate: dateToDateString(assignment.workDate), + startsAt: assignment.startsAt.toISOString(), + endsAt: assignment.endsAt.toISOString(), + workerNameSnapshot: assignment.workerNameSnapshot, + employeeCodeSnapshot: assignment.employeeCodeSnapshot, +}); + +const isCandidateRecommended = ( + candidate: PrismaScheduleCandidate, + assignments: PrismaScheduleAssignment[], +) => + assignments.some( + (assignment) => + assignment.workDate.getTime() === candidate.workDate.getTime() && + assignment.startsAt.getTime() === candidate.startsAt.getTime() && + assignment.endsAt.getTime() === candidate.endsAt.getTime() && + (candidate.workerId !== null + ? assignment.workerId === candidate.workerId + : assignment.workerId === null && + assignment.workerNameSnapshot === candidate.workerNameSnapshot && + assignment.employeeCodeSnapshot === candidate.employeeCodeSnapshot), + ); + +export const toScheduleCandidate = ( + candidate: PrismaScheduleCandidate, + assignments: PrismaScheduleAssignment[], +): ScheduleCandidate => ({ + id: toApiId(candidate.id), + scheduleId: toApiId(candidate.scheduleId), + workerId: candidate.workerId === null ? null : toApiId(candidate.workerId), + workDate: dateToDateString(candidate.workDate), + startsAt: candidate.startsAt.toISOString(), + endsAt: candidate.endsAt.toISOString(), + workerNameSnapshot: candidate.workerNameSnapshot, + employeeCodeSnapshot: candidate.employeeCodeSnapshot, + isRecommended: isCandidateRecommended(candidate, assignments), +}); + +export const toScheduleWorkerShortage = ( + shortage: PrismaScheduleWorkerShortage, +): ScheduleWorkerShortage => ({ + id: toApiId(shortage.id), + scheduleId: toApiId(shortage.scheduleId), + workDate: dateToDateString(shortage.workDate), + dayOfWeek: shortage.dayOfWeek, + startTime: dateToTimeString(shortage.startTime), + endTime: dateToTimeString(shortage.endTime), + endsNextDay: shortage.endsNextDay, + requiredCount: shortage.requiredCount, + assignedCount: shortage.assignedCount, +}); + +export const toScheduleDetail = (schedule: ScheduleWithDetails): ScheduleDetail => ({ + id: toApiId(schedule.id), + status: schedule.status, + startDate: dateToDateString(schedule.startDate), + endDate: dateToDateString(schedule.endDate), + generatedAt: schedule.generatedAt.toISOString(), + confirmedAt: schedule.confirmedAt ? schedule.confirmedAt.toISOString() : null, + assignments: schedule.assignments.map(toScheduleAssignment), + candidates: schedule.candidates.map((candidate) => + toScheduleCandidate(candidate, schedule.assignments), + ), + workerShortages: schedule.workerShortages.map(toScheduleWorkerShortage), +}); diff --git a/apps/api/src/modules/schedules/schedules.recommendation.ts b/apps/api/src/modules/schedules/schedules.recommendation.ts new file mode 100644 index 0000000..f2be20e --- /dev/null +++ b/apps/api/src/modules/schedules/schedules.recommendation.ts @@ -0,0 +1,436 @@ +import { createHash } from "node:crypto"; +import type { + DayOfWeek, + MinimumStaffingRule, + OrganizationBusinessHour, + Worker, + WorkerAvailableTime, +} from "@fragment/database"; + +import { dateToDateString, dateToTimeString } from "@/utils/date-time"; +import { eachDateInRange } from "@/utils/date-range"; +import { getDayOfWeek } from "@/utils/day-of-week"; +import { toApiId } from "@/utils/mapper"; +import { + createDateTimeFromMinutes, + getSchedulableRangesForDate, + minutesToTimeDate, +} from "@/modules/scheduling-time-policy"; + +type RecommendationAssignment = { + workerId: bigint; + workDate: Date; + startsAt: Date; + endsAt: Date; + workerNameSnapshot: string; + employeeCodeSnapshot: string; +}; + +type RecommendationCandidate = RecommendationAssignment; + +type RecommendationShortage = { + workDate: Date; + dayOfWeek: DayOfWeek; + startTime: Date; + endTime: Date; + endsNextDay: boolean; + requiredCount: number; + assignedCount: number; +}; + +type DemandWindow = { + workDate: Date; + dayOfWeek: DayOfWeek; + startMinutes: number; + endMinutes: number; + startsAt: Date; + endsAt: Date; + startTime: Date; + endTime: Date; + endsNextDay: boolean; + requiredCount: number; +}; + +const MINUTES_IN_DAY = 24 * 60; + +const compareBigInt = (left: bigint, right: bigint) => (left < right ? -1 : left > right ? 1 : 0); + +const hasOverlap = ( + assignments: RecommendationAssignment[], + workerId: bigint, + startsAt: Date, + endsAt: Date, +) => + assignments.some( + (assignment) => + assignment.workerId === workerId && + assignment.startsAt < endsAt && + startsAt < assignment.endsAt, + ); + +const mergeAdjacentAssignments = (assignments: RecommendationAssignment[]) => { + const sortedAssignments = [...assignments].sort((a, b) => { + if (a.workerId !== b.workerId) { + return Number(a.workerId - b.workerId); + } + + if (a.workDate.getTime() !== b.workDate.getTime()) { + return a.workDate.getTime() - b.workDate.getTime(); + } + + return a.startsAt.getTime() - b.startsAt.getTime(); + }); + const mergedAssignments: RecommendationAssignment[] = []; + + for (const assignment of sortedAssignments) { + const previous = mergedAssignments.at(-1); + + if ( + previous && + previous.workerId === assignment.workerId && + previous.workDate.getTime() === assignment.workDate.getTime() && + previous.endsAt.getTime() === assignment.startsAt.getTime() + ) { + previous.endsAt = assignment.endsAt; + continue; + } + + mergedAssignments.push({ ...assignment }); + } + + return mergedAssignments.sort((a, b) => { + if (a.workDate.getTime() !== b.workDate.getTime()) { + return a.workDate.getTime() - b.workDate.getTime(); + } + + if (a.startsAt.getTime() !== b.startsAt.getTime()) { + return a.startsAt.getTime() - b.startsAt.getTime(); + } + + return Number(a.workerId - b.workerId); + }); +}; + +const getAssignmentDurationMinutes = ( + assignment: Pick, +) => Math.round((assignment.endsAt.getTime() - assignment.startsAt.getTime()) / 60000); + +const getWeekStartDate = (date: Date) => { + const day = date.getUTCDay(); + const mondayFirstOffset = day === 0 ? -6 : 1 - day; + + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + mondayFirstOffset), + ); +}; + +const getAssignedMinutes = ( + assignments: RecommendationAssignment[], + workerId: bigint, + predicate: (assignment: RecommendationAssignment) => boolean, +) => + assignments + .filter((assignment) => assignment.workerId === workerId && predicate(assignment)) + .reduce((total, assignment) => total + getAssignmentDurationMinutes(assignment), 0); + +const dateTimeToMinutesFromWorkDate = (workDate: Date, dateTime: Date) => + Math.round((dateTime.getTime() - workDate.getTime()) / 60000); + +const compareCandidatesByBalance = ({ + assignments, + firstWorker, + secondWorker, + workDate, +}: { + assignments: RecommendationAssignment[]; + firstWorker: Worker; + secondWorker: Worker; + workDate: Date; +}) => { + const weekStartDate = getWeekStartDate(workDate); + const weekEndDate = new Date( + Date.UTC( + weekStartDate.getUTCFullYear(), + weekStartDate.getUTCMonth(), + weekStartDate.getUTCDate() + 7, + ), + ); + const isSameWeek = (assignment: RecommendationAssignment) => + assignment.workDate >= weekStartDate && assignment.workDate < weekEndDate; + const isSameDay = (assignment: RecommendationAssignment) => + assignment.workDate.getTime() === workDate.getTime(); + const firstWeeklyMinutes = getAssignedMinutes(assignments, firstWorker.id, isSameWeek); + const secondWeeklyMinutes = getAssignedMinutes(assignments, secondWorker.id, isSameWeek); + const firstContractMinutes = firstWorker.weeklyContractHours * 60; + const secondContractMinutes = secondWorker.weeklyContractHours * 60; + const firstUsage = firstWeeklyMinutes / firstContractMinutes; + const secondUsage = secondWeeklyMinutes / secondContractMinutes; + const allCandidatesOverContract = firstUsage >= 1 && secondUsage >= 1; + + if (allCandidatesOverContract) { + const firstOverage = Math.max(0, firstWeeklyMinutes - firstContractMinutes); + const secondOverage = Math.max(0, secondWeeklyMinutes - secondContractMinutes); + + if (firstOverage !== secondOverage) { + return firstOverage - secondOverage; + } + } + + if (firstUsage !== secondUsage) { + return firstUsage - secondUsage; + } + + const firstDailyMinutes = getAssignedMinutes(assignments, firstWorker.id, isSameDay); + const secondDailyMinutes = getAssignedMinutes(assignments, secondWorker.id, isSameDay); + + if (firstDailyMinutes !== secondDailyMinutes) { + return firstDailyMinutes - secondDailyMinutes; + } + + return firstWorker.employeeCode.localeCompare(secondWorker.employeeCode); +}; + +const createDemandWindowsForDate = ({ + availableTimes, + businessHours, + rules, + workDate, +}: { + availableTimes: WorkerAvailableTime[]; + businessHours: OrganizationBusinessHour[]; + rules: MinimumStaffingRule[]; + workDate: Date; +}): DemandWindow[] => { + const dayOfWeek = getDayOfWeek(workDate); + const demandSources = getSchedulableRangesForDate({ + businessHours, + rules, + workDate, + }); + + const boundaries = [ + ...new Set([ + ...demandSources.flatMap((source) => [source.startMinutes, source.endMinutes]), + ...availableTimes + .filter((availableTime) => availableTime.availableDate.getTime() === workDate.getTime()) + .flatMap((availableTime) => { + const startMinutes = dateTimeToMinutesFromWorkDate(workDate, availableTime.startsAt); + const endMinutes = dateTimeToMinutesFromWorkDate(workDate, availableTime.endsAt); + + return demandSources.flatMap((source) => { + if (source.startMinutes >= endMinutes || startMinutes >= source.endMinutes) { + return []; + } + + return [ + Math.max(startMinutes, source.startMinutes), + Math.min(endMinutes, source.endMinutes), + ]; + }); + }), + ]), + ] + .filter((boundary) => Number.isFinite(boundary)) + .sort((a, b) => a - b); + const demandWindows: DemandWindow[] = []; + + for (let index = 0; index < boundaries.length - 1; index += 1) { + const startMinutes = boundaries[index]; + const endMinutes = boundaries[index + 1]; + const activeDemandSources = demandSources.filter( + (source) => source.startMinutes < endMinutes && startMinutes < source.endMinutes, + ); + + if (activeDemandSources.length === 0) { + continue; + } + + const requiredCount = Math.max(...activeDemandSources.map((source) => source.requiredCount)); + const startsAt = createDateTimeFromMinutes(workDate, startMinutes); + const endsAt = createDateTimeFromMinutes(workDate, endMinutes); + + demandWindows.push({ + dayOfWeek, + endMinutes, + endsAt, + endsNextDay: endMinutes >= MINUTES_IN_DAY, + endTime: minutesToTimeDate(endMinutes), + requiredCount, + startMinutes, + startsAt, + startTime: minutesToTimeDate(startMinutes), + workDate, + }); + } + + return demandWindows; +}; + +export const createInputHash = ({ + availableTimes, + businessHours, + endDate, + rules, + startDate, + workers, +}: { + availableTimes: WorkerAvailableTime[]; + businessHours: OrganizationBusinessHour[]; + endDate: Date; + rules: MinimumStaffingRule[]; + startDate: Date; + workers: Worker[]; +}) => { + const payload = { + startDate: dateToDateString(startDate), + endDate: dateToDateString(endDate), + workers: [...workers] + .sort((left, right) => compareBigInt(left.id, right.id)) + .map((worker) => ({ + id: toApiId(worker.id), + employeeCode: worker.employeeCode, + name: worker.name, + weeklyContractHours: worker.weeklyContractHours, + })), + availableTimes: [...availableTimes] + .sort( + (left, right) => + compareBigInt(left.workerId, right.workerId) || + left.availableDate.getTime() - right.availableDate.getTime() || + left.startsAt.getTime() - right.startsAt.getTime() || + left.endsAt.getTime() - right.endsAt.getTime(), + ) + .map((availableTime) => ({ + workerId: toApiId(availableTime.workerId), + availableDate: dateToDateString(availableTime.availableDate), + startsAt: availableTime.startsAt.toISOString(), + endsAt: availableTime.endsAt.toISOString(), + })), + businessHours: [...businessHours] + .sort((left, right) => left.dayOfWeek.localeCompare(right.dayOfWeek)) + .map((businessHour) => ({ + dayOfWeek: businessHour.dayOfWeek, + isClosed: businessHour.isClosed, + openTime: businessHour.openTime ? dateToTimeString(businessHour.openTime) : null, + closeTime: businessHour.closeTime ? dateToTimeString(businessHour.closeTime) : null, + closesNextDay: businessHour.closesNextDay, + })), + rules: [...rules] + .sort( + (left, right) => + left.dayOfWeek.localeCompare(right.dayOfWeek) || + left.startTime.getTime() - right.startTime.getTime() || + left.endTime.getTime() - right.endTime.getTime() || + Number(left.endsNextDay) - Number(right.endsNextDay) || + left.requiredCount - right.requiredCount, + ) + .map((rule) => ({ + dayOfWeek: rule.dayOfWeek, + startTime: dateToTimeString(rule.startTime), + endTime: dateToTimeString(rule.endTime), + endsNextDay: rule.endsNextDay, + requiredCount: rule.requiredCount, + })), + }; + + return createHash("sha256").update(JSON.stringify(payload)).digest("hex"); +}; + +export const createRecommendations = ({ + availableTimes, + businessHours, + endDate, + rules, + startDate, + workers, +}: { + availableTimes: WorkerAvailableTime[]; + businessHours: OrganizationBusinessHour[]; + endDate: Date; + rules: MinimumStaffingRule[]; + startDate: Date; + workers: Worker[]; +}): { + assignments: RecommendationAssignment[]; + candidates: RecommendationCandidate[]; + shortages: RecommendationShortage[]; +} => { + const assignments: RecommendationAssignment[] = []; + const candidates: RecommendationCandidate[] = []; + const shortages: RecommendationShortage[] = []; + + for (const workDate of eachDateInRange(startDate, endDate)) { + const demandWindows = createDemandWindowsForDate({ + availableTimes, + businessHours, + rules, + workDate, + }); + + for (const demandWindow of demandWindows) { + const availableCandidates = workers + .filter((worker) => + availableTimes.some( + (availableTime) => + availableTime.workerId === worker.id && + availableTime.availableDate.getTime() === workDate.getTime() && + availableTime.startsAt <= demandWindow.startsAt && + availableTime.endsAt >= demandWindow.endsAt, + ), + ) + .sort((firstWorker, secondWorker) => + firstWorker.employeeCode.localeCompare(secondWorker.employeeCode), + ); + const selectableCandidates = availableCandidates + .filter( + (worker) => + !hasOverlap(assignments, worker.id, demandWindow.startsAt, demandWindow.endsAt), + ) + .sort((firstWorker, secondWorker) => + compareCandidatesByBalance({ + assignments, + firstWorker, + secondWorker, + workDate, + }), + ); + const selectedWorkers = selectableCandidates.slice(0, demandWindow.requiredCount); + + candidates.push( + ...availableCandidates.map((worker) => ({ + workerId: worker.id, + workDate, + startsAt: demandWindow.startsAt, + endsAt: demandWindow.endsAt, + workerNameSnapshot: worker.name, + employeeCodeSnapshot: worker.employeeCode, + })), + ); + + assignments.push( + ...selectedWorkers.map((worker) => ({ + workerId: worker.id, + workDate, + startsAt: demandWindow.startsAt, + endsAt: demandWindow.endsAt, + workerNameSnapshot: worker.name, + employeeCodeSnapshot: worker.employeeCode, + })), + ); + + if (selectedWorkers.length < demandWindow.requiredCount) { + shortages.push({ + workDate, + dayOfWeek: demandWindow.dayOfWeek, + startTime: demandWindow.startTime, + endTime: demandWindow.endTime, + endsNextDay: demandWindow.endsNextDay, + requiredCount: demandWindow.requiredCount, + assignedCount: selectedWorkers.length, + }); + } + } + } + + return { assignments: mergeAdjacentAssignments(assignments), candidates, shortages }; +}; diff --git a/apps/api/src/modules/schedules/schedules.service.spec.ts b/apps/api/src/modules/schedules/schedules.service.spec.ts new file mode 100644 index 0000000..c897dc5 --- /dev/null +++ b/apps/api/src/modules/schedules/schedules.service.spec.ts @@ -0,0 +1,600 @@ +import type { DayOfWeek } from "@fragment/database"; +import { describe, expect, it } from "@jest/globals"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { createFakePrisma, createTimeDate } from "../../../test/helpers/fake-prisma"; +import { + confirmSchedule, + createScheduleAssignment, + getDraftSchedule, + recommendSchedule, + updateScheduleAssignment, +} from "./schedules.service"; + +const timestamp = new Date("2026-06-25T00:00:00.000Z"); +const dateOnly = (date: string) => new Date(`${date}T00:00:00.000Z`); + +const createBusinessHour = ({ + closeTime = "14:00", + closesNextDay = false, + dayOfWeek = "WED", + id = BigInt(1), + isClosed = false, + openTime = "10:00", + organizationId = BigInt(1), +}: { + closeTime?: string; + closesNextDay?: boolean; + dayOfWeek?: DayOfWeek; + id?: bigint; + isClosed?: boolean; + openTime?: string; + organizationId?: bigint; +} = {}) => ({ + id, + organizationId, + dayOfWeek, + isClosed, + openTime: isClosed ? null : createTimeDate(openTime), + closeTime: isClosed ? null : createTimeDate(closeTime), + closesNextDay, +}); + +const createOrganization = ( + id = BigInt(1), + userId = BigInt(1), + businessHours = [createBusinessHour({ organizationId: id })], +) => ({ + id, + userId, + name: "프래그먼트 카페", + businessHours, +}); + +const createPlanningPeriod = ({ endDate = "2026-07-01", startDate = "2026-07-01" } = {}) => ({ + id: BigInt(1), + organizationId: BigInt(1), + startDate: dateOnly(startDate), + endDate: dateOnly(endDate), + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createWorker = ({ + id = BigInt(1), + organizationId = BigInt(1), + employeeCode = "W-0001", + name = "김민수", + weeklyContractHours = 40, +} = {}) => ({ + id, + organizationId, + employeeCode, + name, + weeklyContractHours, +}); + +const createAvailableTime = ({ + availableDate = "2026-07-01", + endsAt = "2026-07-01T18:00:00.000Z", + id = BigInt(1), + startsAt = "2026-07-01T09:00:00.000Z", + workerId = BigInt(1), +} = {}) => ({ + id, + workerId, + availableDate: dateOnly(availableDate), + startsAt: new Date(startsAt), + endsAt: new Date(endsAt), + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createMinimumStaffingRule = ({ + dayOfWeek = "WED", + endTime = "14:00", + endsNextDay = false, + requiredCount = 1, + startTime = "10:00", +}: { + dayOfWeek?: DayOfWeek; + endTime?: string; + endsNextDay?: boolean; + requiredCount?: number; + startTime?: string; +} = {}) => ({ + id: BigInt(1), + organizationId: BigInt(1), + dayOfWeek, + startTime: createTimeDate(startTime), + endTime: createTimeDate(endTime), + endsNextDay, + requiredCount, + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createDraftSchedule = ({ + id = BigInt(1), + status = "DRAFT", +}: { + id?: bigint; + status?: "DRAFT" | "CONFIRMED"; +} = {}) => ({ + id, + organizationId: BigInt(1), + status, + inputHash: `hash-${id.toString()}`, + startDate: dateOnly("2026-07-01"), + endDate: dateOnly("2026-07-01"), + generatedAt: timestamp, + confirmedAt: null, + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createAssignment = () => ({ + id: BigInt(10), + scheduleId: BigInt(1), + workerId: BigInt(1), + workDate: dateOnly("2026-07-01"), + startsAt: new Date("2026-07-01T10:00:00.000Z"), + endsAt: new Date("2026-07-01T14:00:00.000Z"), + workerNameSnapshot: "김민수", + employeeCodeSnapshot: "W-0001", + createdAt: timestamp, + updatedAt: timestamp, +}); + +describe("recommendSchedule", () => { + it("blocks recommendation when there is no active planning period", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + }); + + await expect( + recommendSchedule(prisma, "1", { + startDate: "2026-07-01", + endDate: "2026-07-01", + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.NO_ACTIVE_PLANNING_PERIOD, + statusCode: 400, + }); + }); + + it("creates a DRAFT schedule and records unmet staffing shortages", async () => { + const { prisma } = createFakePrisma({ + activeSchedulePlanningPeriods: [createPlanningPeriod()], + availableTimes: [createAvailableTime()], + minimumStaffingRules: [createMinimumStaffingRule({ requiredCount: 2 })], + organizations: [createOrganization()], + workers: [createWorker()], + }); + + const schedule = await recommendSchedule(prisma, "1", { + startDate: "2026-07-01", + endDate: "2026-07-01", + }); + + expect(schedule).toMatchObject({ + id: "1", + status: "DRAFT", + startDate: "2026-07-01", + endDate: "2026-07-01", + }); + expect(schedule.assignments).toEqual([ + expect.objectContaining({ + workerId: "1", + workDate: "2026-07-01", + startsAt: "2026-07-01T10:00:00.000Z", + endsAt: "2026-07-01T14:00:00.000Z", + }), + ]); + expect(schedule.workerShortages).toEqual([ + expect.objectContaining({ + workDate: "2026-07-01", + dayOfWeek: "WED", + requiredCount: 2, + assignedCount: 1, + }), + ]); + }); + + it("blocks recommendation when no minimum staffing rule exists", async () => { + const { prisma } = createFakePrisma({ + activeSchedulePlanningPeriods: [createPlanningPeriod()], + availableTimes: [createAvailableTime()], + minimumStaffingRules: [], + organizations: [createOrganization()], + workers: [createWorker()], + }); + + await expect( + recommendSchedule(prisma, "1", { + startDate: "2026-07-01", + endDate: "2026-07-01", + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.VALIDATION_ERROR, + statusCode: 400, + }); + }); + + it("creates recommendations only for minimum staffing rule windows", async () => { + const { prisma } = createFakePrisma({ + activeSchedulePlanningPeriods: [createPlanningPeriod()], + availableTimes: [ + createAvailableTime(), + createAvailableTime({ + id: BigInt(2), + workerId: BigInt(2), + }), + ], + minimumStaffingRules: [ + createMinimumStaffingRule({ + startTime: "12:00", + endTime: "14:00", + requiredCount: 2, + }), + ], + organizations: [ + createOrganization(BigInt(1), BigInt(1), [ + createBusinessHour({ + closeTime: "18:00", + openTime: "09:00", + }), + ]), + ], + workers: [ + createWorker(), + createWorker({ + id: BigInt(2), + employeeCode: "W-0002", + name: "박지민", + }), + ], + }); + + const schedule = await recommendSchedule(prisma, "1", { + startDate: "2026-07-01", + endDate: "2026-07-01", + }); + + expect(schedule.assignments).toEqual([ + expect.objectContaining({ + workerId: "1", + startsAt: "2026-07-01T12:00:00.000Z", + endsAt: "2026-07-01T14:00:00.000Z", + }), + expect.objectContaining({ + workerId: "2", + startsAt: "2026-07-01T12:00:00.000Z", + endsAt: "2026-07-01T14:00:00.000Z", + }), + ]); + expect(schedule.workerShortages).toEqual([]); + }); + + it("returns all candidates while marking selected recommendations", async () => { + const { prisma } = createFakePrisma({ + activeSchedulePlanningPeriods: [createPlanningPeriod()], + availableTimes: [ + createAvailableTime(), + createAvailableTime({ + id: BigInt(2), + workerId: BigInt(2), + }), + ], + minimumStaffingRules: [ + createMinimumStaffingRule({ + requiredCount: 1, + }), + ], + organizations: [createOrganization()], + workers: [ + createWorker(), + createWorker({ + id: BigInt(2), + employeeCode: "W-0002", + name: "박지민", + }), + ], + }); + + const schedule = await recommendSchedule(prisma, "1", { + startDate: "2026-07-01", + endDate: "2026-07-01", + }); + + expect(schedule.assignments).toHaveLength(1); + expect(schedule.candidates).toEqual([ + expect.objectContaining({ + workerId: "1", + startsAt: "2026-07-01T10:00:00.000Z", + endsAt: "2026-07-01T14:00:00.000Z", + isRecommended: true, + }), + expect.objectContaining({ + workerId: "2", + startsAt: "2026-07-01T10:00:00.000Z", + endsAt: "2026-07-01T14:00:00.000Z", + isRecommended: false, + }), + ]); + }); + + it("splits recommendation windows by availability boundaries inside a staffing rule", async () => { + const { prisma } = createFakePrisma({ + activeSchedulePlanningPeriods: [createPlanningPeriod()], + availableTimes: [ + createAvailableTime({ + endsAt: "2026-07-01T11:00:00.000Z", + startsAt: "2026-07-01T09:00:00.000Z", + }), + createAvailableTime({ + id: BigInt(2), + workerId: BigInt(2), + endsAt: "2026-07-01T11:00:00.000Z", + startsAt: "2026-07-01T09:00:00.000Z", + }), + createAvailableTime({ + id: BigInt(3), + workerId: BigInt(2), + endsAt: "2026-07-01T16:00:00.000Z", + startsAt: "2026-07-01T12:00:00.000Z", + }), + ], + minimumStaffingRules: [ + createMinimumStaffingRule({ + endTime: "16:00", + requiredCount: 2, + startTime: "09:00", + }), + ], + organizations: [ + createOrganization(BigInt(1), BigInt(1), [ + createBusinessHour({ + closeTime: "16:00", + openTime: "09:00", + }), + ]), + ], + workers: [ + createWorker(), + createWorker({ + id: BigInt(2), + employeeCode: "W-0002", + name: "박지민", + }), + ], + }); + + const schedule = await recommendSchedule(prisma, "1", { + startDate: "2026-07-01", + endDate: "2026-07-01", + }); + + expect(schedule.candidates).toEqual([ + expect.objectContaining({ + workerId: "1", + startsAt: "2026-07-01T09:00:00.000Z", + endsAt: "2026-07-01T11:00:00.000Z", + isRecommended: true, + }), + expect.objectContaining({ + workerId: "2", + startsAt: "2026-07-01T09:00:00.000Z", + endsAt: "2026-07-01T11:00:00.000Z", + isRecommended: true, + }), + expect.objectContaining({ + workerId: "2", + startsAt: "2026-07-01T12:00:00.000Z", + endsAt: "2026-07-01T16:00:00.000Z", + isRecommended: true, + }), + ]); + expect(schedule.assignments).toEqual([ + expect.objectContaining({ + workerId: "1", + startsAt: "2026-07-01T09:00:00.000Z", + endsAt: "2026-07-01T11:00:00.000Z", + }), + expect.objectContaining({ + workerId: "2", + startsAt: "2026-07-01T09:00:00.000Z", + endsAt: "2026-07-01T11:00:00.000Z", + }), + expect.objectContaining({ + workerId: "2", + startsAt: "2026-07-01T12:00:00.000Z", + endsAt: "2026-07-01T16:00:00.000Z", + }), + ]); + expect(schedule.workerShortages).toEqual([ + expect.objectContaining({ + startTime: "11:00", + endTime: "12:00", + requiredCount: 2, + assignedCount: 0, + }), + expect.objectContaining({ + startTime: "12:00", + endTime: "16:00", + requiredCount: 2, + assignedCount: 1, + }), + ]); + }); + + it("creates next-day assignments for overnight staffing rules", async () => { + const { prisma } = createFakePrisma({ + activeSchedulePlanningPeriods: [ + createPlanningPeriod({ + startDate: "2026-07-03", + endDate: "2026-07-03", + }), + ], + availableTimes: [ + createAvailableTime({ + availableDate: "2026-07-03", + startsAt: "2026-07-03T22:00:00.000Z", + endsAt: "2026-07-04T02:00:00.000Z", + }), + ], + minimumStaffingRules: [ + createMinimumStaffingRule({ + dayOfWeek: "FRI", + startTime: "22:00", + endTime: "02:00", + endsNextDay: true, + }), + ], + organizations: [ + createOrganization(BigInt(1), BigInt(1), [ + createBusinessHour({ + closeTime: "02:00", + closesNextDay: true, + dayOfWeek: "FRI", + openTime: "22:00", + }), + ]), + ], + workers: [createWorker()], + }); + + const schedule = await recommendSchedule(prisma, "1", { + startDate: "2026-07-03", + endDate: "2026-07-03", + }); + + expect(schedule.assignments).toEqual([ + expect.objectContaining({ + workDate: "2026-07-03", + startsAt: "2026-07-03T22:00:00.000Z", + endsAt: "2026-07-04T02:00:00.000Z", + }), + ]); + expect(schedule.workerShortages).toEqual([]); + }); + + it("does not create a duplicate DRAFT for the same input conditions", async () => { + const { prisma } = createFakePrisma({ + activeSchedulePlanningPeriods: [createPlanningPeriod()], + availableTimes: [createAvailableTime()], + minimumStaffingRules: [createMinimumStaffingRule()], + organizations: [createOrganization()], + workers: [createWorker()], + }); + + await recommendSchedule(prisma, "1", { + startDate: "2026-07-01", + endDate: "2026-07-01", + }); + + await expect( + recommendSchedule(prisma, "1", { + startDate: "2026-07-01", + endDate: "2026-07-01", + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.DRAFT_ALREADY_EXISTS, + statusCode: 409, + }); + }); +}); + +describe("getDraftSchedule", () => { + it("returns the latest draft schedule for the same period", async () => { + const { prisma } = createFakePrisma({ + schedules: [createDraftSchedule({ id: BigInt(1) }), createDraftSchedule({ id: BigInt(2) })], + }); + + await expect( + getDraftSchedule(prisma, "1", { + startDate: "2026-07-01", + endDate: "2026-07-01", + }), + ).resolves.toMatchObject({ + schedule: { + id: "2", + }, + }); + }); +}); + +describe("schedule assignments", () => { + it("rejects workers from another organization when adding an assignment", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization(), createOrganization(BigInt(2), BigInt(2))], + schedules: [createDraftSchedule()], + workers: [createWorker({ organizationId: BigInt(2) })], + }); + + await expect( + createScheduleAssignment(prisma, "1", "1", { + workerId: "1", + workDate: "2026-07-01", + startsAt: "2026-07-01T10:00:00.000Z", + endsAt: "2026-07-01T14:00:00.000Z", + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.WORKER_NOT_FOUND, + statusCode: 404, + }); + }); + + it("allows updates only while the schedule is DRAFT", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + scheduleAssignments: [createAssignment()], + schedules: [createDraftSchedule({ status: "CONFIRMED" })], + workers: [createWorker()], + }); + + await expect( + updateScheduleAssignment(prisma, "1", "1", "10", { + startsAt: "2026-07-01T11:00:00.000Z", + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.CONFLICT, + statusCode: 409, + }); + }); + + it("rejects assignment updates that would make the end time earlier than the start time", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + scheduleAssignments: [createAssignment()], + schedules: [createDraftSchedule()], + workers: [createWorker()], + }); + + await expect( + updateScheduleAssignment(prisma, "1", "1", "10", { + startsAt: "2026-07-01T15:00:00.000Z", + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.VALIDATION_ERROR, + statusCode: 400, + }); + }); +}); + +describe("confirmSchedule", () => { + it("changes a DRAFT schedule to CONFIRMED", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + scheduleAssignments: [createAssignment()], + schedules: [createDraftSchedule()], + workers: [createWorker()], + }); + + const schedule = await confirmSchedule(prisma, "1", "1"); + + expect(schedule.status).toBe("CONFIRMED"); + expect(schedule.confirmedAt).toEqual(expect.any(String)); + expect(schedule.assignments).toHaveLength(1); + }); +}); diff --git a/apps/api/src/modules/schedules/schedules.service.ts b/apps/api/src/modules/schedules/schedules.service.ts new file mode 100644 index 0000000..932e4f2 --- /dev/null +++ b/apps/api/src/modules/schedules/schedules.service.ts @@ -0,0 +1,460 @@ +import type { Prisma, PrismaClient, Schedule } from "@fragment/database"; +import type { + DraftScheduleQuery, + DraftScheduleResponse, + RecommendScheduleRequest, + ScheduleAssignment, + ScheduleAssignmentInput, + ScheduleDetail, + UpdateScheduleAssignmentRequest, +} from "@fragment/shared"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { pruneOrganizationAvailabilityForActivePlanningPeriod } from "@/modules/availability/availability.service"; +import { dateStringToDate, dateTimeStringToDate } from "@/utils/date-time"; +import { toPrismaId } from "@/utils/mapper"; +import { scheduleDetailInclude, toScheduleAssignment, toScheduleDetail } from "./schedules.mapper"; +import { createInputHash, createRecommendations } from "./schedules.recommendation"; + +type SchedulesDatabaseClient = PrismaClient | Prisma.TransactionClient; + +const createScheduleNotFoundError = () => + new HttpError(404, ERROR_CODES.SCHEDULE_NOT_FOUND, "스케줄을 찾을 수 없습니다."); + +const createDraftRequiredError = () => + new HttpError(409, ERROR_CODES.CONFLICT, "DRAFT 상태의 스케줄만 변경할 수 있습니다."); + +const createValidationError = (message = "요청 형식이 올바르지 않습니다.") => + new HttpError(400, ERROR_CODES.VALIDATION_ERROR, message); + +const assertDateRangeInSchedule = ( + schedule: Pick, + date: Date, +) => { + if (date < schedule.startDate || date > schedule.endDate) { + throw createValidationError("근무 날짜가 스케줄 기간을 벗어났습니다."); + } +}; + +const findDraftSchedule = async ( + client: SchedulesDatabaseClient, + organizationId: bigint, + scheduleId: bigint, +) => { + const schedule = await client.schedule.findFirst({ + where: { + id: scheduleId, + organizationId, + }, + select: { + id: true, + status: true, + startDate: true, + endDate: true, + }, + }); + + if (!schedule) { + throw createScheduleNotFoundError(); + } + + if (schedule.status !== "DRAFT") { + throw createDraftRequiredError(); + } + + return schedule; +}; + +const findWorkerByOrganization = async ( + client: SchedulesDatabaseClient, + organizationId: bigint, + workerId: bigint, +) => { + const worker = await client.worker.findFirst({ + where: { + id: workerId, + organizationId, + }, + select: { + id: true, + employeeCode: true, + name: true, + }, + }); + + if (!worker) { + throw new HttpError(404, ERROR_CODES.WORKER_NOT_FOUND, "근무자를 찾을 수 없습니다."); + } + + return worker; +}; + +export async function recommendSchedule( + prisma: PrismaClient, + organizationId: string, + input: RecommendScheduleRequest, +): Promise { + const organizationDatabaseId = toPrismaId(organizationId); + const startDate = dateStringToDate(input.startDate); + const endDate = dateStringToDate(input.endDate); + const activePeriod = await prisma.activeSchedulePlanningPeriod.findUnique({ + where: { + organizationId: organizationDatabaseId, + }, + }); + + if (!activePeriod) { + throw new HttpError( + 400, + ERROR_CODES.NO_ACTIVE_PLANNING_PERIOD, + "활성 스케줄 계획 기간이 없습니다.", + ); + } + + if ( + activePeriod.startDate.getTime() !== startDate.getTime() || + activePeriod.endDate.getTime() !== endDate.getTime() + ) { + throw createValidationError("활성 스케줄 계획 기간과 요청 기간이 일치해야 합니다."); + } + + const [organization, workers, rules] = await Promise.all([ + prisma.organization.findUnique({ + where: { + id: organizationDatabaseId, + }, + include: { + businessHours: true, + }, + }), + prisma.worker.findMany({ + where: { + organizationId: organizationDatabaseId, + }, + orderBy: { + employeeCode: "asc", + }, + }), + prisma.minimumStaffingRule.findMany({ + where: { + organizationId: organizationDatabaseId, + }, + orderBy: [{ dayOfWeek: "asc" }, { startTime: "asc" }], + }), + ]); + + const hasOpenBusinessHours = + organization?.businessHours.some((businessHour) => !businessHour.isClosed) ?? false; + + if (!organization || workers.length === 0 || !hasOpenBusinessHours || rules.length === 0) { + throw createValidationError( + "스케줄 추천에 필요한 근무자, 영업시간 또는 최소 인원 조건이 부족합니다.", + ); + } + + await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organizationDatabaseId); + + const availableTimes = await prisma.workerAvailableTime.findMany({ + where: { + worker: { + organizationId: organizationDatabaseId, + }, + availableDate: { + gte: startDate, + lte: endDate, + }, + }, + orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }], + }); + + const inputHash = createInputHash({ + availableTimes, + businessHours: organization.businessHours, + endDate, + rules, + startDate, + workers, + }); + const { assignments, candidates, shortages } = createRecommendations({ + availableTimes, + businessHours: organization.businessHours, + endDate, + rules, + startDate, + workers, + }); + + const schedule = await prisma.$transaction(async (transaction) => { + const existingDraft = await transaction.schedule.findFirst({ + where: { + organizationId: organizationDatabaseId, + inputHash, + status: "DRAFT", + startDate, + endDate, + }, + select: { + id: true, + }, + }); + + if (existingDraft) { + throw new HttpError(409, ERROR_CODES.DRAFT_ALREADY_EXISTS, "이미 생성된 DRAFT 스케줄입니다."); + } + + return transaction.schedule.create({ + data: { + organization: { + connect: { + id: organizationDatabaseId, + }, + }, + status: "DRAFT", + inputHash, + startDate, + endDate, + assignments: { + create: assignments.map((assignment) => ({ + worker: { + connect: { + id: assignment.workerId, + }, + }, + workDate: assignment.workDate, + startsAt: assignment.startsAt, + endsAt: assignment.endsAt, + workerNameSnapshot: assignment.workerNameSnapshot, + employeeCodeSnapshot: assignment.employeeCodeSnapshot, + })), + }, + candidates: { + create: candidates.map((candidate) => ({ + worker: { + connect: { + id: candidate.workerId, + }, + }, + workDate: candidate.workDate, + startsAt: candidate.startsAt, + endsAt: candidate.endsAt, + workerNameSnapshot: candidate.workerNameSnapshot, + employeeCodeSnapshot: candidate.employeeCodeSnapshot, + })), + }, + workerShortages: { + create: shortages, + }, + }, + include: scheduleDetailInclude, + }); + }); + + return toScheduleDetail(schedule); +} + +export async function getDraftSchedule( + prisma: PrismaClient, + organizationId: string, + query: DraftScheduleQuery, +): Promise { + const schedule = await prisma.schedule.findFirst({ + where: { + organizationId: toPrismaId(organizationId), + status: "DRAFT", + startDate: dateStringToDate(query.startDate), + endDate: dateStringToDate(query.endDate), + }, + orderBy: { + id: "desc", + }, + include: scheduleDetailInclude, + }); + + return { + schedule: schedule ? toScheduleDetail(schedule) : null, + }; +} + +export async function createScheduleAssignment( + prisma: PrismaClient, + organizationId: string, + scheduleId: string, + input: ScheduleAssignmentInput, +): Promise { + const organizationDatabaseId = toPrismaId(organizationId); + const scheduleDatabaseId = toPrismaId(scheduleId); + + return prisma.$transaction(async (transaction) => { + const schedule = await findDraftSchedule( + transaction, + organizationDatabaseId, + scheduleDatabaseId, + ); + const workDate = dateStringToDate(input.workDate); + + assertDateRangeInSchedule(schedule, workDate); + + const worker = await findWorkerByOrganization( + transaction, + organizationDatabaseId, + toPrismaId(input.workerId), + ); + const assignment = await transaction.scheduleAssignment.create({ + data: { + scheduleId: schedule.id, + workerId: worker.id, + workDate, + startsAt: dateTimeStringToDate(input.startsAt), + endsAt: dateTimeStringToDate(input.endsAt), + workerNameSnapshot: worker.name, + employeeCodeSnapshot: worker.employeeCode, + }, + }); + + return toScheduleAssignment(assignment); + }); +} + +export async function updateScheduleAssignment( + prisma: PrismaClient, + organizationId: string, + scheduleId: string, + assignmentId: string, + input: UpdateScheduleAssignmentRequest, +): Promise { + const organizationDatabaseId = toPrismaId(organizationId); + const scheduleDatabaseId = toPrismaId(scheduleId); + const assignmentDatabaseId = toPrismaId(assignmentId); + + return prisma.$transaction(async (transaction) => { + const schedule = await findDraftSchedule( + transaction, + organizationDatabaseId, + scheduleDatabaseId, + ); + const existingAssignment = await transaction.scheduleAssignment.findFirst({ + where: { + id: assignmentDatabaseId, + scheduleId: schedule.id, + }, + }); + + if (!existingAssignment) { + throw createScheduleNotFoundError(); + } + + const workDate = + input.workDate !== undefined ? dateStringToDate(input.workDate) : existingAssignment.workDate; + const startsAt = + input.startsAt !== undefined + ? dateTimeStringToDate(input.startsAt) + : existingAssignment.startsAt; + const endsAt = + input.endsAt !== undefined ? dateTimeStringToDate(input.endsAt) : existingAssignment.endsAt; + + if (startsAt >= endsAt) { + throw createValidationError("종료 시각은 시작 시각보다 늦어야 합니다."); + } + + assertDateRangeInSchedule(schedule, workDate); + + const worker = + input.workerId !== undefined + ? await findWorkerByOrganization( + transaction, + organizationDatabaseId, + toPrismaId(input.workerId), + ) + : null; + const updatedAssignment = await transaction.scheduleAssignment.update({ + where: { + id: existingAssignment.id, + }, + data: { + ...(input.workerId !== undefined && worker + ? { + workerId: worker.id, + workerNameSnapshot: worker.name, + employeeCodeSnapshot: worker.employeeCode, + } + : {}), + ...(input.workDate !== undefined ? { workDate } : {}), + ...(input.startsAt !== undefined ? { startsAt } : {}), + ...(input.endsAt !== undefined ? { endsAt } : {}), + }, + }); + + return toScheduleAssignment(updatedAssignment); + }); +} + +export async function deleteScheduleAssignment( + prisma: PrismaClient, + organizationId: string, + scheduleId: string, + assignmentId: string, +): Promise { + const organizationDatabaseId = toPrismaId(organizationId); + const scheduleDatabaseId = toPrismaId(scheduleId); + const assignmentDatabaseId = toPrismaId(assignmentId); + + await prisma.$transaction(async (transaction) => { + const schedule = await findDraftSchedule( + transaction, + organizationDatabaseId, + scheduleDatabaseId, + ); + const result = await transaction.scheduleAssignment.deleteMany({ + where: { + id: assignmentDatabaseId, + scheduleId: schedule.id, + }, + }); + + if (result.count === 0) { + throw createScheduleNotFoundError(); + } + }); +} + +export async function confirmSchedule( + prisma: PrismaClient, + organizationId: string, + scheduleId: string, +): Promise { + const organizationDatabaseId = toPrismaId(organizationId); + const scheduleDatabaseId = toPrismaId(scheduleId); + + const schedule = await prisma.schedule.findFirst({ + where: { + id: scheduleDatabaseId, + organizationId: organizationDatabaseId, + }, + select: { + id: true, + status: true, + }, + }); + + if (!schedule) { + throw createScheduleNotFoundError(); + } + + if (schedule.status !== "DRAFT") { + throw createDraftRequiredError(); + } + + const updatedSchedule = await prisma.schedule.update({ + where: { + id: schedule.id, + }, + data: { + status: "CONFIRMED", + confirmedAt: new Date(), + }, + include: scheduleDetailInclude, + }); + + return toScheduleDetail(updatedSchedule); +} diff --git a/apps/api/src/modules/scheduling-time-policy.ts b/apps/api/src/modules/scheduling-time-policy.ts new file mode 100644 index 0000000..0b29057 --- /dev/null +++ b/apps/api/src/modules/scheduling-time-policy.ts @@ -0,0 +1,211 @@ +import type { + MinimumStaffingRule, + OrganizationBusinessHour, + WorkerAvailableTime, +} from "@fragment/database"; + +import { addDays, dateToTimeString, parseTimeToMinutes, timeStringToDate } from "@/utils/date-time"; +import { getDayOfWeek } from "@/utils/day-of-week"; + +type TimeRange = { + startMinutes: number; + endMinutes: number; +}; + +export type SchedulableRange = TimeRange & { + requiredCount: number; +}; + +export type PrunedAvailabilityTime = { + workerId: bigint; + availableDate: Date; + startsAt: Date; + endsAt: Date; +}; + +const MINUTES_IN_DAY = 24 * 60; + +const timeDateToMinutes = (time: Date) => parseTimeToMinutes(dateToTimeString(time)); + +export const createDateTimeFromMinutes = (date: Date, minutes: number) => { + const dayOffset = Math.floor(minutes / MINUTES_IN_DAY); + const minutesInDay = minutes % MINUTES_IN_DAY; + const hour = Math.floor(minutesInDay / 60); + const minute = minutesInDay % 60; + const targetDate = addDays(date, dayOffset); + + return new Date( + Date.UTC( + targetDate.getUTCFullYear(), + targetDate.getUTCMonth(), + targetDate.getUTCDate(), + hour, + minute, + 0, + ), + ); +}; + +export const minutesToTimeDate = (minutes: number) => { + const minutesInDay = minutes % MINUTES_IN_DAY; + const hour = Math.floor(minutesInDay / 60); + const minute = minutesInDay % 60; + + return timeStringToDate(`${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`); +}; + +const getBusinessHourRange = ( + businessHours: OrganizationBusinessHour[], + workDate: Date, +): TimeRange | null => { + const dayOfWeek = getDayOfWeek(workDate); + const businessHour = businessHours.find((item) => item.dayOfWeek === dayOfWeek); + + if (!businessHour || businessHour.isClosed || !businessHour.openTime || !businessHour.closeTime) { + return null; + } + + const startMinutes = timeDateToMinutes(businessHour.openTime); + const rawEndMinutes = timeDateToMinutes(businessHour.closeTime); + + return { + endMinutes: businessHour.closesNextDay ? rawEndMinutes + MINUTES_IN_DAY : rawEndMinutes, + startMinutes, + }; +}; + +const getRuleRange = (rule: MinimumStaffingRule): SchedulableRange => { + const startMinutes = timeDateToMinutes(rule.startTime); + const rawEndMinutes = timeDateToMinutes(rule.endTime); + + return { + endMinutes: rule.endsNextDay ? rawEndMinutes + MINUTES_IN_DAY : rawEndMinutes, + requiredCount: rule.requiredCount, + startMinutes, + }; +}; + +const mergeRanges = (ranges: TimeRange[]): TimeRange[] => { + const sortedRanges = [...ranges] + .filter((range) => range.startMinutes < range.endMinutes) + .sort((first, second) => first.startMinutes - second.startMinutes); + const mergedRanges: TimeRange[] = []; + + for (const range of sortedRanges) { + const previous = mergedRanges.at(-1); + + if (previous && range.startMinutes <= previous.endMinutes) { + previous.endMinutes = Math.max(previous.endMinutes, range.endMinutes); + continue; + } + + mergedRanges.push({ ...range }); + } + + return mergedRanges; +}; + +const dateTimeToMinutesFromWorkDate = (workDate: Date, dateTime: Date) => + Math.round((dateTime.getTime() - workDate.getTime()) / 60000); + +export const getSchedulableRangesForDate = ({ + businessHours, + rules, + workDate, +}: { + businessHours: OrganizationBusinessHour[]; + rules: MinimumStaffingRule[]; + workDate: Date; +}): SchedulableRange[] => { + const businessHourRange = getBusinessHourRange(businessHours, workDate); + + if (!businessHourRange) { + return []; + } + + return rules + .filter((rule) => rule.dayOfWeek === getDayOfWeek(workDate)) + .map(getRuleRange) + .map((ruleRange) => ({ + endMinutes: Math.min(ruleRange.endMinutes, businessHourRange.endMinutes), + requiredCount: ruleRange.requiredCount, + startMinutes: Math.max(ruleRange.startMinutes, businessHourRange.startMinutes), + })) + .filter((range) => range.startMinutes < range.endMinutes); +}; + +export const getMergedSchedulableRangesForDate = ({ + businessHours, + rules, + workDate, +}: { + businessHours: OrganizationBusinessHour[]; + rules: MinimumStaffingRule[]; + workDate: Date; +}): TimeRange[] => + mergeRanges( + getSchedulableRangesForDate({ + businessHours, + rules, + workDate, + }), + ); + +export const pruneAvailabilityTimeToSchedulableRanges = ({ + availableTime, + businessHours, + rules, +}: { + availableTime: Pick; + businessHours: OrganizationBusinessHour[]; + rules: MinimumStaffingRule[]; +}): PrunedAvailabilityTime[] => { + const ranges = getMergedSchedulableRangesForDate({ + businessHours, + rules, + workDate: availableTime.availableDate, + }); + const availabilityStartMinutes = dateTimeToMinutesFromWorkDate( + availableTime.availableDate, + availableTime.startsAt, + ); + const availabilityEndMinutes = dateTimeToMinutesFromWorkDate( + availableTime.availableDate, + availableTime.endsAt, + ); + + return ranges.flatMap((range) => { + const startMinutes = Math.max(availabilityStartMinutes, range.startMinutes); + const endMinutes = Math.min(availabilityEndMinutes, range.endMinutes); + + if (startMinutes >= endMinutes) { + return []; + } + + return [ + { + availableDate: availableTime.availableDate, + endsAt: createDateTimeFromMinutes(availableTime.availableDate, endMinutes), + startsAt: createDateTimeFromMinutes(availableTime.availableDate, startMinutes), + workerId: availableTime.workerId, + }, + ]; + }); +}; + +export const pruneAvailabilityTimesToSchedulableRanges = ({ + availableTimes, + businessHours, + rules, +}: { + availableTimes: Pick[]; + businessHours: OrganizationBusinessHour[]; + rules: MinimumStaffingRule[]; +}): PrunedAvailabilityTime[] => + availableTimes.flatMap((availableTime) => + pruneAvailabilityTimeToSchedulableRanges({ + availableTime, + businessHours, + rules, + }), + ); diff --git a/apps/api/src/modules/staffing-rules/staffing-rules.handlers.ts b/apps/api/src/modules/staffing-rules/staffing-rules.handlers.ts new file mode 100644 index 0000000..08b36c0 --- /dev/null +++ b/apps/api/src/modules/staffing-rules/staffing-rules.handlers.ts @@ -0,0 +1,71 @@ +import type { PrismaClient } from "@fragment/database"; +import type { Request, RequestHandler } from "express"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { + createMinimumStaffingRule, + deleteMinimumStaffingRule, + getMinimumStaffingRules, + updateMinimumStaffingRule, +} from "./staffing-rules.service"; + +const getAuthenticatedUserId = (req: Request) => { + if (!req.user) { + throw new HttpError(401, ERROR_CODES.UNAUTHORIZED, "인증이 필요합니다."); + } + + return req.user.id; +}; + +export const getMinimumStaffingRulesHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await getMinimumStaffingRules(prisma, getAuthenticatedUserId(req)); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const createMinimumStaffingRuleHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await createMinimumStaffingRule(prisma, getAuthenticatedUserId(req), req.body); + + res.status(201).json(result); + } catch (error) { + next(error); + } +}; + +export const updateMinimumStaffingRuleHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const { ruleId } = req.params as { ruleId: string }; + const result = await updateMinimumStaffingRule( + prisma, + getAuthenticatedUserId(req), + ruleId, + req.body, + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const deleteMinimumStaffingRuleHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const { ruleId } = req.params as { ruleId: string }; + + await deleteMinimumStaffingRule(prisma, getAuthenticatedUserId(req), ruleId); + + res.status(204).send(); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/modules/staffing-rules/staffing-rules.service.spec.ts b/apps/api/src/modules/staffing-rules/staffing-rules.service.spec.ts new file mode 100644 index 0000000..fefbf12 --- /dev/null +++ b/apps/api/src/modules/staffing-rules/staffing-rules.service.spec.ts @@ -0,0 +1,398 @@ +import type { DayOfWeek, OrganizationBusinessHour, PrismaClient } from "@fragment/database"; +import { describe, expect, it, jest } from "@jest/globals"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { + createMinimumStaffingRule, + deleteMinimumStaffingRule, + getMinimumStaffingRules, + updateMinimumStaffingRule, +} from "./staffing-rules.service"; + +const timeOfDay = (hour: number, minute: number) => new Date(Date.UTC(1970, 0, 1, hour, minute)); +const timestamp = new Date("2026-06-25T00:00:00.000Z"); + +const createBusinessHour = ({ + closeTime = timeOfDay(18, 0), + closesNextDay = false, + dayOfWeek = "MON", + id = BigInt(1), + isClosed = false, + openTime = timeOfDay(9, 0), + organizationId = BigInt(1), +}: Partial = {}): OrganizationBusinessHour => ({ + id, + organizationId, + dayOfWeek, + isClosed, + openTime, + closeTime, + closesNextDay, + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createRuleRow = ({ + id = BigInt(10), + organizationId = BigInt(1), + dayOfWeek = "MON" as DayOfWeek, + startTime = timeOfDay(10, 0), + endTime = timeOfDay(14, 0), + endsNextDay = false, + requiredCount = 2, +} = {}) => ({ + id, + organizationId, + dayOfWeek, + startTime, + endTime, + endsNextDay, + requiredCount, + createdAt: timestamp, + updatedAt: timestamp, +}); + +type OrganizationRow = { + businessHours: OrganizationBusinessHour[]; + id: bigint; +}; + +const createPrismaMock = ({ + organization = { businessHours: [createBusinessHour()], id: BigInt(1) }, + minimumStaffingRule = {}, +}: { + organization?: OrganizationRow | null; + minimumStaffingRule?: { + create?: jest.Mock; + deleteMany?: jest.Mock; + findFirst?: jest.Mock; + findMany?: jest.Mock; + update?: jest.Mock; + }; +} = {}) => { + const organizationFindUnique = jest + .fn<() => Promise>() + .mockResolvedValue(organization); + + return { + prisma: { + activeSchedulePlanningPeriod: { + findUnique: jest.fn<() => Promise>().mockResolvedValue(null), + }, + organization: { + findUnique: organizationFindUnique, + }, + minimumStaffingRule: { + create: minimumStaffingRule.create ?? jest.fn(), + deleteMany: minimumStaffingRule.deleteMany ?? jest.fn(), + findFirst: minimumStaffingRule.findFirst ?? jest.fn(), + findMany: minimumStaffingRule.findMany ?? jest.fn(), + update: minimumStaffingRule.update ?? jest.fn(), + }, + } as unknown as PrismaClient, + organizationFindUnique, + }; +}; + +describe("getMinimumStaffingRules", () => { + it("rejects users without an organization", async () => { + const minimumStaffingRuleFindMany = jest.fn(); + const { prisma } = createPrismaMock({ + organization: null, + minimumStaffingRule: { + findMany: minimumStaffingRuleFindMany, + }, + }); + + await expect(getMinimumStaffingRules(prisma, "1")).rejects.toMatchObject({ + code: ERROR_CODES.ORGANIZATION_REQUIRED, + statusCode: 403, + }); + expect(minimumStaffingRuleFindMany).not.toHaveBeenCalled(); + }); + + it("returns rules scoped to the user's organization with HH:mm time values", async () => { + const minimumStaffingRuleFindMany = jest + .fn<() => Promise[]>>() + .mockResolvedValue([ + createRuleRow({ + id: BigInt(10), + startTime: timeOfDay(9, 30), + endTime: timeOfDay(14, 0), + }), + ]); + const { prisma } = createPrismaMock({ + minimumStaffingRule: { + findMany: minimumStaffingRuleFindMany, + }, + }); + + await expect(getMinimumStaffingRules(prisma, "1")).resolves.toEqual({ + items: [ + { + id: "10", + dayOfWeek: "MON", + startTime: "09:30", + endTime: "14:00", + endsNextDay: false, + requiredCount: 2, + }, + ], + }); + expect(minimumStaffingRuleFindMany).toHaveBeenCalledWith({ + where: { + organizationId: BigInt(1), + }, + orderBy: [{ dayOfWeek: "asc" }, { startTime: "asc" }], + }); + }); +}); + +describe("createMinimumStaffingRule", () => { + it("creates a rule for the user's organization with DB time values", async () => { + const minimumStaffingRuleCreate = jest + .fn<() => Promise>>() + .mockResolvedValue( + createRuleRow({ + startTime: timeOfDay(22, 0), + endTime: timeOfDay(2, 0), + endsNextDay: true, + requiredCount: 3, + }), + ); + const { prisma } = createPrismaMock({ + organization: { + id: BigInt(1), + businessHours: [ + createBusinessHour({ + closeTime: timeOfDay(2, 0), + closesNextDay: true, + dayOfWeek: "FRI", + openTime: timeOfDay(22, 0), + }), + ], + }, + minimumStaffingRule: { + create: minimumStaffingRuleCreate, + }, + }); + + await expect( + createMinimumStaffingRule(prisma, "1", { + dayOfWeek: "FRI", + startTime: "22:00", + endTime: "02:00", + endsNextDay: true, + requiredCount: 3, + }), + ).resolves.toMatchObject({ + startTime: "22:00", + endTime: "02:00", + endsNextDay: true, + requiredCount: 3, + }); + expect(minimumStaffingRuleCreate).toHaveBeenCalledWith({ + data: { + organizationId: BigInt(1), + dayOfWeek: "FRI", + startTime: timeOfDay(22, 0), + endTime: timeOfDay(2, 0), + endsNextDay: true, + requiredCount: 3, + }, + }); + }); + + it("rejects rules on closed business days", async () => { + const minimumStaffingRuleCreate = jest.fn(); + const { prisma } = createPrismaMock({ + organization: { + id: BigInt(1), + businessHours: [ + createBusinessHour({ + closeTime: null, + dayOfWeek: "MON", + isClosed: true, + openTime: null, + }), + ], + }, + minimumStaffingRule: { + create: minimumStaffingRuleCreate, + }, + }); + + await expect( + createMinimumStaffingRule(prisma, "1", { + dayOfWeek: "MON", + startTime: "10:00", + endTime: "14:00", + endsNextDay: false, + requiredCount: 2, + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.CLOSED_DAY, + statusCode: 400, + }); + expect(minimumStaffingRuleCreate).not.toHaveBeenCalled(); + }); + + it("rejects rules outside business hours", async () => { + const minimumStaffingRuleCreate = jest.fn(); + const { prisma } = createPrismaMock({ + minimumStaffingRule: { + create: minimumStaffingRuleCreate, + }, + }); + + await expect( + createMinimumStaffingRule(prisma, "1", { + dayOfWeek: "MON", + startTime: "08:00", + endTime: "10:00", + endsNextDay: false, + requiredCount: 2, + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_TIME_RANGE, + statusCode: 400, + }); + expect(minimumStaffingRuleCreate).not.toHaveBeenCalled(); + }); +}); + +describe("updateMinimumStaffingRule", () => { + it("rejects rules outside the user's organization", async () => { + const minimumStaffingRuleFindFirst = jest.fn<() => Promise>().mockResolvedValue(null); + const minimumStaffingRuleUpdate = jest.fn(); + const { prisma } = createPrismaMock({ + minimumStaffingRule: { + findFirst: minimumStaffingRuleFindFirst, + update: minimumStaffingRuleUpdate, + }, + }); + + await expect( + updateMinimumStaffingRule(prisma, "1", "10", { + requiredCount: 3, + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.NOT_FOUND, + statusCode: 404, + }); + expect(minimumStaffingRuleFindFirst).toHaveBeenCalledWith({ + where: { + id: BigInt(10), + organizationId: BigInt(1), + }, + select: { + dayOfWeek: true, + endTime: true, + endsNextDay: true, + id: true, + startTime: true, + }, + }); + expect(minimumStaffingRuleUpdate).not.toHaveBeenCalled(); + }); + + it("rejects endsNextDay-only updates when the merged time range is invalid", async () => { + const minimumStaffingRuleFindFirst = jest + .fn< + () => Promise<{ + dayOfWeek: DayOfWeek; + endTime: Date; + endsNextDay: boolean; + id: bigint; + startTime: Date; + }> + >() + .mockResolvedValue({ + dayOfWeek: "MON", + id: BigInt(10), + startTime: timeOfDay(10, 0), + endTime: timeOfDay(14, 0), + endsNextDay: false, + }); + const minimumStaffingRuleUpdate = jest.fn(); + const { prisma } = createPrismaMock({ + minimumStaffingRule: { + findFirst: minimumStaffingRuleFindFirst, + update: minimumStaffingRuleUpdate, + }, + }); + + await expect( + updateMinimumStaffingRule(prisma, "1", "10", { + endsNextDay: true, + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.VALIDATION_ERROR, + statusCode: 400, + }); + expect(minimumStaffingRuleUpdate).not.toHaveBeenCalled(); + }); + + it("rejects updates that move a rule outside business hours", async () => { + const minimumStaffingRuleFindFirst = jest + .fn< + () => Promise<{ + dayOfWeek: DayOfWeek; + endTime: Date; + endsNextDay: boolean; + id: bigint; + startTime: Date; + }> + >() + .mockResolvedValue({ + dayOfWeek: "MON", + id: BigInt(10), + startTime: timeOfDay(10, 0), + endTime: timeOfDay(14, 0), + endsNextDay: false, + }); + const minimumStaffingRuleUpdate = jest.fn(); + const { prisma } = createPrismaMock({ + minimumStaffingRule: { + findFirst: minimumStaffingRuleFindFirst, + update: minimumStaffingRuleUpdate, + }, + }); + + await expect( + updateMinimumStaffingRule(prisma, "1", "10", { + startTime: "08:00", + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_TIME_RANGE, + statusCode: 400, + }); + expect(minimumStaffingRuleUpdate).not.toHaveBeenCalled(); + }); +}); + +describe("deleteMinimumStaffingRule", () => { + it("rejects rules outside the user's organization", async () => { + const minimumStaffingRuleDeleteMany = jest + .fn<() => Promise<{ count: number }>>() + .mockResolvedValue({ + count: 0, + }); + const { prisma } = createPrismaMock({ + minimumStaffingRule: { + deleteMany: minimumStaffingRuleDeleteMany, + }, + }); + + await expect(deleteMinimumStaffingRule(prisma, "1", "10")).rejects.toMatchObject({ + code: ERROR_CODES.NOT_FOUND, + statusCode: 404, + }); + expect(minimumStaffingRuleDeleteMany).toHaveBeenCalledWith({ + where: { + id: BigInt(10), + organizationId: BigInt(1), + }, + }); + }); +}); diff --git a/apps/api/src/modules/staffing-rules/staffing-rules.service.ts b/apps/api/src/modules/staffing-rules/staffing-rules.service.ts new file mode 100644 index 0000000..e681542 --- /dev/null +++ b/apps/api/src/modules/staffing-rules/staffing-rules.service.ts @@ -0,0 +1,259 @@ +import type { + MinimumStaffingRule as PrismaMinimumStaffingRule, + OrganizationBusinessHour, + Prisma, + PrismaClient, +} from "@fragment/database"; +import type { + CreateMinimumStaffingRuleRequest, + MinimumStaffingRule, + MinimumStaffingRulesListResponse, + UpdateMinimumStaffingRuleRequest, +} from "@fragment/shared"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { pruneOrganizationAvailabilityForActivePlanningPeriod } from "@/modules/availability/availability.service"; +import { dateToTimeString, parseTimeToMinutes, timeStringToDate } from "@/utils/date-time"; +import { toApiId, toPrismaId } from "@/utils/mapper"; + +const MINUTES_IN_DAY = 24 * 60; + +type StaffingRulesOrganization = { + businessHours: OrganizationBusinessHour[]; + id: bigint; +}; + +const createMinimumStaffingRuleNotFoundError = () => + new HttpError(404, ERROR_CODES.NOT_FOUND, "최소 인원 조건을 찾을 수 없습니다."); + +const createMinimumStaffingRuleValidationError = () => + new HttpError(400, ERROR_CODES.VALIDATION_ERROR, "요청 형식이 올바르지 않습니다."); + +const createMinimumStaffingRuleClosedDayError = () => + new HttpError(400, ERROR_CODES.CLOSED_DAY, "휴무일에는 최소 인원 조건을 등록할 수 없습니다."); + +const createMinimumStaffingRuleOutsideBusinessHoursError = () => + new HttpError( + 400, + ERROR_CODES.INVALID_TIME_RANGE, + "최소 인원 조건은 조직 운영시간 안에서만 등록할 수 있습니다.", + ); + +const assertMinimumStaffingRuleTimeRange = ({ + endTime, + endsNextDay, + startTime, +}: Pick) => { + if (startTime === endTime) { + throw createMinimumStaffingRuleValidationError(); + } + + if (!endsNextDay && startTime > endTime) { + throw createMinimumStaffingRuleValidationError(); + } + + if (endsNextDay && startTime < endTime) { + throw createMinimumStaffingRuleValidationError(); + } +}; + +const dateToMinutes = (time: Date) => parseTimeToMinutes(dateToTimeString(time)); + +const getRangeEndMinutes = (endTime: string, endsNextDay: boolean) => { + const rawEndMinutes = parseTimeToMinutes(endTime); + + return endsNextDay ? rawEndMinutes + MINUTES_IN_DAY : rawEndMinutes; +}; + +const assertMinimumStaffingRuleInsideBusinessHours = ({ + businessHours, + dayOfWeek, + endTime, + endsNextDay, + startTime, +}: Pick & { + businessHours: OrganizationBusinessHour[]; +}) => { + const businessHour = businessHours.find((item) => item.dayOfWeek === dayOfWeek); + + if (!businessHour || businessHour.isClosed || !businessHour.openTime || !businessHour.closeTime) { + throw createMinimumStaffingRuleClosedDayError(); + } + + const businessStartMinutes = dateToMinutes(businessHour.openTime); + const rawBusinessEndMinutes = dateToMinutes(businessHour.closeTime); + const businessEndMinutes = businessHour.closesNextDay + ? rawBusinessEndMinutes + MINUTES_IN_DAY + : rawBusinessEndMinutes; + const ruleStartMinutes = parseTimeToMinutes(startTime); + const ruleEndMinutes = getRangeEndMinutes(endTime, endsNextDay); + + if (ruleStartMinutes < businessStartMinutes || ruleEndMinutes > businessEndMinutes) { + throw createMinimumStaffingRuleOutsideBusinessHoursError(); + } +}; + +const findOrganizationByUserId = async ( + prisma: PrismaClient, + userId: string, +): Promise => { + const organization = await prisma.organization.findUnique({ + where: { + userId: toPrismaId(userId), + }, + select: { + businessHours: true, + id: true, + }, + }); + + if (!organization) { + throw new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다."); + } + + return organization; +}; + +export const toMinimumStaffingRule = (rule: PrismaMinimumStaffingRule): MinimumStaffingRule => ({ + id: toApiId(rule.id), + dayOfWeek: rule.dayOfWeek, + startTime: dateToTimeString(rule.startTime), + endTime: dateToTimeString(rule.endTime), + endsNextDay: rule.endsNextDay, + requiredCount: rule.requiredCount, +}); + +const createMinimumStaffingRuleData = ( + organizationId: bigint, + input: CreateMinimumStaffingRuleRequest, +): Prisma.MinimumStaffingRuleUncheckedCreateInput => ({ + organizationId, + dayOfWeek: input.dayOfWeek, + startTime: timeStringToDate(input.startTime), + endTime: timeStringToDate(input.endTime), + endsNextDay: input.endsNextDay, + requiredCount: input.requiredCount, +}); + +const createMinimumStaffingRuleUpdateData = ( + input: UpdateMinimumStaffingRuleRequest, +): Prisma.MinimumStaffingRuleUncheckedUpdateInput => ({ + ...(input.dayOfWeek !== undefined ? { dayOfWeek: input.dayOfWeek } : {}), + ...(input.startTime !== undefined ? { startTime: timeStringToDate(input.startTime) } : {}), + ...(input.endTime !== undefined ? { endTime: timeStringToDate(input.endTime) } : {}), + ...(input.endsNextDay !== undefined ? { endsNextDay: input.endsNextDay } : {}), + ...(input.requiredCount !== undefined ? { requiredCount: input.requiredCount } : {}), +}); + +export async function getMinimumStaffingRules( + prisma: PrismaClient, + userId: string, +): Promise { + const organization = await findOrganizationByUserId(prisma, userId); + + const rules = await prisma.minimumStaffingRule.findMany({ + where: { + organizationId: organization.id, + }, + orderBy: [{ dayOfWeek: "asc" }, { startTime: "asc" }], + }); + + return { + items: rules.map(toMinimumStaffingRule), + }; +} + +export async function createMinimumStaffingRule( + prisma: PrismaClient, + userId: string, + input: CreateMinimumStaffingRuleRequest, +): Promise { + const organization = await findOrganizationByUserId(prisma, userId); + + assertMinimumStaffingRuleTimeRange(input); + assertMinimumStaffingRuleInsideBusinessHours({ + businessHours: organization.businessHours, + ...input, + }); + + const rule = await prisma.minimumStaffingRule.create({ + data: createMinimumStaffingRuleData(organization.id, input), + }); + + await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organization.id); + + return toMinimumStaffingRule(rule); +} + +export async function updateMinimumStaffingRule( + prisma: PrismaClient, + userId: string, + ruleId: string, + input: UpdateMinimumStaffingRuleRequest, +): Promise { + const organization = await findOrganizationByUserId(prisma, userId); + const ruleDatabaseId = toPrismaId(ruleId); + + const existingRule = await prisma.minimumStaffingRule.findFirst({ + where: { + id: ruleDatabaseId, + organizationId: organization.id, + }, + select: { + dayOfWeek: true, + endTime: true, + endsNextDay: true, + id: true, + startTime: true, + }, + }); + + if (!existingRule) { + throw createMinimumStaffingRuleNotFoundError(); + } + + const mergedRule = { + dayOfWeek: input.dayOfWeek ?? existingRule.dayOfWeek, + startTime: input.startTime ?? dateToTimeString(existingRule.startTime), + endTime: input.endTime ?? dateToTimeString(existingRule.endTime), + endsNextDay: input.endsNextDay ?? existingRule.endsNextDay, + }; + + assertMinimumStaffingRuleTimeRange(mergedRule); + assertMinimumStaffingRuleInsideBusinessHours({ + businessHours: organization.businessHours, + ...mergedRule, + }); + + const updatedRule = await prisma.minimumStaffingRule.update({ + where: { + id: existingRule.id, + }, + data: createMinimumStaffingRuleUpdateData(input), + }); + + await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organization.id); + + return toMinimumStaffingRule(updatedRule); +} + +export async function deleteMinimumStaffingRule( + prisma: PrismaClient, + userId: string, + ruleId: string, +): Promise { + const organization = await findOrganizationByUserId(prisma, userId); + const result = await prisma.minimumStaffingRule.deleteMany({ + where: { + id: toPrismaId(ruleId), + organizationId: organization.id, + }, + }); + + if (result.count === 0) { + throw createMinimumStaffingRuleNotFoundError(); + } + + await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organization.id); +} diff --git a/apps/api/src/modules/workers/workers.handlers.ts b/apps/api/src/modules/workers/workers.handlers.ts new file mode 100644 index 0000000..3c2d3c2 --- /dev/null +++ b/apps/api/src/modules/workers/workers.handlers.ts @@ -0,0 +1,74 @@ +import type { PrismaClient } from "@fragment/database"; +import type { Request, RequestHandler } from "express"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { createWorker, deleteWorker, listWorkers, updateWorker } from "./workers.service"; + +const getRequestOrganizationId = (req: Request) => { + // requireOrganization 이후 실행되지만, 타입 좁히기와 라우터 누락 방지를 위해 확인합니다. + if (!req.organization) { + throw new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다."); + } + + return req.organization.id; +}; + +const getWorkerIdParam = (req: Request) => { + const workerId = req.params.workerId; + + if (typeof workerId !== "string") { + throw new HttpError(400, ERROR_CODES.VALIDATION_ERROR, "요청 형식이 올바르지 않습니다."); + } + + return workerId; +}; + +export const listWorkersHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await listWorkers(prisma, getRequestOrganizationId(req)); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const createWorkerHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await createWorker(prisma, getRequestOrganizationId(req), req.body); + + res.status(201).json(result); + } catch (error) { + next(error); + } +}; + +export const updateWorkerHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await updateWorker( + prisma, + getRequestOrganizationId(req), + getWorkerIdParam(req), + req.body, + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const deleteWorkerHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + await deleteWorker(prisma, getRequestOrganizationId(req), getWorkerIdParam(req)); + + res.status(204).send(); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/modules/workers/workers.service.spec.ts b/apps/api/src/modules/workers/workers.service.spec.ts new file mode 100644 index 0000000..0ff8503 --- /dev/null +++ b/apps/api/src/modules/workers/workers.service.spec.ts @@ -0,0 +1,275 @@ +import type { PrismaClient } from "@fragment/database"; +import { describe, expect, it, jest } from "@jest/globals"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { createWorker, deleteWorker, listWorkers, updateWorker } from "./workers.service"; + +const createWorkerRow = ({ + id = BigInt(1), + organizationId = BigInt(1), + employeeCode = "W-0001", + name = "김민수", + weeklyContractHours = 40, +} = {}) => ({ + id, + organizationId, + employeeCode, + name, + weeklyContractHours, +}); + +type WorkerModelMocks = { + create?: jest.Mock; + delete?: jest.Mock; + findFirst?: jest.Mock; + findMany?: jest.Mock; + update?: jest.Mock; +}; + +type OrganizationModelMocks = { + update?: jest.Mock; +}; + +const createPrismaMock = ({ + organization = {}, + worker = {}, +}: { + organization?: OrganizationModelMocks; + worker?: WorkerModelMocks; +} = {}) => { + const prisma = { + $transaction: jest.fn(async (callback: (transaction: unknown) => unknown) => callback(prisma)), + organization: { + update: organization.update ?? jest.fn(), + }, + worker: { + create: worker.create ?? jest.fn(), + delete: worker.delete ?? jest.fn(), + findFirst: worker.findFirst ?? jest.fn(), + findMany: worker.findMany ?? jest.fn(), + update: worker.update ?? jest.fn(), + }, + }; + + return prisma as unknown as PrismaClient; +}; + +describe("listWorkers", () => { + it("lists workers scoped to the organization ordered by employee code", async () => { + const workerFindMany = jest.fn<() => Promise[]>>(); + workerFindMany.mockResolvedValue([ + createWorkerRow({ + id: BigInt(2), + employeeCode: "W-0001", + name: "김민지", + weeklyContractHours: 20, + }), + createWorkerRow({ + id: BigInt(1), + employeeCode: "W-0002", + name: "박준호", + weeklyContractHours: 24, + }), + ]); + const prisma = createPrismaMock({ + worker: { + findMany: workerFindMany, + }, + }); + + await expect(listWorkers(prisma, "1")).resolves.toEqual({ + items: [ + { + id: "2", + employeeCode: "W-0001", + name: "김민지", + weeklyContractHours: 20, + }, + { + id: "1", + employeeCode: "W-0002", + name: "박준호", + weeklyContractHours: 24, + }, + ], + }); + expect(workerFindMany).toHaveBeenCalledWith({ + where: { + organizationId: BigInt(1), + }, + orderBy: { + employeeCode: "asc", + }, + }); + }); +}); + +describe("createWorker", () => { + it("increments the organization sequence and creates the next employee code", async () => { + const organizationUpdate = jest + .fn<() => Promise<{ lastWorkerSequence: number }>>() + .mockResolvedValue({ + lastWorkerSequence: 13, + }); + const workerCreate = jest + .fn<() => Promise>>() + .mockResolvedValue( + createWorkerRow({ + employeeCode: "W-0013", + }), + ); + const prisma = createPrismaMock({ + organization: { + update: organizationUpdate, + }, + worker: { + create: workerCreate, + }, + }); + + await expect( + createWorker(prisma, "1", { + name: "김민수", + weeklyContractHours: 40, + }), + ).resolves.toEqual({ + id: "1", + employeeCode: "W-0013", + name: "김민수", + weeklyContractHours: 40, + }); + expect(organizationUpdate).toHaveBeenCalledWith({ + where: { + id: BigInt(1), + }, + data: { + lastWorkerSequence: { + increment: 1, + }, + }, + select: { + lastWorkerSequence: true, + }, + }); + expect(workerCreate).toHaveBeenCalledWith({ + data: { + organizationId: BigInt(1), + employeeCode: "W-0013", + name: "김민수", + weeklyContractHours: 40, + }, + }); + }); +}); + +describe("updateWorker", () => { + it("rejects workers outside the organization", async () => { + const workerFindFirst = jest.fn<() => Promise>().mockResolvedValue(null); + const workerUpdate = jest.fn(); + const prisma = createPrismaMock({ + worker: { + findFirst: workerFindFirst, + update: workerUpdate, + }, + }); + + await expect( + updateWorker(prisma, "1", "10", { + name: "김민준", + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.WORKER_NOT_FOUND, + statusCode: 404, + }); + expect(workerFindFirst).toHaveBeenCalledWith({ + where: { + id: BigInt(10), + organizationId: BigInt(1), + }, + }); + expect(workerUpdate).not.toHaveBeenCalled(); + }); + + it("updates only provided worker fields", async () => { + const workerFindFirst = jest + .fn<() => Promise>>() + .mockResolvedValue(createWorkerRow()); + const workerUpdate = jest + .fn<() => Promise>>() + .mockResolvedValue( + createWorkerRow({ + name: "김민준", + }), + ); + const prisma = createPrismaMock({ + worker: { + findFirst: workerFindFirst, + update: workerUpdate, + }, + }); + + await expect( + updateWorker(prisma, "1", "1", { + name: "김민준", + }), + ).resolves.toMatchObject({ + id: "1", + name: "김민준", + weeklyContractHours: 40, + }); + expect(workerUpdate).toHaveBeenCalledWith({ + where: { + id: BigInt(1), + }, + data: { + name: "김민준", + }, + }); + }); +}); + +describe("deleteWorker", () => { + it("rejects workers outside the organization", async () => { + const workerFindFirst = jest.fn<() => Promise>().mockResolvedValue(null); + const workerDelete = jest.fn(); + const prisma = createPrismaMock({ + worker: { + delete: workerDelete, + findFirst: workerFindFirst, + }, + }); + + await expect(deleteWorker(prisma, "1", "10")).rejects.toMatchObject({ + code: ERROR_CODES.WORKER_NOT_FOUND, + statusCode: 404, + }); + expect(workerFindFirst).toHaveBeenCalledWith({ + where: { + id: BigInt(10), + organizationId: BigInt(1), + }, + }); + expect(workerDelete).not.toHaveBeenCalled(); + }); + + it("deletes a worker after checking organization scope", async () => { + const workerFindFirst = jest + .fn<() => Promise>>() + .mockResolvedValue(createWorkerRow()); + const workerDelete = jest.fn<() => Promise>>(); + workerDelete.mockResolvedValue(createWorkerRow()); + const prisma = createPrismaMock({ + worker: { + delete: workerDelete, + findFirst: workerFindFirst, + }, + }); + + await expect(deleteWorker(prisma, "1", "1")).resolves.toBeUndefined(); + expect(workerDelete).toHaveBeenCalledWith({ + where: { + id: BigInt(1), + }, + }); + }); +}); diff --git a/apps/api/src/modules/workers/workers.service.ts b/apps/api/src/modules/workers/workers.service.ts new file mode 100644 index 0000000..6f872af --- /dev/null +++ b/apps/api/src/modules/workers/workers.service.ts @@ -0,0 +1,154 @@ +import { Prisma, type PrismaClient } from "@fragment/database"; +import type { + CreateWorkerRequest, + UpdateWorkerRequest, + Worker, + WorkersListResponse, +} from "@fragment/shared"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { toApiId, toPrismaId } from "@/utils/mapper"; + +type WorkersDatabaseClient = PrismaClient | Prisma.TransactionClient; + +type WorkerRecord = Prisma.WorkerGetPayload; + +const formatEmployeeCode = (sequence: number) => `W-${String(sequence).padStart(4, "0")}`; + +const toWorker = (worker: WorkerRecord): Worker => ({ + id: toApiId(worker.id), + employeeCode: worker.employeeCode, + name: worker.name, + weeklyContractHours: worker.weeklyContractHours, +}); + +async function findWorkerByOrganization( + client: WorkersDatabaseClient, + organizationId: bigint, + workerId: bigint, +) { + return client.worker.findFirst({ + where: { + id: workerId, + organizationId, + }, + }); +} + +export async function listWorkers( + prisma: PrismaClient, + organizationId: string, +): Promise { + const organizationDatabaseId = toPrismaId(organizationId); + const workers = await prisma.worker.findMany({ + where: { + organizationId: organizationDatabaseId, + }, + orderBy: { + employeeCode: "asc", + }, + }); + + return { + items: workers.map(toWorker), + }; +} + +export async function createWorker( + prisma: PrismaClient, + organizationId: string, + input: CreateWorkerRequest, +): Promise { + const organizationDatabaseId = toPrismaId(organizationId); + + return prisma.$transaction(async (transaction) => { + const organization = await transaction.organization.update({ + where: { + id: organizationDatabaseId, + }, + data: { + lastWorkerSequence: { + increment: 1, + }, + }, + select: { + lastWorkerSequence: true, + }, + }); + + const worker = await transaction.worker.create({ + data: { + organizationId: organizationDatabaseId, + employeeCode: formatEmployeeCode(organization.lastWorkerSequence), + name: input.name, + weeklyContractHours: input.weeklyContractHours, + }, + }); + + return toWorker(worker); + }); +} + +export async function updateWorker( + prisma: PrismaClient, + organizationId: string, + workerId: string, + input: UpdateWorkerRequest, +): Promise { + const organizationDatabaseId = toPrismaId(organizationId); + const workerDatabaseId = toPrismaId(workerId); + + return prisma.$transaction(async (transaction) => { + const worker = await findWorkerByOrganization( + transaction, + organizationDatabaseId, + workerDatabaseId, + ); + + if (!worker) { + throw new HttpError(404, ERROR_CODES.WORKER_NOT_FOUND, "근무자를 찾을 수 없습니다."); + } + + const updatedWorker = await transaction.worker.update({ + where: { + id: worker.id, + }, + data: { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.weeklyContractHours !== undefined + ? { weeklyContractHours: input.weeklyContractHours } + : {}), + }, + }); + + return toWorker(updatedWorker); + }); +} + +export async function deleteWorker( + prisma: PrismaClient, + organizationId: string, + workerId: string, +): Promise { + const organizationDatabaseId = toPrismaId(organizationId); + const workerDatabaseId = toPrismaId(workerId); + + await prisma.$transaction(async (transaction) => { + const worker = await findWorkerByOrganization( + transaction, + organizationDatabaseId, + workerDatabaseId, + ); + + if (!worker) { + throw new HttpError(404, ERROR_CODES.WORKER_NOT_FOUND, "근무자를 찾을 수 없습니다."); + } + + await transaction.worker.delete({ + where: { + id: worker.id, + }, + }); + }); +} diff --git a/apps/api/src/routes/auth.routes.ts b/apps/api/src/routes/auth.routes.ts new file mode 100644 index 0000000..1643a70 --- /dev/null +++ b/apps/api/src/routes/auth.routes.ts @@ -0,0 +1,43 @@ +import { Router } from "express"; +import { authLoginRequestSchema, authSignupRequestSchema } from "@fragment/shared"; +import { z } from "zod"; + +import { createRequireAuth } from "@/middlewares/require-auth"; +import { createRequireSameOrigin } from "@/middlewares/require-same-origin"; +import { validate } from "@/middlewares/validate"; +import { + loginHandler, + logoutHandler, + meHandler, + refreshHandler, + signupHandler, +} from "@/modules/auth/auth.handlers"; +import { verifyAccessToken } from "@/modules/auth/auth.tokens"; + +export const authRoutes = Router(); +const requireAuth = createRequireAuth({ verifyAccessToken }); +const requireSameOrigin = createRequireSameOrigin(process.env.WEB_APP_ORIGIN); + +authRoutes.post( + "/signup", + validate( + z.object({ + body: authSignupRequestSchema, + }), + ), + signupHandler, +); + +authRoutes.post( + "/login", + validate( + z.object({ + body: authLoginRequestSchema, + }), + ), + loginHandler, +); + +authRoutes.post("/logout", requireSameOrigin, logoutHandler); +authRoutes.post("/refresh", requireSameOrigin, refreshHandler); +authRoutes.get("/me", requireAuth, meHandler); diff --git a/apps/api/src/routes/availability.routes.ts b/apps/api/src/routes/availability.routes.ts new file mode 100644 index 0000000..e8930a9 --- /dev/null +++ b/apps/api/src/routes/availability.routes.ts @@ -0,0 +1,36 @@ +import { availabilityQuerySchema, replaceAvailabilityRequestSchema } from "@fragment/shared"; +import { Router } from "express"; +import { z } from "zod"; + +import { createRequireAuth } from "@/middlewares/require-auth"; +import { validate } from "@/middlewares/validate"; +import { verifyAccessToken } from "@/modules/auth/auth.tokens"; +import { + listAvailabilityHandler, + replaceAvailabilityHandler, +} from "@/modules/availability/availability.handlers"; + +export const availabilityRoutes = Router(); +const requireAuth = createRequireAuth({ verifyAccessToken }); + +availabilityRoutes.get( + "/", + requireAuth, + validate( + z.object({ + query: availabilityQuerySchema, + }), + ), + listAvailabilityHandler, +); + +availabilityRoutes.put( + "/bulk", + requireAuth, + validate( + z.object({ + body: replaceAvailabilityRequestSchema, + }), + ), + replaceAvailabilityHandler, +); diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts new file mode 100644 index 0000000..3d64588 --- /dev/null +++ b/apps/api/src/routes/index.ts @@ -0,0 +1,17 @@ +import { Router } from "express"; + +import { availabilityRoutes } from "@/routes/availability.routes"; +import { authRoutes } from "@/routes/auth.routes"; +import { organizationRoutes } from "@/routes/organization.routes"; +import { schedulesRoutes } from "@/routes/schedules.routes"; +import { staffingRulesRoutes } from "@/routes/staffing-rules.routes"; +import { workersRoutes } from "@/routes/workers.routes"; + +export const apiRoutes = Router(); + +apiRoutes.use("/availability", availabilityRoutes); +apiRoutes.use("/auth", authRoutes); +apiRoutes.use("/organization", organizationRoutes); +apiRoutes.use("/schedules", schedulesRoutes); +apiRoutes.use("/staffing-rules", staffingRulesRoutes); +apiRoutes.use("/workers", workersRoutes); diff --git a/apps/api/src/routes/organization.routes.ts b/apps/api/src/routes/organization.routes.ts new file mode 100644 index 0000000..5865299 --- /dev/null +++ b/apps/api/src/routes/organization.routes.ts @@ -0,0 +1,71 @@ +import { Router } from "express"; +import { + createOrganizationRequestSchema, + updateOrganizationRequestSchema, + upsertActiveSchedulePlanningPeriodRequestSchema, +} from "@fragment/shared"; +import { z } from "zod"; + +import { createRequireAuth } from "@/middlewares/require-auth"; +import { validate } from "@/middlewares/validate"; +import { verifyAccessToken } from "@/modules/auth/auth.tokens"; +import { + createOrganizationHandler, + getOrganizationHandler, + updateOrganizationHandler, +} from "@/modules/organization/organization.handlers"; +import { + deleteActiveSchedulePlanningPeriodHandler, + getActiveSchedulePlanningPeriodHandler, + upsertActiveSchedulePlanningPeriodHandler, +} from "@/modules/organization/planning-period.handlers"; + +export const organizationRoutes = Router(); +const requireAuth = createRequireAuth({ verifyAccessToken }); + +organizationRoutes.post( + "/", + requireAuth, + validate( + z.object({ + body: createOrganizationRequestSchema, + }), + ), + createOrganizationHandler, +); + +organizationRoutes.get("/", requireAuth, getOrganizationHandler); + +organizationRoutes.get( + "/active-schedule-planning-period", + requireAuth, + getActiveSchedulePlanningPeriodHandler, +); + +organizationRoutes.put( + "/active-schedule-planning-period", + requireAuth, + validate( + z.object({ + body: upsertActiveSchedulePlanningPeriodRequestSchema, + }), + ), + upsertActiveSchedulePlanningPeriodHandler, +); + +organizationRoutes.delete( + "/active-schedule-planning-period", + requireAuth, + deleteActiveSchedulePlanningPeriodHandler, +); + +organizationRoutes.patch( + "/", + requireAuth, + validate( + z.object({ + body: updateOrganizationRequestSchema, + }), + ), + updateOrganizationHandler, +); diff --git a/apps/api/src/routes/schedules.routes.ts b/apps/api/src/routes/schedules.routes.ts new file mode 100644 index 0000000..481b5d0 --- /dev/null +++ b/apps/api/src/routes/schedules.routes.ts @@ -0,0 +1,105 @@ +import { + draftScheduleQuerySchema, + idSchema, + recommendScheduleRequestSchema, + scheduleAssignmentInputSchema, + updateScheduleAssignmentRequestSchema, +} from "@fragment/shared"; +import type { PrismaClient } from "@fragment/database"; +import { Router } from "express"; +import { z } from "zod"; + +import { createRequireAuth } from "@/middlewares/require-auth"; +import { createRequireOrganization } from "@/middlewares/require-organization"; +import { validate } from "@/middlewares/validate"; +import { verifyAccessToken } from "@/modules/auth/auth.tokens"; +import { + confirmScheduleHandler, + createScheduleAssignmentHandler, + deleteScheduleAssignmentHandler, + getDraftScheduleHandler, + recommendScheduleHandler, + updateScheduleAssignmentHandler, +} from "@/modules/schedules/schedules.handlers"; + +const scheduleIdParamsSchema = z.object({ + scheduleId: idSchema, +}); + +const assignmentParamsSchema = scheduleIdParamsSchema.extend({ + assignmentId: idSchema, +}); + +export const schedulesRoutes = Router(); +const requireAuth = createRequireAuth({ verifyAccessToken }); + +schedulesRoutes.use(requireAuth); +schedulesRoutes.use((req, res, next) => { + const requireOrganization = createRequireOrganization({ + prisma: req.app.locals.prisma as PrismaClient, + }); + + return requireOrganization(req, res, next); +}); + +schedulesRoutes.post( + "/recommend", + validate( + z.object({ + body: recommendScheduleRequestSchema, + }), + ), + recommendScheduleHandler, +); + +schedulesRoutes.get( + "/draft", + validate( + z.object({ + query: draftScheduleQuerySchema, + }), + ), + getDraftScheduleHandler, +); + +schedulesRoutes.post( + "/:scheduleId/assignments", + validate( + z.object({ + params: scheduleIdParamsSchema, + body: scheduleAssignmentInputSchema, + }), + ), + createScheduleAssignmentHandler, +); + +schedulesRoutes.patch( + "/:scheduleId/assignments/:assignmentId", + validate( + z.object({ + params: assignmentParamsSchema, + body: updateScheduleAssignmentRequestSchema, + }), + ), + updateScheduleAssignmentHandler, +); + +schedulesRoutes.delete( + "/:scheduleId/assignments/:assignmentId", + validate( + z.object({ + params: assignmentParamsSchema, + }), + ), + deleteScheduleAssignmentHandler, +); + +schedulesRoutes.post( + "/:scheduleId/confirm", + validate( + z.object({ + params: scheduleIdParamsSchema, + }), + ), + confirmScheduleHandler, +); diff --git a/apps/api/src/routes/staffing-rules.routes.ts b/apps/api/src/routes/staffing-rules.routes.ts new file mode 100644 index 0000000..f181173 --- /dev/null +++ b/apps/api/src/routes/staffing-rules.routes.ts @@ -0,0 +1,60 @@ +import { + createMinimumStaffingRuleRequestSchema, + idSchema, + updateMinimumStaffingRuleRequestSchema, +} from "@fragment/shared"; +import { Router } from "express"; +import { z } from "zod"; + +import { createRequireAuth } from "@/middlewares/require-auth"; +import { validate } from "@/middlewares/validate"; +import { verifyAccessToken } from "@/modules/auth/auth.tokens"; +import { + createMinimumStaffingRuleHandler, + deleteMinimumStaffingRuleHandler, + getMinimumStaffingRulesHandler, + updateMinimumStaffingRuleHandler, +} from "@/modules/staffing-rules/staffing-rules.handlers"; + +const ruleIdParamsSchema = z.object({ + ruleId: idSchema, +}); + +export const staffingRulesRoutes = Router(); +const requireAuth = createRequireAuth({ verifyAccessToken }); + +staffingRulesRoutes.get("/", requireAuth, getMinimumStaffingRulesHandler); + +staffingRulesRoutes.post( + "/", + requireAuth, + validate( + z.object({ + body: createMinimumStaffingRuleRequestSchema, + }), + ), + createMinimumStaffingRuleHandler, +); + +staffingRulesRoutes.patch( + "/:ruleId", + requireAuth, + validate( + z.object({ + params: ruleIdParamsSchema, + body: updateMinimumStaffingRuleRequestSchema, + }), + ), + updateMinimumStaffingRuleHandler, +); + +staffingRulesRoutes.delete( + "/:ruleId", + requireAuth, + validate( + z.object({ + params: ruleIdParamsSchema, + }), + ), + deleteMinimumStaffingRuleHandler, +); diff --git a/apps/api/src/routes/workers.routes.ts b/apps/api/src/routes/workers.routes.ts new file mode 100644 index 0000000..8d83deb --- /dev/null +++ b/apps/api/src/routes/workers.routes.ts @@ -0,0 +1,64 @@ +import { + createWorkerRequestSchema, + updateWorkerRequestSchema, + workerIdParamsSchema, +} from "@fragment/shared"; +import { Router } from "express"; +import { z } from "zod"; + +import { createRequireAuth } from "@/middlewares/require-auth"; +import { createRequireOrganization } from "@/middlewares/require-organization"; +import { validate } from "@/middlewares/validate"; +import { verifyAccessToken } from "@/modules/auth/auth.tokens"; +import { + createWorkerHandler, + deleteWorkerHandler, + listWorkersHandler, + updateWorkerHandler, +} from "@/modules/workers/workers.handlers"; +import type { PrismaClient } from "@fragment/database"; + +export const workersRoutes = Router(); +const requireAuth = createRequireAuth({ verifyAccessToken }); + +workersRoutes.use(requireAuth); +workersRoutes.use((req, res, next) => { + const requireOrganization = createRequireOrganization({ + prisma: req.app.locals.prisma as PrismaClient, + }); + + return requireOrganization(req, res, next); +}); + +workersRoutes.get("/", listWorkersHandler); + +workersRoutes.post( + "/", + validate( + z.object({ + body: createWorkerRequestSchema, + }), + ), + createWorkerHandler, +); + +workersRoutes.patch( + "/:workerId", + validate( + z.object({ + params: workerIdParamsSchema, + body: updateWorkerRequestSchema, + }), + ), + updateWorkerHandler, +); + +workersRoutes.delete( + "/:workerId", + validate( + z.object({ + params: workerIdParamsSchema, + }), + ), + deleteWorkerHandler, +); diff --git a/apps/api/src/types/express.ts b/apps/api/src/types/express.ts new file mode 100644 index 0000000..c2f7d56 --- /dev/null +++ b/apps/api/src/types/express.ts @@ -0,0 +1,18 @@ +import type { ApiId } from "@/utils/mapper"; + +export type AuthenticatedUser = { + id: ApiId; +}; + +export type RequestOrganization = { + id: ApiId; +}; + +declare module "express-serve-static-core" { + interface Request { + user?: AuthenticatedUser; + organization?: RequestOrganization; + } +} + +export {}; diff --git a/apps/api/src/utils/date-range.ts b/apps/api/src/utils/date-range.ts new file mode 100644 index 0000000..b5f66b7 --- /dev/null +++ b/apps/api/src/utils/date-range.ts @@ -0,0 +1,11 @@ +import { addDays } from "@/utils/date-time"; + +export const eachDateInRange = (startDate: Date, endDate: Date): Date[] => { + const dates: Date[] = []; + + for (let date = startDate; date <= endDate; date = addDays(date, 1)) { + dates.push(date); + } + + return dates; +}; diff --git a/apps/api/src/utils/date-time.ts b/apps/api/src/utils/date-time.ts new file mode 100644 index 0000000..73ffa33 --- /dev/null +++ b/apps/api/src/utils/date-time.ts @@ -0,0 +1,80 @@ +const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/; + +export const isValidTimeString = (value: unknown): value is string => + typeof value === "string" && TIME_PATTERN.test(value); + +export const dateStringToDate = (date: string): Date => new Date(`${date}T00:00:00.000Z`); + +export const dateTimeStringToDate = (dateTime: string): Date => new Date(dateTime); + +export const dateToDateString = (date: Date): string => date.toISOString().slice(0, 10); + +export const timeStringToDate = (time: string): Date => { + const [hour, minute] = time.split(":").map(Number); + return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); +}; + +export const dateToTimeString = (time: Date): string => { + const hour = String(time.getUTCHours()).padStart(2, "0"); + const minute = String(time.getUTCMinutes()).padStart(2, "0"); + return `${hour}:${minute}`; +}; + +export const nullableTimeStringToDate = (time: string | null): Date | null => + time === null ? null : timeStringToDate(time); + +export const nullableDateToTimeString = (time: Date | null): string | null => + time === null ? null : dateToTimeString(time); + +export const parseTimeToMinutes = (time: string): number => { + const match = time.match(TIME_PATTERN); + + if (!match) { + throw new RangeError("Time must be in HH:mm format."); + } + + const [, hour, minute] = match; + + return Number(hour) * 60 + Number(minute); +}; + +export const isDateTimeRangeOrdered = (startsAt: Date, endsAt: Date): boolean => + startsAt.getTime() < endsAt.getTime(); + +export const addDays = (date: Date, days: number): Date => + new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days)); + +export const createDateTimeOnDate = (date: Date, time: Date): Date => + new Date( + Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + time.getUTCHours(), + time.getUTCMinutes(), + 0, + ), + ); + +export const assertDateTimeRange = (startsAt: Date, endsAt: Date): void => { + if (!isDateTimeRangeOrdered(startsAt, endsAt)) { + throw new RangeError("Start date-time must be before end date-time."); + } +}; + +export const getTimeRangeDurationMinutes = (startTime: string, endTime: string): number => { + const startMinutes = parseTimeToMinutes(startTime); + const endMinutes = parseTimeToMinutes(endTime); + + if (startMinutes === endMinutes) { + throw new RangeError("시작 시간과 종료 시간은 같을 수 없습니다."); + } + + return endMinutes > startMinutes + ? endMinutes - startMinutes + : endMinutes + 24 * 60 - startMinutes; +}; + +export const assertTimeRange = (startTime: string, endTime: string): void => { + getTimeRangeDurationMinutes(startTime, endTime); +}; diff --git a/apps/api/src/utils/day-of-week.ts b/apps/api/src/utils/day-of-week.ts new file mode 100644 index 0000000..1314767 --- /dev/null +++ b/apps/api/src/utils/day-of-week.ts @@ -0,0 +1,23 @@ +import { DAY_OF_WEEK_VALUES } from "@fragment/shared"; + +export type ApiDayOfWeek = (typeof DAY_OF_WEEK_VALUES)[number]; + +const DAY_OF_WEEK_SET = new Set(DAY_OF_WEEK_VALUES); + +export const isDayOfWeek = (value: unknown): value is ApiDayOfWeek => + typeof value === "string" && DAY_OF_WEEK_SET.has(value); + +export const parseDayOfWeek = (value: unknown): ApiDayOfWeek => { + if (!isDayOfWeek(value)) { + throw new RangeError("Day of week must be one of MON, TUE, WED, THU, FRI, SAT, SUN."); + } + + return value; +}; + +export const getDayOfWeek = (date: Date): ApiDayOfWeek => { + const sundayFirstDay = date.getUTCDay(); + const mondayFirstDay = sundayFirstDay === 0 ? 6 : sundayFirstDay - 1; + + return DAY_OF_WEEK_VALUES[mondayFirstDay]; +}; diff --git a/apps/api/src/utils/mapper.spec.ts b/apps/api/src/utils/mapper.spec.ts new file mode 100644 index 0000000..e6669a4 --- /dev/null +++ b/apps/api/src/utils/mapper.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "@jest/globals"; + +import { toApiId, toNullableApiId, toPrismaId } from "@/utils/mapper"; + +describe("mapper", () => { + it("converts supported database IDs to API string IDs", () => { + expect(toApiId(BigInt(1))).toBe("1"); + expect(toApiId(2)).toBe("2"); + expect(toApiId("003")).toBe("3"); + }); + + it("rejects invalid database IDs", () => { + expect(() => toApiId(-1)).toThrow(RangeError); + expect(() => toApiId(Number.MAX_SAFE_INTEGER + 1)).toThrow(RangeError); + expect(() => toApiId("abc")).toThrow(RangeError); + }); + + it("maps nullable IDs and Prisma IDs", () => { + expect(toNullableApiId(null)).toBeNull(); + expect(toNullableApiId(undefined)).toBeNull(); + expect(toPrismaId("42")).toBe(BigInt(42)); + }); +}); diff --git a/apps/api/src/utils/mapper.ts b/apps/api/src/utils/mapper.ts new file mode 100644 index 0000000..0866f7a --- /dev/null +++ b/apps/api/src/utils/mapper.ts @@ -0,0 +1,31 @@ +export type ApiId = string; +export type DatabaseId = string | number | bigint; + +export const toApiId = (id: DatabaseId): ApiId => { + if (typeof id === "bigint") { + if (id < BigInt(0)) { + throw new RangeError("ID는 0 이상의 정수여야 합니다."); + } + + return id.toString(); + } + + if (typeof id === "number") { + if (!Number.isSafeInteger(id) || id < 0) { + throw new RangeError("ID는 0 이상의 안전한 정수여야 합니다."); + } + + return BigInt(id).toString(); + } + + if (!/^\d+$/.test(id)) { + throw new RangeError("ID는 숫자 문자열이어야 합니다."); + } + + return BigInt(id).toString(); +}; + +export const toNullableApiId = (id: DatabaseId | null | undefined): ApiId | null => + id == null ? null : toApiId(id); + +export const toPrismaId = (id: DatabaseId): bigint => BigInt(toApiId(id)); diff --git a/apps/api/test/auth.routes.test.ts b/apps/api/test/auth.routes.test.ts new file mode 100644 index 0000000..5cdf0dd --- /dev/null +++ b/apps/api/test/auth.routes.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it, beforeEach } from "@jest/globals"; +import { hash } from "bcryptjs"; +import request from "supertest"; + +import { createApp } from "@/app"; +import { createAccessToken } from "@/modules/auth/auth.tokens"; +import { createFakePrisma } from "./helpers/fake-prisma"; + +describe("auth routes", () => { + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = "test-access-secret"; + delete process.env.JWT_ACCESS_EXPIRES_IN; + delete process.env.REFRESH_TOKEN_EXPIRES_DAYS; + delete process.env.WEB_APP_ORIGIN; + }); + + it("signs up a user, returns an access token, and sets a refresh cookie", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).post("/api/auth/signup").send({ + name: "홍길동", + email: "owner@example.com", + password: "password123", + passwordConfirm: "password123", + }); + + expect(response.status).toBe(201); + expect(response.body).toEqual({ + accessToken: expect.any(String), + hasOrganization: false, + user: { + id: "1", + email: "owner@example.com", + name: "홍길동", + }, + }); + expect(response.headers["set-cookie"]?.[0]).toContain("refreshToken="); + expect(response.headers["set-cookie"]?.[0]).toContain("HttpOnly"); + }); + + it("returns a validation error for invalid signup input", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).post("/api/auth/signup").send({ + name: "", + email: "invalid-email", + password: "short", + passwordConfirm: "different", + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + + it("rejects duplicate signup emails", async () => { + const { prisma } = createFakePrisma({ + users: [ + { + id: BigInt(1), + email: "owner@example.com", + name: "기존 사용자", + passwordHash: "stored-hash", + }, + ], + }); + const app = createApp({ prisma }); + + const response = await request(app).post("/api/auth/signup").send({ + name: "홍길동", + email: "owner@example.com", + password: "password123", + passwordConfirm: "password123", + }); + + expect(response.status).toBe(409); + expect(response.body).toMatchObject({ + errorCode: "DUPLICATE_EMAIL", + statusCode: 409, + }); + }); + + it("logs in and reports whether the user has an organization", async () => { + const passwordHash = await hash("password123", 1); + const { prisma } = createFakePrisma({ + organizations: [ + { + id: BigInt(10), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours: [], + }, + ], + users: [ + { + id: BigInt(1), + email: "owner@example.com", + name: "홍길동", + passwordHash, + }, + ], + }); + const app = createApp({ prisma }); + + const response = await request(app).post("/api/auth/login").send({ + email: "owner@example.com", + password: "password123", + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + accessToken: expect.any(String), + hasOrganization: true, + user: { + id: "1", + email: "owner@example.com", + name: "홍길동", + }, + }); + }); + + it("rejects invalid login credentials", async () => { + const passwordHash = await hash("password123", 1); + const { prisma } = createFakePrisma({ + users: [ + { + id: BigInt(1), + email: "owner@example.com", + name: "홍길동", + passwordHash, + }, + ], + }); + const app = createApp({ prisma }); + + const response = await request(app).post("/api/auth/login").send({ + email: "owner@example.com", + password: "wrong-password", + }); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + errorCode: "UNAUTHORIZED", + statusCode: 401, + }); + }); + + it("refreshes a session with the refresh cookie", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + const signupResponse = await request(app).post("/api/auth/signup").send({ + name: "홍길동", + email: "owner@example.com", + password: "password123", + passwordConfirm: "password123", + }); + + const response = await request(app) + .post("/api/auth/refresh") + .set("Cookie", signupResponse.headers["set-cookie"]); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + accessToken: expect.any(String), + hasOrganization: false, + user: { + id: "1", + email: "owner@example.com", + }, + }); + expect(response.headers["set-cookie"]?.[0]).toContain("refreshToken="); + }); + + it("rejects reusing a consumed refresh cookie", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + const signupResponse = await request(app).post("/api/auth/signup").send({ + name: "홍길동", + email: "owner@example.com", + password: "password123", + passwordConfirm: "password123", + }); + const initialRefreshCookie = signupResponse.headers["set-cookie"]; + + const firstRefreshResponse = await request(app) + .post("/api/auth/refresh") + .set("Cookie", initialRefreshCookie); + const secondRefreshResponse = await request(app) + .post("/api/auth/refresh") + .set("Cookie", initialRefreshCookie); + + expect(firstRefreshResponse.status).toBe(200); + expect(secondRefreshResponse.status).toBe(401); + expect(secondRefreshResponse.body).toMatchObject({ + errorCode: "UNAUTHORIZED", + statusCode: 401, + }); + }); + + it("logs out with the refresh cookie and clears it", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + const signupResponse = await request(app).post("/api/auth/signup").send({ + name: "홍길동", + email: "owner@example.com", + password: "password123", + passwordConfirm: "password123", + }); + + const response = await request(app) + .post("/api/auth/logout") + .set("Cookie", signupResponse.headers["set-cookie"]); + + expect(response.status).toBe(204); + expect(response.headers["set-cookie"]?.[0]).toContain("refreshToken="); + expect(response.headers["set-cookie"]?.[0]).toContain("Expires=Thu, 01 Jan 1970"); + }); + + it("returns the authenticated user and organization", async () => { + const { prisma } = createFakePrisma({ + organizations: [ + { + id: BigInt(10), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours: [], + }, + ], + users: [ + { + id: BigInt(1), + email: "owner@example.com", + name: "홍길동", + passwordHash: "stored-hash", + }, + ], + }); + const app = createApp({ prisma }); + const accessToken = createAccessToken(BigInt(1)); + + const response = await request(app) + .get("/api/auth/me") + .set("Authorization", `Bearer ${accessToken}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + organization: { + id: "10", + name: "프래그먼트 카페", + }, + user: { + id: "1", + email: "owner@example.com", + name: "홍길동", + }, + }); + }); + + it("requires a bearer token for the current user route", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/auth/me"); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + errorCode: "UNAUTHORIZED", + statusCode: 401, + }); + }); +}); diff --git a/apps/api/test/availability.routes.test.ts b/apps/api/test/availability.routes.test.ts new file mode 100644 index 0000000..82af400 --- /dev/null +++ b/apps/api/test/availability.routes.test.ts @@ -0,0 +1,326 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import request from "supertest"; + +import { createApp } from "@/app"; +import { createAccessToken } from "@/modules/auth/auth.tokens"; +import { createFakePrisma, createTimeDate } from "./helpers/fake-prisma"; + +const now = new Date("2026-06-25T00:00:00.000Z"); + +const createOrganization = ( + businessHours = [ + { + id: BigInt(1), + organizationId: BigInt(1), + dayOfWeek: "WED" as const, + isClosed: false, + openTime: createTimeDate("09:00"), + closeTime: createTimeDate("18:00"), + closesNextDay: false, + }, + ], +) => ({ + id: BigInt(1), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours, +}); + +const createWorker = () => ({ + id: BigInt(1), + organizationId: BigInt(1), + employeeCode: "W-0001", + name: "김민수", + weeklyContractHours: 40, +}); + +const createMinimumStaffingRule = ({ + dayOfWeek = "WED" as const, + endTime = "18:00", + endsNextDay = false, + startTime = "09:00", +} = {}) => ({ + id: BigInt(1), + organizationId: BigInt(1), + dayOfWeek, + startTime: createTimeDate(startTime), + endTime: createTimeDate(endTime), + endsNextDay, + requiredCount: 1, + createdAt: now, + updatedAt: now, +}); + +describe("availability routes", () => { + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = "test-access-secret"; + delete process.env.JWT_ACCESS_EXPIRES_IN; + delete process.env.WEB_APP_ORIGIN; + }); + + const authHeader = () => `Bearer ${createAccessToken(BigInt(1))}`; + + it("requires authentication", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/availability").query({ + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + }); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + errorCode: "UNAUTHORIZED", + statusCode: 401, + }); + }); + + it("rejects invalid query", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + workers: [createWorker()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/availability") + .set("Authorization", authHeader()) + .query({ + workerId: "1", + startDate: "2026-08-01", + endDate: "2026-07-31", + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + + it("lists availability by worker and date range ordered by date and start time", async () => { + const { prisma } = createFakePrisma({ + availableTimes: [ + { + id: BigInt(3), + workerId: BigInt(1), + availableDate: new Date("2026-08-01T00:00:00.000Z"), + startsAt: new Date("2026-08-01T09:00:00.000Z"), + endsAt: new Date("2026-08-01T12:00:00.000Z"), + createdAt: now, + updatedAt: now, + }, + { + id: BigInt(2), + workerId: BigInt(1), + availableDate: new Date("2026-07-01T00:00:00.000Z"), + startsAt: new Date("2026-07-01T14:00:00.000Z"), + endsAt: new Date("2026-07-01T18:00:00.000Z"), + createdAt: now, + updatedAt: now, + }, + { + id: BigInt(1), + workerId: BigInt(1), + availableDate: new Date("2026-07-01T00:00:00.000Z"), + startsAt: new Date("2026-07-01T10:00:00.000Z"), + endsAt: new Date("2026-07-01T12:00:00.000Z"), + createdAt: now, + updatedAt: now, + }, + ], + organizations: [createOrganization()], + workers: [createWorker()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/availability") + .set("Authorization", authHeader()) + .query({ + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + items: [ + { + id: "1", + workerId: "1", + availableDate: "2026-07-01", + startsAt: "2026-07-01T10:00:00.000Z", + endsAt: "2026-07-01T12:00:00.000Z", + }, + { + id: "2", + workerId: "1", + availableDate: "2026-07-01", + startsAt: "2026-07-01T14:00:00.000Z", + endsAt: "2026-07-01T18:00:00.000Z", + }, + ], + }); + }); + + it("rejects invalid bulk save body", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + workers: [createWorker()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .put("/api/availability/bulk") + .set("Authorization", authHeader()) + .send({ + workerId: "1", + startDate: "2026-07-31", + endDate: "2026-07-01", + items: [], + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + + it("rejects bulk save times off the 30-minute boundary", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + workers: [createWorker()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .put("/api/availability/bulk") + .set("Authorization", authHeader()) + .send({ + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + items: [ + { + availableDate: "2026-07-01", + startsAt: "2026-07-01T10:00:15.000Z", + endsAt: "2026-07-01T12:00:00.000Z", + }, + ], + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + + it("accepts bulk save times on the original offset 30-minute boundary", async () => { + const { prisma } = createFakePrisma({ + minimumStaffingRules: [ + createMinimumStaffingRule({ + endTime: "02:00", + startTime: "00:00", + }), + ], + organizations: [ + createOrganization([ + { + id: BigInt(1), + organizationId: BigInt(1), + dayOfWeek: "WED" as const, + isClosed: false, + openTime: createTimeDate("00:00"), + closeTime: createTimeDate("02:00"), + closesNextDay: false, + }, + ]), + ], + workers: [createWorker()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .put("/api/availability/bulk") + .set("Authorization", authHeader()) + .send({ + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + items: [ + { + availableDate: "2026-07-01", + startsAt: "2026-07-01T10:30:00.000+09:45", + endsAt: "2026-07-01T11:00:00.000+09:45", + }, + ], + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + items: [ + { + id: "1", + workerId: "1", + availableDate: "2026-07-01", + startsAt: "2026-07-01T00:45:00.000Z", + endsAt: "2026-07-01T01:15:00.000Z", + }, + ], + }); + }); + + it("replaces availability for the selected date range", async () => { + const { prisma } = createFakePrisma({ + availableTimes: [ + { + id: BigInt(1), + workerId: BigInt(1), + availableDate: new Date("2026-07-01T00:00:00.000Z"), + startsAt: new Date("2026-07-01T10:00:00.000Z"), + endsAt: new Date("2026-07-01T12:00:00.000Z"), + createdAt: now, + updatedAt: now, + }, + ], + minimumStaffingRules: [createMinimumStaffingRule()], + organizations: [createOrganization()], + workers: [createWorker()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .put("/api/availability/bulk") + .set("Authorization", authHeader()) + .send({ + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + items: [ + { + availableDate: "2026-07-01", + startsAt: "2026-07-01T13:00:00.000Z", + endsAt: "2026-07-01T15:00:00.000Z", + }, + ], + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + items: [ + { + id: "2", + workerId: "1", + availableDate: "2026-07-01", + startsAt: "2026-07-01T13:00:00.000Z", + endsAt: "2026-07-01T15:00:00.000Z", + }, + ], + }); + }); +}); diff --git a/apps/api/test/helpers/fake-prisma.ts b/apps/api/test/helpers/fake-prisma.ts new file mode 100644 index 0000000..5001de0 --- /dev/null +++ b/apps/api/test/helpers/fake-prisma.ts @@ -0,0 +1,1071 @@ +import type { PrismaClient } from "@fragment/database"; +import { DAY_OF_WEEK_VALUES } from "@fragment/shared"; + +type FakeUser = { + id: bigint; + email: string; + name: string; + passwordHash: string; +}; + +type FakeBusinessHour = { + id: bigint; + organizationId: bigint; + dayOfWeek: "MON" | "TUE" | "WED" | "THU" | "FRI" | "SAT" | "SUN"; + isClosed: boolean; + openTime: Date | null; + closeTime: Date | null; + closesNextDay: boolean; +}; + +type FakeOrganization = { + id: bigint; + userId: bigint; + name: string; + businessHours: FakeBusinessHour[]; +}; + +type FakeActiveSchedulePlanningPeriod = { + id: bigint; + organizationId: bigint; + startDate: Date; + endDate: Date; + createdAt: Date; + updatedAt: Date; +}; + +type FakeMinimumStaffingRule = { + id: bigint; + organizationId: bigint; + dayOfWeek: "MON" | "TUE" | "WED" | "THU" | "FRI" | "SAT" | "SUN"; + startTime: Date; + endTime: Date; + endsNextDay: boolean; + requiredCount: number; + createdAt: Date; + updatedAt: Date; +}; + +type FakeWorkerAvailableTime = { + id: bigint; + workerId: bigint; + availableDate: Date; + startsAt: Date; + endsAt: Date; + createdAt: Date; + updatedAt: Date; +}; + +type FakeSchedule = { + id: bigint; + organizationId: bigint; + status: "DRAFT" | "CONFIRMED"; + inputHash: string; + startDate: Date; + endDate: Date; + generatedAt: Date; + confirmedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +type FakeScheduleAssignment = { + id: bigint; + scheduleId: bigint; + workerId: bigint | null; + workDate: Date; + startsAt: Date; + endsAt: Date; + workerNameSnapshot: string; + employeeCodeSnapshot: string; + createdAt: Date; + updatedAt: Date; +}; + +type FakeScheduleCandidate = { + id: bigint; + scheduleId: bigint; + workerId: bigint | null; + workDate: Date; + startsAt: Date; + endsAt: Date; + workerNameSnapshot: string; + employeeCodeSnapshot: string; + createdAt: Date; + updatedAt: Date; +}; + +type FakeScheduleWorkerShortage = { + id: bigint; + scheduleId: bigint; + workDate: Date; + dayOfWeek: "MON" | "TUE" | "WED" | "THU" | "FRI" | "SAT" | "SUN"; + startTime: Date; + endTime: Date; + endsNextDay: boolean; + requiredCount: number; + assignedCount: number; + createdAt: Date; + updatedAt: Date; +}; + +type FakeRefreshToken = { + id: bigint; + userId: bigint; + tokenHash: string; + expiresAt: Date; + revokedAt: Date | null; +}; + +type FakeWorker = { + id: bigint; + organizationId: bigint; + employeeCode: string; + name: string; + weeklyContractHours: number; +}; + +type CreateFakePrismaOptions = { + activeSchedulePlanningPeriods?: FakeActiveSchedulePlanningPeriod[]; + availableTimes?: FakeWorkerAvailableTime[]; + minimumStaffingRules?: FakeMinimumStaffingRule[]; + organizations?: FakeOrganization[]; + refreshTokens?: FakeRefreshToken[]; + scheduleAssignments?: FakeScheduleAssignment[]; + scheduleCandidates?: FakeScheduleCandidate[]; + scheduleWorkerShortages?: FakeScheduleWorkerShortage[]; + schedules?: FakeSchedule[]; + users?: FakeUser[]; + workers?: FakeWorker[]; +}; + +const cloneDate = (date: Date | null) => (date ? new Date(date.getTime()) : null); +const cloneRequiredDate = (date: Date) => new Date(date.getTime()); + +const cloneBusinessHour = (businessHour: FakeBusinessHour): FakeBusinessHour => ({ + ...businessHour, + closeTime: cloneDate(businessHour.closeTime), + openTime: cloneDate(businessHour.openTime), +}); + +const cloneActiveSchedulePlanningPeriod = ( + period: FakeActiveSchedulePlanningPeriod, +): FakeActiveSchedulePlanningPeriod => ({ + ...period, + createdAt: cloneRequiredDate(period.createdAt), + endDate: cloneRequiredDate(period.endDate), + startDate: cloneRequiredDate(period.startDate), + updatedAt: cloneRequiredDate(period.updatedAt), +}); + +const cloneMinimumStaffingRule = ( + minimumStaffingRule: FakeMinimumStaffingRule, +): FakeMinimumStaffingRule => ({ + ...minimumStaffingRule, + createdAt: cloneRequiredDate(minimumStaffingRule.createdAt), + endTime: cloneRequiredDate(minimumStaffingRule.endTime), + startTime: cloneRequiredDate(minimumStaffingRule.startTime), + updatedAt: cloneRequiredDate(minimumStaffingRule.updatedAt), +}); + +const cloneWorkerAvailableTime = ( + availableTime: FakeWorkerAvailableTime, +): FakeWorkerAvailableTime => ({ + ...availableTime, + availableDate: cloneRequiredDate(availableTime.availableDate), + createdAt: cloneRequiredDate(availableTime.createdAt), + endsAt: cloneRequiredDate(availableTime.endsAt), + startsAt: cloneRequiredDate(availableTime.startsAt), + updatedAt: cloneRequiredDate(availableTime.updatedAt), +}); + +const cloneSchedule = (schedule: FakeSchedule): FakeSchedule => ({ + ...schedule, + confirmedAt: cloneDate(schedule.confirmedAt), + createdAt: cloneRequiredDate(schedule.createdAt), + endDate: cloneRequiredDate(schedule.endDate), + generatedAt: cloneRequiredDate(schedule.generatedAt), + startDate: cloneRequiredDate(schedule.startDate), + updatedAt: cloneRequiredDate(schedule.updatedAt), +}); + +const cloneScheduleAssignment = (assignment: FakeScheduleAssignment): FakeScheduleAssignment => ({ + ...assignment, + createdAt: cloneRequiredDate(assignment.createdAt), + endsAt: cloneRequiredDate(assignment.endsAt), + startsAt: cloneRequiredDate(assignment.startsAt), + updatedAt: cloneRequiredDate(assignment.updatedAt), + workDate: cloneRequiredDate(assignment.workDate), +}); + +const cloneScheduleCandidate = (candidate: FakeScheduleCandidate): FakeScheduleCandidate => ({ + ...candidate, + createdAt: cloneRequiredDate(candidate.createdAt), + endsAt: cloneRequiredDate(candidate.endsAt), + startsAt: cloneRequiredDate(candidate.startsAt), + updatedAt: cloneRequiredDate(candidate.updatedAt), + workDate: cloneRequiredDate(candidate.workDate), +}); + +const cloneScheduleWorkerShortage = ( + shortage: FakeScheduleWorkerShortage, +): FakeScheduleWorkerShortage => ({ + ...shortage, + createdAt: cloneRequiredDate(shortage.createdAt), + endTime: cloneRequiredDate(shortage.endTime), + startTime: cloneRequiredDate(shortage.startTime), + updatedAt: cloneRequiredDate(shortage.updatedAt), + workDate: cloneRequiredDate(shortage.workDate), +}); + +const getNextId = (items: T[]) => + items.reduce((nextId, item) => (item.id >= nextId ? item.id + BigInt(1) : nextId), BigInt(1)); + +const dayOfWeekOrder = Object.fromEntries( + DAY_OF_WEEK_VALUES.map((dayOfWeek, index) => [dayOfWeek, index]), +) as Record<(typeof DAY_OF_WEEK_VALUES)[number], number>; + +export function createFakePrisma({ + activeSchedulePlanningPeriods = [], + availableTimes = [], + minimumStaffingRules = [], + organizations = [], + refreshTokens = [], + scheduleAssignments = [], + scheduleCandidates = [], + scheduleWorkerShortages = [], + schedules = [], + users = [], + workers = [], +}: CreateFakePrismaOptions = {}) { + const state = { + nextBusinessHourId: getNextId( + organizations.flatMap((organization) => organization.businessHours), + ), + nextOrganizationId: getNextId(organizations), + nextAvailableTimeId: getNextId(availableTimes), + nextPlanningPeriodId: getNextId(activeSchedulePlanningPeriods), + nextRefreshTokenId: getNextId(refreshTokens), + nextScheduleAssignmentId: getNextId(scheduleAssignments), + nextScheduleCandidateId: getNextId(scheduleCandidates), + nextScheduleId: getNextId(schedules), + nextScheduleWorkerShortageId: getNextId(scheduleWorkerShortages), + nextUserId: getNextId(users), + activeSchedulePlanningPeriods: activeSchedulePlanningPeriods.map( + cloneActiveSchedulePlanningPeriod, + ), + availableTimes: availableTimes.map(cloneWorkerAvailableTime), + minimumStaffingRules: minimumStaffingRules.map(cloneMinimumStaffingRule), + organizations: organizations.map((organization) => ({ + ...organization, + businessHours: organization.businessHours.map(cloneBusinessHour), + })), + refreshTokens: refreshTokens.map((refreshToken) => ({ ...refreshToken })), + scheduleAssignments: scheduleAssignments.map(cloneScheduleAssignment), + scheduleCandidates: scheduleCandidates.map(cloneScheduleCandidate), + scheduleWorkerShortages: scheduleWorkerShortages.map(cloneScheduleWorkerShortage), + schedules: schedules.map(cloneSchedule), + users: [...users], + workers: workers.map((worker) => ({ ...worker })), + }; + + const findOrganizationByUserId = (userId: bigint) => + state.organizations.find((organization) => organization.userId === userId) ?? null; + + const findOrganizationById = (id: bigint) => + state.organizations.find((organization) => organization.id === id) ?? null; + + const toOrganizationResult = (organization: FakeOrganization | null, options?: any) => { + if (!organization) { + return null; + } + + if (options?.select) { + return Object.fromEntries( + Object.keys(options.select) + .filter((key) => options.select[key]) + .map((key) => [key, organization[key as keyof FakeOrganization]]), + ); + } + + if (options?.include) { + return { + ...organization, + ...(options.include.businessHours + ? { businessHours: organization.businessHours.map(cloneBusinessHour) } + : {}), + ...(options.include.minimumStaffingRules + ? { + minimumStaffingRules: state.minimumStaffingRules + .filter((rule) => rule.organizationId === organization.id) + .map(cloneMinimumStaffingRule), + } + : {}), + }; + } + + return { + ...organization, + businessHours: organization.businessHours.map(cloneBusinessHour), + }; + }; + + const toSelectedResult = >(item: T, select: any) => + Object.fromEntries( + Object.keys(select) + .filter((key) => select[key]) + .map((key) => [key, item[key]]), + ); + + const sortScheduleAssignments = (assignments: FakeScheduleAssignment[]) => + assignments.sort((a, b) => { + if (a.workDate.getTime() !== b.workDate.getTime()) { + return a.workDate.getTime() - b.workDate.getTime(); + } + + if (a.startsAt.getTime() !== b.startsAt.getTime()) { + return a.startsAt.getTime() - b.startsAt.getTime(); + } + + return Number(a.id - b.id); + }); + + const sortScheduleCandidates = (candidates: FakeScheduleCandidate[]) => + candidates.sort((a, b) => { + if (a.workDate.getTime() !== b.workDate.getTime()) { + return a.workDate.getTime() - b.workDate.getTime(); + } + + if (a.startsAt.getTime() !== b.startsAt.getTime()) { + return a.startsAt.getTime() - b.startsAt.getTime(); + } + + return a.employeeCodeSnapshot.localeCompare(b.employeeCodeSnapshot); + }); + + const sortScheduleWorkerShortages = (shortages: FakeScheduleWorkerShortage[]) => + shortages.sort((a, b) => { + if (a.workDate.getTime() !== b.workDate.getTime()) { + return a.workDate.getTime() - b.workDate.getTime(); + } + + if (a.startTime.getTime() !== b.startTime.getTime()) { + return a.startTime.getTime() - b.startTime.getTime(); + } + + return Number(a.id - b.id); + }); + + const toScheduleResult = (schedule: FakeSchedule | null, options?: any) => { + if (!schedule) { + return null; + } + + const clonedSchedule = cloneSchedule(schedule); + + if (options?.select) { + return toSelectedResult(clonedSchedule as unknown as Record, options.select); + } + + if (options?.include) { + return { + ...clonedSchedule, + assignments: sortScheduleAssignments( + state.scheduleAssignments + .filter((assignment) => assignment.scheduleId === schedule.id) + .map(cloneScheduleAssignment), + ), + candidates: sortScheduleCandidates( + state.scheduleCandidates + .filter((candidate) => candidate.scheduleId === schedule.id) + .map(cloneScheduleCandidate), + ), + workerShortages: sortScheduleWorkerShortages( + state.scheduleWorkerShortages + .filter((shortage) => shortage.scheduleId === schedule.id) + .map(cloneScheduleWorkerShortage), + ), + }; + } + + return clonedSchedule; + }; + + const matchesScheduleWhere = (schedule: FakeSchedule, where: any) => { + if (where.id !== undefined && schedule.id !== where.id) { + return false; + } + + if (where.organizationId !== undefined && schedule.organizationId !== where.organizationId) { + return false; + } + + if (where.status !== undefined && schedule.status !== where.status) { + return false; + } + + if (where.inputHash !== undefined && schedule.inputHash !== where.inputHash) { + return false; + } + + if ( + where.startDate !== undefined && + schedule.startDate.getTime() !== where.startDate.getTime() + ) { + return false; + } + + if (where.endDate !== undefined && schedule.endDate.getTime() !== where.endDate.getTime()) { + return false; + } + + return true; + }; + + const matchesScheduleAssignmentWhere = (assignment: FakeScheduleAssignment, where: any) => { + if (where.id !== undefined && assignment.id !== where.id) { + return false; + } + + if (where.scheduleId !== undefined && assignment.scheduleId !== where.scheduleId) { + return false; + } + + return true; + }; + + const prisma = { + $transaction: async (callback: (transaction: PrismaClient) => T | Promise) => + callback(prisma as unknown as PrismaClient), + organizationBusinessHour: { + deleteMany: async ({ where }: any) => { + const organization = findOrganizationById(where.organizationId); + + if (!organization) { + return { count: 0 }; + } + + const count = organization.businessHours.length; + organization.businessHours = []; + + return { count }; + }, + }, + activeSchedulePlanningPeriod: { + deleteMany: async ({ where }: any) => { + const initialCount = state.activeSchedulePlanningPeriods.length; + state.activeSchedulePlanningPeriods = state.activeSchedulePlanningPeriods.filter( + (period) => period.organizationId !== where.organizationId, + ); + + return { count: initialCount - state.activeSchedulePlanningPeriods.length }; + }, + findUnique: async ({ where }: any) => { + const period = + state.activeSchedulePlanningPeriods.find( + (currentPeriod) => currentPeriod.organizationId === where.organizationId, + ) ?? null; + + return period ? cloneActiveSchedulePlanningPeriod(period) : null; + }, + upsert: async ({ create, update, where }: any) => { + const existingPeriod = state.activeSchedulePlanningPeriods.find( + (period) => period.organizationId === where.organizationId, + ); + const now = new Date("2026-06-25T00:00:00.000Z"); + + if (existingPeriod) { + Object.assign(existingPeriod, { + ...update, + updatedAt: now, + }); + + return cloneActiveSchedulePlanningPeriod(existingPeriod); + } + + const period: FakeActiveSchedulePlanningPeriod = { + id: state.nextPlanningPeriodId++, + createdAt: now, + updatedAt: now, + ...create, + }; + + state.activeSchedulePlanningPeriods.push(period); + + return cloneActiveSchedulePlanningPeriod(period); + }, + }, + minimumStaffingRule: { + create: async ({ data }: any) => { + const now = new Date("2026-06-25T00:00:00.000Z"); + const rule: FakeMinimumStaffingRule = { + id: getNextId(state.minimumStaffingRules), + createdAt: now, + updatedAt: now, + ...data, + }; + + state.minimumStaffingRules.push(rule); + + return cloneMinimumStaffingRule(rule); + }, + deleteMany: async ({ where }: any) => { + const initialCount = state.minimumStaffingRules.length; + state.minimumStaffingRules = state.minimumStaffingRules.filter((rule) => { + if (where.id !== undefined && rule.id !== where.id) { + return true; + } + + if (where.organizationId !== undefined && rule.organizationId !== where.organizationId) { + return true; + } + + return false; + }); + + return { count: initialCount - state.minimumStaffingRules.length }; + }, + findFirst: async ({ select, where }: any) => { + const rule = + state.minimumStaffingRules.find((currentRule) => { + if (where.id !== undefined && currentRule.id !== where.id) { + return false; + } + + if ( + where.organizationId !== undefined && + currentRule.organizationId !== where.organizationId + ) { + return false; + } + + return true; + }) ?? null; + + if (!rule || !select) { + return rule ? cloneMinimumStaffingRule(rule) : null; + } + + return Object.fromEntries( + Object.keys(select) + .filter((key) => select[key]) + .map((key) => [key, rule[key as keyof FakeMinimumStaffingRule]]), + ); + }, + findMany: async ({ orderBy, where }: any) => { + const rules = state.minimumStaffingRules + .filter((rule) => rule.organizationId === where.organizationId) + .map(cloneMinimumStaffingRule); + + if (Array.isArray(orderBy)) { + rules.sort((a, b) => { + for (const order of orderBy) { + if (order.dayOfWeek === "asc" && a.dayOfWeek !== b.dayOfWeek) { + return dayOfWeekOrder[a.dayOfWeek] - dayOfWeekOrder[b.dayOfWeek]; + } + + if (order.startTime === "asc" && a.startTime.getTime() !== b.startTime.getTime()) { + return a.startTime.getTime() - b.startTime.getTime(); + } + } + + return 0; + }); + } + + return rules; + }, + update: async ({ data, where }: any) => { + const rule = state.minimumStaffingRules.find((currentRule) => currentRule.id === where.id); + + if (!rule) { + return null; + } + + Object.assign(rule, { + ...data, + updatedAt: new Date("2026-06-25T00:00:00.000Z"), + }); + + return cloneMinimumStaffingRule(rule); + }, + }, + organization: { + create: async ({ data }: any) => { + const organization: FakeOrganization = { + id: state.nextOrganizationId, + name: data.name, + userId: data.userId, + businessHours: data.businessHours.create.map((businessHour: any) => ({ + ...businessHour, + id: state.nextBusinessHourId++, + organizationId: state.nextOrganizationId, + })), + }; + + state.nextOrganizationId++; + state.organizations.push(organization); + + return toOrganizationResult(organization); + }, + findUnique: async ({ where, ...options }: any) => { + const organization = + where.userId !== undefined + ? findOrganizationByUserId(where.userId) + : findOrganizationById(where.id); + + return toOrganizationResult(organization, options); + }, + update: async ({ data, where, ...options }: any) => { + const organization = findOrganizationById(where.id); + + if (!organization) { + return null; + } + + if (data.name !== undefined) { + organization.name = data.name; + } + + if (data.businessHours?.create) { + organization.businessHours = data.businessHours.create.map((businessHour: any) => ({ + ...businessHour, + id: state.nextBusinessHourId++, + organizationId: organization.id, + })); + } + + return toOrganizationResult(organization, options); + }, + }, + worker: { + findFirst: async ({ select, where }: any) => { + const worker = + state.workers.find((currentWorker) => { + if (where.id !== undefined && currentWorker.id !== where.id) { + return false; + } + + if ( + where.organizationId !== undefined && + currentWorker.organizationId !== where.organizationId + ) { + return false; + } + + return true; + }) ?? null; + + if (!worker || !select) { + return worker ? { ...worker } : null; + } + + return Object.fromEntries( + Object.keys(select) + .filter((key) => select[key]) + .map((key) => [key, worker[key as keyof FakeWorker]]), + ); + }, + findMany: async ({ orderBy, where }: any) => { + const matchingWorkers = state.workers + .filter((worker) => { + if ( + where.organizationId !== undefined && + worker.organizationId !== where.organizationId + ) { + return false; + } + + return true; + }) + .map((worker) => ({ ...worker })); + + if (orderBy?.employeeCode === "asc") { + matchingWorkers.sort((a, b) => a.employeeCode.localeCompare(b.employeeCode)); + } + + return matchingWorkers; + }, + }, + workerAvailableTime: { + createMany: async ({ data }: any) => { + const availableTimesToCreate = data.map((availableTime: any) => ({ + id: state.nextAvailableTimeId++, + createdAt: new Date("2026-06-25T00:00:00.000Z"), + updatedAt: new Date("2026-06-25T00:00:00.000Z"), + ...availableTime, + })); + + state.availableTimes.push(...availableTimesToCreate); + + return { count: availableTimesToCreate.length }; + }, + deleteMany: async ({ where }: any) => { + const initialCount = state.availableTimes.length; + state.availableTimes = state.availableTimes.filter((availableTime) => { + if (where.workerId !== undefined && availableTime.workerId !== where.workerId) { + return true; + } + + if (where.worker?.organizationId !== undefined) { + const worker = state.workers.find( + (currentWorker) => currentWorker.id === availableTime.workerId, + ); + + if (!worker || worker.organizationId !== where.worker.organizationId) { + return true; + } + } + + if ( + where.availableDate?.gte !== undefined && + availableTime.availableDate < where.availableDate.gte + ) { + return true; + } + + if ( + where.availableDate?.lte !== undefined && + availableTime.availableDate > where.availableDate.lte + ) { + return true; + } + + return false; + }); + + return { count: initialCount - state.availableTimes.length }; + }, + findMany: async ({ orderBy, where }: any) => { + const matchingAvailableTimes = state.availableTimes + .filter((availableTime) => { + if (where.workerId !== undefined && availableTime.workerId !== where.workerId) { + return false; + } + + if (where.worker?.organizationId !== undefined) { + const worker = state.workers.find( + (currentWorker) => currentWorker.id === availableTime.workerId, + ); + + if (!worker || worker.organizationId !== where.worker.organizationId) { + return false; + } + } + + if ( + where.availableDate?.gte !== undefined && + availableTime.availableDate < where.availableDate.gte + ) { + return false; + } + + if ( + where.availableDate?.lte !== undefined && + availableTime.availableDate > where.availableDate.lte + ) { + return false; + } + + return true; + }) + .map(cloneWorkerAvailableTime); + + if (Array.isArray(orderBy)) { + matchingAvailableTimes.sort((a, b) => { + for (const order of orderBy) { + if ( + order.availableDate === "asc" && + a.availableDate.getTime() !== b.availableDate.getTime() + ) { + return a.availableDate.getTime() - b.availableDate.getTime(); + } + + if (order.startsAt === "asc" && a.startsAt.getTime() !== b.startsAt.getTime()) { + return a.startsAt.getTime() - b.startsAt.getTime(); + } + } + + return 0; + }); + } + + return matchingAvailableTimes; + }, + }, + schedule: { + create: async ({ data, ...options }: any) => { + const now = new Date("2026-06-25T00:00:00.000Z"); + const organizationId = data.organization?.connect?.id ?? data.organizationId; + const schedule: FakeSchedule = { + id: state.nextScheduleId++, + organizationId, + status: data.status, + inputHash: data.inputHash, + startDate: cloneRequiredDate(data.startDate), + endDate: cloneRequiredDate(data.endDate), + generatedAt: cloneRequiredDate(data.generatedAt ?? now), + confirmedAt: cloneDate(data.confirmedAt ?? null), + createdAt: now, + updatedAt: now, + }; + + state.schedules.push(schedule); + + const assignmentsToCreate = data.assignments?.create ?? []; + state.scheduleAssignments.push( + ...assignmentsToCreate.map((assignment: any) => ({ + id: state.nextScheduleAssignmentId++, + scheduleId: schedule.id, + workerId: assignment.worker?.connect?.id ?? assignment.workerId ?? null, + workDate: cloneRequiredDate(assignment.workDate), + startsAt: cloneRequiredDate(assignment.startsAt), + endsAt: cloneRequiredDate(assignment.endsAt), + workerNameSnapshot: assignment.workerNameSnapshot, + employeeCodeSnapshot: assignment.employeeCodeSnapshot, + createdAt: now, + updatedAt: now, + })), + ); + + const candidatesToCreate = data.candidates?.create ?? []; + state.scheduleCandidates.push( + ...candidatesToCreate.map((candidate: any) => ({ + id: state.nextScheduleCandidateId++, + scheduleId: schedule.id, + workerId: candidate.worker?.connect?.id ?? candidate.workerId ?? null, + workDate: cloneRequiredDate(candidate.workDate), + startsAt: cloneRequiredDate(candidate.startsAt), + endsAt: cloneRequiredDate(candidate.endsAt), + workerNameSnapshot: candidate.workerNameSnapshot, + employeeCodeSnapshot: candidate.employeeCodeSnapshot, + createdAt: now, + updatedAt: now, + })), + ); + + const shortagesToCreate = data.workerShortages?.create ?? []; + state.scheduleWorkerShortages.push( + ...shortagesToCreate.map((shortage: any) => ({ + id: state.nextScheduleWorkerShortageId++, + scheduleId: schedule.id, + workDate: cloneRequiredDate(shortage.workDate), + dayOfWeek: shortage.dayOfWeek, + startTime: cloneRequiredDate(shortage.startTime), + endTime: cloneRequiredDate(shortage.endTime), + endsNextDay: shortage.endsNextDay, + requiredCount: shortage.requiredCount, + assignedCount: shortage.assignedCount, + createdAt: now, + updatedAt: now, + })), + ); + + return toScheduleResult(schedule, options); + }, + findFirst: async ({ orderBy, where, ...options }: any) => { + let schedules = state.schedules.filter((currentSchedule) => + matchesScheduleWhere(currentSchedule, where), + ); + + if (orderBy?.id === "desc") { + schedules = [...schedules].sort((first, second) => + first.id < second.id ? 1 : first.id > second.id ? -1 : 0, + ); + } + + const schedule = schedules[0]; + + return toScheduleResult(schedule ?? null, options); + }, + update: async ({ data, where, ...options }: any) => { + const schedule = state.schedules.find((currentSchedule) => currentSchedule.id === where.id); + + if (!schedule) { + return null; + } + + Object.assign(schedule, { + ...(data.status !== undefined ? { status: data.status } : {}), + ...(data.confirmedAt !== undefined ? { confirmedAt: cloneDate(data.confirmedAt) } : {}), + updatedAt: new Date("2026-06-25T00:00:00.000Z"), + }); + + return toScheduleResult(schedule, options); + }, + }, + scheduleAssignment: { + create: async ({ data }: any) => { + const now = new Date("2026-06-25T00:00:00.000Z"); + const assignment: FakeScheduleAssignment = { + id: state.nextScheduleAssignmentId++, + scheduleId: data.scheduleId, + workerId: data.workerId ?? null, + workDate: cloneRequiredDate(data.workDate), + startsAt: cloneRequiredDate(data.startsAt), + endsAt: cloneRequiredDate(data.endsAt), + workerNameSnapshot: data.workerNameSnapshot, + employeeCodeSnapshot: data.employeeCodeSnapshot, + createdAt: now, + updatedAt: now, + }; + + state.scheduleAssignments.push(assignment); + + return cloneScheduleAssignment(assignment); + }, + deleteMany: async ({ where }: any) => { + const initialCount = state.scheduleAssignments.length; + state.scheduleAssignments = state.scheduleAssignments.filter( + (assignment) => !matchesScheduleAssignmentWhere(assignment, where), + ); + + return { count: initialCount - state.scheduleAssignments.length }; + }, + findFirst: async ({ where }: any) => { + const assignment = + state.scheduleAssignments.find((currentAssignment) => + matchesScheduleAssignmentWhere(currentAssignment, where), + ) ?? null; + + return assignment ? cloneScheduleAssignment(assignment) : null; + }, + update: async ({ data, where }: any) => { + const assignment = state.scheduleAssignments.find( + (currentAssignment) => currentAssignment.id === where.id, + ); + + if (!assignment) { + return null; + } + + Object.assign(assignment, { + ...(data.workerId !== undefined ? { workerId: data.workerId } : {}), + ...(data.workDate !== undefined ? { workDate: cloneRequiredDate(data.workDate) } : {}), + ...(data.startsAt !== undefined ? { startsAt: cloneRequiredDate(data.startsAt) } : {}), + ...(data.endsAt !== undefined ? { endsAt: cloneRequiredDate(data.endsAt) } : {}), + ...(data.workerNameSnapshot !== undefined + ? { workerNameSnapshot: data.workerNameSnapshot } + : {}), + ...(data.employeeCodeSnapshot !== undefined + ? { employeeCodeSnapshot: data.employeeCodeSnapshot } + : {}), + updatedAt: new Date("2026-06-25T00:00:00.000Z"), + }); + + return cloneScheduleAssignment(assignment); + }, + }, + refreshToken: { + create: async ({ data }: any) => { + const refreshToken = { + id: state.nextRefreshTokenId++, + revokedAt: null, + ...data, + }; + + state.refreshTokens.push(refreshToken); + + return refreshToken; + }, + findUnique: async ({ include, where }: any) => { + const refreshToken = + state.refreshTokens.find((token) => token.tokenHash === where.tokenHash) ?? null; + + if (!refreshToken || !include?.user) { + return refreshToken; + } + + return { + ...refreshToken, + user: state.users.find((user) => user.id === refreshToken.userId), + }; + }, + update: async ({ data, where }: any) => { + const refreshToken = state.refreshTokens.find((token) => token.id === where.id); + + if (!refreshToken) { + return null; + } + + Object.assign(refreshToken, data); + + return refreshToken; + }, + updateMany: async ({ data, where }: any) => { + const matchingTokens = state.refreshTokens.filter((token) => { + if (where.id !== undefined && token.id !== where.id) { + return false; + } + + if (where.userId !== undefined && token.userId !== where.userId) { + return false; + } + + if (where.revokedAt === null && token.revokedAt !== null) { + return false; + } + + if (where.expiresAt?.gt && token.expiresAt <= where.expiresAt.gt) { + return false; + } + + return true; + }); + + matchingTokens.forEach((token) => Object.assign(token, data)); + + return { count: matchingTokens.length }; + }, + }, + user: { + create: async ({ data }: any) => { + const user = { + id: state.nextUserId++, + ...data, + }; + + state.users.push(user); + + return user; + }, + findUnique: async ({ include, where }: any) => { + const user = + where.email !== undefined + ? state.users.find((currentUser) => currentUser.email === where.email) + : state.users.find((currentUser) => currentUser.id === where.id); + + if (!user) { + return null; + } + + if (!include?.organization) { + return user; + } + + const organization = findOrganizationByUserId(user.id); + + return { + ...user, + organization: organization + ? { + id: organization.id, + name: organization.name, + } + : null, + }; + }, + }, + }; + + return { + prisma: prisma as unknown as PrismaClient, + state, + }; +} + +export const createTimeDate = (time: string) => { + const [hour, minute] = time.split(":").map(Number); + + return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); +}; diff --git a/apps/api/test/helpers/organization-fixtures.ts b/apps/api/test/helpers/organization-fixtures.ts new file mode 100644 index 0000000..5636d0a --- /dev/null +++ b/apps/api/test/helpers/organization-fixtures.ts @@ -0,0 +1,19 @@ +import { DAY_OF_WEEK_VALUES } from "@fragment/shared"; + +type BusinessHourInput = { + dayOfWeek: (typeof DAY_OF_WEEK_VALUES)[number]; + isClosed: boolean; + openTime: string | null; + closeTime: string | null; +}; + +export const createBusinessHoursInput = ( + overrides: Partial>> = {}, +) => + DAY_OF_WEEK_VALUES.map((dayOfWeek) => ({ + dayOfWeek, + isClosed: false, + openTime: "09:00", + closeTime: "18:00", + ...overrides[dayOfWeek], + })); diff --git a/apps/api/test/organization.routes.test.ts b/apps/api/test/organization.routes.test.ts new file mode 100644 index 0000000..2314715 --- /dev/null +++ b/apps/api/test/organization.routes.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it, beforeEach } from "@jest/globals"; +import request from "supertest"; + +import { createApp } from "@/app"; +import { createAccessToken } from "@/modules/auth/auth.tokens"; +import { createBusinessHoursInput } from "./helpers/organization-fixtures"; +import { createFakePrisma, createTimeDate } from "./helpers/fake-prisma"; + +describe("organization routes", () => { + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = "test-access-secret"; + delete process.env.JWT_ACCESS_EXPIRES_IN; + delete process.env.WEB_APP_ORIGIN; + }); + + const authHeader = () => `Bearer ${createAccessToken(BigInt(1))}`; + + it("requires authentication when creating an organization", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).post("/api/organization").send({ + name: "프래그먼트 카페", + businessHours: createBusinessHoursInput(), + }); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + errorCode: "UNAUTHORIZED", + statusCode: 401, + }); + }); + + it("rejects invalid organization input", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app) + .post("/api/organization") + .set("Authorization", authHeader()) + .send({ + name: "", + businessHours: [], + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + + it("creates an organization and derives overnight business hours", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app) + .post("/api/organization") + .set("Authorization", authHeader()) + .send({ + name: "프래그먼트 카페", + businessHours: createBusinessHoursInput({ + FRI: { + openTime: "22:00", + closeTime: "02:00", + }, + SUN: { + isClosed: true, + openTime: null, + closeTime: null, + }, + }).reverse(), + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ + id: "1", + name: "프래그먼트 카페", + }); + expect(response.body.businessHours).toHaveLength(7); + expect( + response.body.businessHours.map( + (businessHour: { dayOfWeek: string }) => businessHour.dayOfWeek, + ), + ).toEqual(["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]); + expect(response.body.businessHours[4]).toMatchObject({ + dayOfWeek: "FRI", + openTime: "22:00", + closeTime: "02:00", + closesNextDay: true, + }); + expect(response.body.businessHours[6]).toMatchObject({ + dayOfWeek: "SUN", + isClosed: true, + openTime: null, + closeTime: null, + closesNextDay: false, + }); + }); + + it("prevents creating a second organization for the same user", async () => { + const { prisma } = createFakePrisma({ + organizations: [ + { + id: BigInt(1), + userId: BigInt(1), + name: "기존 조직", + businessHours: [], + }, + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .post("/api/organization") + .set("Authorization", authHeader()) + .send({ + name: "프래그먼트 카페", + businessHours: createBusinessHoursInput(), + }); + + expect(response.status).toBe(409); + expect(response.body).toMatchObject({ + errorCode: "ORGANIZATION_ALREADY_EXISTS", + statusCode: 409, + }); + }); + + it("returns the authenticated user's organization", async () => { + const { prisma } = createFakePrisma({ + organizations: [ + { + id: BigInt(1), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours: [ + { + id: BigInt(1), + organizationId: BigInt(1), + dayOfWeek: "MON", + isClosed: false, + openTime: createTimeDate("09:00"), + closeTime: createTimeDate("18:00"), + closesNextDay: false, + }, + ], + }, + ], + }); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/organization").set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + id: "1", + name: "프래그먼트 카페", + businessHours: [ + { + id: "1", + dayOfWeek: "MON", + isClosed: false, + openTime: "09:00", + closeTime: "18:00", + closesNextDay: false, + }, + ], + }); + }); + + it("updates an existing organization", async () => { + const { prisma } = createFakePrisma({ + organizations: [ + { + id: BigInt(1), + userId: BigInt(1), + name: "기존 조직", + businessHours: [], + }, + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .patch("/api/organization") + .set("Authorization", authHeader()) + .send({ + name: "수정된 조직", + businessHours: createBusinessHoursInput({ + SUN: { + isClosed: true, + openTime: null, + closeTime: null, + }, + }), + }); + + expect(response.status).toBe(200); + expect(response.body.name).toBe("수정된 조직"); + expect(response.body.businessHours).toHaveLength(7); + expect(response.body.businessHours[6]).toMatchObject({ + dayOfWeek: "SUN", + isClosed: true, + openTime: null, + closeTime: null, + }); + }); + + it("returns null when there is no active planning period", async () => { + const { prisma } = createFakePrisma({ + organizations: [ + { + id: BigInt(1), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours: [], + }, + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/organization/active-schedule-planning-period") + .set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + period: null, + }); + }); + + it("upserts and returns the active planning period", async () => { + const { prisma } = createFakePrisma({ + organizations: [ + { + id: BigInt(1), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours: [], + }, + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .put("/api/organization/active-schedule-planning-period") + .set("Authorization", authHeader()) + .send({ + startDate: "2026-07-01", + endDate: "2026-07-31", + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + id: "1", + startDate: "2026-07-01", + endDate: "2026-07-31", + }); + }); + + it("rejects an invalid active planning period date range", async () => { + const { prisma } = createFakePrisma({ + organizations: [ + { + id: BigInt(1), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours: [], + }, + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .put("/api/organization/active-schedule-planning-period") + .set("Authorization", authHeader()) + .send({ + startDate: "2026-08-01", + endDate: "2026-07-31", + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + + it("deletes the active planning period", async () => { + const now = new Date("2026-06-25T00:00:00.000Z"); + const { prisma } = createFakePrisma({ + activeSchedulePlanningPeriods: [ + { + id: BigInt(1), + organizationId: BigInt(1), + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-31T00:00:00.000Z"), + createdAt: now, + updatedAt: now, + }, + ], + organizations: [ + { + id: BigInt(1), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours: [], + }, + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .delete("/api/organization/active-schedule-planning-period") + .set("Authorization", authHeader()); + + expect(response.status).toBe(204); + }); +}); diff --git a/apps/api/test/schedules.routes.test.ts b/apps/api/test/schedules.routes.test.ts new file mode 100644 index 0000000..f8f3e33 --- /dev/null +++ b/apps/api/test/schedules.routes.test.ts @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import request from "supertest"; + +import { createApp } from "@/app"; +import { createAccessToken } from "@/modules/auth/auth.tokens"; +import { createFakePrisma, createTimeDate } from "./helpers/fake-prisma"; + +const timestamp = new Date("2026-06-25T00:00:00.000Z"); +const dateOnly = (date: string) => new Date(`${date}T00:00:00.000Z`); + +const createBusinessHour = () => ({ + id: BigInt(1), + organizationId: BigInt(1), + dayOfWeek: "WED" as const, + isClosed: false, + openTime: createTimeDate("10:00"), + closeTime: createTimeDate("14:00"), + closesNextDay: false, +}); + +const createOrganization = () => ({ + id: BigInt(1), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours: [createBusinessHour()], +}); + +const createPlanningPeriod = () => ({ + id: BigInt(1), + organizationId: BigInt(1), + startDate: dateOnly("2026-07-01"), + endDate: dateOnly("2026-07-01"), + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createWorker = () => ({ + id: BigInt(1), + organizationId: BigInt(1), + employeeCode: "W-0001", + name: "김민수", + weeklyContractHours: 40, +}); + +const createAvailableTime = () => ({ + id: BigInt(1), + workerId: BigInt(1), + availableDate: dateOnly("2026-07-01"), + startsAt: new Date("2026-07-01T09:00:00.000Z"), + endsAt: new Date("2026-07-01T18:00:00.000Z"), + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createMinimumStaffingRule = () => ({ + id: BigInt(1), + organizationId: BigInt(1), + dayOfWeek: "WED" as const, + startTime: createTimeDate("10:00"), + endTime: createTimeDate("14:00"), + endsNextDay: false, + requiredCount: 1, + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createDraftSchedule = () => ({ + id: BigInt(1), + organizationId: BigInt(1), + status: "DRAFT" as const, + inputHash: "hash-1", + startDate: dateOnly("2026-07-01"), + endDate: dateOnly("2026-07-01"), + generatedAt: timestamp, + confirmedAt: null, + createdAt: timestamp, + updatedAt: timestamp, +}); + +describe("schedules routes", () => { + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = "test-access-secret"; + delete process.env.JWT_ACCESS_EXPIRES_IN; + delete process.env.WEB_APP_ORIGIN; + }); + + const authHeader = () => `Bearer ${createAccessToken(BigInt(1))}`; + + it("requires authentication", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).post("/api/schedules/recommend").send({ + startDate: "2026-07-01", + endDate: "2026-07-01", + }); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + errorCode: "UNAUTHORIZED", + statusCode: 401, + }); + }); + + it("requires an organization", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedules/draft?startDate=2026-07-01&endDate=2026-07-01") + .set("Authorization", authHeader()); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + errorCode: "ORGANIZATION_REQUIRED", + statusCode: 403, + }); + }); + + it("rejects invalid recommendation input", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .post("/api/schedules/recommend") + .set("Authorization", authHeader()) + .send({ + startDate: "2026-07-02", + endDate: "2026-07-01", + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + + it("creates and returns a recommended DRAFT schedule", async () => { + const { prisma } = createFakePrisma({ + activeSchedulePlanningPeriods: [createPlanningPeriod()], + availableTimes: [createAvailableTime()], + minimumStaffingRules: [createMinimumStaffingRule()], + organizations: [createOrganization()], + workers: [createWorker()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .post("/api/schedules/recommend") + .set("Authorization", authHeader()) + .send({ + startDate: "2026-07-01", + endDate: "2026-07-01", + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ + id: "1", + status: "DRAFT", + startDate: "2026-07-01", + endDate: "2026-07-01", + assignments: [ + { + workerId: "1", + workDate: "2026-07-01", + workerNameSnapshot: "김민수", + employeeCodeSnapshot: "W-0001", + }, + ], + candidates: [ + { + workerId: "1", + workDate: "2026-07-01", + workerNameSnapshot: "김민수", + employeeCodeSnapshot: "W-0001", + isRecommended: true, + }, + ], + workerShortages: [], + }); + }); + + it("returns an existing DRAFT schedule for the requested period", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + scheduleAssignments: [ + { + id: BigInt(10), + scheduleId: BigInt(1), + workerId: BigInt(1), + workDate: dateOnly("2026-07-01"), + startsAt: new Date("2026-07-01T10:00:00.000Z"), + endsAt: new Date("2026-07-01T14:00:00.000Z"), + workerNameSnapshot: "김민수", + employeeCodeSnapshot: "W-0001", + createdAt: timestamp, + updatedAt: timestamp, + }, + ], + schedules: [createDraftSchedule()], + workers: [createWorker()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedules/draft?startDate=2026-07-01&endDate=2026-07-01") + .set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.body.schedule).toMatchObject({ + id: "1", + status: "DRAFT", + assignments: [ + { + id: "10", + workerId: "1", + }, + ], + }); + }); + + it("adds an assignment and confirms the DRAFT schedule", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + schedules: [createDraftSchedule()], + workers: [createWorker()], + }); + const app = createApp({ prisma }); + + const assignmentResponse = await request(app) + .post("/api/schedules/1/assignments") + .set("Authorization", authHeader()) + .send({ + workerId: "1", + workDate: "2026-07-01", + startsAt: "2026-07-01T10:00:00.000Z", + endsAt: "2026-07-01T14:00:00.000Z", + }); + + expect(assignmentResponse.status).toBe(201); + expect(assignmentResponse.body).toMatchObject({ + id: "1", + scheduleId: "1", + workerId: "1", + workerNameSnapshot: "김민수", + }); + + const confirmResponse = await request(app) + .post("/api/schedules/1/confirm") + .set("Authorization", authHeader()); + + expect(confirmResponse.status).toBe(200); + expect(confirmResponse.body).toMatchObject({ + id: "1", + status: "CONFIRMED", + assignments: [ + { + id: "1", + workerId: "1", + }, + ], + }); + }); +}); diff --git a/apps/api/test/staffing-rules.routes.test.ts b/apps/api/test/staffing-rules.routes.test.ts new file mode 100644 index 0000000..e1ee4c1 --- /dev/null +++ b/apps/api/test/staffing-rules.routes.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import request from "supertest"; + +import { createApp } from "@/app"; +import { createAccessToken } from "@/modules/auth/auth.tokens"; +import { createFakePrisma, createTimeDate } from "./helpers/fake-prisma"; + +const now = new Date("2026-06-25T00:00:00.000Z"); + +const createOrganization = ( + businessHours = [ + { + id: BigInt(1), + organizationId: BigInt(1), + dayOfWeek: "WED" as const, + isClosed: false, + openTime: createTimeDate("09:00"), + closeTime: createTimeDate("18:00"), + closesNextDay: false, + }, + ], +) => ({ + id: BigInt(1), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours, +}); + +describe("staffing rules routes", () => { + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = "test-access-secret"; + delete process.env.JWT_ACCESS_EXPIRES_IN; + delete process.env.WEB_APP_ORIGIN; + }); + + const authHeader = () => `Bearer ${createAccessToken(BigInt(1))}`; + + it("requires authentication", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/staffing-rules"); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + errorCode: "UNAUTHORIZED", + statusCode: 401, + }); + }); + + it("rejects invalid create input", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app) + .post("/api/staffing-rules") + .set("Authorization", authHeader()) + .send({ + dayOfWeek: "MON", + startTime: "14:00", + endTime: "10:00", + endsNextDay: false, + requiredCount: 0, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + + it("returns the authenticated user's staffing rules", async () => { + const { prisma } = createFakePrisma({ + minimumStaffingRules: [ + { + id: BigInt(10), + organizationId: BigInt(1), + dayOfWeek: "MON", + startTime: createTimeDate("09:30"), + endTime: createTimeDate("14:00"), + endsNextDay: false, + requiredCount: 2, + createdAt: now, + updatedAt: now, + }, + ], + organizations: [createOrganization()], + }); + const app = createApp({ + prisma, + }); + + const response = await request(app) + .get("/api/staffing-rules") + .set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + items: [ + { + id: "10", + dayOfWeek: "MON", + startTime: "09:30", + endTime: "14:00", + endsNextDay: false, + requiredCount: 2, + }, + ], + }); + }); + + it("prunes availability when a staffing rule is deleted", async () => { + const { prisma } = createFakePrisma({ + activeSchedulePlanningPeriods: [ + { + id: BigInt(1), + organizationId: BigInt(1), + startDate: new Date("2026-07-01T00:00:00.000Z"), + endDate: new Date("2026-07-01T00:00:00.000Z"), + createdAt: now, + updatedAt: now, + }, + ], + availableTimes: [ + { + id: BigInt(1), + workerId: BigInt(1), + availableDate: new Date("2026-07-01T00:00:00.000Z"), + startsAt: new Date("2026-07-01T12:00:00.000Z"), + endsAt: new Date("2026-07-01T14:00:00.000Z"), + createdAt: now, + updatedAt: now, + }, + ], + minimumStaffingRules: [ + { + id: BigInt(10), + organizationId: BigInt(1), + dayOfWeek: "WED", + startTime: createTimeDate("12:00"), + endTime: createTimeDate("14:00"), + endsNextDay: false, + requiredCount: 1, + createdAt: now, + updatedAt: now, + }, + ], + organizations: [createOrganization()], + workers: [ + { + id: BigInt(1), + organizationId: BigInt(1), + employeeCode: "W-0001", + name: "김민수", + weeklyContractHours: 40, + }, + ], + }); + const app = createApp({ prisma }); + + const deleteResponse = await request(app) + .delete("/api/staffing-rules/10") + .set("Authorization", authHeader()); + + expect(deleteResponse.status).toBe(204); + + const availabilityResponse = await request(app) + .get("/api/availability") + .set("Authorization", authHeader()) + .query({ + workerId: "1", + startDate: "2026-07-01", + endDate: "2026-07-01", + }); + + expect(availabilityResponse.status).toBe(200); + expect(availabilityResponse.body).toEqual({ + items: [], + }); + }); +}); diff --git a/apps/api/test/workers.routes.test.ts b/apps/api/test/workers.routes.test.ts new file mode 100644 index 0000000..424cc5c --- /dev/null +++ b/apps/api/test/workers.routes.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import request from "supertest"; + +import { createApp } from "@/app"; +import { createAccessToken } from "@/modules/auth/auth.tokens"; +import { createFakePrisma } from "./helpers/fake-prisma"; + +describe("workers routes", () => { + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = "test-access-secret"; + delete process.env.JWT_ACCESS_EXPIRES_IN; + delete process.env.WEB_APP_ORIGIN; + }); + + const authHeader = () => `Bearer ${createAccessToken(BigInt(1))}`; + + const organization = { + id: BigInt(1), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours: [], + }; + + it("requires authentication when listing workers", async () => { + const { prisma } = createFakePrisma({ organizations: [organization] }); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/workers"); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + errorCode: "UNAUTHORIZED", + statusCode: 401, + }); + }); + + it("requires an organization", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/workers").set("Authorization", authHeader()); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + errorCode: "ORGANIZATION_REQUIRED", + statusCode: 403, + }); + }); + + it("lists workers in the authenticated user's organization ordered by employee code", async () => { + const { prisma } = createFakePrisma({ + organizations: [ + organization, + { + id: BigInt(2), + userId: BigInt(2), + name: "다른 조직", + businessHours: [], + }, + ], + workers: [ + { + id: BigInt(1), + organizationId: BigInt(1), + employeeCode: "W-0002", + name: "박준호", + weeklyContractHours: 24, + }, + { + id: BigInt(2), + organizationId: BigInt(1), + employeeCode: "W-0001", + name: "김민지", + weeklyContractHours: 20, + }, + { + id: BigInt(3), + organizationId: BigInt(2), + employeeCode: "W-0001", + name: "이서연", + weeklyContractHours: 16, + }, + ], + }); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/workers").set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + items: [ + { + id: "2", + employeeCode: "W-0001", + name: "김민지", + weeklyContractHours: 20, + }, + { + id: "1", + employeeCode: "W-0002", + name: "박준호", + weeklyContractHours: 24, + }, + ], + }); + }); + + it("rejects invalid create input", async () => { + const { prisma } = createFakePrisma({ organizations: [organization] }); + const app = createApp({ prisma }); + + const response = await request(app) + .post("/api/workers") + .set("Authorization", authHeader()) + .send({ + name: "", + weeklyContractHours: 0, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); +}); diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json index 64f86c6..554f3fe 100644 --- a/apps/api/tsconfig.build.json +++ b/apps/api/tsconfig.build.json @@ -1,4 +1,8 @@ { "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index b408425..febce81 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -7,16 +7,18 @@ "module": "commonjs", "declaration": true, "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "ES2017", "sourceMap": true, "outDir": "./dist", - "rootDir": "./src", + "rootDir": ".", + "paths": { + "@/*": ["./src/*"] + }, "skipLibCheck": true, "esModuleInterop": true, "resolveJsonModule": true, - "types": ["node"] - } + "types": ["jest", "node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore deleted file mode 100644 index 5873d9a..0000000 --- a/apps/mobile/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ - -# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb -# The following patterns were generated by expo-cli - -expo-env.d.ts -# @end expo-cli \ No newline at end of file diff --git a/apps/mobile/CLAUDE.md b/apps/mobile/CLAUDE.md deleted file mode 100644 index 75af78c..0000000 --- a/apps/mobile/CLAUDE.md +++ /dev/null @@ -1,116 +0,0 @@ -# apps/mobile — CLAUDE.md (근로자 모바일 앱) - -## 역할 및 범위 - -**근로자 전용 모바일 애플리케이션**입니다. -희망 근무 신청, 확정 스케줄 조회, 출퇴근 GPS 체크, 근태 서명, 급여 명세서 확인을 담당합니다. -대응 FR: FR-1.2(근로자 계정), FR-2.1(희망 근무 신청), FR-2.4/2.5(스케줄 조회·교환), FR-3.1/3.4/3.6(근태), FR-4.3(명세서), FR-5.4(다중 조직) - -## 기술 스택 - -| 항목 | 기술 | 버전 | -|------|------|------| -| Framework | React Native | 0.79.x | -| Build Toolchain | Expo SDK | 53.x | -| Language | TypeScript | 5.8.x | -| Navigation | Expo Router | 5.x | -| Global State | Zustand | 5.0.x | -| Server State | TanStack Query | 5.74.x | -| Form | React Hook Form | 7.54.x | -| Validation | Zod | 3.24.x | -| Styling | NativeWind | 4.x | -| UI Components | Gluestack UI | v2.x | -| Push 알림 | Expo Notifications | SDK 53 내장 | -| 위치 정보 | Expo Location | SDK 53 내장 | - -## 디렉터리 구조 - -``` -apps/mobile/ -├── app/ # Expo Router 파일 기반 라우팅 -│ ├── (auth)/ # 소셜 로그인 화면 -│ ├── (tabs)/ # 인증 후 탭 네비게이션 -│ │ ├── schedule/ # 스케줄 조회·신청 (FR-2) -│ │ ├── attendance/ # 출퇴근 체크·근태 서명 (FR-3) -│ │ └── payroll/ # 급여 명세서 (FR-4) -│ └── _layout.tsx -├── components/ -│ ├── ui/ # 재사용 범용 컴포넌트 -│ └── features/ # 도메인별 컴포넌트 -├── hooks/ # 커스텀 훅 (useGPS, useAttendance 등) -├── lib/ -│ ├── api/ # API 클라이언트 -│ └── utils/ -└── stores/ # Zustand 스토어 (선택된 조직 등) -``` - -## 주요 규칙 - -### 스타일링 -- **NativeWind 4.x**를 사용합니다. React Native의 `StyleSheet`을 직접 작성하지 않습니다. -- 웹(`apps/web`)과 동일한 Tailwind 클래스 문법을 사용하지만, NativeWind는 지원 클래스 범위가 다릅니다. 레이아웃(`flex`, `gap`, `p-`, `m-`)·타이포그래피·색상 클래스를 우선 사용하고, 웹 전용 클래스(`hover:`, `group-`, CSS Grid 등)는 사용하지 않습니다. -- 디자인 토큰(색상, 폰트 크기 등)은 루트 `tailwind.config.ts`의 `theme.extend`에서 앱·웹이 공유합니다. -- Gluestack UI 컴포넌트는 내부적으로 NativeWind 클래스를 사용합니다. 커스터마이징은 `className` prop으로 처리합니다. - -### UI 컴포넌트 -- **Gluestack UI v2**를 기본 컴포넌트 라이브러리로 사용합니다. -- Button, Input, Modal, Toast 등 기본 UI는 Gluestack 컴포넌트를 사용하고, `components/ui/`에서 re-export하여 import 경로를 통일합니다. -- Gluestack 컴포넌트를 직접 수정하지 않습니다. 커스터마이징이 필요하면 `components/ui/`에 래핑 컴포넌트를 만듭니다. -- Gluestack에 없는 도메인 특화 컴포넌트(출퇴근 버튼, GPS 상태 인디케이터 등)는 `components/features/`에 직접 구현합니다. - -### 공유 패키지 -- **Zod 스키마**는 `packages/shared`에서 가져옵니다. 앱에서 별도로 정의하지 않습니다. -- **GPS 반경 판정 유틸**(`packages/shared/src/utils/gps.ts`)을 사용합니다. 앱에 동일 로직을 중복 구현하지 않습니다. -- **API 요청·응답 타입**은 `packages/shared/src/types/api/`에서 가져옵니다. - -### 네비게이션 -- **Expo Router(파일 기반 라우팅)**를 사용합니다. `react-navigation` API를 직접 사용하지 않습니다. -- 인증 흐름은 `app/(auth)/` 그룹, 메인 앱은 `app/(tabs)/` 그룹으로 분리합니다. -- 딥링크·초대 링크 처리는 Expo Router의 `Linking` 설정으로 구현합니다. - -### GPS 및 권한 -- **Expo Location**으로 현재 위치를 가져옵니다. 출근 체크 전 반드시 권한을 확인합니다. -- 권한 거부 시 기능 명세서(FR-3.1)에 명시된 안내 문구를 표시합니다. -- 위치 정확도: `Accuracy.Balanced` 이상을 사용합니다. 배터리 절약을 위해 백그라운드 추적을 하지 않습니다. -- 매장 반경 판정은 `packages/shared/src/utils/gps.ts`의 유틸을 호출합니다. 앱에 동일 로직을 직접 구현하지 않습니다. - -### Push 알림 -- **Expo Notifications**를 사용합니다. 디바이스 토큰은 로그인 시 API 서버에 등록합니다. -- 앱이 Foreground 상태일 때와 Background/Kill 상태일 때의 핸들러를 각각 분리합니다. -- 알림 수신 후 해당 화면으로 이동하는 딥링크를 설정합니다. - -### 소셜 로그인 -- 카카오·네이버 OAuth는 각 SDK의 Expo 모듈을 사용합니다. -- 로그인 성공 후 JWT는 **Expo SecureStore**에 저장합니다. `AsyncStorage`에 저장하지 마십시오. - -### 상태 관리 -- **Zustand**: 현재 선택된 조직 ID, 모달 상태 등 UI 전역 상태. -- **TanStack Query**: 서버 데이터 (스케줄 목록, 근태 목록, 명세서 등). 오프라인 대응을 위해 `gcTime`을 충분히 설정합니다. - -### 네이티브 모듈 -- EAS Build를 통해 빌드합니다. 네이티브 코드 변경이 필요한 경우 팀에 공유 후 진행합니다. -- `expo-dev-client`를 사용하여 개발 빌드를 실행합니다. - -### 에러 처리 및 UX -- 네트워크 오류 시 사용자 친화적인 토스트/모달 메시지를 표시합니다. -- 기능 명세서의 각 "예외 처리" 항목에 명시된 문구를 그대로 사용합니다. - -## 테스트 - -```bash -pnpm --filter @ilgam/mobile test # 단위 테스트 (Vitest) -``` - -- **Vitest + React Testing Library** (React Native용)을 사용합니다. -- 테스트 파일은 별도 `test/` 디렉터리 없이 대상 파일 옆에 코로케이션합니다. (`useAttendance.ts` → `useAttendance.test.ts`) -- 네이티브 API(Expo Location, Notifications) 모킹 설정은 `apps/mobile/__mocks__/`에 둡니다. -- GPS 권한, 위치 계산, 출근 판정 로직은 반드시 단위 테스트를 작성합니다. - -## 개발 실행 - -```bash -pnpm --filter @ilgam/mobile start # Expo Metro 번들러 시작 -pnpm --filter @ilgam/mobile ios # iOS 시뮬레이터 -pnpm --filter @ilgam/mobile android # Android 에뮬레이터 -pnpm --filter @ilgam/mobile lint -``` diff --git a/apps/mobile/app.json b/apps/mobile/app.json deleted file mode 100644 index 88e47ca..0000000 --- a/apps/mobile/app.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "expo": { - "name": "ilgam", - "slug": "ilgam-app", - "version": "1.0.0", - "scheme": "ilgam", - "orientation": "portrait", - "userInterfaceStyle": "automatic", - "newArchEnabled": true, - "ios": { - "supportsTablet": true, - "bundleIdentifier": "com.ilgam.app" - }, - "android": { - "adaptiveIcon": { - "backgroundColor": "#ffffff" - }, - "package": "com.ilgam.app" - }, - "web": { - "bundler": "metro" - }, - "experiments": { - "typedRoutes": true - }, - "plugins": [ - "expo-router", - "expo-font" - ] - } -} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx deleted file mode 100644 index ced57ee..0000000 --- a/apps/mobile/app/_layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Stack } from 'expo-router' - -export default function RootLayout() { - return -} diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx deleted file mode 100644 index 3c591e8..0000000 --- a/apps/mobile/app/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { View, Text, StyleSheet } from 'react-native' - -export default function HomeScreen() { - return ( - - 일감 앱 - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - text: { - fontSize: 24, - fontWeight: 'bold', - }, -}) diff --git a/apps/mobile/package.json b/apps/mobile/package.json deleted file mode 100644 index 412e97a..0000000 --- a/apps/mobile/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@ilgam/mobile", - "version": "0.1.0", - "private": true, - "main": "expo-router/entry", - "scripts": { - "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", - "web": "expo start --web", - "lint": "expo lint" - }, - "dependencies": { - "expo": "~53.0.27", - "expo-constants": "~17.1.8", - "expo-font": "~13.3.2", - "expo-linking": "~7.1.7", - "expo-router": "~5.1.11", - "expo-splash-screen": "~0.30.10", - "expo-status-bar": "~2.2.3", - "react": "19.0.0", - "react-dom": "19.0.0", - "react-native": "0.79.6", - "react-native-gesture-handler": "~2.24.0", - "react-native-reanimated": "~3.17.5", - "react-native-safe-area-context": "5.4.0", - "react-native-screens": "~4.11.1", - "react-native-web": "^0.20.0" - }, - "devDependencies": { - "@types/react": "~19.0.14", - "typescript": "~5.8.3" - } -} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json deleted file mode 100644 index ec96c84..0000000 --- a/apps/mobile/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "expo/tsconfig.base", - "compilerOptions": { - "strict": true, - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] -} diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 8bd0e39..c153a9b 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -1,5 +1,7 @@ + # This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index 969db76..ec02372 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -1,100 +1,149 @@ -# apps/web — CLAUDE.md (관리자 웹) +# apps/web — CLAUDE.md (운영 웹) ## 역할 및 범위 -**관리자 전용 웹 애플리케이션**입니다. -근로자 초대·등록, 스케줄 확정, 근태 조회·수정, 급여 명세서 발급을 담당합니다. -대응 FR: FR-1(계정·조직), FR-2(스케줄 관리), FR-3(근태 관리), FR-4(급여), FR-5(인력 관리) +**근무 스케줄 관리 전용 웹 애플리케이션**입니다. +MVP에서는 회원가입/로그인, 단일 조직 생성·수정, 인력 관리, 가능 시간 관리, 최소 인원 조건 관리, 스케줄 추천·조정·확정, 확정 스케줄 보관함 조회/export를 담당합니다. + +MVP 범위에서는 별도 앱을 제공하지 않으므로, 운영 웹은 모바일·태블릿·데스크탑에서 사용할 수 있는 반응형 화면으로 구현합니다. ## 기술 스택 -| 항목 | 기술 | 버전 | -|------|------|------| -| Framework | Next.js (App Router) | 15.3.x | -| Language | TypeScript | 5.8.x | -| UI Library | React | 19.1.x | -| Global State | Zustand | 5.0.x | -| Server State | TanStack Query | 5.74.x | -| Form | React Hook Form | 7.54.x | -| Validation | Zod | 3.24.x | -| Styling | Tailwind CSS | 4.1.x | -| UI Components | shadcn/ui | latest (소스 복사 방식) | +기술 스택과 버전은 루트 `docs/stack.md`를 단일 출처로 따른다. 이 문서에는 운영 웹에서 사용하는 기술의 역할과 적용 규칙만 기록한다. + +- Framework: Next.js App Router +- Language: TypeScript +- UI Library: React +- Global State: Zustand +- Server State: TanStack Query +- Form: React Hook Form +- Validation: Zod +- Styling: Tailwind CSS +- UI Components: `@moyeorak/design-system`(1순위) + shadcn/ui(보조) ## 디렉터리 구조 ``` apps/web/ -├── app/ # Next.js App Router 페이지 -│ ├── (auth)/ # 로그인·회원가입 (비인증 레이아웃) -│ ├── (dashboard)/ # 인증 후 레이아웃 -│ │ ├── schedule/ # FR-2 스케줄 관리 -│ │ ├── attendance/ # FR-3 근태 관리 -│ │ ├── payroll/ # FR-4 급여 -│ │ └── members/ # FR-5 인력 관리 -│ └── layout.tsx -├── components/ -│ ├── ui/ # 재사용 가능한 범용 컴포넌트 -│ └── features/ # 도메인별 컴포넌트 (schedule/, attendance/ 등) -├── hooks/ # 커스텀 훅 -├── lib/ -│ ├── api/ # API 클라이언트 함수 -│ └── utils/ # 순수 유틸 함수 -└── stores/ # Zustand 스토어 +└── src/ + ├── app/ # Next.js App Router 페이지 + │ ├── (auth)/ # 로그인·회원가입 (비인증 레이아웃) + │ ├── (admin)/ # 인증 후 운영 레이아웃 + │ │ ├── dashboard/ # 조직 정보, 확정 스케줄 달력 + │ │ ├── organization/ # 단일 조직 생성·수정 + │ │ ├── workers/ # 인력 관리 + │ │ ├── availability/ # 가능 시간 관리 + │ │ ├── staffing-rules/ # 최소 인원 조건 + │ │ ├── schedules/ # 스케줄 추천·조정·확정 + │ │ └── schedule-history/ # 보관함 + │ ├── moyeorak-test/ # 디자인 시스템 확인용 임시 페이지 + │ ├── globals.css + │ └── layout.tsx + ├── components/ + │ ├── ui/ # shadcn/ui 소스 복사 컴포넌트 (@moyeorak/design-system에 없는 것만) + │ ├── layout/ # AdminShell, Sidebar 등 + │ └── common/ # 폼 필드, 상태 배지 등 공통 조합 컴포넌트 + ├── features/ + │ ├── auth/ # 로그인·회원가입 폼 + │ ├── dashboard/ # 대시보드 + │ ├── organization/ # 조직 생성·수정 + │ ├── workers/ # 인력 관리 + │ ├── availability/ # 가능 시간 관리 + │ ├── staffing-rules/ # 최소 인원 조건 + │ ├── schedules/ # 스케줄 추천·조정·확정 + │ └── schedule-history/ # 보관함 + ├── lib/ # 공통 유틸 + └── global.d.ts ``` ## 주요 규칙 ### 라우팅 및 렌더링 + - **App Router**를 사용합니다. `pages/` 디렉터리를 사용하지 마십시오. - 데이터 패칭은 서버 컴포넌트(RSC)에서 초기 로드, 이후 인터랙션은 TanStack Query로 처리합니다. - 클라이언트 컴포넌트는 필요한 최소 범위에만 `"use client"`를 선언합니다. ### 상태 관리 -- **Zustand**: 필터 조건, 선택된 조직 ID, 모달 열림 상태 등 순수 UI 전역 상태에 사용합니다. -- **TanStack Query**: 서버에서 오는 모든 데이터(스케줄 목록, 근태 목록 등)에 사용합니다. 낙관적 업데이트(optimistic update)를 활용합니다. + +- **Zustand**: 필터 조건, 사이드바 상태, 모달 열림 상태 등 순수 UI 전역 상태에 사용합니다. +- **TanStack Query**: 서버에서 오는 모든 데이터(조직 정보, 인력 목록, 가능 시간, 스케줄 목록 등)에 사용합니다. 낙관적 업데이트(optimistic update)를 활용합니다. - 두 상태를 혼용하지 않습니다. 서버 데이터를 Zustand에 저장하지 마십시오. ### 폼 및 유효성 검증 + - 모든 폼은 **React Hook Form + Zod**로 작성합니다. - Zod 스키마는 `packages/shared`에서 가져옵니다. 웹에서 별도로 정의하지 않습니다. ### 스타일링 + - **Tailwind CSS** 유틸리티 클래스만 사용합니다. 인라인 `style` 속성 사용을 지양합니다. -- 디자인 토큰(색상, 간격, 폰트)은 `tailwind.config.ts`에서 중앙 관리합니다. +- 앱 자체 디자인 토큰(색상, 간격, 폰트)은 Tailwind CSS 4의 CSS-first 방식에 맞춰 `src/app/globals.css`의 `@theme`와 CSS 변수에서 중앙 관리합니다. +- `@moyeorak/design-system` 토큰과 컴포넌트 CSS는 `src/app/globals.css`에서 `layers.css`, `tailwindcss`, `tailwind-plugin`, `styles.css` 순서로 불러옵니다. +- `max-w-[440px]`, `h-[52px]`, `px-[18px]`처럼 픽셀 값을 arbitrary value로 직접 넣지 않습니다. Tailwind 기본 scale을 먼저 사용하고, 반복되는 값은 `globals.css`의 token으로 승격합니다. - 컴포넌트 로직과 스타일이 복잡해지면 `variants`(cva) 패턴으로 분리합니다. +### 반응형 기준 + +- 모든 MVP 화면은 모바일, 태블릿, 데스크탑에서 핵심 액션이 가능해야 합니다. +- 모바일에서는 사이드바를 접힘/오버레이 내비게이션으로 사용하고, 본문 콘텐츠가 가로로 잘리지 않게 합니다. +- 테이블, 달력, 타임테이블처럼 폭이 큰 UI는 모바일에서 가로 스크롤 또는 리스트형 대체 레이아웃을 제공합니다. +- 버튼/입력 필드는 모바일에서 터치 가능한 크기를 유지하고, 텍스트가 버튼이나 셀 밖으로 넘치지 않게 합니다. +- 데스크탑 기준으로만 보이는 정보나 액션을 만들지 않습니다. + ### UI 컴포넌트 -- **shadcn/ui**를 기본 컴포넌트 라이브러리로 사용합니다. + +MVP 화면은 아래 우선순위를 적용합니다. + +1. **`@moyeorak/design-system`에 필요한 컴포넌트가 있으면 그것을 사용합니다.** (1순위) +2. 없는 컴포넌트만 **shadcn/ui**를 사용합니다. (보조) +3. 한 화면 안에서 두 라이브러리를 섞어 쓸 수 있습니다. 단, 같은 역할(예: 버튼)에 두 라이브러리 컴포넌트를 동시에 쓰지 않습니다 — 화면 단위로 우선순위 1번을 먼저 확인하고, 없는 요소만 2번으로 채웁니다. + +`@moyeorak/design-system`: + +- npm 패키지로 설치합니다(`pnpm add @moyeorak/design-system@^0.3.0 --filter @fragment/web`). 소스를 복사하지 않습니다. +- 컴포넌트는 패키지에서 직접 import합니다. 커스터마이징이 필요하면 패키지 자체를 수정(별도 레포)하거나, 불가피한 경우에만 `components/common/`에서 한 번 감쌉니다. +- 전역 CSS 진입점(`src/app/globals.css`)에서 아래 순서로 import합니다. `layers.css`는 Tailwind CSS v4와 Panda CSS cascade layer 우선순위를 고정하기 위해 가장 먼저 둡니다. + + ```css + @import "@moyeorak/design-system/layers.css"; + @import "tailwindcss"; + @plugin "@moyeorak/design-system/tailwind-plugin"; + @import "@moyeorak/design-system/styles.css"; + ``` + +- 패키지의 CSS는 Panda CSS 기반 자체 디자인 토큰을 쓰므로, shadcn/Tailwind 토큰(`--primary`, `--foreground` 등)과 색상·spacing이 동일하지 않습니다. 한 화면에 섞어 쓸 때 시각적으로 어긋나지 않는지 확인합니다. +- 버전은 `docs/stack.md`에 명시된 범위를 따릅니다. 메이저/마이너 업그레이드는 팀과 협의 후 `docs/stack.md`를 먼저 업데이트합니다. + +shadcn/ui (보조): + - shadcn/ui는 패키지가 아닌 소스 복사 방식입니다. `components/ui/`에 복사된 파일을 직접 수정해 커스터마이징합니다. - `pnpm dlx shadcn@latest add `로 컴포넌트를 추가합니다. 추가 후 팀에 공유합니다. -- shadcn/ui에 없는 도메인 특화 컴포넌트(스케줄 캘린더, 근태 현황 타임라인 등)는 `components/features/`에 shadcn/ui 컴포넌트를 조합해 구현합니다. +- 두 라이브러리 모두에 없는 도메인 특화 컴포넌트(스케줄 달력, 가능 시간 타임테이블 등)는 `features/` 아래 도메인 컴포넌트로 구현합니다. - shadcn/ui 내부적으로 Radix UI primitives를 사용하므로 별도 접근성(a11y) 처리가 필요하지 않습니다. ### 인증 처리 + - JWT는 HTTP-only 쿠키에 저장합니다. `localStorage`에 저장하지 마십시오. - 미인증 접근은 Next.js middleware에서 `/login`으로 리다이렉트합니다. ### 에러 처리 + - API 에러는 TanStack Query의 `onError` 또는 `error boundary`에서 처리합니다. - 사용자에게 노출되는 에러 메시지는 기능 명세서의 "예외 처리" 항목을 따릅니다. ## 테스트 -```bash -pnpm --filter @ilgam/web test # 단위 테스트 (Vitest) -pnpm --filter @ilgam/web test:coverage # 커버리지 리포트 -``` +Web은 기본적으로 typecheck/lint를 우선하고, 복잡한 유틸 또는 hook을 추가할 때만 테스트를 작성합니다. -- **Vitest + React Testing Library**를 사용합니다. - 테스트 파일은 별도 `test/` 디렉터리 없이 대상 파일 옆에 코로케이션합니다. (`ScheduleCard.tsx` → `ScheduleCard.test.tsx`) - 커스텀 훅은 `renderHook`으로 단독 테스트합니다. -- API 호출은 `msw`로 모킹합니다. -- 커버리지 목표: 비즈니스 로직 훅·유틸 80% 이상. +- API mocking은 필요해지는 테스트 범위가 확정될 때 MSW 도입 여부와 버전을 함께 결정합니다. ## 개발 서버 ```bash -pnpm --filter @ilgam/web dev # http://localhost:3000 -pnpm --filter @ilgam/web build -pnpm --filter @ilgam/web lint +pnpm --filter @fragment/web dev # http://localhost:3000 +pnpm --filter @fragment/web build +pnpm --filter @fragment/web lint ``` diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..5b67bd0 --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,14 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [...compat.extends('next/core-web-vitals', 'next/typescript')]; + +export default eslintConfig; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 874334c..67aa17b 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,23 @@ import type { NextConfig } from "next"; +const apiProxyOrigin = + process.env.API_PROXY_ORIGIN ?? + (process.env.NODE_ENV === "development" ? "http://localhost:3001" : undefined); + const nextConfig: NextConfig = { - transpilePackages: ['@ilgam/shared'], + transpilePackages: ["@fragment/shared"], + ...(apiProxyOrigin + ? { + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${apiProxyOrigin.replace(/\/$/, "")}/api/:path*`, + }, + ]; + }, + } + : {}), }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 1d67411..2b5497c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,23 +1,50 @@ { - "name": "@ilgam/web", + "name": "@fragment/web", "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3000", "build": "next build", - "start": "next start" + "start": "next start", + "lint": "eslint src/", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" }, "dependencies": { + "@fragment/shared": "workspace:*", + "@hookform/resolvers": "^5.4.0", + "@moyeorak/design-system": "^0.3.0", + "@radix-ui/react-alert-dialog": "^1.1.16", + "@radix-ui/react-checkbox": "^1.3.4", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-dropdown-menu": "^2.1.17", + "@radix-ui/react-select": "^2.3.0", + "@radix-ui/react-slider": "^1.4.0", + "@radix-ui/react-slot": "^1.2.5", + "@tanstack/react-query": "^5.101.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.17.0", "next": "~15.3.0", "react": "~19.1.0", - "react-dom": "~19.1.0" + "react-dom": "~19.1.0", + "react-hook-form": "^7.80.0", + "tailwind-merge": "^3.6.0", + "zod": "^3.25.76" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "~15.3.0", + "jsdom": "^29.1.1", "tailwindcss": "~4.1.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.9" } } diff --git a/apps/web/public/favicons/apple-touch-icon-180.png b/apps/web/public/favicons/apple-touch-icon-180.png new file mode 100644 index 0000000..d3ac6bb Binary files /dev/null and b/apps/web/public/favicons/apple-touch-icon-180.png differ diff --git a/apps/web/public/favicons/favicon-128.png b/apps/web/public/favicons/favicon-128.png new file mode 100644 index 0000000..917b40b Binary files /dev/null and b/apps/web/public/favicons/favicon-128.png differ diff --git a/apps/web/public/favicons/favicon-16.png b/apps/web/public/favicons/favicon-16.png new file mode 100644 index 0000000..6deb76f Binary files /dev/null and b/apps/web/public/favicons/favicon-16.png differ diff --git a/apps/web/public/favicons/favicon-256.png b/apps/web/public/favicons/favicon-256.png new file mode 100644 index 0000000..adf998b Binary files /dev/null and b/apps/web/public/favicons/favicon-256.png differ diff --git a/apps/web/public/favicons/favicon-32.png b/apps/web/public/favicons/favicon-32.png new file mode 100644 index 0000000..f799328 Binary files /dev/null and b/apps/web/public/favicons/favicon-32.png differ diff --git a/apps/web/public/favicons/favicon-48.png b/apps/web/public/favicons/favicon-48.png new file mode 100644 index 0000000..643578e Binary files /dev/null and b/apps/web/public/favicons/favicon-48.png differ diff --git a/apps/web/public/favicons/favicon-512.png b/apps/web/public/favicons/favicon-512.png new file mode 100644 index 0000000..e9fa06e Binary files /dev/null and b/apps/web/public/favicons/favicon-512.png differ diff --git a/apps/web/public/favicons/favicon-64.png b/apps/web/public/favicons/favicon-64.png new file mode 100644 index 0000000..2cf9f88 Binary files /dev/null and b/apps/web/public/favicons/favicon-64.png differ diff --git a/apps/web/public/favicons/icon-192.png b/apps/web/public/favicons/icon-192.png new file mode 100644 index 0000000..595c036 Binary files /dev/null and b/apps/web/public/favicons/icon-192.png differ diff --git a/apps/web/public/favicons/icon-512.png b/apps/web/public/favicons/icon-512.png new file mode 100644 index 0000000..c6f7e2d Binary files /dev/null and b/apps/web/public/favicons/icon-512.png differ diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 0000000..160268a --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Fragment 관리자", + "short_name": "Fragment", + "description": "근무 스케줄 운영을 위한 Fragment 관리자 웹", + "start_url": "/", + "display": "standalone", + "background_color": "#151A2E", + "theme_color": "#3C76E9", + "icons": [ + { + "src": "/favicons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/favicons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/apps/web/src/app/(admin)/availability/page.tsx b/apps/web/src/app/(admin)/availability/page.tsx new file mode 100644 index 0000000..eb03772 --- /dev/null +++ b/apps/web/src/app/(admin)/availability/page.tsx @@ -0,0 +1,5 @@ +import { MvpAvailabilityPage } from "@/features/availability/components/mvp-availability-page"; + +export default function AvailabilityPage() { + return ; +} diff --git a/apps/web/src/app/(admin)/dashboard/page.tsx b/apps/web/src/app/(admin)/dashboard/page.tsx new file mode 100644 index 0000000..3e9b1ed --- /dev/null +++ b/apps/web/src/app/(admin)/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { MvpDashboardPage } from "@/features/dashboard/components/mvp-dashboard-page"; + +export default function DashboardPage() { + return ; +} diff --git a/apps/web/src/app/(admin)/layout.tsx b/apps/web/src/app/(admin)/layout.tsx new file mode 100644 index 0000000..c8dca3e --- /dev/null +++ b/apps/web/src/app/(admin)/layout.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +import { AdminShell } from "@/components/layout/admin-shell"; +import { RequireAdminAuth } from "@/features/auth/components/require-admin-auth"; + +export default function AdminLayout({ + children, +}: Readonly<{ + children: ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/app/(admin)/organization/page.tsx b/apps/web/src/app/(admin)/organization/page.tsx new file mode 100644 index 0000000..bbab9fa --- /dev/null +++ b/apps/web/src/app/(admin)/organization/page.tsx @@ -0,0 +1,5 @@ +import { MvpOrganizationEditPage } from "@/features/organization/components/mvp-organization-edit-page"; + +export default function OrganizationPage() { + return ; +} diff --git a/apps/web/src/app/(admin)/schedule-history/page.tsx b/apps/web/src/app/(admin)/schedule-history/page.tsx new file mode 100644 index 0000000..a6fb7c7 --- /dev/null +++ b/apps/web/src/app/(admin)/schedule-history/page.tsx @@ -0,0 +1,5 @@ +import { MvpScheduleHistoryPage } from "@/features/schedule-history/components/mvp-schedule-history-page"; + +export default function ScheduleHistoryPage() { + return ; +} diff --git a/apps/web/src/app/(admin)/schedules/page.tsx b/apps/web/src/app/(admin)/schedules/page.tsx new file mode 100644 index 0000000..74a043c --- /dev/null +++ b/apps/web/src/app/(admin)/schedules/page.tsx @@ -0,0 +1,5 @@ +import { MvpSchedulesPage } from "@/features/schedules/components/mvp-schedules-page"; + +export default function SchedulesPage() { + return ; +} diff --git a/apps/web/src/app/(admin)/staffing-rules/page.tsx b/apps/web/src/app/(admin)/staffing-rules/page.tsx new file mode 100644 index 0000000..2ae1c34 --- /dev/null +++ b/apps/web/src/app/(admin)/staffing-rules/page.tsx @@ -0,0 +1,5 @@ +import { MvpStaffingRulesPage } from "@/features/staffing-rules/components/mvp-staffing-rules-page"; + +export default function StaffingRulesPage() { + return ; +} diff --git a/apps/web/src/app/(admin)/workers/page.tsx b/apps/web/src/app/(admin)/workers/page.tsx new file mode 100644 index 0000000..8d01bf6 --- /dev/null +++ b/apps/web/src/app/(admin)/workers/page.tsx @@ -0,0 +1,5 @@ +import { MvpWorkersPage } from "@/features/workers/components/mvp-workers-page"; + +export default function WorkersPage() { + return ; +} diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..e3b5736 --- /dev/null +++ b/apps/web/src/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +import { RequireGuestAuth } from "@/features/auth/components/require-guest-auth"; + +export default function AuthLayout({ + children, +}: Readonly<{ + children: ReactNode; +}>) { + return {children}; +} diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..c760f5c --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,13 @@ +import { AuthCard } from "@/features/auth/components/auth-card"; +import { AuthPageShell } from "@/features/auth/components/auth-page-shell"; +import { LoginForm } from "@/features/auth/components/login-form"; + +export default function LoginPage() { + return ( + + + + + + ); +} diff --git a/apps/web/src/app/(auth)/signup/page.tsx b/apps/web/src/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..ca02506 --- /dev/null +++ b/apps/web/src/app/(auth)/signup/page.tsx @@ -0,0 +1,13 @@ +import { AuthCard } from "@/features/auth/components/auth-card"; +import { AuthPageShell } from "@/features/auth/components/auth-page-shell"; +import { SignupForm } from "@/features/auth/components/signup-form"; + +export default function SignupPage() { + return ( + + + + + + ); +} diff --git a/apps/web/src/app/(onboarding)/layout.tsx b/apps/web/src/app/(onboarding)/layout.tsx new file mode 100644 index 0000000..91dea92 --- /dev/null +++ b/apps/web/src/app/(onboarding)/layout.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +import { AdminShell } from "@/components/layout/admin-shell"; +import { RequireOnboardingAuth } from "@/features/auth/components/require-onboarding-auth"; + +export default function OnboardingLayout({ + children, +}: Readonly<{ + children: ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/app/(onboarding)/organization/new/page.tsx b/apps/web/src/app/(onboarding)/organization/new/page.tsx new file mode 100644 index 0000000..9fac696 --- /dev/null +++ b/apps/web/src/app/(onboarding)/organization/new/page.tsx @@ -0,0 +1,13 @@ +import { MvpOrganizationForm } from "@/features/organization/components/mvp-organization-form"; +import { OrganizationFormPageShell } from "@/features/organization/components/organization-form-page-shell"; + +export default function NewOrganizationPage() { + return ( + + + + ); +} diff --git a/apps/web/src/app/fonts/PretendardVariable.woff2 b/apps/web/src/app/fonts/PretendardVariable.woff2 new file mode 100644 index 0000000..49c54b5 Binary files /dev/null and b/apps/web/src/app/fonts/PretendardVariable.woff2 differ diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index f1d8c73..85f537a 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1 +1,152 @@ +@import "@moyeorak/design-system/layers.css"; @import "tailwindcss"; +@plugin "@moyeorak/design-system/tailwind-plugin"; +@import "@moyeorak/design-system/styles.css"; + +:root { + color-scheme: light; + + --background: #f9fafb; + --foreground: #111827; + --card: #ffffff; + --card-foreground: #111827; + --popover: #ffffff; + --popover-foreground: #111827; + --primary: #3c76e9; + --primary-hover: #315fd1; + --primary-foreground: #ffffff; + --secondary: #f3f4f6; + --secondary-foreground: #374151; + --muted: #f3f4f6; + --muted-foreground: #6b7280; + --accent: #9dc4ff; + --accent-foreground: #173e83; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #e5e7eb; + --input: #d1d5db; + --ring: #9dc4ff; + --brand-soft: #9dc4ff; + --brand-ink: #151a2e; + + --surface-canvas: #ffffff; + --surface-secondary: #f9fafb; + --surface-tertiary: #f3f4f6; + --surface-sidebar: #111827; + --surface-sidebar-hover: #1f2937; + --surface-sidebar-active: #2563eb; + + --text-ink: #111827; + --text-body: #374151; + --text-muted: #6b7280; + --text-disabled: #9ca3af; + --text-on-dark: #f9fafb; + --text-on-dark-muted: #9ca3af; + + --status-confirmed: #16a34a; + --status-confirmed-subtle: #dcfce7; + --status-pending: #d97706; + --status-pending-subtle: #fef3c7; + --status-cancelled: #dc2626; + --status-cancelled-subtle: #fee2e2; + --status-draft: #7c3aed; + --status-draft-subtle: #ede9fe; + --status-unprocessed: #6b7280; + --status-unprocessed-subtle: #f3f4f6; + + --radius: 0.5rem; + --fragment-radius-xs: 0.25rem; + --fragment-radius-sm: 0.375rem; + --fragment-radius-md: 0.5rem; + --fragment-radius-lg: 0.75rem; + --fragment-radius-xl: 1rem; + --fragment-radius-2xl: 1.25rem; + + --elev-card: 0 1px 3px rgb(0 0 0 / 0.08), 0 1px 2px rgb(0 0 0 / 0.06); + --elev-dropdown: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -1px rgb(0 0 0 / 0.06); + --elev-modal: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 10px 10px -5px rgb(0 0 0 / 0.04); + --elev-sticky: 0 1px 0 rgb(0 0 0 / 0.08); + + --layout-sidebar-width: 15rem; + --layout-topbar-height: 3.5rem; + --layout-content-max: 90rem; + --layout-table-row: 3rem; +} + +@theme inline { + --font-sans: var(--font-pretendard); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-hover: var(--primary-hover); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-brand-soft: var(--brand-soft); + --color-brand-ink: var(--brand-ink); + + --color-surface-canvas: var(--surface-canvas); + --color-surface-secondary: var(--surface-secondary); + --color-surface-tertiary: var(--surface-tertiary); + --color-surface-sidebar: var(--surface-sidebar); + --color-surface-sidebar-hover: var(--surface-sidebar-hover); + --color-surface-sidebar-active: var(--surface-sidebar-active); + + --color-text-ink: var(--text-ink); + --color-text-body: var(--text-body); + --color-text-muted: var(--text-muted); + --color-text-disabled: var(--text-disabled); + --color-text-on-dark: var(--text-on-dark); + --color-text-on-dark-muted: var(--text-on-dark-muted); + + --color-status-confirmed: var(--status-confirmed); + --color-status-confirmed-subtle: var(--status-confirmed-subtle); + --color-status-pending: var(--status-pending); + --color-status-pending-subtle: var(--status-pending-subtle); + --color-status-cancelled: var(--status-cancelled); + --color-status-cancelled-subtle: var(--status-cancelled-subtle); + --color-status-draft: var(--status-draft); + --color-status-draft-subtle: var(--status-draft-subtle); + --color-status-unprocessed: var(--status-unprocessed); + --color-status-unprocessed-subtle: var(--status-unprocessed-subtle); + + --radius-xs: var(--fragment-radius-xs); + --radius-sm: var(--fragment-radius-sm); + --radius-md: var(--fragment-radius-md); + --radius-lg: var(--fragment-radius-lg); + --radius-xl: var(--fragment-radius-xl); + --radius-2xl: var(--fragment-radius-2xl); + + --shadow-card: var(--elev-card); + --shadow-dropdown: var(--elev-dropdown); + --shadow-modal: var(--elev-modal); + --shadow-sticky: var(--elev-sticky); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + html { + @apply h-full; + } + + body { + @apply min-h-full bg-background font-sans text-foreground antialiased; + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 976eb90..caca937 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,20 +1,32 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import localFont from "next/font/local"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); +import { Providers } from "./providers"; -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], +const pretendard = localFont({ + display: "swap", + src: "./fonts/PretendardVariable.woff2", + variable: "--font-pretendard", + weight: "400 700", }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "프래그먼트", + description: "근무 스케줄 편성과 확정 이력 관리를 돕는 도구", + manifest: "/manifest.webmanifest", + icons: { + icon: [ + { url: "/favicons/favicon-16.png", sizes: "16x16", type: "image/png" }, + { url: "/favicons/favicon-32.png", sizes: "32x32", type: "image/png" }, + { url: "/favicons/favicon-48.png", sizes: "48x48", type: "image/png" }, + { url: "/favicons/favicon-64.png", sizes: "64x64", type: "image/png" }, + { url: "/favicons/favicon-128.png", sizes: "128x128", type: "image/png" }, + { url: "/favicons/favicon-256.png", sizes: "256x256", type: "image/png" }, + { url: "/favicons/favicon-512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [{ url: "/favicons/apple-touch-icon-180.png", sizes: "180x180", type: "image/png" }], + }, }; export default function RootLayout({ @@ -23,11 +35,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + + + {children} + ); } diff --git a/apps/web/src/app/moyeorak-test/page.tsx b/apps/web/src/app/moyeorak-test/page.tsx new file mode 100644 index 0000000..2816d7b --- /dev/null +++ b/apps/web/src/app/moyeorak-test/page.tsx @@ -0,0 +1,40 @@ +import { + Badge, + Button, + Chip, + FloatingButton, + Toast, + ToastProvider, + ToastViewport, + version, +} from "@moyeorak/design-system"; + +export default function MoyeorakTestPage() { + return ( + +
+

@moyeorak/design-system v{version}

+ +
+ + + + +
+ +
+ brand-weak + neutral-weak +
+ +
+ brand chip + outlined chip +
+ + +
+ +
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index e4e7756..a452ea1 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,3 +1,5 @@ +import { redirect } from "next/navigation"; + export default function Page() { - return
페이지
; + redirect("/login"); } diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx new file mode 100644 index 0000000..023a759 --- /dev/null +++ b/apps/web/src/app/providers.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { AuthProvider } from "@/features/auth/components/auth-provider"; + +type ProvidersProps = { + children: ReactNode; +}; + +export function Providers({ children }: ProvidersProps) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, + }), + ); + + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/admin/admin-table-section.tsx b/apps/web/src/components/admin/admin-table-section.tsx new file mode 100644 index 0000000..0e74587 --- /dev/null +++ b/apps/web/src/components/admin/admin-table-section.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; + +import { EmptyState } from "@/components/common/empty-state"; +import { cn } from "@/lib/utils"; + +type AdminTableSectionProps = { + title: string; + isEmpty: boolean; + emptyTitle: string; + emptyDescription: string; + children: ReactNode; + tableContainerClassName?: string; +}; + +export function AdminTableSection({ + title, + isEmpty, + emptyTitle, + emptyDescription, + children, + tableContainerClassName, +}: AdminTableSectionProps) { + return ( +
+
+

{title}

+
+ + {isEmpty ? ( + + ) : ( +
{children}
+ )} +
+ ); +} diff --git a/apps/web/src/components/admin/crud-form-dialog.tsx b/apps/web/src/components/admin/crud-form-dialog.tsx new file mode 100644 index 0000000..feec444 --- /dev/null +++ b/apps/web/src/components/admin/crud-form-dialog.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from "react"; + +import { Button } from "@moyeorak/design-system"; +import { X } from "lucide-react"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +type CrudFormDialogProps = { + open: boolean; + title: string; + description: string; + children: ReactNode; + isSaving?: boolean; + onClose: () => void; + onSave: () => void | Promise; +}; + +export function CrudFormDialog({ + open, + title, + description, + children, + isSaving = false, + onClose, + onSave, +}: CrudFormDialogProps) { + return ( + !nextOpen && !isSaving && onClose()}> + +
+ + {title} + {description} + + + 닫기 + +
+ +
{children}
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/components/common/delete-confirm-dialog.tsx b/apps/web/src/components/common/delete-confirm-dialog.tsx new file mode 100644 index 0000000..ace3f22 --- /dev/null +++ b/apps/web/src/components/common/delete-confirm-dialog.tsx @@ -0,0 +1,60 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +type DeleteConfirmDialogProps = { + open: boolean; + title: string; + description: string; + isDeleting?: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void | Promise; +}; + +export function DeleteConfirmDialog({ + open, + title, + description, + isDeleting = false, + onOpenChange, + onConfirm, +}: DeleteConfirmDialogProps) { + return ( + { + if (isDeleting) { + return; + } + + onOpenChange(nextOpen); + }} + > + + + {title} + {description} + + + 취소 + { + event.preventDefault(); + void onConfirm(); + }} + > + {isDeleting ? "삭제 중..." : "삭제"} + + + + + ); +} diff --git a/apps/web/src/components/common/empty-state.tsx b/apps/web/src/components/common/empty-state.tsx new file mode 100644 index 0000000..87157c4 --- /dev/null +++ b/apps/web/src/components/common/empty-state.tsx @@ -0,0 +1,13 @@ +type EmptyStateProps = { + title: string; + description: string; +}; + +export function EmptyState({ title, description }: EmptyStateProps) { + return ( +
+

{title}

+

{description}

+
+ ); +} diff --git a/apps/web/src/components/common/password-input.tsx b/apps/web/src/components/common/password-input.tsx new file mode 100644 index 0000000..a1a7877 --- /dev/null +++ b/apps/web/src/components/common/password-input.tsx @@ -0,0 +1,30 @@ +"use client"; + +import * as React from "react"; +import { Eye, EyeOff } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +type PasswordInputProps = Omit, "type">; + +export function PasswordInput({ className, ...props }: PasswordInputProps) { + const [visible, setVisible] = React.useState(false); + + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/common/row-actions.tsx b/apps/web/src/components/common/row-actions.tsx new file mode 100644 index 0000000..5cc37c0 --- /dev/null +++ b/apps/web/src/components/common/row-actions.tsx @@ -0,0 +1,37 @@ +import { IconButton } from "@moyeorak/design-system"; +import { Pencil, Trash2 } from "lucide-react"; + +type RowActionsProps = { + editLabel?: string; + deleteLabel?: string; + onEdit: () => void; + onDelete: () => void; +}; + +export function RowActions({ + editLabel = "수정", + deleteLabel = "삭제", + onEdit, + onDelete, +}: RowActionsProps) { + return ( +
+ + + + + + +
+ ); +} diff --git a/apps/web/src/components/layout/admin-page-shell.tsx b/apps/web/src/components/layout/admin-page-shell.tsx new file mode 100644 index 0000000..bec49b5 --- /dev/null +++ b/apps/web/src/components/layout/admin-page-shell.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +type AdminPageShellProps = { + title: string; + description: string; + children: ReactNode; + actions?: ReactNode; + containerClassName?: string; + contentClassName?: string; + descriptionClassName?: string; +}; + +export function AdminPageShell({ + title, + description, + children, + actions, + containerClassName, + contentClassName, + descriptionClassName, +}: AdminPageShellProps) { + return ( +
+
+
+
+

{title}

+

+ {description} +

+
+ {actions ?
{actions}
: null} +
+
{children}
+
+
+ ); +} diff --git a/apps/web/src/components/layout/admin-shell.tsx b/apps/web/src/components/layout/admin-shell.tsx new file mode 100644 index 0000000..4f8e0d6 --- /dev/null +++ b/apps/web/src/components/layout/admin-shell.tsx @@ -0,0 +1,33 @@ +"use client"; + +import type { MouseEvent, ReactNode } from "react"; + +import { FloatingButton } from "@moyeorak/design-system"; + +import { AdminSidebar } from "@/components/layout/admin-sidebar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; + +type AdminShellProps = { + children: ReactNode; + sidebarNavigation?: "default" | "hidden"; +}; + +export function AdminShell({ children, sidebarNavigation = "default" }: AdminShellProps) { + function scrollToTop(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + const scrollRoot = document.scrollingElement ?? document.documentElement; + scrollRoot.scrollTo({ top: 0, behavior: "smooth" }); + } + + return ( + + + +
{children}
+
+ +
+ ); +} diff --git a/apps/web/src/components/layout/admin-sidebar.tsx b/apps/web/src/components/layout/admin-sidebar.tsx new file mode 100644 index 0000000..2d0c76d --- /dev/null +++ b/apps/web/src/components/layout/admin-sidebar.tsx @@ -0,0 +1,117 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Archive, CalendarDays, Clock3, Home, ListChecks, LogOut, Users } from "lucide-react"; + +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { useAuthMeQuery, useLogoutMutation } from "@/features/auth/queries/auth-session"; + +type AdminSidebarProps = { + navigation?: "default" | "hidden"; +}; + +const navigationItems = [ + { title: "대시보드", href: "/dashboard", icon: Home }, + { title: "스케줄", href: "/schedules", icon: CalendarDays }, + { title: "보관함", href: "/schedule-history", icon: Archive }, + { title: "근무자 관리", href: "/workers", icon: Users }, + { title: "가능 시간 관리", href: "/availability", icon: Clock3 }, + { title: "최소 인원 조건", href: "/staffing-rules", icon: ListChecks }, +]; + +function isActivePath(pathname: string, href: string) { + return pathname === href || pathname.startsWith(`${href}/`); +} + +export function AdminSidebar({ navigation = "default" }: AdminSidebarProps) { + const pathname = usePathname(); + const { data: authMe } = useAuthMeQuery(); + const logoutMutation = useLogoutMutation(); + + async function handleLogout() { + try { + await logoutMutation.mutateAsync(); + } finally { + window.location.replace("/login"); + } + } + + return ( + + + + + + {navigation === "default" ? ( + + + {navigationItems.map((item) => ( + + ))} + + + ) : ( + + ); +} + +type NavigationItem = { + title: string; + href: string; + icon: typeof Home; +}; + +type NavigationMenuItemProps = { + item: NavigationItem; + pathname: string; +}; + +function NavigationMenuItem({ item, pathname }: NavigationMenuItemProps) { + const Icon = item.icon; + const isActive = isActivePath(pathname, item.href); + + return ( + + + + + + ); +} diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..c08db98 --- /dev/null +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const AlertDialog = AlertDialogPrimitive.Root; +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..58a2e8c --- /dev/null +++ b/apps/web/src/components/ui/badge.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground", + secondary: "bg-secondary text-secondary-foreground", + confirmed: + "bg-status-confirmed-subtle text-status-confirmed", + destructive: + "bg-status-cancelled-subtle text-status-cancelled", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + ...props +}: React.ComponentProps<"span"> & VariantProps) { + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 0000000..4aa6a1e --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary-hover", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-card hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx new file mode 100644 index 0000000..a0b8d18 --- /dev/null +++ b/apps/web/src/components/ui/card.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardContent }; diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..9539efd --- /dev/null +++ b/apps/web/src/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"use client"; + +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..dae9294 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,89 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +}; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..7f9ce04 --- /dev/null +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,260 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 0000000..ce8a9de --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 0000000..4c5e958 --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +