diff --git a/.agents/docs/4_API_RESPONSE_FORMATS.md b/.agents/docs/4_API_RESPONSE_FORMATS.md new file mode 100644 index 0000000..9119a23 --- /dev/null +++ b/.agents/docs/4_API_RESPONSE_FORMATS.md @@ -0,0 +1,280 @@ +# 4. API Response Formats + +캘린더 연동 3종(에브리타임, Google Calendar, iCloud CalDAV)의 요청/응답 형식을 정리한다. +모든 API는 JSON을 기본 응답 형식으로 사용하며, 에러 응답은 `{ "error": string }` 형태로 통일된다. + +--- + +## 공통 타입 + +```typescript +// 요일 코드 — 스케줄링 도메인 전반에서 사용 +type DayCode = "MON" | "TUE" | "WED" | "THU" | "FRI" | "SAT" | "SUN"; + +// 빈 시간 슬롯 — 세 연동 모두 최종적으로 이 형태로 변환됨 +interface TimeSlot { + day: DayCode; + startHour: number; // 0-23 + endHour: number; // 0-23, startHour보다 커야 함 +} +``` + +--- + +## 1. 에브리타임 (Everytime) + +### `POST /api/everytime/timetable` + +에브리타임 공유 URL 또는 ICS 파일로 시간표를 받아 빈 시간(`TimeSlot[]`)으로 변환한다. + +#### 방법 A — 공유 URL + +**Request** +``` +Content-Type: application/json + +{ + "url": "https://everytime.kr/@XXXX" +} +``` + +Query parameter (선택): `?days=MON,WED,FRI` (기본값: 월~금) + +#### 방법 B — ICS 파일 업로드 + +**Request** +``` +Content-Type: multipart/form-data + +file: <.ics 파일> +``` + +#### Response `200` + +```typescript +{ + timetable: { + lectures: Array<{ + name: string; + times: Array<{ + day: 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0=월, 1=화, ..., 6=일 + startMinute: number; // 자정 기준 분 (예: 540 = 09:00) + endMinute: number; // 자정 기준 분 (예: 705 = 11:45) + }>; + }>; + }; + freeSlots: TimeSlot[]; +} +``` + +> **시간 단위**: 에브리타임 API의 `starttime` / `endtime` 값 × 5 = 자정 기준 분 +> 예) `starttime=108` → 108 × 5 = 540분 = 09:00 + +#### 에러 응답 + +| 상태코드 | 사유 | +|---|---| +| `400` | url 필드 누락, ICS 파일 미첨부, 파일 형식 오류 | +| `415` | Content-Type이 json/multipart 외의 값 | +| `422` | 유효하지 않은 에브리타임 URL, ICS 파싱 실패 | +| `500` | 서버 내부 오류 | + +--- + +## 2. Google Calendar + +### `GET /api/google/calendars` + +연결된 Google 계정의 캘린더 목록을 반환한다. + +#### Response `200` + +```typescript +{ + calendars: Array<{ + id: string; + summary: string; + description?: string; + backgroundColor?: string; + primary?: boolean; + accessRole: string; + }>; +} +``` + +--- + +### `GET /api/google/events/query` + +특정 캘린더의 기간별 일정을 조회한다. + +**Query parameters** + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `calendarId` | string | ✅ | 캘린더 ID | +| `startDate` | string | ✅ | ISO 8601 (예: `2026-05-01T00:00:00Z`) | +| `endDate` | string | ✅ | ISO 8601, startDate보다 이후 | + +#### Response `200` + +```typescript +{ + events: Array<{ + id: string; + summary: string; + description?: string; + location?: string; + start: { + dateTime?: string; // ISO 8601 (시간 있는 이벤트) + date?: string; // YYYY-MM-DD (종일 이벤트) + timeZone?: string; + }; + end: { + dateTime?: string; + date?: string; + timeZone?: string; + }; + status: string; + htmlLink?: string; + }>; +} +``` + +--- + +### `POST /api/google/events/create` + +**Request** +```typescript +{ + calendarId: string; + summary: string; // 1-255자 + startDateTime: string; // ISO 8601 (예: "2026-05-14T10:00:00+09:00") + endDateTime: string; // ISO 8601 + timeZone?: string; // 예: "Asia/Seoul" + location?: string; // 최대 500자 + description?: string; // 최대 8000자 +} +``` + +#### Response `201` + +```typescript +{ + event: GoogleEvent; // events/query 응답의 단건 항목과 동일 +} +``` + +#### 에러 응답 + +| 상태코드 | 사유 | +|---|---| +| `401` | Google 계정 미연결 또는 인증 만료 | +| `400` | 필수 파라미터 누락, 날짜 형식 오류 | +| `502` | Google Calendar API 오류 | + +--- + +## 3. iCloud CalDAV + +### `GET /api/icloud/calendars` + +연결된 iCloud 계정의 캘린더 목록을 반환한다. DB 캐시 우선, 없으면 CalDAV 서버에서 조회한다. + +**Query parameter**: `?connectionId=` + +#### Response `200` + +```typescript +{ + calendars: Array<{ + id: string; // DB UUID + displayName: string; + calendarUrl: string; // CalDAV 캘린더 URL + color: string | null; + }>; + cached: boolean; // true: DB 캐시, false: CalDAV 서버에서 새로 조회 +} +``` + +--- + +### `POST /api/icloud/events/query` + +특정 캘린더의 기간별 일정을 조회한다. + +**Request** +```typescript +{ + calendarId: string; // DB UUID + startDate: string; // ISO 8601 + endDate: string; // ISO 8601 +} +``` + +#### Response `200` + +```typescript +{ + events: Array<{ + uid: string; + title: string; + startAt: string; // ISO 8601 + endAt: string; // ISO 8601 + isAllDay: boolean; + location: string | null; + description: string | null; + etag: string; // CalDAV ETag (캐시 검증용) + }>; +} +``` + +--- + +### `POST /api/icloud/events/create` + +**Request** +```typescript +{ + calendarId: string; // DB UUID + title: string; // 1-255자 + startAt: string; // ISO 8601 + endAt: string; // ISO 8601 + location?: string; // 최대 500자 + description?: string; // 최대 2000자 +} +``` + +#### Response `201` + +```typescript +{ + uid: string; // 생성된 이벤트 UID + eventUrl: string; // CalDAV href + etag: string; // CalDAV ETag +} +``` + +#### 에러 응답 + +| 상태코드 | 사유 | +|---|---| +| `401` | 인증 필요 또는 iCloud 인증 만료 | +| `403` | 캘린더 소유권 불일치 | +| `404` | calendarId 없음 | +| `400` | 입력값 검증 실패 (UUID 형식, 날짜 형식 등) | +| `502` | CalDAV 서버 오류 | + +--- + +## 연동별 비교 요약 + +| 항목 | 에브리타임 | Google Calendar | iCloud CalDAV | +|---|---|---|---| +| **인증** | 없음 (공개 URL) | OAuth 2.0 (세션 토큰) | Apple ID + 앱 암호 (세션) | +| **시간 형식** | 분 단위 정수 (자정 기준) | ISO 8601 문자열 | ISO 8601 문자열 | +| **요일 표현** | 0~6 정수 (0=월) | 이벤트 날짜에 포함 | 이벤트 날짜에 포함 | +| **종일 이벤트** | 없음 (수업 시간표) | `date` 필드로 구분 | `isAllDay` 플래그 | +| **반복 일정** | 없음 (주간 고정) | recurrence 지원 | 없음 (개별 이벤트 확장) | +| **최종 출력** | `TimeSlot[]` (자동 변환) | `GoogleEvent[]` | `ParsedEvent[]` | diff --git a/.claude/worktrees/availability-aggregation b/.claude/worktrees/availability-aggregation new file mode 160000 index 0000000..fdbed67 --- /dev/null +++ b/.claude/worktrees/availability-aggregation @@ -0,0 +1 @@ +Subproject commit fdbed6717ea897329dfa003ea2577a6bd7d3f8eb diff --git a/.env b/.env new file mode 100644 index 0000000..d152b92 --- /dev/null +++ b/.env @@ -0,0 +1,14 @@ +# 데이터베이스 및 API (Supabase) +NEXT_PUBLIC_SUPABASE_URL=https://dummy-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key_here +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here + +# 캘린더 연동용 OAuth Client (Google/Apple) +GOOGLE_CLIENT_ID=your_google_client_id_here +GOOGLE_CLIENT_SECRET=your_google_client_secret_here + +# Gemini API (이미지 분석용) +GEMINI_API_KEY=your_gemini_api_key_here + +# 기타 연동 시 필요한 기본 주소 포트 설정 등 +NEXT_PUBLIC_BASE_URL=http://localhost:3000 diff --git a/docs/codex-work-context.md b/docs/codex-work-context.md new file mode 100644 index 0000000..d0ae25f --- /dev/null +++ b/docs/codex-work-context.md @@ -0,0 +1,239 @@ +# MOIM 개발 현황 인수인계 + +작성일: 2026-05-14 + +이 문서는 새 Codex 세션이 `C:\Users\kksu1\Dev\MOIM`에서 바로 맥락을 잡기 위한 작업 현황 노트다. 제품 설명서가 아니라, 현재 어디까지 왔고 무엇을 조심해야 하는지 정리한 handoff 문서다. + +## 먼저 읽을 파일 + +1. `docs/codex-work-context.md` + - 현재 개발 상태, 열린 PR, 다음 작업 순서를 빠르게 파악한다. +2. `docs/user-flow.md` + - 실제 제품 흐름의 기준 문서다. 핵심 방향은 게스트 퍼스트, 초대 링크 문맥 우선이다. +3. `docs/superpowers/specs/2026-04-28-moim-role-based-user-test-prototype-design.md` + - 사용자 테스트용 역할 기반 프로토타입의 승인된 설계 문서다. +4. `docs/superpowers/plans/2026-04-28-moim-role-based-user-test-prototype.md` + - 역할 기반 프로토타입 구현 계획과 검증 흐름이 들어 있다. +5. `prototype/moim.html` + - 현재 사용자 테스트용 단일 HTML 프로토타입의 중심 파일이다. + +## 제품 방향 + +MOIM은 여러 사람이 모임 시간을 정할 때, 주최자가 링크를 만들고 참여자들이 가능한 시간을 제출하면 공통 가능한 시간을 추천해 주는 서비스다. + +현재 가장 중요한 제품 원칙은 다음과 같다. + +- 실제 제품 흐름은 `게스트 퍼스트 + 초대 링크 문맥 우선`이다. +- `/join/:id`처럼 초대 링크로 들어온 사용자는 바로 참여자 흐름으로 들어가야 한다. +- 일반 홈 `/`은 `모임 만들기`와 `초대 링크로 참여`를 중심으로 둔다. +- 로그인, 캘린더 저장, 계정 저장은 첫 관문이 아니라 가치를 경험한 뒤 제안한다. +- 사용자 테스트용 역할 허브는 테스트 장치다. 실제 제품 첫 화면으로 쓰면 안 된다. + +MVP의 최소 흐름은 다음 순서가 기준이다. + +1. 주최자가 모임을 만든다. +2. 초대 링크를 공유한다. +3. 참여자가 로그인 없이 가능한 시간을 제출한다. +4. 주최자가 추천 시간을 확인한다. +5. 주최자가 최종 시간을 확정한다. + +## 현재 repo 상태 + +2026-05-14 확인 기준: + +- repo root: `C:\Users\kksu1\Dev\MOIM` +- 현재 로컬 브랜치: `feat/prototype-user-test` +- 현재 HEAD: `d8e5dd1 feat: 역할 기반 사용자 테스트 프로토타입 개선` +- GitHub remote: `origin https://github.com/Siul49/moim.git` +- 기준 통합 브랜치: `dev` + +현재 작업트리는 깨끗하지 않다. 다음 세션은 아래 변경을 사용자 작업으로 보고 함부로 되돌리면 안 된다. + +수정됨: + +- `.agents/docs/1_DOMAIN_CONTEXT.md` +- `.agents/docs/2_EXECUTION_PLAN.md` + +미추적: + +- `.claude/` +- `docs/provided-documents-summary.md` +- `docs/superpowers/plans/2026-04-27-moim-user-test-prototype.md` +- `docs/user-flow.md` +- `tsconfig.json` +- `docs/codex-work-context.md` + +특히 `.agents/docs/*`와 `.claude/`는 다른 작업 흐름의 산출물일 수 있으므로, 명시 요청 없이 정리하지 않는다. + +## 현재 구현 축 + +### 1. 사용자 테스트용 정적 프로토타입 + +중심 파일: + +- `prototype/moim.html` + +역할 기반 사용자 테스트 프로토타입은 네 관점을 한 파일에서 볼 수 있게 하는 방향이다. + +- 게스트 +- 로그인 유저 +- 모임 만드는 사람 +- 초대받아 참여하는 사람 + +이 역할 허브는 실제 제품의 정보 구조가 아니라 테스트용 진입 장치다. 실제 제품 첫 화면은 `docs/user-flow.md`의 게스트 퍼스트 흐름을 따른다. + +### 2. Next.js 앱 + +프로젝트는 Next.js App Router 기반이다. + +주요 명령: + +```powershell +npm ci +npm test +npm run lint +npm run build +npm run test:e2e +``` + +`package.json` 기준 주요 스크립트: + +- `npm run dev`: Next dev server +- `npm test`: Vitest +- `npm run lint`: Next lint +- `npm run build`: production build +- `npm run test:e2e`: Playwright + +## 열린 PR 현황 + +2026-05-14 확인 기준 열린 PR은 2개다. + +### PR #16 + +- URL: https://github.com/Siul49/moim/pull/16 +- 제목: `feat(scheduling): 캘린더 통합 가용시간 산출 프레임워크 (Date 기반 어댑터)` +- base: `dev` +- head: `feature/15-availability-aggregation` +- GitHub merge 상태: `MERGEABLE`, `CLEAN` +- checks: 통과 +- review decision: `CHANGES_REQUESTED` + +로컬 검증 결과: + +- `npm test`: 16 files, 125 tests 통과 +- `npm run lint`: 통과 +- `npm run build`: 통과 + +머지 판단: + +- 충돌이나 기계적 실패는 없다. +- 다만 CodeRabbit이 남긴 Date 객체 aliasing, Google all-day parsing, manual adapter purity 같은 리뷰는 아직 남아 있다. +- 기반 scheduling 유틸이므로, 바로 머지하기보다는 작은 방어 수정 후 머지하는 편이 좋다. + +우선 확인할 파일: + +- `src/lib/scheduling/time-slot.ts` +- `src/lib/scheduling/free-slots.ts` +- `src/lib/calendar/adapters/google.ts` +- `src/lib/calendar/adapters/manual.ts` + +### PR #21 + +- URL: https://github.com/Siul49/moim/pull/21 +- 제목: `feat: 에브리타임 시간표 조회 기능 구현` +- base: `dev` +- head: `feature/20-everytime-timetable` +- GitHub merge 상태: `MERGEABLE`, `CLEAN` +- checks: 통과 +- review decision: `CHANGES_REQUESTED` + +로컬 검증 결과: + +- `npm test`: 15 files, 123 tests 통과 +- `npm run lint`: 통과 +- `npm run build`: 통과 + +머지 판단: + +- 충돌이나 기계적 실패는 없다. +- 실제 외부 입력을 받는 API라서 머지 전 보강이 더 중요하다. +- 특히 ICS 업로드 크기 제한, 내용 기반 ICS 검증, 시간 값 범위 검증, URL 입력 검증을 먼저 보는 것이 좋다. + +우선 확인할 파일: + +- `src/app/api/everytime/timetable/route.ts` +- `src/lib/everytime/url-scraper.ts` +- `src/lib/everytime/timetable.ts` +- `src/lib/everytime/ics-converter.ts` + +### 두 PR 동시 머지 검증 + +임시 worktree에서 `origin/dev` 위에 PR #16, PR #21을 순서대로 merge 했을 때: + +- merge conflict 없음 +- `npm test`: 21 files, 157 tests 통과 +- `npm run lint`: 통과 +- `npm run build`: 통과 + +즉 통합 자체는 가능하지만, review gate는 아직 남아 있다. + +## 다음 작업 추천 순서 + +1. 현재 작업트리 상태를 다시 확인한다. + +```powershell +git status --short --branch +gh pr list --state open --json number,title,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,url +``` + +2. 사용자가 PR 정리를 원하면 PR #21부터 보는 것을 추천한다. + +이유: + +- Everytime API는 외부 URL, XML, ICS 파일을 직접 다룬다. +- validation 누락이 실제 운영 리스크로 이어질 가능성이 PR #16보다 높다. +- checks는 통과하지만 review decision은 아직 `CHANGES_REQUESTED`다. + +3. PR #21 보강 시 최소 성공 기준: + +- ICS 파일 크기 제한을 둔다. +- 파일명/MIME만 보지 말고 `BEGIN:VCALENDAR`, `END:VCALENDAR` 같은 내용 기반 확인을 한다. +- `startMinute`, `endMinute`, `day`는 finite number, 정수, 범위 조건을 검증한다. +- `timetableToFreeSlots` 오류와 scrape/parse 오류를 구분해서 응답한다. +- `npm test`, `npm run lint`, `npm run build`를 통과시킨다. + +4. PR #16 보강 시 최소 성공 기준: + +- `mergeOverlappingDateSlots`가 입력 Date 객체를 결과에 그대로 공유하지 않게 한다. +- `busyEventsToFree`의 empty busy path도 window Date 객체를 복사해서 반환한다. +- Google all-day parsing의 서버 로컬 timezone 의존성을 줄인다. +- `npm test`, `npm run lint`, `npm run build`를 통과시킨다. + +5. 프로토타입 작업을 이어가면 `prototype/moim.html`과 `e2e/prototype-flow.spec.ts`를 함께 본다. + +현재 역할 기반 프로토타입 문서의 방향은: + +- 첫 화면은 사용자 테스트용 역할 허브 +- 실제 제품 첫 화면은 게스트 퍼스트 +- 참여자는 로그인 없이 제출 가능 +- 주최자는 빠르게 링크를 만들고 공유 가능 +- AI는 시간을 자동 확정하지 않고 추천만 한다 + +## 다음 세션 시작 프롬프트 + +새 세션에서 바로 이어가려면 아래처럼 시작하면 된다. + +```text +C:\Users\kksu1\Dev\MOIM에서 작업해줘. +먼저 docs/codex-work-context.md를 읽고 현재 개발 현황을 파악해. +기존 작업트리에 수정/미추적 파일이 있으니 함부로 되돌리지 말고, git status와 열린 PR 상태를 다시 확인해. +우선순위는 PR #21 Everytime 시간표 기능의 merge-blocking 리뷰 보강이고, 필요하면 PR #16 scheduling 유틸 보강도 이어서 봐줘. +변경 후에는 npm test, npm run lint, npm run build로 검증해. +``` + +## 주의할 점 + +- 이 문서는 2026-05-14 시점의 snapshot이다. PR 상태와 CI 결과는 바뀔 수 있으므로 다음 세션에서 반드시 다시 확인한다. +- 기존 문서 중 일부는 PowerShell 출력에서 mojibake처럼 보일 수 있다. 문서 내용을 판단할 때는 `-Encoding UTF8`로 다시 읽는다. +- 로컬 `node_modules`가 현재 브랜치와 맞지 않으면 테스트가 잘못 실패할 수 있다. PR 검증은 깨끗한 worktree에서 `npm ci`부터 실행하는 편이 안전하다. +- 문서/프로토타입은 한국어 톤을 유지한다. 과한 corporate wording보다 학생 팀이 실제로 설명하는 문장에 가깝게 쓴다. diff --git a/docs/provided-documents-summary.md b/docs/provided-documents-summary.md new file mode 100644 index 0000000..fe14b28 --- /dev/null +++ b/docs/provided-documents-summary.md @@ -0,0 +1,202 @@ +# MOIM 제공 문서 정리 + +작성일: 2026-05-08 + +## 정리 기준 + +이 문서는 현재 `C:\Users\kksu1\Dev\MOIM` 작업트리에서 확인 가능한 사용자 제공/핵심 문서와, 이전 작업에서 다룬 PRD 리뷰 맥락을 한곳에 모은 요약본이다. + +제외한 항목: + +- `node_modules`, `.next`, `.git`, `.claude/worktrees`, 테스트 결과물처럼 생성되거나 중복된 작업 디렉터리 +- 실제 비밀값, 토큰, 개인 인증 정보 + +현재 확인한 결론: + +- `docs/`에는 프로토타입 설계/실행 계획 문서는 있었지만, 사업계획서 DOCX와 PRD 리뷰 맥락까지 한 번에 볼 수 있는 통합 정리본은 없었다. +- 루트의 `MOIM_2026_학생창업유망팀300_사업계획서.docx`는 별도 Markdown 요약으로 정리되어 있지 않았다. +- `.agents/docs/`에는 프로젝트 컨텍스트와 실행 계획이 있으나, 일부 콘솔 출력에서 한글이 깨져 보일 수 있고 `docs/` 하위의 일반 문서로 모여 있지는 않았다. +- `.agents/docs/3_FILE_INDEX.md`에는 `PRD.md` / `PRD.pdf`가 문서 항목으로 언급되지만, 현재 작업트리 루트에서는 해당 파일을 확인하지 못했다. + +## 문서 목록 + +| 문서 | 현재 위치 | 성격 | 상태 | +|---|---|---|---| +| 학생창업유망팀 300+ 사업계획서 | `MOIM_2026_학생창업유망팀300_사업계획서.docx` | 제출용 사업계획서 | 원본 DOCX 존재 | +| 프로젝트 아키텍처 가이드 | `ARCHITECTURE.md` | 개발 구조 설명 | 원본 MD 존재 | +| 협업 컨벤션 가이드 | `convention.md` | 커밋, 브랜치, 이슈, PR 규칙 | 원본 MD 존재 | +| 도메인 컨텍스트 | `.agents/docs/1_DOMAIN_CONTEXT.md` | 에이전트용 프로젝트 맥락 | 수정 중 | +| 실행 계획 | `.agents/docs/2_EXECUTION_PLAN.md` | Phase별 구현 로드맵 | 수정 중 | +| 파일 인덱스 | `.agents/docs/3_FILE_INDEX.md` | 파일 역할 지도 | 원본 MD 존재 | +| 사용자 테스트 프로토타입 설계 | `docs/superpowers/specs/2026-04-27-moim-user-test-prototype-design.md` | 게스트 퍼스트 프로토타입 설계 | 원본 MD 존재 | +| 역할 기반 프로토타입 설계 | `docs/superpowers/specs/2026-04-28-moim-role-based-user-test-prototype-design.md` | 역할 허브 기반 프로토타입 설계 | 원본 MD 존재 | +| 사용자 테스트 프로토타입 실행 계획 | `docs/superpowers/plans/2026-04-27-moim-user-test-prototype.md` | 구현 체크리스트 | 현재 untracked | +| 역할 기반 프로토타입 실행 계획 | `docs/superpowers/plans/2026-04-28-moim-role-based-user-test-prototype.md` | 구현 체크리스트 | 원본 MD 존재 | +| AI calendar assistant PRD 리뷰 맥락 | 현재 원본 파일 미확인 | 제품/수익화/범위 리뷰 | 메모리 기반 맥락만 확인 | + +## 사업계획서 핵심 내용 + +MOIM은 대학생 팀 활동에서 반복되는 일정 조율 문제를 줄이기 위한 AI 캠퍼스 모임 비서다. 사용자는 모임 링크를 만들고, 참여자는 캘린더 연동, 에브리타임 시간표 이미지, 직접 입력 중 편한 방식으로 가능한 시간을 제출한다. 서비스는 각 입력을 통합해 공통 가능 시간을 찾고, 확정 일정을 캘린더에 반영하는 흐름을 목표로 한다. + +핵심 문제: + +- 조별과제, 동아리, 스터디, 공모전 팀에서 카카오톡 대화와 시간표 캡처를 반복 비교해야 한다. +- 수업 시간은 에브리타임에, 개인 일정은 캘린더에 분리되어 있어 일반 캘린더만으로는 빈 시간을 찾기 어렵다. +- 확정 후 각자가 캘린더에 다시 입력해야 해 누락과 지각 가능성이 생긴다. + +핵심 해결 방향: + +- 링크 기반 모임 생성과 카카오톡 공유로 첫 진입 부담을 낮춘다. +- 캘린더 연동을 강제하지 않고, 시간표 이미지 업로드와 직접 입력을 함께 제공한다. +- “가능한 시간”과 “막힌 시간”을 통합해 공통 가능 시간대를 계산한다. +- 확정된 일정은 캘린더 연동 사용자에게 자동 반영하고, 비연동 사용자에게는 추가 가능한 버튼 또는 파일을 제공한다. + +초기 목표 시장: + +- 국내 대학생 중 조별과제, 동아리, 스터디, 공모전, 팀 프로젝트를 자주 수행하는 사용자 +- 팀 활동이 많은 전공, 창업/공모전 팀, 동아리 운영진 +- 카카오톡 단체방 중심으로 일정을 조율하는 사용자 + +사업화 방향: + +- 설치 부담이 낮은 웹 기반 MVP로 시작한다. +- 호스트의 모임 생성, 공유 링크 발급, 참여자의 일정 입력/연동 선택, 공통 가능 시간 확인, 일정 확정 흐름을 먼저 검증한다. +- 외부 API가 완성되기 전에는 더미 일정 데이터로 사용자 흐름과 화면 구조를 검증한다. +- 설문 수치가 충분하지 않은 단계에서는 과장된 수치를 쓰지 않고, 기존 방식과 MOIM 방식의 비교 흐름을 그림으로 보여준다. + +성장 전략: + +- 소속 대학 내 조별과제와 동아리 일정 조율처럼 작은 사용 사례부터 시작한다. +- 한 명이 모임 링크를 만들면 여러 참여자가 동시에 경험하는 공유 구조를 확산 경로로 쓴다. +- 초기 검증 이후 대학 내 조직, 기관, 면접/미팅 조율, 기업용 스케줄링으로 확장 가능성을 검토한다. + +자금/운영 방향: + +- 초기에는 무료 개발 도구와 팀 보유 장비를 활용해 최소 비용으로 검증한다. +- 지원금이 생기면 사용자 테스트, 개인정보/보안 검토, 서버 안정화, 프로토타입 개선에 우선 투입한다. +- 유료 기능은 반복 사용과 문제 해결력이 확인된 뒤 실험한다. + +## PRD/제품 리뷰에서 남은 판단 + +현재 원본 PRD 파일은 작업트리에서 확인되지 않았지만, 이전 리뷰 맥락상 다음 판단이 중요하게 남아 있다. + +- 강점은 한국 대학생의 실제 일정 조율 방식에 맞춘 구체성이다. `Everytime`, `KakaoTalk Share`, 모바일 링크 진입, 캘린더 재입력 문제를 제품 흐름에 반영해야 한다. +- MVP는 너무 많은 자동화와 연동을 한 번에 담으면 위험하다. 첫 검증은 “모임 링크 생성 → 참여자 가능 시간 제출 → 공통 가능 시간 확인”에 집중하는 편이 낫다. +- 수익화는 참여자에게 과금을 걸기보다, 동아리/팀/운영진/관리자에게 반복 일정 관리 편의성을 제공하는 방향이 자연스럽다. +- 검증 지표는 단순 MAU보다 활성화 퍼널을 봐야 한다. 예: 링크 생성률, 초대 링크 클릭률, 참여자 제출 완료율, 공통 시간 도출률, 확정까지 걸린 시간. +- Kakao 공유나 캘린더 반영은 플랫폼 제약을 과장하면 안 된다. 완전 자동화가 확실하지 않은 경우 “사용자 주도 공유/추가” 표현을 유지해야 한다. + +## 프로토타입 설계 문서 핵심 + +### 2026-04-27 사용자 테스트용 프로토타입 + +목적은 최종 제품 UI가 아니라 사용자 테스트다. 처음 쓰는 사용자가 MOIM의 가치를 이해하고, 두 핵심 여정을 혼란 없이 완료하는지 검증한다. + +핵심 여정: + +- 주최자가 모임을 만들고 초대 링크를 공유한다. +- 참여자가 초대 링크를 열고 가능 시간을 제출한다. + +주요 결정: + +- 로그인은 첫 관문이 아니다. 사용자가 먼저 가치를 경험한 뒤, 재방문 편의성이 보이는 순간에 계정 저장을 제안한다. +- 링크 생성 또는 가능 시간 제출 직전에는 작고 닫을 수 있는 저장 안내를 보여준다. +- 완료 후에는 더 명확한 계정 저장 제안을 보여준다. +- 모바일과 데스크톱을 모두 1급 화면으로 다룬다. +- 실제 백엔드 없이 단일 HTML/JS 프로토타입의 시뮬레이션 상태로 구현한다. + +필수 상태: + +- 캘린더 권한 거부와 회복 +- 스케줄 캡처 분석 중/실패 +- 연동 없이 계속하기 +- 참여자 미응답 +- 후보 시간 겹침 없음 +- 계정 저장 제안 닫기 + +### 2026-04-28 역할 기반 프로토타입 + +목적은 팀 리뷰와 사용자 테스트에서 하나의 HTML 파일로 네 관점을 바로 확인하게 하는 것이다. + +역할: + +- 게스트로 보기 +- 로그인 유저로 보기 +- 모임 만드는 사람 +- 초대받아 참여하는 사람 + +핵심 변경: + +- 첫 화면을 역할 선택 허브로 바꾼다. +- 게스트 화면은 로그인 장벽 없이 가치를 설명한다. +- 로그인 유저 화면은 저장된 캘린더, 기본 설정, 최근 모임, 빠른 액션을 보여준다. +- 주최자 흐름은 모임 생성, 링크 공유, 실시간 현황, 추천 시간 확정, 최종 결과까지 포함한다. +- 참여자 흐름은 초대 요약, 캘린더/사진/직접 입력, 가능 시간 검토, 제출, 완료 화면을 포함한다. + +테스트 요구: + +- Playwright로 역할 허브, 게스트 경로, 로그인 유저 경로, 주최자 경로, 참여자 경로, 모바일 CTA 가시성을 검증한다. +- 한국어 문구는 깨지지 않는 UTF-8 텍스트여야 한다. + +## 개발 문서 핵심 + +### 아키텍처 + +기술 스택: + +- `Next.js 14` +- `React` +- `TypeScript` +- `Tailwind CSS v3` +- `shadcn/ui` +- `Vitest` +- `Playwright` +- `Supabase` +- `Vercel` + +구조 원칙: + +- `src/app`은 사용자 플로우 기반 라우팅을 담당한다. +- `src/components/ui`는 범용 UI, `src/components/schedule`은 MOIM 도메인 컴포넌트로 분리한다. +- `src/lib/scheduling`은 React와 분리된 순수 함수 중심의 핵심 로직 영역이다. +- 테스트는 구현 파일 가까이에 두는 코로케이션 방식을 쓴다. +- `Unit`은 핵심 로직, `Integration`은 컴포넌트 상호작용, `E2E`는 사용자 전체 플로우를 맡는다. + +### 협업 컨벤션 + +- 커밋 메시지는 `feat:`, `fix:`, `docs:`, `refactor:`, `design:`, `chore:` 같은 Conventional Commit 타입을 쓴다. +- `main`은 배포 브랜치, `dev`는 개발 통합 브랜치, 기능 작업은 `feature/이슈번호-기능명` 브랜치에서 진행한다. +- 작업 전 GitHub Issue를 만들고, PR에서 관련 이슈를 연결한다. +- PR에는 작업 내용, 변경 이유, 체크리스트, 관련 이슈를 남긴다. +- CI는 lint, test, build, 템플릿 준수 검사를 수행한다. +- 비밀값은 `.env`에 두고, 공유용 껍데기는 `.env.example`에만 둔다. + +## 현재 구현/계획 상태와 연결 + +현재 문서 기준으로 보면 제품 방향은 크게 세 줄로 정리된다. + +1. 사업계획서: 대학생 일정 조율 문제와 시장/사업화 논리 +2. 프로토타입 설계: 사용자 테스트에서 검증할 화면과 여정 +3. 개발 문서: Next.js 기반 구현 구조와 협업 규칙 + +현재 작업트리의 진행상황과 연결하면 다음과 같다. + +- `.agents/docs/2_EXECUTION_PLAN.md`는 Phase 4를 캘린더 연동 구현 진행 중으로 보고 있다. +- 별도 worktree에는 `feature/15-availability-aggregation` 브랜치의 캘린더 통합 가용시간 산출 프레임워크가 존재한다. +- `feat/prototype-user-test` 브랜치에는 프로토타입 사용자 테스트 관련 문서와 계획이 남아 있다. +- `docs/superpowers/plans/2026-04-27-moim-user-test-prototype.md`는 현재 untracked 상태라, 유지할 문서라면 명시적으로 추적 대상으로 추가해야 한다. + +## 남은 문서 정리 이슈 + +- 원본 `PRD.md` 또는 `PRD.pdf`가 실제로 있었다면 현재 작업트리에 없다. 필요하면 원본을 다시 넣고 이 정리본에 “요구사항 원문” 섹션을 추가해야 한다. +- `docs/superpowers/plans/2026-04-27-moim-user-test-prototype.md`는 일부 환경에서 한글이 깨져 보이는 내용이 포함되어 있다. 이 계획을 계속 쓸 거라면 UTF-8 정상 문구로 복구하는 편이 좋다. +- `.agents/docs/1_DOMAIN_CONTEXT.md`와 `.agents/docs/2_EXECUTION_PLAN.md`도 콘솔 출력에서 깨져 보였다. 실제 파일 인코딩/내용을 확인하고, 필요하면 `docs/` 하위의 사람이 읽는 문서와 동기화해야 한다. +- 사업계획서에 들어간 비교/효과 내용은 아직 실제 설문 수치 기반이 아니다. 프로토타입 테스트 뒤 평균 조율 시간, 메시지 수, 제출 완료율 같은 실제 수치로 보완해야 한다. + +## 다음에 문서 업데이트할 때 지킬 기준 + +- 학생 창업 문서는 학생다운 현실적인 문장으로 유지한다. +- 검증되지 않은 수치나 “완전 자동화” 표현은 넣지 않는다. +- 참여자에게 로그인/설치를 강제하지 않는 게 핵심 제품 방향이다. +- 캘린더 연동은 유용한 선택지지만, 직접 입력과 사진 업로드 폴백을 항상 함께 둔다. +- 구현 계획은 실제 브랜치/이슈/테스트 상태와 맞춰 갱신한다. diff --git a/docs/superpowers/plans/2026-04-27-moim-user-test-prototype.md b/docs/superpowers/plans/2026-04-27-moim-user-test-prototype.md new file mode 100644 index 0000000..32a2287 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-moim-user-test-prototype.md @@ -0,0 +1,777 @@ +# MOIM User-Test Prototype Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn `prototype/moim.html` into a guest-first, user-test-ready prototype covering both host meeting creation and participant invite response flows. + +**Architecture:** Keep the current single-file prototype delivery model, but reorganize it around explicit screen states, guest-first entry, reusable save prompts, and simulated recovery/error states. Add a focused Playwright smoke spec that opens the static prototype via `file://` and verifies the most important host and participant paths across desktop and mobile. + +**Tech Stack:** Static HTML/CSS/JavaScript in `prototype/moim.html`, Playwright in `e2e/prototype-flow.spec.ts`, existing npm scripts and Playwright configuration. + +--- + +## File Structure + +- Modify `prototype/moim.html`: primary prototype UI, styling, state, simulated interactions, responsive behavior. +- Create `e2e/prototype-flow.spec.ts`: smoke coverage for guest-first home, host link creation, participant availability submission, save prompt dismissal, and mobile CTA visibility. +- Reference `docs/superpowers/specs/2026-04-27-moim-user-test-prototype-design.md`: approved design source of truth. + +The local `dev` branch currently has the approved spec commit, while `origin/dev` contains the first `prototype/moim.html` commit. Start by merging `origin/dev` into the local branch so the implementation includes both the spec and the prototype file. + +--- + +### Task 1: Synchronize Prototype Baseline + +**Files:** +- Modify through git merge: `prototype/moim.html` +- Preserve unrelated working changes: `.agents/docs/1_DOMAIN_CONTEXT.md`, `.agents/docs/2_EXECUTION_PLAN.md` + +- [ ] **Step 1: Confirm branch divergence and dirty files** + +Run: + +```powershell +git status --short --branch +git log --oneline --left-right --graph dev...origin/dev +``` + +Expected: + +```text +## dev...origin/dev [ahead 1, behind 1] + M .agents/docs/1_DOMAIN_CONTEXT.md + M .agents/docs/2_EXECUTION_PLAN.md +< c7a0277 docs: 사용자 테스트 프로토타입 설계 문서 추가 +> f289732 feat: HTML 프로토타입 초기 작업물 추가 +``` + +- [ ] **Step 2: Merge the remote prototype commit** + +Run: + +```powershell +git merge --no-edit origin/dev +``` + +Expected: merge succeeds and adds `prototype/moim.html`. If git reports that `.agents/docs/*` would be overwritten, stop and ask the user before touching those files. + +- [ ] **Step 3: Verify prototype file exists** + +Run: + +```powershell +Test-Path prototype\moim.html +git ls-files prototype/moim.html +``` + +Expected: + +```text +True +prototype/moim.html +``` + +- [ ] **Step 4: Inspect baseline screen IDs** + +Run: + +```powershell +Select-String -Path prototype\moim.html -Pattern 'id="s-|function go|function loginEmail|function submitP' +``` + +Expected: output includes the existing screen IDs `s-landing`, `s-calendar-setup`, `s-main`, `s-host-create`, `s-host-dates`, `s-host-share`, `s-status`, `s-participant`, `s-confirm`, `s-result`, `s-profile`. + +--- + +### Task 2: Add Failing Prototype Smoke Tests + +**Files:** +- Create: `e2e/prototype-flow.spec.ts` + +- [ ] **Step 1: Write static prototype Playwright tests** + +Create `e2e/prototype-flow.spec.ts` with this content: + +```ts +import { expect, test } from "@playwright/test"; +import { pathToFileURL } from "node:url"; +import path from "node:path"; + +const prototypeUrl = pathToFileURL( + path.join(process.cwd(), "prototype", "moim.html"), +).toString(); + +test.describe("MOIM 사용자 테스트용 HTML 프로토타입", () => { + test("홈은 로그인 게이트보다 모임 만들기와 초대 링크 참여를 먼저 보여준다", async ({ + page, + }) => { + await page.goto(prototypeUrl); + + await expect(page.getByRole("heading", { name: "모두의 빈 시간을 링크 하나로 찾기" })).toBeVisible(); + await expect(page.getByRole("button", { name: "모임 만들기" })).toBeVisible(); + await expect(page.getByRole("button", { name: "초대 링크로 참여" })).toBeVisible(); + await expect(page.getByText("로그인하면 다음부터 캘린더 연동 없이 시작할 수 있어요")).toBeVisible(); + }); + + test("주최자는 로그인 없이 모임을 만들고 초대 링크를 생성할 수 있다", async ({ page }) => { + await page.goto(prototypeUrl); + + await page.getByRole("button", { name: "모임 만들기" }).click(); + await expect(page.getByRole("heading", { name: "모임 기본 정보" })).toBeVisible(); + await page.getByRole("textbox", { name: "모임 이름" }).fill("조별과제 회의"); + await page.getByRole("button", { name: "다음" }).click(); + + await expect(page.getByRole("heading", { name: "내 일정 반영 방식" })).toBeVisible(); + await page.getByRole("button", { name: "연동 없이 계속" }).click(); + await page.getByRole("button", { name: "후보 시간 선택으로 이동" }).click(); + + await expect(page.getByRole("heading", { name: "후보 날짜와 시간" })).toBeVisible(); + await page.getByRole("button", { name: "링크 생성" }).click(); + + await expect(page.getByText("계정에 저장하지 않아도 링크를 만들 수 있어요")).toBeVisible(); + await page.getByRole("button", { name: "계속해서 링크 생성" }).click(); + + await expect(page.getByRole("heading", { name: "초대 링크가 만들어졌어요" })).toBeVisible(); + await expect(page.getByText("moim.ai/join/abc123xyz")).toBeVisible(); + await expect(page.getByText("다음 모임은 바로 만들 수 있어요")).toBeVisible(); + }); + + test("참여자는 로그인 없이 초대 링크에서 가능 시간을 제출할 수 있다", async ({ page }) => { + await page.goto(`${prototypeUrl}#join`); + + await expect(page.getByRole("heading", { name: "이 모임에 참여할 수 있는 시간을 알려주세요" })).toBeVisible(); + await expect(page.getByText("로그인 없이 제출 가능")).toBeVisible(); + await page.getByRole("button", { name: "직접 입력" }).click(); + + await expect(page.getByRole("heading", { name: "가능 시간 확인" })).toBeVisible(); + await page.getByRole("button", { name: "화 4/15 14:00 가능" }).click(); + await page.getByRole("button", { name: "제출하기" }).click(); + + await expect(page.getByText("계정 없이도 제출할 수 있어요")).toBeVisible(); + await page.getByRole("button", { name: "그냥 제출" }).click(); + + await expect(page.getByRole("heading", { name: "가능 시간이 제출됐어요" })).toBeVisible(); + await expect(page.getByText("다음 초대에서도 자동으로 응답하려면 저장할까요?")).toBeVisible(); + }); + + test("참여자는 사진 분석 실패 후 직접 입력으로 회복할 수 있다", async ({ page }) => { + await page.goto(`${prototypeUrl}#join`); + + await page.getByRole("button", { name: "사진 업로드" }).click(); + await expect(page.getByText("AI가 시간표를 분석하고 있어요")).toBeVisible(); + await page.getByRole("button", { name: "분석 실패 상태 보기" }).click(); + await expect(page.getByText("시간표를 읽지 못했어요")).toBeVisible(); + await page.getByRole("button", { name: "직접 입력으로 계속" }).click(); + await expect(page.getByRole("heading", { name: "가능 시간 확인" })).toBeVisible(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail against the baseline** + +Run: + +```powershell +npx playwright test e2e/prototype-flow.spec.ts --project=chromium +``` + +Expected: FAIL because the baseline home is login-first and does not expose the new headings/buttons. + +--- + +### Task 3: Implement Guest-First Home And Entry Routing + +**Files:** +- Modify: `prototype/moim.html` +- Test: `e2e/prototype-flow.spec.ts` + +- [ ] **Step 1: Replace the login-first landing screen** + +In `prototype/moim.html`, replace the visible contents of `#s-landing` with a guest-first screen that exposes these accessible targets: + +```html +

