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
204 changes: 148 additions & 56 deletions src/app/(root)/list/hooks/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';

/** ===== helpers ===== */
/** ===== helpers (프로젝트 proxy route 정책에 맞춤) ===== */
function proxy(path: string) {
const p = path.startsWith('/') ? path.slice(1) : path;
return `/api/proxy/${p}`;
Expand All @@ -15,25 +15,66 @@ async function assertOk(res: Response, message: string) {
}
}

/**
* proxy 라우트가 수정되어 "null/빈문자열"도 반환 가능하므로
* json 파싱은 "텍스트가 있을 때만" 수행해야 안전함.
*/
async function safeReadJson<T>(res: Response): Promise<T> {
if (res.status === 204) return undefined as unknown as T;

const text = await res.text().catch(() => '');
if (!text) return undefined as unknown as T;

try {
return JSON.parse(text) as T;
} catch {
throw new Error(`응답 JSON 파싱 실패: ${text.slice(0, 200)}`);
}
Comment on lines +22 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The safeReadJson function is a good addition for handling potentially empty or non-JSON responses from the proxy route. However, the error message for JSON parsing failure could be more informative. Instead of just 응답 JSON 파싱 실패, it would be helpful to include the status code of the response.

Suggested change
async function safeReadJson<T>(res: Response): Promise<T> {
if (res.status === 204) return undefined as unknown as T;
const text = await res.text().catch(() => '');
if (!text) return undefined as unknown as T;
try {
return JSON.parse(text) as T;
} catch {
throw new Error(`응답 JSON 파싱 실패: ${text.slice(0, 200)}`);
}
async function safeReadJson<T>(res: Response): Promise<T> {
if (res.status === 204) return undefined as unknown as T;
const text = await res.text().catch(() => '');
if (!text) return undefined as unknown as T;
try {
return JSON.parse(text) as T;
} catch {
throw new Error(`응답 JSON 파싱 실패 (status: ${res.status}): ${text.slice(0, 200)}`);
}
}

}

async function fetchJson<T>(path: string, init?: RequestInit, message = '요청 실패'): Promise<T> {
const method = (init?.method ?? 'GET').toUpperCase();
const isBodyless = method === 'GET' || method === 'HEAD' || method === 'DELETE';

const res = await fetch(proxy(path), {
...init,
method,
body: isBodyless ? undefined : init?.body,
credentials: 'include',
headers: {
...(init?.headers ?? {}),
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...(isBodyless ? {} : { 'Content-Type': 'application/json' }),
Accept: 'application/json',
},
});

await assertOk(res, message);
return (await res.json()) as T;
return await safeReadJson<T>(res);
}
Comment on lines 35 to 53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The fetchJson function now correctly handles different HTTP methods and sets appropriate headers. However, the credentials: 'include' option is applied to all requests. While this might be the desired behavior for this application, it's generally good practice to be explicit about when credentials are included, especially for GET requests where they might not always be necessary or could lead to unexpected behavior if not carefully managed. Consider if credentials: 'include' is strictly needed for all fetchJson calls.


async function fetchVoid(path: string, init?: RequestInit, message = '요청 실패'): Promise<void> {
const res = await fetch(proxy(path), init);
const method = (init?.method ?? 'GET').toUpperCase();

const res = await fetch(proxy(path), {
...init,
method,
body: method === 'DELETE' || method === 'GET' || method === 'HEAD' ? undefined : init?.body,
credentials: 'include',
});

await assertOk(res, message);
}
Comment on lines 55 to 66

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Similar to fetchJson, the fetchVoid function also applies credentials: 'include' to all requests. Review if this is necessary for all fetchVoid calls, especially for methods like GET or HEAD where credentials might not be required.


/** ===== types (swagger 기반 최소 필요 필드) ===== */
export type ApiFrequency = 'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY';
export type ApiWeekDay =
| 'MONDAY'
| 'TUESDAY'
| 'WEDNESDAY'
| 'THURSDAY'
| 'FRIDAY'
| 'SATURDAY'
| 'SUNDAY';

export type Group = {
id: number;
Expand Down Expand Up @@ -80,15 +121,36 @@ export type TaskList = {
updatedAt: string;
};

export type TaskWriter = { id: number; nickname: string; image: string | null } | null;

export type Task = {
id: number;
name: string;
description: string | null;
date: string; // ISO
doneAt: string | null;
frequency: ApiFrequency;
description?: string | null;

/** 서버가 date/startDate/startAt/scheduledAt 중 무엇을 주든 UI에서 흡수 가능하게 optional */
date?: string; // ISO
startDate?: string; // ISO
startAt?: string;
scheduledAt?: string;

doneAt?: string | null;

/** 서버가 frequency/frequencyType/repeatType 중 무엇을 주든 UI에서 흡수 가능 */
frequency?: ApiFrequency;
frequencyType?: ApiFrequency;
repeatType?: ApiFrequency;

/** 주/월 반복 부가 */
weekDays?: ApiWeekDay[];
repeatWeekDays?: ApiWeekDay[];
repeatDays?: unknown; // 모달 포맷 들어올 수도 있어 방어

monthDay?: number;
repeatMonthDay?: number;
Comment on lines +129 to +150

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The Task type has been significantly expanded with optional fields for description, date, startDate, startAt, scheduledAt, doneAt, frequency, frequencyType, repeatType, weekDays, repeatWeekDays, repeatDays, monthDay, and repeatMonthDay. While this provides flexibility, having multiple optional fields for similar concepts (e.g., date, startDate, startAt, scheduledAt or frequency, frequencyType, repeatType) can lead to confusion and potential inconsistencies in how data is handled. Consider consolidating these fields or adding clear documentation on their intended usage and precedence.


commentCount: number;
writer?: { id: number; nickname: string; image: string | null } | null;
writer?: TaskWriter;
};

export type TaskListByDateResponse = {
Expand Down Expand Up @@ -125,8 +187,9 @@ export type Comment = {
export function useMe() {
return useQuery({
queryKey: ['me'],
queryFn: () =>
fetchJson<UserResponse>('user', undefined, '유저 정보를 불러오는데 실패했습니다.'),
queryFn: async () => {
return fetchJson<UserResponse>('user', undefined, '유저 정보를 불러오는데 실패했습니다.');
},
staleTime: 30_000,
});
}
Expand All @@ -135,12 +198,13 @@ export function useGroupDetail(groupId: number) {
return useQuery({
queryKey: ['groupDetail', groupId],
enabled: groupId > 0,
queryFn: () =>
fetchJson<GroupDetailResponse>(
queryFn: async () => {
return fetchJson<GroupDetailResponse>(
`groups/${groupId}`,
undefined,
'그룹 정보를 불러오는데 실패했습니다.',
),
);
},
staleTime: 10_000,
});
}
Expand All @@ -155,25 +219,27 @@ export function useTaskListByDate(params: {
return useQuery({
queryKey: ['taskListByDate', groupId, taskListId, dateIso],
enabled: groupId > 0 && taskListId > 0 && !!dateIso,
queryFn: () =>
fetchJson<TaskListByDateResponse>(
queryFn: async () => {
return fetchJson<TaskListByDateResponse>(
`groups/${groupId}/task-lists/${taskListId}?date=${encodeURIComponent(dateIso)}`,
{ cache: 'no-store' },
'할 일 목록을 불러오는데 실패했습니다.',
),
);
},
});
}

export function useTaskComments(taskId: number) {
return useQuery({
queryKey: ['taskComments', taskId],
enabled: taskId > 0,
queryFn: () =>
fetchJson<Comment[]>(
queryFn: async () => {
return fetchJson<Comment[]>(
`tasks/${taskId}/comments`,
undefined,
'댓글을 불러오는데 실패했습니다.',
),
);
},
});
}

Expand All @@ -182,12 +248,13 @@ export function useCreateTaskList() {
const qc = useQueryClient();

return useMutation({
mutationFn: (vars: { groupId: number; name: string }) =>
fetchJson<TaskList>(
mutationFn: async (vars: { groupId: number; name: string }) => {
return fetchJson<TaskList>(
`groups/${vars.groupId}/task-lists`,
{ method: 'POST', body: JSON.stringify({ name: vars.name }) },
'할 일 목록 생성에 실패했습니다.',
),
);
},
onSuccess: async (_, vars) => {
await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] });
},
Expand All @@ -198,15 +265,20 @@ export function useUpdateTaskList() {
const qc = useQueryClient();

return useMutation({
mutationFn: (vars: { groupId: number; taskListId: number; name: string }) =>
fetchJson<TaskList>(
mutationFn: async (vars: { groupId: number; taskListId: number; name: string }) => {
return fetchJson<TaskList>(
`groups/${vars.groupId}/task-lists/${vars.taskListId}`,
{ method: 'PATCH', body: JSON.stringify({ name: vars.name }) },
'할 일 목록 수정에 실패했습니다.',
),
);
},
onSuccess: async (_, vars) => {
await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] });
await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] });
// dateIso까지 포함된 키도 같이 invalidate되도록 exact:false
await qc.invalidateQueries({
queryKey: ['taskListByDate', vars.groupId, vars.taskListId],
exact: false,
});
Comment on lines +277 to +281

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Adding exact: false to invalidateQueries for taskListByDate is a good improvement to ensure all relevant cached data is invalidated when a task list is updated. This prevents stale data from being displayed.

},
});
}
Expand All @@ -215,39 +287,39 @@ export function useDeleteTaskList() {
const qc = useQueryClient();

return useMutation({
mutationFn: (vars: { groupId: number; taskListId: number }) =>
fetchVoid(
mutationFn: async (vars: { groupId: number; taskListId: number }) => {
return fetchVoid(
`groups/${vars.groupId}/task-lists/${vars.taskListId}`,
{ method: 'DELETE' },
'할 일 목록 삭제에 실패했습니다.',
),
);
},
onSuccess: async (_, vars) => {
await qc.invalidateQueries({ queryKey: ['groupDetail', vars.groupId] });
await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] });
await qc.invalidateQueries({
queryKey: ['taskListByDate', vars.groupId, vars.taskListId],
exact: false,
});
Comment on lines +299 to +302

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The invalidateQueries for taskListByDate also benefits from exact: false when deleting a task list, ensuring comprehensive cache invalidation.

},
});
}

