Skip to content

refactor: 전역 API SOLID 원칙 준수 리팩토링 및 서비스 격리 (#62) - #64

Merged
Siul49 merged 12 commits into
devfrom
feature/refactor-calendar-adapters-and-errors
Jun 14, 2026
Merged

Siul49 merged 12 commits into
devfrom
feature/refactor-calendar-adapters-and-errors

Conversation

@Siul49

@Siul49 Siul49 commented Jun 12, 2026

Copy link
Copy Markdown
Owner

🚀 작업 내용 (What)

  • Next.js API 라우터 40여 개를 공통 createApiHandler 제네릭 헬퍼로 통합하여 일관된 요청 파싱, 인증 체크, 에러 핸들링을 수행하도록 개선했습니다.
  • 기존의 개별 API 핸들러에 흩어져 있던 비즈니스 로직을 서비스 계층으로 분리하여 관심사를 격리했습니다.
  • 캘린더 어댑터 구조를 BaseCalendarAdapterArrayCalendarAdapter 기반의 클래스 상속 구조로 리팩토링하여 Google, iCloud, Manual, Photo 캘린더 등 다양한 어댑터에 공통 로직을 효율적으로 적용할 수 있게 했습니다.
  • MoimError를 부모로 하는 중앙 집중화된 에러 계층(UnauthorizedError, ForbiddenError, ExternalServiceError, CalDAVError, EverytimeError 등)을 설계하고 적용했습니다.
  • Supabase E2E 모킹 및 환경 설정을 정교화하여 테스트 환경의 안전성을 높이고, OAuth 인증 흐름 시 동적 origin 전달을 처리할 수 있도록 구조를 개선했습니다.

📣 핵심 변경 이유 (Why)

  • 기존 API 및 캘린더 연동 코드의 비즈니스 로직 혼재와 에러 핸들링 파편화 문제를 해결하여 코드 재사용성과 SOLID 설계 원칙을 강화하기 위함입니다.
  • E2E 테스트 안정성과 인증 처리의 유연성을 강화하기 위함입니다.

📸 스크린샷 (Visuals, 선택)

  • 해당 없음 (백엔드 및 API 아키텍처 리팩토링)

⚠️ 체크리스트 (Checklist)

  • 브랜치 컨벤션(feature/00-name)을 지켰나요?
  • 커밋 컨벤션(feat:, fix: 등)을 지켰나요?
  • 작업 전에 관련 이슈를 생성하고 연결했나요?
  • 내 코드가 팀의 기존 코드를 망가뜨리지 않았는지 확인했나요?

🔗 관련 이슈 (Issue)

Close #62

@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
moim-app Ready Ready Preview, Comment Jun 14, 2026 4:33am

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kokkumong, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 56 minutes and 13 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 590ec7fe-e355-4cda-878e-d677fff010a1

📥 Commits

Reviewing files that changed from the base of the PR and between 7c502d7 and bd3aaea.

📒 Files selected for processing (9)
  • src/app/api/google/auth/route.ts
  • src/app/api/google/callback/route.ts
  • src/app/api/icloud/events/create/route.ts
  • src/app/api/icloud/events/query/route.ts
  • src/lib/__tests__/errors.test.ts
  • src/lib/caldav/__tests__/client.test.ts
  • src/lib/caldav/client.ts
  • src/lib/calendar/adapters/manual.ts
  • src/lib/google/auth.ts

Walkthrough

Next.js API 라우트 40+개를 공통 createApiHandler 래퍼로 통합하고, 비즈니스 로직을 서비스 계층으로 분리했습니다. 캘린더 어댑터를 클래스 기반 상속으로 변경하고, 공통 에러 계층을 도입하며, E2E 테스트 안정성과 OAuth 동적 원점 처리를 개선했습니다.

Changes

API 인프라 및 서비스 계층 리팩터링

Layer / File(s) Summary
공통 에러 계층 및 테스트 인프라
src/lib/errors.ts, src/lib/auth/session.ts, src/lib/caldav/client.ts, src/lib/everytime/*.ts, src/lib/__tests__/errors.test.ts
MoimError를 베이스로 UnauthorizedError(401), ForbiddenError(403), ExternalServiceError, CalDAVError, EverytimeError 계층을 정의하고, 기존 분산된 에러 클래스들을 중앙 모듈로 통합하여 재-export했습니다.
API 핸들러 팩토리 및 테스트
src/lib/api-handler.ts, src/lib/__tests__/api-handler.test.ts
createApiHandler 제네릭 래퍼로 동적 라우트 params 비동기 바인딩, requireAuth 기반 인증, 요청 JSON 파싱, Zod 스키마 검증(422), 예외 처리(500)를 일관되게 수행합니다. 151줄의 종합 테스트로 모든 경로를 검증합니다.
인증 서비스 구현
src/lib/services/auth-service.ts
signUp/signIn/getProfile 메서드로 이메일 정규화, E2E 테스트 Prisma 모드, Supabase Auth 통합, 닉네임 기반 이메일 조회 기능을 제공합니다.
OAuth 원점 동적 전달
src/lib/auth/naver.ts, src/lib/google/auth.ts, src/lib/google/__tests__/auth.test.ts
getNaverAuthUrl/getNaverToken, buildAuthUrl, exchangeCodeForTokensorigin?: string 파라미터를 추가하여 환경변수 대신 요청 URL origin을 기준으로 리다이렉트 URI를 동적으로 생성합니다.
인증 라우트 리팩터링
src/app/api/auth/login/route.ts, src/app/api/auth/signup/route.ts, src/app/api/auth/me/route.ts, src/app/api/auth/naver/login,callback/route.ts, src/app/api/google/auth,callback/route.ts
기존 직접 구현(Supabase/Prisma/세션 관리)을 createApiHandler + AuthService로 교체했습니다. 로그인 라우트는 110줄 → 25줄로 단순화됐습니다.
스케줄 서비스 및 라우트
src/features/schedules/schedule.schema.ts, src/lib/services/schedule-service.ts, src/app/api/schedules/[id]/route.ts
timeSlotSchema/confirmScheduleSchema Zod 정의, getSchedule(세션/호스트토큰), confirmSchedule(생성자/호스트토큰) 메서드로 스케줄 조회·확정·쿠키 관리를 서비스화하고, 라우트는 GET/PATCH 모두 createApiHandler로 통일했습니다.
캘린더 어댑터 베이스 계층
src/lib/calendar/adapter.ts
기존 CalendarAdapter<TRaw> 인터페이스를 BaseCalendarAdapter<TRaw,TItem> + ArrayCalendarAdapter<TItem> 추상 클래스로 변경하여 item 단위 필드 접근(getExternalId/getTitle/...)과 공통 변환 로직(mapToCalendarEvent)을 표준화했습니다.
캘린더 어댑터 구현
src/lib/calendar/adapters/google.ts, src/lib/calendar/adapters/icloud.ts, src/lib/calendar/adapters/manual.ts, src/lib/calendar/adapters/photo.ts, src/lib/calendar/adapters/__tests__/manual.test.ts
Google/iCloud/Manual/Photo 어댑터를 클래스로 변환하고, createEvent, listCalendars, queryEvents 메서드를 추가하여 API 호출을 어댑터 내부로 통합했습니다.
캘린더 이벤트 및 목록 라우트
src/app/api/calendar/events/create,query/route.ts, src/app/api/calendar/list/route.ts, src/app/api/google/calendars,events/route.ts, src/app/api/icloud/calendars,events/route.ts
Google/iCloud 캘린더 조회·생성 라우트를 createApiHandler + 어댑터 메서드로 재구현했습니다. provider별 인증(토큰/연결정보), 검증(startAt < endAt), 에러 매핑(401/404/502)을 일관되게 처리합니다.
Everytime 서비스 및 라우트
src/lib/services/everytime-service.ts, src/app/api/everytime/timetable/route.ts
processUrl, processIcs 메서드로 URL/ICS 파싱·변환·사용자메타 저장을 서비스화하고, 라우트는 createApiHandler로 래핑하여 반복 로직을 제거했습니다.
Supabase E2E 모킹 및 설정
src/lib/supabase/env.ts, src/lib/supabase/client.ts, src/lib/supabase/server.ts
E2E 환경 감지 로직, 브라우저 기반 쿠키 모킹, MockSupabaseQueryBuilder 클래스로 profiles 테이블 upsert/update 지원, 서버에서 { isServer: true } 옵션 추가로 환경 분리를 강화했습니다.
환경 및 스키마 설정
.env.example, prisma/schema.prisma, scripts/ensure-sqlite-schema.mjs, supabase/migrations/20260611000000_profiles_naver_id.sql, e2e/host-flow.spec.ts
로컬 포트 3000 → 4000 변경, Prisma PostgreSQL → SQLite, @@schema("public") 주석 처리, Schedule 테이블 creatorId 컬럼 추가, E2E 자동완성 버그 우회(조건부 재기입) 로직 추가.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50분

근거:

  • 40+ 라우트의 일관된 패턴 변경이지만, 각 라우트마다 provider/에러 매핑 로직이 다름
  • 캘린더 어댑터 7개 파일의 클래스 상속 구조 변경 (추상 메서드 이해 필수)
  • 인증/Supabase 모킹 로직 확장으로 인한 E2E/환경 분기 복잡도 증가
  • 에러 계층 재정의에 따른 기존 호출처 검증 필요
  • 새로운 createApiHandler 팩토리 패턴의 세부 인자 전달/응답 형식 일관성 확인

Possibly related PRs

  • Siul49/moim#21: /api/everytime/timetable 신규 엔드포인트 추가 및 URL/ICS 파싱 로직과 직접 연결 (이번 PR에서 EverytimeService로 재구성)
  • Siul49/moim#59: Naver OAuth 라우트 및 getNaverAuthUrl/getNaverToken 함수 구현 (이번 PR에서 origin 매개변수 추가)
  • Siul49/moim#61: 캘린더 어댑터 베이스 클래스 구조 및 공통 에러 계층 통합 (동일 코드 경로에서 직접 확장)

Suggested labels

refactor, api, architecture

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/refactor-calendar-adapters-and-errors

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 23

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/supabase/server.ts (1)

84-95: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

쿠키가 아직 없어도 mock auth는 유효한 user.id를 반환해야 합니다.

Why: signInWithPasswordupdateUsermockUid를 그대로 사용해서 undefined를 내보낼 수 있습니다. 그런데 E2E_TEST === "true" 분기는 쿠키가 없는 최초 로그인에서도 항상 진입합니다. 이 상태의 응답은 실제 Supabase 계약과 달라서, 호출부가 user.id를 키로 쓰는 순간 바로 깨집니다.

How:

최소 수정 예시
+        const effectiveUid = mockUid || `e2e_uid_${Date.now()}`;
+
         signInWithPassword: async ({ email }: { email: string }) => {
           return {
             data: {
               user: {
-                id: mockUid,
+                id: effectiveUid,
                 email,
                 user_metadata: { nickname: mockNickname },
               },
               session: { access_token: "mock_jwt_token" },
             },
             error: null,
           };
         },
@@
         updateUser: async ({ data }: { data: any }) => {
           return {
             data: {
               user: {
-                id: mockUid,
+                id: effectiveUid,
                 email: mockEmail,
                 user_metadata: { ...data, nickname: mockNickname },
               },
             },
             error: null,
           };
         },

Also applies to: 119-130

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/supabase/server.ts` around lines 84 - 95, The mock auth returns
undefined user IDs when no cookie exists; update the signInWithPassword (and
similarly updateUser) mock implementations to always return a valid id by
falling back to mockUid when req.cookies or any cookie-derived id is missing —
i.e., in signInWithPassword and updateUser ensure user.id is set to mockUid (or
a deterministic fallback) rather than undefined so the E2E_TEST === "true"
branch's response matches Supabase's contract and callers can safely use user.id
as a key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.example:
- Around line 19-20: Add the missing E2E environment variable keys to
.env.example so local/CI mock flows are reproducible: add placeholder entries
for NEXT_PUBLIC_SUPABASE_E2E and E2E_TEST (and any other E2E-related keys
referenced by src/lib/supabase/env.ts and src/lib/supabase/server.ts) with clear
example values and brief comments indicating purpose; ensure names match exactly
the symbols used in the code and keep values as safe placeholders to avoid
leaking real credentials.

In `@prisma/schema.prisma`:
- Around line 3-10: The datasource block ("datasource db") currently hardcodes
provider = "sqlite", which breaks production Postgres usage; change provider
back to "postgresql", restore the url/directUrl usage (use env("DATABASE_URL")
and env("DIRECT_URL") as before) and re-enable the schemas setting (e.g.
["public","auth"]) so Prisma targets the same Postgres DB used by migrations; if
sqlite is needed for local tests, move that configuration into a separate
test-only Prisma schema/config rather than altering the main datasource.

In `@src/app/api/calendar/events/query/route.ts`:
- Around line 52-65: The Google auth-expiry handling in
src/app/api/calendar/events/query/route.ts is swallowing 401->expired errors as
502; update the catch path to detect Google token-expiry and return the
standardized 401 expiry response by reusing a shared helper (e.g.,
isGoogleAuthExpiredError(err) and mapGoogleAuthErrorToResponse(err) or
buildGoogleExpiryResponse()) used by the list route; modify the catch around
googleAdapter.queryEvents to call that helper and return the mapped NextResponse
(401 when expired) otherwise log and return the 502 error as before so both
routes share identical expiry mapping and reconnection flow.
- Around line 78-84: The route is using the client-supplied iCloud calendar URL
(targetIdentifier) directly which can leak server-stored Apple credentials to
attacker-controlled hosts; add a server-side whitelist check by implementing
assertAllowedIcloudCalendar(connection, targetIdentifier) and calling it before
any icloudAdapter query/create calls (e.g., before icloudAdapter.queryEvents in
the shown route). Implement assertAllowedIcloudCalendar to authenticate with the
stored credentials (connection.appleId, connection.password), retrieve the list
of calendars via the iCloud adapter discovery/listing API, and verify that
targetIdentifier exactly matches one of the discovered calendar URLs; if not,
throw/return a 4xx error. Apply the same check in the other affected handlers
that accept a client calendar URL (the create and query iCloud routes) so only
calendars discovered for the current account are allowed.

In `@src/app/api/everytime/timetable/route.ts`:
- Around line 9-12: The handler currently calls parseCandidateDays inside POST
and treats its undefined result the same as a missing query, letting downstream
defaults (Mon-Fri) silently mask invalid input; change parseCandidateDays (or
its call in POST) to differentiate "no days param provided" from "days param
provided but all tokens invalid" and return a 400 Bad Request for the latter.
Specifically, ensure parseCandidateDays exposes whether the param was present
(e.g., returns a discriminated result or throws on invalid tokens) and update
the POST route handler (and the other handler using parseCandidateDays) to
respond 400 when the input was present but invalid, while still allowing
undefined when the param is genuinely missing so downstream defaults remain
applied.

In `@src/app/api/google/callback/route.ts`:
- Around line 33-40: When starting the OAuth flow, persist the exact origin (or
full redirect URI) you pass into buildAuthUrl by setting a secure, HttpOnly
cookie (e.g., "google_oauth_origin") in the auth initiation code, and in the
callback handler read that cookie and pass its value into
exchangeCodeForTokens(code, savedOrigin) instead of recomputing origin from
req.url; if the cookie is missing, fail the callback with a clear error so you
don't risk a redirect_uri mismatch. Ensure cookie name matches between both
handlers and is set with Secure, HttpOnly, sameSite and appropriate expiry.

In `@src/app/api/icloud/events/query/route.ts`:
- Around line 19-24: The current presence-only check for body.calendarUrl,
startDate, and endDate lets invalid strings like "not-a-date" proceed; in the
route handler where you read body and set calendarUrl, startDate, endDate, parse
startDate and endDate with new Date(...) and validate with getTime() (or
Number.isNaN(date.getTime())) to detect Invalid Date, and return a 400 error
when either date is invalid or start >= end; ensure the validation runs before
calling any CalDAV functions so only valid, correctly ordered dates are
accepted.

In `@src/features/schedules/schedule.schema.ts`:
- Around line 3-7: timeSlotSchema is too permissive: it allows non-integers and
ranges where startHour === endHour or startHour > endHour, which later fails
repository validation; update timeSlotSchema to require integers for startHour
and endHour (use .int()) and add a cross-field refinement on the object (refine)
to enforce startHour < endHour, keeping the 0..24 bounds (allow endHour==24 if
desired) so route-level validation matches repository expectations.

In `@src/lib/__tests__/errors.test.ts`:
- Around line 1-12: Expand the tests in src/lib/__tests__/errors.test.ts to
explicitly verify the public contract exported from "../errors": assert that
MoimError, UnauthorizedError, ForbiddenError, ExternalServiceError and the
Everytime* classes (EverytimeError, EverytimeAuthError, EverytimeFetchError,
EverytimeScrapeError) are exported (e.g., typeof checks or instanceof use) and
that each error exposes expected shape fields (statusCode/code and for
Everytime* errors that type === "everytime" and details.type maps correctly to
"auth"/"fetch"/"scrape" as appropriate); add boundary cases for missing/empty
message and invalid details to ensure constructors on these classes normalize
fields consistently.

In `@src/lib/api-handler.ts`:
- Around line 47-48: The wrapper currently calls getSession() unconditionally
(see getSession reference in src/lib/api-handler.ts), which ties all routes to
Supabase availability; change the wrapper to not call getSession by default and
add an opt-in flag (e.g., loadSession: boolean) on the handler options so only
routes that request session loading (or call requireAuth) will invoke
getSession(); update requireAuth to call getSession() when enforcing auth and
mark public routes (like signup) with loadSession: false to avoid
session-dependent failures.

In `@src/lib/auth/naver.ts`:
- Around line 40-43: The redirect URI logic currently lets
process.env.NAVER_REDIRECT_URI override a per-request origin causing
multi-origin OAuth to break; update the implementation so that when an origin
argument is provided it takes highest precedence, otherwise fall back to
process.env.NAVER_REDIRECT_URI and lastly to a localhost default; centralize
this into a single helper (e.g., computeNaverRedirectUri or similar) and replace
duplicated logic at the sites referenced by base/redirectUri and the other
occurrence (around lines 73-76) so both authorize and token exchanges use the
same computed redirect URI.

In `@src/lib/calendar/adapter.ts`:
- Around line 58-71: Replace the use of unknown by parameterizing the adapter
with generics so the adapter boundary enforces provider-specific auth and result
shapes: make the abstract adapter generic (e.g., CalendarAdapter<AuthT,
CreateResultT>), change createEvent(auth: unknown, ...) to createEvent(auth:
AuthT, ...) returning Promise<CreateResultT>, and change listCalendars and
queryEvents signatures to use AuthT (listCalendars(auth: AuthT):
Promise<CommonCalendar[]> and queryEvents(auth: AuthT, targetIdentifier: string,
start: Date, end: Date): Promise<CalendarEvent[]>); update any implementing
classes to supply concrete types for AuthT and CreateResultT to restore
compile-time safety.

In `@src/lib/calendar/adapters/__tests__/manual.test.ts`:
- Around line 47-58: Rename the test and make assertions behavior-focused
instead of tied to implementation details: change the describe/it text to
describe the expected behavior (e.g., "converts manual slots to calendar events
with correct title and timing"), remove the strict id format assertion against
manualAdapter.toCalendarEvents (don't assert exact "manual:0:MON-9-11"), and
instead assert that an id exists/is non-empty if needed and verify public
outputs like title ("가용"), startAt.getHours() and endAt.getHours(), and that the
source/slot mapping is preserved; note that id uniqueness is already covered
elsewhere so omit duplicative id format checks in this test.

In `@src/lib/calendar/adapters/manual.ts`:
- Around line 58-79: The toCalendarEvents flow must normalize and validate
weekStart and slot hour bounds before mapping: ensure weekStart (in
toCalendarEvents) is normalized to Monday 00:00 (use or adapt getThisMonday
logic) and replace raw.weekStart with that normalized Date; validate each slot's
startHour and endHour in toCalendarEvents (or just before calling
mapToCalendarEvent) to be integers within 0..24 and require startHour < endHour
(fail-fast by throwing a clear Error); additionally clamp/normalize non-integer
hours if your domain allows or reject them explicitly; then pass the validated
slot and the normalized weekStart into mapToCalendarEvent so downstream code
never receives bad absolute times.

In `@src/lib/calendar/adapters/photo.ts`:
- Around line 13-87: The photo adapter currently exports a runtime instance
(photoAdapter) that throws on use; remove the exported instance to avoid
accidental runtime 500s and instead only export the PhotoCalendarAdapter class
(keep class name PhotoCalendarAdapter and the internal
constructor/implementation unchanged) and create/ export the photoAdapter
instance later at the provider/registry registration site where the feature
becomes enabled; ensure no other modules rely on the exported photoAdapter and
update registration code to instantiate and export the adapter when registering
the "photo" provider.

In `@src/lib/services/auth-service.ts`:
- Around line 123-160: The E2E path currently still calls createAdminClient()
for nickname lookups which can throw and break isolated tests; move the E2E
check (process.env.E2E_TEST === "true") to run immediately after normalizing
loginId so no Supabase admin calls occur during E2E, and inside that branch
resolve users via Prisma only (use prisma.user.findUnique/findFirst by email or
by nickname using the normalized loginId) rather than calling
createAdminClient(); keep Supabase admin lookup (createAdminClient(),
.from("profiles")...) only in the non-E2E branch.
- Around line 196-210: The code treats a missing profiles row as success; update
the block that calls supabase.from("profiles").select(...).maybeSingle() to
throw an error when profile is null/undefined (i.e. check !profile after the
query) instead of returning success: true with fallbacks; ensure the thrown
error bubbles to the common error handler so /api/auth/me returns a failure when
the profiles row for userId is absent (reference the variables profile,
profileError, userId and the maybeSingle() call).

In `@src/lib/services/everytime-service.ts`:
- Around line 7-10: EverytimeResult currently exposes timetable and freeSlots as
unknown; tighten the contract by introducing concrete types (e.g., define a
Timetable interface/object shape and a FreeSlot type or FreeSlot[] array) and
update EverytimeResult to use those types instead of unknown; then update the
return signatures and implementations of processUrl and processIcs to return
EverytimeResult with the new Timetable and FreeSlot types so callers, routes,
and tests get compile-time type safety (refer to EverytimeResult, timetable,
freeSlots, processUrl, processIcs).
- Around line 21-36: The Supabase auth calls currently assume exceptions but
return {data, error}; update the createClient usage in the function that calls
supabase.auth.getUser() and supabase.auth.updateUser() to check the returned
error fields and throw when present so the existing catch logs failures
(specifically inspect the results of supabase.auth.getUser() and
supabase.auth.updateUser() and throw the returned error if non-null), and apply
the same error-check-and-throw pattern to the processIcs function where Supabase
auth/update is used so metadata write failures surface to the existing catch
logger instead of being silently ignored.

In `@src/lib/services/schedule-service.ts`:
- Around line 98-119: The current flow always calls dbConfirmScheduleByCreator
when session exists, causing logged-in non-creators to take the creator path and
fail; fix by only using the creator-confirmation path when the session user is
actually the creator (e.g., check creator explicitly or call
dbConfirmScheduleByCreator and if it returns/throws a not-found/forbidden result
treat the user as non-creator and continue), otherwise fall through to the
hostToken branch; also ensure bodyHostToken is trimmed before deciding presence
(use trimmedBodyHostToken = typeof bodyHostToken === "string" ?
bodyHostToken.trim() : "" and treat empty string as absent) so whitespace-only
tokens are rejected before calling dbConfirmSchedule(id, hostToken,
confirmedSlot).
- Around line 121-130: In the catch block in
src/lib/services/schedule-service.ts (the code that computes message from error
and derives status), do not default unknown exceptions to 400; instead map only
known application errors ("schedule not found" -> 404, "invalid host token" ->
403) to those client statuses and treat all other/unmapped errors as server
errors (set status = 500 or rethrow) so infrastructure/observability and global
500 handling remain effective; update the logic around the variables
error/message/status in that catch handler (or the enclosing method) to return a
500 for unexpected errors or propagate the error to the common handler.

In `@src/lib/supabase/server.ts`:
- Around line 252-257: The lookup for the Naver mock user is incomplete: in
prisma.user.findFirst (the targetUser lookup) you only check { id: naverId } and
the synthetic email but not the upsert pattern id = `naver_${naverId}` (and you
later use .eq("naver_id", naverId)), which causes duplicate mock users; update
the OR conditions used in prisma.user.findFirst to also include { id:
`naver_${naverId}` } or alternatively check the naver_id column (e.g., include {
naver_id: naverId }) so the lookup matches users created via the upsert path;
apply the same fix to the other occurrence around the second find/update block
(the later prisma lookup at lines 304-310).
- Around line 13-17: The createClient function currently calls getSupabaseConfig
and createServerClient before checking the E2E_TEST mock branch, which causes
immediate exceptions when server env vars are absent; change createClient to
first read the E2E_TEST flag (and cookies() if needed for the mock) and, if
E2E_TEST === "true", return the Proxy/mock client immediately without invoking
getSupabaseConfig or createServerClient; otherwise proceed to call
getSupabaseConfig and createServerClient as before. Ensure references:
createClient, getSupabaseConfig, createServerClient, and the Proxy/mock return
path are updated so the mock branch short-circuits prior to any config/client
creation.

---

Outside diff comments:
In `@src/lib/supabase/server.ts`:
- Around line 84-95: The mock auth returns undefined user IDs when no cookie
exists; update the signInWithPassword (and similarly updateUser) mock
implementations to always return a valid id by falling back to mockUid when
req.cookies or any cookie-derived id is missing — i.e., in signInWithPassword
and updateUser ensure user.id is set to mockUid (or a deterministic fallback)
rather than undefined so the E2E_TEST === "true" branch's response matches
Supabase's contract and callers can safely use user.id as a key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0ea01404-52dc-4c0c-b62c-8319bc904438

📥 Commits

Reviewing files that changed from the base of the PR and between e3d3e5b and 7c502d7.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json, !**/*.json, !package-lock.json
  • package.json is excluded by !**/*.json
📒 Files selected for processing (49)
  • .env.example
  • e2e/host-flow.spec.ts
  • prisma/schema.prisma
  • scripts/ensure-sqlite-schema.mjs
  • src/app/api/auth/login/route.ts
  • src/app/api/auth/me/route.ts
  • src/app/api/auth/naver/callback/route.ts
  • src/app/api/auth/naver/login/route.ts
  • src/app/api/auth/signup/route.ts
  • src/app/api/calendar/events/create/route.ts
  • src/app/api/calendar/events/query/route.ts
  • src/app/api/calendar/list/route.ts
  • src/app/api/everytime/timetable/route.ts
  • src/app/api/google/auth/route.ts
  • src/app/api/google/calendars/route.ts
  • src/app/api/google/callback/route.ts
  • src/app/api/google/disconnect/route.ts
  • src/app/api/google/events/create/route.ts
  • src/app/api/google/events/query/route.ts
  • src/app/api/icloud/calendars/route.ts
  • src/app/api/icloud/events/create/route.ts
  • src/app/api/icloud/events/query/route.ts
  • src/app/api/schedules/[id]/route.ts
  • src/features/schedules/schedule.schema.ts
  • src/lib/__tests__/api-handler.test.ts
  • src/lib/__tests__/errors.test.ts
  • src/lib/api-handler.ts
  • src/lib/auth/naver.ts
  • src/lib/auth/session.ts
  • src/lib/caldav/client.ts
  • src/lib/calendar/adapter.ts
  • src/lib/calendar/adapters/__tests__/manual.test.ts
  • src/lib/calendar/adapters/google.ts
  • src/lib/calendar/adapters/icloud.ts
  • src/lib/calendar/adapters/manual.ts
  • src/lib/calendar/adapters/photo.ts
  • src/lib/errors.ts
  • src/lib/everytime/auth.ts
  • src/lib/everytime/timetable.ts
  • src/lib/everytime/url-scraper.ts
  • src/lib/google/__tests__/auth.test.ts
  • src/lib/google/auth.ts
  • src/lib/services/auth-service.ts
  • src/lib/services/everytime-service.ts
  • src/lib/services/schedule-service.ts
  • src/lib/supabase/client.ts
  • src/lib/supabase/env.ts
  • src/lib/supabase/server.ts
  • supabase/migrations/20260611000000_profiles_naver_id.sql

Comment thread .env.example
Comment thread prisma/schema.prisma Outdated
Comment on lines +52 to +65
try {
const events = await googleAdapter.queryEvents(
{ accessToken: tokens.accessToken },
targetIdentifier,
start,
end,
);
return NextResponse.json({ events });
} catch (err) {
console.error("[calendar.events.query] Google 일정 조회 오류:", err);
return NextResponse.json(
{ error: "일정 조회 중 오류가 발생했습니다." },
{ status: 502 },
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

공통 provider 라우트에서 Google 만료 오류 매핑이 드리프트했습니다.

근거: src/app/api/calendar/events/query/route.tssrc/app/api/calendar/list/route.ts는 같은 root cause를 공유합니다. 두 파일 모두 Google 전용 라우트가 이미 유지하는 401 만료 계약을 버리고 502로 뭉개고 있어, 재연결 플로우와 클라이언트 분기 기준이 함께 깨집니다.
수정: 두 라우트에 동일한 만료 매핑 헬퍼를 두고, Google 분기에서 공통으로 재사용하세요.

수정 예시
+function mapGoogleExpiredToken(err: unknown, message: string) {
+  if (err instanceof Error && err.message.includes("만료")) {
+    return NextResponse.json({ error: message }, { status: 401 });
+  }
+  return null;
+}
...
    } catch (err) {
+     const expired = mapGoogleExpiredToken(
+       err,
+       "Google 인증이 만료되었습니다. 다시 연결해주세요.",
+     );
+     if (expired) return expired;
      return NextResponse.json(
        { error: "캘린더 목록 조회 중 오류가 발생했습니다." },
        { status: 502 },
      );
    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/calendar/events/query/route.ts` around lines 52 - 65, The Google
auth-expiry handling in src/app/api/calendar/events/query/route.ts is swallowing
401->expired errors as 502; update the catch path to detect Google token-expiry
and return the standardized 401 expiry response by reusing a shared helper
(e.g., isGoogleAuthExpiredError(err) and mapGoogleAuthErrorToResponse(err) or
buildGoogleExpiryResponse()) used by the list route; modify the catch around
googleAdapter.queryEvents to call that helper and return the mapped NextResponse
(401 when expired) otherwise log and return the 502 error as before so both
routes share identical expiry mapping and reconnection flow.

Comment on lines +78 to +84
try {
const events = await icloudAdapter.queryEvents(
{ username: connection.appleId, password: connection.password },
targetIdentifier,
start,
end,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

클라이언트가 준 iCloud 캘린더 URL을 그대로 CalDAV 호출에 쓰고 있습니다.

근거: src/app/api/calendar/events/query/route.ts, src/app/api/icloud/events/create/route.ts, src/app/api/icloud/events/query/route.ts 모두 같은 root cause를 가집니다. 사용자 입력 URL을 그대로 쓰면 서버가 저장한 Apple 자격증명을 공격자 호스트로 보낼 수 있어, 단순 입력 검증 문제가 아니라 자격증명 유출 취약점입니다.
수정: 세 파일 모두 공통 assertAllowedIcloudCalendar 같은 서버 측 검증 단계를 추가해, 현재 계정으로 발견된 캘린더 URL만 허용하세요.

수정 예시
+async function assertAllowedIcloudCalendar(
+  connection: { appleId: string; password: string },
+  calendarUrl: string,
+) {
+  const calendars = await icloudAdapter.listCalendars({
+    username: connection.appleId,
+    password: connection.password,
+  });
+  return calendars.find((item) => item.id === calendarUrl) ?? null;
+}
...
+const allowedCalendar = await assertAllowedIcloudCalendar(connection, calendarUrl);
+if (!allowedCalendar) {
+  return NextResponse.json(
+    { error: "허용되지 않은 iCloud 캘린더입니다." },
+    { status: 400 },
+  );
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/calendar/events/query/route.ts` around lines 78 - 84, The route
is using the client-supplied iCloud calendar URL (targetIdentifier) directly
which can leak server-stored Apple credentials to attacker-controlled hosts; add
a server-side whitelist check by implementing
assertAllowedIcloudCalendar(connection, targetIdentifier) and calling it before
any icloudAdapter query/create calls (e.g., before icloudAdapter.queryEvents in
the shown route). Implement assertAllowedIcloudCalendar to authenticate with the
stored credentials (connection.appleId, connection.password), retrieve the list
of calendars via the iCloud adapter discovery/listing API, and verify that
targetIdentifier exactly matches one of the discovered calendar URLs; if not,
throw/return a 4xx error. Apply the same check in the other affected handlers
that accept a client calendar URL (the create and query iCloud routes) so only
calendars discovered for the current account are allowed.

Comment on lines +9 to 12
export const POST = createApiHandler({}, async ({ req }) => {
const contentType = req.headers.get("content-type") ?? "";
const candidateDays = parseCandidateDays(req);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

잘못된 days 쿼리를 기본값으로 덮어쓰면 결과가 틀어집니다.

근거(Why): 지금 구현은 유효하지 않은 토큰을 조용히 버린 뒤 전부 버려지면 undefined를 반환합니다. 그러면 downstream 기본값(월~금)이 적용되어, 잘못된 요청이 400이 아니라 정상 200과 잘못된 freeSlots로 보입니다.

수정(How):

`누락`과 `잘못된 값`을 분리하는 최소 수정
 export const POST = createApiHandler({}, async ({ req }) => {
   const contentType = req.headers.get("content-type") ?? "";
   const candidateDays = parseCandidateDays(req);
+  if (candidateDays === null) {
+    return NextResponse.json(
+      { error: "days 쿼리는 MON~SUN 값만 허용됩니다." },
+      { status: 400 },
+    );
+  }

   if (contentType.includes("application/json")) {
     return handleUrlRequest(req, candidateDays);
   }
@@
-function parseCandidateDays(req: NextRequest): DayCode[] | undefined {
+function parseCandidateDays(req: NextRequest): DayCode[] | undefined | null {
   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)),
-    ),
-  );
+  const tokens = raw
+    .split(",")
+    .map((d) => d.trim().toUpperCase())
+    .filter(Boolean);
+  if (tokens.some((d) => !VALID_DAYS.has(d as DayCode))) {
+    return null;
+  }
+  const days = Array.from(new Set(tokens as DayCode[]));
   return days.length > 0 ? days : undefined;
 }

Also applies to: 166-177

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/everytime/timetable/route.ts` around lines 9 - 12, The handler
currently calls parseCandidateDays inside POST and treats its undefined result
the same as a missing query, letting downstream defaults (Mon-Fri) silently mask
invalid input; change parseCandidateDays (or its call in POST) to differentiate
"no days param provided" from "days param provided but all tokens invalid" and
return a 400 Bad Request for the latter. Specifically, ensure parseCandidateDays
exposes whether the param was present (e.g., returns a discriminated result or
throws on invalid tokens) and update the POST route handler (and the other
handler using parseCandidateDays) to respond 400 when the input was present but
invalid, while still allowing undefined when the param is genuinely missing so
downstream defaults remain applied.

Comment thread src/lib/services/everytime-service.ts Outdated
Comment on lines +21 to +36
try {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (user) {
await supabase.auth.updateUser({
data: {
everytime_url: url,
everytime_slots: freeSlots,
},
});
}
} catch (err) {
console.error("[everytime] 유저 메타데이터 저장 실패:", err);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

@supabase/supabase-js 2.x 공식 문서에서 auth.getUser()와 auth.updateUser()가 실패 시 예외를 throw하는지, 아니면 { data, error } 형태로 반환하는지 확인해 주세요.

💡 Result:

@supabase/supabase-js 2.x 공식 문서와 SDK 설계에 따르면, auth.getUser와 auth.updateUser를 포함한 대부분의 Supabase SDK 메서드는 예외를 throw하는 대신 { data, error } 객체를 반환합니다 [1][2][3]. 이러한 설계는 호출 결과가 성공했는지 실패했는지를 개발자가 직접 확인하도록 유도하며, 에러 발생 시 throw 대신 error 속성에 AuthError 객체를 담아 반환합니다 [4][3][5]. 따라서 코드를 작성할 때는 예외 처리(try-catch)가 아닌, 반환된 객체의 error 속성을 체크하는 방식을 사용하는 것이 공식 권장 사항입니다 [3]. 사용 예시: const { data, error } = await supabase.auth.getUser; if (error) { // 에러 처리 로직 console.error(error.message); } else { // 성공 로직 console.log(data.user); }

Citations:


Supabase auth.getUser()/auth.updateUser() 실패를 catch가 아닌 반환 error로 처리하세요(메타데이터 조용한 누락 방지)

Why: Supabase JS(v2) auth 메서드는 실패 시 예외를 던지기보다 { data, error }를 반환합니다. 현재처럼 error를 확인하지 않으면 저장 실패가 예외로 이어지지 않아 catch가 실행되지 않고, 메서드는 성공처럼 마무리되어 메타데이터가 누락됩니다.

How: 반환된 error를 즉시 처리(throw → 기존 catch 로깅)하고, processIcs에도 동일 패턴을 적용하세요.

processUrl: 반환 `error` 즉시 처리
      const {
         data: { user },
-      } = await supabase.auth.getUser();
+        error: getUserError,
+      } = await supabase.auth.getUser();
+      if (getUserError) throw getUserError;
       if (user) {
-        await supabase.auth.updateUser({
+        const { error: updateUserError } = await supabase.auth.updateUser({
           data: {
             everytime_url: url,
             everytime_slots: freeSlots,
           },
         });
+        if (updateUserError) throw updateUserError;
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/services/everytime-service.ts` around lines 21 - 36, The Supabase
auth calls currently assume exceptions but return {data, error}; update the
createClient usage in the function that calls supabase.auth.getUser() and
supabase.auth.updateUser() to check the returned error fields and throw when
present so the existing catch logs failures (specifically inspect the results of
supabase.auth.getUser() and supabase.auth.updateUser() and throw the returned
error if non-null), and apply the same error-check-and-throw pattern to the
processIcs function where Supabase auth/update is used so metadata write
failures surface to the existing catch logger instead of being silently ignored.

Source: Coding guidelines

Comment thread src/lib/services/schedule-service.ts Outdated
Comment on lines +98 to +119
try {
// 1. 로그인 유저가 생성자(호스트)인지 먼저 확인하여 확정
if (session) {
const schedule = await dbConfirmScheduleByCreator(
id,
session.userId,
confirmedSlot,
);
return { success: true, status: 200, schedule };
}

// 2. hostToken 기반 확정 처리
const hostToken =
typeof bodyHostToken === "string" && bodyHostToken.trim()
? bodyHostToken
: cookieHostToken;

if (!hostToken) {
return { success: false, status: 400, error: "hostToken is required" };
}

const schedule = await dbConfirmSchedule(id, hostToken, confirmedSlot);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

로그인된 비생성자는 유효한 hostToken으로도 확정할 수 없습니다.

Why: getSchedule()은 세션이 있어도 creator가 아니면 hostToken 경로로 계속 진행하는데, 여기서는 Line 100에서 creator 전용 확정으로 바로 고정됩니다. 그래서 로그인된 사용자가 같은 호스트 링크로 조회는 성공해도 PATCH는 404/400으로 깨집니다. 같은 분기에서 bodyHostToken.trim() 결과도 버려서 공백 포함 토큰까지 그대로 내려갑니다.

How:

최소 수정 예시
-      // 1. 로그인 유저가 생성자(호스트)인지 먼저 확인하여 확정
-      if (session) {
-        const schedule = await dbConfirmScheduleByCreator(
-          id,
-          session.userId,
-          confirmedSlot,
-        );
-        return { success: true, status: 200, schedule };
-      }
-
-      // 2. hostToken 기반 확정 처리
-      const hostToken =
-        typeof bodyHostToken === "string" && bodyHostToken.trim()
-          ? bodyHostToken
-          : cookieHostToken;
+      const normalizedBodyHostToken =
+        typeof bodyHostToken === "string" ? bodyHostToken.trim() : "";
+      const hostToken = normalizedBodyHostToken || cookieHostToken;
+
+      // 1. 로그인 유저가 실제 생성자인 경우에만 creator 경로 사용
+      if (session) {
+        const creatorSchedule = await getScheduleForCreator(id, session.userId);
+        if (creatorSchedule) {
+          const schedule = await dbConfirmScheduleByCreator(
+            id,
+            session.userId,
+            confirmedSlot,
+          );
+          return { success: true, status: 200, schedule };
+        }
+      }
 
       if (!hostToken) {
         return { success: false, status: 400, error: "hostToken is required" };
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/services/schedule-service.ts` around lines 98 - 119, The current flow
always calls dbConfirmScheduleByCreator when session exists, causing logged-in
non-creators to take the creator path and fail; fix by only using the
creator-confirmation path when the session user is actually the creator (e.g.,
check creator explicitly or call dbConfirmScheduleByCreator and if it
returns/throws a not-found/forbidden result treat the user as non-creator and
continue), otherwise fall through to the hostToken branch; also ensure
bodyHostToken is trimmed before deciding presence (use trimmedBodyHostToken =
typeof bodyHostToken === "string" ? bodyHostToken.trim() : "" and treat empty
string as absent) so whitespace-only tokens are rejected before calling
dbConfirmSchedule(id, hostToken, confirmedSlot).

Comment thread src/lib/services/schedule-service.ts Outdated
Comment on lines +121 to +130
} catch (error) {
const message =
error instanceof Error ? error.message : "invalid request";
const status =
message === "schedule not found"
? 404
: message === "invalid host token"
? 403
: 400;
return { success: false, status, error: message };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

알 수 없는 예외를 전부 400으로 누르면 서버 장애가 클라이언트 오류로 위장됩니다.

Why: 여기서 매핑하지 않은 예외까지 400으로 바꾸면 저장소 실패, DB 장애, 버그가 전부 잘못된 요청처럼 보입니다. 그러면 공통 핸들러의 500 처리와 관측이 모두 무력화됩니다.

How:

최소 수정 예시
-    } catch (error) {
-      const message =
-        error instanceof Error ? error.message : "invalid request";
-      const status =
-        message === "schedule not found"
-          ? 404
-          : message === "invalid host token"
-            ? 403
-            : 400;
-      return { success: false, status, error: message };
+    } catch (error) {
+      if (!(error instanceof Error)) {
+        throw error;
+      }
+
+      if (error.message === "schedule not found") {
+        return { success: false, status: 404, error: error.message };
+      }
+      if (error.message === "invalid host token") {
+        return { success: false, status: 403, error: error.message };
+      }
+      if (
+        error.message === "hour range must be an integer range between 0 and 24" ||
+        error.message === "confirmed slot must stay inside the candidate window" ||
+        error.message === "confirmed slot must be inside the current common slots"
+      ) {
+        return { success: false, status: 400, error: error.message };
+      }
+
+      throw error;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/services/schedule-service.ts` around lines 121 - 130, In the catch
block in src/lib/services/schedule-service.ts (the code that computes message
from error and derives status), do not default unknown exceptions to 400;
instead map only known application errors ("schedule not found" -> 404, "invalid
host token" -> 403) to those client statuses and treat all other/unmapped errors
as server errors (set status = 500 or rethrow) so infrastructure/observability
and global 500 handling remain effective; update the logic around the variables
error/message/status in that catch handler (or the enclosing method) to return a
500 for unexpected errors or propagate the error to the common handler.

Comment thread src/lib/supabase/server.ts
Comment thread src/lib/supabase/server.ts Outdated
@Siul49
Siul49 force-pushed the feature/refactor-calendar-adapters-and-errors branch from 7af1d30 to 62e3723 Compare June 12, 2026 11:55
PR #64에 대한 CodeRabbit 리뷰 중 현재 코드에 유효한 항목 반영:

- iCloud query/create 라우트가 클라이언트 calendarUrl을 검증 없이 CalDAV에
  넘겨 저장된 Apple 자격증명이 임의 호스트로 유출될 수 있던 문제 차단
  (isIcloudCalendarUrl 화이트리스트 + 단위 테스트)
- Google OAuth: 인증 시작 시 origin을 쿠키에 저장하고 콜백에서 재사용해
  redirect_uri 드리프트(www↔apex 등)를 방지 (쿠키 부재 시 요청 origin 폴백)
- manual 어댑터: weekStart를 월요일 0시로 정규화하고 잘못된 슬롯 검증
- errors: 공개 진입점 재-export 동일성 및 Everytime* details 매핑 테스트 보강

스킵: services/*·calendar/events·adapter 제네릭(현재 코드에 없음, stale),
schedule 확정/예외 매핑·Google 만료 매핑(이미 반영됨),
naver redirect 우선순위(env 우선이 기존 테스트로 고정된 의도된 동작)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dar-adapters-and-errors

# Conflicts:
#	src/app/api/google/auth/route.ts
#	src/app/api/google/callback/route.ts
#	src/app/api/icloud/events/create/route.ts
#	src/app/api/icloud/events/query/route.ts
#	src/lib/__tests__/errors.test.ts
#	src/lib/calendar/adapters/manual.ts
@Siul49
Siul49 merged commit 52ba97e into dev Jun 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] API 엔드포인트 SRP 및 DRY 리팩토링

2 participants