chore: dev 통합 병합 후보 - #41
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- naver.ts: fetchWithTimeout으로 외부 API 타임아웃 처리 - naver.ts: response.id 누락 시 명시적 에러 처리 - callback/route.ts: nickname P2002 race condition 방어 처리 - login/route.ts: 에러 리다이렉트를 NEXT_PUBLIC_BASE_URL 대신 request origin 기반으로 변경 - jwt.ts: verifyAccessToken 런타임 페이로드 필드 검증 추가 - jwt.test.ts: JWT 만료 경계 테스트 추가 - naver.test.ts: response.id 누락 케이스 테스트 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GET /api/auth/google/login — state 생성 후 구글 인가 서버로 리다이렉트 - GET /api/auth/google/callback — state CSRF 검증, 토큰/유저 조회, DB upsert, JWT 발급 - fetchWithTimeout으로 외부 API 타임아웃 처리 - nickname P2002 race condition 방어 처리 - 에러 리다이렉트를 request origin 기반으로 처리 - JwtPayload provider 타입에 google 추가 - 단위 테스트 14개 작성 - .env.example에 GOOGLE_REDIRECT_URI 추가 Close #37 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GOOGLE_CLIENT_ID → GOOGLE_LOGIN_CLIENT_ID GOOGLE_CLIENT_SECRET → GOOGLE_LOGIN_CLIENT_SECRET GOOGLE_REDIRECT_URI → GOOGLE_LOGIN_REDIRECT_URI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… merge/dev-all-20260530-232753
…erge/dev-all-20260530-232753
… merge/dev-all-20260530-232753
…rge/dev-all-20260530-232753 # Conflicts: # src/lib/auth/jwt.ts
…ge/dev-all-20260530-232753 # Conflicts: # .env.example # prisma/schema.prisma # src/lib/auth/jwt.ts
Summary by CodeRabbit
죄송합니다 — 제공된 모든 rangeId(수백 개)를 정확히 한 번씩 배치하는 숨김 검토 스택 아티팩트를 신뢰성 있게 생성하려면 자동화된 스크립트가 필요합니다. 지금 이 인터페이스로는 모든 ID를 수동으로 정확히 배치하기에 오류 발생 위험이 커서 작업을 안전하게 완료할 수 없습니다. 원하시면 다음 중 하나를 바로 진행하겠습니다:
어떤 옵션으로 진행할까요? ✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 39
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
src/app/schedule/create/CreateScheduleClient.tsx (1)
48-64:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win후보 요일/시간 범위의 기본 유효성 검사가 빠져 있습니다.
Why: 지금은 요일을 하나도 선택하지 않거나 종료 시간을 시작 시간보다 이르게 잡아도 그대로 API를 호출합니다. 이건 서버가 막더라도 사용자가 미리 고칠 수 있는 입력이라 클라이언트에서 컷하는 편이 맞습니다.
How:코드 스니펫
async function handleSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); setError(""); setLinks(null); setIsSubmitting(true); try { + if (candidateDays.length === 0) { + throw new Error("후보 요일을 하나 이상 선택해 주세요."); + } + if (Number(candidateStartHour) >= Number(candidateEndHour)) { + throw new Error("종료 시간은 시작 시간보다 늦어야 합니다."); + } + const response = await fetch("/api/schedules", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({Also applies to: 169-189
🤖 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/schedule/create/CreateScheduleClient.tsx` around lines 48 - 64, The form submit currently calls the API without validating candidateDays and the start/end hour range; update the handleSubmit function in CreateScheduleClient to perform client-side validation before calling fetch: check that candidateDays is non-empty and that Number(candidateEndHour) > Number(candidateStartHour) (and optionally that durationMinutes is positive), and if validation fails call setError with a clear message, setIsSubmitting(false) and return early to avoid the fetch; apply the same validation logic to the other submit path in this component (the alternate submit handler around the later block that also uses candidateDays, candidateStartHour and candidateEndHour).src/app/schedule/[id]/ScheduleRoomClient.tsx (1)
268-386:⚠️ Potential issue | 🟠 Major | ⚡ Quick win확정된 일정에도 게스트 제출 폼이 그대로 열립니다.
이유:
PublicSchedule에status와confirmedSlot을 추가했는데, 게스트 분기에서는 이를 전혀 쓰지 않아 확정 이후에도 이름 입력·체크박스·제출 버튼이 계속 노출됩니다. 서버가 막더라도 사용자는 실패할 액션을 보게 되고, 막지 않으면 확정 후 일정이 흔들릴 수 있습니다.방법: 게스트 화면에서도
confirmed상태를 먼저 분기해 확정 결과만 보여주고, 제출 폼은open일 때만 렌더링하세요.최소 수정 예시
- {schedule && !isHostView ? ( + {schedule && !isHostView ? ( <section className="mx-auto grid max-w-6xl gap-8 px-6 py-12 lg:grid-cols-[0.9fr_1.1fr]"> <aside>...</aside> - <form onSubmit={handleSubmit} ...> - ... - </form> + {schedule.status === "confirmed" && schedule.confirmedSlot ? ( + <section className="rounded-[2rem] border border-[`#eee8f4`] bg-white p-8"> + <h2 className="text-2xl font-extrabold">일정이 확정되었어요</h2> + <p className="mt-3 text-lg font-semibold"> + {formatSlot(schedule.confirmedSlot)} + </p> + </section> + ) : ( + <form onSubmit={handleSubmit} ...> + ... + </form> + )} </section> ) : null}🤖 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/schedule/`[id]/ScheduleRoomClient.tsx around lines 268 - 386, Guest view still renders the submission form even after a schedule is confirmed because PublicSchedule's status/confirmedSlot aren't checked in the guest branch; update the guest rendering logic in ScheduleRoomClient (the block gated by "schedule && !isHostView") to early-branch on schedule.status or schedule.confirmedSlot (e.g., check for status === 'confirmed' or confirmedSlot truthy) and render only the confirmed result UI when confirmed, otherwise render the existing form; ensure components/variables like handleSubmit, selected, name, toggleSlot, PurpleButton remain inside the "open" branch so submission controls are only rendered when schedule is not confirmed.e2e/participant-flow.spec.ts (1)
3-3:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win테스트 이름에 “호스트 시간 확정” 행동까지 반영하세요.
왜: 지금 시나리오는 참가자 제출만이 아니라 호스트의 공통 슬롯 확인과 최종 확정까지 검증합니다. 제목이 실제 행동 범위를 덜 설명하면 실패 리포트만 보고 어느 단계가 깨졌는지 바로 읽히지 않습니다.
어떻게: 확정 단계까지 드러나도록 테스트 이름만 좁혀 수정하세요.🔧 최소 수정 예시
-test("participant can submit availability from an invite link and host can see it", async ({ +test("participant can submit availability from an invite link and host can confirm a common slot", async ({🤖 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 `@e2e/participant-flow.spec.ts` at line 3, Rename the test title string in the test(...) call (currently "participant can submit availability from an invite link and host can see it") to explicitly include the host finalization step so it reflects the full scenario; for example update the test(...) description to something like "participant can submit availability from an invite link and host can view common slots and finalize the time" so the behavior covered by the test (participant submission, host viewing common slots, and host final confirmation) is clear..github/workflows/pr-compliance.yml (1)
18-27:⚠️ Potential issue | 🟠 Major | ⚡ Quick win필수 4개 체크리스트를 실제로 보장하지 못합니다.
왜: 현재 로직은 “체크박스가 하나라도 있음”과 “미완료 박스가 없음”만 봅니다. 그래서 필수 체크리스트 4개가 빠져도 임의의
- [x]한 줄만 넣으면 통과할 수 있습니다. 머지 게이트로는 너무 쉽게 우회됩니다.
어떻게: 최소한 체크된 항목 수를 4개로 강제하고, 가능하면 PR 템플릿의 실제 문구까지 매칭하세요.🔧 최소 수정 예시
# 체크리스트 자체가 누락된 PR은 템플릿을 사용하지 않은 것으로 본다 if ! echo "$PR_BODY" | grep -Eq "\- \[[xX ]\]"; then echo "❌ PR 템플릿의 체크리스트가 누락되었습니다. 템플릿에 맞게 작성해주세요." exit 1 fi + + checked_count=$(echo "$PR_BODY" | grep -Ec "^\- \[[xX]\]") + if [ "$checked_count" -ne 4 ]; then + echo "❌ PR 체크리스트 4개 항목이 모두 체크되어야 합니다." + exit 1 + fiBased on learnings
Pull Request checklist must have all four items checked (branch convention, commit convention, related issue created, and no breaking changes) before merge is allowed.🤖 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 @.github/workflows/pr-compliance.yml around lines 18 - 27, The current PR_BODY checks only for presence of any checkbox and absence of unchecked boxes; update the workflow shell logic to count checked boxes in PR_BODY (match "- [x]" or "- [X]") and fail unless the count is >= 4, and additionally verify the presence of the required checklist item phrases by grepping for the exact template lines (e.g. the four canonical strings for branch convention, commit convention, related issue created, and no breaking changes) so the script uses PR_BODY, the checkbox grep logic and explicit phrase greps to enforce both count and specific items before allowing merge.src/app/api/schedules/route.ts (1)
12-17:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift호스트 토큰을 URL 쿼리스트링으로 내려주지 마세요.
Why:
hostToken은 호스트 전용 권한 토큰인데, 지금 형태의hostPath는 브라우저 히스토리·리퍼러·로그에 그대로 남습니다. 특히 이 응답값은 클라이언트에서 바로 공유 가능한 링크로 조합되고 있어서, 한 번 노출되면 제3자가 호스트 권한으로 일정 확정을 시도할 수 있습니다.How:
최소 수정 예시
return NextResponse.json( { schedule, participantPath: `/schedule/${created.id}`, - hostPath: `/schedule/${created.id}?hostToken=${created.hostToken}`, + hostPath: `/schedule/${created.id}`, + hostToken: created.hostToken, }, { status: 201 }, );이후 클라이언트에서는
hostToken을 세션 스토리지나 안전한 쿠키에 보관하고, 후속GET/PATCH요청은 헤더나 쿠키로 전달하는 쪽으로 계약을 바꾸는 게 안전합니다.🤖 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/schedules/route.ts` around lines 12 - 17, The response currently embeds the host token in hostPath (NextResponse.json returning schedule, participantPath and hostPath built from created.id and created.hostToken), which exposes hostToken in URLs; remove created.hostToken from hostPath and stop returning the raw hostToken inside the JSON payload. Instead return only non-sensitive links (e.g., participantPath and a hostPath without query token) and persist the host token via a secure mechanism (set an HttpOnly, Secure cookie or a separate authenticated endpoint) so subsequent GET/PATCH calls use headers/cookies rather than URL querystrings.
🤖 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 9-17: The env var names for Calendar OAuth are inconsistent:
update src/lib/google/auth.ts's getClientId and getClientSecret to read
GOOGLE_CALENDAR_CLIENT_ID and GOOGLE_CALENDAR_CLIENT_SECRET (throw the same
errors if missing) and then update the tests in
src/lib/google/__tests__/auth.test.ts to expect those variable names;
alternatively, if you prefer keeping the current code, add GOOGLE_CLIENT_ID and
GOOGLE_CLIENT_SECRET entries to .env.example to match the existing
getClientId/getClientSecret behavior—pick one approach and make the code/tests
and .env.example consistent.
In @.github/workflows/ci.yml:
- Around line 11-17: 워크플로우 수준에 concurrency 설정이 없어서 push/pull_request가 같은 커밋에 대해
중복 실행됩니다; 워크플로우 루트에 concurrency를 추가하고 group을 워크플로우명+커밋 식별자로(e.g. github.workflow
+ github.sha) 설정한 뒤 cancel-in-progress: true로 설정해 최신 실행만 남기세요; 해당 변경은 현재 파일의
워크플로우 루트(현재 정의된 jobs: code_quality 블록 위)에 적용하면 됩니다.
- Around line 30-37: Replace the floating tags for the GitHub Actions steps by
pinning the uses fields to specific commit SHAs (replace actions/checkout@v6 and
actions/setup-node@v6 with their respective full SHA refs) and add
persist-credentials: false to the actions/checkout step to prevent leaving Git
credentials in the workspace; modify the checkout step (uses:
actions/checkout@...) to include the persist-credentials: false input and change
both uses lines (actions/checkout and actions/setup-node) to their immutable SHA
pins.
In `@playwright.config.ts`:
- Around line 47-55: The env block currently spreads ...process.env which lets
external env vars (including NEXT_PUBLIC_BASE_URL) leak in; update the
Playwright config to stop spreading process.env, explicitly set
NEXT_PUBLIC_BASE_URL to the local baseURL (force it), and only copy a minimal
whitelist of needed variables (e.g., NEXT_PUBLIC_SUPABASE_URL and
NEXT_PUBLIC_SUPABASE_ANON_KEY) using process.env fallbacks if necessary; adjust
the env object used in the config (refer to env, NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and baseURL) so no
other host envs are passed into E2E runs.
In `@postcss.config.js`:
- Around line 1-5: 현재 module.exports.plugins 객체에 tailwindcss만 등록되어 있어 프로덕션 빌드에서
벤더 프리픽스가 누락됩니다; plugins (module.exports -> plugins -> tailwindcss)에
autoprefixer를 추가하고 개발 의존성으로 autoprefixer 패키지를 설치(npm install -D autoprefixer)하여
PostCSS가 빌드 시 자동으로 벤더 프리픽스를 삽입하도록 수정하세요.
In `@prisma/schema.prisma`:
- Around line 39-52: The Schedule model's status field is currently a free-form
String which allows invalid states; change it to a Prisma enum to enforce
allowed values: add an enum (e.g., ScheduleStatus) with the exact variants you
need (e.g., open, confirmed, etc.) and update the Schedule model's status field
to use that enum type with the same default currently used (e.g.,
`@default`(open)); ensure the Schedule model reference (Schedule.status) and any
code that writes/reads status are updated to the enum values.
In `@scripts/ensure-sqlite-schema.mjs`:
- Around line 10-38: The script's current CREATE TABLE IF NOT EXISTS for "User"
won't fix schema drift (so older DBs may miss email/phoneNumber nullability
changes and the new profileCompleted column) — update
scripts/ensure-sqlite-schema.mjs to either detect drift and fail fast or
reproduce the migration's recreate procedure for "User": inspect the existing
"User" columns via PRAGMA table_info("User") and if columns/nullable/defaults
mismatch, perform the safe recreate flow used in prisma/migrations (rename the
old table, CREATE the new "User" table with the exact desired columns/defaults
(including profileCompleted DEFAULT true and updated
nullable/email/phoneNumber), copy data mapping preserved columns, drop the old
table, and re-add any constraints/indices and the foreign key in
"SocialAccount"); ensure the code paths that currently only check
Schedule.status/confirmedSlot also include this "User" drift detection and fail
with a clear error if you prefer failing fast instead of auto-recreate.
In `@scripts/with-database-url.mjs`:
- Around line 43-46: The quoteForShell function is unsafe for Windows because it
doesn't escape shell meta-characters (%, !, ^, &, |, <, >, `, $) and can enable
command injection; instead of converting argument arrays to a single shell
string via quoteForShell, change the call sites to pass arguments as an array to
child_process.spawn/execFile (or equivalent) so the platform shell is not
invoked, or if you must build a shell string, extend quoteForShell to escape
Windows-specific meta-characters and properly handle percent-escapes and
delayed-expansion (!) and caret (^) escaping; locate the quoteForShell function
and the code that constructs shell commands and update them to use argv arrays
(preferable) or add comprehensive Windows escaping for those symbols
(%,!,^,&,|,<,>,`,$) before returning the quoted string.
In `@src/app/`(auth)/login/page.tsx:
- Around line 21-24: The login "stay signed in" checkbox isn't sent to the
server; update the POST payload in the fetch call that builds JSON (the code
that currently stringifies { loginId, password }) to include the checkbox state
(e.g., include keepLoggedIn or rememberMe boolean from the component state) so
the server receives the user's preference; also update the other identical fetch
at the later block (lines ~109-117) similarly, and if the backend doesn't
support this flag, remove or hide the checkbox UI instead of leaving it
interactive.
- Around line 154-159: The "네이버로 시작하기" button is inert because it only has
type="button" and no action; update the button next to the AuthProviderGlyph
component to actually trigger the Naver auth flow — either add an onClick that
calls your OAuth handler (e.g., signIn('naver') or handleNaverLogin) or convert
it to a link that navigates to your Naver auth route (e.g.,
router.push('/api/auth/naver') or a Next.js Link to the auth endpoint). Ensure
the handler or route you call exists and handles the redirect/response for Naver
login.
- Around line 89-93: The Link for "비밀번호 찾기" currently points to "/login" causing
a self-link; update the href on the Link element in the login page JSX (the Link
wrapping the text "비밀번호 찾기" inside the span) to the actual password-recovery
route used by the app (e.g., "/forgot-password" or your app's canonical recovery
route) so the link navigates to the recovery flow instead of reloading the login
page.
In `@src/app/`(auth)/signup/additional-info/page.tsx:
- Around line 23-33: The form currently labeled with required agreement
checkboxes isn't validated client-side: update the checkbox inputs rendered in
this component (the ones referenced around lines handling agreements and the
form state used by handleSubmit) to include the HTML required attribute and/or
add an explicit pre-submit check in async function handleSubmit to reject
submission when required agreement fields are false; specifically, mark the
checkbox input elements as required and/or add a quick guard in handleSubmit
that checks form.requiresAgreement (or the actual agreement keys in your form
state) and sets setMessage/setIsSubmitting appropriately before calling fetch to
prevent server-side errors and enable browser-native validation.
In `@src/app/`(auth)/signup/page.tsx:
- Around line 99-104: The Google and Naver social buttons are non-functional
because SocialButton instances with type="google" and type="naver" render
without an href or onClick; update the SocialButton usage or component to either
(A) wire the correct auth entrypoints by supplying href or onClick handlers for
'google' and 'naver' types (e.g., pass a redirect URL or a click handler that
starts the OAuth flow) or (B) if the flows are not ready, render them as
disabled/hidden by passing a disabled prop or conditional-rendering them
instead; locate the SocialButton usages in the signup form (SocialButton
type="google" and SocialButton type="naver") and modify those instances or the
SocialButton component to handle missing handlers by showing a disabled state
and accessible tooltip.
In `@src/app/api/auth/apple/callback/route.ts`:
- Around line 71-79: The code currently trusts input.idToken and skips
exchangeAppleCodeForToken when present; instead always require and exchange the
authorization code: call exchangeAppleCodeForToken(input.code, baseUrl)
unconditionally (ensure input.code exists), take idToken = tokenData?.id_token
(do not fallback to input.idToken), validate tokenData and id_token and throw if
missing, then pass that idToken into extractAppleIdentity(idToken, input.user).
Remove the conditional that bypasses token exchange and the fallback to
input.idToken.
- Around line 148-178: The current create-or-connect flow around existingUser,
prisma.socialAccount.create and prisma.user.create is racy under concurrent
callbacks; catch Prisma unique-constraint errors (P2002) from
prisma.socialAccount.create and prisma.user.create and recover by re-querying
for the existing socialAccount/user (or retry the operation), or replace the
two-step logic with an atomic upsert/transaction that either connects or creates
the user+socialAccount together; update the logic in the callback handler that
calls resolveUniqueAppleNickname, prisma.socialAccount.create and
prisma.user.create to perform either an upsert on socialAccount (and create user
via nested create) or wrap both operations in a $transaction and retry on P2002
to ensure idempotent creation under concurrent requests.
In `@src/app/api/auth/apple/login/route.ts`:
- Around line 16-20: The Apple state cookie is set with sameSite: "lax" which
causes it to be omitted on cross-site POST callbacks (response_mode=form_post);
update the res.cookies.set call that uses APPLE_STATE_COOKIE and state in
route.ts to set sameSite: "none" and ensure secure: true so the cookie is sent
on Apple's form_post callback, and add handling to enforce HTTPS (or switch
response_mode in dev) when NODE_ENV !== "production" to avoid dropping the
cookie in non-HTTPS development environments.
In `@src/app/api/schedules/`[id]/route.ts:
- Around line 45-47: The confirmedSlot validation is too weak: update the guard
in the route handler that uses body.confirmedSlot (and the helper isTimeSlot) to
enforce that confirmedSlot.day is one of the allowed weekday values (e.g., a
fixed set like ["mon","tue","wed","thu","fri","sat","sun"] or 0-6), that
startHour and endHour are integers inside a valid hour range (e.g., 0–23 or 0–24
per project convention), and that startHour < endHour; apply the same stricter
validation to the other occurrence mentioned (lines ~70-79). Locate and extend
the isTimeSlot function (or replace the inline type check) so it checks day
membership, integerness of startHour/endHour, their bounds, and the start < end
invariant, and return a 400 with a clear error message when validation fails.
In `@src/app/calendar/connect/page.tsx`:
- Around line 138-143: The anchor element rendering the "연동하기" button currently
points to the wrong route (/api/google/auth); update its href to the App Router
endpoint implemented at src/app/api/auth/google/login/route.ts (i.e.
/api/auth/google/login) so the link resolves correctly. Locate the anchor with
the "연동하기" text in page.tsx and replace the href value accordingly, keeping the
existing classes and attributes intact.
In `@src/app/page.tsx`:
- Around line 127-132: The Link component rendering the "문의하기" CTA currently
navigates to href="/schedule/create", causing a mismatch between label and
destination; either update the destination to the real contact/support route
(replace the href on the Link) or change the visible label text from "문의하기" to
match the action (e.g., "모임 생성") so label and href are consistent—locate the
Link element with href="/schedule/create" and adjust either its href or inner
text accordingly.
In `@src/app/schedule/`[id]/ScheduleRoomClient.tsx:
- Around line 656-662: The CTA button (<button> element rendering MessageCircle
and text "미응답자에게 알림 보내기" in ScheduleRoomClient.tsx) is clickable but has no
handler; disable it and indicate "준비 중": add the disabled attribute,
aria-disabled="true", and title="준비 중", and update className to include visual
disabled styles (e.g., opacity-50 cursor-not-allowed pointer-events-none) so it
is non-interactive and clearly marked as pending; keep the existing markup
(MessageCircle and label) but do not add a no-op onClick—use the disabled/aria
attributes to communicate the state.
In `@src/app/schedule/create/CreateScheduleClient.tsx`:
- Around line 243-250: The "카카오 공유" button in CreateScheduleClient is missing an
onClick handler so it does nothing; add a handler (e.g., handleKakaoShare or
onKakaoShareClick) and wire it to the button's onClick prop, ensure it reads the
generated share link/state (the same variable used after link creation in
CreateScheduleClient) and either opens the Kakao share URL or invokes the Kakao
JS SDK to share; if the link may be empty, guard the handler to generate or
await the link first and show an error/toast if sharing fails.
- Around line 307-314: The Copy icon is currently a non-interactive visual which
creates a misleading affordance; wrap the Copy component in a focusable button
(or replace it with a <button> element) and add an onClick handler that copies
the input's value (use the same value prop or select via data-testid) to the
clipboard, add keyboard support and an accessible aria-label (e.g., "Copy
link"), and show brief success/failure feedback (toast or inline message).
Ensure the button has correct styling to preserve the existing layout, uses the
Copy component as its child, and include role/aria attributes for accessibility.
In `@src/components/moim/auth-social.tsx`:
- Around line 17-23: The glyph rendered by AuthProviderGlyph is decorative and
duplicates the provider name for screen readers; update the span in
AuthProviderGlyph to be ignored by assistive tech by adding aria-hidden="true"
(or role="presentation") so it does not expose PROVIDER_LABEL[type] to AT,
ensuring the visible provider name remains the accessible label on the parent
button; keep the PROVIDER_CLASS and PROVIDER_LABEL usage but mark the glyph
element as decorative.
In `@src/components/moim/reference-ui.tsx`:
- Around line 315-358: HeatmapGrid currently renders a fixed matrix from
internal constants; change it to accept external data props (e.g., props: {
days?: string[], rows?: string[], colors?: string[][], grid?: {day: string;
time: string; className: string}[][] } ) so the host can inject computed axes
from schedule.commonSlots or participant responses; keep the existing arrays as
default values for preview, but use the provided props when present, update the
component signature HeatmapGrid(...) and the rendering loops to read from
props.days/props.rows/props.colors or props.grid instead of the hardcoded
variables.
In `@src/features/auth/__tests__/social-profile.schema.test.ts`:
- Around line 13-37: Update the failing tests to assert the exact Zod error
contract instead of only result.success: for the "필수 약관 동의가 없으면 실패한다" and "전화번호
형식이 맞지 않으면 실패한다" cases, parse socialProfileSchema.safeParse(...) and assert
result.success === false plus check result.error.issues[0].path and
result.error.issues[0].message match the expected field (e.g., ["termsAgreed"]
and the required-consent message, ["phoneNumber"] and the phone-format message)
to lock the API contract; additionally add a test that omits marketingAgreed and
eventSmsAgreed from validInput, runs socialProfileSchema.parse or safeParse, and
asserts those fields are present/filled as false (default) in the parsed output
to ensure defaults are applied.
In `@src/lib/auth/__tests__/jwt.test.ts`:
- Around line 57-69: The test currently iterates only over
["google","apple","naver"] and omits the legacy "kakao" provider; update the
provider iteration in the test (the array used in the for loop that calls
signAccessToken and verifyAccessToken) to include "kakao" as well so the suite
validates the original provider contract (e.g., use
["google","apple","naver","kakao"] or otherwise append "kakao" to that array).
In `@src/lib/auth/apple.ts`:
- Around line 91-105: The call to fetch in exchangeAppleCodeForToken has no
timeout; add an AbortController with a configurable timeout (e.g., 5–10s) and
pass its signal to fetch (targeting APPLE_TOKEN_URL inside
exchangeAppleCodeForToken), canceling the request on timeout and clearing the
timer; catch the abort/timeout case and throw or return a distinct timeout error
so callers can distinguish network timeouts from other failures (keep creating
client_secret via createAppleClientSecret() and other params like
client_id/readRequiredEnv and getAppleRedirectUri as-is).
- Around line 133-157: The function extractAppleIdentity currently uses
decodeJwt (no signature/iss/aud/exp checks) — replace the decodeJwt call with an
async jwtVerify against Apple's JWKS (validate signature, issuer, audience, and
exp) and use the verified payload as claims; update extractAppleIdentity to be
async (or add an async wrapper) and adjust all call sites and tests to await it
and test failure cases (bad signature, expired token, wrong issuer/audience) so
only verified id_tokens produce an AppleIdentity.
In `@src/lib/auth/google.ts`:
- Around line 1-19: The fetch timeout and abort logic is duplicated in google.ts
(FETCH_TIMEOUT_MS and fetchWithTimeout); extract that helper into a shared
module (e.g., create a new helper exporting FETCH_TIMEOUT_MS and
fetchWithTimeout) and update src/lib/auth/google.ts and src/lib/auth/naver.ts to
import and use the shared fetchWithTimeout instead of redefining it, ensuring
identical error handling (AbortError -> throw new Error(`요청 시간 초과: ${url}`)) and
keeping the same function signature (url: string, options: RequestInit) so
callers like fetchWithTimeout continue to work unchanged.
In `@src/lib/auth/jwt.ts`:
- Around line 61-68: The code is currently catching exceptions from getSecret()
and masking configuration errors as auth failures; fix by calling getSecret()
before the try block so any missing-JWT_SECRET error from getSecret() bubbles
up, then wrap only the jwtVerify(token, secret) and isJwtPayload(payload) checks
in the try/catch so that verification failures return null while
secret-retrieval errors are not swallowed; update the function that uses
getSecret(), jwtVerify, and isJwtPayload accordingly.
In `@src/lib/auth/naver.ts`:
- Around line 68-108: In getNaverToken, validate inputs before making the
network call by trimming and rejecting empty code or state (e.g., if
(!code?.trim() || !state?.trim()) throw new Error(...)) so you don't call
fetchWithTimeout with invalid params; after parsing the response into
NaverTokenResponse, assert that data.access_token exists and is non-empty (if
(!data?.access_token) throw new Error(...)) and include the error details from
the response when throwing; keep the existing clientId/clientSecret/redirectUri
checks and update error messages accordingly to reference getNaverToken,
fetchWithTimeout, and data.access_token for easy locating.
- Around line 1-19: The fetchWithTimeout implementation is duplicated between
provider modules; extract the function and constant into a single exported
helper (e.g., fetchWithTimeout in a new module such as
src/lib/auth/fetch-with-timeout.ts), keep the same FETCH_TIMEOUT_MS and
AbortError-to-Error mapping, export the function, then replace the local
implementations in naver.ts and google.ts by importing that shared
fetchWithTimeout; ensure both providers call the shared function so they share
identical timeout and error behavior.
In `@src/lib/schedules/store.ts`:
- Around line 87-90: The current public schedule lookup loads full participants
JSON via prisma.schedule.findUnique({ include: { participants: true } }) causing
unnecessary I/O; change the query to only fetch the participant count using
prisma.schedule.findUnique({ where: { id }, include: { _count: { select: {
participants: true } } } }), then assemble the PublicSchedule object using
result._count.participants assigned to participantCount (and do not expose
participants array); update any code that reads schedule.participants to use the
new _count-based participantCount.
- Around line 126-139: The code currently creates a schedule participant without
checking schedule.status, allowing new participants after a schedule is
confirmed; before calling prisma.scheduleParticipant.create (after
prisma.schedule.findUnique), check schedule.status and throw or reject if
schedule.status !== "open" (i.e., only allow creation when status === "open") so
confirmed schedules cannot accept new participants; update the control flow
around schedule (from the findUnique result) to validate status and return an
error immediately if not "open" before calling
normalizeAvailability/createToken/normalizeParticipantName and creating the
participant.
- Around line 150-166: confirmSchedule currently does a read
(prisma.schedule.findUnique) then a separate update, causing a TOCTOU race and
missing a status gate; modify confirmSchedule to perform the read/validation
(including tokenMatches and normalizeConfirmedSlot) and the update inside a
single prisma.$transaction (or use tx) and include a precondition that
schedule.status === "open" before setting status to "confirmed". Likewise update
addParticipantAvailability to check schedule.status === "open" inside the same
transaction that creates the scheduleParticipant so participant additions are
rejected once status is not "open". Ensure you reference the same schedule rows
inside the transaction (use tx.schedule.findUnique / tx.schedule.update or
equivalent) and preserve existing validations (tokenMatches,
normalizeConfirmedSlot, normalizeAvailability) while throwing descriptive errors
when status is not open.
In `@src/lib/scheduling/ics-parser.ts`:
- Around line 11-15: The current pipeline in
sortSlots(extractEvents(icsContent).map(eventToSlot).filter(...)) collapses
multi-day or cross-midnight VEVENTs into a single TimeSlot, losing busy info;
replace the map+filter with a flatMap that expands each VEVENT into a TimeSlot[]
per calendar day (e.g., implement a helper splitEventIntoDailySlots or modify
eventToSlot to return TimeSlot[]), computing per-day startHour/endHour
differently for the first day (use original start time), middle days
(00:00–24:00 or full-day), and last day (use original end time), then flatten
and pass to sortSlots; apply the same change to the other occurrence mentioned
(lines 68–93) so all code paths emit daily TimeSlot entries.
- Around line 95-129: The parseIcsDate function currently lets Date.UTC silently
normalize out-of-range components (e.g., 20260230 or 250000) into a different
valid Date; after constructing the Date for both the date-only branch and the
datetime branch, re-validate each UTC component (year, month, day, hour, minute,
second as applicable) against the original numeric parts parsed from the ICS
string and return undefined if any component differs, so malformed ICS values
are rejected instead of being silently adjusted.
In `@src/lib/supabase/__tests__/supabase.test.ts`:
- Around line 69-91: Add a test to assert that createServerClient also rejects
when NEXT_PUBLIC_SUPABASE_URL is a blank string: after the existing test that
deletes NEXT_PUBLIC_SUPABASE_URL, add a new case that sets
process.env.NEXT_PUBLIC_SUPABASE_URL = " " and then await
expect(createServerClient()).rejects.toThrow("NEXT_PUBLIC_SUPABASE_URL"). This
mirrors the anon-key blank-string test and keeps the server/client
environment-contract parity for createServerClient and NEXT_PUBLIC_SUPABASE_URL.
In `@src/types/user.ts`:
- Line 8: The AuthProvider union in src/types/user.ts is missing "naver",
breaking the type contract for social auth users; update the AuthProvider type
definition (symbol: AuthProvider) to include "naver" in the union so it matches
the actual supported providers used by the authentication layer and prevents
casts/compile errors when mapping Naver-authenticated users to the User type.
---
Outside diff comments:
In @.github/workflows/pr-compliance.yml:
- Around line 18-27: The current PR_BODY checks only for presence of any
checkbox and absence of unchecked boxes; update the workflow shell logic to
count checked boxes in PR_BODY (match "- [x]" or "- [X]") and fail unless the
count is >= 4, and additionally verify the presence of the required checklist
item phrases by grepping for the exact template lines (e.g. the four canonical
strings for branch convention, commit convention, related issue created, and no
breaking changes) so the script uses PR_BODY, the checkbox grep logic and
explicit phrase greps to enforce both count and specific items before allowing
merge.
In `@e2e/participant-flow.spec.ts`:
- Line 3: Rename the test title string in the test(...) call (currently
"participant can submit availability from an invite link and host can see it")
to explicitly include the host finalization step so it reflects the full
scenario; for example update the test(...) description to something like
"participant can submit availability from an invite link and host can view
common slots and finalize the time" so the behavior covered by the test
(participant submission, host viewing common slots, and host final confirmation)
is clear.
In `@src/app/api/schedules/route.ts`:
- Around line 12-17: The response currently embeds the host token in hostPath
(NextResponse.json returning schedule, participantPath and hostPath built from
created.id and created.hostToken), which exposes hostToken in URLs; remove
created.hostToken from hostPath and stop returning the raw hostToken inside the
JSON payload. Instead return only non-sensitive links (e.g., participantPath and
a hostPath without query token) and persist the host token via a secure
mechanism (set an HttpOnly, Secure cookie or a separate authenticated endpoint)
so subsequent GET/PATCH calls use headers/cookies rather than URL querystrings.
In `@src/app/schedule/`[id]/ScheduleRoomClient.tsx:
- Around line 268-386: Guest view still renders the submission form even after a
schedule is confirmed because PublicSchedule's status/confirmedSlot aren't
checked in the guest branch; update the guest rendering logic in
ScheduleRoomClient (the block gated by "schedule && !isHostView") to
early-branch on schedule.status or schedule.confirmedSlot (e.g., check for
status === 'confirmed' or confirmedSlot truthy) and render only the confirmed
result UI when confirmed, otherwise render the existing form; ensure
components/variables like handleSubmit, selected, name, toggleSlot, PurpleButton
remain inside the "open" branch so submission controls are only rendered when
schedule is not confirmed.
In `@src/app/schedule/create/CreateScheduleClient.tsx`:
- Around line 48-64: The form submit currently calls the API without validating
candidateDays and the start/end hour range; update the handleSubmit function in
CreateScheduleClient to perform client-side validation before calling fetch:
check that candidateDays is non-empty and that Number(candidateEndHour) >
Number(candidateStartHour) (and optionally that durationMinutes is positive),
and if validation fails call setError with a clear message,
setIsSubmitting(false) and return early to avoid the fetch; apply the same
validation logic to the other submit path in this component (the alternate
submit handler around the later block that also uses candidateDays,
candidateStartHour and candidateEndHour).
🪄 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: 859984c5-769d-42a6-9719-159204ca8acd
⛔ Files ignored due to path filters (15)
ARCHITECTURE.mdis excluded by!**/*.mdREADME.mdis excluded by!**/*.mdconvention.mdis excluded by!**/*.mddocs/README.mdis excluded by!**/*.mddocs/codex-work-context.mdis excluded by!**/*.mddocs/superpowers/plans/2026-04-27-moim-user-test-prototype.mdis excluded by!**/*.mddocs/v1/codex-work-context.mdis excluded by!**/*.mddocs/v1/provided-documents-summary.mdis excluded by!**/*.mddocs/v1/user-flow.mdis excluded by!**/*.mddocs/v2/monetization-strategy.mdis excluded by!**/*.mddocs/v2/prd.mdis excluded by!**/*.mddocs/v2/user-flow.mdis excluded by!**/*.mdpackage-lock.jsonis excluded by!**/package-lock.json,!**/*.json,!package-lock.jsonpackage.jsonis excluded by!**/*.jsontsconfig.jsonis excluded by!**/*.json
📒 Files selected for processing (63)
.claude/worktrees/availability-aggregation.env.env.example.github/workflows/ci.yml.github/workflows/issue-compliance.yml.github/workflows/pr-compliance.yml.gitignoree2e/participant-flow.spec.tsnext.config.tsplaywright.config.tspostcss.config.jsprisma/migrations/20260525000000_add_social_account/migration.sqlprisma/migrations/20260528001000_add_schedule_persistence/migration.sqlprisma/migrations/20260528032000_add_schedule_confirmation/migration.sqlprisma/schema.prismascripts/ensure-sqlite-schema.mjsscripts/with-database-url.mjssrc/app/(auth)/login/page.tsxsrc/app/(auth)/signup/additional-info/page.tsxsrc/app/(auth)/signup/page.tsxsrc/app/api/auth/apple/callback/route.tssrc/app/api/auth/apple/login/route.tssrc/app/api/auth/google/callback/route.tssrc/app/api/auth/google/login/route.tssrc/app/api/auth/naver/callback/route.tssrc/app/api/auth/naver/login/route.tssrc/app/api/auth/profile/complete/route.tssrc/app/api/schedules/[id]/availability/route.tssrc/app/api/schedules/[id]/route.tssrc/app/api/schedules/route.tssrc/app/calendar/connect/page.tsxsrc/app/page.tsxsrc/app/schedule/[id]/ScheduleRoomClient.tsxsrc/app/schedule/[id]/page.tsxsrc/app/schedule/create/CreateScheduleClient.tsxsrc/components/moim/auth-social.tsxsrc/components/moim/reference-ui.tsxsrc/components/schedule/AvailabilityResult.tsxsrc/components/schedule/ParticipantList.tsxsrc/components/schedule/TimeGrid.tsxsrc/features/auth/__tests__/social-profile.schema.test.tssrc/features/auth/social-profile.schema.tssrc/lib/auth/__tests__/apple.test.tssrc/lib/auth/__tests__/google.test.tssrc/lib/auth/__tests__/jwt.test.tssrc/lib/auth/__tests__/naver.test.tssrc/lib/auth/apple.tssrc/lib/auth/google.tssrc/lib/auth/jwt.tssrc/lib/auth/naver.tssrc/lib/prisma.tssrc/lib/schedule-test/__tests__/store.test.tssrc/lib/schedule-test/store.tssrc/lib/schedules/__tests__/store.test.tssrc/lib/schedules/store.tssrc/lib/scheduling/__tests__/ics-parser.test.tssrc/lib/scheduling/ics-parser.tssrc/lib/supabase/__tests__/supabase.test.tssrc/lib/supabase/client.tssrc/lib/supabase/env.tssrc/lib/supabase/server.tssrc/middleware.tssrc/types/user.ts
💤 Files with no reviewable changes (7)
- src/components/schedule/AvailabilityResult.tsx
- .claude/worktrees/availability-aggregation
- src/lib/schedule-test/tests/store.test.ts
- src/components/schedule/ParticipantList.tsx
- .env
- src/components/schedule/TimeGrid.tsx
- src/lib/schedule-test/store.ts
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/lib/auth/apple.ts (1)
96-110:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winApple 토큰 교환 전에 빈 인가 코드를 먼저 차단하세요.
Why: 지금은
code가 공백이어도 외부 Apple 토큰 엔드포인트까지 호출합니다. 그러면 로컬 입력 오류가 원격 OAuth 실패처럼 보이고, Google/Naver와도 방어 수준이 달라집니다.How:
fetchWithTimeout호출 전에trim()으로 즉시 실패시키세요.최소 수정 예시
export async function exchangeAppleCodeForToken( code: string, origin?: string, ): Promise<AppleTokenResponse> { + if (!code.trim()) { + throw new Error("Apple authorization code is empty."); + } + const response = await fetchWithTimeout(APPLE_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },As per coding guidelines
src/lib/**: 에지 케이스, 타입 안전성, 예외 처리가 부족하면 엄격히 지적하세요.🤖 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/auth/apple.ts` around lines 96 - 110, In exchangeAppleCodeForToken, validate the incoming code before calling fetchWithTimeout: check code?.trim() and immediately throw (or return a rejected Promise) with a clear error like "Missing authorization code" so you don't call the Apple token endpoint with an empty value; update the function (exchangeAppleCodeForToken) to perform this early guard and keep the rest of the logic unchanged.src/lib/auth/naver.ts (1)
32-47:⚠️ Potential issue | 🟠 Major | ⚡ Quick win빈
state로 인가 URL을 만들지 마세요.Why:
state는 OAuth CSRF 방어값인데 여기서는 빈 문자열도 그대로 통과합니다. 같은 파일의getNaverToken은 빈state를 거부하므로, 생성 단계와 검증 단계의 계약이 서로 어긋나 있습니다.How: URL 생성 전에
trim()으로 비어 있는state를 차단하세요. 같은 패턴의 Google/Apple 인가 URL 생성기도 같이 맞추는 게 안전합니다.최소 수정 예시
export function getNaverAuthUrl(state: string): string { + if (!state.trim()) { + throw new Error("네이버 OAuth state가 비어 있습니다."); + } + const clientId = process.env.NAVER_CLIENT_ID; const redirectUri = process.env.NAVER_REDIRECT_URI; if (!clientId || !redirectUri) {As per coding guidelines
src/lib/**: 에지 케이스, 타입 안전성, 예외 처리가 부족하면 엄격히 지적하세요.🤖 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/auth/naver.ts` around lines 32 - 47, getNaverAuthUrl currently allows an empty state which breaks the contract with getNaverToken (which rejects empty state); before building params in getNaverAuthUrl validate state by doing state = (state || "").trim() and throw a clear Error if it's empty, ensuring you reject blank/whitespace-only CSRF states; apply the same non-empty-trimmed-state check to the other providers' auth URL creators (e.g., Google/Apple auth URL functions) so generation and validation behavior are consistent across getNaverAuthUrl and its peers.src/lib/schedules/store.ts (1)
118-177:⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift[치명]
confirmSchedule/addParticipantAvailability상태 전이 동시성 경쟁 조건 처리 필요
- 왜: 두 함수 모두 트랜잭션 안에서
schedule.status === "open"을 “읽기”만 하고, 이후 write(schedule.update,scheduleParticipant.create)에status: "open"같은 조건을 강제하지 않습니다. 그래서 동시에 들어오면(1)confirmSchedule이 confirmedSlot을 서로 덮어쓰거나(2)addParticipantAvailability가 확정 직후 참가자를 추가해,normalizeConfirmedSlot이 계산한 당시 participants 기반 confirmedSlot과 최종 commonSlots/참가자 집합이 어긋날 수 있습니다. 또한prisma/schema.prisma에 status/open 관련 제약이 없어 코드 수정 없이는 막기 어렵습니다.- 어떻게: 상태 전이를 CAS처럼 강제하세요.
confirmSchedule은tx.schedule.update대신updateMany({ where: { id, status:"open" }})+count !== 1예외로 “open일 때만 확정”을 보장하고,addParticipantAvailability/confirmSchedule둘 다 동일하게isolationLevel: Prisma.TransactionIsolationLevel.Serializable(또는 동일 수준의 직렬화)로 실행해 교차 정합성 경쟁을 차단하세요.최소 수정 예시
- const participant = await prisma.$transaction(async (tx) => { + const participant = await prisma.$transaction(async (tx) => { const schedule = await tx.schedule.findUnique({ where: { id: scheduleId }, }); if (!schedule) throw new Error("schedule not found"); if (schedule.status !== "open") { throw new Error("schedule is not open"); } return tx.scheduleParticipant.create({ data: { id: createToken(12), scheduleId, name: normalizeParticipantName(input.name), available: JSON.stringify( normalizeAvailability(schedule, input.available), ), }, }); - }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); - const updated = await prisma.$transaction(async (tx) => { + const updated = await prisma.$transaction(async (tx) => { const schedule = await tx.schedule.findUnique({ where: { id }, include: { participants: true }, }); if (!schedule) throw new Error("schedule not found"); if (!tokenMatches(schedule.hostTokenHash, hostToken)) { throw new Error("invalid host token"); } if (schedule.status !== "open") { throw new Error("schedule is not open"); } const normalizedSlot = normalizeConfirmedSlot(schedule, confirmedSlot); - await tx.schedule.update({ - where: { id }, + const { count } = await tx.schedule.updateMany({ + where: { id, status: "open" }, data: { status: "confirmed", confirmedSlot: JSON.stringify(normalizedSlot), }, }); + if (count !== 1) throw new Error("schedule is not open"); return tx.schedule.findUnique({ where: { id }, include: { participants: true }, }); - }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });🤖 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/schedules/store.ts` around lines 118 - 177, The transaction logic in confirmSchedule and addParticipantAvailability is racy because you only read schedule.status === "open" then write; fix by running both prisma.$transaction calls with isolationLevel: Prisma.TransactionIsolationLevel.Serializable and enforce CAS-style checks: in confirmSchedule replace the tx.schedule.update(...) with tx.schedule.updateMany({ where: { id, status: "open" }, data: { status: "confirmed", confirmedSlot: JSON.stringify(normalizedSlot) } }) and throw if count !== 1, and in addParticipantAvailability (the function that calls tx.scheduleParticipant.create) add a preceding tx.schedule.updateMany({ where: { id: scheduleId, status: "open" }, data: {} }) or equivalent conditional update and require count === 1 before creating the participant so creation only proceeds if the schedule was still open; keep using tokenMatches, normalizeConfirmedSlot, toScheduleParticipant/toHostSchedule as before.src/app/schedule/create/CreateScheduleClient.tsx (1)
251-258:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win
카카오 공유가 실제로는 링크 복사입니다.이유: 현재 클릭 시
copyText(links.participant)만 호출합니다. 카카오 공유 창도, 앱 전환도 없어서 버튼 라벨과 실제 동작이 다릅니다.방법: 이번 PR에서 실제 카카오 공유를 붙이지 않을 거면 라벨과 아이콘을 복사 동작에 맞추세요. 최소 수정은 버튼 의미를 정직하게 바꾸는 쪽입니다.
코드 스니펫
- <button - type="button" - onClick={() => copyText(links.participant)} - className="inline-flex h-12 items-center justify-center gap-2 rounded-xl bg-[`#fee500`] text-sm font-bold text-[`#191919`]" - > - <MessageCircle className="h-4 w-4" /> - 카카오 공유 - </button> + <button + type="button" + onClick={() => copyText(links.participant)} + className="inline-flex h-12 items-center justify-center gap-2 rounded-xl bg-[`#fee500`] text-sm font-bold text-[`#191919`]" + > + <Copy className="h-4 w-4" /> + 참여 링크 복사 + </button>🤖 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/schedule/create/CreateScheduleClient.tsx` around lines 251 - 258, The button labeled "카카오 공유" currently only calls copyText(links.participant) (and uses the MessageCircle icon), so the label and icon are misleading; update the button in CreateScheduleClient (the element that calls copyText and references links.participant and MessageCircle) to reflect a copy action — e.g., change the visible text from "카카오 공유" to "링크 복사" and replace the MessageCircle icon with an appropriate copy/clipboard icon (or otherwise adjust the label/icon) unless you implement the actual Kakao share flow; keep the onClick as copyText(links.participant) if you choose the minimal label/icon change.
♻️ Duplicate comments (2)
src/app/api/auth/apple/login/route.ts (1)
16-21:⚠️ Potential issue | 🟠 Major | ⚡ Quick win개발 환경 HTTP에서는 Apple state 쿠키가 저장되지 않습니다.
이유:
sameSite: "none"조합은secure: true가 필수인데, 현재 Line 18-19를 고정하면http://localhost같은 비-HTTPS 환경에서 브라우저가 쿠키를 버립니다. 그러면 콜백의 state 검증이 항상 실패해 로컬 Apple 로그인 흐름이 깨집니다.방법:
최소 수정 예시
export async function GET(req: NextRequest) { try { + if (process.env.NODE_ENV !== "production" && req.nextUrl.protocol !== "https:") { + return NextResponse.redirect( + `${req.nextUrl.origin}/login?error=apple_login_requires_https`, + ); + } + const state = crypto.randomUUID(); const authUrl = getAppleAuthUrl(state, req.nextUrl.origin); const res = NextResponse.redirect(authUrl); res.cookies.set(APPLE_STATE_COOKIE, state, { httpOnly: true, secure: true, sameSite: "none", path: "/", maxAge: APPLE_STATE_MAX_AGE, });🤖 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/auth/apple/login/route.ts` around lines 16 - 21, The Apple state cookie is always set with secure: true and sameSite: "none", which causes browsers to drop the cookie on non-HTTPS (dev) origins; update the res.cookies.set call that uses APPLE_STATE_COOKIE and APPLE_STATE_MAX_AGE to make secure and sameSite conditional: set secure = true and sameSite = "none" only for HTTPS/production environments (e.g., when NODE_ENV === "production" or req.nextUrl.protocol === "https:"), otherwise set secure = false and sameSite = "lax" (or similar) so the cookie persists on http://localhost and state validation works locally.src/lib/scheduling/ics-parser.ts (1)
64-96:⚠️ Potential issue | 🟠 Major | ⚡ Quick win분 단위 일정이 조용히 축소되거나 통째로 사라집니다.
왜: 지금은
getUTCHours()만 써서 분/초를 버립니다. 그래서09:30~09:45는startHour === endHour === 9가 되어 아예 슬롯이 생성되지 않고,23:30~00:30도 다음 날00:00~01:00이 빠집니다. busy 시간을 과소계산하면 이후 공통 슬롯 계산이 틀어집니다.방법: 시작 시각은 현재처럼 시 기준으로 내리고, 종료 시각은 분/초가 있으면 다음 시각으로 올림 처리하세요. 최소 수정은 마지막 날
endHour계산만 보정하면 됩니다.최소 수정 예시
function eventToSlots(event: IcsEvent): TimeSlot[] { if (!event.start || !event.end) return []; const { start, end } = event; if (end.date <= start.date) return []; const slots: TimeSlot[] = []; let currentDay = startOfUtcDay(start.date); const finalDay = startOfUtcDay(end.date); while (currentDay <= finalDay) { const isFirstDay = isSameUtcDay(currentDay, start.date); const isLastDay = isSameUtcDay(currentDay, end.date); const startHour = isFirstDay && !start.isDateOnly ? start.date.getUTCHours() : 0; - const endHour = isLastDay - ? end.isDateOnly - ? 0 - : end.date.getUTCHours() - : 24; + const endHour = isLastDay + ? end.isDateOnly + ? 0 + : Math.min( + 24, + end.date.getUTCHours() + + (end.date.getUTCMinutes() > 0 || end.date.getUTCSeconds() > 0 + ? 1 + : 0), + ) + : 24; if (startHour < endHour) { slots.push({ day: toDayCode(currentDay.getUTCDay()), startHour, endHour, }); }🤖 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/scheduling/ics-parser.ts` around lines 64 - 96, In eventToSlots, the endHour computation currently drops minutes/seconds (using getUTCHours()), which makes short-range slots vanish; change the last-day endHour logic so that for non-date-only events you ceil to the next hour if there are non-zero minutes/seconds (e.g., if end.date has minutes or seconds > 0 then use endHour = end.date.getUTCHours() + 1 else use end.date.getUTCHours()), keeping the rest of the loop unchanged (symbols: eventToSlots, startHour, endHour, isLastDay, end.isDateOnly).
🤖 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 `@src/app/api/schedules/`[id]/route.ts:
- Around line 59-63: The hostToken assignment currently checks
body.hostToken.trim() but then uses the original body.hostToken, allowing tokens
with surrounding whitespace (e.g., " token ") to pass validation but fail later;
update the assignment so hostToken uses the trimmed string when body.hostToken
is a non-empty string (i.e., replace usage of body.hostToken with
body.hostToken.trim()), falling back to
request.cookies.get(getHostTokenCookieName(id))?.value if no valid trimmed body
token exists; ensure this change is applied at the hostToken declaration near
isTimeSlot and will align with confirmSchedule comparisons.
In `@src/app/api/schedules/route.ts`:
- Around line 16-22: Response currently includes created.hostToken in the JSON
body (the object containing schedule, participantPath, hostPath, hostToken)
which exposes the host token to client-side scripts; remove the hostToken
property from the JSON response and only set it via the existing httpOnly cookie
logic (the code that references created.hostToken when setting the cookie in the
same route handler), leaving participantPath and hostPath intact so the client
simply navigates and authorization relies on the cookie.
In `@src/app/schedule/`[id]/ScheduleRoomClient.tsx:
- Around line 320-322: The left-side UI (the instructions and QuickImportPanel)
is still shown even when a schedule is confirmed because only the right-hand
branch switches to ConfirmedGuestPanel; update the rendering logic in
ScheduleRoomClient.tsx so both panels use the same confirmation check
(schedule.status === "confirmed" && schedule.confirmedSlot): when confirmed,
hide or replace the left instruction/QuickImportPanel (or render a confirmed
state panel) instead of showing the "possible times / QuickImportPanel" UI;
adjust the conditional that currently controls only ConfirmedGuestPanel to also
control the left panel rendering to prevent showing invite/submit prompts after
confirmation.
In `@src/app/schedule/create/CreateScheduleClient.tsx`:
- Around line 294-299: The current check in CreateScheduleClient.tsx only
validates that Number(candidateEndHour) > Number(candidateStartHour) but doesn't
ensure the candidate window length in minutes can accommodate durationMinutes;
compute the candidate window in minutes (e.g., convert candidateStartHour and
candidateEndHour to minutes since midnight — handling possible "HH" or "HH:MM"
formats or, if they are plain hour numbers, multiply hour difference by 60),
then if (candidateWindowMinutes < Number(durationMinutes)) throw an Error (same
UX language) to block combinations where the meeting length exceeds the
candidate time range; update the validation block that references
candidateStartHour, candidateEndHour and durationMinutes accordingly.
In `@src/lib/auth/fetch-with-timeout.ts`:
- Around line 7-11: fetchWithTimeout currently overwrites caller-provided
options.signal with controller.signal, breaking upstream cancellation
propagation; modify fetchWithTimeout to preserve and combine signals by adding a
listener on the incoming options.signal (if present) that calls
controller.abort() when the external signal aborts, and ensure you remove that
listener on cleanup (and clear the timeoutId). Also distinguish internal-timeout
abort vs external abort in the catch/abort handling (e.g., set a flag or use
separate abort reasons) so the error message or thrown AbortError can reflect
whether the timeout triggered or the caller cancelled; reference the controller,
timeoutId and the incoming options.signal in your changes.
---
Outside diff comments:
In `@src/app/schedule/create/CreateScheduleClient.tsx`:
- Around line 251-258: The button labeled "카카오 공유" currently only calls
copyText(links.participant) (and uses the MessageCircle icon), so the label and
icon are misleading; update the button in CreateScheduleClient (the element that
calls copyText and references links.participant and MessageCircle) to reflect a
copy action — e.g., change the visible text from "카카오 공유" to "링크 복사" and replace
the MessageCircle icon with an appropriate copy/clipboard icon (or otherwise
adjust the label/icon) unless you implement the actual Kakao share flow; keep
the onClick as copyText(links.participant) if you choose the minimal label/icon
change.
In `@src/lib/auth/apple.ts`:
- Around line 96-110: In exchangeAppleCodeForToken, validate the incoming code
before calling fetchWithTimeout: check code?.trim() and immediately throw (or
return a rejected Promise) with a clear error like "Missing authorization code"
so you don't call the Apple token endpoint with an empty value; update the
function (exchangeAppleCodeForToken) to perform this early guard and keep the
rest of the logic unchanged.
In `@src/lib/auth/naver.ts`:
- Around line 32-47: getNaverAuthUrl currently allows an empty state which
breaks the contract with getNaverToken (which rejects empty state); before
building params in getNaverAuthUrl validate state by doing state = (state ||
"").trim() and throw a clear Error if it's empty, ensuring you reject
blank/whitespace-only CSRF states; apply the same non-empty-trimmed-state check
to the other providers' auth URL creators (e.g., Google/Apple auth URL
functions) so generation and validation behavior are consistent across
getNaverAuthUrl and its peers.
In `@src/lib/schedules/store.ts`:
- Around line 118-177: The transaction logic in confirmSchedule and
addParticipantAvailability is racy because you only read schedule.status ===
"open" then write; fix by running both prisma.$transaction calls with
isolationLevel: Prisma.TransactionIsolationLevel.Serializable and enforce
CAS-style checks: in confirmSchedule replace the tx.schedule.update(...) with
tx.schedule.updateMany({ where: { id, status: "open" }, data: { status:
"confirmed", confirmedSlot: JSON.stringify(normalizedSlot) } }) and throw if
count !== 1, and in addParticipantAvailability (the function that calls
tx.scheduleParticipant.create) add a preceding tx.schedule.updateMany({ where: {
id: scheduleId, status: "open" }, data: {} }) or equivalent conditional update
and require count === 1 before creating the participant so creation only
proceeds if the schedule was still open; keep using tokenMatches,
normalizeConfirmedSlot, toScheduleParticipant/toHostSchedule as before.
---
Duplicate comments:
In `@src/app/api/auth/apple/login/route.ts`:
- Around line 16-21: The Apple state cookie is always set with secure: true and
sameSite: "none", which causes browsers to drop the cookie on non-HTTPS (dev)
origins; update the res.cookies.set call that uses APPLE_STATE_COOKIE and
APPLE_STATE_MAX_AGE to make secure and sameSite conditional: set secure = true
and sameSite = "none" only for HTTPS/production environments (e.g., when
NODE_ENV === "production" or req.nextUrl.protocol === "https:"), otherwise set
secure = false and sameSite = "lax" (or similar) so the cookie persists on
http://localhost and state validation works locally.
In `@src/lib/scheduling/ics-parser.ts`:
- Around line 64-96: In eventToSlots, the endHour computation currently drops
minutes/seconds (using getUTCHours()), which makes short-range slots vanish;
change the last-day endHour logic so that for non-date-only events you ceil to
the next hour if there are non-zero minutes/seconds (e.g., if end.date has
minutes or seconds > 0 then use endHour = end.date.getUTCHours() + 1 else use
end.date.getUTCHours()), keeping the rest of the loop unchanged (symbols:
eventToSlots, startHour, endHour, isLastDay, end.isDateOnly).
🪄 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: 168531f2-1a9f-4208-bc8b-3c1184d83575
⛔ Files ignored due to path filters (2)
package-lock.jsonis excluded by!**/package-lock.json,!**/*.json,!package-lock.jsonpackage.jsonis excluded by!**/*.json
📒 Files selected for processing (38)
.github/workflows/ci.yml.github/workflows/pr-compliance.ymle2e/host-flow.spec.tse2e/participant-flow.spec.tsplaywright.config.tspostcss.config.jsscripts/with-database-url.mjssrc/app/(auth)/login/page.tsxsrc/app/(auth)/signup/additional-info/page.tsxsrc/app/(auth)/signup/page.tsxsrc/app/api/auth/apple/callback/route.tssrc/app/api/auth/apple/login/route.tssrc/app/api/auth/login/route.tssrc/app/api/schedules/[id]/route.tssrc/app/api/schedules/route.tssrc/app/schedule/[id]/ScheduleRoomClient.tsxsrc/app/schedule/create/CreateScheduleClient.tsxsrc/components/moim/auth-social.tsxsrc/components/moim/reference-ui.tsxsrc/features/auth/__tests__/social-profile.schema.test.tssrc/features/auth/login.schema.tssrc/lib/auth/__tests__/apple.test.tssrc/lib/auth/__tests__/jwt.test.tssrc/lib/auth/__tests__/naver.test.tssrc/lib/auth/apple.tssrc/lib/auth/fetch-with-timeout.tssrc/lib/auth/google.tssrc/lib/auth/jwt.tssrc/lib/auth/naver.tssrc/lib/google/__tests__/auth.test.tssrc/lib/google/auth.tssrc/lib/schedules/__tests__/store.test.tssrc/lib/schedules/host-cookie.tssrc/lib/schedules/store.tssrc/lib/scheduling/__tests__/ics-parser.test.tssrc/lib/scheduling/ics-parser.tssrc/lib/supabase/__tests__/supabase.test.tssrc/types/user.ts
| const hostToken = | ||
| typeof body.hostToken === "string" && body.hostToken.trim() | ||
| ? body.hostToken | ||
| : request.cookies.get(getHostTokenCookieName(id))?.value; | ||
| if (!hostToken || !isTimeSlot(body.confirmedSlot)) { |
There was a problem hiding this comment.
hostToken은 검증한 값 그대로 사용하세요.
이유: Line 60-62는 body.hostToken.trim()으로 비어 있지 않은지만 확인하고, 실제로는 공백이 포함된 원본 body.hostToken을 사용합니다. " token " 같은 입력은 유효성 검사를 통과한 뒤 confirmSchedule에서 다른 값으로 취급되어 불필요한 403을 만들 수 있습니다.
방법:
최소 수정 예시
- const hostToken =
- typeof body.hostToken === "string" && body.hostToken.trim()
- ? body.hostToken
- : request.cookies.get(getHostTokenCookieName(id))?.value;
+ const trimmedHostToken =
+ typeof body.hostToken === "string" ? body.hostToken.trim() : "";
+ const hostToken =
+ trimmedHostToken ||
+ request.cookies.get(getHostTokenCookieName(id))?.value;🤖 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/schedules/`[id]/route.ts around lines 59 - 63, The hostToken
assignment currently checks body.hostToken.trim() but then uses the original
body.hostToken, allowing tokens with surrounding whitespace (e.g., " token ") to
pass validation but fail later; update the assignment so hostToken uses the
trimmed string when body.hostToken is a non-empty string (i.e., replace usage of
body.hostToken with body.hostToken.trim()), falling back to
request.cookies.get(getHostTokenCookieName(id))?.value if no valid trimmed body
token exists; ensure this change is applied at the hostToken declaration near
isTimeSlot and will align with confirmSchedule comparisons.
| const response = NextResponse.json( | ||
| { | ||
| schedule, | ||
| participantPath: `/schedule/${created.id}`, | ||
| hostPath: `/schedule/${created.id}?hostToken=${created.hostToken}`, | ||
| hostPath: `/schedule/${created.id}`, | ||
| hostToken: created.hostToken, | ||
| }, |
There was a problem hiding this comment.
호스트 토큰을 응답 JSON으로 다시 노출하지 마세요.
이유: Line 21에서 hostToken을 본문으로 반환하면 생성 직후 클라이언트 스크립트가 토큰을 그대로 읽을 수 있습니다. 바로 아래 Line 26-35에서 httpOnly 쿠키로 숨긴 이점이 사실상 사라져, XSS나 서드파티 스크립트가 호스트 권한 토큰을 탈취할 수 있습니다.
방법:
최소 수정 예시
const response = NextResponse.json(
{
schedule,
participantPath: `/schedule/${created.id}`,
hostPath: `/schedule/${created.id}`,
- hostToken: created.hostToken,
},
{ status: 201 },
);클라이언트는 hostPath로 이동만 하고, 이후 권한 판별은 쿠키에 맡기면 됩니다.
Also applies to: 26-35
🤖 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/schedules/route.ts` around lines 16 - 22, Response currently
includes created.hostToken in the JSON body (the object containing schedule,
participantPath, hostPath, hostToken) which exposes the host token to
client-side scripts; remove the hostToken property from the JSON response and
only set it via the existing httpOnly cookie logic (the code that references
created.hostToken when setting the cookie in the same route handler), leaving
participantPath and hostPath intact so the client simply navigates and
authorization relies on the cookie.
| {schedule.status === "confirmed" && schedule.confirmedSlot ? ( | ||
| <ConfirmedGuestPanel slot={schedule.confirmedSlot} /> | ||
| ) : ( |
There was a problem hiding this comment.
확정 후에도 좌측이 계속 “제출 전” 상태로 남습니다.
이유: 지금 분기는 오른쪽 폼만 ConfirmedGuestPanel로 바꿉니다. 그래서 일정이 이미 확정돼도 왼쪽에는 여전히 “가능한 시간을 선택” 안내와 Everytime/ICS 빠른 입력이 남아, 게스트가 아직 응답할 수 있는 것처럼 오해합니다.
방법: 확정 상태에서는 좌측 안내와 QuickImportPanel도 함께 숨기거나 확정 안내로 교체하세요.
코드 스니펫
- <QuickImportPanel
- everytimeUrl={everytimeUrl}
- importMessage={importMessage}
- importMode={importMode}
- onUrlChange={setEverytimeUrl}
- onUrlSubmit={importEverytimeUrl}
- onFileChange={importEverytimeFile}
- />
+ {schedule.status === "confirmed" ? (
+ <div className="mt-6 rounded-[1.5rem] border border-[`#d8efd7`] bg-[`#f4fbf4`] p-5 text-sm font-semibold text-[`#23623a`]">
+ 호스트가 최종 일정을 확정했습니다. 오른쪽 확정 시간을 확인해 주세요.
+ </div>
+ ) : (
+ <QuickImportPanel
+ everytimeUrl={everytimeUrl}
+ importMessage={importMessage}
+ importMode={importMode}
+ onUrlChange={setEverytimeUrl}
+ onUrlSubmit={importEverytimeUrl}
+ onFileChange={importEverytimeFile}
+ />
+ )}🤖 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/schedule/`[id]/ScheduleRoomClient.tsx around lines 320 - 322, The
left-side UI (the instructions and QuickImportPanel) is still shown even when a
schedule is confirmed because only the right-hand branch switches to
ConfirmedGuestPanel; update the rendering logic in ScheduleRoomClient.tsx so
both panels use the same confirmation check (schedule.status === "confirmed" &&
schedule.confirmedSlot): when confirmed, hide or replace the left
instruction/QuickImportPanel (or render a confirmed state panel) instead of
showing the "possible times / QuickImportPanel" UI; adjust the conditional that
currently controls only ConfirmedGuestPanel to also control the left panel
rendering to prevent showing invite/submit prompts after confirmation.
| if (Number(candidateEndHour) <= Number(candidateStartHour)) { | ||
| throw new Error("종료 시간은 시작 시간보다 늦어야 합니다."); | ||
| } | ||
| if (Number(durationMinutes) <= 0) { | ||
| throw new Error("소요 시간은 0보다 커야 합니다."); | ||
| } |
There was a problem hiding this comment.
모임 길이가 후보 시간 범위를 초과하는 조합을 막아야 합니다.
이유: 지금 검증은 종료 > 시작만 확인합니다. 그래서 120분 모임에 17:00-18:00 같은 입력이 그대로 통과하고, 실제로는 배치 가능한 슬롯이 없는 모임이 생성될 수 있습니다.
방법: 후보 시간 폭을 분 단위로 계산해 durationMinutes보다 작으면 바로 막으세요.
코드 스니펫
function validateScheduleForm({
candidateDays,
candidateStartHour,
candidateEndHour,
durationMinutes,
}: {
candidateDays: DayCode[];
candidateStartHour: string;
candidateEndHour: string;
durationMinutes: string;
}) {
if (candidateDays.length === 0) {
throw new Error("후보 요일을 하나 이상 선택해 주세요.");
}
if (Number(candidateEndHour) <= Number(candidateStartHour)) {
throw new Error("종료 시간은 시작 시간보다 늦어야 합니다.");
}
+ const windowMinutes =
+ (Number(candidateEndHour) - Number(candidateStartHour)) * 60;
+ if (windowMinutes < Number(durationMinutes)) {
+ throw new Error("소요 시간이 후보 시간 범위를 초과했습니다.");
+ }
if (Number(durationMinutes) <= 0) {
throw new Error("소요 시간은 0보다 커야 합니다.");
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (Number(candidateEndHour) <= Number(candidateStartHour)) { | |
| throw new Error("종료 시간은 시작 시간보다 늦어야 합니다."); | |
| } | |
| if (Number(durationMinutes) <= 0) { | |
| throw new Error("소요 시간은 0보다 커야 합니다."); | |
| } | |
| function validateScheduleForm({ | |
| candidateDays, | |
| candidateStartHour, | |
| candidateEndHour, | |
| durationMinutes, | |
| }: { | |
| candidateDays: DayCode[]; | |
| candidateStartHour: string; | |
| candidateEndHour: string; | |
| durationMinutes: string; | |
| }) { | |
| if (candidateDays.length === 0) { | |
| throw new Error("후보 요일을 하나 이상 선택해 주세요."); | |
| } | |
| if (Number(candidateEndHour) <= Number(candidateStartHour)) { | |
| throw new Error("종료 시간은 시작 시간보다 늦어야 합니다."); | |
| } | |
| const windowMinutes = | |
| (Number(candidateEndHour) - Number(candidateStartHour)) * 60; | |
| if (windowMinutes < Number(durationMinutes)) { | |
| throw new Error("소요 시간이 후보 시간 범위를 초과했습니다."); | |
| } | |
| if (Number(durationMinutes) <= 0) { | |
| throw new Error("소요 시간은 0보다 커야 합니다."); | |
| } | |
| } |
🤖 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/schedule/create/CreateScheduleClient.tsx` around lines 294 - 299, The
current check in CreateScheduleClient.tsx only validates that
Number(candidateEndHour) > Number(candidateStartHour) but doesn't ensure the
candidate window length in minutes can accommodate durationMinutes; compute the
candidate window in minutes (e.g., convert candidateStartHour and
candidateEndHour to minutes since midnight — handling possible "HH" or "HH:MM"
formats or, if they are plain hour numbers, multiply hour difference by 60),
then if (candidateWindowMinutes < Number(durationMinutes)) throw an Error (same
UX language) to block combinations where the meeting length exceeds the
candidate time range; update the validation block that references
candidateStartHour, candidateEndHour and durationMinutes accordingly.
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
src/app/calendar/connect/page.tsx (2)
222-224:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win같은 ICS 파일을 바로 다시 고를 수 없습니다.
이유: 브라우저는 파일 input의 값이 같으면
change를 다시 발생시키지 않습니다. 지금 구현은 검증 실패나 서버 실패 뒤 같은 파일로 재시도할 때 아무 일도 안 일어납니다. 방법: 핸들러 종료 후 input 값을 비워 재선택을 허용하세요.최소 수정 예시
- onChange={(event) => - submitEverytimeFile(event.target.files?.[0] ?? null) - } + onChange={(event) => { + const input = event.currentTarget; + void submitEverytimeFile(input.files?.[0] ?? null).finally(() => { + input.value = ""; + }); + }}🤖 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/calendar/connect/page.tsx` around lines 222 - 224, 핸들러가 같은 파일을 다시 선택해도 change 이벤트가 발생하지 않는 문제는 onChange에서 submitEverytimeFile 호출 후 input 값을 비우면 해결됩니다; 변경된 onChange 핸들러에서 submitEverytimeFile 호출을 await(또는 .finally)로 완료한 뒤 event.currentTarget.value = '' (또는 event.target.value = '')로 입력값을 리셋하여 동일한 ICS 파일을 바로 다시 선택할 수 있도록 하세요. 참조: onChange handler and submitEverytimeFile.
26-26:⚠️ Potential issue | 🟠 Major | ⚡ Quick win공유 상태인데 액션별로만 비활성화해서 요청 레이스가 납니다.
이유:
message와slots는 컴포넌트 전체에서 하나인데, 현재는 자기 버튼만 막고 다른 액션은 동시에 눌릴 수 있습니다. 그러면 응답 순서에 따라 성공/실패 메시지와 변환된 시간이 서로 덮어써져 잘못된 화면이 남습니다. 방법: 공통 busy 상태를 만들어 모든 진입점을 함께 잠그고, 제출 함수도 초기에 막으세요.최소 수정 예시
const [slots, setSlots] = useState<TimeSlot[]>([]); const [message, setMessage] = useState(""); const [isLoading, setIsLoading] = useState(""); + const isBusy = isLoading !== ""; async function submitEverytimeUrl(event: FormEvent<HTMLFormElement>) { event.preventDefault(); + if (isBusy) return; setMessage(""); setIsLoading("everytime-url"); @@ async function submitEverytimeFile(file: File | null) { - if (!file) return; + if (isBusy || !file) return; @@ async function submitIcloud(event: FormEvent<HTMLFormElement>) { event.preventDefault(); + if (isBusy) return; setMessage(""); setIsLoading("icloud"); @@ - disabled={isLoading === "icloud"} + disabled={isBusy} @@ - disabled={isLoading === "everytime-url"} + disabled={isBusy} @@ <input type="file" accept=".ics,text/calendar" className="sr-only" + disabled={isBusy} onChange={(event) => submitEverytimeFile(event.target.files?.[0] ?? null) } />Also applies to: 31-31, 68-68, 94-95, 175-175, 202-202, 218-224
🤖 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/calendar/connect/page.tsx` at line 26, 현재 isLoading가 문자열이고 각 액션별로만 비활성화해 컴포넌트 전체의 공유 상태(message, slots 등)를 동시에 변경하는 요청 레이스가 발생합니다; isLoading를 boolean 공통 busy 상태로 변경하고 모든 진입점(예: 각 제출 핸들러들, submit 함수들)을 가장 처음에 busy 검사로 막고 작업 시작 시 setIsLoading(true), 완료/에러 시 setIsLoading(false)로 통일해 동시 요청을 차단하며, 각 핸들러(참조할 심볼: isLoading, setIsLoading, message, slots, 그리고 모든 submit/handle* 함수들)에 이 검사를 추가해 응답이 덮어써지지 않도록 보호하세요.src/lib/scheduling/ics-parser.ts (1)
46-50:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift
TZID가 붙은 timed VEVENT를 지금 구현은 조용히 버립니다.Why: Line 46-50에서
DTSTART;TZID=...도DTSTART로 인식하지만, Line 115-121에서Z가 없는 datetime은 전부undefined로 버립니다. 그래서 유효한.ics를 업로드해도 결과가 빈 슬롯이 될 수 있습니다. 지금ScheduleRoomClient.tsxLine 446-452는.ics업로드를 열어 둔 상태라 사용자 흐름이 바로 끊깁니다.How:
TZID파라미터를 읽어서 timezone-aware 변환 경로로 넘기거나, 이번 PR 범위에서 미지원이라면 최소한 빈 배열로 삼키지 말고 명시적 parse error를 올려 UI가 “지원하지 않는 ICS 형식”을 보여줄 수 있게 바꿔야 합니다. 그리고 회귀 방지용으로DTSTART;TZID=Asia/Seoul케이스를 테스트에 추가하세요.코드 스니펫
- const key = line.slice(0, separatorIndex).split(";")[0]; + const [key, ...params] = line.slice(0, separatorIndex).split(";"); + const tzid = params.find((param) => param.startsWith("TZID="))?.slice(5); const value = line.slice(separatorIndex + 1); - if (key === "DTSTART") current.start = parseIcsDate(value); - if (key === "DTEND") current.end = parseIcsDate(value); + if (key === "DTSTART") current.start = parseIcsDate(value, tzid); + if (key === "DTEND") current.end = parseIcsDate(value, tzid);-function parseIcsDate(value: string): IcsDate | undefined { +function parseIcsDate(value: string, tzid?: string): IcsDate | undefined { + if (tzid) { + throw new Error(`unsupported timezone ICS: ${tzid}`); + } // 기존 UTC/date-only 처리 }Also applies to: 115-121
🤖 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/scheduling/ics-parser.ts` around lines 46 - 50, The parser currently ignores TZID parameters (keys like "DTSTART;TZID=Asia/Seoul") because you only take the part before ";" and later drop non-Z datetimes as undefined; update the logic around key/value parsing in ics-parser.ts so you extract the parameter (e.g., from line handling using keyParts = line.slice(0, separatorIndex).split(";")), detect a TZID param and pass that timezone into parseIcsDate (or into a new timezone-aware parsing path) when setting current.start/current.end; if timezone parsing is not implemented within this PR, explicitly throw/return a parse error instead of swallowing events so the UI (ScheduleRoomClient.tsx) can show “unsupported ICS format”; finally add a unit test covering DTSTART;TZID=Asia/Seoul to prevent regressions.
♻️ Duplicate comments (3)
src/app/schedule/[id]/ScheduleRoomClient.tsx (2)
322-329:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win확정 후에도 좌측 빠른 입력이 살아 있어 상태를 오해하게 만듭니다.
Why: 오른쪽만
ConfirmedGuestPanel로 바뀌고, 왼쪽의QuickImportPanel은 계속 렌더링됩니다. 그래서 이미 확정된 일정에도 가져오기/선택이 가능한 것처럼 보이고, 실제로applyImportedSlots는selected상태까지 바꿉니다.How: 오른쪽과 같은 확정 조건으로 좌측 패널도 숨기거나 확정 안내 패널로 교체하세요.
코드 스니펫
- <QuickImportPanel - everytimeUrl={everytimeUrl} - importMessage={importMessage} - importMode={importMode} - onUrlChange={setEverytimeUrl} - onUrlSubmit={importEverytimeUrl} - onFileChange={importEverytimeFile} - /> + {schedule.status === "confirmed" && schedule.confirmedSlot ? ( + <div className="mt-6 rounded-[1.5rem] border border-[`#d8efd7`] bg-[`#f4fbf4`] p-5 text-sm font-semibold text-[`#23623a`]"> + 호스트가 최종 일정을 확정했습니다. 오른쪽 확정 시간을 확인해 주세요. + </div> + ) : ( + <QuickImportPanel + everytimeUrl={everytimeUrl} + importMessage={importMessage} + importMode={importMode} + onUrlChange={setEverytimeUrl} + onUrlSubmit={importEverytimeUrl} + onFileChange={importEverytimeFile} + /> + )}Also applies to: 332-334
🤖 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/schedule/`[id]/ScheduleRoomClient.tsx around lines 322 - 329, The QuickImportPanel remains visible after confirmation which misleads users; update the component render logic so the left QuickImportPanel (props: everytimeUrl, importMessage, importMode, onUrlChange setEverytimeUrl, onUrlSubmit importEverytimeUrl, onFileChange importEverytimeFile) is hidden or replaced with the same ConfirmedGuestPanel/confirmation notice used on the right whenever the schedule is confirmed (same condition used to render ConfirmedGuestPanel), ensuring import actions cannot be performed after confirm (this will prevent applyImportedSlots from mutating selected state post-confirmation); apply the same change for the other occurrence mentioned (lines 332-334).
738-744:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win동작 없는 알림 버튼은 지금 상태로는 클릭 가능하게 두면 안 됩니다.
Why: 이 버튼은 클릭 가능하게 보이지만 실제 핸들러가 없어 아무 일도 일어나지 않습니다. 이런 더미 CTA는 호스트 플로우에서 바로 혼란을 만듭니다.
How: 이번 PR에서 기능을 연결하지 못하면 최소한 비활성화하고 준비 중 상태를 명시하세요.
코드 스니펫
<button type="button" - className="inline-flex h-14 items-center justify-center gap-2 rounded-[1.25rem] bg-[`#fee500`] text-base font-extrabold text-[`#191919`]" + disabled + aria-disabled="true" + title="준비 중" + className="inline-flex h-14 items-center justify-center gap-2 rounded-[1.25rem] bg-[`#fee500`] text-base font-extrabold text-[`#191919`] opacity-50 cursor-not-allowed" > <MessageCircle className="h-5 w-5" /> - 미응답자에게 알림 보내기 + 미응답자 알림 준비 중 </button>🤖 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/schedule/`[id]/ScheduleRoomClient.tsx around lines 738 - 744, The "미응답자에게 알림 보내기" button in ScheduleRoomClient.tsx is clickable but has no handler; disable the button until the feature is implemented by adding the disabled attribute (and/or aria-disabled) to the button element and update its accessible label/title to indicate "준비 중" (or "Coming soon"); also adjust its styling via the button's className (e.g., add opacity and cursor-not-allowed) so it visually appears disabled and ensure MessageCircle remains for icon consistency. Ensure no onClick is attached while disabled and include an accessible tooltip or title so screen reader users see the "준비 중" state.src/app/schedule/create/CreateScheduleClient.tsx (1)
299-304:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win클라이언트 검증에 후보 범위 대비 duration 체크가 아직 없습니다.
Why: 서버는
src/lib/schedules/store.tsLine 237-240에서 이미 막고 있는데, 여기서는 그 전에 걸러 주지 않아서 불필요한 요청 뒤에 서버 에러 문구를 그대로 보여주게 됩니다.How: 같은 규칙을 폼 검증에도 넣어서 제출 전에 즉시 막아 주세요.
코드 스니펫
function validateScheduleForm({ candidateDays, candidateStartHour, candidateEndHour, durationMinutes, }: { candidateDays: DayCode[]; candidateStartHour: string; candidateEndHour: string; durationMinutes: string; }) { if (candidateDays.length === 0) { throw new Error("후보 요일을 하나 이상 선택해 주세요."); } if (Number(candidateEndHour) <= Number(candidateStartHour)) { throw new Error("종료 시간은 시작 시간보다 늦어야 합니다."); } + const windowMinutes = + (Number(candidateEndHour) - Number(candidateStartHour)) * 60; + if (windowMinutes < Number(durationMinutes)) { + throw new Error("소요 시간이 후보 시간 범위를 초과했습니다."); + } if (Number(durationMinutes) <= 0) { throw new Error("소요 시간은 0보다 커야 합니다."); } }🤖 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/schedule/create/CreateScheduleClient.tsx` around lines 299 - 304, Add a client-side validation to ensure the requested duration fits within the selected candidate time range: in CreateScheduleClient.tsx (the form submit/validation logic that currently checks candidateEndHour vs candidateStartHour and durationMinutes > 0), compute the candidate window in minutes from candidateStartHour and candidateEndHour and throw/return the same validation error if durationMinutes is greater than that window; update the same location where the two existing checks live (referencing candidateStartHour, candidateEndHour, and durationMinutes) so the form blocks submission before calling the server.
🤖 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 `@docs/business-plan/MOIM_2026_학생창업유망팀300_사업계획서_raw_text.txt`:
- Line 27: The phrase "[그림 1 삽입 위치] 기존 일정 조율 방식과 MOIM 방식 비교권장 이미지: 카톡 대화·시간표
캡처·수동 비교 흐름과 링크 공유·공통 가능 시간 확인 흐름을 나란히 비교" has merged two semantic units; split
"기존 일정 조율 방식과 MOIM 방식 비교" and "권장 이미지: ..." with a clear delimiter (add a colon,
dash, or a line break) so the meaning boundary is explicit; update the line
containing "[그림 1 삽입 위치]" accordingly and ensure the fragment describing image
contents ("카톡 대화·시간표 캡처·수동 비교 흐름..." etc.) remains after the delimiter for
readability and correct parsing.
In `@docs/business-plan/MOIM_사업계획서_1_dump.txt`:
- Line 29: The dump has two lines missing the expected "P_숫자:" line-record
prefix (e.g., the line containing "MOIM은 기술적, 경제적, 사회적 세 측면에서 기여함" and the
similar one near the later section), which breaks the parser; update those exact
lines to include the same "P_<number>:" prefix format used elsewhere in the file
so they match the rest of the records (retain the original text after the
prefix).
- Around line 140-141: Fix the typographical errors in the team/strength
introduction lines (e.g., the phrases starting "창업자로서의 강점은..." and "팀원 김유현은...")
by correcting Korean spelling and spacing without changing meaning; ensure
"창업자로서의" and "팀원 김유현은 글로벌미디어학부 소속으로 전반적이 UX/UI 디자인과 프론트엔드 개발 담당" are rewritten
with proper spacing and orthography consistent with Korean spelling rules (apply
same correction to the similar issue at the other occurrence around line 152).
In `@docs/business-plan/MOIM_사업계획서_final_dump.txt`:
- Around line 34-36: The paragraph block P_34–P_35 duplicates the earlier
summary (P_33), reducing message density; remove the duplicated block (the
second occurrence of the same survey/statistics paragraph) and keep only the
first summary paragraph (P_33) so the document contains a single clear instance
of that survey-based argument.
In `@docs/business-plan/references/사업계획서의` 이해_part2_extracted.txt:
- Line 36: Replace the external reference "통계청 http://kostat.go.kr" so the URL
uses HTTPS; locate the exact string "통계청 http://kostat.go.kr" in the document
and change the protocol to "https" (i.e., "통계청 https://kostat.go.kr").
In `@next.config.mjs`:
- Around line 1-5: 현재 비어 있는 nextConfig 객체 때문에 App Router 경로 문자열 오류를 런타임에서만 잡습니다;
nextConfig 설정에 typedRoutes: true 옵션을 추가해 컴파일 타임 경로 검증을 켜고 라우트 오타를 사전에 발견할 수 있게
하세요 (수정 대상: nextConfig 객체 내에 typedRoutes: true 추가).
In `@src/lib/schedules/store.ts`:
- Around line 174-180: The update path uses normalizeConfirmedSlot and
tx.schedule.updateMany to set confirmedSlot without ensuring the confirmed slot
length matches schedule.durationMinutes or that durationMinutes is constrained
to the current hour-based TimeSlot model; restrict supported durations in
CreateScheduleClient.tsx to multiples of 60 while we still use hour-granularity
TimeSlot, and before calling tx.schedule.updateMany (in the confirm/normalize
flow) validate that the normalizedSlot length (computed by
normalizeConfirmedSlot) equals schedule.durationMinutes (or convert
schedule.durationMinutes to hours consistently) and reject/throw an error if it
does not, so we never confirm a slot shorter/longer than the declared duration
and the stored confirmedSlot JSON remains consistent.
---
Outside diff comments:
In `@src/app/calendar/connect/page.tsx`:
- Around line 222-224: 핸들러가 같은 파일을 다시 선택해도 change 이벤트가 발생하지 않는 문제는 onChange에서
submitEverytimeFile 호출 후 input 값을 비우면 해결됩니다; 변경된 onChange 핸들러에서
submitEverytimeFile 호출을 await(또는 .finally)로 완료한 뒤 event.currentTarget.value = ''
(또는 event.target.value = '')로 입력값을 리셋하여 동일한 ICS 파일을 바로 다시 선택할 수 있도록 하세요. 참조:
onChange handler and submitEverytimeFile.
- Line 26: 현재 isLoading가 문자열이고 각 액션별로만 비활성화해 컴포넌트 전체의 공유 상태(message, slots 등)를
동시에 변경하는 요청 레이스가 발생합니다; isLoading를 boolean 공통 busy 상태로 변경하고 모든 진입점(예: 각 제출 핸들러들,
submit 함수들)을 가장 처음에 busy 검사로 막고 작업 시작 시 setIsLoading(true), 완료/에러 시
setIsLoading(false)로 통일해 동시 요청을 차단하며, 각 핸들러(참조할 심볼: isLoading, setIsLoading,
message, slots, 그리고 모든 submit/handle* 함수들)에 이 검사를 추가해 응답이 덮어써지지 않도록 보호하세요.
In `@src/lib/scheduling/ics-parser.ts`:
- Around line 46-50: The parser currently ignores TZID parameters (keys like
"DTSTART;TZID=Asia/Seoul") because you only take the part before ";" and later
drop non-Z datetimes as undefined; update the logic around key/value parsing in
ics-parser.ts so you extract the parameter (e.g., from line handling using
keyParts = line.slice(0, separatorIndex).split(";")), detect a TZID param and
pass that timezone into parseIcsDate (or into a new timezone-aware parsing path)
when setting current.start/current.end; if timezone parsing is not implemented
within this PR, explicitly throw/return a parse error instead of swallowing
events so the UI (ScheduleRoomClient.tsx) can show “unsupported ICS format”;
finally add a unit test covering DTSTART;TZID=Asia/Seoul to prevent regressions.
---
Duplicate comments:
In `@src/app/schedule/`[id]/ScheduleRoomClient.tsx:
- Around line 322-329: The QuickImportPanel remains visible after confirmation
which misleads users; update the component render logic so the left
QuickImportPanel (props: everytimeUrl, importMessage, importMode, onUrlChange
setEverytimeUrl, onUrlSubmit importEverytimeUrl, onFileChange
importEverytimeFile) is hidden or replaced with the same
ConfirmedGuestPanel/confirmation notice used on the right whenever the schedule
is confirmed (same condition used to render ConfirmedGuestPanel), ensuring
import actions cannot be performed after confirm (this will prevent
applyImportedSlots from mutating selected state post-confirmation); apply the
same change for the other occurrence mentioned (lines 332-334).
- Around line 738-744: The "미응답자에게 알림 보내기" button in ScheduleRoomClient.tsx is
clickable but has no handler; disable the button until the feature is
implemented by adding the disabled attribute (and/or aria-disabled) to the
button element and update its accessible label/title to indicate "준비 중" (or
"Coming soon"); also adjust its styling via the button's className (e.g., add
opacity and cursor-not-allowed) so it visually appears disabled and ensure
MessageCircle remains for icon consistency. Ensure no onClick is attached while
disabled and include an accessible tooltip or title so screen reader users see
the "준비 중" state.
In `@src/app/schedule/create/CreateScheduleClient.tsx`:
- Around line 299-304: Add a client-side validation to ensure the requested
duration fits within the selected candidate time range: in
CreateScheduleClient.tsx (the form submit/validation logic that currently checks
candidateEndHour vs candidateStartHour and durationMinutes > 0), compute the
candidate window in minutes from candidateStartHour and candidateEndHour and
throw/return the same validation error if durationMinutes is greater than that
window; update the same location where the two existing checks live (referencing
candidateStartHour, candidateEndHour, and durationMinutes) so the form blocks
submission before calling the server.
🪄 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: 276fdfd4-fa4d-4b1a-940e-b6cbbe5b3eac
⛔ Files ignored due to path filters (15)
docs/README.mdis excluded by!**/*.mddocs/business-plan/MOIM_2026_학생창업유망팀300_사업계획서_보강초안.mdis excluded by!**/*.mddocs/business-plan/README.mdis excluded by!**/*.mddocs/business-plan/references/사업계획서 개선점.zipis excluded by!**/*.zipdocs/business-plan/references/사업계획서 개선점/image.pngis excluded by!**/*.png,!**/*.pngdocs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.15.29.pngis excluded by!**/*.png,!**/*.pngdocs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.16.07.pngis excluded by!**/*.png,!**/*.pngdocs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.16.46.pngis excluded by!**/*.png,!**/*.pngdocs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.20.32.pngis excluded by!**/*.png,!**/*.pngdocs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.22.25.pngis excluded by!**/*.png,!**/*.pngdocs/business-plan/references/사업계획서 개선점/스크린샷_2026-04-14_오후_5.22.50.pngis excluded by!**/*.png,!**/*.pngdocs/business-plan/references/사업계획서 개선점/사업계획서 개선점 3724a6ec39318075b301ddd9e2abe7eb.mdis excluded by!**/*.mddocs/startup-data/processed-indicators.csvis excluded by!**/*.csvdocs/startup-data/public-data-analysis.mdis excluded by!**/*.mddocs/survey/모임(응답) - 설문지 응답 시트1.csvis excluded by!**/*.csv
📒 Files selected for processing (24)
docs/archive/legacy-business-plans/20212578_7팅_이동윤_사업계획서.hwpxdocs/archive/legacy-business-plans/20222074_이동은_사업계획서.hwpxdocs/archive/legacy-business-plans/20223007_김유현_예창패.hwpxdocs/archive/legacy-business-plans/20223026_정규호_예창패.hwpxdocs/business-plan/MOIM_2026_학생창업유망팀300_사업계획서_raw_text.txtdocs/business-plan/MOIM_사업계획서_1_dump.txtdocs/business-plan/MOIM_사업계획서_final_dump.txtdocs/business-plan/references/사업계획서의 이해_part1_extracted.txtdocs/business-plan/references/사업계획서의 이해_part2_extracted.txtdocs/business-plan/references/사업계획서의 이해_part3_extracted.txtnext.config.mjssrc/app/api/auth/apple/login/route.tssrc/app/calendar/connect/page.tsxsrc/app/schedule/[id]/ScheduleRoomClient.tsxsrc/app/schedule/create/CreateScheduleClient.tsxsrc/lib/auth/apple.tssrc/lib/auth/fetch-with-timeout.tssrc/lib/auth/google.tssrc/lib/auth/naver.tssrc/lib/schedules/__tests__/store.test.tssrc/lib/schedules/store.tssrc/lib/scheduling/__tests__/ics-parser.test.tssrc/lib/scheduling/ics-parser.tstsconfig.json.backup
💤 Files with no reviewable changes (1)
- tsconfig.json.backup
| 경쟁 서비스로는 직접 가능한 시간을 칠하는 방식의 일정 조율 도구, 일반 캘린더 공유 기능, 해외 인공지능 일정 관리 서비스가 있다. 그러나 수동 입력 도구는 매번 참여자가 직접 시간을 입력해야 하고, 일반 캘린더는 에브리타임 수업 시간표와 카카오톡 공유 맥락을 반영하기 어렵다. 모임은 한국 대학생의 실제 일정 조율 방식에 맞춘 낮은 참여 장벽과 자동화를 차별점으로 삼는다. | ||
| 기존 방식과 MOIM 방식 비교 | ||
| 설문 결과가 아직 충분히 쌓인 단계는 아니므로, 제출 문서에는 과장된 수치를 넣기보다 현재 우리가 해결하려는 문제 흐름을 명확히 보여주는 비교 이미지를 넣는다. 이후 1차 사용자 설문과 프로토타입 테스트를 통해 평균 조율 시간, 메시지 수, 사용 의향을 실제 수치로 보완할 계획이다. | ||
| [그림 1 삽입 위치] 기존 일정 조율 방식과 MOIM 방식 비교권장 이미지: 카톡 대화·시간표 캡처·수동 비교 흐름과 링크 공유·공통 가능 시간 확인 흐름을 나란히 비교 |
There was a problem hiding this comment.
문구 결합으로 도식 설명 가독성이 깨집니다.
Why: 비교와 권장 이미지가 붙어 있어 문서 자동 파싱/사람 읽기 모두에서 의미 경계가 모호합니다.
How: 구분자(: 또는 줄바꿈)만 추가해 의미 단위를 분리하세요.
수정 예시
-[그림 1 삽입 위치] 기존 일정 조율 방식과 MOIM 방식 비교권장 이미지: 카톡 대화·시간표 캡처·수동 비교 흐름과 링크 공유·공통 가능 시간 확인 흐름을 나란히 비교
+[그림 1 삽입 위치] 기존 일정 조율 방식과 MOIM 방식 비교
+권장 이미지: 카톡 대화·시간표 캡처·수동 비교 흐름과 링크 공유·공통 가능 시간 확인 흐름을 나란히 비교📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| [그림 1 삽입 위치] 기존 일정 조율 방식과 MOIM 방식 비교권장 이미지: 카톡 대화·시간표 캡처·수동 비교 흐름과 링크 공유·공통 가능 시간 확인 흐름을 나란히 비교 | |
| [그림 1 삽입 위치] 기존 일정 조율 방식과 MOIM 방식 비교 | |
| 권장 이미지: 카톡 대화·시간표 캡처·수동 비교 흐름과 링크 공유·공통 가능 시간 확인 흐름을 나란히 비교 |
🤖 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 `@docs/business-plan/MOIM_2026_학생창업유망팀300_사업계획서_raw_text.txt` at line 27, The
phrase "[그림 1 삽입 위치] 기존 일정 조율 방식과 MOIM 방식 비교권장 이미지: 카톡 대화·시간표 캡처·수동 비교 흐름과 링크
공유·공통 가능 시간 확인 흐름을 나란히 비교" has merged two semantic units; split "기존 일정 조율 방식과
MOIM 방식 비교" and "권장 이미지: ..." with a clear delimiter (add a colon, dash, or a
line break) so the meaning boundary is explicit; update the line containing "[그림
1 삽입 위치]" accordingly and ensure the fragment describing image contents ("카톡
대화·시간표 캡처·수동 비교 흐름..." etc.) remains after the delimiter for readability and
correct parsing.
| P_25: Vision: 모든 대학생이 모임 시간 걱정 없이 활동에만 집중할 수 있는 캠퍼스 | ||
| P_26: Mission: 반복적인 일정 조율의 마찰을 기술로 제거하여, 대학생 팀 활동의 실행력 증대 | ||
| P_27: 창업 아이템의 기여도 | ||
| MOIM은 기술적, 경제적, 사회적 세 측면에서 기여함 |
There was a problem hiding this comment.
라인 레코드 접두어 누락으로 덤프 포맷이 깨집니다.
Why: 동일 파일이 P_숫자: 구조를 따르는데 두 줄만 예외라 파서/정렬 로직에서 누락·오인식될 수 있습니다.
How: 누락 라인에 동일 접두어만 보강하세요.
수정 예시
-MOIM은 기술적, 경제적, 사회적 세 측면에서 기여함
+P_27A: MOIM은 기술적, 경제적, 사회적 세 측면에서 기여함
-국내 대학생 중 조별과제, 동아리, 스터디, 공모전, 팀 프로젝트를 자주 수행하는 사용자
+P_50A: 국내 대학생 중 조별과제, 동아리, 스터디, 공모전, 팀 프로젝트를 자주 수행하는 사용자Also applies to: 53-53
🤖 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 `@docs/business-plan/MOIM_사업계획서_1_dump.txt` at line 29, The dump has two lines
missing the expected "P_숫자:" line-record prefix (e.g., the line containing
"MOIM은 기술적, 경제적, 사회적 세 측면에서 기여함" and the similar one near the later section),
which breaks the parser; update those exact lines to include the same
"P_<number>:" prefix format used elsewhere in the file so they match the rest of
the records (retain the original text after the prefix).
| P_137: 창업자로서의 강점은 문제를 직접 겪은 사용자이자 구현을 주도할 개발자라는 점점 | ||
| P_138: 팀원 김유현은 글로벌미디어학부 소속으로 전반적이 UX/UI 디자인과 프론트엔드 개발 담당 |
There was a problem hiding this comment.
핵심 소개 구간 오탈자는 신뢰도를 떨어뜨립니다.
Why: 팀 역량 섹션은 평가자가 집중해서 읽는 구간이라 오탈자 하나가 문서 신뢰도에 직접 타격을 줍니다.
How: 의미 변화 없이 맞춤법만 교정하세요.
수정 예시
-P_137: 창업자로서의 강점은 문제를 직접 겪은 사용자이자 구현을 주도할 개발자라는 점점
+P_137: 창업자로서의 강점은 문제를 직접 겪은 사용자이자 구현을 주도할 개발자라는 점
-P_138: 팀원 김유현은 글로벌미디어학부 소속으로 전반적이 UX/UI 디자인과 프론트엔드 개발 담당
+P_138: 팀원 김유현은 글로벌미디어학부 소속으로 전반적인 UX/UI 디자인과 프론트엔드 개발 담당
-P_148: 개발 스터디와 여러 프로젝트들을 통한 서비스 구현 경험과 운영 경험을 통해 문제해결 및 대응 능력을 기름
+P_148: 개발 스터디와 여러 프로젝트들을 통한 서비스 구현 경험과 운영 경험을 통해 문제해결 및 대응 능력을 기름 → 키움</details>
Also applies to: 152-152
<details>
<summary>🤖 Prompt for AI Agents</summary>
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @docs/business-plan/MOIM_사업계획서_1_dump.txt around lines 140 - 141, Fix the
typographical errors in the team/strength introduction lines (e.g., the phrases
starting "창업자로서의 강점은..." and "팀원 김유현은...") by correcting Korean spelling and
spacing without changing meaning; ensure "창업자로서의" and "팀원 김유현은 글로벌미디어학부 소속으로
전반적이 UX/UI 디자인과 프론트엔드 개발 담당" are rewritten with proper spacing and orthography
consistent with Korean spelling rules (apply same correction to the similar
issue at the other occurrence around line 152).
</details>
<!-- fingerprinting:phantom:triton:hawk -->
<!-- This is an auto-generated comment by CodeRabbit -->
| P_33: 이러한 통계는 청년층의 일상 속 만성적인 시간 결핍감을 방증한다. 통계청 사회조사에 따르면 청년층 여가생활 불만족 이유 중 '시간 부족'이 21.9%로 높은 비중을 차지하고 있으며, 비효율적인 모임 일정 조율에 불필요하게 낭비되는 시간이 이들의 한정된 여가 시간 자원을 한층 더 압박하고 있다. 따라서 조율 단계를 극적으로 줄여 시간 낭비를 최소화하는 것은 대학생 청년들의 일상적 시간 결핍감을 해소하는 데 직접적으로 기여하는 유의미한 가치를 지닌다. | ||
| P_34: 이러한 일정 조율의 비효율성은 실제 자체 설문조사(대학생 및 청년 38명 대상) 결과에서도 뚜렷하게 확인된다. 설문에 따르면 한 달 평균 모임 일정 조율 횟수로 '3회~5회'가 42.1%, '6회~10회' 및 '10회 이상'이 21.1%로 나타나 대학생들의 일상 속 조율 빈도가 매우 높았다. 그럼에도 현재 조율 방식에 대한 만족도는 3.13점 / 5점으로 보통 이하 수준에 머물렀다. 조율에 걸리는 시간은 '30분 이상에서 하루 이상'이 소요된다고 답한 비율이 36.9%에 달했으며, 스케줄링의 병목으로 인해 '일정이 지연되거나 모임 자체가 연기·무산된 경험이 있다'고 답한 비율이 무려 79.0%를 기록했다. 특히 조원들이 일정 조율 과정에서 겪는 가장 파괴적인 스트레스 포인트는 "다 정해놨는데 한 명이 마지막에 안 된다고 번복할 때" (78.9%, 30명)와 "누가 응답을 제때 안 해서 한참을 무작정 기다려야 할 때" (60.5%, 23명)로 나타났다. | ||
| P_35: 이러한 통계는 청년층의 일상 속 만성적인 시간 결핍감을 방증한다. 통계청 사회조사에 따르면 청년층 여가생활 불만족 이유 중 '시간 부족'이 21.9%로 높은 비중을 차지하고 있으며, 비효율적인 모임 일정 조율에 불필요하게 낭비되는 시간이 이들의 한정된 여가 시간 자원을 한층 더 압박하고 있다. 따라서 조율 단계를 극적으로 줄여 시간 낭비를 최소화하는 것은 대학생 청년들의 일상적 시간 결핍감을 해소하는 데 직접적으로 기여하는 유의미한 가치를 지닌다. |
There was a problem hiding this comment.
동일 통계 문단이 중복되어 메시지 밀도가 떨어집니다.
Why: 같은 설문 근거가 연속 반복되어 심사자 입장에서 핵심 메시지 추적이 어려워집니다.
How: 첫 번째 요약 블록을 유지하고 중복 블록(Line 34~36)만 삭제하세요.
수정 예시
-P_34: 이러한 일정 조율의 비효율성은 실제 자체 설문조사(대학생 및 청년 38명 대상) 결과에서도 뚜렷하게 확인된다. 설문에 따르면 ...
-P_35: 이러한 통계는 청년층의 일상 속 만성적인 시간 결핍감을 방증한다. 통계청 사회조사에 따르면 ...
-P_36: → 각자의 시간표와 캘린더 정보를 한 번만 반영하면 공통으로 가능한 시간이 자동으로 보이는 방식으로 줄일 수 있다고 판단
+P_36: → 각자의 시간표와 캘린더 정보를 한 번만 반영하면 공통으로 가능한 시간이 자동으로 보이는 방식으로 줄일 수 있다고 판단🤖 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 `@docs/business-plan/MOIM_사업계획서_final_dump.txt` around lines 34 - 36, The
paragraph block P_34–P_35 duplicates the earlier summary (P_33), reducing
message density; remove the duplicated block (the second occurrence of the same
survey/statistics paragraph) and keep only the first summary paragraph (P_33) so
the document contains a single clear instance of that survey-based argument.
| --- PAGE 15 --- | ||
| (No text found or image page) | ||
| --- PAGE 16 --- | ||
| 통계청 http://kostat.go.kr |
There was a problem hiding this comment.
외부 참조 URL은 HTTPS로 표기하세요.
Why: 제출/공유 문서에서 HTTP 표기는 신뢰도와 보안 인식에 불리합니다.
How: 프로토콜만 https로 치환하면 됩니다.
수정 예시
-통계청 http://kostat.go.kr
+통계청 https://kostat.go.kr📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 통계청 http://kostat.go.kr | |
| 통계청 https://kostat.go.kr |
🤖 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 `@docs/business-plan/references/사업계획서의` 이해_part2_extracted.txt at line 36,
Replace the external reference "통계청 http://kostat.go.kr" so the URL uses HTTPS;
locate the exact string "통계청 http://kostat.go.kr" in the document and change the
protocol to "https" (i.e., "통계청 https://kostat.go.kr").
| /** @type {import('next').NextConfig} */ | ||
| const nextConfig = { | ||
| }; | ||
|
|
||
| export default nextConfig; |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | 🏗️ Heavy lift
빈 설정으로 두지 말고 typedRoutes를 켜세요.
이유: 지금처럼 비어 있으면 App Router 경로 문자열 오류를 런타임 404에서만 발견합니다. 이 PR에서도 실제 라우트 오타가 이미 보였습니다. Next.js 15.5에서는 typedRoutes가 안정화됐으니, 최소한 컴파일 타임 검증은 켜두는 편이 안전합니다. 방법: 설정 한 줄만 추가하세요.
최소 수정 예시
/** `@type` {import('next').NextConfig} */
const nextConfig = {
+ typedRoutes: true,
};🤖 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 `@next.config.mjs` around lines 1 - 5, 현재 비어 있는 nextConfig 객체 때문에 App Router 경로
문자열 오류를 런타임에서만 잡습니다; nextConfig 설정에 typedRoutes: true 옵션을 추가해 컴파일 타임 경로 검증을 켜고
라우트 오타를 사전에 발견할 수 있게 하세요 (수정 대상: nextConfig 객체 내에 typedRoutes: true 추가).
| const normalizedSlot = normalizeConfirmedSlot(schedule, confirmedSlot); | ||
| const updateResult = await tx.schedule.updateMany({ | ||
| where: { id, status: "open" }, | ||
| data: { | ||
| status: "confirmed", | ||
| confirmedSlot: JSON.stringify(normalizedSlot), | ||
| }, |
There was a problem hiding this comment.
durationMinutes가 실제 확정 길이에 전혀 반영되지 않습니다.
Why: 여기서는 confirmedSlot이 공통 슬롯 안에만 있으면 통과합니다. 그런데 공통 슬롯 계산도 schedule.durationMinutes를 모르기 때문에(Line 302-310) 120분 모임을 1시간으로 확정하는 것도 막지 못합니다. 더 큰 문제는 현재 TimeSlot이 정수 hour 모델이라 CreateScheduleClient.tsx의 30/90분 옵션은 애초에 정확히 표현할 수 없다는 점입니다.
How: 머지 전 최소 안전선은 지원 duration 계약을 현재 슬롯 모델과 맞추는 것입니다. 즉, 분 단위 슬롯 모델로 재작업하기 전까지는 지원 값을 60분 단위로 제한하고, 확정 시에도 길이를 검증해야 합니다.
코드 스니펫
// src/lib/schedules/store.ts
- if (
- !Number.isInteger(input.durationMinutes) ||
- input.durationMinutes < 15 ||
- input.durationMinutes > 480
- ) {
- throw new Error("durationMinutes must be between 15 and 480");
- }
+ if (
+ !Number.isInteger(input.durationMinutes) ||
+ input.durationMinutes < 60 ||
+ input.durationMinutes > 480 ||
+ input.durationMinutes % 60 !== 0
+ ) {
+ throw new Error("durationMinutes must be a whole-hour value between 60 and 480");
+ }// src/lib/schedules/store.ts
const normalizedSlot = normalizeConfirmedSlot(schedule, confirmedSlot);
+ if (
+ normalizedSlot.endHour - normalizedSlot.startHour !==
+ schedule.durationMinutes / 60
+ ) {
+ throw new Error("confirmed slot must match durationMinutes");
+ }
const updateResult = await tx.schedule.updateMany({// src/app/schedule/create/CreateScheduleClient.tsx
- <option value="30">30분</option>
<option value="60">60분</option>
- <option value="90">90분</option>
<option value="120">120분</option>🤖 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/schedules/store.ts` around lines 174 - 180, The update path uses
normalizeConfirmedSlot and tx.schedule.updateMany to set confirmedSlot without
ensuring the confirmed slot length matches schedule.durationMinutes or that
durationMinutes is constrained to the current hour-based TimeSlot model;
restrict supported durations in CreateScheduleClient.tsx to multiples of 60
while we still use hour-granularity TimeSlot, and before calling
tx.schedule.updateMany (in the confirm/normalize flow) validate that the
normalizedSlot length (computed by normalizeConfirmedSlot) equals
schedule.durationMinutes (or convert schedule.durationMinutes to hours
consistently) and reject/throw an error if it does not, so we never confirm a
slot shorter/longer than the declared duration and the stored confirmedSlot JSON
remains consistent.
There was a problem hiding this comment.
Actionable comments posted: 33
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/app/api/auth/kakao/callback/route.ts (1)
99-120:⚠️ Potential issue | 🟠 Major | ⚡ Quick win신규 사용자 생성 시 레이스 컨디션 미처리
Why: Google/Naver 콜백에는
P2002유니크 제약 예외 처리가 있지만, Kakao 콜백에는 누락됨. 동시 요청 시prisma.user.create가 중복 실행되면 500 에러 발생.How: Google 콜백의
isUniqueConstraintError패턴 적용 필요.레이스 처리 패턴 적용
+import { Prisma } from "`@prisma/client`"; +function isUniqueConstraintError( + error: unknown, +): error is Prisma.PrismaClientKnownRequestError { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ); +} // 기존 prisma.user.create 호출을 try-catch로 감싸고 // P2002 발생 시 socialAccount 재조회 또는 닉네임 suffix 재시도Google/Naver 콜백과 동일한 패턴으로 통일 권장.
🤖 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/auth/kakao/callback/route.ts` around lines 99 - 120, The Kakao callback branch creates a new user via prisma.user.create (newUser -> user) but lacks the unique-constraint race handling used in Google/Naver; update the Kakao flow to catch Prisma P2002 errors (use your existing isUniqueConstraintError helper) around prisma.user.create, and on P2002 re-run the lookup (e.g., prisma.user.findUnique / findFirst using email or providerUserId) and attach the existing user instead of throwing; keep the same selected fields (id, email, nickname, profileCompleted) and preserve socialAccounts.create for the non-racing path so behavior mirrors the Google/Naver handlers.src/app/api/auth/login/route.ts (1)
4-9: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
COOKIE_MAX_AGE상수 불일치 가능성Why: Line 4에서
jwt.ts의COOKIE_MAX_AGE를 import하지 않고 Line 9에서 로컬 상수(7일)를 별도 정의. 소셜 로그인 콜백들은jwt.ts의 값을 사용하므로 세션 만료 시간이 로그인 방식에 따라 달라질 수 있음.How:
jwt.ts의 상수를 재사용하거나 의도적 차이라면 상수명을 구분.일관성 유지 예시
-import { signAccessToken, COOKIE_NAME } from "`@/lib/auth/jwt`"; +import { signAccessToken, COOKIE_NAME, COOKIE_MAX_AGE } from "`@/lib/auth/jwt`"; -const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7일 +const REMEMBER_ME_MAX_AGE = 60 * 60 * 24 * 30; // 30일🤖 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/auth/login/route.ts` around lines 4 - 9, The local COOKIE_MAX_AGE constant in route.ts may diverge from the canonical value in jwt.ts; instead of redefining it, import and reuse COOKIE_MAX_AGE from "`@/lib/auth/jwt`" (alongside signAccessToken and COOKIE_NAME) so social login callbacks and this login route share the same session expiry; if a different expiry is intentional, rename the local constant to a distinct name (e.g., COOKIE_MAX_AGE_LOGIN) and document the difference where signAccessToken or COOKIE_NAME are used.src/app/schedule/create/CreateScheduleClient.tsx (1)
321-341:⚠️ Potential issue | 🟠 Major | ⚡ Quick win소요 시간이 후보 시간 범위를 초과하는 경우를 검증하지 않습니다.
120분모임에17:00-18:00후보 시간을 설정해도 검증을 통과합니다. 실제 배치 가능한 슬롯이 없는 모임이 생성됩니다.수정 예시
function validateScheduleForm({ candidateDays, candidateStartHour, candidateEndHour, durationMinutes, }: { candidateDays: DayCode[]; candidateStartHour: string; candidateEndHour: string; durationMinutes: string; }) { if (candidateDays.length === 0) { throw new Error("후보 요일을 하나 이상 선택해 주세요."); } if (Number(candidateEndHour) <= Number(candidateStartHour)) { throw new Error("종료 시간은 시작 시간보다 늦어야 합니다."); } + const windowMinutes = + (Number(candidateEndHour) - Number(candidateStartHour)) * 60; + if (windowMinutes < Number(durationMinutes)) { + throw new Error("소요 시간이 후보 시간 범위를 초과했습니다."); + } if (Number(durationMinutes) <= 0) { throw new Error("소요 시간은 0보다 커야 합니다."); } }🤖 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/schedule/create/CreateScheduleClient.tsx` around lines 321 - 341, validateScheduleForm currently doesn't check whether durationMinutes fits within the candidateStartHour/candidateEndHour window, allowing impossible schedules; update validateScheduleForm to parse candidateStartHour and candidateEndHour into minutes (handle "HH" or "HH:MM" formats), compute availableMinutes = endMinutes - startMinutes, and throw an Error (e.g., "소요 시간이 후보 시간 범위를 초과합니다.") if Number(durationMinutes) > availableMinutes; keep existing checks (candidateDays length, end > start, duration > 0) and reference the function name validateScheduleForm and its params candidateStartHour, candidateEndHour, durationMinutes when making the change.src/app/schedule/[id]/ScheduleRoomClient.tsx (1)
391-398:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win확정 상태에서도 QuickImportPanel이 표시됩니다.
schedule.status === "confirmed"체크 없이QuickImportPanel이 항상 렌더링됩니다. 게스트가 확정된 일정에서도 "빠른 입력" UI를 보게 되어 혼란을 줍니다.수정 예시
- <QuickImportPanel - everytimeUrl={everytimeUrl} - importMessage={importMessage} - importMode={importMode} - onUrlChange={setEverytimeUrl} - onUrlSubmit={importEverytimeUrl} - onFileChange={importEverytimeFile} - onConnectCalendarClick={() => setShowPremiumModal(true)} - /> + {schedule.status !== "confirmed" && ( + <QuickImportPanel + everytimeUrl={everytimeUrl} + importMessage={importMessage} + importMode={importMode} + onUrlChange={setEverytimeUrl} + onUrlSubmit={importEverytimeUrl} + onFileChange={importEverytimeFile} + onConnectCalendarClick={() => setShowPremiumModal(true)} + /> + )}🤖 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/schedule/`[id]/ScheduleRoomClient.tsx around lines 391 - 398, QuickImportPanel이 schedule.status === "confirmed"인 경우에도 항상 렌더링되어 확정된 일정에서 게스트에게 표시되므로, QuickImportPanel을 렌더링할 때 schedule.status를 검사해 "confirmed"일 경우에는 렌더링하지 않도록 조건부로 감싸세요; 예를 들어 기존 QuickImportPanel 호출을 schedule?.status !== "confirmed" && <QuickImportPanel ...> 형태로 감싸거나 해당 조건을 체크하는 별도 변수(예: isEditableSchedule)를 도입해 QuickImportPanel(및 전달되는 props: everytimeUrl, importMessage, importMode, setEverytimeUrl, importEverytimeUrl, importEverytimeFile, setShowPremiumModal)을 렌더링하지 않게 수정하세요.
🤖 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 `@e2e/calendar-integration.spec.ts`:
- Line 31: Replace the fixed sleeps that call page.waitForTimeout(2000) with
conditional waits that wait for a specific element or state instead (e.g., use
page.waitForSelector(...) or locator.waitFor({ state: 'visible' }) or
page.waitForFunction(...) targeting the UI element that indicates rendering is
complete); update the occurrences of page.waitForTimeout in this spec (the shown
call and the other occurrences at the same pattern) to wait on the relevant
selector/locator visibility or a function that returns true so the test is
stable and faster.
- Around line 14-27: The route handler captures a stale calendarStatusMock value
because page.route's fulfillment uses JSON.stringify(calendarStatusMock) at
registration time; update the test to compute the response at request time or
re-register the route before each test step so the handler reads the latest
state. Concretely, modify the page.route handler (the async route => { await
route.fulfill({... body: ...}) }) to serialize calendarStatusMock inside the
handler when invoked, or call page.unroute("**/api/calendar/status") and then
page.route with the new calendarStatusMock whenever you reassign
calendarStatusMock so calendarStatusMock, page.route, and route.fulfill always
use the current value.
In `@e2e/host-flow.spec.ts`:
- Around line 6-71: Remove the hardcoded page.waitForTimeout calls and
test.slow() usage and replace them with Playwright's automatic waiting patterns:
use locator.waitFor / expect(locator).toBeVisible / page.waitForURL /
page.waitForLoadState / page.waitForNavigation where appropriate (e.g., replace
the initial 2s hydration waits before interacting with elements by awaiting
emailInput.waitFor/expect(emailInput).toBeVisible and similarly for
loginEmailInput and after navigation use page.waitForURL("**/schedule/create")
or page.waitForLoadState("networkidle") instead of the 2–3s sleeps); also remove
the about:blank navigation unless a specific router reset is required and if you
must reset history, prefer page.reload() or a targeted navigation and await its
load state. Ensure all interactions reference the existing locators (emailInput,
phoneInput, nicknameInput, pwInput, pwConfirmInput, loginEmailInput,
loginPwInput) and await their visible/enabled states before fill/click.
In `@e2e/participant-flow.spec.ts`:
- Around line 33-34: Replace the fixed 2s sleep (page.waitForTimeout(2000)) with
an explicit wait for the UI change that indicates the submit finished: use
Playwright's expect/waitForSelector (e.g., wait for the success message element
you already assert with toBeVisible, or wait for navigation via
page.waitForNavigation if you call page.goto) so the test waits for the specific
element/state change instead of an arbitrary timeout.
In `@playwright.config.ts`:
- Around line 43-45: The Playwright webServer.command currently runs `npm run
start` which requires a prior Next.js build; update the setup so tests never
fail due to a missing build: either add or update a package.json script (e.g.,
"test:e2e") to run `npm run build && npx playwright test` and use that for
CI/local e2e runs, or change the webServer.command to include the build step by
running `npm run build && npm run start -- --port ${e2ePort}`; locate the
webServer.command and e2ePort usage in playwright.config.ts and/or add/update
the "test:e2e" script in package.json accordingly.
In `@scripts/seed_availability.js`:
- Around line 82-86: The current sequential insertion loop over dummyData calls
await prisma.scheduleParticipant.create for each participant, which is slow;
replace it by mapping dummyData to an array of create promises (using
prisma.scheduleParticipant.create) and await Promise.all(...) so all inserts run
in parallel (ensure you still await the Promise.all result to propagate errors).
- Line 12: The hardcoded production-looking hostTokenHash value in
scripts/seed_availability.js must be removed and replaced with a non-sensitive
test value; locate the hostTokenHash assignment in the seed data (field name
hostTokenHash) and either generate a safe random value at runtime using
crypto.randomBytes(32).toString('base64url') or replace it with an explicit test
placeholder like "TEST_HOST_TOKEN_HASH" before committing, ensuring no real or
production token/hash remains in the repository.
- Line 1: Remove the file-wide "/* eslint-disable */" from
scripts/seed_availability.js and either (A) replace it with targeted disables
only for the rules you need (e.g., disable no-console or dangling underscores)
placed adjacent to the offending lines, or (B) update your ESLint config to add
an override for "scripts/*.js" that relaxes specific rules for scripts, or (C)
migrate the file to TypeScript (scripts/seed_availability.ts) and fix typing
warnings — locate the file and the main async function that uses PrismaClient to
apply the minimal rule changes rather than blanket-disabling all linting.
In `@src/app/`(auth)/forgot-password/page.tsx:
- Around line 24-27: The code calls await response.json() unconditionally which
can throw on empty responses; update the logic in the forgot-password page
handler to check response.ok before parsing and only parse when the response has
a JSON content-type (or wrap the parse in try/catch). Concretely, in the block
that uses response and result (the lines using response.ok and result = await
response.json()), first verify response.ok and/or inspect
response.headers.get('content-type') for 'application/json' (or use
response.text() and JSON.parse defensively) and handle empty/no-content cases by
providing a sensible default error/message instead of calling response.json()
blindly.
In `@src/app/`(auth)/login/page.tsx:
- Line 78: Remove the manual onSubmit={(e) => e.preventDefault()} on the form
and switch to the standard form submission flow: wire the form's onSubmit to
your existing handleSubmit handler (or update handleSubmit to accept a
React.FormEvent and call your submit logic there), change the submit control to
type="submit" (not type="button"), and use the isSubmitting flag to set the
submit button's disabled attribute to prevent double submits; apply the same
change where another form uses preventDefault (the other form block around the
submit button) so native Enter-key submission, browser validation and autofill
work correctly.
- Around line 18-27: The client-side cookie parsing in useEffect
(document.cookie split, providerCookie lookup, setLastProvider) should be moved
to the server component and passed as a prop to the client component to follow
App Router patterns: read the 'last_login_provider' using next/headers cookies()
in the server entry (e.g., LoginPage), derive initialLastProvider, and render
the client LoginForm with a prop like initialLastProvider so you can remove the
document.cookie parsing and the useEffect that setsMounted/setLastProvider in
the client component.
- Around line 115-125: The password visibility toggle button lacks an
accessibility label; update the button (the element using onClick={() =>
setShowPassword(!showPassword)} and the showPassword state) to include an
appropriate ARIA attribute such as aria-label that changes based on showPassword
(e.g., "Show password" when showPassword is false and "Hide password" when true)
and consider adding aria-pressed or role="button" to communicate state to screen
readers; ensure the label text is concise and uses the same strings across the
Login page so the Eye/EyeOff icons remain decorative.
- Around line 155-217: Extract the repeated "최근 사용" span into a small component
(e.g., RecentlyUsedBadge) defined at the top of page.tsx and replace each inline
span in the AuthProvider blocks (the local form, Kakao, Google, Naver, Apple
buttons where you check mounted && lastProvider === "...") with
<RecentlyUsedBadge show={mounted && lastProvider === "<provider>"} />; ensure
RecentlyUsedBadge returns null when show is false and renders the badge with
aria-hidden="true" and the same classes, but swap animate-bounce for
animate-bounceOnce to match your tailwind config.
- Around line 46-47: 삭제된 고정 2초 대기(await new Promise(...,2000))를 제거하고 로그인 성공 후
클라이언트가 서버에 인증 쿠키가 반영되었는지 확인하도록 변경하세요: in src/app/(auth)/login/page.tsx, replace
the static sleep with a short polling loop that calls fetch("/api/auth/me", {
credentials: "include" }) up to a small number of attempts (e.g., 5) with a
short backoff between tries, break when meRes.ok is true, then navigate using
Next's router.push("/schedule/create") (not window.location.href); keep retries
short and limited to avoid long UX delays.
In `@src/app/`(auth)/reset-password/complete/page.tsx:
- Around line 41-61: The current useEffect uses a 1s setTimeout to wait before
calling createClient() and supabase.auth.getSession(), which can misdetect
missing sessions; remove the artificial setTimeout and instead call
createClient() and supabase.auth.getSession() immediately inside the useEffect
(or register supabase.auth.onAuthStateChange to react to auth events) so session
parsing is not race-prone—update the code in the useEffect that references
createClient, supabase, and getSession to perform immediate session retrieval or
subscribe to onAuthStateChange and handle setting setMessage accordingly, and
remove the timer/clearTimeout logic.
- Around line 22-29: The password validation currently performs a duplicate
length check (password.length < 8) and also uses {8,} in the regex; simplify by
using a single source of truth: either remove the explicit length check and rely
solely on the regex in the password validation expression (the ternary that
returns the Korean error message), or split checks into separate conditions to
provide clearer messages (e.g., check for min length, letters, digits, special
chars separately) and update the ternary accordingly; locate the validation in
the page.tsx snippet around the password: field and update the conditional to
use one approach consistently.
- Around line 170-180: The password-toggle UI is duplicated (see the button
using setShowPassword/showPassword and the Eye/EyeOff icons); extract it into a
reusable PasswordInput component (e.g., src/components/ui/PasswordInput.tsx)
that encapsulates its own show state and renders the input, toggle button and
optional error display; have PasswordInput accept props value, onChange, onBlur,
placeholder, label, error, and touched, then replace both duplicated blocks in
page.tsx (the inputs currently using showPassword/setShowPassword and
Eye/EyeOff) with PasswordInput instances to remove duplication.
- Around line 93-100: Duplicate password validation exists in the client page
and the server route; extract the regex and validation into a shared module
(e.g., create validatePassword and PASSWORD_REGEX in a new lib such as
src/lib/auth/password.ts), replace the inline checks in
src/app/(auth)/reset-password/complete/page.tsx (where the fetch body sets {
password }) to import and call validatePassword for client-side validation, and
import the same validatePassword in
src/app/api/auth/reset-password/complete/route.ts to perform server-side
validation so both sides rely on the identical validatePassword/PASSWORD_REGEX
functions.
In `@src/app/`(auth)/signup/page.tsx:
- Around line 251-257: The ChevronRight icon button lacks an aria-label, so
update the button (the element that calls setActiveTermsKey with name as
TermsKey) to include an accessible aria-label that uses the corresponding terms
label (e.g., the variable/prop holding the term's label) so screen readers
announce which terms will be opened; ensure the label is descriptive like "View
details for {label}" and attach it to the same button that renders the
ChevronRight icon.
- Around line 431-441: The password-visibility toggle button (the button using
onToggleShow and rendering Eye/EyeOff based on showPassword) lacks an accessible
label; add an aria-label to the button element that reflects the current state
(e.g., when showPassword is true use "Hide password", otherwise "Show password")
so screen readers announce the button purpose; update the button rendering
around the onToggleShow handler to set aria-label dynamically from showPassword.
- Around line 269-277: The submit button currently only uses visual cues to
indicate submitting; add the disabled attribute to the button element to prevent
duplicate submissions by keyboard/mouse (i.e., set disabled={isSubmitting} on
the button that calls handleSubmit) and ensure any aria-disabled or focus
handling remains consistent with the existing className logic that uses
isSubmitting.
In `@src/app/api/auth/forgot-password/route.ts`:
- Around line 33-38: The current branch returns a 404 when "user" is not found,
which leaks account existence; change the forgot-password route handler so it
does not distinguish existence: always return the same NextResponse.json payload
and HTTP status (e.g., 200) whether user is found or not, and keep using the
same call site symbols (user, NextResponse.json) so callers see a generic
message like "If an account with that email exists, you will receive reset
instructions." Optionally keep internal logic that sends reset email only when
user exists but do not surface that result in the response.
In `@src/app/api/calendar/status/route.ts`:
- Around line 24-26: The DB query errors for Supabase are currently only logged
and then treated as "not linked" because googleConn and icloudConn become
undefined; update the route handler to detect errors from the Supabase calls
that populate googleConn/icloudConn and on error immediately return a 500 JSON
response (or at minimum include an "error" field in the response body) instead
of proceeding to compute !!googleConn/!!icloudConn; locate the Supabase query
code that sets googleConn and icloudConn in route.ts (the handler function) and
either throw/return a 500 with the error details or fold the error into the
final response so callers can distinguish DB failures from "not linked" states.
In `@src/app/api/google/disconnect/route.ts`:
- Around line 24-28: The google disconnect endpoint currently hard-deletes from
the google_connections table using
supabase.from("google_connections").delete().eq("profile_id", session.userId)
but lacks the cascade note present in the iCloud endpoint; inspect whether the
foreign-key on google_calendars (or other Google-related tables) is configured
with ON DELETE CASCADE, and then update the comment above this delete in
src/app/api/google/disconnect/route.ts to state the cascade behavior (e.g., "Due
to FK ON DELETE CASCADE, related google_calendars are also removed") or, if no
cascade exists, explicitly delete dependent records (e.g., delete from
google_calendars where profile_id = session.userId) before removing
google_connections; reference the supabase delete call and session.userId when
making the change.
In `@src/app/api/icloud/disconnect/route.ts`:
- Around line 7-47: The POST handlers for iCloud and Google share identical
logic and should be DRYed: extract a shared async function
disconnectCalendar(userId, tableName) that uses createClient() to delete from
the given table (e.g., "icloud_connections" or "google_connections") and returns
a success/error result; then update the POST handler in this file to call
disconnectCalendar(session.userId, "icloud_connections") and handle the returned
error the same way as the current code (log via console.error and return the
appropriate NextResponse JSON). Ensure the new function exposes clear error
strings and logs include the table name for easier debugging.
In `@src/app/globals.css`:
- Around line 128-135: Rename the keyframes identifier from "glowPulse" to
kebab-case "glow-pulse" and update every reference to it (e.g. any "animation:
glowPulse" or "animation-name: glowPulse") to use "glow-pulse"; specifically
update the `@keyframes` declaration currently named glowPulse and all uses of the
symbol "glowPulse" so they match the Stylelint keyframes-name-pattern.
- Around line 67-68: Remove the duplicate letter-spacing by keeping only the
explicit value and dropping the Tailwind utility: remove "tracking-tight" from
the `@apply` list and keep the explicit "letter-spacing: -0.02em;" so there's a
single clear source of truth for spacing; update the `@apply` line that currently
includes "tracking-tight" and ensure only the desired explicit letter-spacing
remains.
In `@src/app/schedule/`[id]/ScheduleRoomClient.tsx:
- Around line 543-553: The PurpleButton currently only looks disabled via
className but lacks the actual disabled prop; update the PurpleButton element to
pass disabled={isSubmitting || selected.length === 0} (same condition used for
the visual state) so the button is truly disabled for assistive tech and
keyboard users, and ensure handleSubmit is not invoked when disabled (e.g., keep
onClick={() => handleSubmit()} but rely on the native disabled behavior or guard
inside handleSubmit if needed).
In `@src/components/moim/auth-social.tsx`:
- Around line 22-37: The Google SVG in auth-social.tsx hardcodes brand colors
which breaks dark-mode consistency; locate the four <path> elements whose d
attributes start with "M22.56 12.25", "M12 23c2.97", "M5.84 14.09" and "M12
5.38" and change their fill attributes from the hex colors (`#4285F4`, `#34A853`,
`#FBBC05`, `#EA4335`) to fill="currentColor" so the Google icon inherits the parent
button/text color (matching Apple/Kakao/Naver behavior).
- Around line 17-72: The SVG icons returned in the social auth switch cases (the
<svg> elements used in the "google", "apple", "kakao", and "naver" branches) are
decorative and must include aria-hidden="true"; update each SVG element inside
the component (where those cases return their JSX) to add aria-hidden="true" so
screen readers ignore these decorative paths while preserving existing
className, xmlns, viewBox, and fill attributes.
In `@src/components/moim/TermsModal.tsx`:
- Around line 81-87: The modal currently only disables background scroll in
TermsModal but lacks ESC-to-close and focus trapping; update the useEffect in
TermsModal to add a keydown listener that calls the modal close handler (e.g.,
the existing close callback passed into TermsModal) when event.key === "Escape"
and clean it up on unmount, and wrap the modal content with a focus trap (prefer
using the focus-trap-react <FocusTrap> around the modal body) so keyboard focus
is contained while open; ensure the escape handler and focus-trap are wired to
the same close function and all event listeners are removed in the effect
cleanup.
- Around line 92-124: The modal wrapper in the TermsModal component lacks
accessibility attributes; update the outer modal container (the topmost div
returned by TermsModal) to include role="dialog" and aria-modal="true", and
provide either aria-labelledby that points to the h2 title (give the h2 a stable
id, e.g., id="terms-title") or an explicit aria-label using data.title; also
ensure the close button (onClick={onClose}, the X button) has an accessible
label (e.g., aria-label="Close terms") so screen readers can announce it.
In `@tailwind.config.ts`:
- Around line 103-112: The custom animation bounceOnce is defined but the
"recent" badge still uses Tailwind's infinite animate-bounce; either switch the
badge's class from animate-bounce to animate-bounceOnce so it only animates once
(update the component that renders the "최근 사용" badge to use
"animate-bounceOnce"), or if you intend to keep the infinite behavior remove the
unused bounceOnce entry from the animation config (the animation and keyframe
entries named bounceOnce) to avoid dead config; pick one option and apply the
corresponding change consistently.
---
Outside diff comments:
In `@src/app/api/auth/kakao/callback/route.ts`:
- Around line 99-120: The Kakao callback branch creates a new user via
prisma.user.create (newUser -> user) but lacks the unique-constraint race
handling used in Google/Naver; update the Kakao flow to catch Prisma P2002
errors (use your existing isUniqueConstraintError helper) around
prisma.user.create, and on P2002 re-run the lookup (e.g., prisma.user.findUnique
/ findFirst using email or providerUserId) and attach the existing user instead
of throwing; keep the same selected fields (id, email, nickname,
profileCompleted) and preserve socialAccounts.create for the non-racing path so
behavior mirrors the Google/Naver handlers.
In `@src/app/api/auth/login/route.ts`:
- Around line 4-9: The local COOKIE_MAX_AGE constant in route.ts may diverge
from the canonical value in jwt.ts; instead of redefining it, import and reuse
COOKIE_MAX_AGE from "`@/lib/auth/jwt`" (alongside signAccessToken and COOKIE_NAME)
so social login callbacks and this login route share the same session expiry; if
a different expiry is intentional, rename the local constant to a distinct name
(e.g., COOKIE_MAX_AGE_LOGIN) and document the difference where signAccessToken
or COOKIE_NAME are used.
In `@src/app/schedule/`[id]/ScheduleRoomClient.tsx:
- Around line 391-398: QuickImportPanel이 schedule.status === "confirmed"인 경우에도
항상 렌더링되어 확정된 일정에서 게스트에게 표시되므로, QuickImportPanel을 렌더링할 때 schedule.status를 검사해
"confirmed"일 경우에는 렌더링하지 않도록 조건부로 감싸세요; 예를 들어 기존 QuickImportPanel 호출을
schedule?.status !== "confirmed" && <QuickImportPanel ...> 형태로 감싸거나 해당 조건을 체크하는
별도 변수(예: isEditableSchedule)를 도입해 QuickImportPanel(및 전달되는 props: everytimeUrl,
importMessage, importMode, setEverytimeUrl, importEverytimeUrl,
importEverytimeFile, setShowPremiumModal)을 렌더링하지 않게 수정하세요.
In `@src/app/schedule/create/CreateScheduleClient.tsx`:
- Around line 321-341: validateScheduleForm currently doesn't check whether
durationMinutes fits within the candidateStartHour/candidateEndHour window,
allowing impossible schedules; update validateScheduleForm to parse
candidateStartHour and candidateEndHour into minutes (handle "HH" or "HH:MM"
formats), compute availableMinutes = endMinutes - startMinutes, and throw an
Error (e.g., "소요 시간이 후보 시간 범위를 초과합니다.") if Number(durationMinutes) >
availableMinutes; keep existing checks (candidateDays length, end > start,
duration > 0) and reference the function name validateScheduleForm and its
params candidateStartHour, candidateEndHour, durationMinutes when making the
change.
🪄 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: 43dc0452-128a-4b2e-a9bc-2f8cd2f0ee64
⛔ Files ignored due to path filters (7)
package.jsonis excluded by!**/*.jsonsrc/app/fonts/Pretendard-Bold.woff2is excluded by!**/*.woff2src/app/fonts/Pretendard-ExtraBold.woff2is excluded by!**/*.woff2src/app/fonts/Pretendard-Light.woff2is excluded by!**/*.woff2src/app/fonts/Pretendard-Medium.woff2is excluded by!**/*.woff2src/app/fonts/Pretendard-Regular.woff2is excluded by!**/*.woff2src/app/fonts/Pretendard-SemiBold.woff2is excluded by!**/*.woff2
📒 Files selected for processing (34)
.gitignoree2e/calendar-integration.spec.tse2e/host-flow.spec.tse2e/mocks/sample.icse2e/participant-flow.spec.tsplaywright.config.tsscripts/seed_availability.jssrc/app/(auth)/forgot-password/page.tsxsrc/app/(auth)/login/page.tsxsrc/app/(auth)/reset-password/complete/page.tsxsrc/app/(auth)/signup/additional-info/page.tsxsrc/app/(auth)/signup/page.tsxsrc/app/api/auth/forgot-password/route.tssrc/app/api/auth/google/callback/route.tssrc/app/api/auth/kakao/callback/route.tssrc/app/api/auth/login/route.tssrc/app/api/auth/naver/callback/route.tssrc/app/api/auth/reset-password/complete/route.tssrc/app/api/calendar/status/route.tssrc/app/api/google/disconnect/route.tssrc/app/api/icloud/disconnect/route.tssrc/app/api/schedules/[id]/route.tssrc/app/api/schedules/route.tssrc/app/calendar/connect/page.tsxsrc/app/globals.csssrc/app/layout.tsxsrc/app/page.tsxsrc/app/schedule/[id]/ScheduleRoomClient.tsxsrc/app/schedule/create/CreateScheduleClient.tsxsrc/components/moim/TermsModal.tsxsrc/components/moim/auth-social.tsxsrc/components/moim/reference-ui.tsxsrc/middleware.tstailwind.config.ts
| let calendarStatusMock = { | ||
| googleConnected: false, | ||
| googleEmail: "", | ||
| icloudConnected: false, | ||
| icloudAppleId: "", | ||
| }; | ||
|
|
||
| await page.route("**/api/calendar/status", async (route) => { | ||
| await route.fulfill({ | ||
| status: 200, | ||
| contentType: "application/json", | ||
| body: JSON.stringify(calendarStatusMock), | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Mock 상태를 테스트 함수 스코프 내 let으로 선언하면 route handler 클로저가 stale state를 참조할 위험이 있습니다.
왜: Line 21에서 route.fulfill의 body가 현재 calendarStatusMock 값을 JSON.stringify하는데, 이후 Line 90, 129에서 재할당이 발생합니다. 클로저가 초기 값을 캡처하면 업데이트된 상태가 반영되지 않을 수 있습니다.
어떻게: Mock 상태를 함수 호출 시점에 동적으로 계산하거나, 각 route mocking을 테스트 단계마다 새로 등록하세요.
🔧 제안 수정
- let calendarStatusMock = { ... };
-
- await page.route("**/api/calendar/status", async (route) => {
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify(calendarStatusMock),
- });
- });
+ // 초기 상태 등록
+ await page.route("**/api/calendar/status", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ googleConnected: false,
+ googleEmail: "",
+ icloudConnected: false,
+ icloudAppleId: "",
+ }),
+ });
+ });
...
// Line 88 이전에 다시 route 등록
+ await page.unroute("**/api/calendar/status");
+ await page.route("**/api/calendar/status", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ googleConnected: false,
+ googleEmail: "",
+ icloudConnected: true,
+ icloudAppleId: "testuser@icloud.com",
+ }),
+ });
+ });각 단계에서 unroute → route로 갱신하면 클로저 캡처 문제를 회피할 수 있습니다.
🤖 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 `@e2e/calendar-integration.spec.ts` around lines 14 - 27, The route handler
captures a stale calendarStatusMock value because page.route's fulfillment uses
JSON.stringify(calendarStatusMock) at registration time; update the test to
compute the response at request time or re-register the route before each test
step so the handler reads the latest state. Concretely, modify the page.route
handler (the async route => { await route.fulfill({... body: ...}) }) to
serialize calendarStatusMock inside the handler when invoked, or call
page.unroute("**/api/calendar/status") and then page.route with the new
calendarStatusMock whenever you reassign calendarStatusMock so
calendarStatusMock, page.route, and route.fulfill always use the current value.
|
|
||
| // 2. 회원가입 진행 | ||
| await page.goto("/signup"); | ||
| await page.waitForTimeout(2000); |
There was a problem hiding this comment.
고정 대기 시간을 조건부 대기로 교체하세요.
왜: waitForTimeout(2000) 같은 고정 대기는 CI 환경/브라우저 속도에 따라 테스트가 간헐적으로 실패하거나 불필요하게 느려집니다.
어떻게: 특정 요소나 상태가 준비될 때까지 waitFor를 사용해 조건부로 대기하세요.
♻️ 권장 수정
- await page.goto("/signup");
- await page.waitForTimeout(2000);
-
const emailInput = page.locator("`#email`");
await emailInput.waitFor({ state: "visible", timeout: 10000 });Line 59, 60, 64, 78에도 동일하게 적용하세요. 렌더링이 완료되기를 기다리는 목적이라면 특정 요소의 가시성을 기준으로 대기하는 것이 더 안정적입니다.
🤖 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 `@e2e/calendar-integration.spec.ts` at line 31, Replace the fixed sleeps that
call page.waitForTimeout(2000) with conditional waits that wait for a specific
element or state instead (e.g., use page.waitForSelector(...) or
locator.waitFor({ state: 'visible' }) or page.waitForFunction(...) targeting the
UI element that indicates rendering is complete); update the occurrences of
page.waitForTimeout in this spec (the shown call and the other occurrences at
the same pattern) to wait on the relevant selector/locator visibility or a
function that returns true so the test is stable and faster.
| // 컴파일 및 핫 리로드 속도를 고려하여 타임아웃 연장 | ||
| test.slow(); | ||
|
|
||
| await page.getByLabel("모임 제목").fill("제품 인터뷰"); | ||
| const testEmail = `test_${Date.now()}@example.com`; | ||
| const testPhone = `010-${Math.floor(1000 + Math.random() * 9000)}-${Math.floor(1000 + Math.random() * 9000)}`; | ||
|
|
||
| // 1. 회원가입 진행 | ||
| await page.goto("/signup"); | ||
|
|
||
| // React Hydration 안정화를 위해 페이지 로드 후 대기 | ||
| await page.waitForTimeout(2000); | ||
|
|
||
| const emailInput = page.locator("#email"); | ||
| await emailInput.waitFor({ state: "visible", timeout: 10000 }); | ||
| await emailInput.fill(testEmail); | ||
| await expect(emailInput).toHaveValue(testEmail); | ||
|
|
||
| const phoneInput = page.locator("#phoneNumber"); | ||
| await phoneInput.fill(testPhone); | ||
| await expect(phoneInput).toHaveValue(testPhone); | ||
|
|
||
| const nicknameInput = page.locator("#nickname"); | ||
| await nicknameInput.fill(`host_${Date.now().toString().slice(-6)}`); | ||
|
|
||
| const pwInput = page.locator('input[type="password"]').first(); | ||
| await pwInput.fill("Test1234!"); | ||
| await expect(pwInput).toHaveValue("Test1234!"); | ||
|
|
||
| const pwConfirmInput = page.locator('input[type="password"]').last(); | ||
| await pwConfirmInput.fill("Test1234!"); | ||
| await expect(pwConfirmInput).toHaveValue("Test1234!"); | ||
|
|
||
| await page.getByRole("checkbox", { name: /만 14세 이상입니다/ }).check(); | ||
| await page.getByRole("checkbox", { name: /이용약관/ }).check(); | ||
| await page.getByRole("checkbox", { name: /개인정보수집/ }).check(); | ||
| await page.getByRole("button", { name: "회원가입" }).click(); | ||
| await expect(page.getByText("회원가입 완료")).toBeVisible({ timeout: 20000 }); | ||
|
|
||
| // 리다이렉션 인터럽트 및 클라이언트 상태 안정을 위한 충분한 대기 | ||
| await page.waitForTimeout(2000); | ||
|
|
||
| // Webkit 히스토리/라우터 충돌 방지를 위한 컨텍스트 초기화 네비게이션 | ||
| await page.goto("about:blank"); | ||
|
|
||
| // 2. 로그인 진행 | ||
| await page.goto("/login"); | ||
|
|
||
| // 로그인 페이지 Hydration 안정 대기 | ||
| await page.waitForTimeout(2000); | ||
|
|
||
| const loginEmailInput = page.locator("#loginId"); | ||
| await loginEmailInput.waitFor({ state: "visible", timeout: 10000 }); | ||
| await loginEmailInput.fill(testEmail); | ||
| await expect(loginEmailInput).toHaveValue(testEmail); | ||
|
|
||
| const loginPwInput = page.locator("#password"); | ||
| await loginPwInput.fill("Test1234!"); | ||
| await expect(loginPwInput).toHaveValue("Test1234!"); | ||
|
|
||
| await page.getByRole("button", { name: "로그인" }).click(); | ||
|
|
||
| // 3. 스케줄 생성 | ||
| await page.waitForURL("**/schedule/create", { timeout: 60000 }); | ||
|
|
||
| // 페이지 컴파일 및 Hydration 안정을 위해 대기 | ||
| await page.waitForTimeout(3000); |
There was a problem hiding this comment.
하드코딩된 waitForTimeout을 제거하고 Playwright 자동 대기 패턴으로 전환하세요.
Why: test.slow()와 6회의 고정 대기 시간(2~3초)을 결합하면 테스트가 과도하게 느려지고(총 12초+ 인위적 지연), CI 환경 차이에 따라 여전히 실패할 수 있습니다. Playwright는 요소 상태/네트워크/라우팅을 자동으로 대기하므로 고정 시간 대기는 anti-pattern입니다.
How:
♻️ 자동 대기 패턴으로 전환
- test.slow();
-
const testEmail = `test_${Date.now()}`@example.com``;
const testPhone = `010-${Math.floor(1000 + Math.random() * 9000)}-${Math.floor(1000 + Math.random() * 9000)}`;
await page.goto("/signup");
- // React Hydration 안정화를 위해 페이지 로드 후 대기
- await page.waitForTimeout(2000);
+ // 첫 입력 필드가 상호작용 가능해질 때까지 대기 (Playwright 자동 재시도)
const emailInput = page.locator("`#email`");
- await emailInput.waitFor({ state: "visible", timeout: 10000 });
+ // waitFor는 기본적으로 attached + visible이므로 생략 가능, fill이 자동 대기
await emailInput.fill(testEmail);
- await expect(emailInput).toHaveValue(testEmail);
+ // fill 직후 assertion은 불필요 (fill이 실패하면 이미 throw)
// ... 나머지 필드 동일 패턴
await page.getByRole("button", { name: "회원가입" }).click();
await expect(page.getByText("회원가입 완료")).toBeVisible({ timeout: 20000 });
- // 리다이렉션 인터럽트 및 클라이언트 상태 안정을 위한 충분한 대기
- await page.waitForTimeout(2000);
-
- // Webkit 히스토리/라우터 충돌 방지를 위한 컨텍스트 초기화 네비게이션
- await page.goto("about:blank");
+ // URL 변경을 명시적으로 대기 (리다이렉트가 있다면)
+ // 없다면 바로 다음 페이지로 이동
await page.goto("/login");
- await page.waitForTimeout(2000);
const loginEmailInput = page.locator("`#loginId`");
- await loginEmailInput.waitFor({ state: "visible", timeout: 10000 });
await loginEmailInput.fill(testEmail);
- await expect(loginEmailInput).toHaveValue(testEmail);
const loginPwInput = page.locator("`#password`");
await loginPwInput.fill("Test1234!");
- await expect(loginPwInput).toHaveValue("Test1234!");
await page.getByRole("button", { name: "로그인" }).click();
await page.waitForURL("**/schedule/create", { timeout: 60000 });
- // 페이지 컴파일 및 Hydration 안정을 위해 대기
- await page.waitForTimeout(3000);
+ // 실제 상호작용할 요소가 준비될 때까지 대기
const titleInput = page.getByLabel("모임 제목");
- await titleInput.waitFor({ state: "visible", timeout: 15000 });
await titleInput.fill("제품 인터뷰");추가 권장사항:
about:blank네비게이션이 정말 필요하다면 앱 코드의 라우터/히스토리 문제를 근본적으로 수정하세요.- 타임아웃 증가가 필요하다면
playwright.config.ts에서 전역 설정하고, 개별 테스트는 기본값 사용하세요.
🤖 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 `@e2e/host-flow.spec.ts` around lines 6 - 71, Remove the hardcoded
page.waitForTimeout calls and test.slow() usage and replace them with
Playwright's automatic waiting patterns: use locator.waitFor /
expect(locator).toBeVisible / page.waitForURL / page.waitForLoadState /
page.waitForNavigation where appropriate (e.g., replace the initial 2s hydration
waits before interacting with elements by awaiting
emailInput.waitFor/expect(emailInput).toBeVisible and similarly for
loginEmailInput and after navigation use page.waitForURL("**/schedule/create")
or page.waitForLoadState("networkidle") instead of the 2–3s sleeps); also remove
the about:blank navigation unless a specific router reset is required and if you
must reset history, prefer page.reload() or a targeted navigation and await its
load state. Ensure all interactions reference the existing locators (emailInput,
phoneInput, nicknameInput, pwInput, pwConfirmInput, loginEmailInput,
loginPwInput) and await their visible/enabled states before fill/click.
| // 리렌더링 및 브라우저 이벤트 큐가 완전히 비워지도록 충분한 대기 | ||
| await page.waitForTimeout(2000); |
There was a problem hiding this comment.
고정 대기 시간을 제거하고 명시적 조건 대기로 교체하세요.
왜: waitForTimeout(2000)는 리렌더링이 2초 이내에 완료된다는 가정이며, CI 부하 상황에서는 실패하거나 불필요하게 느려질 수 있습니다.
어떻게: 제출 후 표시되는 특정 요소(예: 성공 메시지)나 상태 변화를 waitFor로 대기하세요.
♻️ 권장 수정
await expect(page.getByText("가능 시간이 제출됐습니다")).toBeVisible({
timeout: 15000,
});
- // 리렌더링 및 브라우저 이벤트 큐가 완전히 비워지도록 충분한 대기
- await page.waitForTimeout(2000);성공 메시지가 이미 toBeVisible로 검증되었으므로, 이후 page.goto가 새 페이지를 로드하기 때문에 추가 대기가 불필요합니다. 만약 같은 페이지 내에서 상태 업데이트를 기다려야 한다면, 변경될 요소를 명시적으로 검증하세요.
🤖 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 `@e2e/participant-flow.spec.ts` around lines 33 - 34, Replace the fixed 2s
sleep (page.waitForTimeout(2000)) with an explicit wait for the UI change that
indicates the submit finished: use Playwright's expect/waitForSelector (e.g.,
wait for the success message element you already assert with toBeVisible, or
wait for navigation via page.waitForNavigation if you call page.goto) so the
test waits for the specific element/state change instead of an arbitrary
timeout.
| // Next.js 프로덕션 서버를 자동으로 띄워서 테스트 | ||
| webServer: { | ||
| command: "npm run dev", | ||
| url: "http://localhost:3000", | ||
| command: `npm run start -- --port ${e2ePort}`, |
There was a problem hiding this comment.
npm run start는 사전 빌드가 필요합니다. CI/로컬에서 빌드 누락 시 실패합니다.
왜: npm run start는 .next/ 디렉터리의 빌드 결과물을 요구하는데, webServer.command에서 빌드를 수행하지 않으므로 사전에 npm run build가 실행되어야 합니다.
어떻게: (1) package.json에 test:e2e 스크립트를 npm run build && npx playwright test 형태로 정의하거나, (2) webServer.command를 npm run build && npm run start -- --port ...로 변경하세요. 단, 매 테스트마다 빌드는 느리므로 (1)을 권장합니다.
🔧 권장 수정
package.json:
"scripts": {
...
- "test:e2e": "playwright test"
+ "test:e2e": "npm run build && playwright test"
}또는 개발 속도를 위해 다시 npm run dev를 사용하고, CI에서만 production 테스트를 수행하도록 분리할 수 있습니다:
webServer: {
- command: `npm run start -- --port ${e2ePort}`,
+ command: process.env.CI ? `npm run start -- --port ${e2ePort}` : `npm run dev -- --port ${e2ePort}`,🤖 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 `@playwright.config.ts` around lines 43 - 45, The Playwright webServer.command
currently runs `npm run start` which requires a prior Next.js build; update the
setup so tests never fail due to a missing build: either add or update a
package.json script (e.g., "test:e2e") to run `npm run build && npx playwright
test` and use that for CI/local e2e runs, or change the webServer.command to
include the build step by running `npm run build && npm run start -- --port
${e2ePort}`; locate the webServer.command and e2ePort usage in
playwright.config.ts and/or add/update the "test:e2e" script in package.json
accordingly.
| <svg | ||
| className={className} | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| viewBox="0 0 24 24" | ||
| > | ||
| <path | ||
| fill="#4285F4" | ||
| d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" | ||
| /> | ||
| <path | ||
| fill="#34A853" | ||
| d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" | ||
| /> | ||
| <path | ||
| fill="#FBBC05" | ||
| d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z" | ||
| /> | ||
| <path | ||
| fill="#EA4335" | ||
| d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z" | ||
| /> | ||
| </svg> | ||
| ); | ||
| case "apple": | ||
| return ( | ||
| <svg | ||
| className={className} | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| viewBox="0 0 24 24" | ||
| fill="currentColor" | ||
| > | ||
| <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M15.97 4.17c.66-.81 1.11-1.93.99-3.06-1 .04-2.22.67-2.94 1.52-.63.73-1.18 1.87-1.03 2.97 1.12.09 2.28-.58 2.98-1.43z" /> | ||
| </svg> | ||
| ); | ||
| case "kakao": | ||
| return ( | ||
| <svg | ||
| className={className} | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| viewBox="0 0 24 24" | ||
| fill="currentColor" | ||
| > | ||
| <path d="M12 3c-4.97 0-9 3.185-9 7.115 0 2.557 1.707 4.8 4.27 6.054-.277.96-.997 3.456-1.042 3.636-.06.24.08.24.17.18.07-.05 1.124-.763 3.12-2.112.805.112 1.637.172 2.482.172 4.97 0 9-3.185 9-7.115S16.97 3 12 3z" /> | ||
| </svg> | ||
| ); | ||
| case "naver": | ||
| return ( | ||
| <svg | ||
| className={className} | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| viewBox="0 0 24 24" | ||
| fill="currentColor" | ||
| > | ||
| <path d="M16.273 19.167L7.697 6.782V19.17H3V4.83h4.727L16.303 17.21V4.83H21v14.337z" /> | ||
| </svg> | ||
| ); |
There was a problem hiding this comment.
모든 SVG에 aria-hidden="true"를 추가하세요.
Why: 이 컴포넌트는 버튼 내부에서 사용되며 부모 버튼이 이미 "구글로 시작하기" 같은 텍스트를 제공합니다. SVG path는 장식용이므로 스크린 리더에 노출되어서는 안 됩니다. 과거 리뷰에서 이미 지적되어 "✅ Addressed in commit 9a9cf22"로 표시되었으나, 현재 코드에는 반영되지 않았습니다(코드 병합 중 손실 추정).
How:
🔒 모든 SVG에 aria-hidden 추가
case "google":
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
+ aria-hidden="true"
>
{/* paths */}
</svg>
);
case "apple":
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
+ aria-hidden="true"
>
{/* path */}
</svg>
);
case "kakao":
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
+ aria-hidden="true"
>
{/* path */}
</svg>
);
case "naver":
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
+ aria-hidden="true"
>
{/* path */}
</svg>
);As per coding guidelines src/components/**: 접근성(a11y) 속성(aria-label, role 등)이 적절한지 확인하세요.
🤖 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/components/moim/auth-social.tsx` around lines 17 - 72, The SVG icons
returned in the social auth switch cases (the <svg> elements used in the
"google", "apple", "kakao", and "naver" branches) are decorative and must
include aria-hidden="true"; update each SVG element inside the component (where
those cases return their JSX) to add aria-hidden="true" so screen readers ignore
these decorative paths while preserving existing className, xmlns, viewBox, and
fill attributes.
Source: Coding guidelines
| <path | ||
| fill="#4285F4" | ||
| d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" | ||
| /> | ||
| <path | ||
| fill="#34A853" | ||
| d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" | ||
| /> | ||
| <path | ||
| fill="#FBBC05" | ||
| d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z" | ||
| /> | ||
| <path | ||
| fill="#EA4335" | ||
| d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z" | ||
| /> |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff
Google SVG 색상 하드코딩은 다크 모드에서 일관성이 깨질 수 있습니다.
Why: Apple/Kakao/Naver는 fill="currentColor"를 사용해 부모 버튼의 텍스트 색상을 따르지만, Google만 고정 색상(#4285F4 등)을 사용합니다. 프로젝트가 향후 다크 모드를 지원한다면 Google 로고만 밝은 색상으로 남아 어색할 수 있습니다.
How:
♻️ 선택지 검토
Option 1: Google 브랜드 가이드라인 준수 (현재 방식 유지)
Google의 공식 브랜드 가이드라인은 4색 로고를 권장하므로, 현재 방식이 브랜드 일관성 측면에서는 올바릅니다. 다크 모드 지원 시 별도 SVG를 제공하거나 필터를 적용하는 방식으로 해결할 수 있습니다.
Option 2: currentColor 통일 (일관성 우선)
<path
- fill="`#4285F4`"
+ fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>하지만 이 경우 Google 로고의 시각적 정체성이 손실됩니다.
현재 프로젝트가 다크 모드를 지원하지 않는다면 이슈가 아닙니다. 향후 고려사항으로 남겨 두세요.
🤖 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/components/moim/auth-social.tsx` around lines 22 - 37, The Google SVG in
auth-social.tsx hardcodes brand colors which breaks dark-mode consistency;
locate the four <path> elements whose d attributes start with "M22.56 12.25",
"M12 23c2.97", "M5.84 14.09" and "M12 5.38" and change their fill attributes
from the hex colors (`#4285F4`, `#34A853`, `#FBBC05`, `#EA4335`) to fill="currentColor"
so the Google icon inherits the parent button/text color (matching
Apple/Kakao/Naver behavior).
| useEffect(() => { | ||
| // 모달 오픈 시 배경 스크롤 방지 | ||
| document.body.style.overflow = "hidden"; | ||
| return () => { | ||
| document.body.style.overflow = ""; | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
ESC 키로 모달을 닫을 수 없고, 포커스 트랩이 없어서 키보드 사용성이 떨어집니다.
Why: 모달 UX 표준에 따르면 ESC 키로 닫을 수 있어야 하고, 모달이 열렸을 때 포커스가 모달 내부에 갇혀야 합니다(Tab 키로 배경 요소에 접근 방지). 현재는 오버레이 클릭만 구현되어 있습니다.
How: useEffect에서 keydown 이벤트 리스너를 추가하고, 포커스 트랩 라이브러리(예: focus-trap-react)를 사용하거나 수동으로 구현하세요.
⌨️ 제안 수정 (ESC 키 닫기)
useEffect(() => {
// 모달 오픈 시 배경 스크롤 방지
document.body.style.overflow = "hidden";
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ onClose();
+ }
+ }
+
+ document.addEventListener("keydown", handleKeyDown);
+
return () => {
document.body.style.overflow = "";
+ document.removeEventListener("keydown", handleKeyDown);
};
- }, []);
+ }, [onClose]);포커스 트랩은 focus-trap-react 라이브러리 사용을 권장합니다:
npm install focus-trap-react⌨️ 제안 수정 (포커스 트랩 추가)
+import FocusTrap from "focus-trap-react";
export function TermsModal({ termsKey, onClose }: TermsModalProps) {
// ... (기존 코드)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-6 backdrop-blur-sm">
<div className="absolute inset-0" onClick={onClose} />
+ <FocusTrap>
<div
role="dialog"
aria-labelledby="terms-modal-title"
aria-modal="true"
className="relative z-10 w-full max-w-[500px] rounded-3xl bg-white p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200"
>
{/* ... (모달 내용) */}
</div>
+ </FocusTrap>
</div>
);
}🤖 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/components/moim/TermsModal.tsx` around lines 81 - 87, The modal currently
only disables background scroll in TermsModal but lacks ESC-to-close and focus
trapping; update the useEffect in TermsModal to add a keydown listener that
calls the modal close handler (e.g., the existing close callback passed into
TermsModal) when event.key === "Escape" and clean it up on unmount, and wrap the
modal content with a focus trap (prefer using the focus-trap-react <FocusTrap>
around the modal body) so keyboard focus is contained while open; ensure the
escape handler and focus-trap are wired to the same close function and all event
listeners are removed in the effect cleanup.
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-6 backdrop-blur-sm"> | ||
| {/* 아웃사이드 클릭 시 닫기 */} | ||
| <div className="absolute inset-0" onClick={onClose} /> | ||
|
|
||
| <div className="relative z-10 w-full max-w-[500px] rounded-3xl bg-white p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200"> | ||
| <div className="flex items-center justify-between border-b border-[#eee8f4] pb-4"> | ||
| <h2 className="text-xl font-extrabold text-[#222026]"> | ||
| {data.title} | ||
| </h2> | ||
| <button | ||
| type="button" | ||
| onClick={onClose} | ||
| className="rounded-lg p-1 text-[#aaa5ad] hover:bg-[#fbf7ff] hover:text-[#6252ac]" | ||
| > | ||
| <X className="h-6 w-6" /> | ||
| </button> | ||
| </div> | ||
|
|
||
| <div className="mt-4 max-h-[300px] overflow-y-auto whitespace-pre-wrap pr-2 text-base leading-7 text-[#504b55]"> | ||
| {data.content} | ||
| </div> | ||
|
|
||
| <div className="mt-6"> | ||
| <button | ||
| type="button" | ||
| onClick={onClose} | ||
| className="h-12 w-full rounded-xl bg-[#8f7bd6] font-bold text-white shadow-[0_4px_12px_rgba(98,82,172,0.15)] hover:bg-[#7d68c9]" | ||
| > | ||
| 확인 | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
모달에 접근성 속성(role, aria-label, aria-labelledby)이 누락되었습니다.
Why: 스크린 리더 사용자가 이 요소를 모달 대화상자로 인식하려면 role="dialog"와 aria-labelledby (또는 aria-label)가 필요합니다. 현재는 시각적으로만 모달처럼 보이지만, 보조 기술에는 일반 div로 인식됩니다.
How: 접근성 속성을 추가하세요.
♿ 제안 수정
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-6 backdrop-blur-sm">
{/* 아웃사이드 클릭 시 닫기 */}
<div className="absolute inset-0" onClick={onClose} />
<div
+ role="dialog"
+ aria-labelledby="terms-modal-title"
+ aria-modal="true"
className="relative z-10 w-full max-w-[500px] rounded-3xl bg-white p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between border-b border-[`#eee8f4`] pb-4">
- <h2 className="text-xl font-extrabold text-[`#222026`]">
+ <h2 id="terms-modal-title" className="text-xl font-extrabold text-[`#222026`]">
{data.title}
</h2>
<button
type="button"
onClick={onClose}
+ aria-label="약관 모달 닫기"
className="rounded-lg p-1 text-[`#aaa5ad`] hover:bg-[`#fbf7ff`] hover:text-[`#6252ac`]"
>
<X className="h-6 w-6" />
</button>
</div>🤖 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/components/moim/TermsModal.tsx` around lines 92 - 124, The modal wrapper
in the TermsModal component lacks accessibility attributes; update the outer
modal container (the topmost div returned by TermsModal) to include
role="dialog" and aria-modal="true", and provide either aria-labelledby that
points to the h2 title (give the h2 a stable id, e.g., id="terms-title") or an
explicit aria-label using data.title; also ensure the close button
(onClick={onClose}, the X button) has an accessible label (e.g.,
aria-label="Close terms") so screen readers can announce it.
| bounceOnce: { | ||
| "0%, 100%": { transform: "scale(1)" }, | ||
| "50%": { transform: "scale(1.05)" }, | ||
| }, | ||
| }, | ||
| animation: { | ||
| fadeIn: "fadeIn 0.25s ease-out forwards", | ||
| scaleIn: "scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards", | ||
| bounceOnce: "bounceOnce 0.4s ease-out 1", | ||
| }, |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | 💤 Low value
animate-bounceOnce와 animate-bounce의 사용 용도를 명확히 구분하세요.
Why: tailwind.config.ts에서 bounceOnce 애니메이션을 정의했지만, src/app/(auth)/login/page.tsx의 "최근 사용" 배지는 여전히 animate-bounce(Tailwind 기본, 무한 반복)를 사용합니다. bounceOnce는 1회만 실행되므로 초기 어텐션용이고, bounce는 지속적 강조용입니다.
How:
♻️ 일관된 애니메이션 선택
Option 1: 배지가 처음 나타날 때만 튀게 하려면 bounceOnce 사용
<span
aria-hidden="true"
- className="absolute -top-2 -right-2 flex h-5 items-center rounded-full bg-[`#7048e8`] px-2.5 text-xs font-bold text-white shadow-sm animate-bounce"
+ className="absolute -top-2 -right-2 flex h-5 items-center rounded-full bg-[`#7048e8`] px-2.5 text-xs font-bold text-white shadow-sm animate-bounceOnce"
>
최근 사용
</span>Option 2: 배지가 계속 튀게 하려면 현재 방식 유지
animate-bounce를 유지하되, bounceOnce가 사용되지 않는다면 설정에서 제거하세요.
UX 관점에서는 bounceOnce가 덜 산만하므로 권장합니다.
🤖 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 `@tailwind.config.ts` around lines 103 - 112, The custom animation bounceOnce
is defined but the "recent" badge still uses Tailwind's infinite animate-bounce;
either switch the badge's class from animate-bounce to animate-bounceOnce so it
only animates once (update the component that renders the "최근 사용" badge to use
"animate-bounceOnce"), or if you intend to keep the infinite behavior remove the
unused bounceOnce entry from the animation config (the animation and keyframe
entries named bounceOnce) to avoid dead config; pick one option and apply the
corresponding change consistently.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
scripts/ensure-sqlite-schema.mjs (1)
9-13:⚠️ Potential issue | 🟠 Major
scripts/ensure-sqlite-schema.mjs의file:./dev.db기본값 주입은provider="postgresql"과 불일치로 초기 실패 리스크가 큼Why:
prisma/schema.prisma의datasource db.provider는postgresql인데,scripts/ensure-sqlite-schema.mjs는NODE_ENV !== "production"일 때DATABASE_URL이 비어있으면file:./dev.db로 채운 뒤(9-11) SQLite 전용 SQL(PRAGMA,DATETIME,ALTER TABLE ...)을new PrismaClient()로 실행합니다. 이건 스킴-엔진 불일치로PrismaClient초기화/쿼리 단계에서 바로 깨질 수 있습니다.How:
ensure-sqlite-schema.mjs에서file:./dev.db기본값 주입을 제거하고,DATABASE_URL이postgres:///postgresql://이 아니면 즉시 실패(또는 스킵)하도록 최소 수정하세요(라인9-11교체).최소 수정 예시
const dbUrl = process.env.DATABASE_URL || ""; if (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://")) { console.log("Using PostgreSQL database. Skipping SQLite schema auto-creation."); process.exit(0); } // postgresql provider인데 sqlite(file:...) 기본값을 주입하지 않음 throw new Error( `DATABASE_URL이 postgresql URL이어야 합니다. (현재: ${dbUrl || "빈 값"})`, );추가로, 같은 “sqlite 기본값”은
scripts/with-database-url.mjs:5(unconditional)와src/lib/prisma.ts:4(NODE_ENV !== "production"조건)에서도 발생하므로, 테스트/런타임에서 동일 문제가 재유입됩니다. 이 두 지점도 동일 규칙(포스트그레스 URL 요구)으로 정렬해야 합니다.🤖 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 `@scripts/ensure-sqlite-schema.mjs` around lines 9 - 13, Remove the unconditional SQLite default injection (process.env.DATABASE_URL ||= "file:./dev.db") and instead validate DATABASE_URL before initializing PrismaClient: read process.env.DATABASE_URL into a local dbUrl, if it does not startWith "postgres://" or "postgresql://" then either exit with a clear log saying "Using PostgreSQL database. Skipping SQLite schema auto-creation." or throw a descriptive Error (so PrismaClient() is never constructed against a mismatched provider); apply the same validation rule to the other locations that inject a SQLite default (the scripts/with-database-url.mjs unconditional assignment and the src/lib/prisma.ts NODE_ENV block) so all three places require a PostgreSQL URL rather than silently falling back to file:./dev.db.
🤖 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.
Outside diff comments:
In `@scripts/ensure-sqlite-schema.mjs`:
- Around line 9-13: Remove the unconditional SQLite default injection
(process.env.DATABASE_URL ||= "file:./dev.db") and instead validate DATABASE_URL
before initializing PrismaClient: read process.env.DATABASE_URL into a local
dbUrl, if it does not startWith "postgres://" or "postgresql://" then either
exit with a clear log saying "Using PostgreSQL database. Skipping SQLite schema
auto-creation." or throw a descriptive Error (so PrismaClient() is never
constructed against a mismatched provider); apply the same validation rule to
the other locations that inject a SQLite default (the
scripts/with-database-url.mjs unconditional assignment and the src/lib/prisma.ts
NODE_ENV block) so all three places require a PostgreSQL URL rather than
silently falling back to file:./dev.db.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 5425390d-6e85-4086-b377-032b6e1c2aca
📒 Files selected for processing (5)
.env.exampleprisma/migrations/20260527132319_merge_auth_and_social/migration.sqlprisma/migrations/migration_lock.tomlprisma/schema.prismascripts/ensure-sqlite-schema.mjs
💤 Files with no reviewable changes (2)
- prisma/migrations/migration_lock.toml
- prisma/migrations/20260527132319_merge_auth_and_social/migration.sql
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 3-4: Remove the hardcoded remote host and credentials from
DATABASE_URL and DIRECT_URL in .env.example and replace them with neutral
placeholders (e.g.
DATABASE_URL="postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
and
DIRECT_URL="postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DIRECT_PORT}/{DB_NAME}");
instruct contributors to put real connection values into their private
.env.local instead. Ensure the variables DATABASE_URL and DIRECT_URL remain
present in .env.example but contain no production-specific hostnames or
passwords so local dev/migrations cannot accidentally target the shared DB.
🪄 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: 5b0ce922-f8e4-45f1-bad7-2c1d18c95ae1
📒 Files selected for processing (2)
.env.exampleprisma/schema.prisma
| DATABASE_URL="postgresql://postgres.ydpihnpnjnqkdcmeuyfw:[YOUR-PASSWORD]@aws-1-ap-northeast-2.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1" | ||
| DIRECT_URL="postgresql://postgres.ydpihnpnjnqkdcmeuyfw:[YOUR-PASSWORD]@aws-1-ap-northeast-2.pooler.supabase.com:5432/postgres" |
There was a problem hiding this comment.
실제 원격 DB 호스트를 .env.example에 고정하면 데이터 오염 사고를 유발할 수 있습니다.
Why: Line 3-4가 특정 원격 호스트를 직접 가리켜, 개발자가 비밀번호만 넣고 실행하면 로컬 작업(마이그레이션/시드)이 공유 DB에 적용될 수 있습니다.
How: .env.example는 중립 placeholder만 남기고, 실제 호스트는 개인 .env.local에서 채우도록 분리하세요.
최소 수정 예시
-DATABASE_URL="postgresql://postgres.ydpihnpnjnqkdcmeuyfw:[YOUR-PASSWORD]`@aws-1-ap-northeast-2.pooler.supabase.com`:6543/postgres?pgbouncer=true&connection_limit=1"
-DIRECT_URL="postgresql://postgres.ydpihnpnjnqkdcmeuyfw:[YOUR-PASSWORD]`@aws-1-ap-northeast-2.pooler.supabase.com`:5432/postgres"
+DATABASE_URL="postgresql://<DB_USER>:<DB_PASSWORD>@<DB_HOST>:6543/<DB_NAME>?pgbouncer=true&connection_limit=1"
+DIRECT_URL="postgresql://<DB_USER>:<DB_PASSWORD>@<DB_HOST>:5432/<DB_NAME>"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| DATABASE_URL="postgresql://postgres.ydpihnpnjnqkdcmeuyfw:[YOUR-PASSWORD]@aws-1-ap-northeast-2.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1" | |
| DIRECT_URL="postgresql://postgres.ydpihnpnjnqkdcmeuyfw:[YOUR-PASSWORD]@aws-1-ap-northeast-2.pooler.supabase.com:5432/postgres" | |
| DATABASE_URL="postgresql://<DB_USER>:<DB_PASSWORD>@<DB_HOST>:6543/<DB_NAME>?pgbouncer=true&connection_limit=1" | |
| DIRECT_URL="postgresql://<DB_USER>:<DB_PASSWORD>@<DB_HOST>:5432/<DB_NAME>" |
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 4-4: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
🤖 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 @.env.example around lines 3 - 4, Remove the hardcoded remote host and
credentials from DATABASE_URL and DIRECT_URL in .env.example and replace them
with neutral placeholders (e.g.
DATABASE_URL="postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
and
DIRECT_URL="postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DIRECT_PORT}/{DB_NAME}");
instruct contributors to put real connection values into their private
.env.local instead. Ensure the variables DATABASE_URL and DIRECT_URL remain
present in .env.example but contain no production-specific hostnames or
passwords so local dev/migrations cannot accidentally target the shared DB.
요약
origin/dev기준 통합 브랜치로 병합했습니다.origin/feature/20-everytime-timetable,origin/feat/create-account는.env,.claude/worktrees, stale docs/중복 구현을 다시 끌고 와 병합 제외 대상으로 분류했습니다.리뷰 대응 추가 반영
id_token서명/audience 검증을 보강했습니다._count기반으로 줄였습니다.검증
git diff --checknpm run lintnpm run test(29 files, 278 tests)npm run buildnpm run test:e2e -- --project=chromium(2 tests)기존 PR 처리 제안