/**
* TaskRecurringCreateDto 기반
* - ONCE도 여기로 POST /tasks
* - weekly/monthly면 weekDays/monthDay 전달 가능
*/
/** Task 생성 */
export function useCreateTask() {
const qc = useQueryClient();

return useMutation({
mutationFn: (vars: {
mutationFn: async (vars: {
groupId: number;
taskListId: number;
name: string;
description?: string;
startDate: string; // ISO
frequencyType: ApiFrequency;
weekDays?: string[]; // ['MONDAY'...]
weekDays?: ApiWeekDay[];
monthDay?: number;
}) =>
fetchJson<Task>(
}) => {
Comment on lines +312 to +321

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The mutationFn for useCreateTask is now asynchronous, which is a good practice for API calls. The addition of weekDays and monthDay to the task creation payload correctly supports weekly and monthly recurring tasks.

return fetchJson<Task>(
`groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks`,
{
method: 'POST',
Expand All @@ -261,9 +333,13 @@ export function useCreateTask() {
}),
},
'할 일 생성에 실패했습니다.',
),
);
},
onSuccess: async (_, vars) => {
await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] });
await qc.invalidateQueries({
queryKey: ['taskListByDate', vars.groupId, vars.taskListId],
exact: false,
});
Comment on lines +339 to +342

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Applying exact: false to invalidateQueries for taskListByDate in useCreateTask's onSuccess handler is a good change to ensure that the list is refreshed correctly after a new task is created.

},
});
}
Expand All @@ -272,19 +348,31 @@ export function usePatchTask() {
const qc = useQueryClient();

return useMutation({
mutationFn: (vars: {
mutationFn: async (vars: {
groupId: number;
taskListId: number;
taskId: number;
body: { name?: string; description?: string; date?: string; doneAt?: string | null };
}) =>
fetchJson<Task>(
body: {
name?: string;
description?: string;
startDate?: string; //
frequencyType?: ApiFrequency;
weekDays?: ApiWeekDay[];
monthDay?: number;
doneAt?: string | null;
};
Comment on lines +351 to +363

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The mutationFn for usePatchTask now includes startDate, frequencyType, weekDays, and monthDay in the body type. This allows for comprehensive updates to task recurrence settings. However, the body is cast to unknown as never in page.tsx which bypasses type safety. It would be better to ensure the body type is fully compatible or create a specific type for the patch body.

}) => {
return fetchJson<Task>(
`groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks/${vars.taskId}`,
{ method: 'PATCH', body: JSON.stringify(vars.body) },
'할 일 수정에 실패했습니다.',
),
);
},
onSuccess: async (_, vars) => {
await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] });
await qc.invalidateQueries({
queryKey: ['taskListByDate', vars.groupId, vars.taskListId],
exact: false,
});
Comment on lines +372 to +375

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The invalidateQueries for taskListByDate in usePatchTask also correctly uses exact: false for broader cache invalidation.

await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] });
},
});
Expand All @@ -294,14 +382,18 @@ export function useDeleteTask() {
const qc = useQueryClient();

return useMutation({
mutationFn: (vars: { groupId: number; taskListId: number; taskId: number }) =>
fetchVoid(
mutationFn: async (vars: { groupId: number; taskListId: number; taskId: number }) => {
return fetchVoid(
`groups/${vars.groupId}/task-lists/${vars.taskListId}/tasks/${vars.taskId}`,
{ method: 'DELETE' },
'할 일 삭제에 실패했습니다.',
),
);
},
onSuccess: async (_, vars) => {
await qc.invalidateQueries({ queryKey: ['taskListByDate', vars.groupId, vars.taskListId] });
await qc.invalidateQueries({
queryKey: ['taskListByDate', vars.groupId, vars.taskListId],
exact: false,
});
Comment on lines +393 to +396

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The invalidateQueries for taskListByDate in useDeleteTask correctly uses exact: false to ensure the task list is updated after a task is deleted.

await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] });
},
});
Expand All @@ -311,15 +403,15 @@ export function useCreateTaskComment() {
const qc = useQueryClient();

return useMutation({
mutationFn: (vars: { taskId: number; content: string }) =>
fetchJson<Comment>(
mutationFn: async (vars: { taskId: number; content: string }) => {
return fetchJson<Comment>(
`tasks/${vars.taskId}/comments`,
{ method: 'POST', body: JSON.stringify({ content: vars.content }) },
'댓글 작성에 실패했습니다.',
),
);
},
onSuccess: async (_, vars) => {
await qc.invalidateQueries({ queryKey: ['taskComments', vars.taskId] });
// commentCount가 바뀌니까 리스트도 갱신 필요 (상위에서 invalidate 추가로 해도 됨)
},
});
}
Loading
Loading