Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@ import { SettingsProfileContainer } from "@/app/[locale]/(main)/settings/_contai
import { useSettingsTab } from "@/app/[locale]/(main)/settings/_hooks/useSettingsTab";
import { SettingsPolicyContainer } from "@/app/[locale]/(main)/settings/policy/_containers/SettingsPolicyContainer";
import { SettingsWithdrawalContainer } from "@/app/[locale]/(main)/settings/withdrawal/_containers/SettingsWithdrawalContainer";
import { AsyncBoundary } from "@/components/boundary/AsyncBoundary";

export const SettingsTabsContainer = () => {
const tab = useSettingsTab();

if (tab === "policy") return <SettingsPolicyContainer />;
if (tab === "withdrawal") return <SettingsWithdrawalContainer />;
return <SettingsProfileContainer />;
return (
<AsyncBoundary>
<SettingsProfileContainer />
</AsyncBoundary>
);
Comment on lines +14 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

AsyncBoundaryerrorFallback가 누락되어 zod 검증 실패 시 페이지가 크래시됩니다.

useSettingsProfileQueryselect에서 settingsProfileResponseSchema.parse(data)를 호출합니다. API 응답이 스키마와 불일치하면 zod가 에러를 throw하는데, 현재 AsyncBoundaryerrorFallback을 전달하지 않아 ErrorBoundary가 래핑되지 않습니다. 결과적으로 처리되지 않은 에러가 발생해 설정 페이지 전체가 크래시됩니다.

🔒️ 제안하는 개선
  return (
-   <AsyncBoundary>
+   <AsyncBoundary
+     pendingFallback={<SettingsProfileSkeleton />}
+     errorFallback={<SettingsProfileError />}
+   >
      <SettingsProfileContainer />
    </AsyncBoundary>
  );

AsyncBoundary 컴포넌트는 errorFallback을 전달받아야 ErrorBoundary로 래핑합니다. 컴포넌트 정의를 참고하세요.

📝 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.

Suggested change
return (
<AsyncBoundary>
<SettingsProfileContainer />
</AsyncBoundary>
);
return (
<AsyncBoundary
pendingFallback={<SettingsProfileSkeleton />}
errorFallback={<SettingsProfileError />}
>
<SettingsProfileContainer />
</AsyncBoundary>
);
🤖 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
`@apps/timo-web/app/`[locale]/(main)/settings/_containers/SettingsTabsContainer.tsx
around lines 14 - 18, Update the AsyncBoundary usage in SettingsTabsContainer to
provide an errorFallback so zod errors thrown by useSettingsProfileQuery’s
select validation are handled by an ErrorBoundary instead of crashing the
settings page. Use the existing application error fallback component or pattern
defined for AsyncBoundary.

};
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,22 @@ import { useForm } from "react-hook-form";

import type {
SettingsDefaultTagKey,
SettingsProfile,
SettingsLanguage,
SettingsProfileFormValues,
} from "@/app/[locale]/(main)/settings/_types/profile-type";

import { useUpdateLanguage } from "@/api/generated/endpoints/user/user";
import { UpdateLanguageRequestLanguage } from "@/api/generated/models";
import { useSettingsLanguageParam } from "@/app/[locale]/(main)/settings/_hooks/useSettingsLanguageParam";
import { settingsProfileMock } from "@/app/[locale]/(main)/settings/_mocks/profile-mock";
import { useSettingsProfileQuery } from "@/app/[locale]/(main)/settings/_queries/use-settings-profile";

const LANGUAGE_REQUEST_MAP: Record<
SettingsLanguage,
(typeof UpdateLanguageRequestLanguage)[keyof typeof UpdateLanguageRequestLanguage]
> = {
ko: UpdateLanguageRequestLanguage.KO,
en: UpdateLanguageRequestLanguage.EN,
};