MOIM

+

모두의 빈 시간을 링크 하나로 찾기

+

캘린더 연동, 사진 업로드, 직접 입력 중 편한 방식으로 모임 시간을 빠르게 맞춰요.

+ + + + +``` + +- [ ] **Step 2: Add entry state helpers** + +Add these functions near the existing navigation helpers: + +```html + +``` + +If `prototypeState` conflicts with script placement, keep one global object and do not duplicate it. + +- [ ] **Step 3: Route `#join` directly to participant flow** + +In the `DOMContentLoaded` handler, add: + +```js +if (window.location.hash === "#join") { + startParticipantFlow(); +} else { + go("s-landing", false); +} +``` + +Expected behavior: opening `prototype/moim.html#join` shows the participant invite screen. + +- [ ] **Step 4: Run the home test** + +Run: + +```powershell +npx playwright test e2e/prototype-flow.spec.ts --project=chromium -g "홈은 로그인 게이트" +``` + +Expected: PASS. + +--- + +### Task 4: Rework Host Flow For Optional Calendar Setup And Link Creation + +**Files:** +- Modify: `prototype/moim.html` +- Test: `e2e/prototype-flow.spec.ts` + +- [ ] **Step 1: Rename and simplify host meeting basics** + +In `#s-host-create`, make the screen heading accessible as: + +```html +

