Compare commits

...

2 Commits

Author SHA1 Message Date
ttipo c128b19599 fix: 목록 페이지네이션 응답 계약 불일치 및 업무 목록 쿼리 크래시 수정
페이지네이션 도입 후 백엔드 목록 API 가 { items, total, ... } 를 반환하는데
프론트 멤버·업무 소비처가 배열로 기대해 list.map 이 깨지고, 멤버 탭과 업무
목록이 비어 보이던 회귀를 수정.

- backend(task): 업무 목록 쿼리에서 to-many 조인 컬럼(checklist.sort_order)
  으로 정렬해 페이지네이션 시 TypeORM 이 터지던 문제 제거(task.seq 만 정렬).
  목록 응답은 체크리스트 [완료/전체] 수만 쓰므로 항목 순서가 불필요.
- frontend(member/task): list 컴포저블이 Paginated<T> 를 반환하도록 맞추고
  스토어에서 .items 로 언래핑(저장소·내업무와 동일 계약).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:23:01 +09:00
ttipo 9b01fbaf2f fix: 미정의 예외 로깅에서 메시지·스택 유실 수정
HttpException 이 아닌 예외를 Logger.error 두 번째 인자(스택 문자열 자리)에
Error 객체째로 넘겨, nest-winston 직렬화 과정에서 message/stack 이 사라지고
`{ stack: [ {} ] }` 로만 찍히던 문제를 수정. 메시지는 message 로, 스택은
문자열로 분리해 실제 원인이 로그에 남도록 한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:22:48 +09:00
6 changed files with 36 additions and 12 deletions
@@ -63,7 +63,15 @@ export class HttpExceptionFilter implements ExceptionFilter {
}
} else {
// 정의되지 않은 예외 → 운영팀이 추적 가능하도록 로그 + SYS_001 응답 유지
this.logger.error('Unhandled Exception', exception as Error);
// NestJS Logger 2번째 인자는 "스택 문자열"이어야 한다. Error 객체를 그대로 넘기면
// nest-winston 이 비열거 속성을 직렬화하며 메시지/스택을 잃어 `{ stack: [ {} ] }` 로 찍힌다.
// → 메시지는 message 로, 스택은 문자열로 분리해 실제 원인이 로그에 남도록 한다.
const message =
exception instanceof Error ? exception.message : String(exception);
this.logger.error(
`Unhandled Exception: ${message}`,
exception instanceof Error ? exception.stack : undefined,
);
}
response.status(status).json({
+6 -1
View File
@@ -216,7 +216,12 @@ export class TaskService {
);
}
qb.orderBy('task.seq', 'DESC').addOrderBy('checklist.sort_order', 'ASC');
// 정렬은 메인 엔티티(task.seq) 기준만 사용한다.
// skip/take(페이지네이션)와 to-many 조인을 함께 쓰면 TypeORM 이 DISTINCT id 서브쿼리로
// 페이징하는데, 이때 조인된 to-many 컬럼(checklist.sort_order)으로 ORDER BY 하면
// 컬럼 메타데이터를 찾지 못해 터진다(createOrderByCombinedWithSelectExpression).
// 목록 응답은 체크리스트의 [완료/전체] 수만 쓰므로 항목 순서가 필요 없다.
qb.orderBy('task.seq', 'DESC');
const [tasks, total] = await qb
.skip(pg.skip)
+10 -3
View File
@@ -4,15 +4,22 @@ import type {
InviteMemberPayload,
UpdateMemberPayload,
} from '@/types/member'
import type { Paginated } from '@/types/pagination'
// 저장소 멤버 도메인 API Composable — 인터셉터가 success/data 를 언래핑
// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/members
export function useMember() {
const api = useApi()
// 멤버 목록 (멤버 또는 공개 저장소)
async function list(repoId: string): Promise<ApiRepoMember[]> {
return (await api.get(`/repos/${repoId}/members`)) as unknown as ApiRepoMember[]
// 멤버 목록 (멤버 또는 공개 저장소) — 백엔드가 페이지네이션 { items, total, ... } 반환
async function list(
repoId: string,
page = 1,
size = 20,
): Promise<Paginated<ApiRepoMember>> {
return (await api.get(`/repos/${repoId}/members`, {
params: { page, size },
})) as unknown as Paginated<ApiRepoMember>
}
// 멤버 초대 (admin) — 가입된 사용자만 이메일로 초대
+7 -3
View File
@@ -10,17 +10,21 @@ import type {
UpdateTaskPayload,
} from '@/types/task'
import type { TaskStatus } from '@/mock/relay.mock'
import type { Paginated } from '@/types/pagination'
// 업무 도메인 API Composable — 인터셉터가 success/data 를 언래핑
// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/tasks[/:taskId]
export function useTask() {
const api = useApi()
// 업무 목록 (상태/담당자 필터 선택)
async function list(repoId: string, query: TaskListQuery = {}): Promise<ApiRepoTask[]> {
// 업무 목록 (상태/담당자 필터 선택) — 백엔드가 페이지네이션 { items, total, ... } 반환
async function list(
repoId: string,
query: TaskListQuery = {},
): Promise<Paginated<ApiRepoTask>> {
return (await api.get(`/repos/${repoId}/tasks`, {
params: query,
})) as unknown as ApiRepoTask[]
})) as unknown as Paginated<ApiRepoTask>
}
// 업무 단건 상세 (taskId = 저장소 내 순번)
+2 -2
View File
@@ -45,8 +45,8 @@ export const useMemberStore = defineStore('member', () => {
async function fetchList(repoId: string): Promise<void> {
loading.value = true
try {
const list = await memberApi.list(repoId)
members.value = list.map((m) => toMemberView(m, currentUserId()))
const { items } = await memberApi.list(repoId)
members.value = items.map((m) => toMemberView(m, currentUserId()))
} finally {
loading.value = false
}
+2 -2
View File
@@ -211,8 +211,8 @@ export const useTaskStore = defineStore('task', () => {
async function fetchList(repoId: string, query: TaskListQuery = {}): Promise<void> {
loading.value = true
try {
const list = await taskApi.list(repoId, query)
tasks.value = list.map(toRepoTaskView)
const { items } = await taskApi.list(repoId, query)
tasks.value = items.map(toRepoTaskView)
} finally {
loading.value = false
}