const DEFAULT_TAG_KEYS: SettingsDefaultTagKey[] = [
"assignment",
Expand All @@ -28,11 +38,16 @@ export const useSettingsProfile = () => {
const { language, locale, setLanguage, commitLanguage } =
useSettingsLanguageParam();

const [profile, setProfile] = useState<SettingsProfile>(settingsProfileMock);
const { data: profile } = useSettingsProfileQuery();
const [isCalendarConnected, setIsCalendarConnected] = useState(
profile.calendarConnected,
);
const { mutateAsync: updateLanguage } = useUpdateLanguage();

// TODO: 태그 목록 조회 API 연동 후 기본 태그 대신 실제 응답으로 교체
const { watch, setValue, handleSubmit, reset, formState } =
useForm<SettingsProfileFormValues>({
defaultValues: { tags: profile.tags },
defaultValues: { tags: DEFAULT_TAG_KEYS },
});
const tags = watch("tags");

Expand All @@ -47,20 +62,20 @@ export const useSettingsProfile = () => {
});

const handleConnectCalendar = () => {
if (profile.isCalendarConnected) {
if (isCalendarConnected) {
// TODO: 실제 확인 모달로 교체
const confirmed = window.confirm("구글 캘린더 연동을 해제하시겠습니까?");
if (!confirmed) return;

// TODO: API - 연동 토큰 파기
console.log("구글 캘린더 연동 토큰을 파기합니다.");
setProfile((prev) => ({ ...prev, isCalendarConnected: false }));
setIsCalendarConnected(false);
return;
}

// TODO: Google 계정 인증 및 캘린더 접근 권한 동의 팝업 호출
console.log("Google Calendar 연동 인증 팝업을 호출합니다.");
setProfile((prev) => ({ ...prev, isCalendarConnected: true }));
setIsCalendarConnected(true);
};

const handleAddTag = () => {
Expand All @@ -86,10 +101,15 @@ export const useSettingsProfile = () => {
console.log("[Login] 페이지로 이동합니다.");
};

const isLanguageDirty = language !== locale;

const handleSave = handleSubmit(async (values) => {
try {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 1000));
if (isLanguageDirty) {
await updateLanguage({
data: { language: LANGUAGE_REQUEST_MAP[language] },
});
}
reset(values);
commitLanguage();
Comment on lines +104 to 114

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

언어 업데이트 후 프로필 쿼리 캐시 무효화가 필요합니다.

updateLanguage 성공 후 reset(values)commitLanguage()는 실행되지만, 프로필 쿼리(getGetMyProfileQueryKey) 캐시가 무효화되지 않습니다. 동일한 엔드포인트(/api/v1/users)를 사용하므로, mutation 성공 후 캐시를 무효화하지 않으면 재조회 시 stale data가 반환될 수 있습니다.

♻️ 제안하는 개선
+import { useQueryClient } from "`@tanstack/react-query`";
+import { getGetMyProfileQueryKey } from "`@/api/generated/endpoints/user/user`";
+
 // ... inside useSettingsProfile
+ const queryClient = useQueryClient();

  const handleSave = handleSubmit(async (values) => {
    try {
      if (isLanguageDirty) {
        await updateLanguage({
          data: { language: LANGUAGE_REQUEST_MAP[language] },
        });
+       await queryClient.invalidateQueries({
+         queryKey: getGetMyProfileQueryKey(),
+       });
      }
      reset(values);
      commitLanguage();
    } catch {

React Query의 invalidateQueries 문서를 참고하세요.

📝 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.

Suggested change
const isLanguageDirty = language !== locale;
const handleSave = handleSubmit(async (values) => {
try {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 1000));
if (isLanguageDirty) {
await updateLanguage({
data: { language: LANGUAGE_REQUEST_MAP[language] },
});
}
reset(values);
commitLanguage();
const queryClient = useQueryClient();
const isLanguageDirty = language !== locale;
const handleSave = handleSubmit(async (values) => {
try {
if (isLanguageDirty) {
await updateLanguage({
data: { language: LANGUAGE_REQUEST_MAP[language] },
});
await queryClient.invalidateQueries({
queryKey: getGetMyProfileQueryKey(),
});
}
reset(values);
commitLanguage();
🤖 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 `@apps/timo-web/app/`[locale]/(main)/settings/_hooks/useSettingsProfile.ts
around lines 104 - 114, Update handleSave in useSettingsProfile so that, after
updateLanguage succeeds, the React Query cache entry identified by
getGetMyProfileQueryKey is invalidated before completing the existing
reset(values) and commitLanguage() flow.

} catch {
Expand All @@ -98,13 +118,11 @@ export const useSettingsProfile = () => {
}
});

const isLanguageDirty = language !== locale;

return {
profileState: {
name: profile.name,
googleEmail: profile.googleEmail,
isCalendarConnected: profile.isCalendarConnected,
googleEmail: profile.email,
isCalendarConnected,
language,
tags: tagItems,
isSaveDisabled:
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"use client";

import { useSuspenseQuery } from "@tanstack/react-query";

import {
getGetMyProfileQueryKey,
getMyProfile,
} from "@/api/generated/endpoints/user/user";
import { settingsProfileResponseSchema } from "@/app/[locale]/(main)/settings/_types/profile-type";

export const useSettingsProfileQuery = () =>
useSuspenseQuery({
queryKey: getGetMyProfileQueryKey(),
queryFn: ({ signal }) => getMyProfile(undefined, signal),
select: ({ data }) => settingsProfileResponseSchema.parse(data),
});
17 changes: 11 additions & 6 deletions apps/timo-web/app/[locale]/(main)/settings/_types/profile-type.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { z } from "zod";

export type SettingsLanguage = "ko" | "en";

export type SettingsDefaultTagKey =
Expand All @@ -6,12 +8,15 @@ export type SettingsDefaultTagKey =
| "exercise"
| "dailyLife";

export interface SettingsProfile {
name: string;
googleEmail: string;
isCalendarConnected: boolean;
tags: string[];
}
export const settingsProfileResponseSchema = z.object({
name: z.string(),
email: z.string(),
calendarConnected: z.boolean(),
});
Comment on lines +11 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

이메일 필드 검증을 z.email()로 강화해 보세요.

Zod 4부터 z.email()이 독립적인 문자열 포맷 스키마로 제공됩니다. 현재 z.string()을 사용 중인데, 이메일 형식 검증을 추가하면 API 응답의 신뢰성을 더욱 높일 수 있습니다.

♻️ 제안하는 개선
 export const settingsProfileResponseSchema = z.object({
   name: z.string(),
-  email: z.string(),
+  email: z.email(),
   calendarConnected: z.boolean(),
 });

Zod 4 문서의 String Enhancements 섹션을 참고하세요.

📝 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.

Suggested change
export const settingsProfileResponseSchema = z.object({
name: z.string(),
email: z.string(),
calendarConnected: z.boolean(),
});
export const settingsProfileResponseSchema = z.object({
name: z.string(),
email: z.email(),
calendarConnected: z.boolean(),
});
🤖 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 `@apps/timo-web/app/`[locale]/(main)/settings/_types/profile-type.ts around
lines 11 - 15, Update the email field in settingsProfileResponseSchema to use
Zod 4’s z.email() format schema instead of z.string(), while leaving the name
and calendarConnected validations unchanged.


export type SettingsProfileResponse = z.infer<
typeof settingsProfileResponseSchema
>;

export interface SettingsProfileFormValues {
tags: string[];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
"use client";

import { useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef } from "react";

import { useToken } from "@/api/generated/endpoints/auth/auth";
import { getGetMyProfileQueryKey } from "@/api/generated/endpoints/user/user";
import { ROUTES } from "@/constants/routes";
import { useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth/useAuthStore";

export const OauthCallbackContainer = () => {
const code = useSearchParams().get("code");
const router = useRouter();
const queryClient = useQueryClient();
const setAccessToken = useAuthStore((state) => state.setAccessToken);
const { mutate } = useToken();
const hasRequested = useRef(false);
Expand All @@ -35,15 +32,14 @@ export const OauthCallbackContainer = () => {
return;
}
setAccessToken(data.accessToken);
queryClient.setQueryData(getGetMyProfileQueryKey(), data.user);
router.replace(data.isNewUser ? ROUTES.ONBOARDING : ROUTES.HOME);
},
onError: () => {
router.replace(ROUTES.LOGIN);
},
},
);
}, [code, mutate, queryClient, router, setAccessToken]);
}, [code, mutate, router, setAccessToken]);

return null;
};
Loading