모임 기본 정보

+``` + +Ensure the meeting-name field has a real label: + +```html + + +``` + +Keep duration, participant count, and purpose chips from the existing prototype. + +- [ ] **Step 2: Send the host to calendar setup after basics** + +Change the primary next button in `#s-host-create` to: + +```html + +``` + +- [ ] **Step 3: Reframe calendar setup as optional** + +In `#s-calendar-setup`, change the heading to: + +```html +

내 일정 반영 방식

+

캘린더를 연동하면 자동으로 막힌 시간이 반영돼요. 지금은 건너뛰고 직접 입력해도 됩니다.

+``` + +Add explicit recovery buttons: + +```html + + + +``` + +- [ ] **Step 4: Add calendar setup state functions** + +Add: + +```js +function simulateCalendarRejected() { + prototypeState.calendarState = "rejected"; + const notice = document.getElementById("calendar-state-notice"); + if (notice) { + notice.className = "notice notice-red"; + notice.textContent = "캘린더 권한이 거부됐어요. 다시 시도하거나 직접 입력으로 계속할 수 있어요."; + } +} + +function skipCalendarSetup() { + prototypeState.calendarState = "skipped"; + const notice = document.getElementById("calendar-state-notice"); + if (notice) { + notice.className = "notice notice-purple"; + notice.textContent = "연동 없이 계속합니다. 후보 시간은 직접 조정할 수 있어요."; + } +} +``` + +Also add a target element inside `#s-calendar-setup`: + +```html +
로그인 없이도 계속할 수 있어요.
+``` + +- [ ] **Step 5: Add pre-link soft save prompt** + +In the link generation action from `#s-host-dates`, show a soft prompt instead of navigating immediately: + +```js +function showHostLinkSavePrompt() { + prototypeState.authSavePrompt = "soft"; + const modal = document.getElementById("soft-save-modal"); + modal.querySelector("[data-save-title]").textContent = "계정에 저장하지 않아도 링크를 만들 수 있어요"; + modal.querySelector("[data-save-body]").textContent = "저장하면 다음 모임부터 캘린더 연동을 다시 하지 않아도 됩니다."; + modal.querySelector("[data-save-primary]").textContent = "저장하고 링크 생성"; + modal.querySelector("[data-save-secondary]").textContent = "계속해서 링크 생성"; + modal.querySelector("[data-save-secondary]").onclick = finishHostLinkCreation; + modal.style.display = "flex"; +} + +function finishHostLinkCreation() { + document.getElementById("soft-save-modal").style.display = "none"; + prototypeState.authSavePrompt = "strong"; + prototypeState.meetingState = "linkCreated"; + go("s-host-share"); +} +``` + +Add a reusable modal near the end of `body`: + +```html + +``` + +- [ ] **Step 6: Update host share completion copy** + +In `#s-host-share`, use: + +```html +

초대 링크가 만들어졌어요

+ +
+

다음 모임은 바로 만들 수 있어요

+

이 캘린더 연동 정보를 계정에 저장하면 다음부터 같은 설정을 반복하지 않아도 됩니다.

+ + +
+``` + +Add: + +```js +function dismissStrongSavePrompt() { + prototypeState.authSavePrompt = "dismissed"; + showToast("저장하지 않고 계속합니다"); +} +``` + +- [ ] **Step 7: Run host flow test** + +Run: + +```powershell +npx playwright test e2e/prototype-flow.spec.ts --project=chromium -g "주최자는 로그인 없이" +``` + +Expected: PASS. + +--- + +### Task 5: Rework Participant Invite And Availability Submission Flow + +**Files:** +- Modify: `prototype/moim.html` +- Test: `e2e/prototype-flow.spec.ts` + +- [ ] **Step 1: Replace participant entry hierarchy** + +In `#s-participant`, ensure the main heading and reassurance are: + +```html +

이 모임에 참여할 수 있는 시간을 알려주세요

+

조별과제 회의 · 1시간 · 후보 3일 · 마감 4/16

+로그인 없이 제출 가능 +``` + +Add three response method buttons: + +```html + + + +``` + +- [ ] **Step 2: Add participant upload failure recovery** + +Add a participant upload state panel: + +```html + +``` + +Add functions: + +```js +function participantUploadPhoto() { + prototypeState.uploadState = "analyzing"; + const panel = document.getElementById("participant-upload-state"); + panel.style.display = "block"; + panel.innerHTML = ` +

AI가 시간표를 분석하고 있어요

+

수업 블록과 고정 일정을 찾는 중입니다.

+ + `; +} + +function participantUploadFailed() { + prototypeState.uploadState = "failed"; + const panel = document.getElementById("participant-upload-state"); + panel.innerHTML = ` +

시간표를 읽지 못했어요

+

이미지가 흐리거나 표 영역이 잘렸을 수 있어요.

+ + + `; +} +``` + +- [ ] **Step 3: Build availability review screen** + +Use the existing `#s-confirm` as the participant review screen. Set its heading and controls: + +```html +

가능 시간 확인

+

제출 전에 가능한 시간을 확인하고 수정할 수 있어요.

+
+ + + +
+ +``` + +Add: + +```js +function participantManualInput() { + prototypeState.availabilityState = "edited"; + go("s-confirm"); +} + +function participantConnectCalendar() { + prototypeState.calendarState = "connected"; + prototypeState.availabilityState = "detected"; + showToast("캘린더에서 가능한 시간이 감지됐어요"); + go("s-confirm"); +} + +function toggleAvailabilityCell(button) { + const pressed = button.getAttribute("aria-pressed") === "true"; + button.setAttribute("aria-pressed", String(!pressed)); + button.classList.toggle("on", !pressed); +} +``` + +- [ ] **Step 4: Add participant pre-submit soft save prompt** + +Add: + +```js +function showParticipantSubmitSavePrompt() { + prototypeState.authSavePrompt = "soft"; + const modal = document.getElementById("soft-save-modal"); + modal.querySelector("[data-save-title]").textContent = "계정 없이도 제출할 수 있어요"; + modal.querySelector("[data-save-body]").textContent = "저장하면 다음 초대에서 캘린더를 다시 연동하지 않아도 됩니다."; + modal.querySelector("[data-save-primary]").textContent = "저장하고 제출"; + modal.querySelector("[data-save-secondary]").textContent = "그냥 제출"; + modal.querySelector("[data-save-secondary]").onclick = finishParticipantSubmit; + modal.style.display = "flex"; +} + +function finishParticipantSubmit() { + document.getElementById("soft-save-modal").style.display = "none"; + prototypeState.availabilityState = "submitted"; + prototypeState.authSavePrompt = "strong"; + go("s-result"); +} +``` + +- [ ] **Step 5: Update participant completion screen** + +In `#s-result`, use: + +```html +

가능 시간이 제출됐어요

+

주최자가 시간을 확정하면 알려드릴게요.

+
+

다음 초대에서도 자동으로 응답하려면 저장할까요?

+

캘린더 연동과 기본 정보를 저장하면 다음 모임에서는 더 빠르게 제출할 수 있어요.

+ + +
+``` + +- [ ] **Step 6: Run participant flow tests** + +Run: + +```powershell +npx playwright test e2e/prototype-flow.spec.ts --project=chromium -g "참여자는" +``` + +Expected: both participant tests PASS. + +--- + +### Task 6: Responsive Polish And Visual Test Pass + +**Files:** +- Modify: `prototype/moim.html` +- Test: `e2e/prototype-flow.spec.ts` + +- [ ] **Step 1: Add responsive layout rules** + +Add or update the responsive CSS near the existing layout styles: + +```css +.entry-actions, +.method-grid, +.availability-review-grid { + display: grid; + gap: 10px; +} + +.method-grid { + grid-template-columns: 1fr; +} + +.availability-review-grid { + grid-template-columns: 1fr; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 16px; + background: rgba(23, 23, 23, 0.24); +} + +.modal-card { + width: 100%; + max-width: 420px; +} + +.availability-cell { + min-height: 48px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: white; + font: inherit; + font-weight: 700; + color: var(--text-heading); +} + +.availability-cell.on, +.availability-cell[aria-pressed="true"] { + border-color: var(--primary-500); + background: var(--primary-100); + color: var(--primary-600); +} + +@media (min-width: 768px) { + .entry-actions { + grid-template-columns: 1fr 1fr; + } + + .method-grid { + grid-template-columns: repeat(3, 1fr); + } + + .availability-review-grid { + grid-template-columns: repeat(3, 1fr); + } + + .modal-backdrop { + align-items: center; + } +} + +@media (max-width: 520px) { + .bottom-bar { + padding-bottom: max(12px, env(safe-area-inset-bottom)); + } + + .container, + .container-wide { + padding-left: 16px; + padding-right: 16px; + } +} +``` + +- [ ] **Step 2: Ensure bottom CTAs do not hide content** + +For screens with `.bottom-bar`, ensure the main container has: + +```html +style="padding-bottom:120px" +``` + +Expected: the final input/control above the sticky CTA remains visible on mobile. + +- [ ] **Step 3: Run full prototype tests on desktop and mobile** + +Run: + +```powershell +npx playwright test e2e/prototype-flow.spec.ts --project=chromium +npx playwright test e2e/prototype-flow.spec.ts --project="Mobile Safari" +``` + +Expected: all tests PASS in both projects. + +- [ ] **Step 4: Check console errors manually** + +Open `prototype/moim.html` in the in-app browser or Playwright and inspect console logs. + +Expected: no uncaught JavaScript errors. + +--- + +### Task 7: Manual Visual Verification And Commit + +**Files:** +- Modify: `prototype/moim.html` +- Create: `e2e/prototype-flow.spec.ts` + +- [ ] **Step 1: Capture desktop screenshots** + +Use the in-app browser or Playwright screenshots for: + +```text +home +host basic info +host calendar setup +host link created +participant invite +participant review +participant submitted +``` + +Expected: each screen clearly shows the current step, primary CTA, and optional auth-save guidance where relevant. + +- [ ] **Step 2: Capture mobile screenshots** + +Use a mobile-width viewport or Mobile Safari project for the same screens. + +Expected: no CTA overlaps content, no text is clipped, tap targets are visually large enough. + +- [ ] **Step 3: Run final verification** + +Run: + +```powershell +npm run test +npx playwright test e2e/prototype-flow.spec.ts --project=chromium +npx playwright test e2e/prototype-flow.spec.ts --project="Mobile Safari" +git status --short +``` + +Expected: + +```text +vitest tests pass +prototype Playwright tests pass +git status shows only intended files plus pre-existing .agents/docs changes +``` + +- [ ] **Step 4: Commit implementation** + +Stage only intended implementation files: + +```powershell +git add -- prototype/moim.html e2e/prototype-flow.spec.ts +git commit -m "feat: 사용자 테스트용 프로토타입 개선" +``` + +Expected: commit succeeds. Do not stage `.agents/docs/1_DOMAIN_CONTEXT.md` or `.agents/docs/2_EXECUTION_PLAN.md`. + +--- + +## Self-Review + +Spec coverage: + +- Guest-first home is covered by Task 3. +- Host first-use journey is covered by Task 4. +- Participant invite response journey is covered by Task 5. +- Soft and strong auth prompts are covered by Tasks 4 and 5. +- Main error and recovery states are covered by Tasks 4 and 5. +- Mobile and desktop responsiveness are covered by Task 6. +- Manual visual/browser verification is covered by Task 7. + +Placeholder scan: + +- No unresolved placeholder markers or unspecified future work remains in this plan. +- Each implementation step names concrete files, functions, commands, and expected results. + +Type and naming consistency: + +- The shared state object is consistently named `prototypeState`. +- Save prompt states use `hidden`, `soft`, `strong`, and `dismissed`. +- Calendar states use `none`, `connected`, `rejected`, and `skipped`. +- Upload states use `idle`, `analyzing`, `success`, and `failed`. +- Availability states use `empty`, `detected`, `edited`, and `submitted`. diff --git a/docs/user-flow.md b/docs/user-flow.md new file mode 100644 index 0000000..be2a8ae --- /dev/null +++ b/docs/user-flow.md @@ -0,0 +1,323 @@ +# MOIM 사용자 흐름 문서 + +작성일: 2026-05-08 + +## 결론 + +MOIM의 실제 제품 흐름은 **게스트 퍼스트 + 초대 링크 문맥 우선 흐름**이 가장 적합하다. + +즉, 사용자가 어떤 경로로 들어왔는지에 따라 첫 화면을 다르게 보여준다. + +- `/join/:id`처럼 초대 링크로 들어온 사용자는 곧바로 참여자 흐름으로 보낸다. +- 일반 홈으로 들어온 사용자는 `모임 만들기`와 `초대 링크로 참여`를 먼저 보여준다. +- 로그인, 회원가입, 캘린더 저장은 첫 관문이 아니라 사용자가 가치를 경험한 뒤 제안한다. + +사용자 테스트용 프로토타입에서는 `게스트`, `로그인 유저`, `주최자`, `참여자`를 바로 고를 수 있는 역할 허브가 유용하다. 다만 이 역할 허브는 테스트 장치이지, 실제 제품 첫 화면으로 쓰면 안 된다. + +## 전체 흐름 + +```mermaid +flowchart TD + A["사용자 진입"] --> B{"진입 경로"} + B -->|"초대 링크 /join/:id"| C["참여자 초대 화면"] + B -->|"일반 홈 /"| D["게스트 홈"] + B -->|"로그인 재방문"| E["내 MOIM 대시보드"] + + D --> F["모임 만들기"] + D --> C + E --> F + E --> G["최근 모임 현황 보기"] + + F --> H["모임 기본 정보 입력"] + H --> I["캘린더/시간표 반영 방식 선택"] + I --> J["후보 날짜와 시간 범위 선택"] + J --> K["초대 링크 생성"] + K --> L["KakaoTalk 등으로 공유"] + L --> G + + C --> M["모임 요약 확인"] + M --> N["가능 시간 입력 방식 선택"] + N --> O["가능 시간 검토/수정"] + O --> P["가능 시간 제출"] + P --> Q["제출 완료 및 선택적 계정 저장"] + + G --> R["응답 현황 확인"] + R --> S["추천 시간 확인"] + S --> T["모임 시간 확정"] + T --> U["캘린더 추가 / 확정 카드 공유"] +``` + +## 실제 제품 권장 흐름 + +### 1. 일반 홈 진입 + +목표는 사용자가 MOIM의 가치를 5초 안에 이해하고 바로 행동하게 만드는 것이다. + +화면 구성: + +- 브랜드: `MOIM` +- 한 줄 가치: `모두의 빈 시간을 링크 하나로 찾기` +- 주요 CTA: `모임 만들기` +- 보조 CTA: `초대 링크로 참여` +- 작은 안내: `로그인하면 다음 모임은 더 빠르게 시작할 수 있어요` + +로그인 버튼은 첫 화면의 중심이 되면 안 된다. MOIM은 계정 서비스가 아니라 일정 조율 문제를 해결하는 도구이기 때문이다. + +### 2. 주최자 흐름 + +주최자는 “회의를 만들고 링크를 보내는 사람”이다. 이 사용자는 빠르게 공유 가능한 링크를 얻고 싶어 한다. + +권장 순서: + +1. `모임 만들기` 선택 +2. 모임 기본 정보 입력 +3. 캘린더/시간표 반영 방식 선택 +4. 후보 날짜와 시간 범위 선택 +5. 초대 링크 생성 +6. 링크 공유 +7. 실시간 현황 확인 +8. 추천 시간 확인 +9. 시간 확정 +10. 확정 일정 공유 또는 캘린더 추가 + +모임 기본 정보: + +- 모임 이름 +- 예상 소요 시간 +- 예상 참여 인원 +- 모임 목적 + +캘린더/시간표 반영 방식: + +- Google Calendar 연동 +- Apple/iCloud Calendar 연동 +- 시간표 이미지 업로드 +- `.ics` 파일 업로드 +- 연동 없이 직접 입력 또는 건너뛰기 + +중요한 원칙: + +- 캘린더 연동은 유용한 선택지지만 필수 조건이 아니다. +- 권한 거부, 연동 실패, 사진 분석 실패가 있어도 링크 생성까지 갈 수 있어야 한다. +- 링크 생성 전 회원가입을 요구하면 공유가 늦어지고 이탈 가능성이 커진다. + +### 3. 참여자 흐름 + +참여자는 “초대 링크를 받고 가능한 시간을 알려주는 사람”이다. 이 사용자는 보통 KakaoTalk 같은 모바일 맥락에서 들어온다. + +권장 순서: + +1. 초대 링크 열기 +2. 모임 요약 확인 +3. 가능한 시간 입력 방식 선택 +4. 가능 시간 검토/수정 +5. 제출 +6. 제출 완료 확인 +7. 선택적으로 계정 저장 + +초대 화면에서 보여줄 정보: + +- 모임 이름 +- 주최자 +- 예상 소요 시간 +- 후보 날짜 수 +- 응답 마감 +- `로그인 없이 제출 가능` 안내 + +가능 시간 입력 방식: + +- `직접 입력`: 가장 안전한 기본 경로 +- `캘린더 연동`: 재방문 사용자나 일정이 많은 사용자에게 빠른 경로 +- `시간표 사진 업로드`: Everytime 시간표를 쓰는 대학생에게 익숙한 경로 + +중요한 원칙: + +- 참여자는 회원가입 때문에 멈추면 안 된다. +- 제출 전에는 감지된 시간 또는 선택한 시간을 직접 수정할 수 있어야 한다. +- 색상만으로 가능/불가능을 전달하지 말고, 텍스트 라벨을 함께 둔다. +- 복잡한 히트맵 이해를 요구하지 않고도 제출할 수 있어야 한다. + +### 4. 로그인/계정 저장 흐름 + +로그인은 독립 목표가 아니라 재방문 편의를 높이는 장치다. + +권장 제안 타이밍: + +- 1순위: 링크 생성 완료 후 +- 1순위: 가능 시간 제출 완료 후 +- 2순위: 캘린더 연동을 마친 뒤 다음에도 유지할 가치가 명확할 때 +- 신중히 사용: 링크 생성 직전 또는 제출 직전의 작은 저장 안내 + +문구 방향: + +- 나쁜 방향: `로그인해야 계속할 수 있어요` +- 좋은 방향: `저장하면 다음 모임은 캘린더 연동 없이 바로 시작할 수 있어요` + +## 이 흐름이 좋은 이유 + +### 1. 사용자의 실제 상황과 맞다 + +MOIM의 핵심 사용 상황은 카카오톡 단체방에서 링크를 주고받는 것이다. 참여자가 초대 링크를 눌렀는데 홈이나 로그인 화면을 먼저 보면 맥락이 끊긴다. 초대 링크는 바로 참여자 화면으로 연결되어야 한다. + +### 2. 첫 사용 장벽이 낮다 + +대학생 팀 활동 도구는 “한 번 써볼까?”의 장벽이 낮아야 한다. 앱 설치, 회원가입, 캘린더 권한 허용을 앞에 두면 사용자는 가치를 보기 전에 멈춘다. + +### 3. 주최자의 성공 기준이 빠르다 + +주최자는 멋진 대시보드보다 초대 링크를 빨리 만드는 것이 먼저다. 링크가 생성되어야 참여자가 들어오고, 참여자가 들어와야 MOIM의 자동 조율 가치가 드러난다. + +### 4. 참여자의 부담을 줄인다 + +참여자는 서비스에 대한 소유감이 낮다. 그래서 `로그인 없이 제출 가능`, `직접 입력 가능`, `나중에 저장 가능` 같은 장치가 필요하다. 이 흐름은 참여자가 권한 허용이나 계정 생성 없이도 역할을 끝낼 수 있게 한다. + +### 5. 개인정보 불안을 줄인다 + +캘린더 연동은 민감하게 느껴질 수 있다. 직접 입력과 사진 업로드 폴백이 있으면 사용자는 “연동을 해야만 쓰는 서비스”가 아니라 “내가 편한 방식으로 응답할 수 있는 서비스”로 받아들인다. + +### 6. 제품 확산 구조와 맞다 + +한 명의 주최자가 링크를 만들면 여러 참여자가 서비스를 경험한다. 계정 생성보다 링크 공유를 먼저 성공시키는 흐름이 MOIM의 자연 확산에 더 유리하다. + +## 더 나은 사용자 경험을 위한 제안 + +### 제안 1. 참여자 화면에서는 `직접 입력`을 기본 안전 경로로 둔다 + +참여자에게 처음부터 세 가지 선택지를 같은 무게로 보여주면 고민이 생길 수 있다. 더 편한 방식은 다음과 같다. + +- 기본 CTA: `직접 선택해서 30초 안에 제출` +- 보조 CTA: `캘린더로 빠르게 채우기` +- 보조 CTA: `시간표 사진으로 채우기` + +이렇게 하면 권한 허용이 부담스러운 사용자도 바로 진행할 수 있고, 자동화를 원하는 사용자는 더 빠른 경로를 선택할 수 있다. + +### 제안 2. 재방문 사용자는 대시보드보다 빠른 액션을 먼저 보여준다 + +로그인 사용자가 들어왔을 때도 대시보드 정보가 너무 많으면 느려 보일 수 있다. + +권장 위계: + +1. `바로 모임 만들기` +2. `최근 모임 현황 보기` +3. 연결된 캘린더와 기본 설정 상태 + +계정의 가치는 정보 나열이 아니라 “다음 행동이 빨라지는 것”으로 보여주는 편이 좋다. + +### 제안 3. 링크 생성 전 저장 안내는 MVP에서는 생략해도 된다 + +기존 설계에는 링크 생성 직전과 제출 직전에 작은 저장 안내가 있다. 하지만 MVP 첫 테스트에서는 이 안내도 마찰이 될 수 있다. + +더 단순한 MVP 대안: + +- 링크 생성 전에는 저장 안내를 보여주지 않는다. +- 가능 시간 제출 전에도 저장 안내를 보여주지 않는다. +- 완료 후에만 계정 저장을 제안한다. + +이 대안은 전환율을 더 깨끗하게 측정하기 좋다. 사용자가 핵심 행동을 끝낸 뒤에 저장 제안을 보기 때문에, 흐름 중간 이탈 원인을 줄일 수 있다. + +추천은 다음과 같다. + +- 사용자 테스트 1차: 완료 후 저장 제안만 사용 +- 사용자 테스트 2차: 완료 전 작은 저장 안내를 A/B로 비교 + +### 제안 4. `초대 링크로 참여`는 홈보다 URL 직접 진입을 우선한다 + +실제 사용에서는 참여자가 홈에서 초대 코드를 입력하는 경우보다, 카카오톡 링크를 직접 누르는 경우가 많다. + +따라서 홈의 `초대 링크로 참여`는 보조 기능으로 두고, 핵심 설계는 `/join/:id` 직접 진입에 맞춘다. + +### 제안 5. 시간 확정은 주최자에게만 맡기되, 추천 이유를 보여준다 + +MOIM이 바로 시간을 자동 확정하면 사용자가 통제감을 잃을 수 있다. 더 좋은 흐름은 AI가 추천하고 주최자가 확정하는 방식이다. + +추천 시간 화면에는 다음 정보를 함께 보여준다. + +- 몇 명이 가능한지 +- 누가 아직 미응답인지 +- 대안 시간이 무엇인지 +- 캘린더 충돌이 있는지 + +이 방식은 자동화의 편리함과 주최자의 통제감을 함께 준다. + +## 테스트 프로토타입 흐름과 실제 제품 흐름의 차이 + +역할 기반 프로토타입의 첫 화면은 `게스트`, `로그인 유저`, `모임 만드는 사람`, `초대받아 참여하는 사람`을 고르는 허브다. + +이 허브는 테스트에는 좋다. + +- 팀원이 각 화면을 빠르게 검토할 수 있다. +- 사용자 테스트에서 특정 역할을 바로 시작할 수 있다. +- 로그인 사용자와 게스트 차이를 한 파일에서 비교할 수 있다. + +하지만 실제 제품 첫 화면에는 맞지 않는다. + +- 일반 사용자는 자신을 `역할`로 고르고 싶어 하지 않는다. +- 초대 링크로 들어온 사용자는 선택 없이 바로 참여하고 싶어 한다. +- 역할 허브는 제품보다 테스트 도구처럼 느껴진다. + +따라서 정리하면 다음과 같다. + +| 상황 | 권장 첫 화면 | +|---|---| +| 실제 제품 `/` | 게스트 퍼스트 홈 | +| 실제 제품 `/join/:id` | 참여자 초대 화면 | +| 로그인 재방문 | 빠른 액션 중심 내 MOIM | +| 사용자 테스트용 HTML | 역할 선택 허브 | + +## MVP에서 먼저 구현할 흐름 + +1차 MVP는 아래 흐름만 제대로 구현해도 충분하다. + +### 주최자 MVP + +1. 모임 기본 정보 입력 +2. 후보 날짜/시간 선택 +3. 초대 링크 생성 +4. 공유 +5. 응답 현황 확인 +6. 추천 시간 확정 + +### 참여자 MVP + +1. 초대 링크 진입 +2. 모임 요약 확인 +3. 직접 입력으로 가능 시간 선택 +4. 제출 +5. 제출 완료 확인 + +### MVP 이후 추가 + +- Google Calendar 연동 +- iCloud Calendar 연동 +- Everytime 시간표 이미지 분석 +- `.ics` 업로드 +- 재방문 계정 저장 +- 조직/동아리 운영진용 반복 모임 관리 + +## 성공 지표 + +사용자 흐름이 좋은지 판단하려면 다음 지표를 본다. + +- 홈에서 `모임 만들기` 클릭률 +- 모임 생성 시작 후 초대 링크 생성 완료율 +- 초대 링크 클릭 후 가능 시간 제출 완료율 +- 참여자 1명당 제출까지 걸린 시간 +- 모임당 평균 참여자 응답률 +- 공통 가능 시간 도출률 +- 주최자가 추천 시간을 확정하는 비율 +- 완료 후 계정 저장 선택률 + +특히 초기에는 MAU보다 `링크 생성 → 참여자 제출 → 시간 확정` 퍼널이 더 중요하다. + +## 최종 권장안 + +가장 좋은 기본 선택은 다음이다. + +1. 실제 제품은 게스트 퍼스트로 시작한다. +2. 초대 링크는 바로 참여자 화면으로 보낸다. +3. 로그인은 완료 후 저장 제안으로 둔다. +4. 캘린더 연동은 선택지로 제공하되 직접 입력 폴백을 항상 둔다. +5. AI는 시간을 자동 확정하지 않고 추천만 한다. +6. 주최자가 최종 시간을 확정하고, 참여자는 가능한 시간을 빠르게 제출하는 데 집중한다. + +이 흐름이 MOIM의 초기 목표인 “대학생 팀 활동의 일정 조율 시간을 줄이기”에 가장 직접적으로 맞다. diff --git a/package-lock.json b/package-lock.json index 43245e8..5870cea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "^1.4.0", + "@supabase/ssr": "^0.10.3", + "@supabase/supabase-js": "^2.106.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "fast-xml-parser": "^5.5.12", @@ -131,6 +133,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -991,6 +994,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1039,6 +1043,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1219,29 +1224,6 @@ "@noble/ciphers": "^1.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1899,6 +1881,7 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2039,6 +2022,7 @@ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.59.1" }, @@ -2400,6 +2384,116 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.1.tgz", + "integrity": "sha512-7eyheXfAGwkB9bZewJPs+N3UYt6kra2JG6mIxNEgbkvcO15PLD1e75PTIUEYYl3zrifm3GrpShVl7QZxKrXO/w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.1.tgz", + "integrity": "sha512-XbOPnR2mW7jp/EcW447xmGwCa+/Wc00Hkw8t4tUIJjRsHQ4xAESsLKcyLRhRJjJoUnJVXUlC+w0wUxUCM7CG2A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", + "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.1.tgz", + "integrity": "sha512-Qbn6d2lqiqeaBX1Uko0e/hL90dtQGRN6CG2wMVQtJpRFstlVW45qmUTyTOsiB8dYUWu1fWYo4YzJuDbokGv3tQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.1.tgz", + "integrity": "sha512-eQCYri5E8KsjpDgC7g28cOOS2britjUWdNSJluFMainqrMRepzjOnaxqXc3RoAz7H0dxmBrfLUNF6NGP8C+YaA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.3.tgz", + "integrity": "sha512-ux2CJgX89h0Fz2lY7ZNafNG2SkXpyRc5dz77K9eKeBLPdtywQixKwIuetDeIViAJBp/buOUVmgj8PVesOklNpw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.105.3" + } + }, + "node_modules/@supabase/ssr/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.1.tgz", + "integrity": "sha512-HWcLIhqinhWKpOQ3WzglR2unjW0eh9J7yOu3IZrZNIEkraK4La/HDvTqndljGsNw0itPtyHhuKBxRoPG1VUARw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.1.tgz", + "integrity": "sha512-gP4HurGkGu7Z3xoOCjtAI17BKKp7jpsmwY0Ssbsks9XQRzJ7ZhK7LxfLdBSYgUdgZCQgjRK+Mr7+cl4Gxrk0Rw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/auth-js": "2.106.1", + "@supabase/functions-js": "2.106.1", + "@supabase/postgrest-js": "2.106.1", + "@supabase/realtime-js": "2.106.1", + "@supabase/storage-js": "2.106.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -2569,8 +2663,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", @@ -2610,6 +2703,7 @@ "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2627,6 +2721,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2638,6 +2733,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2699,6 +2795,7 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -3370,6 +3467,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3876,6 +3974,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4432,6 +4531,7 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4820,8 +4920,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dot-prop": { "version": "5.3.0", @@ -5174,6 +5273,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5343,6 +5443,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5732,6 +5833,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -6584,6 +6686,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -6659,6 +6762,15 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -7487,6 +7599,7 @@ "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", @@ -8330,7 +8443,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9493,6 +9605,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9715,7 +9828,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9731,7 +9843,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -9882,6 +9993,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9894,6 +10006,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9907,8 +10020,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/read-cache": { "version": "1.0.0", @@ -11619,6 +11731,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11816,6 +11929,7 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -12475,6 +12589,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 1980bfc..eee95f1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ }, "dependencies": { "@base-ui/react": "^1.4.0", + "@supabase/ssr": "^0.10.3", + "@supabase/supabase-js": "^2.106.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "fast-xml-parser": "^5.5.12", diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 38b9f99..c075490 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,15 +1,105 @@ -/** - * 로그인 페이지 (스켈레톤) - * - * PRD 3.1 — 카카오 또는 구글 소셜 로그인. - * 별도 회원가입 폼 없이 OAuth만 사용. - */ +"use client"; + +import { createClient } from "@/lib/supabase/client"; +import { useState } from "react"; export default function LoginPage() { + const [isLoading, setIsLoading] = useState(null); + const supabase = createClient(); + + const handleLogin = async (provider: "google" | "kakao") => { + setIsLoading(provider); + try { + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${window.location.origin}/api/auth/callback`, + }, + }); + if (error) throw error; + } catch (err) { + console.error("로그인 에러:", err); + setIsLoading(null); + } + }; + return ( -
-

로그인

- {/* TODO: 카카오/구글 소셜 로그인 버튼 구현 */} -
+
+ {/* 백그라운드 오로라 이펙트 */} +
+
+
+
+ + {/* 로그인 카드 */} +
+
+
+ M +
+

+ MOIM 시작하기 +

+

+ 시간 조율을 더 스마트하고 완벽하게 +

+
+ +
+ {/* 카카오 로그인 버튼 */} + + + {/* 구글 로그인 버튼 */} + +
+ +
+ 로그인 시 MOIM의{" "} + + 서비스 이용약관 + + 및{" "} + + 개인정보 처리방침 + + 에 동의하게 됩니다. +
+
+
); } diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts new file mode 100644 index 0000000..c4477f6 --- /dev/null +++ b/src/app/api/auth/callback/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url); + const code = searchParams.get("code"); + const next = searchParams.get("next") ?? "/"; + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + return NextResponse.redirect(`${origin}${next}`); + } + } + + // Authentication failed: redirect back to login with an error message + return NextResponse.redirect(`${origin}/login?error=auth_failed`); +} diff --git a/src/app/api/everytime/timetable/route.ts b/src/app/api/everytime/timetable/route.ts new file mode 100644 index 0000000..ae7387e --- /dev/null +++ b/src/app/api/everytime/timetable/route.ts @@ -0,0 +1,215 @@ +import { NextRequest, NextResponse } from "next/server"; +import { parseTimetableFromIcs } from "@/lib/everytime/ics-converter"; +import { + EverytimeScrapeError, + fetchTimetableFromUrl, +} from "@/lib/everytime/url-scraper"; +import { timetableToFreeSlots } from "@/lib/everytime/converter"; +import type { DayCode } from "@/types/schedule"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/everytime/timetable + * + * 에브리타임 시간표를 받아 빈 시간(TimeSlot[])으로 변환한다. + * + * 방법 A — 공유 URL (application/json): + * { "url": "https://everytime.kr/@XXXX" } + * + * 방법 B — ICS 파일 업로드 (multipart/form-data): + * file: .ics 파일 (앱 → 시간표 공유 → 캘린더 내보내기) + * + * Query: ?days=MON,TUE,WED (선택 — 기본값 월~금) + * + * Response: + * { "timetable": EverytimeTimetable, "freeSlots": TimeSlot[] } + */ +export async function POST(req: NextRequest) { + const contentType = req.headers.get("content-type") ?? ""; + const candidateDays = parseCandidateDays(req); + + // 방법 A: JSON body에 url 필드 + if (contentType.includes("application/json")) { + return handleUrlRequest(req, candidateDays); + } + + // 방법 B: multipart/form-data에 file 필드 + if (contentType.includes("multipart/form-data")) { + return handleFileRequest(req, candidateDays); + } + + return NextResponse.json( + { + error: + "Content-Type은 application/json(URL) 또는 multipart/form-data(ICS 파일)여야 합니다.", + }, + { status: 415 }, + ); +} + +async function handleUrlRequest( + req: NextRequest, + candidateDays: DayCode[] | undefined, +) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "요청 본문이 올바른 JSON이 아닙니다." }, + { status: 400 }, + ); + } + + const url = (body as Record)?.url; + if (typeof url !== "string" || !url.trim()) { + return NextResponse.json( + { error: "url 필드가 필요합니다." }, + { status: 400 }, + ); + } + + // SSRF 방지: 허용된 도메인·프로토콜만 통과 + let parsedUrl: URL; + try { + parsedUrl = new URL(url.trim()); + } catch { + return NextResponse.json( + { error: "유효하지 않은 URL입니다." }, + { status: 400 }, + ); + } + if ( + parsedUrl.protocol !== "https:" || + parsedUrl.hostname !== "everytime.kr" + ) { + return NextResponse.json( + { error: "everytime.kr의 https URL만 허용됩니다." }, + { status: 400 }, + ); + } + + let timetable: Awaited>; + try { + timetable = await fetchTimetableFromUrl(url.trim()); + } catch (err) { + if (err instanceof EverytimeScrapeError) { + return NextResponse.json({ error: err.message }, { status: 422 }); + } + console.error("[everytime.timetable] URL 스크래핑 오류:", err); + return NextResponse.json( + { error: "시간표 조회 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } + + try { + const freeSlots = timetableToFreeSlots(timetable, { candidateDays }); + return NextResponse.json({ timetable, freeSlots }); + } catch (err) { + console.error("[everytime.timetable] 시간표 변환 오류:", err); + return NextResponse.json( + { error: "시간표 변환 중 오류가 발생했습니다." }, + { status: 422 }, + ); + } +} + +async function handleFileRequest( + req: NextRequest, + candidateDays: DayCode[] | undefined, +) { + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return NextResponse.json( + { error: "파일 업로드 형식이 올바르지 않습니다." }, + { status: 400 }, + ); + } + + const file = formData.get("file"); + if (!file || !(file instanceof File)) { + return NextResponse.json( + { error: "file 필드에 ICS 파일을 첨부해주세요." }, + { status: 400 }, + ); + } + + if (!file.name.toLowerCase().endsWith(".ics")) { + return NextResponse.json( + { error: ".ics 파일만 지원합니다." }, + { status: 400 }, + ); + } + + if (file.size > 100 * 1024) { + return NextResponse.json( + { + error: + "파일 크기가 너무 큽니다. 100KB 이하의 ICS 파일을 업로드해주세요.", + }, + { status: 400 }, + ); + } + + const icsText = await file.text(); + const normalizedIcs = icsText.trim().toUpperCase(); + if ( + !normalizedIcs.includes("BEGIN:VCALENDAR") || + !normalizedIcs.includes("END:VCALENDAR") + ) { + return NextResponse.json( + { error: "올바른 ICS 파일 형식이 아닙니다." }, + { status: 400 }, + ); + } + + let timetable: Awaited>; + try { + timetable = parseTimetableFromIcs(icsText); + } catch (err) { + console.error("[everytime.timetable] ICS 파싱 오류:", err); + return NextResponse.json( + { error: "ICS 파일 파싱 중 오류가 발생했습니다." }, + { status: 422 }, + ); + } + + try { + const freeSlots = timetableToFreeSlots(timetable, { candidateDays }); + return NextResponse.json({ timetable, freeSlots }); + } catch (err) { + console.error("[everytime.timetable] 시간표 변환 오류:", err); + return NextResponse.json( + { error: "시간표 변환 중 오류가 발생했습니다." }, + { status: 422 }, + ); + } +} + +const VALID_DAYS = new Set([ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN", +]); + +function parseCandidateDays(req: NextRequest): DayCode[] | undefined { + const raw = req.nextUrl.searchParams.get("days"); + if (!raw) return undefined; + const days = Array.from( + new Set( + raw + .split(",") + .map((d) => d.trim().toUpperCase() as DayCode) + .filter((d) => VALID_DAYS.has(d)), + ), + ); + return days.length > 0 ? days : undefined; +} diff --git a/src/lib/everytime/__tests__/auth.test.ts b/src/lib/everytime/__tests__/auth.test.ts new file mode 100644 index 0000000..13ff0c8 --- /dev/null +++ b/src/lib/everytime/__tests__/auth.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EverytimeAuthError, parseLoginResponse } from "../auth"; + +describe("parseLoginResponse", () => { + it("정상 응답 JSON에서 token과 userIdx를 반환한다", () => { + const data = { status: "ok", token: "abc123", idx: 99999 }; + const session = parseLoginResponse(data); + expect(session.token).toBe("abc123"); + expect(session.userIdx).toBe("99999"); + }); + + it("status가 ok가 아니면 EverytimeAuthError를 throw한다", () => { + const data = { status: "not_exists_user" }; + expect(() => parseLoginResponse(data)).toThrow(EverytimeAuthError); + }); + + it("token이 없으면 EverytimeAuthError를 throw한다", () => { + const data = { status: "ok", idx: 99999 }; + expect(() => parseLoginResponse(data)).toThrow(EverytimeAuthError); + }); + + it("idx가 없으면 EverytimeAuthError를 throw한다", () => { + const data = { status: "ok", token: "abc123" }; + expect(() => parseLoginResponse(data)).toThrow(EverytimeAuthError); + }); + + it("token이 빈 문자열이면 EverytimeAuthError를 throw한다", () => { + const data = { status: "ok", token: "", idx: 99999 }; + expect(() => parseLoginResponse(data)).toThrow(EverytimeAuthError); + }); + + it("idx가 number 타입일 때 string으로 변환된다", () => { + const data = { status: "ok", token: "abc123", idx: 12345 }; + const session = parseLoginResponse(data); + expect(session.userIdx).toBe("12345"); + expect(typeof session.userIdx).toBe("string"); + }); + + it("null을 전달하면 EverytimeAuthError를 throw한다", () => { + expect(() => parseLoginResponse(null)).toThrow(EverytimeAuthError); + }); + + it("undefined를 전달하면 EverytimeAuthError를 throw한다", () => { + expect(() => parseLoginResponse(undefined)).toThrow(EverytimeAuthError); + }); + + it("문자열을 전달하면 EverytimeAuthError를 throw한다", () => { + expect(() => parseLoginResponse("string")).toThrow(EverytimeAuthError); + }); + + it("숫자를 전달하면 EverytimeAuthError를 throw한다", () => { + expect(() => parseLoginResponse(123)).toThrow(EverytimeAuthError); + }); + + it("배열을 전달하면 EverytimeAuthError를 throw한다", () => { + expect(() => parseLoginResponse([])).toThrow(EverytimeAuthError); + }); +}); + +describe("loginToEverytime (HTTP)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("세션 쿠키 획득 후 로그인 성공 시 세션을 반환한다", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + // 1번째 호출: 로그인 페이지 GET (쿠키 획득) + .mockResolvedValueOnce({ + headers: { + getSetCookie: () => [ + "etsid=session123; Path=/", + "x-et-device=device456; Path=/", + ], + get: () => null, + }, + redirect: "follow", + }) + // 2번째 호출: 로그인 POST + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ status: "ok", token: "tok-xyz", idx: 12345 }), + }), + ); + + const { loginToEverytime } = await import("../auth"); + const session = await loginToEverytime({ id: "user1", password: "pass1" }); + + expect(session.token).toBe("tok-xyz"); + expect(session.userIdx).toBe("12345"); + }); + + it("서버 오류(5xx)이면 EverytimeAuthError를 throw한다", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + headers: { getSetCookie: () => [], get: () => null }, + }) + .mockResolvedValueOnce({ ok: false, status: 500 }), + ); + + const { loginToEverytime, EverytimeAuthError: AuthErr } = + await import("../auth"); + await expect( + loginToEverytime({ id: "user1", password: "pass1" }), + ).rejects.toThrow(AuthErr); + }); + + it("잘못된 자격증명이면 EverytimeAuthError를 throw한다", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + headers: { getSetCookie: () => [], get: () => null }, + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ status: "not_exists_user" }), + }), + ); + + const { loginToEverytime, EverytimeAuthError: AuthErr } = + await import("../auth"); + await expect( + loginToEverytime({ id: "wrong", password: "wrong" }), + ).rejects.toThrow(AuthErr); + }); + + it("세션 쿠키 요청이 네트워크 오류로 실패하면 에러를 throw한다", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("Network error")), + ); + + const { loginToEverytime } = await import("../auth"); + await expect( + loginToEverytime({ id: "user1", password: "pass1" }), + ).rejects.toThrow(); + }); +}); diff --git a/src/lib/everytime/__tests__/converter.test.ts b/src/lib/everytime/__tests__/converter.test.ts new file mode 100644 index 0000000..3f8c5eb --- /dev/null +++ b/src/lib/everytime/__tests__/converter.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { timetableToFreeSlots } from "../converter"; +import type { EverytimeTimetable } from "@/types/everytime"; + +describe("timetableToFreeSlots", () => { + it("수업이 없으면 후보 시간 전체가 빈 시간으로 반환된다", () => { + const timetable: EverytimeTimetable = { lectures: [] }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["MON"], + candidateStartHour: 9, + candidateEndHour: 12, + }); + expect(result).toEqual([{ day: "MON", startHour: 9, endHour: 12 }]); + }); + + it("수업 시간이 빈 시간에서 제외된다", () => { + // MON 09:00-10:30 수업 → 9시(9:00-10:00), 10시(10:00-11:00) 블록 불가 + const timetable: EverytimeTimetable = { + lectures: [ + { + name: "수학", + times: [{ day: 0, startMinute: 540, endMinute: 630 }], + }, + ], + }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["MON"], + candidateStartHour: 9, + candidateEndHour: 13, + }); + expect(result).toEqual([{ day: "MON", startHour: 11, endHour: 13 }]); + }); + + it("여러 수업 사이 시간이 각각 빈 시간으로 반환된다", () => { + // MON 09:00-10:30, MON 13:00-14:30 + const timetable: EverytimeTimetable = { + lectures: [ + { + name: "수학", + times: [{ day: 0, startMinute: 540, endMinute: 630 }], + }, + { + name: "영어", + times: [{ day: 0, startMinute: 780, endMinute: 870 }], + }, + ], + }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["MON"], + candidateStartHour: 9, + candidateEndHour: 18, + }); + expect(result).toEqual([ + { day: "MON", startHour: 11, endHour: 13 }, + { day: "MON", startHour: 15, endHour: 18 }, + ]); + }); + + it("다른 요일 수업은 해당 요일 빈 시간에 영향을 주지 않는다", () => { + const timetable: EverytimeTimetable = { + lectures: [ + { + name: "수학", + times: [{ day: 1, startMinute: 540, endMinute: 630 }], // 화요일 + }, + ], + }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["MON"], + candidateStartHour: 9, + candidateEndHour: 12, + }); + expect(result).toEqual([{ day: "MON", startHour: 9, endHour: 12 }]); + }); + + it("여러 요일에 걸친 시간표를 한 번에 처리한다", () => { + const timetable: EverytimeTimetable = { + lectures: [ + { + name: "데이터구조", + times: [ + { day: 1, startMinute: 540, endMinute: 630 }, // 화 09:00-10:30 + { day: 3, startMinute: 540, endMinute: 630 }, // 목 09:00-10:30 + ], + }, + ], + }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["TUE", "THU"], + candidateStartHour: 9, + candidateEndHour: 12, + }); + expect(result).toEqual([ + { day: "TUE", startHour: 11, endHour: 12 }, + { day: "THU", startHour: 11, endHour: 12 }, + ]); + }); + + it("정각에 시작하고 끝나는 수업은 해당 시간 블록만 제외된다", () => { + // MON 10:00-12:00 → 10시, 11시 블록 불가 + const timetable: EverytimeTimetable = { + lectures: [ + { + name: "알고리즘", + times: [{ day: 0, startMinute: 600, endMinute: 720 }], + }, + ], + }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["MON"], + candidateStartHour: 9, + candidateEndHour: 14, + }); + expect(result).toEqual([ + { day: "MON", startHour: 9, endHour: 10 }, + { day: "MON", startHour: 12, endHour: 14 }, + ]); + }); + + it("candidateStartHour >= candidateEndHour이면 에러를 throw한다", () => { + const timetable: EverytimeTimetable = { lectures: [] }; + expect(() => + timetableToFreeSlots(timetable, { + candidateStartHour: 12, + candidateEndHour: 9, + }), + ).toThrow(); + }); + + it("candidateStartHour가 음수이면 에러를 throw한다", () => { + const timetable: EverytimeTimetable = { lectures: [] }; + expect(() => + timetableToFreeSlots(timetable, { + candidateStartHour: -1, + candidateEndHour: 22, + }), + ).toThrow(); + }); + + it("candidateEndHour가 24 초과이면 에러를 throw한다", () => { + const timetable: EverytimeTimetable = { lectures: [] }; + expect(() => + timetableToFreeSlots(timetable, { + candidateStartHour: 9, + candidateEndHour: 25, + }), + ).toThrow(); + }); + + it("빈 candidateDays이면 빈 배열을 반환한다", () => { + const timetable: EverytimeTimetable = { lectures: [] }; + const result = timetableToFreeSlots(timetable, { + candidateDays: [], + candidateStartHour: 9, + candidateEndHour: 22, + }); + expect(result).toEqual([]); + }); +}); diff --git a/src/lib/everytime/__tests__/ics-converter.test.ts b/src/lib/everytime/__tests__/ics-converter.test.ts new file mode 100644 index 0000000..f022b9a --- /dev/null +++ b/src/lib/everytime/__tests__/ics-converter.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { parseTimetableFromIcs } from "../ics-converter"; + +// Asia/Seoul UTC+9 기준 ICS 샘플 (에브리타임 내보내기 형식) +const SAMPLE_ICS = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Everytime//Timetable//EN +BEGIN:VEVENT +UID:ds-mon@everytime +SUMMARY:데이터구조 +DTSTART;TZID=Asia/Seoul:20250303T090000 +DTEND;TZID=Asia/Seoul:20250303T103000 +RRULE:FREQ=WEEKLY;UNTIL=20250620T145900Z +LOCATION:공학관 301 +END:VEVENT +BEGIN:VEVENT +UID:ds-wed@everytime +SUMMARY:데이터구조 +DTSTART;TZID=Asia/Seoul:20250305T090000 +DTEND;TZID=Asia/Seoul:20250305T103000 +RRULE:FREQ=WEEKLY;UNTIL=20250620T145900Z +LOCATION:공학관 301 +END:VEVENT +BEGIN:VEVENT +UID:algo-tue@everytime +SUMMARY:알고리즘 +DTSTART;TZID=Asia/Seoul:20250304T130000 +DTEND;TZID=Asia/Seoul:20250304T143000 +RRULE:FREQ=WEEKLY;UNTIL=20250620T145900Z +LOCATION:공학관 201 +END:VEVENT +END:VCALENDAR`; + +describe("parseTimetableFromIcs", () => { + it("같은 과목명의 여러 시간대를 하나의 lecture로 병합한다", () => { + const timetable = parseTimetableFromIcs(SAMPLE_ICS); + const ds = timetable.lectures.find((l) => l.name === "데이터구조"); + + expect(ds).toBeDefined(); + expect(ds!.times).toHaveLength(2); + }); + + it("요일을 월요일 기준(0=월)으로 정확히 변환한다", () => { + const timetable = parseTimetableFromIcs(SAMPLE_ICS); + const ds = timetable.lectures.find((l) => l.name === "데이터구조")!; + + const days = ds.times.map((t) => t.day).sort(); + // 2025-03-03=월(0), 2025-03-05=수(2) + expect(days).toEqual([0, 2]); + }); + + it("시작/종료 시각을 분 단위로 정확히 변환한다", () => { + const timetable = parseTimetableFromIcs(SAMPLE_ICS); + const ds = timetable.lectures.find((l) => l.name === "데이터구조")!; + const mon = ds.times.find((t) => t.day === 0)!; + + // 09:00 → 540분, 10:30 → 630분 + expect(mon.startMinute).toBe(540); + expect(mon.endMinute).toBe(630); + }); + + it("여러 과목을 각각 별도 lecture로 반환한다", () => { + const timetable = parseTimetableFromIcs(SAMPLE_ICS); + expect(timetable.lectures).toHaveLength(2); + + const names = timetable.lectures.map((l) => l.name); + expect(names).toContain("데이터구조"); + expect(names).toContain("알고리즘"); + }); + + it("동일한 (과목, 요일, 시작분) 조합이 중복 포함되지 않는다", () => { + // 에브리타임이 RRULE 없이 매주 개별 VEVENT로 내보내는 경우 시뮬레이션 + const icsWithDuplicates = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:ds-mon-week1@everytime +SUMMARY:데이터구조 +DTSTART;TZID=Asia/Seoul:20250303T090000 +DTEND;TZID=Asia/Seoul:20250303T103000 +END:VEVENT +BEGIN:VEVENT +UID:ds-mon-week2@everytime +SUMMARY:데이터구조 +DTSTART;TZID=Asia/Seoul:20250310T090000 +DTEND;TZID=Asia/Seoul:20250310T103000 +END:VEVENT +END:VCALENDAR`; + + const timetable = parseTimetableFromIcs(icsWithDuplicates); + const ds = timetable.lectures.find((l) => l.name === "데이터구조")!; + // 같은 요일·시작분이므로 1개만 남아야 함 + expect(ds.times).toHaveLength(1); + }); + + it("종일 이벤트는 무시한다", () => { + const icsWithAllDay = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:holiday@test +SUMMARY:공휴일 +DTSTART;VALUE=DATE:20250303 +DTEND;VALUE=DATE:20250304 +END:VEVENT +BEGIN:VEVENT +UID:class@test +SUMMARY:수학 +DTSTART;TZID=Asia/Seoul:20250303T100000 +DTEND;TZID=Asia/Seoul:20250303T120000 +END:VEVENT +END:VCALENDAR`; + + const timetable = parseTimetableFromIcs(icsWithAllDay); + expect(timetable.lectures).toHaveLength(1); + expect(timetable.lectures[0].name).toBe("수학"); + }); + + it("빈 ICS는 빈 timetable을 반환한다", () => { + const timetable = parseTimetableFromIcs("BEGIN:VCALENDAR\nEND:VCALENDAR"); + expect(timetable.lectures).toEqual([]); + }); + + it("빈 과목명(SUMMARY)은 무시된다", () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:empty@test +SUMMARY: +DTSTART;TZID=Asia/Seoul:20250303T090000 +DTEND;TZID=Asia/Seoul:20250303T103000 +END:VEVENT +END:VCALENDAR`; + const timetable = parseTimetableFromIcs(ics); + expect(timetable.lectures).toHaveLength(0); + }); + + it("DTSTART >= DTEND인 이벤트는 무시된다", () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:invalid@test +SUMMARY:역순수업 +DTSTART;TZID=Asia/Seoul:20250303T103000 +DTEND;TZID=Asia/Seoul:20250303T090000 +END:VEVENT +END:VCALENDAR`; + const timetable = parseTimetableFromIcs(ics); + expect(timetable.lectures).toHaveLength(0); + }); + + it("비정상적인 시간 범위(시간 범위를 벗어남 등)는 무시한다", () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:outofbounds@test +SUMMARY:자정넘김수업 +DTSTART;TZID=Asia/Seoul:20250303T230000 +DTEND;TZID=Asia/Seoul:20250304T010000 +END:VEVENT +END:VCALENDAR`; + const timetable = parseTimetableFromIcs(ics); + expect(timetable.lectures).toHaveLength(0); + }); +}); diff --git a/src/lib/everytime/__tests__/timetable.test.ts b/src/lib/everytime/__tests__/timetable.test.ts new file mode 100644 index 0000000..f053cdc --- /dev/null +++ b/src/lib/everytime/__tests__/timetable.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { parseSemesterResponse, parseSubjectListResponse } from "../timetable"; +import { EverytimeFetchError } from "../timetable"; + +describe("parseSemesterResponse", () => { + it("정상 응답 XML에서 semester id를 반환한다", () => { + const xml = ``; + expect(parseSemesterResponse(xml)).toBe("100"); + }); + + it("status가 ok가 아니면 EverytimeFetchError를 throw한다", () => { + const xml = ``; + expect(() => parseSemesterResponse(xml)).toThrow(EverytimeFetchError); + }); + + it("semester id가 없으면 EverytimeFetchError를 throw한다", () => { + const xml = ``; + expect(() => parseSemesterResponse(xml)).toThrow(EverytimeFetchError); + }); + + it("잘못된 XML 형식이면 EverytimeFetchError를 throw한다", () => { + expect(() => parseSemesterResponse("not valid xml <<<<")).toThrow( + EverytimeFetchError, + ); + }); +}); + +describe("parseSubjectListResponse", () => { + it("강의 목록 XML을 파싱해 lectures 배열을 반환한다", () => { + const xml = ` + + + 데이터구조 + + + 알고리즘 + + + `; + const timetable = parseSubjectListResponse(xml); + + expect(timetable.lectures).toHaveLength(2); + + const ds = timetable.lectures[0]; + expect(ds.name).toBe("데이터구조"); + expect(ds.times).toHaveLength(2); + expect(ds.times[0]).toEqual({ day: 1, startMinute: 540, endMinute: 630 }); + expect(ds.times[1]).toEqual({ day: 3, startMinute: 540, endMinute: 630 }); + + const algo = timetable.lectures[1]; + expect(algo.name).toBe("알고리즘"); + expect(algo.times[0]).toEqual({ day: 2, startMinute: 780, endMinute: 870 }); + }); + + it("강의가 없으면 빈 배열을 반환한다", () => { + const xml = ``; + const timetable = parseSubjectListResponse(xml); + expect(timetable.lectures).toEqual([]); + }); + + it("status가 ok가 아니면 EverytimeFetchError를 throw한다", () => { + const xml = ``; + expect(() => parseSubjectListResponse(xml)).toThrow(EverytimeFetchError); + }); + + it("유효하지 않은 time 속성은 필터링된다", () => { + const xml = ` + + + 테스트 + + + `; + const timetable = parseSubjectListResponse(xml); + // day=8은 유효하지 않으므로 필터링 + expect(timetable.lectures[0].times).toHaveLength(1); + }); + + it("day=0(월요일)과 day=6(일요일) 경계값을 허용한다", () => { + const xml = ` + + + 월요일수업 + + + 일요일수업 + + + `; + const timetable = parseSubjectListResponse(xml); + expect(timetable.lectures[0].times[0].day).toBe(0); + expect(timetable.lectures[1].times[0].day).toBe(6); + }); + + it("startMinute >= endMinute인 시간은 필터링된다", () => { + const xml = ` + + + 테스트 + + + `; + const timetable = parseSubjectListResponse(xml); + expect(timetable.lectures[0].times).toHaveLength(0); + }); + + it("endMinute > 1440인 시간은 필터링된다", () => { + const xml = ` + + + 테스트 + + + `; + const timetable = parseSubjectListResponse(xml); + expect(timetable.lectures[0].times).toHaveLength(0); + }); + + it("startMinute < 0인 시간은 필터링된다", () => { + const xml = ` + + + 테스트 + + + `; + const timetable = parseSubjectListResponse(xml); + expect(timetable.lectures[0].times).toHaveLength(0); + }); +}); diff --git a/src/lib/everytime/__tests__/url-scraper.test.ts b/src/lib/everytime/__tests__/url-scraper.test.ts new file mode 100644 index 0000000..a37527e --- /dev/null +++ b/src/lib/everytime/__tests__/url-scraper.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { EverytimeScrapeError, parseShareResponse } from "../url-scraper"; + +const SAMPLE_XML = ` + + + + + + + + + + + + +
+
`; + +describe("parseShareResponse", () => { + it("XML에서 과목 목록을 파싱한다", () => { + const timetable = parseShareResponse(SAMPLE_XML); + expect(timetable.lectures).toHaveLength(2); + }); + + it("과목명을 정확히 파싱한다", () => { + const timetable = parseShareResponse(SAMPLE_XML); + const names = timetable.lectures.map((l) => l.name); + expect(names).toContain("오픈소스를활용한실전창업"); + expect(names).toContain("프로그래밍언어"); + }); + + it("starttime × 5 = 자정 기준 분으로 변환한다", () => { + const timetable = parseShareResponse(SAMPLE_XML); + const open = timetable.lectures.find( + (l) => l.name === "오픈소스를활용한실전창업", + )!; + // starttime=108 → 108×5=540분 = 09:00 + // endtime=141 → 141×5=705분 = 11:45 + expect(open.times[0].startMinute).toBe(540); + expect(open.times[0].endMinute).toBe(705); + }); + + it("요일(day)을 그대로 반환한다 (0=월)", () => { + const timetable = parseShareResponse(SAMPLE_XML); + const open = timetable.lectures.find( + (l) => l.name === "오픈소스를활용한실전창업", + )!; + expect(open.times[0].day).toBe(0); + }); + + it("여러 요일 수업을 각각 times 항목으로 반환한다", () => { + const timetable = parseShareResponse(SAMPLE_XML); + const prog = timetable.lectures.find((l) => l.name === "프로그래밍언어")!; + expect(prog.times).toHaveLength(2); + const days = prog.times.map((t) => t.day).sort(); + expect(days).toEqual([2, 4]); // 수=2, 금=4 + }); + + it("table이 없으면 EverytimeScrapeError를 throw한다", () => { + const xml = ``; + expect(() => parseShareResponse(xml)).toThrow(EverytimeScrapeError); + }); + + it("비정상적인 시간 데이터(day 범위 초과, 정수가 아님, 범위 오류 등)는 무시한다", () => { + const invalidXml = ` + + + + + + + + + + + + + + +
+
`; + const timetable = parseShareResponse(invalidXml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("빈 시간표(subject 없음)를 처리한다", () => { + const xml = ` + + +
+
`; + const timetable = parseShareResponse(xml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("음수 starttime은 필터링된다", () => { + const xml = ` + + + + + + +
+
`; + const timetable = parseShareResponse(xml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("day가 0-6 범위를 벗어나면 필터링된다", () => { + const xml = ` + + + + + + +
+
`; + const timetable = parseShareResponse(xml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("starttime >= endtime이면 필터링된다", () => { + const xml = ` + + + + + + +
+
`; + const timetable = parseShareResponse(xml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("endtime × 5 > 1440인 시간은 필터링된다", () => { + // endtime=300 → 300×5=1500 > 1440 + const xml = ` + + + + + + +
+
`; + const timetable = parseShareResponse(xml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("잘못된 XML 형식이면 EverytimeScrapeError를 throw한다", () => { + expect(() => parseShareResponse("not valid xml <<<<")).toThrow( + EverytimeScrapeError, + ); + }); +}); diff --git a/src/lib/everytime/auth.ts b/src/lib/everytime/auth.ts new file mode 100644 index 0000000..5b0d7aa --- /dev/null +++ b/src/lib/everytime/auth.ts @@ -0,0 +1,120 @@ +import type { EverytimeCredentials, EverytimeSession } from "@/types/everytime"; + +// ============================================================ +// 에브리타임 인증 모듈 — 서버 전용 (credentials 노출 금지) +// ============================================================ + +const LOGIN_PAGE_URL = "https://account.everytime.kr/login"; +const LOGIN_API_URL = "https://account.everytime.kr/api/authenticate/login"; + +const BASE_HEADERS = { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Origin: "https://account.everytime.kr", + Referer: "https://account.everytime.kr/login", +}; + +export class EverytimeAuthError extends Error { + constructor(message: string) { + super(message); + this.name = "EverytimeAuthError"; + } +} + +/** + * 에브리타임에 로그인하고 세션 토큰을 반환한다. + * + * 1단계: 로그인 페이지 GET → 세션 쿠키 획득 + * 2단계: 쿠키 + 자격증명으로 POST → 토큰 반환 + * + * 자격증명은 서버 → 에브리타임 API로만 전송되며 저장되지 않는다. + * + * @note 프로덕션 플로우에서는 reCAPTCHA v3로 인해 실제 로그인이 차단되므로 사용되지 않는다. + * 테스트·개발 목적으로만 유지한다. + */ +export async function loginToEverytime( + credentials: EverytimeCredentials, +): Promise { + const cookies = await fetchSessionCookies(); + + const response = await fetch(LOGIN_API_URL, { + method: "POST", + headers: { + ...BASE_HEADERS, + "Content-Type": "application/x-www-form-urlencoded", + "X-Requested-With": "XMLHttpRequest", + Cookie: cookies, + }, + body: new URLSearchParams({ + id: credentials.id, + password: credentials.password, + keep: "false", + recaptchaToken: "", + }), + }); + + if (!response.ok) { + throw new EverytimeAuthError( + `에브리타임 서버 응답 오류 (${response.status})`, + ); + } + + const data = (await response.json()) as unknown; + return parseLoginResponse(data); +} + +/** + * 로그인 페이지를 GET해서 세션 쿠키를 가져온다. + * etsid(세션 ID)와 x-et-device(기기 식별자)가 포함된다. + */ +async function fetchSessionCookies(): Promise { + const response = await fetch(LOGIN_PAGE_URL, { + headers: { "User-Agent": BASE_HEADERS["User-Agent"] }, + redirect: "follow", + }); + + const headers = response.headers as unknown as { + getSetCookie?: () => string[]; + get: (name: string) => string | null; + }; + + // Node 18+: getSetCookie()로 복수의 Set-Cookie 헤더를 배열로 가져옴 + const setCookies: string[] = + typeof headers.getSetCookie === "function" + ? headers.getSetCookie() + : [headers.get("set-cookie") ?? ""].filter(Boolean); + + // "name=value; Path=...; ..." → "name=value" 만 추출해서 합침 + return setCookies + .map((c) => c.split(";")[0]) + .filter(Boolean) + .join("; "); +} + +/** 로그인 응답 JSON을 파싱한다. */ +export function parseLoginResponse(data: unknown): EverytimeSession { + // 성공: { "status": "ok", "token": "TOKEN", "idx": 12345 } + // 실패: { "status": "not_exists_user" } + if (typeof data !== "object" || data === null || Array.isArray(data)) { + throw new EverytimeAuthError("로그인 응답 형식이 올바르지 않습니다."); + } + const res = data as Record; + + if (res.status !== "ok") { + throw new EverytimeAuthError("아이디 또는 비밀번호가 올바르지 않습니다."); + } + + const token = String(res.token ?? ""); + const userIdx = String(res.idx ?? ""); + + if (!token) { + throw new EverytimeAuthError("로그인 응답에서 토큰을 찾을 수 없습니다."); + } + if (!userIdx) { + throw new EverytimeAuthError( + "로그인 응답에서 사용자 ID를 찾을 수 없습니다.", + ); + } + + return { token, userIdx }; +} diff --git a/src/lib/everytime/converter.ts b/src/lib/everytime/converter.ts new file mode 100644 index 0000000..b6fa71b --- /dev/null +++ b/src/lib/everytime/converter.ts @@ -0,0 +1,113 @@ +import type { DayCode, TimeSlot } from "@/types/schedule"; +import type { EverytimeTimetable } from "@/types/everytime"; + +// ============================================================ +// 에브리타임 시간표 → 빈 시간(TimeSlot[]) 변환기 +// +// 에브리타임 시간표는 "수업 시간(바쁜 시간)"이다. +// MOIM의 findCommonSlots는 "가능한 시간(빈 시간)"을 입력받으므로 +// 후보 시간대에서 수업 시간을 제외한 블록을 반환한다. +// ============================================================ + +const DAY_CODE_MAP: Record = { + 0: "MON", + 1: "TUE", + 2: "WED", + 3: "THU", + 4: "FRI", + 5: "SAT", + 6: "SUN", +}; + +const CODE_TO_DAY_NUM: Record = { + MON: 0, + TUE: 1, + WED: 2, + THU: 3, + FRI: 4, + SAT: 5, + SUN: 6, +}; + +export interface TimetableConvertOptions { + /** 후보 요일 (기본: 월~금) */ + candidateDays?: DayCode[]; + /** 후보 시작 시각 (기본: 9시) */ + candidateStartHour?: number; + /** 후보 종료 시각 (기본: 22시) */ + candidateEndHour?: number; +} + +/** + * 에브리타임 시간표를 빈 시간 목록으로 변환한다. + * + * 수업이 없는 1시간 단위 블록을 추출하고, 연속된 블록은 하나의 TimeSlot으로 병합한다. + * + * @example + * // MON 09:00-10:30 수업 → MON 11:00-22:00 빈 시간 + * timetableToFreeSlots(timetable, { candidateDays: ["MON"], candidateStartHour: 9, candidateEndHour: 22 }) + * // → [{ day: "MON", startHour: 11, endHour: 22 }] + */ +export function timetableToFreeSlots( + timetable: EverytimeTimetable, + options: TimetableConvertOptions = {}, +): TimeSlot[] { + const { + candidateDays = ["MON", "TUE", "WED", "THU", "FRI"], + candidateStartHour = 9, + candidateEndHour = 22, + } = options; + + if (candidateStartHour < 0 || candidateEndHour > 24) { + throw new Error("시간 범위는 0~24 사이여야 합니다."); + } + if (candidateStartHour >= candidateEndHour) { + throw new Error("candidateStartHour는 candidateEndHour보다 작아야 합니다."); + } + + const result: TimeSlot[] = []; + + for (const day of candidateDays) { + const dayNum = CODE_TO_DAY_NUM[day]; + + const busyRanges = timetable.lectures + .flatMap((l) => l.times) + .filter((t) => t.day === dayNum); + + const freeHours: number[] = []; + for (let h = candidateStartHour; h < candidateEndHour; h++) { + const hourStartMin = h * 60; + const hourEndMin = (h + 1) * 60; + const isBusy = busyRanges.some( + (b) => b.startMinute < hourEndMin && b.endMinute > hourStartMin, + ); + if (!isBusy) freeHours.push(h); + } + + result.push(...groupConsecutiveHours(day, freeHours)); + } + + return result; +} + +/** 오름차순으로 정렬된 정수 시간 배열을 TimeSlot 블록으로 병합한다. */ +function groupConsecutiveHours(day: DayCode, hours: number[]): TimeSlot[] { + if (hours.length === 0) return []; + + const slots: TimeSlot[] = []; + let blockStart = hours[0]; + let prev = hours[0]; + + for (let i = 1; i < hours.length; i++) { + if (hours[i] !== prev + 1) { + slots.push({ day, startHour: blockStart, endHour: prev + 1 }); + blockStart = hours[i]; + } + prev = hours[i]; + } + slots.push({ day, startHour: blockStart, endHour: prev + 1 }); + + return slots; +} + +export { DAY_CODE_MAP }; diff --git a/src/lib/everytime/ics-converter.ts b/src/lib/everytime/ics-converter.ts new file mode 100644 index 0000000..b93235b --- /dev/null +++ b/src/lib/everytime/ics-converter.ts @@ -0,0 +1,87 @@ +import { parseIcsToEvents } from "@/lib/ics/parser"; +import type { + EverytimeLectureTime, + EverytimeTimetable, +} from "@/types/everytime"; + +// ============================================================ +// 에브리타임 ICS → EverytimeTimetable 변환기 +// +// 에브리타임 앱의 "시간표 내보내기(ICS)"로 받은 파일을 파싱한다. +// 각 VEVENT의 DTSTART/DTEND에서 요일·시간을 추출하고, +// 같은 과목명의 여러 시간대(월/수, 화/목 등)를 하나로 병합한다. +// ============================================================ + +// 에브리타임 ICS는 Asia/Seoul(UTC+9) 기준으로 작성됨 +const KST_OFFSET_MS = 9 * 60 * 60_000; + +/** + * JavaScript Date.getUTCDay() 기준(0=일) → 에브리타임 day(0=월) 변환 + * (jsDay + 6) % 7 : 일=6, 월=0, 화=1, 수=2, 목=3, 금=4, 토=5 + */ +function toMondayBasedDay(jsDay: number): EverytimeLectureTime["day"] { + return ((jsDay + 6) % 7) as EverytimeLectureTime["day"]; +} + +/** + * 에브리타임 ICS 텍스트를 EverytimeTimetable로 변환한다. + * + * - 종일 이벤트는 무시한다 (수업 시간은 항상 시작/종료 시각이 있다). + * - 같은 과목명끼리 times 배열로 합산한다. + * - (과목명, 요일, 시작분) 기준으로 중복을 제거한다. + * (RRULE이 있는 경우 단일 VEVENT이므로 중복이 없고, + * RRULE 없이 매주 개별 VEVENT로 내보내는 경우 중복 제거가 필요하다.) + */ +export function parseTimetableFromIcs(icsText: string): EverytimeTimetable { + const events = parseIcsToEvents(icsText); + + const lectureMap = new Map>(); + + for (const event of events) { + if (event.isAllDay) continue; + + const startKst = new Date(event.startAt.getTime() + KST_OFFSET_MS); + const endKst = new Date(event.endAt.getTime() + KST_OFFSET_MS); + + const day = toMondayBasedDay(startKst.getUTCDay()); + const startMinute = startKst.getUTCHours() * 60 + startKst.getUTCMinutes(); + const endMinute = endKst.getUTCHours() * 60 + endKst.getUTCMinutes(); + + // 정수 및 범위 검증 추가 + if ( + !Number.isInteger(day) || + day < 0 || + day > 6 || + !Number.isInteger(startMinute) || + !Number.isInteger(endMinute) || + startMinute < 0 || + endMinute > 1440 || + startMinute >= endMinute + ) { + continue; + } + const name = event.title; + if (!name.trim()) continue; + + if (startMinute >= endMinute) { + console.warn( + `[ics-converter] 잘못된 시간 범위 무시: ${name} (${startMinute}-${endMinute})`, + ); + continue; + } + // (요일, 시작분) 조합을 키로 중복 제거 + const slotKey = `${day}-${startMinute}`; + + if (!lectureMap.has(name)) { + lectureMap.set(name, new Map()); + } + lectureMap.get(name)!.set(slotKey, { day, startMinute, endMinute }); + } + + const lectures = Array.from(lectureMap.entries()).map(([name, slotMap]) => ({ + name, + times: Array.from(slotMap.values()), + })); + + return { lectures }; +} diff --git a/src/lib/everytime/timetable.ts b/src/lib/everytime/timetable.ts new file mode 100644 index 0000000..4e83761 --- /dev/null +++ b/src/lib/everytime/timetable.ts @@ -0,0 +1,160 @@ +import { XMLParser } from "fast-xml-parser"; +import type { + EverytimeLecture, + EverytimeLectureTime, + EverytimeSession, + EverytimeTimetable, +} from "@/types/everytime"; + +// ============================================================ +// 에브리타임 시간표 조회 모듈 — 서버 전용 +// ============================================================ + +const BASE_URL = "https://api.everytime.kr"; +const SEMESTER_SUGGEST_URL = `${BASE_URL}/find/timetable/semester/suggest`; + +const xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + isArray: (name) => name === "subject" || name === "time", +}); + +export class EverytimeFetchError extends Error { + constructor(message: string) { + super(message); + this.name = "EverytimeFetchError"; + } +} + +/** + * 현재 학기의 시간표를 가져온다. + * + * 1단계: 현재 학기 ID 조회 + * 2단계: 해당 학기의 강의 목록 조회 + */ +export async function fetchCurrentTimetable( + session: EverytimeSession, +): Promise { + const semesterId = await fetchCurrentSemesterId(session); + return fetchTimetableBySemester(session, semesterId); +} + +async function fetchCurrentSemesterId( + session: EverytimeSession, +): Promise { + const response = await fetch(SEMESTER_SUGGEST_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: session.token }), + }); + + if (!response.ok) { + throw new EverytimeFetchError(`학기 조회 실패 (${response.status})`); + } + + const xml = await response.text(); + return parseSemesterResponse(xml); +} + +async function fetchTimetableBySemester( + session: EverytimeSession, + semesterId: string, +): Promise { + const url = `${BASE_URL}/find/timetable/subject/list/semester/id/${semesterId}`; + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: session.token }), + }); + + if (!response.ok) { + throw new EverytimeFetchError(`시간표 조회 실패 (${response.status})`); + } + + const xml = await response.text(); + return parseSubjectListResponse(xml); +} + +/** 학기 응답 XML에서 semester id를 파싱한다. */ +export function parseSemesterResponse(xml: string): string { + // 예상 응답: + // + // + // + let parsed: ReturnType; + try { + parsed = xmlParser.parse(xml); + } catch { + throw new EverytimeFetchError("XML 파싱에 실패했습니다."); + } + const response = parsed?.response; + + if (response?.["@_status"] !== "ok") { + throw new EverytimeFetchError("학기 정보를 가져오지 못했습니다."); + } + + const rawId = response?.semester?.["@_id"]; + if (typeof rawId !== "string" && typeof rawId !== "number") { + throw new EverytimeFetchError("학기 ID를 찾을 수 없습니다."); + } + const id = String(rawId); + if (!id) throw new EverytimeFetchError("학기 ID를 찾을 수 없습니다."); + + return id; +} + +/** 강의 목록 응답 XML을 파싱한다. */ +export function parseSubjectListResponse(xml: string): EverytimeTimetable { + // 예상 응답: + // + // + // 데이터구조 + // + // + let parsed: ReturnType; + try { + parsed = xmlParser.parse(xml); + } catch { + throw new EverytimeFetchError("XML 파싱에 실패했습니다."); + } + const response = parsed?.response; + + if (response?.["@_status"] !== "ok") { + throw new EverytimeFetchError("시간표 정보를 가져오지 못했습니다."); + } + + const subjects: unknown[] = response?.subject ?? []; + const lectures: EverytimeLecture[] = subjects.map(parseSubject); + + return { lectures }; +} + +function parseSubject(subject: unknown): EverytimeLecture { + const s = subject as Record; + const name = String(s?.name ?? ""); + + const rawTimes = (s?.time ?? []) as unknown[]; + const times: EverytimeLectureTime[] = rawTimes + .map((t) => { + const time = t as Record; + const day = Number(time?.["@_day"]); + const startMinute = Number(time?.["@_start"]); + const endMinute = Number(time?.["@_end"]); + return { day, startMinute, endMinute }; + }) + .filter( + (t): t is EverytimeLectureTime => + Number.isInteger(t.day) && + t.day >= 0 && + t.day <= 6 && + Number.isInteger(t.startMinute) && + Number.isInteger(t.endMinute) && + t.startMinute >= 0 && + t.endMinute <= 1440 && + t.startMinute < t.endMinute, + ); + + return { name, times }; +} diff --git a/src/lib/everytime/url-scraper.ts b/src/lib/everytime/url-scraper.ts new file mode 100644 index 0000000..60d65a4 --- /dev/null +++ b/src/lib/everytime/url-scraper.ts @@ -0,0 +1,177 @@ +import { XMLParser } from "fast-xml-parser"; +import type { + EverytimeLectureTime, + EverytimeTimetable, +} from "@/types/everytime"; + +// ============================================================ +// 에브리타임 공유 URL → 시간표 추출 +// +// https://everytime.kr/@XXXX 형식의 공유 링크에서 identifier를 추출하고 +// api.everytime.kr/find/timetable/table/friend API를 호출해 XML로 받아온다. +// +// 시간 단위: starttime/endtime 값 × 5 = 자정 기준 분 +// 예) 108 × 5 = 540분 = 09:00 +// ============================================================ + +const API_URL = "https://api.everytime.kr/find/timetable/table/friend"; +const TIMEOUT_MS = 10_000; +const SHARE_URL_PATTERN = /^https:\/\/everytime\.kr\/@([\w-]+)$/; + +const xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + isArray: (name) => name === "subject" || name === "data", +}); + +export class EverytimeScrapeError extends Error { + constructor(message: string) { + super(message); + this.name = "EverytimeScrapeError"; + } +} + +/** + * 에브리타임 공유 URL에서 시간표를 가져온다. + * + * @param url - https://everytime.kr/@XXXX 형식 + */ +export async function fetchTimetableFromUrl( + url: string, +): Promise { + const match = url.match(SHARE_URL_PATTERN); + if (!match) { + throw new EverytimeScrapeError( + "유효하지 않은 에브리타임 URL입니다. (예: https://everytime.kr/@XXXX)", + ); + } + + const identifier = match[1]; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + + let response: Response; + try { + response = await fetch(API_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Origin: "https://everytime.kr", + Referer: `https://everytime.kr/@${identifier}`, + }, + body: new URLSearchParams({ identifier }), + signal: controller.signal, + }); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new EverytimeScrapeError("에브리타임 API 요청 시간 초과"); + } + throw err; + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + throw new EverytimeScrapeError( + `에브리타임 API 응답 오류 (${response.status})`, + ); + } + + const xml = await response.text(); + return parseShareResponse(xml); +} + +/** 공유 API XML 응답을 EverytimeTimetable로 파싱한다. */ +export function parseShareResponse(xml: string): EverytimeTimetable { + // 응답 형식: + // + // + // + // + // + // + //
+ //
+ + let parsed: ReturnType; + try { + parsed = xmlParser.parse(xml); + } catch (err) { + throw new EverytimeScrapeError( + `XML 파싱 실패: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const table = parsed?.response?.table; + + if (!table) { + throw new EverytimeScrapeError("시간표 응답을 파싱할 수 없습니다."); + } + + const subjects: unknown[] = table.subject ?? []; + const lectures = subjects + .map((s) => parseSubject(s)) + .filter( + (l): l is NonNullable => l !== null && l.times.length > 0, + ); + + return { lectures }; +} + +function parseSubject( + subject: unknown, +): { name: string; times: EverytimeLectureTime[] } | null { + const s = subject as Record; + + const nameNode = s?.name as Record | undefined; + const name = String(nameNode?.["@_value"] ?? "").trim(); + if (!name) return null; + + const timeNode = s?.time as Record | undefined; + const dataItems: unknown[] = (timeNode?.data as unknown[]) ?? []; + + const times: EverytimeLectureTime[] = dataItems + .map((d) => parseDataItem(d)) + .filter((t): t is EverytimeLectureTime => t !== null); + + return { name, times }; +} + +function parseDataItem(d: unknown): EverytimeLectureTime | null { + const item = d as Record; + + const rawDay = Number(item["@_day"]); + const rawStart = Number(item["@_starttime"]); + const rawEnd = Number(item["@_endtime"]); + + // finite number, 정수 검증 + if ( + !Number.isInteger(rawDay) || + !Number.isInteger(rawStart) || + !Number.isInteger(rawEnd) + ) { + return null; + } + + const day = rawDay; + // starttime/endtime은 5분 단위 → ×5 하면 자정 기준 분 + const startMinute = rawStart * 5; + const endMinute = rawEnd * 5; + + // 요일 범위: 0~6, 시간 범위: 0~1440 + if ( + day < 0 || + day > 6 || + startMinute < 0 || + endMinute > 1440 || + startMinute >= endMinute + ) { + return null; + } + + return { day: day as EverytimeLectureTime["day"], startMinute, endMinute }; +} diff --git a/src/lib/supabase/__tests__/supabase.test.ts b/src/lib/supabase/__tests__/supabase.test.ts new file mode 100644 index 0000000..bf8e25b --- /dev/null +++ b/src/lib/supabase/__tests__/supabase.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createClient as createBrowserClient } from "../client"; +import { createClient as createServerClient } from "../server"; + +// Next.js cookies API 모킹 +vi.mock("next/headers", () => ({ + cookies: () => ({ + getAll: vi.fn().mockReturnValue([]), + set: vi.fn(), + }), +})); + +describe("Supabase 클라이언트 인스턴스 생성 검증", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { + ...originalEnv, + NEXT_PUBLIC_SUPABASE_URL: "https://example-project.supabase.co", + NEXT_PUBLIC_SUPABASE_ANON_KEY: "test-anon-key", + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("createBrowserClient가 정상적으로 Supabase 브라우저 클라이언트를 반환하는지 테스트", () => { + const client = createBrowserClient(); + expect(client).toBeDefined(); + expect(client.auth).toBeDefined(); + }); + + it("createServerClient가 정상적으로 Supabase 서버 클라이언트를 반환하는지 테스트", async () => { + const client = await createServerClient(); + expect(client).toBeDefined(); + expect(client.auth).toBeDefined(); + }); +}); diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index 9aee907..18f3f96 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -7,11 +7,11 @@ * TODO: Supabase URL + anon key 연결 후 구현 */ -// import { createBrowserClient } from '@supabase/ssr' -// -// export function createClient() { -// return createBrowserClient( -// process.env.NEXT_PUBLIC_SUPABASE_URL!, -// process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! -// ) -// } +import { createBrowserClient } from "@supabase/ssr"; + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ); +} diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index 502a482..7d94162 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -7,9 +7,32 @@ * TODO: Supabase URL + service role key 연결 후 구현 */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function createClient(): Promise { - throw new Error( - "Supabase 클라이언트가 아직 설정되지 않았습니다. .env.local에 SUPABASE 환경변수를 설정해주세요.", +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +export async function createClient() { + const cookieStore = cookies(); + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options), + ); + } catch { + // The `setAll` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + }, + }, ); } diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..9a9b64d --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,49 @@ +import { createServerClient } from "@supabase/ssr"; +import { NextResponse, type NextRequest } from "next/server"; + +export async function middleware(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value), + ); + supabaseResponse = NextResponse.next({ + request, + }); + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options), + ); + }, + }, + }, + ); + + // This will refresh session if expired + await supabase.auth.getUser(); + + return supabaseResponse; +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * Feel free to modify this pattern to include more paths. + */ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; diff --git a/src/types/everytime.ts b/src/types/everytime.ts new file mode 100644 index 0000000..dec8d11 --- /dev/null +++ b/src/types/everytime.ts @@ -0,0 +1,37 @@ +/** 에브리타임 로그인 자격증명 — 서버 사이드에서만 사용 */ +export interface EverytimeCredentials { + id: string; + password: string; +} + +/** 로그인 성공 후 받는 세션 정보 */ +export interface EverytimeSession { + token: string; + userIdx: string; +} + +/** + * 강의 시간 (분 단위) + * + * 에브리타임 API가 반환하는 start/end 값은 자정 기준 분. + * 예: startMinute=540 → 09:00, endMinute=630 → 10:30 + */ +export interface EverytimeLectureTime { + /** 0=월, 1=화, 2=수, 3=목, 4=금, 5=토, 6=일 */ + day: 0 | 1 | 2 | 3 | 4 | 5 | 6; + /** 자정 기준 분 (0–1439). startMinute < endMinute 보장. */ + startMinute: number; + /** 자정 기준 분 (1–1440). startMinute < endMinute 보장. */ + endMinute: number; +} + +/** 강의 단건 */ +export interface EverytimeLecture { + name: string; + times: EverytimeLectureTime[]; +} + +/** 한 학기의 시간표 전체 */ +export interface EverytimeTimetable { + lectures: EverytimeLecture[]; +} diff --git a/supabase/migrations/20260413000000_init_profiles.sql b/supabase/migrations/20260413000000_init_profiles.sql new file mode 100644 index 0000000..60188b5 --- /dev/null +++ b/supabase/migrations/20260413000000_init_profiles.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- Profiles 테이블 및 사용자 트리거 초기 설정 +-- ============================================================ + +-- 공용 updated_at 트리거 함수 정의 +create or replace function public.update_updated_at() +returns trigger as $$ +begin + new.updated_at = now(); + return new; +end; +$$ language plpgsql; + +-- Profiles 테이블 정의 +create table public.profiles ( + id uuid references auth.users on delete cascade primary key, + email text, + nickname text, + avatar_url text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +comment on table public.profiles is '사용자 프로필 정보 (Supabase Auth와 자동 연동)'; + +-- RLS 활성화 +alter table public.profiles enable row level security; + +-- RLS 정책 설정 +create policy "Public profiles are viewable by everyone" + on public.profiles for select + using (true); + +create policy "Users can update their own profile" + on public.profiles for update + using (auth.uid() = id); + +-- 회원가입 시 public.profiles에 자동으로 프로필을 연동해 주는 트리거 함수 정의 +create or replace function public.handle_new_user() +returns trigger as $$ +begin + insert into public.profiles (id, email, nickname, avatar_url) + values ( + new.id, + new.email, + coalesce(new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'name', new.email), + new.raw_user_meta_data->>'avatar_url' + ); + return new; +end; +$$ language plpgsql security definer; + +-- 트리거 설정 +create or replace trigger on_auth_user_created + after insert on auth.users + for each row execute procedure public.handle_new_user(); + +-- profiles updated_at 트리거 등록 +create trigger profiles_updated_at + before update on public.profiles + for each row execute function public.update_updated_at(); diff --git a/supabase/migrations/20260416000000_scheduling_sessions.sql b/supabase/migrations/20260416000000_scheduling_sessions.sql new file mode 100644 index 0000000..a5dc16b --- /dev/null +++ b/supabase/migrations/20260416000000_scheduling_sessions.sql @@ -0,0 +1,93 @@ +-- ============================================================ +-- 스케줄링 세션 및 참여자 가용 시간 테이블 +-- ============================================================ + +-- schedule_sessions 테이블 정의 +create table public.schedule_sessions ( + id uuid primary key default gen_random_uuid(), + title text not null, + host_id uuid not null references public.profiles(id) on delete cascade, + duration_minutes integer not null, + candidate_days text[] not null, + candidate_start_hour integer not null, + candidate_end_hour integer not null, + status text not null default 'open', + confirmed_slot jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +comment on table public.schedule_sessions is '일정 잡기 세션 정보'; + +-- RLS 활성화 +alter table public.schedule_sessions enable row level security; + +-- schedule_sessions RLS 정책 +create policy "Anyone can select schedule sessions" + on public.schedule_sessions for select + using (true); + +create policy "Authenticated users can create schedule sessions" + on public.schedule_sessions for insert + to authenticated + with check (host_id = auth.uid()); + +create policy "Hosts can update their own schedule sessions" + on public.schedule_sessions for update + to authenticated + using (host_id = auth.uid()) + with check (host_id = auth.uid()); + +create policy "Hosts can delete their own schedule sessions" + on public.schedule_sessions for delete + to authenticated + using (host_id = auth.uid()); + +-- participant_availabilities 테이블 정의 +create table public.participant_availabilities ( + id uuid primary key default gen_random_uuid(), + session_id uuid not null references public.schedule_sessions(id) on delete cascade, + user_id uuid references public.profiles(id) on delete cascade, + nickname text not null, + available_slots jsonb not null, -- TimeSlot[] 형태의 json 배열 + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + -- 한 사용자가 한 세션에 가용시간을 하나만 등록할 수 있도록 유니크 제약 + unique (session_id, user_id) +); + +comment on table public.participant_availabilities is '참여자별 가용 시간 정보'; + +-- RLS 활성화 +alter table public.participant_availabilities enable row level security; + +-- participant_availabilities RLS 정책 +create policy "Anyone can select participant availabilities" + on public.participant_availabilities for select + using (true); + +create policy "Authenticated users can insert their own availability" + on public.participant_availabilities for insert + to authenticated + with check (user_id = auth.uid()); + +create policy "Users can update their own availability" + on public.participant_availabilities for update + to authenticated + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + +create policy "Users can delete their own availability" + on public.participant_availabilities for delete + to authenticated + using (user_id = auth.uid()); + +-- updated_at 트리거 등록 +create trigger schedule_sessions_updated_at + before update on public.schedule_sessions + for each row execute function public.update_updated_at(); + +create trigger participant_availabilities_updated_at + before update on public.participant_availabilities + for each row execute function public.update_updated_at(); diff --git a/tsconfig.json.backup b/tsconfig.json.backup new file mode 100644 index 0000000..ccb2ed9 --- /dev/null +++ b/tsconfig.json.backup @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +}