diff --git a/apps/api/.claude/api-conventions.md b/apps/api/.claude/api-conventions.md index 02089400..7d02ec75 100644 --- a/apps/api/.claude/api-conventions.md +++ b/apps/api/.claude/api-conventions.md @@ -499,7 +499,7 @@ if (confirmed) { | 플랜 | 일일 제한 | |------|----------| -| FREE | 10회 | +| FREE | 5회 | | PREMIUM | 100회 | --- @@ -569,7 +569,7 @@ async sendCheer(senderId: string, receiverId: string) { { "success": true, "data": { ... }, - "timestamp": "2024-01-15T10:30:00.000Z" + "timestamp": "2026-02-06T10:30:00.000Z" } ``` @@ -584,7 +584,7 @@ async sendCheer(senderId: string, receiverId: string) { "code": "USER_NOT_FOUND", "message": "사용자를 찾을 수 없습니다" }, - "timestamp": "2024-01-15T10:30:00.000Z" + "timestamp": "2026-02-06T10:30:00.000Z" } ``` @@ -602,7 +602,7 @@ async sendCheer(senderId: string, receiverId: string) { "totalPages": 5 } }, - "timestamp": "2024-01-15T10:30:00.000Z" + "timestamp": "2026-02-06T10:30:00.000Z" } ``` @@ -788,3 +788,82 @@ pnpm start:prod - [ ] Repository 단위 테스트 - [ ] Service 단위 테스트 - [ ] E2E 테스트 + +--- + +## 타임존 처리 규칙 + +### 개요 + +서버는 **모든 날짜를 UTC**로 저장하며, 클라이언트가 `X-Timezone` 헤더로 사용자의 타임존을 전달한다. + +| 항목 | 규칙 | +|------|------| +| 저장 | UTC (PostgreSQL TIMESTAMPTZ) | +| 전송 | ISO 8601 UTC (`2026-02-06T10:30:00.000Z`) | +| 날짜 경계 판단 | 클라이언트 `X-Timezone` 헤더 기준 | +| 기본값 | `X-Timezone` 미전송 시 `UTC` | + +### X-Timezone 헤더 + +``` +X-Timezone: Asia/Seoul +``` + +- IANA 타임존 식별자 사용 (예: `Asia/Seoul`, `America/New_York`) +- 날짜 경계 판단이 필요한 API에서만 사용 +- 헤더가 없으면 `UTC`로 fallback + +### @Timezone 데코레이터 + +```typescript +import { Timezone } from '@/common/decorators'; + +@Post() +async create( + @Body() dto: CreateTodoDto, + @Timezone() timezone: string, // X-Timezone 헤더 값 추출 +) { + return this.service.create(dto, timezone); +} +``` + +### Swagger 문서화 + +`@Timezone()`을 사용하는 메서드에는 반드시 `@ApiHeader`를 추가: + +```typescript +import { ApiHeader } from '@nestjs/swagger'; + +@ApiHeader({ + name: 'X-Timezone', + required: false, + description: '사용자 타임존 (IANA, 기본값: UTC)', + example: 'Asia/Seoul', +}) +@Post() +async create(@Timezone() timezone: string) { ... } +``` + +### 타임존이 필요한 API + +| 모듈 | 엔드포인트 | 용도 | +|------|-----------|------| +| Todo | `POST /todos`, `PATCH /todos/:id`, `PATCH /todos/:id/complete`, `PATCH /todos/:id/schedule` | 날짜 경계 판단, 스케줄 시간 변환 | +| Cheer | `POST /cheers`, `GET /cheers/limit` | 일일 제한 리셋 기준 | +| Nudge | `POST /nudges`, `GET /nudges/limit` | 일일 제한 리셋 기준 | + +### 날짜 유틸리티 (`@common/date`) + +```typescript +import { getUserToday, toScheduledTime, startOfDayInTimezone } from '@common/date'; + +// 사용자의 "오늘" 시작 시각 (UTC) +const today = getUserToday(timezone); + +// 사용자의 로컬 시간 → UTC 변환 +const scheduledAt = toScheduledTime('2026-02-06', '14:00', timezone); + +// 특정 시점의 타임존 기준 자정 (UTC) +const dayStart = startOfDayInTimezone(date, timezone); +``` diff --git a/apps/api/.claude/architecture.md b/apps/api/.claude/architecture.md index 578a91e9..fb081809 100644 --- a/apps/api/.claude/architecture.md +++ b/apps/api/.claude/architecture.md @@ -145,7 +145,7 @@ POST /v1/ai/parse-todo │ AI Service │ │ - Google Gemini API 호출 │ │ - 토큰 최적화 프롬프트 (~200 tokens) │ -│ - 일일 사용량 제한 (FREE: 10회, PREMIUM: 100회) │ +│ - 일일 사용량 제한 (FREE: 5회, PREMIUM: 100회) │ └─────────────────────────────────────────────────────────┘ │ ▼ @@ -204,7 +204,7 @@ POST /v1/ai/parse-todo { "success": true, "data": { ... }, - "timestamp": "2024-01-15T10:30:00.000Z" + "timestamp": "2026-02-06T10:30:00.000Z" } ``` @@ -219,7 +219,7 @@ POST /v1/ai/parse-todo "code": "ERROR_CODE", "message": "에러 메시지" }, - "timestamp": "2024-01-15T10:30:00.000Z" + "timestamp": "2026-02-06T10:30:00.000Z" } ``` diff --git a/apps/api/.claude/testing-guide.md b/apps/api/.claude/testing-guide.md index 7349ac9f..d1906058 100644 --- a/apps/api/.claude/testing-guide.md +++ b/apps/api/.claude/testing-guide.md @@ -455,5 +455,5 @@ pnpm --filter @aido/api test:e2e -- -t "패턴" # 특정 테스트 --- -**문서 버전**: 1.0.0 -**최종 수정일**: 2026-02-03 +**문서 버전**: 1.0.1 +**최종 수정일**: 2026-02-06 diff --git a/apps/api/README.md b/apps/api/README.md index 1042b07c..19b20d2a 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -7,7 +7,7 @@ NestJS 11 기반 RESTful API 서버. Prisma 7 + PostgreSQL. | 분류 | 기술 | |------|------| | Framework | NestJS 11 | -| ORM | Prisma 7 | +| ORM | Prisma 7 + @prisma/adapter-pg | | Database | PostgreSQL 16 | | Validation | Zod + nestjs-zod | | Documentation | Swagger/OpenAPI | @@ -20,6 +20,8 @@ NestJS 11 기반 RESTful API 서버. Prisma 7 + PostgreSQL. src/ ├── common/ # 공통 모듈 │ ├── database/ # DB 유틸리티 +│ ├── date/ # 날짜/타임존 유틸리티 +│ ├── decorators/ # 커스텀 데코레이터 (@Timezone 등) │ ├── exception/ # 예외 처리 │ ├── logger/ # 로깅 │ ├── pagination/ # 페이지네이션 @@ -78,7 +80,7 @@ pnpm --filter @aido/api dev - **OpenAPI JSON**: http://localhost:8080/api-docs-json ### 클라이언트 가이드 -- [📱 알림 구현 가이드](./docs/NOTIFICATION_GUIDE.md) +- [📱 알림 구현 가이드](./docs/PUSH_NOTIFICATION_GUIDE.md) ## 배포 diff --git a/apps/api/docs/PUSH_NOTIFICATION_GUIDE.md b/apps/api/docs/PUSH_NOTIFICATION_GUIDE.md index 9b0c27fa..7afff183 100644 --- a/apps/api/docs/PUSH_NOTIFICATION_GUIDE.md +++ b/apps/api/docs/PUSH_NOTIFICATION_GUIDE.md @@ -48,9 +48,9 @@ | 모바일 앱 | expo-notifications 패키지 | 설치됨 (v0.32.16) | | | app.config.ts 설정 | 완료 | | | 알림 설정 화면 UI | 완료 | -| | 푸시 토큰 요청/등록 | **미구현** | -| | 알림 수신 리스너 | **미구현** | -| | 알림 클릭 핸들링 | **미구현** | +| | 푸시 토큰 요청/등록 | 완료 | +| | 알림 수신 리스너 | 완료 | +| | 알림 클릭 핸들링 | 완료 | --- diff --git a/apps/api/prisma/migrations/20260205233052_add_timestamptz_to_scheduled_time/migration.sql b/apps/api/prisma/migrations/20260205233052_add_timestamptz_to_scheduled_time/migration.sql new file mode 100644 index 00000000..e370d5df --- /dev/null +++ b/apps/api/prisma/migrations/20260205233052_add_timestamptz_to_scheduled_time/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Todo" ALTER COLUMN "scheduledTime" SET DATA TYPE TIMESTAMPTZ(3); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 455f5600..1eb310c4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -405,7 +405,7 @@ model Todo { // 날짜 (기간 지원) startDate DateTime @db.Date // 시작일 endDate DateTime? @db.Date // 종료일 (null = 단일 날짜) - scheduledTime DateTime? // 시간 (선택) + scheduledTime DateTime? @db.Timestamptz(3) // 예정 시각 (UTC, 시간 이벤트 전용) isAllDay Boolean @default(true) // 가시성 diff --git a/apps/api/src/common/date/utils/date.util.spec.ts b/apps/api/src/common/date/utils/date.util.spec.ts new file mode 100644 index 00000000..0a69bbbf --- /dev/null +++ b/apps/api/src/common/date/utils/date.util.spec.ts @@ -0,0 +1,73 @@ +import { + getUserToday, + startOfDayInTimezone, + toDateString, + toScheduledTime, +} from "./date.util"; + +describe("getUserToday", () => { + it("KST 자정 이후에도 한국 날짜를 반환한다", () => { + // KST 2026-02-07 01:00 = UTC 2026-02-06 16:00 + jest.useFakeTimers().setSystemTime(new Date("2026-02-06T16:00:00Z")); + const today = getUserToday("Asia/Seoul"); + expect(toDateString(today)).toBe("2026-02-07"); + jest.useRealTimers(); + }); + + it("UTC 기본값으로 동작한다", () => { + jest.useFakeTimers().setSystemTime(new Date("2026-02-06T23:00:00Z")); + const today = getUserToday(); + expect(toDateString(today)).toBe("2026-02-06"); + jest.useRealTimers(); + }); + + it("미국 동부 시간대에서 정확한 날짜를 반환한다", () => { + // EST 2026-02-06 23:30 = UTC 2026-02-07 04:30 + jest.useFakeTimers().setSystemTime(new Date("2026-02-07T04:30:00Z")); + const today = getUserToday("America/New_York"); + expect(toDateString(today)).toBe("2026-02-06"); + jest.useRealTimers(); + }); +}); + +describe("startOfDayInTimezone", () => { + it("KST 타임존의 자정을 UTC로 변환한다", () => { + const date = new Date("2026-02-06T20:00:00Z"); // KST 2026-02-07 05:00 + const result = startOfDayInTimezone(date, "Asia/Seoul"); + expect(toDateString(result)).toBe("2026-02-07"); + expect(result.getUTCHours()).toBe(0); + expect(result.getUTCMinutes()).toBe(0); + }); + + it("UTC 기본값으로 동작한다", () => { + const date = new Date("2026-02-06T15:30:00Z"); + const result = startOfDayInTimezone(date); + expect(toDateString(result)).toBe("2026-02-06"); + expect(result.getUTCHours()).toBe(0); + }); +}); + +describe("toScheduledTime", () => { + it("KST 로컬 시간을 UTC로 변환한다", () => { + // KST 14:00 → UTC 05:00 + const result = toScheduledTime("2026-01-15", "14:00", "Asia/Seoul"); + expect(result.toISOString()).toBe("2026-01-15T05:00:00.000Z"); + }); + + it("EST 로컬 시간을 UTC로 변환한다", () => { + // EST 14:00 → UTC 19:00 + const result = toScheduledTime("2026-01-15", "14:00", "America/New_York"); + expect(result.toISOString()).toBe("2026-01-15T19:00:00.000Z"); + }); + + it("UTC 기본값으로 동작한다", () => { + const result = toScheduledTime("2026-01-15", "14:00"); + expect(result.toISOString()).toBe("2026-01-15T14:00:00.000Z"); + }); + + it("자정 근처 타임존 변환 시 날짜가 올바르게 처리된다", () => { + // KST 01:00 → UTC 이전 날 16:00 + const result = toScheduledTime("2026-01-15", "01:00", "Asia/Seoul"); + expect(result.toISOString()).toBe("2026-01-14T16:00:00.000Z"); + }); +}); diff --git a/apps/api/src/common/date/utils/date.util.ts b/apps/api/src/common/date/utils/date.util.ts index 19a4081c..d38960fd 100644 --- a/apps/api/src/common/date/utils/date.util.ts +++ b/apps/api/src/common/date/utils/date.util.ts @@ -1,8 +1,12 @@ import dayjs from "dayjs"; +import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import { DATE_FORMAT, type DateFormatType } from "../constants"; dayjs.extend(utc); +dayjs.extend(timezone); + +const DEFAULT_TIMEZONE = "UTC"; // ============================================ // 현재 시각 @@ -40,6 +44,15 @@ export function toDate(dateString: string): Date { return dayjs.utc(dateString).toDate(); } +/** + * DATE 타입 필드용 날짜 변환 + * PostgreSQL DATE(@db.Date)와 호환되는 UTC 자정 Date 반환 + * @example toDateOnly("2026-02-06") // 2026-02-06T00:00:00.000Z + */ +export function toDateOnly(dateString: string): Date { + return dayjs.utc(dateString).startOf("day").toDate(); +} + // ============================================ // 포맷팅 // ============================================ @@ -249,3 +262,43 @@ export function diffInDays(date: Date, compare: Date): number { export function diffInSeconds(date: Date, compare: Date): number { return dayjs.utc(date).diff(dayjs.utc(compare), "second"); } + +// ============================================ +// 타임존 +// ============================================ + +/** + * 지정된 타임존의 "오늘" 날짜를 UTC midnight Date로 반환 + * @example getUserToday('Asia/Seoul') at 2026-02-06 23:00 KST → 2026-02-06T00:00:00.000Z + * @example getUserToday('America/New_York') at 2026-02-06 23:00 EST → 2026-02-06T00:00:00.000Z + */ +export function getUserToday(tz: string = DEFAULT_TIMEZONE): Date { + const localDateStr = dayjs().tz(tz).format("YYYY-MM-DD"); + return dayjs.utc(localDateStr).startOf("day").toDate(); +} + +/** + * 사용자의 로컬 시간(날짜 + HH:mm)을 UTC Date 객체로 변환 + * Google Calendar 패턴: 시간 이벤트는 TIMESTAMPTZ(UTC)로 저장 + * @example toScheduledTime("2026-01-15", "14:00", "Asia/Seoul") → 2026-01-15T05:00:00.000Z + * @example toScheduledTime("2026-01-15", "14:00", "America/New_York") → 2026-01-15T19:00:00.000Z + */ +export function toScheduledTime( + dateStr: string, + timeStr: string, + tz: string = DEFAULT_TIMEZONE, +): Date { + return dayjs.tz(`${dateStr}T${timeStr}:00`, tz).utc().toDate(); +} + +/** + * 지정된 타임존에서 특정 시점의 날짜 시작(자정)을 UTC Date로 반환 + * @example startOfDayInTimezone(new Date(), 'Asia/Seoul') → 해당 시점의 KST 자정을 UTC로 표현 + */ +export function startOfDayInTimezone( + date: Date = now(), + tz: string = DEFAULT_TIMEZONE, +): Date { + const localDateStr = dayjs(date).tz(tz).format("YYYY-MM-DD"); + return dayjs.utc(localDateStr).startOf("day").toDate(); +} diff --git a/apps/api/src/common/decorators/index.ts b/apps/api/src/common/decorators/index.ts new file mode 100644 index 00000000..9c559732 --- /dev/null +++ b/apps/api/src/common/decorators/index.ts @@ -0,0 +1 @@ +export { Timezone } from "./timezone.decorator"; diff --git a/apps/api/src/common/decorators/timezone.decorator.ts b/apps/api/src/common/decorators/timezone.decorator.ts new file mode 100644 index 00000000..79af7a14 --- /dev/null +++ b/apps/api/src/common/decorators/timezone.decorator.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, type ExecutionContext } from "@nestjs/common"; + +export const Timezone = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest(); + return request.headers["x-timezone"] || "UTC"; + }, +); diff --git a/apps/api/src/modules/cheer/cheer.controller.ts b/apps/api/src/modules/cheer/cheer.controller.ts index d58ddf6d..4da09755 100644 --- a/apps/api/src/modules/cheer/cheer.controller.ts +++ b/apps/api/src/modules/cheer/cheer.controller.ts @@ -13,8 +13,8 @@ import { Query, UseGuards, } from "@nestjs/common"; -import { ApiBearerAuth, ApiParam, ApiTags } from "@nestjs/swagger"; - +import { ApiBearerAuth, ApiHeader, ApiParam, ApiTags } from "@nestjs/swagger"; +import { Timezone } from "@/common/decorators"; import { ApiBadRequestError, ApiConflictError, @@ -79,6 +79,12 @@ export class CheerController { // ============================================ @Post() + @ApiHeader({ + name: "X-Timezone", + required: false, + description: "사용자 타임존 (IANA, 기본값: UTC)", + example: "Asia/Seoul", + }) @ApiDoc({ summary: "응원 보내기", operationId: "sendCheer", @@ -100,16 +106,20 @@ export class CheerController { async sendCheer( @CurrentUser() user: CurrentUserPayload, @Body() dto: SendCheerDto, + @Timezone() tz: string, ): Promise { this.logger.debug( `응원 보내기: senderId=${user.userId}, receiverId=${dto.receiverId}`, ); - const cheer = await this.cheerService.sendCheer({ - senderId: user.userId, - receiverId: dto.receiverId, - message: dto.message, - }); + const cheer = await this.cheerService.sendCheer( + { + senderId: user.userId, + receiverId: dto.receiverId, + message: dto.message, + }, + tz, + ); this.logger.log( `응원 완료: id=${cheer.id}, senderId=${user.userId}, receiverId=${dto.receiverId}`, @@ -195,6 +205,12 @@ export class CheerController { // ============================================ @Get("limit") + @ApiHeader({ + name: "X-Timezone", + required: false, + description: "사용자 타임존 (IANA, 기본값: UTC)", + example: "Asia/Seoul", + }) @ApiDoc({ summary: "일일 응원 제한 정보 조회", operationId: "getCheerLimitInfo", @@ -206,8 +222,9 @@ export class CheerController { @ApiUnauthorizedError(ErrorCode.AUTH_0107) async getLimitInfo( @CurrentUser() user: CurrentUserPayload, + @Timezone() tz: string, ): Promise { - const limitInfo = await this.cheerService.getLimitInfo(user.userId); + const limitInfo = await this.cheerService.getLimitInfo(user.userId, tz); return CheerMapper.toLimitInfoDto(limitInfo); } diff --git a/apps/api/src/modules/cheer/cheer.service.ts b/apps/api/src/modules/cheer/cheer.service.ts index c00eed4a..100b650a 100644 --- a/apps/api/src/modules/cheer/cheer.service.ts +++ b/apps/api/src/modules/cheer/cheer.service.ts @@ -2,6 +2,7 @@ import { CHEER_LIMITS, SUBSCRIPTION_CHEER_LIMITS } from "@aido/validators"; import { Injectable, Logger } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { CacheService } from "@/common/cache/cache.service"; +import { addMilliseconds, now, startOfDayInTimezone } from "@/common/date"; import { BusinessExceptions } from "@/common/exception/services/business-exception.service"; import type { CursorPaginatedResponse } from "@/common/pagination/interfaces/pagination.interface"; import { PaginationService } from "@/common/pagination/services/pagination.service"; @@ -60,7 +61,10 @@ export class CheerService { * * @note 트랜잭션으로 감싸서 TOCTOU 경합 조건을 방지합니다. */ - async sendCheer(params: SendCheerParams): Promise { + async sendCheer( + params: SendCheerParams, + tz: string = "UTC", + ): Promise { const { senderId, receiverId, message } = params; // 1. 자기 자신 체크 @@ -80,12 +84,7 @@ export class CheerService { // 트랜잭션으로 감싸서 check-and-create를 atomic하게 수행 const cheer = await this.database.$transaction(async (tx) => { // 3. 일일 제한 체크 (트랜잭션 내에서 실시간 조회) - const today = new Date(); - const startOfDay = new Date( - today.getFullYear(), - today.getMonth(), - today.getDate(), - ); + const todayStart = startOfDayInTimezone(now(), tz); const subscriptionStatus = await tx.user.findUnique({ where: { id: senderId }, @@ -103,7 +102,7 @@ export class CheerService { where: { senderId, createdAt: { - gte: startOfDay, + gte: todayStart, }, }, }); @@ -125,11 +124,11 @@ export class CheerService { if (lastCheer) { const cooldownMs = CHEER_LIMITS.COOLDOWN_HOURS * 60 * 60 * 1000; - const canCheerAt = new Date(lastCheer.createdAt.getTime() + cooldownMs); - const now = new Date(); + const canCheerAt = addMilliseconds(cooldownMs, lastCheer.createdAt); + const currentTime = now(); - if (now < canCheerAt) { - const remainingMs = canCheerAt.getTime() - now.getTime(); + if (currentTime < canCheerAt) { + const remainingMs = canCheerAt.getTime() - currentTime.getTime(); const remainingSeconds = Math.ceil(remainingMs / 1000); throw BusinessExceptions.cheerCooldownActive( receiverId, @@ -270,7 +269,10 @@ export class CheerService { /** * 일일 응원 제한 정보 조회 */ - async getLimitInfo(userId: string): Promise { + async getLimitInfo( + userId: string, + tz: string = "UTC", + ): Promise { // 구독 상태 조회 (캐시 우선) let subscriptionStatus: "FREE" | "ACTIVE" | "EXPIRED" | "CANCELLED" | null; @@ -295,7 +297,7 @@ export class CheerService { : CHEER_LIMITS.FREE_DAILY_LIMIT; // 오늘 사용량 조회 - const today = new Date(); + const today = startOfDayInTimezone(now(), tz); const used = await this.cheerRepository.countTodayCheers({ senderId: userId, date: today, @@ -386,10 +388,10 @@ export class CheerService { } const cooldownMs = CHEER_LIMITS.COOLDOWN_HOURS * 60 * 60 * 1000; - const canCheerAt = new Date(lastCheerTime.getTime() + cooldownMs); - const now = new Date(); + const canCheerAt = addMilliseconds(cooldownMs, lastCheerTime); + const currentTime = now(); - if (now >= canCheerAt) { + if (currentTime >= canCheerAt) { return { isActive: false, remainingSeconds: 0, @@ -397,7 +399,7 @@ export class CheerService { }; } - const remainingMs = canCheerAt.getTime() - now.getTime(); + const remainingMs = canCheerAt.getTime() - currentTime.getTime(); const remainingSeconds = Math.ceil(remainingMs / 1000); return { diff --git a/apps/api/src/modules/notification/listeners/todo.listener.spec.ts b/apps/api/src/modules/notification/listeners/todo.listener.spec.ts new file mode 100644 index 00000000..5016e0a8 --- /dev/null +++ b/apps/api/src/modules/notification/listeners/todo.listener.spec.ts @@ -0,0 +1,161 @@ +/** + * TodoListener 단위 테스트 (Suites + GWT 패턴) + * + * 알림 중복 방지 로직을 검증합니다. + * - DAILY_COMPLETE: 하루에 1회만 발송 + * - FRIEND_COMPLETED: 같은 친구에 대해 하루에 1회만 발송 (배치) + */ + +import type { Mocked } from "@suites/doubles.jest"; +import { TestBed } from "@suites/unit"; + +import { NotificationRepository } from "../notification.repository"; +import { NotificationService } from "../notification.service"; +import { TodoListener } from "./todo.listener"; + +jest.mock("@/common/date/utils/date.util", () => ({ + startOfDay: jest.fn(() => new Date("2026-02-06T00:00:00.000Z")), +})); + +describe("TodoListener", () => { + let listener: TodoListener; + let notificationService: Mocked; + let notificationRepository: Mocked; + + beforeEach(async () => { + const { unit, unitRef } = await TestBed.solitary(TodoListener).compile(); + + listener = unit; + notificationService = unitRef.get( + NotificationService, + ) as unknown as Mocked; + notificationRepository = unitRef.get( + NotificationRepository, + ) as unknown as Mocked; + }); + + // ============================================ + // handleTodoAllCompleted + // ============================================ + + describe("handleTodoAllCompleted", () => { + const payload = { userId: "user-1", completedCount: 3 }; + + it("오늘 첫 전체 완료 시 DAILY_COMPLETE 알림을 발송한다", async () => { + // Given: 오늘 DAILY_COMPLETE 알림이 없음 + notificationRepository.existsNotification.mockResolvedValue(false); + notificationService.createAndSend.mockResolvedValue({} as any); + + // When + await listener.handleTodoAllCompleted(payload); + + // Then: 알림 발송됨 + expect(notificationService.createAndSend).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + type: "DAILY_COMPLETE", + }), + ); + }); + + it("오늘 이미 DAILY_COMPLETE를 발송했으면 중복 발송하지 않는다", async () => { + // Given: 오늘 이미 DAILY_COMPLETE 알림이 존재 + notificationRepository.existsNotification.mockResolvedValue(true); + + // When + await listener.handleTodoAllCompleted(payload); + + // Then: 알림 발송 안됨 + expect(notificationService.createAndSend).not.toHaveBeenCalled(); + }); + }); + + // ============================================ + // handleFriendCompleted + // ============================================ + + describe("handleFriendCompleted", () => { + const payload = { + friendId: "friend-1", + friendName: "김철수", + notifyUserIds: ["user-1", "user-2", "user-3"], + }; + + it("오늘 첫 친구 완료 시 모든 친구에게 알림을 발송한다", async () => { + // Given: 아무도 아직 알림을 받지 않음 + notificationRepository.findAlreadyNotifiedUserIds.mockResolvedValue( + new Set(), + ); + notificationService.createAndSendBatch.mockResolvedValue({ count: 3 }); + + // When + await listener.handleFriendCompleted(payload); + + // Then: 3명 모두에게 발송 + expect(notificationService.createAndSendBatch).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + userId: "user-1", + type: "FRIEND_COMPLETED", + }), + expect.objectContaining({ + userId: "user-2", + type: "FRIEND_COMPLETED", + }), + expect.objectContaining({ + userId: "user-3", + type: "FRIEND_COMPLETED", + }), + ]), + ); + }); + + it("이미 알림을 받은 유저는 제외하고 발송한다", async () => { + // Given: user-1은 이미 알림을 받음 + notificationRepository.findAlreadyNotifiedUserIds.mockResolvedValue( + new Set(["user-1"]), + ); + notificationService.createAndSendBatch.mockResolvedValue({ count: 2 }); + + // When + await listener.handleFriendCompleted(payload); + + // Then: user-2, user-3에게만 발송 + const batchArg = + notificationService.createAndSendBatch.mock.calls[0]?.[0] ?? []; + expect(batchArg).toHaveLength(2); + expect(batchArg.map((n: any) => n.userId)).toEqual(["user-2", "user-3"]); + }); + + it("모든 유저가 이미 알림을 받았으면 발송하지 않는다", async () => { + // Given: 모든 유저가 이미 알림을 받음 + notificationRepository.findAlreadyNotifiedUserIds.mockResolvedValue( + new Set(["user-1", "user-2", "user-3"]), + ); + + // When + await listener.handleFriendCompleted(payload); + + // Then: 발송 안됨 + expect(notificationService.createAndSendBatch).not.toHaveBeenCalled(); + }); + + it("알림 대상이 없으면 중복 체크도 하지 않는다", async () => { + // Given: 알림 대상이 빈 배열 + const emptyPayload = { + friendId: "friend-1", + friendName: "김철수", + notifyUserIds: [] as string[], + }; + + // When + await listener.handleFriendCompleted(emptyPayload); + + // Then: DB 조회도, 알림 발송도 하지 않음 + expect( + notificationRepository.findAlreadyNotifiedUserIds, + ).not.toHaveBeenCalled(); + expect(notificationService.createAndSendBatch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/modules/notification/listeners/todo.listener.ts b/apps/api/src/modules/notification/listeners/todo.listener.ts index a40a3fa6..fdc571f4 100644 --- a/apps/api/src/modules/notification/listeners/todo.listener.ts +++ b/apps/api/src/modules/notification/listeners/todo.listener.ts @@ -1,12 +1,15 @@ import { Injectable, Logger } from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; +import { startOfDay } from "@/common/date/utils/date.util"; + import { type FriendCompletedEventPayload, NotificationEvents, type TodoAllCompletedEventPayload, type TodoReminderEventPayload, } from "../events/notification.events"; +import { NotificationRepository } from "../notification.repository"; import { NotificationService } from "../notification.service"; import { NotificationMessageBuilder } from "../templates/notification-templates"; @@ -22,7 +25,10 @@ import { NotificationMessageBuilder } from "../templates/notification-templates" export class TodoListener { private readonly logger = new Logger(TodoListener.name); - constructor(private readonly notificationService: NotificationService) {} + constructor( + private readonly notificationService: NotificationService, + private readonly notificationRepository: NotificationRepository, + ) {} /** * 오늘 할일 전체 완료 이벤트 처리 @@ -41,6 +47,19 @@ export class TodoListener { ); try { + const alreadySent = await this.notificationRepository.existsNotification({ + userId: payload.userId, + type: "DAILY_COMPLETE", + since: startOfDay(), + }); + + if (alreadySent) { + this.logger.debug( + `Daily completion already sent today: userId=${payload.userId}`, + ); + return; + } + await this.notificationService.createAndSend({ userId: payload.userId, type: "DAILY_COMPLETE", @@ -116,11 +135,30 @@ export class TodoListener { } try { + const alreadyNotified = + await this.notificationRepository.findAlreadyNotifiedUserIds({ + userIds: payload.notifyUserIds, + type: "FRIEND_COMPLETED", + since: startOfDay(), + friendId: payload.friendId, + }); + + const newUserIds = payload.notifyUserIds.filter( + (id) => !alreadyNotified.has(id), + ); + + if (newUserIds.length === 0) { + this.logger.debug( + `Friend completion already sent today: friendId=${payload.friendId}`, + ); + return; + } + const message = NotificationMessageBuilder.friendCompleted( payload.friendName, ); - const notifications = payload.notifyUserIds.map((userId) => ({ + const notifications = newUserIds.map((userId) => ({ userId, type: "FRIEND_COMPLETED" as const, title: message.title, @@ -131,7 +169,7 @@ export class TodoListener { await this.notificationService.createAndSendBatch(notifications); this.logger.log( - `Friend completion notifications sent: friendId=${payload.friendId}, count=${payload.notifyUserIds.length}`, + `Friend completion notifications sent: friendId=${payload.friendId}, count=${newUserIds.length}`, ); } catch (error) { this.logger.error( diff --git a/apps/api/src/modules/notification/notification.repository.spec.ts b/apps/api/src/modules/notification/notification.repository.spec.ts index c54440a6..074c17ff 100644 --- a/apps/api/src/modules/notification/notification.repository.spec.ts +++ b/apps/api/src/modules/notification/notification.repository.spec.ts @@ -373,6 +373,100 @@ describe("NotificationRepository", () => { }); }); + // ========================================================================== + // Deduplication Queries + // ========================================================================== + + describe("existsNotification", () => { + const since = new Date("2026-02-06T00:00:00.000Z"); + + it("해당 타입의 알림이 존재하면 true를 반환해야 한다", async () => { + // Given + (db.notification.count as jest.Mock).mockResolvedValue(1); + + // When + const result = await repository.existsNotification({ + userId: "user-1", + type: "DAILY_COMPLETE", + since, + }); + + // Then + expect(db.notification.count).toHaveBeenCalledWith({ + where: { + userId: "user-1", + type: "DAILY_COMPLETE", + createdAt: { gte: since }, + }, + }); + expect(result).toBe(true); + }); + + it("해당 타입의 알림이 없으면 false를 반환해야 한다", async () => { + // Given + (db.notification.count as jest.Mock).mockResolvedValue(0); + + // When + const result = await repository.existsNotification({ + userId: "user-1", + type: "DAILY_COMPLETE", + since, + }); + + // Then + expect(result).toBe(false); + }); + }); + + describe("findAlreadyNotifiedUserIds", () => { + const since = new Date("2026-02-06T00:00:00.000Z"); + + it("이미 알림을 받은 사용자 ID Set을 반환해야 한다", async () => { + // Given + (db.notification.findMany as jest.Mock).mockResolvedValue([ + { userId: "user-1" }, + { userId: "user-3" }, + ]); + + // When + const result = await repository.findAlreadyNotifiedUserIds({ + userIds: ["user-1", "user-2", "user-3"], + type: "FRIEND_COMPLETED", + since, + friendId: "friend-1", + }); + + // Then + expect(db.notification.findMany).toHaveBeenCalledWith({ + where: { + userId: { in: ["user-1", "user-2", "user-3"] }, + type: "FRIEND_COMPLETED", + friendId: "friend-1", + createdAt: { gte: since }, + }, + select: { userId: true }, + distinct: ["userId"], + }); + expect(result).toEqual(new Set(["user-1", "user-3"])); + }); + + it("아무도 알림을 받지 않았으면 빈 Set을 반환해야 한다", async () => { + // Given + (db.notification.findMany as jest.Mock).mockResolvedValue([]); + + // When + const result = await repository.findAlreadyNotifiedUserIds({ + userIds: ["user-1", "user-2"], + type: "FRIEND_COMPLETED", + since, + friendId: "friend-1", + }); + + // Then + expect(result).toEqual(new Set()); + }); + }); + // ========================================================================== // PushToken CRUD Tests // ========================================================================== diff --git a/apps/api/src/modules/notification/notification.repository.ts b/apps/api/src/modules/notification/notification.repository.ts index 4d074029..06be4ae1 100644 --- a/apps/api/src/modules/notification/notification.repository.ts +++ b/apps/api/src/modules/notification/notification.repository.ts @@ -1,6 +1,10 @@ import { Injectable } from "@nestjs/common"; import { DatabaseService } from "@/database/database.service"; -import type { Notification, PushToken } from "@/generated/prisma/client"; +import type { + Notification, + NotificationType, + PushToken, +} from "@/generated/prisma/client"; import type { CreateNotificationData, @@ -189,6 +193,48 @@ export class NotificationRepository { }); } + /** + * 특정 타입의 알림이 지정 시각 이후 존재하는지 확인 + * - DAILY_COMPLETE 중복 방지용 (단건) + */ + async existsNotification(params: { + userId: string; + type: NotificationType; + since: Date; + }): Promise { + const count = await this.database.notification.count({ + where: { + userId: params.userId, + type: params.type, + createdAt: { gte: params.since }, + }, + }); + return count > 0; + } + + /** + * 이미 알림을 받은 사용자 ID 목록 조회 (배치) + * - FRIEND_COMPLETED 중복 방지용 — N+1 방지를 위해 단일 쿼리로 처리 + */ + async findAlreadyNotifiedUserIds(params: { + userIds: string[]; + type: NotificationType; + since: Date; + friendId: string; + }): Promise> { + const rows = await this.database.notification.findMany({ + where: { + userId: { in: params.userIds }, + type: params.type, + friendId: params.friendId, + createdAt: { gte: params.since }, + }, + select: { userId: true }, + distinct: ["userId"], + }); + return new Set(rows.map((r) => r.userId)); + } + // ========================================================================= // PushToken CRUD // ========================================================================= diff --git a/apps/api/src/modules/nudge/nudge.controller.ts b/apps/api/src/modules/nudge/nudge.controller.ts index 6f8ad8e4..95baed5f 100644 --- a/apps/api/src/modules/nudge/nudge.controller.ts +++ b/apps/api/src/modules/nudge/nudge.controller.ts @@ -13,8 +13,8 @@ import { Query, UseGuards, } from "@nestjs/common"; -import { ApiBearerAuth, ApiParam, ApiTags } from "@nestjs/swagger"; - +import { ApiBearerAuth, ApiHeader, ApiParam, ApiTags } from "@nestjs/swagger"; +import { Timezone } from "@/common/decorators"; import { ApiBadRequestError, ApiConflictError, @@ -77,6 +77,12 @@ export class NudgeController { // ============================================ @Post() + @ApiHeader({ + name: "X-Timezone", + required: false, + description: "사용자 타임존 (IANA, 기본값: UTC)", + example: "Asia/Seoul", + }) @ApiDoc({ summary: "콕 찌르기", operationId: "sendNudge", @@ -100,17 +106,21 @@ export class NudgeController { async sendNudge( @CurrentUser() user: CurrentUserPayload, @Body() dto: SendNudgeDto, + @Timezone() tz: string, ): Promise { this.logger.debug( `콕 찌르기: senderId=${user.userId}, receiverId=${dto.receiverId}, todoId=${dto.todoId}`, ); - const nudge = await this.nudgeService.sendNudge({ - senderId: user.userId, - receiverId: dto.receiverId, - todoId: dto.todoId, - message: dto.message, - }); + const nudge = await this.nudgeService.sendNudge( + { + senderId: user.userId, + receiverId: dto.receiverId, + todoId: dto.todoId, + message: dto.message, + }, + tz, + ); this.logger.log( `콕 찌르기 완료: id=${nudge.id}, senderId=${user.userId}, receiverId=${dto.receiverId}`, @@ -196,6 +206,12 @@ export class NudgeController { // ============================================ @Get("limit") + @ApiHeader({ + name: "X-Timezone", + required: false, + description: "사용자 타임존 (IANA, 기본값: UTC)", + example: "Asia/Seoul", + }) @ApiDoc({ summary: "일일 콕 찌르기 제한 정보 조회", operationId: "getNudgeLimitInfo", @@ -207,8 +223,9 @@ export class NudgeController { @ApiUnauthorizedError(ErrorCode.AUTH_0107) async getLimitInfo( @CurrentUser() user: CurrentUserPayload, + @Timezone() tz: string, ): Promise { - const limitInfo = await this.nudgeService.getLimitInfo(user.userId); + const limitInfo = await this.nudgeService.getLimitInfo(user.userId, tz); return NudgeMapper.toLimitInfoDto(limitInfo); } diff --git a/apps/api/src/modules/nudge/nudge.service.ts b/apps/api/src/modules/nudge/nudge.service.ts index 94d39e04..43cb41c8 100644 --- a/apps/api/src/modules/nudge/nudge.service.ts +++ b/apps/api/src/modules/nudge/nudge.service.ts @@ -1,6 +1,7 @@ import { NUDGE_LIMITS, SUBSCRIPTION_NUDGE_LIMITS } from "@aido/validators"; import { Injectable, Logger } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; +import { addMilliseconds, now, startOfDayInTimezone } from "@/common/date"; import { BusinessExceptions } from "@/common/exception/services/business-exception.service"; import type { CursorPaginatedResponse } from "@/common/pagination/interfaces/pagination.interface"; import { PaginationService } from "@/common/pagination/services/pagination.service"; @@ -59,7 +60,10 @@ export class NudgeService { * * @note 트랜잭션 사용: Rate Limiting의 TOCTOU 동시성 문제 방지 */ - async sendNudge(params: SendNudgeParams): Promise { + async sendNudge( + params: SendNudgeParams, + tz: string = "UTC", + ): Promise { const { senderId, receiverId, todoId, message } = params; // 트랜잭션으로 감싸서 check-and-create를 atomic하게 수행 @@ -105,16 +109,11 @@ export class NudgeService { ? SUBSCRIPTION_NUDGE_LIMITS[limitKey] : NUDGE_LIMITS.FREE_DAILY_LIMIT; - const today = new Date(); - const startOfDay = new Date( - today.getFullYear(), - today.getMonth(), - today.getDate(), - ); + const todayStart = startOfDayInTimezone(now(), tz); const used = await tx.nudge.count({ where: { senderId, - createdAt: { gte: startOfDay }, + createdAt: { gte: todayStart }, }, }); @@ -136,13 +135,11 @@ export class NudgeService { if (lastNudge) { const cooldownMs = NUDGE_LIMITS.COOLDOWN_HOURS * 60 * 60 * 1000; - const cooldownEndsAt = new Date( - lastNudge.createdAt.getTime() + cooldownMs, - ); - const now = new Date(); + const cooldownEndsAt = addMilliseconds(cooldownMs, lastNudge.createdAt); + const currentTime = now(); - if (now < cooldownEndsAt) { - const remainingMs = cooldownEndsAt.getTime() - now.getTime(); + if (currentTime < cooldownEndsAt) { + const remainingMs = cooldownEndsAt.getTime() - currentTime.getTime(); const remainingSeconds = Math.ceil(remainingMs / 1000); throw BusinessExceptions.nudgeCooldownActive( receiverId, @@ -292,7 +289,10 @@ export class NudgeService { /** * 일일 독촉 제한 정보 조회 */ - async getLimitInfo(userId: string): Promise { + async getLimitInfo( + userId: string, + tz: string = "UTC", + ): Promise { // 구독 상태 조회 const subscriptionStatus = await this.nudgeRepository.getUserSubscriptionStatus(userId); @@ -307,7 +307,7 @@ export class NudgeService { : NUDGE_LIMITS.FREE_DAILY_LIMIT; // 오늘 사용량 조회 - const today = new Date(); + const today = startOfDayInTimezone(now(), tz); const used = await this.nudgeRepository.countTodayNudges({ senderId: userId, date: today, @@ -402,10 +402,10 @@ export class NudgeService { } const cooldownMs = NUDGE_LIMITS.COOLDOWN_HOURS * 60 * 60 * 1000; - const cooldownEndsAt = new Date(lastNudgeTime.getTime() + cooldownMs); - const now = new Date(); + const cooldownEndsAt = addMilliseconds(cooldownMs, lastNudgeTime); + const currentTime = now(); - if (now >= cooldownEndsAt) { + if (currentTime >= cooldownEndsAt) { return { isActive: false, remainingSeconds: 0, @@ -413,7 +413,7 @@ export class NudgeService { }; } - const remainingMs = cooldownEndsAt.getTime() - now.getTime(); + const remainingMs = cooldownEndsAt.getTime() - currentTime.getTime(); const remainingSeconds = Math.ceil(remainingMs / 1000); return { diff --git a/apps/api/src/modules/todo/todo.controller.ts b/apps/api/src/modules/todo/todo.controller.ts index d8916350..441f09f1 100644 --- a/apps/api/src/modules/todo/todo.controller.ts +++ b/apps/api/src/modules/todo/todo.controller.ts @@ -13,7 +13,9 @@ import { Query, UseGuards, } from "@nestjs/common"; -import { ApiBearerAuth, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiHeader, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { toDateOnly, toScheduledTime } from "@/common/date"; +import { Timezone } from "@/common/decorators/timezone.decorator"; import { ApiBadRequestError, @@ -82,6 +84,12 @@ export class TodoController { * 새로운 할 일을 생성합니다. */ @Post() + @ApiHeader({ + name: "X-Timezone", + required: false, + description: "사용자 타임존 (IANA, 기본값: UTC)", + example: "Asia/Seoul", + }) @ApiDoc({ summary: "할 일 생성", operationId: "createTodo", @@ -95,7 +103,7 @@ export class TodoController { **선택 필드** - \`content\`: 상세 내용 (최대 5000자) - \`endDate\`: 종료 날짜 (YYYY-MM-DD) -- \`scheduledTime\`: 예정 시간 (HH:mm) +- \`scheduledTime\`: 예정 시간 (HH:mm, 24시간 형식). \`X-Timezone\` 헤더 기반으로 UTC 변환되어 저장됩니다. - \`isAllDay\`: 종일 여부 (기본값: true) - \`visibility\`: 공개 범위 (PUBLIC/PRIVATE, 기본값: PUBLIC)`, }) @@ -105,6 +113,7 @@ export class TodoController { async create( @CurrentUser() user: CurrentUserPayload, @Body() dto: CreateTodoDto, + @Timezone() tz: string, ): Promise { this.logger.debug(`Todo 생성: user=${user.userId}, title=${dto.title}`); @@ -113,10 +122,10 @@ export class TodoController { title: dto.title, content: dto.content, categoryId: dto.categoryId, - startDate: new Date(dto.startDate), - endDate: dto.endDate ? new Date(dto.endDate) : undefined, + startDate: toDateOnly(dto.startDate), + endDate: dto.endDate ? toDateOnly(dto.endDate) : undefined, scheduledTime: dto.scheduledTime - ? this.parseScheduledTime(dto.startDate, dto.scheduledTime) + ? this.parseScheduledTime(dto.startDate, dto.scheduledTime, tz) : undefined, isAllDay: dto.isAllDay, visibility: dto.visibility, @@ -150,8 +159,56 @@ export class TodoController { - \`size\`: 페이지 크기 (1-100, 기본값: 20) - \`completed\`: 완료 상태 필터 - \`categoryId\`: 카테고리 ID 필터 -- \`startDate\`: 시작일 이후 필터 (YYYY-MM-DD) -- \`endDate\`: 종료일 이전 필터 (YYYY-MM-DD)`, +- \`startDate\`: 시작일 필터 (YYYY-MM-DD) +- \`endDate\`: 종료일 필터 (YYYY-MM-DD) + +--- + +### 날짜 필터링 (Overlapping Intervals) + +날짜 필터는 **DATE(날짜) 기준**으로 동작합니다. 타임존 변환이 적용되지 않는 floating date입니다. + +#### 사용 시나리오 + +| startDate | endDate | 결과 | +|-----------|---------|------| +| 미지정 | 미지정 | 전체 할 일 반환 | +| 2026-01-15 | 2026-01-15 | **특정 날짜**에 해당하는 할 일 (단일 날짜 조회) | +| 2026-01-01 | 2026-01-31 | **기간 범위**에 걸쳐 있는 할 일 (월간/주간 뷰) | +| 2026-01-15 | 미지정 | **해당 날짜**에 해당하는 할 일 (exact match) | +| 미지정 | 2026-01-31 | **해당 날짜**에 해당하는 할 일 (exact match) | + +#### 필터링 로직 상세 + +- **다일(multi-day) 할 일**: \`todo.startDate <= endDate\` AND \`todo.endDate >= startDate\` +- **단일 날짜 할 일** (endDate가 없는 경우): \`todo.startDate\`가 필터 범위 내에 있는지 확인 + +#### 시간 처리 (scheduledTime) + +- **종일 이벤트** (\`isAllDay=true\`): \`scheduledTime\`은 null +- **시간 이벤트** (\`isAllDay=false\`): \`scheduledTime\`은 UTC ISO 8601 형식 + - 생성/수정 시 \`X-Timezone\` 헤더 기반으로 로컬→UTC 변환하여 저장 + - 응답은 UTC로 반환, 클라이언트에서 로컬 시간으로 표시 + +#### 에러 케이스 + +| 케이스 | 응답 | +|--------|------| +| startDate가 endDate보다 이후 | \`400 Bad Request\` (SYS_0002) | +| 잘못된 날짜 형식 (예: 2026-13-01) | \`400 Bad Request\` (SYS_0002) | +| 유효하지 않은 날짜 (예: 2026-02-30) | \`400 Bad Request\` (SYS_0002) | + +#### 예시 + +1. **2026-01-10에 시작하여 2026-01-20에 끝나는 할 일**이 있을 때: + - \`startDate=2026-01-15&endDate=2026-01-15\` → ✅ 반환 (기간이 겹침) + - \`startDate=2026-01-01&endDate=2026-01-05\` → ❌ 미반환 (기간이 겹치지 않음) + - \`startDate=2026-01-25&endDate=2026-01-31\` → ❌ 미반환 (기간이 겹치지 않음) + +2. **2026-01-15에만 해당하는 단일 날짜 할 일**이 있을 때: + - \`startDate=2026-01-15&endDate=2026-01-15\` → ✅ 반환 + - \`startDate=2026-01-10&endDate=2026-01-20\` → ✅ 반환 (범위 내에 포함) + - \`startDate=2026-01-16&endDate=2026-01-20\` → ❌ 미반환`, }) @ApiQuery({ name: "cursor", @@ -184,14 +241,16 @@ export class TodoController { @ApiQuery({ name: "startDate", required: false, - description: "시작일 이후 필터 (YYYY-MM-DD)", + description: + "시작일 (YYYY-MM-DD). 단독 사용 시 해당 날짜의 할 일만 반환합니다. endDate와 함께 사용 시 범위 조회합니다.", schema: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, example: "2026-01-01", }) @ApiQuery({ name: "endDate", required: false, - description: "종료일 이전 필터 (YYYY-MM-DD)", + description: + "종료일 (YYYY-MM-DD). 단독 사용 시 해당 날짜의 할 일만 반환합니다. startDate와 함께 사용 시 범위 조회합니다. startDate보다 이전 날짜를 지정하면 400 에러가 발생합니다.", schema: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, example: "2026-01-31", }) @@ -212,8 +271,9 @@ export class TodoController { size: query.size, completed: query.completed, categoryId: query.categoryId, - startDate: query.startDate ? new Date(query.startDate) : undefined, - endDate: query.endDate ? new Date(query.endDate) : undefined, + // DATE 타입 필드는 시간 정보가 없으므로 toDateOnly 사용 + startDate: query.startDate ? toDateOnly(query.startDate) : undefined, + endDate: query.endDate ? toDateOnly(query.endDate) : undefined, }); return { @@ -263,7 +323,27 @@ export class TodoController { 맞팔 관계여야만 조회 가능하며, PRIVATE 할 일은 표시되지 않습니다. -**쿼리 파라미터**: cursor, size, startDate, endDate`, +**쿼리 파라미터** +- \`cursor\`: 페이지네이션 커서 +- \`size\`: 페이지 크기 (1-100, 기본값: 20) +- \`startDate\`: 시작일 필터 (YYYY-MM-DD) +- \`endDate\`: 종료일 필터 (YYYY-MM-DD) + +--- + +### 날짜 필터링 + +날짜 필터링은 \`GET /todos\` API와 동일한 Overlapping Intervals 로직을 사용합니다. +단일 날짜만 전달 시 해당 날짜의 할 일만 반환합니다 (exact match). +자세한 내용은 해당 API 문서를 참조하세요. + +#### 에러 케이스 + +| 케이스 | 응답 | +|--------|------| +| 맞팔 관계가 아닌 경우 | \`403 Forbidden\` (FOLLOW_0906) | +| startDate가 endDate보다 이후 | \`400 Bad Request\` (SYS_0002) | +| 잘못된 날짜 형식 | \`400 Bad Request\` (SYS_0002) |`, }) @ApiSuccessResponse({ type: TodoListResponseDto }) @ApiUnauthorizedError(ErrorCode.AUTH_0107) @@ -282,8 +362,9 @@ export class TodoController { friendUserId: params.userId, cursor: query.cursor, size: query.size, - startDate: query.startDate ? new Date(query.startDate) : undefined, - endDate: query.endDate ? new Date(query.endDate) : undefined, + // DATE 타입 필드는 시간 정보가 없으므로 toDateOnly 사용 + startDate: query.startDate ? toDateOnly(query.startDate) : undefined, + endDate: query.endDate ? toDateOnly(query.endDate) : undefined, }); return { @@ -303,6 +384,12 @@ export class TodoController { */ @Patch(":id") @HttpCode(HttpStatus.OK) + @ApiHeader({ + name: "X-Timezone", + required: false, + description: "사용자 타임존 (IANA, 기본값: UTC)", + example: "Asia/Seoul", + }) @ApiDoc({ summary: "할 일 수정", operationId: "updateTodo", @@ -318,6 +405,7 @@ export class TodoController { @CurrentUser() user: CurrentUserPayload, @Param() params: TodoIdParamDto, @Body() dto: UpdateTodoDto, + @Timezone() tz: string, ): Promise { this.logger.debug(`Todo 수정: id=${params.id}, user=${user.userId}`); @@ -325,18 +413,18 @@ export class TodoController { title: dto.title, content: dto.content, categoryId: dto.categoryId, - startDate: dto.startDate ? new Date(dto.startDate) : undefined, + startDate: dto.startDate ? toDateOnly(dto.startDate) : undefined, endDate: dto.endDate === null ? null : dto.endDate - ? new Date(dto.endDate) + ? toDateOnly(dto.endDate) : undefined, scheduledTime: dto.scheduledTime === null ? null : dto.scheduledTime && dto.startDate - ? this.parseScheduledTime(dto.startDate, dto.scheduledTime) + ? this.parseScheduledTime(dto.startDate, dto.scheduledTime, tz) : undefined, isAllDay: dto.isAllDay, visibility: dto.visibility, @@ -356,6 +444,12 @@ export class TodoController { */ @Patch(":id/complete") @HttpCode(HttpStatus.OK) + @ApiHeader({ + name: "X-Timezone", + required: false, + description: "사용자 타임존 (IANA, 기본값: UTC)", + example: "Asia/Seoul", + }) @ApiDoc({ summary: "할 일 완료 상태 토글", operationId: "toggleTodoComplete", @@ -371,6 +465,7 @@ export class TodoController { @CurrentUser() user: CurrentUserPayload, @Param() params: TodoIdParamDto, @Body() dto: ToggleTodoCompleteDto, + @Timezone() tz: string, ): Promise { this.logger.debug( `Todo 완료 상태 변경: id=${params.id}, completed=${dto.completed}, user=${user.userId}`, @@ -380,6 +475,7 @@ export class TodoController { params.id, user.userId, dto, + tz, ); return { @@ -469,6 +565,12 @@ export class TodoController { */ @Patch(":id/schedule") @HttpCode(HttpStatus.OK) + @ApiHeader({ + name: "X-Timezone", + required: false, + description: "사용자 타임존 (IANA, 기본값: UTC)", + example: "Asia/Seoul", + }) @ApiDoc({ summary: "할 일 일정 변경", operationId: "updateTodoSchedule", @@ -477,7 +579,7 @@ export class TodoController { **요청 필드** (모두 선택) - \`startDate\`: 시작일 (YYYY-MM-DD) - \`endDate\`: 종료일 (YYYY-MM-DD) -- \`scheduledTime\`: 예정 시간 (HH:mm) +- \`scheduledTime\`: 예정 시간 (HH:mm, 24시간 형식). \`X-Timezone\` 헤더 기반으로 UTC 변환되어 저장됩니다. - \`isAllDay\`: 종일 여부`, }) @ApiSuccessResponse({ type: UpdateTodoResponseDto }) @@ -488,6 +590,7 @@ export class TodoController { @CurrentUser() user: CurrentUserPayload, @Param() params: TodoIdParamDto, @Body() dto: UpdateTodoScheduleDto, + @Timezone() tz: string, ): Promise { this.logger.debug( `Todo 일정 변경: id=${params.id}, startDate=${dto.startDate}, user=${user.userId}`, @@ -497,6 +600,7 @@ export class TodoController { params.id, user.userId, dto, + tz, ); return { @@ -616,14 +720,21 @@ export class TodoController { // ============================================ /** - * HH:mm 형식의 시간을 Date 객체로 변환 + * HH:mm 형식의 시간을 UTC Date 객체로 변환 + * + * 사용자의 로컬 시간을 X-Timezone 헤더 기반으로 UTC 변환하여 저장합니다. + * Google Calendar 패턴: 시간 이벤트는 TIMESTAMPTZ(UTC)로 저장 + * + * @param dateStr - YYYY-MM-DD 형식의 날짜 문자열 + * @param timeStr - HH:mm 형식의 시간 문자열 + * @param tz - IANA 타임존 (예: "Asia/Seoul", "America/New_York") + * @example parseScheduledTime("2026-01-15", "14:00", "Asia/Seoul") → 2026-01-15T05:00:00.000Z */ - private parseScheduledTime(dateStr: string, timeStr: string): Date { - const timeParts = timeStr.split(":"); - const hours = Number(timeParts[0] ?? 0); - const minutes = Number(timeParts[1] ?? 0); - const date = new Date(dateStr); - date.setHours(hours, minutes, 0, 0); - return date; + private parseScheduledTime( + dateStr: string, + timeStr: string, + tz: string, + ): Date { + return toScheduledTime(dateStr, timeStr, tz); } } diff --git a/apps/api/src/modules/todo/todo.repository.ts b/apps/api/src/modules/todo/todo.repository.ts index 04cae675..a65f6581 100644 --- a/apps/api/src/modules/todo/todo.repository.ts +++ b/apps/api/src/modules/todo/todo.repository.ts @@ -1,12 +1,8 @@ import { Injectable } from "@nestjs/common"; -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; import { DatabaseService } from "@/database/database.service"; import type { Prisma, Todo } from "@/generated/prisma/client"; -dayjs.extend(utc); - import type { FindFriendTodosParams, FindTodosParams, @@ -14,6 +10,64 @@ import type { TransactionClient, } from "./types/todo.types.ts"; +/** + * 날짜 범위 필터 조건 생성 (Overlapping Intervals 패턴) + * + * 투두의 기간 [todo.startDate, todo.endDate]가 필터 범위 [filterStart, filterEnd]와 겹치는지 확인합니다. + * + * ### 동작 방식 + * + * **다일(multi-day) 투두** (endDate가 있는 경우): + * - 두 구간이 겹치려면: todo.startDate <= filterEnd AND todo.endDate >= filterStart + * + * **단일 날짜 투두** (endDate가 null인 경우): + * - todo.startDate가 필터 범위 내에 있어야 함: filterStart <= todo.startDate <= filterEnd + * + * ### 단일 날짜 파라미터 처리 + * + * startDate 또는 endDate 중 하나만 전달되면 해당 날짜를 **exact match**로 처리합니다. + * (오픈 레인지 방지 — Google Calendar 패턴 준수) + * + * ### 사용 시나리오 + * + * | filterStart | filterEnd | 결과 | + * |-------------|-----------|------| + * | 미지정 | 미지정 | undefined (필터 없음) | + * | 2026-01-15 | 2026-01-15 | 해당 날짜에 해당하는 투두 | + * | 2026-01-01 | 2026-01-31 | 해당 기간에 걸쳐 있는 투두 | + * | 2026-01-15 | 미지정 | 해당 날짜에 해당하는 투두 (exact match) | + * | 미지정 | 2026-01-31 | 해당 날짜에 해당하는 투두 (exact match) | + */ +function buildDateRangeFilter( + startDate?: Date, + endDate?: Date, +): Prisma.TodoWhereInput | undefined { + if (!startDate && !endDate) { + return undefined; + } + + // 단일 날짜만 전달 시 → exact match (오픈 레인지 방지) + const effectiveStart = startDate ?? (endDate as Date); + const effectiveEnd = endDate ?? (startDate as Date); + + // 다일 투두 (endDate가 있는 경우): Overlapping Intervals + const multiDayCondition: Prisma.TodoWhereInput = { + AND: [ + { endDate: { not: null } }, + { startDate: { lte: effectiveEnd } }, + { endDate: { gte: effectiveStart } }, + ], + }; + + // 단일 날짜 투두 (endDate가 null인 경우): startDate가 필터 범위 내에 있는지 확인 + const singleDayCondition: Prisma.TodoWhereInput = { + endDate: null, + startDate: { gte: effectiveStart, lte: effectiveEnd }, + }; + + return { OR: [multiDayCondition, singleDayCondition] }; +} + @Injectable() export class TodoRepository { constructor(private readonly database: DatabaseService) {} @@ -99,14 +153,9 @@ export class TodoRepository { } // 날짜 범위 필터 - if (startDate || endDate) { - where.startDate = {}; - if (startDate) { - where.startDate.gte = startDate; - } - if (endDate) { - where.startDate.lte = endDate; - } + const dateFilter = buildDateRangeFilter(startDate, endDate); + if (dateFilter) { + where.AND = [dateFilter]; } return client.todo.findMany({ @@ -175,14 +224,9 @@ export class TodoRepository { }; // 날짜 범위 필터 - if (startDate || endDate) { - where.startDate = {}; - if (startDate) { - where.startDate.gte = startDate; - } - if (endDate) { - where.startDate.lte = endDate; - } + const dateFilter = buildDateRangeFilter(startDate, endDate); + if (dateFilter) { + where.AND = [dateFilter]; } return client.todo.findMany({ @@ -211,38 +255,27 @@ export class TodoRepository { /** * 사용자의 오늘 할일 통계 조회 + * @param userId - 사용자 ID + * @param today - 오늘 날짜 (타임존 고려하여 호출부에서 전달) * @returns { total: 전체 할일 수, completed: 완료된 할일 수 } */ async getTodayTodoStats( userId: string, + today: Date, tx?: TransactionClient, ): Promise<{ total: number; completed: number }> { const client = tx ?? this.database; - // 오늘 날짜 범위 계산 (UTC 기준으로 일관성 보장) - const today = dayjs.utc().startOf("day").toDate(); - const tomorrow = dayjs.utc().add(1, "day").startOf("day").toDate(); + // 오늘 날짜에 해당하는 투두 필터 + const dateFilter = buildDateRangeFilter(today, today); + const where: Prisma.TodoWhereInput = { + userId, + ...(dateFilter && { AND: [dateFilter] }), + }; const [total, completed] = await Promise.all([ - client.todo.count({ - where: { - userId, - startDate: { - gte: today, - lt: tomorrow, - }, - }, - }), - client.todo.count({ - where: { - userId, - startDate: { - gte: today, - lt: tomorrow, - }, - completed: true, - }, - }), + client.todo.count({ where }), + client.todo.count({ where: { ...where, completed: true } }), ]); return { total, completed }; diff --git a/apps/api/src/modules/todo/todo.service.spec.ts b/apps/api/src/modules/todo/todo.service.spec.ts index 9f62c8f6..9013676b 100644 --- a/apps/api/src/modules/todo/todo.service.spec.ts +++ b/apps/api/src/modules/todo/todo.service.spec.ts @@ -329,6 +329,47 @@ describe("TodoService", () => { ); }); + it("startDate만 전달하면 해당 날짜로 필터링한다", async () => { + // Given: startDate만 전달 + const startDate = new Date("2024-01-15"); + const params = { userId: mockUserId, startDate }; + + // When: startDate만으로 필터링 + await service.findMany(params); + + // Then: startDate가 전달됨 (repository에서 exact match 처리) + expect(todoRepo.findManyByUserId).toHaveBeenCalledWith( + expect.objectContaining({ startDate, endDate: undefined }), + ); + }); + + it("endDate만 전달하면 해당 날짜로 필터링한다", async () => { + // Given: endDate만 전달 + const endDate = new Date("2024-01-15"); + const params = { userId: mockUserId, endDate }; + + // When: endDate만으로 필터링 + await service.findMany(params); + + // Then: endDate가 전달됨 (repository에서 exact match 처리) + expect(todoRepo.findManyByUserId).toHaveBeenCalledWith( + expect.objectContaining({ startDate: undefined, endDate }), + ); + }); + + it("startDate가 endDate보다 이후면 SYS_0002 에러를 던진다", async () => { + // Given: 잘못된 날짜 범위 + const params = { + userId: mockUserId, + startDate: new Date("2024-02-03"), + endDate: new Date("2024-02-02"), + }; + + // When & Then: BusinessException 발생 + await expect(service.findMany(params)).rejects.toThrow(BusinessException); + expect(todoRepo.findManyByUserId).not.toHaveBeenCalled(); + }); + it("카테고리로 필터링할 수 있다", async () => { // Given: 카테고리 필터 파라미터 const params = { userId: mockUserId, categoryId: 1 }; @@ -630,6 +671,37 @@ describe("TodoService", () => { }), ).rejects.toThrow(BusinessException); }); + + it("완료 시 타임존이 checkAndEmitAllCompletedEvent에 전달된다", async () => { + // Given: 미완료 상태의 Todo + const uncompletedTodo = TodoBuilder.create(mockUserId) + .withId(1) + .uncompleted() + .build(); + todoRepo.findByIdAndUserId.mockResolvedValue(uncompletedTodo); + todoRepo.update.mockImplementation( + async (_id: number, data: Record) => + ({ + ...uncompletedTodo, + ...data, + }) as any, + ); + todoRepo.getTodayTodoStats.mockResolvedValue({ total: 1, completed: 1 }); + + // When: KST 타임존으로 완료 변경 + await service.toggleComplete( + uncompletedTodo.id, + mockUserId, + { completed: true }, + "Asia/Seoul", + ); + + // Then: getTodayTodoStats가 KST 기준 오늘 날짜로 호출됨 + expect(todoRepo.getTodayTodoStats).toHaveBeenCalledWith( + mockUserId, + expect.any(Date), + ); + }); }); // ============================================ @@ -1199,5 +1271,23 @@ describe("TodoService", () => { expect.objectContaining({ friendUserId, startDate, endDate }), ); }); + + it("startDate가 endDate보다 이후면 SYS_0002 에러를 던진다", async () => { + // Given: 잘못된 날짜 범위 + const startDate = new Date("2024-02-03"); + const endDate = new Date("2024-02-02"); + + // When & Then: BusinessException 발생 + await expect( + service.findFriendTodos({ + userId: mockUserId, + friendUserId, + startDate, + endDate, + }), + ).rejects.toThrow(BusinessException); + expect(followService.isMutualFriend).not.toHaveBeenCalled(); + expect(todoRepo.findPublicTodosByUserId).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/api/src/modules/todo/todo.service.ts b/apps/api/src/modules/todo/todo.service.ts index f6734168..7656e000 100644 --- a/apps/api/src/modules/todo/todo.service.ts +++ b/apps/api/src/modules/todo/todo.service.ts @@ -1,7 +1,7 @@ import type { Todo } from "@aido/validators"; import { Injectable, Logger } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; -import { now, toDate } from "@/common/date"; +import { getUserToday, now, toDateOnly, toScheduledTime } from "@/common/date"; import { BusinessExceptions } from "@/common/exception/services/business-exception.service"; import type { CursorPaginatedResponse } from "@/common/pagination/interfaces/pagination.interface"; import { PaginationService } from "@/common/pagination/services/pagination.service"; @@ -39,6 +39,24 @@ export class TodoService { private readonly database: DatabaseService, ) {} + /** + * 날짜 범위 파라미터 검증 + * startDate와 endDate가 모두 존재할 때 startDate <= endDate 여야 합니다. + */ + private validateDateRange(startDate?: Date, endDate?: Date): void { + if (!startDate || !endDate) { + return; + } + + if (startDate.getTime() > endDate.getTime()) { + throw BusinessExceptions.invalidParameter({ + message: "startDate must be less than or equal to endDate", + startDate, + endDate, + }); + } + } + /** * Todo 생성 */ @@ -96,6 +114,8 @@ export class TodoService { async findMany( params: GetTodosParams, ): Promise> { + this.validateDateRange(params.startDate, params.endDate); + const { cursor, size } = this.paginationService.normalizeCursorPagination({ cursor: params.cursor, @@ -134,6 +154,7 @@ export class TodoService { params: GetFriendTodosParams, ): Promise> { const { userId, friendUserId } = params; + this.validateDateRange(params.startDate, params.endDate); // 1. 맞팔 관계 확인 const isMutualFriend = await this.followService.isMutualFriend( @@ -244,6 +265,7 @@ export class TodoService { id: number, userId: string, data: { completed: boolean }, + tz: string = "UTC", ): Promise { const todo = await this.todoRepository.findByIdAndUserId(id, userId); @@ -264,7 +286,7 @@ export class TodoService { // 완료로 변경된 경우, 오늘 할일 전체 완료 여부 확인 후 이벤트 발행 if (data.completed) { - await this.checkAndEmitAllCompletedEvent(userId); + await this.checkAndEmitAllCompletedEvent(userId, tz); } return TodoMapper.toResponse(updatedTodo); @@ -274,9 +296,13 @@ export class TodoService { * 오늘 할일 전체 완료 시 이벤트 발행 * @private */ - private async checkAndEmitAllCompletedEvent(userId: string): Promise { + private async checkAndEmitAllCompletedEvent( + userId: string, + tz: string = "UTC", + ): Promise { try { - const stats = await this.todoRepository.getTodayTodoStats(userId); + const today = getUserToday(tz); + const stats = await this.todoRepository.getTodayTodoStats(userId, today); // 오늘 할일이 있고, 모두 완료된 경우 if (stats.total > 0 && stats.total === stats.completed) { @@ -389,6 +415,7 @@ export class TodoService { scheduledTime?: string | null; isAllDay?: boolean; }, + tz: string = "UTC", ): Promise { const todo = await this.todoRepository.findByIdAndUserId(id, userId); @@ -397,10 +424,10 @@ export class TodoService { } const updatedTodo = await this.todoRepository.update(id, { - startDate: toDate(data.startDate), - endDate: data.endDate ? toDate(data.endDate) : null, + startDate: toDateOnly(data.startDate), + endDate: data.endDate ? toDateOnly(data.endDate) : null, scheduledTime: data.scheduledTime - ? toDate(`1970-01-01T${data.scheduledTime}:00Z`) + ? toScheduledTime(data.startDate, data.scheduledTime, tz) : null, isAllDay: data.isAllDay ?? true, }); diff --git a/apps/api/test/e2e/todo.e2e-spec.ts b/apps/api/test/e2e/todo.e2e-spec.ts index 56cc60c9..eb1f3699 100644 --- a/apps/api/test/e2e/todo.e2e-spec.ts +++ b/apps/api/test/e2e/todo.e2e-spec.ts @@ -293,6 +293,120 @@ describe("Todo (e2e)", () => { expect(response.body.data.items).toBeDefined(); }); + it("다중일 투두가 범위 필터에서 정상 노출된다", async () => { + // 다중일 투두 생성 (1/15 ~ 1/20) + await request(app.getHttpServer()) + .post("/todos") + .set("Authorization", `Bearer ${accessToken}`) + .send({ + title: "다중일 할 일", + categoryId, + startDate: "2024-01-15", + endDate: "2024-01-20", + }) + .expect(201); + + // 1/18로 필터 → 다중일 투두 포함 확인 + const response = await request(app.getHttpServer()) + .get("/todos") + .query({ + startDate: "2024-01-18", + endDate: "2024-01-18", + }) + .set("Authorization", `Bearer ${accessToken}`) + .expect(200); + + const titles = response.body.data.items.map( + (t: { title: string }) => t.title, + ); + expect(titles).toContain("다중일 할 일"); + }); + + it("특정 하루(2월 1일)만 조회할 수 있다", async () => { + await request(app.getHttpServer()) + .post("/todos") + .set("Authorization", `Bearer ${accessToken}`) + .send({ + title: "2월1일 단건", + categoryId, + startDate: "2024-02-01", + }) + .expect(201); + + await request(app.getHttpServer()) + .post("/todos") + .set("Authorization", `Bearer ${accessToken}`) + .send({ + title: "2월2일 단건", + categoryId, + startDate: "2024-02-02", + }) + .expect(201); + + const response = await request(app.getHttpServer()) + .get("/todos") + .query({ + startDate: "2024-02-01", + endDate: "2024-02-01", + }) + .set("Authorization", `Bearer ${accessToken}`) + .expect(200); + + const titles = response.body.data.items.map( + (t: { title: string }) => t.title, + ); + expect(titles).toContain("2월1일 단건"); + expect(titles).not.toContain("2월2일 단건"); + }); + + it("기간(2월 2일~2월 3일) 조회가 가능하다", async () => { + await request(app.getHttpServer()) + .post("/todos") + .set("Authorization", `Bearer ${accessToken}`) + .send({ + title: "2월2일 포함", + categoryId, + startDate: "2024-02-02", + }) + .expect(201); + + await request(app.getHttpServer()) + .post("/todos") + .set("Authorization", `Bearer ${accessToken}`) + .send({ + title: "2월3일 포함", + categoryId, + startDate: "2024-02-03", + }) + .expect(201); + + await request(app.getHttpServer()) + .post("/todos") + .set("Authorization", `Bearer ${accessToken}`) + .send({ + title: "2월4일 제외", + categoryId, + startDate: "2024-02-04", + }) + .expect(201); + + const response = await request(app.getHttpServer()) + .get("/todos") + .query({ + startDate: "2024-02-02", + endDate: "2024-02-03", + }) + .set("Authorization", `Bearer ${accessToken}`) + .expect(200); + + const titles = response.body.data.items.map( + (t: { title: string }) => t.title, + ); + expect(titles).toContain("2월2일 포함"); + expect(titles).toContain("2월3일 포함"); + expect(titles).not.toContain("2월4일 제외"); + }); + it("카테고리로 필터링", async () => { const response = await request(app.getHttpServer()) .get("/todos") diff --git a/apps/api/test/integration/todo.integration-spec.ts b/apps/api/test/integration/todo.integration-spec.ts index 69f612be..e599f8b0 100644 --- a/apps/api/test/integration/todo.integration-spec.ts +++ b/apps/api/test/integration/todo.integration-spec.ts @@ -377,10 +377,15 @@ describe("TodoService Integration Tests", () => { expect(mockDatabaseService.todo.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ - startDate: { - gte: startDate, - lte: endDate, - }, + AND: expect.arrayContaining([ + { startDate: { lte: endDate } }, + { + OR: [ + { endDate: { gte: startDate } }, + { endDate: null, startDate: { gte: startDate } }, + ], + }, + ]), }), }), ); @@ -1169,10 +1174,15 @@ describe("TodoService Integration Tests", () => { where: expect.objectContaining({ userId: mockFriendUserId, visibility: "PUBLIC", - startDate: { - gte: startDate, - lte: endDate, - }, + AND: expect.arrayContaining([ + { startDate: { lte: endDate } }, + { + OR: [ + { endDate: { gte: startDate } }, + { endDate: null, startDate: { gte: startDate } }, + ], + }, + ]), }), }), ); diff --git a/apps/mobile/.claude/testing-guide.md b/apps/mobile/.claude/testing-guide.md index b0966f3e..61ff5b91 100644 --- a/apps/mobile/.claude/testing-guide.md +++ b/apps/mobile/.claude/testing-guide.md @@ -2,252 +2,804 @@ ## 개요 -Context API 의존성 주입을 활용한 **Stub 기반 테스트** 전략입니다. +Ports & Adapters 아키텍처의 의존성 주입을 활용한 테스트 전략입니다. -- **모킹 최소화**: `jest.mock()` 대신 Stub 클래스 사용 -- **의존성 주입**: Context API를 통한 Repository 주입 -- **한국어 테스트 설명**: `describe`/`it` 블록은 모두 한국어로 작성 +- **mock 객체**: 생성자 주입을 활용하여 `jest.fn()` 기반 mock 객체로 의존성 교체 +- **순수 함수 테스트**: Mapper와 Policy는 mock 없이 직접 테스트 +- **테스트 데이터 팩토리**: 공통 테스트 데이터는 factory 함수로 생성 -## 아키텍처 계층 +> **왜 Stub이 아니라 Mock인가?** +> Stub은 정해진 결과를 반환할 뿐 호출 여부를 검증하지 않습니다 (결과 검증). +> Mock은 `toHaveBeenCalledWith` 등으로 **어떤 인자로 호출되었는지까지 검증**합니다 (상호작용 검증). +> 이 프로젝트에서는 "Policy 실패 시 Repository가 호출되지 않는다" 같은 상호작용 검증이 중요하므로 `jest.fn()` mock을 사용합니다. + +- **Result 타입**: `Result` 기반 성공/실패 검증 +- **한국어**: `describe`/`it` 블록은 모두 한국어로 작성 + +--- + +## 테스트 우선순위 + +| 우선순위 | 계층 | 테스트 대상 | mock 필요 | +|---------|------|------------|----------| +| 1 | Mapper | DTO → Domain 변환 | 없음 (순수 함수) | +| 2 | Policy | 비즈니스 규칙 검증 | 없음 (순수 함수) | +| 3 | Repository | API 응답 파싱, Zod 검증 | mock HttpClient | +| 4 | Service | 비즈니스 로직 조합, Policy 검증 | mock Repository | +| 5 | ErrorBoundary | InfraError → fallback UI 렌더링 | mock Service/Repository | + +--- + +## 1. 테스트 데이터 팩토리 + +테스트마다 인라인으로 데이터를 만들면 의도가 묻힙니다. Factory 함수를 사용하면 **테스트마다 달라지는 값만 명시**할 수 있어 의도가 명확해집니다. + +### 위치 ``` -UI Layer (Components/Screens) - └─ Custom Hooks 사용 (useAuth, useTodo 등) -Hook Layer (React Query + Context) - └─ Service를 주입받아 데이터 페칭 및 상태 관리 -Service Layer (비즈니스 로직) - └─ Repository를 주입받아 비즈니스 로직 수행 -Repository Layer (데이터 페칭) - └─ API 호출, 외부 의존성 캡슐화 -Model Layer (순수 도메인) - └─ 타입 정의, Zod 스키마, 도메인 로직 +shared/testing/factories/ +├── todo.factory.ts +├── friend.factory.ts +└── auth.factory.ts ``` -## 파일 구조 +### Todo 팩토리 예시 + +```typescript +// shared/testing/factories/todo.factory.ts +import type { Todo } from '@aido/validators'; +import type { TodoItem } from '@src/features/todo/models/todo.model'; + +/** DTO (서버 응답) 팩토리 */ +export const createTodoDto = (overrides?: Partial): Todo => ({ + id: 1, + title: '할일 제목', + category: { id: 1, name: '일상', color: '#FF9500' }, + completed: false, + scheduledTime: null, + isAllDay: true, + visibility: 'PUBLIC', + ...overrides, +}); +/** Domain 모델 팩토리 */ +export const createTodoItem = (overrides?: Partial): TodoItem => ({ + id: 1, + title: '할일 제목', + category: { id: 1, name: '일상', color: '#FF9500' }, + completed: false, + scheduledTime: null, + isAllDay: true, + visibility: 'PUBLIC', + ...overrides, +}); ``` -src/features/{feature}/ -├── models/ -│ └── {feature}.model.ts # 타입 및 Zod 스키마 -├── repositories/ -│ ├── {feature}.repository.ts # 인터페이스 정의 -│ ├── {feature}.repository.impl.ts # 실제 구현 -│ ├── {feature}.repository.stub.ts # 테스트용 Stub -│ └── {feature}.repository.spec.ts # Repository 테스트 -├── services/ -│ ├── {feature}.service.ts # 비즈니스 로직 -│ └── {feature}.service.spec.ts # Service 테스트 -├── hooks/ -│ └── use-{feature}.ts # Custom Hook -└── contexts/ - └── {feature}.context.tsx # Context Provider + +### 사용법 + +```typescript +// 기본값 — 의도가 없는 필드는 생략 +const dto = createTodoDto(); + +// 특정 필드만 오버라이드 — 테스트 의도가 명확 +const completedDto = createTodoDto({ completed: true }); +const withTimeDto = createTodoDto({ scheduledTime: '2024-06-01T09:00:00Z', isAllDay: false }); +const privateTodo = createTodoItem({ visibility: 'PRIVATE' }); ``` -## 테스트 우선순위 +--- + +## 2. Mapper 테스트 + +Mapper는 순수 함수입니다. 외부 의존성 없이 DTO → Domain 변환만 검증합니다. + +```typescript +// features/todo/repositories/todo.mapper.spec.ts +import { toTodoItem, toTodoItems } from './todo.mapper'; +import { createTodoDto } from '@src/shared/testing/factories/todo.factory'; + +describe('Todo Mapper', () => { + describe('toTodoItem', () => { + it('DTO의 scheduledTime 문자열을 Date 객체로 변환해야 한다', () => { + const dto = createTodoDto({ scheduledTime: '2024-06-01T09:00:00Z', isAllDay: false }); + + const result = toTodoItem(dto); + + expect(result.scheduledTime).toBeInstanceOf(Date); + expect(result.scheduledTime?.toISOString()).toBe('2024-06-01T09:00:00.000Z'); + }); + + it('scheduledTime이 null이면 null을 유지해야 한다', () => { + const dto = createTodoDto({ scheduledTime: null }); + + const result = toTodoItem(dto); + + expect(result.scheduledTime).toBeNull(); + }); -| 우선순위 | 계층 | 테스트 대상 | 이유 | -|---------|------|------------|------| -| 1 | Repository | API 응답 파싱, Zod 검증 | 데이터 무결성의 첫 관문 | -| 2 | Service | 비즈니스 로직 | 핵심 로직, Stub으로 쉽게 테스트 | -| 3 | Model | 복잡한 도메인 로직 | 순수 함수 테스트 | -| 4 | Hook | 복잡한 상태 로직 | UI 통합 전 검증 | + it('모든 필드를 올바르게 매핑해야 한다', () => { + const dto = createTodoDto(); -## Repository 테스트 패턴 + const result = toTodoItem(dto); -Repository는 **API 응답을 올바르게 파싱하는지** 검증합니다. + expect(result).toEqual({ + id: 1, + title: '할일 제목', + category: { id: 1, name: '일상', color: '#FF9500' }, + completed: false, + scheduledTime: null, + isAllDay: true, + visibility: 'PUBLIC', + }); + }); + }); + + describe('toTodoItems', () => { + it('DTO 배열을 Domain 모델 배열로 변환해야 한다', () => { + const dtos = [createTodoDto(), createTodoDto({ id: 2 })]; + + const result = toTodoItems(dtos); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe(1); + expect(result[1].id).toBe(2); + }); + }); +}); +``` + +--- + +## 3. Policy 테스트 + +Policy는 도메인 비즈니스 규칙을 순수 함수로 검증합니다. ```typescript -// {feature}.repository.spec.ts +// features/todo/models/todo.model.spec.ts +import { TodoPolicy } from './todo.model'; +import { createTodoItem } from '@src/shared/testing/factories/todo.factory'; + +describe('TodoPolicy', () => { + describe('getColor', () => { + it('카테고리의 색상을 반환해야 한다', () => { + const todo = createTodoItem(); + + expect(TodoPolicy.getColor(todo)).toBe('#FF9500'); + }); + }); + + describe('isPublic', () => { + it('visibility가 PUBLIC이면 true를 반환해야 한다', () => { + const todo = createTodoItem({ visibility: 'PUBLIC' }); + + expect(TodoPolicy.isPublic(todo)).toBe(true); + }); + + it('visibility가 PRIVATE이면 false를 반환해야 한다', () => { + const todo = createTodoItem({ visibility: 'PRIVATE' }); + + expect(TodoPolicy.isPublic(todo)).toBe(false); + }); + }); +}); +``` + +--- + +## 4. Mock 객체 생성 방법 + +모든 의존성은 생성자 주입을 사용하므로, 인터페이스를 구현하는 `jest.fn()` mock 객체를 만들어 주입합니다. + +### HttpClient mock + +```typescript +import type { HttpClient } from '@src/core/ports/http'; + +const createMockHttpClient = (): jest.Mocked => ({ + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), +}); +``` + +### Repository mock 예시 + +```typescript +import type { TodoRepository } from './todo.repository'; + +const createMockTodoRepository = (): jest.Mocked => ({ + getTodos: jest.fn(), + toggleTodoComplete: jest.fn(), + createTodo: jest.fn(), + parseTodo: jest.fn(), + getAiUsage: jest.fn(), +}); +``` + +```typescript +import type { FriendRepository } from './friend.repository'; + +const createMockFriendRepository = (): jest.Mocked => ({ + sendRequest: jest.fn(), + getReceivedRequests: jest.fn(), + getSentRequests: jest.fn(), + acceptRequest: jest.fn(), + rejectRequest: jest.fn(), + cancelRequest: jest.fn(), + getFriends: jest.fn(), + removeFriend: jest.fn(), +}); +``` + +```typescript +import type { AuthRepository } from './auth.repository'; + +const createMockAuthRepository = (): jest.Mocked => ({ + exchangeCode: jest.fn(), + emailLogin: jest.fn(), + appleLogin: jest.fn(), + logout: jest.fn(), + getCurrentUser: jest.fn(), + getPreference: jest.fn(), + updatePreference: jest.fn(), + getConsent: jest.fn(), + updateMarketingConsent: jest.fn(), + register: jest.fn(), + verifyEmail: jest.fn(), + resendVerification: jest.fn(), + getKakaoAuthUrl: jest.fn(), + getNaverAuthUrl: jest.fn(), + getGoogleAuthUrl: jest.fn(), +}); +``` + +```typescript +import type { NotificationRepository } from './notification.repository'; + +const createMockNotificationRepository = (): jest.Mocked => ({ + registerToken: jest.fn(), + unregisterToken: jest.fn(), + getNotifications: jest.fn(), + getUnreadCount: jest.fn(), + markAsRead: jest.fn(), + markAllAsRead: jest.fn(), +}); +``` + +> mock 팩토리 함수는 테스트 파일 상단에 정의하거나, 필요하면 `src/shared/testing/` 하위에 공유 파일로 추출합니다. + +--- + +## 5. Repository 테스트 + +Repository는 mock HttpClient를 주입하여 **API 응답 파싱, Zod 검증, Result 반환**을 검증합니다. + +```typescript +// features/todo/repositories/todo.repository.spec.ts +import type { TodoListResponse } from '@aido/validators'; +import type { HttpClient } from '@src/core/ports/http'; +import { ServerError, ParseError } from '@src/shared/errors/infra-error'; +import { ok, err } from '@src/shared/errors/result'; +import { ApiError } from '@src/shared/errors/api-error'; +import { createTodoDto } from '@src/shared/testing/factories/todo.factory'; + import { TodoRepositoryImpl } from './todo.repository.impl'; -import { ApiClientStub } from '@src/test-utils/api-client.stub'; + +const createMockHttpClient = (): jest.Mocked => ({ + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), +}); describe('TodoRepositoryImpl', () => { - let apiClientStub: ApiClientStub; + let httpClient: jest.Mocked; let repository: TodoRepositoryImpl; beforeEach(() => { - apiClientStub = new ApiClientStub(); - repository = new TodoRepositoryImpl(apiClientStub); + httpClient = createMockHttpClient(); + repository = new TodoRepositoryImpl(httpClient); }); - describe('getAll', () => { - it('API 응답을 Todo 모델 배열로 올바르게 변환해야 한다', async () => { - // Given - 서버 API 응답 형태 - const apiResponse = [ - { - id: '1', - title: '할일 1', - completed: false, - created_at: '2024-01-01T00:00:00Z', - }, - ]; - apiClientStub.setResponse('/todos', apiResponse); + describe('getTodos', () => { + const validResponse: TodoListResponse = { + items: [createTodoDto()], + pagination: { hasNext: false, nextCursor: null }, + }; + + it('API 성공 응답을 Zod로 검증하고 ok Result를 반환해야 한다', async () => { + // Given + httpClient.get.mockResolvedValue(ok(validResponse)); // When - const result = await repository.getAll(); + const result = await repository.getTodos({ size: 10 }); // Then - expect(apiClientStub.getCalled).toContain('/todos'); - expect(result).toHaveLength(1); - expect(result[0].createdAt).toBeInstanceOf(Date); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.items).toHaveLength(1); + expect(result.value.items[0].title).toBe('할일 제목'); + } + expect(httpClient.get).toHaveBeenCalledWith('v1/todos', { params: { size: 10 } }); }); - it('API 에러 시 예외를 전파해야 한다', async () => { + it('4xx API 에러 시 err Result를 그대로 반환해야 한다', async () => { // Given - apiClientStub.setError('/todos', new Error('Network Error')); + httpClient.get.mockResolvedValue( + err(new ApiError('TODO_0801', '할 일을 찾을 수 없어요', 404)), + ); + + // When + const result = await repository.getTodos({ size: 10 }); + + // Then + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('TODO_0801'); + expect(result.error.status).toBe(404); + } + }); + + it('Zod 검증 실패 시 ParseError를 throw해야 한다', async () => { + // Given — 스키마에 맞지 않는 응답 + httpClient.get.mockResolvedValue(ok({ items: [{ id: 'not-a-number' }], pagination: {} })); // When & Then - await expect(repository.getAll()).rejects.toThrow('Network Error'); + await expect(repository.getTodos({ size: 10 })).rejects.toThrow(ParseError); }); - }); - describe('Zod 스키마 검증', () => { - it('잘못된 API 응답 형식에 대해 검증 에러를 발생시켜야 한다', async () => { - // Given - 필수 필드 누락 - const invalidResponse = [{ id: '1' }]; // title 누락 - apiClientStub.setResponse('/todos', invalidResponse); + it('HttpClient가 throw하면 그대로 전파해야 한다', async () => { + // Given — 5xx 서버 에러 + httpClient.get.mockRejectedValue(new ServerError(500)); // When & Then - await expect(repository.getAll()).rejects.toThrow(); + await expect(repository.getTodos({ size: 10 })).rejects.toThrow(ServerError); }); }); }); ``` -## Repository Stub 패턴 +--- -Service 테스트에서 사용할 Repository Stub입니다. +## 6. Service 테스트 -```typescript -// {feature}.repository.stub.ts -import type { TodoRepository } from './todo.repository'; -import type { CreateTodoInput, Todo } from '../models/todo.model'; - -export class TodoRepositoryStub implements TodoRepository { - private todos: Todo[] = []; - - // 호출 추적용 플래그 - public getAllCalled = false; - public createCalled = false; - public lastCreateInput: CreateTodoInput | null = null; - - async getAll(): Promise { - this.getAllCalled = true; - return this.todos; - } - - async create(input: CreateTodoInput): Promise { - this.createCalled = true; - this.lastCreateInput = input; - const newTodo: Todo = { - id: String(this.todos.length + 1), - ...input, - completed: false, - createdAt: new Date(), - }; - this.todos.push(newTodo); - return newTodo; - } - - // 테스트 헬퍼 - setFakeTodos(todos: Todo[]) { - this.todos = todos; - } - - reset() { - this.todos = []; - this.getAllCalled = false; - this.createCalled = false; - this.lastCreateInput = null; - } -} -``` +Service는 mock Repository를 주입하여 **비즈니스 로직, Policy 검증, Result 전파**를 검증합니다. -## Service 테스트 패턴 +### 기본 패턴: Result 전파 ```typescript -// {feature}.service.spec.ts +// features/todo/services/todo.service.spec.ts +import type { TodosResult } from '../models/todo.model'; +import type { TodoRepository } from '../repositories/todo.repository'; +import { ApiError } from '@src/shared/errors/api-error'; +import { ok, err } from '@src/shared/errors/result'; +import { createTodoItem } from '@src/shared/testing/factories/todo.factory'; + import { TodoService } from './todo.service'; -import { TodoRepositoryStub } from '../repositories/todo.repository.stub'; + +const createMockTodoRepository = (): jest.Mocked => ({ + getTodos: jest.fn(), + toggleTodoComplete: jest.fn(), + createTodo: jest.fn(), + parseTodo: jest.fn(), + getAiUsage: jest.fn(), +}); describe('TodoService', () => { - let todoRepositoryStub: TodoRepositoryStub; - let todoService: TodoService; + let repository: jest.Mocked; + let service: TodoService; beforeEach(() => { - todoRepositoryStub = new TodoRepositoryStub(); - todoService = new TodoService(todoRepositoryStub); + repository = createMockTodoRepository(); + service = new TodoService(repository); }); - describe('getAllTodos', () => { - it('모든 할일 목록을 반환해야 한다', async () => { + describe('getTodos', () => { + it('Repository 성공 시 ok Result를 반환해야 한다', async () => { + // Given + const todosResult: TodosResult = { + todos: [createTodoItem({ scheduledTime: new Date('2024-06-01T09:00:00Z'), isAllDay: false })], + hasNext: false, + nextCursor: null, + }; + repository.getTodos.mockResolvedValue(ok(todosResult)); + + // When + const result = await service.getTodos({ size: 10 }); + + // Then + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.todos).toHaveLength(1); + expect(result.value.hasNext).toBe(false); + } + expect(repository.getTodos).toHaveBeenCalledWith({ size: 10 }); + }); + + it('Repository가 에러를 반환하면 그대로 전파해야 한다', async () => { // Given - const fakeTodos = [ - { id: '1', title: '첫번째 할일', completed: false, createdAt: new Date() }, - ]; - todoRepositoryStub.setFakeTodos(fakeTodos); + repository.getTodos.mockResolvedValue( + err(new ApiError('TODO_0801', '할 일을 찾을 수 없어요', 404)), + ); // When - const result = await todoService.getAllTodos(); + const result = await service.getTodos({ size: 10 }); // Then - expect(todoRepositoryStub.getAllCalled).toBe(true); - expect(result).toEqual(fakeTodos); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('TODO_0801'); + } }); }); +}); +``` + +### Policy 검증 패턴 (FriendService) - describe('createTodo', () => { - it('새로운 할일을 생성해야 한다', async () => { +```typescript +// features/friend/services/friend.service.spec.ts +import type { FriendRepository } from '../repositories/friend.repository'; +import { ok } from '@src/shared/errors/result'; + +import { FriendService } from './friend.service'; + +const createMockFriendRepository = (): jest.Mocked => ({ + sendRequest: jest.fn(), + getReceivedRequests: jest.fn(), + getSentRequests: jest.fn(), + acceptRequest: jest.fn(), + rejectRequest: jest.fn(), + cancelRequest: jest.fn(), + getFriends: jest.fn(), + removeFriend: jest.fn(), +}); + +describe('FriendService', () => { + let repository: jest.Mocked; + let service: FriendService; + + beforeEach(() => { + repository = createMockFriendRepository(); + service = new FriendService(repository); + }); + + describe('sendRequestByTag', () => { + it('빈 태그로 요청 시 FRIEND_EMPTY_TAG 에러를 반환해야 한다', async () => { + const result = await service.sendRequestByTag(' '); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('FRIEND_EMPTY_TAG'); + } + // Repository가 호출되지 않아야 한다 + expect(repository.sendRequest).not.toHaveBeenCalled(); + }); + + it('잘못된 태그 형식으로 요청 시 FRIEND_INVALID_TAG 에러를 반환해야 한다', async () => { + const result = await service.sendRequestByTag('잘못된태그!!'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('FRIEND_INVALID_TAG'); + } + expect(repository.sendRequest).not.toHaveBeenCalled(); + }); + + it('유효한 태그로 요청 시 Repository를 호출해야 한다', async () => { // Given - const input = { title: '새 할일' }; + repository.sendRequest.mockResolvedValue(ok({ userId: 'user-1' })); // When - const result = await todoService.createTodo(input); + const result = await service.sendRequestByTag('#1234'); // Then - expect(todoRepositoryStub.createCalled).toBe(true); - expect(todoRepositoryStub.lastCreateInput).toEqual(input); - expect(result.title).toBe('새 할일'); + expect(repository.sendRequest).toHaveBeenCalledWith('#1234'); + expect(result.ok).toBe(true); }); + }); +}); +``` + +### AuthService 테스트 (Storage mock 포함) + +AuthService는 Repository와 Storage 두 의존성을 주입받습니다: + +```typescript +// features/auth/services/auth.service.spec.ts +import type { AuthRepository } from '../repositories/auth.repository'; +import type { Storage } from '@src/core/ports/storage'; +import { ok, err } from '@src/shared/errors/result'; +import { ApiError } from '@src/shared/errors/api-error'; + +import { AuthService } from './auth.service'; + +const createMockAuthRepository = (): jest.Mocked => ({ + exchangeCode: jest.fn(), + emailLogin: jest.fn(), + appleLogin: jest.fn(), + logout: jest.fn(), + getCurrentUser: jest.fn(), + getPreference: jest.fn(), + updatePreference: jest.fn(), + getConsent: jest.fn(), + updateMarketingConsent: jest.fn(), + register: jest.fn(), + verifyEmail: jest.fn(), + resendVerification: jest.fn(), + getKakaoAuthUrl: jest.fn(), + getNaverAuthUrl: jest.fn(), + getGoogleAuthUrl: jest.fn(), +}); + +const createMockStorage = (): jest.Mocked => ({ + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), +}); - it('빈 제목으로 생성 시 에러를 발생시켜야 한다', async () => { +describe('AuthService', () => { + let authRepository: jest.Mocked; + let storage: jest.Mocked; + let service: AuthService; + + beforeEach(() => { + authRepository = createMockAuthRepository(); + storage = createMockStorage(); + service = new AuthService(authRepository, storage); + }); + + describe('emailLogin', () => { + it('로그인 성공 시 토큰을 저장하고 ok Result를 반환해야 한다', async () => { // Given - const input = { title: '' }; + const tokens = { accessToken: 'access', refreshToken: 'refresh' }; + authRepository.emailLogin.mockResolvedValue(ok(tokens)); - // When & Then - await expect(todoService.createTodo(input)).rejects.toThrow('제목은 필수입니다'); + // When + const result = await service.emailLogin('test@example.com', 'password'); + + // Then + expect(result.ok).toBe(true); + expect(storage.set).toHaveBeenCalledWith('accessToken', 'access'); + expect(storage.set).toHaveBeenCalledWith('refreshToken', 'refresh'); + }); + + it('로그인 실패 시 토큰을 저장하지 않고 에러를 전파해야 한다', async () => { + // Given + authRepository.emailLogin.mockResolvedValue( + err(new ApiError('AUTH_0401', '이메일 또는 비밀번호가 틀렸어요', 401)), + ); + + // When + const result = await service.emailLogin('test@example.com', 'wrong'); + + // Then + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('AUTH_0401'); + } + expect(storage.set).not.toHaveBeenCalled(); + }); + }); + + describe('logout', () => { + it('성공/실패 관계없이 로컬 토큰을 삭제해야 한다', async () => { + // Given + authRepository.logout.mockResolvedValue(ok(undefined)); + + // When + await service.logout(); + + // Then + expect(storage.remove).toHaveBeenCalledWith('accessToken'); + expect(storage.remove).toHaveBeenCalledWith('refreshToken'); + }); + }); +}); +``` + +--- + +## 7. ErrorBoundary 통합 테스트 + +``가 Repository/Service에서 throw된 InfraError를 잡아서 fallback UI를 렌더링하는지 검증합니다. + +### 테스트 대상 + +Repository에서 `ParseError`/`ServerError`가 throw되면 → TanStack Query가 error를 전파 → ``가 catch → fallback UI 렌더링 + +### 테스트 유틸: throwError 컴포넌트 + +```typescript +// shared/testing/utils/throw-error.tsx +import { useSuspenseQuery } from '@tanstack/react-query'; + +/** queryFn에서 에러를 throw하여 ErrorBoundary를 트리거하는 컴포넌트 */ +export function ThrowError({ error }: { error: Error }) { + useSuspenseQuery({ + queryKey: ['test-error'], + queryFn: () => { throw error; }, + retry: false, + }); + return null; +} +``` + +### 기본 패턴: InfraError → fallback 렌더링 + +```typescript +// shared/ui/QueryErrorBoundary/QueryErrorBoundary.spec.tsx +import { renderWithClient } from '@src/shared/testing/utils/render-with-client'; +import { screen, waitFor } from '@testing-library/react-native'; +import { ServerError, ParseError } from '@src/shared/errors/infra-error'; + +import { QueryErrorBoundary } from './QueryErrorBoundary'; +import { ThrowError } from '@src/shared/testing/utils/throw-error'; + +describe('QueryErrorBoundary', () => { + it('ServerError 발생 시 기본 fallback UI를 렌더링해야 한다', async () => { + renderWithClient( + + + , + ); + + await waitFor(() => { + expect(screen.getByText('오류가 발생했어요')).toBeTruthy(); + expect(screen.getByText('재시도')).toBeTruthy(); }); }); + + it('ParseError 발생 시 기본 fallback UI를 렌더링해야 한다', async () => { + renderWithClient( + + + , + ); + + await waitFor(() => { + expect(screen.getByText('오류가 발생했어요')).toBeTruthy(); + }); + }); + + it('커스텀 fallback을 전달하면 해당 UI를 렌더링해야 한다', async () => { + renderWithClient( + 커스텀 에러} + > + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('custom-fallback')).toBeTruthy(); + }); + }); +}); +``` + +### 재시도 흐름 테스트 + +```typescript +it('재시도 버튼을 누르면 쿼리가 다시 실행되어야 한다', async () => { + const queryFn = jest.fn() + .mockRejectedValueOnce(new ServerError(500)) // 첫 번째: 실패 + .mockResolvedValueOnce({ data: 'success' }); // 두 번째: 성공 + + renderWithClient( + + + , + ); + + // fallback 렌더링 대기 + await waitFor(() => { + expect(screen.getByText('오류가 발생했어요')).toBeTruthy(); + }); + + // 재시도 + fireEvent.press(screen.getByText('재시도')); + + // 성공 UI 렌더링 대기 + await waitFor(() => { + expect(screen.getByText('success')).toBeTruthy(); + }); + expect(queryFn).toHaveBeenCalledTimes(2); }); ``` +### renderWithClient 유틸 + +ErrorBoundary 테스트에는 실제 `QueryClient`가 필요합니다: + +```typescript +// shared/testing/utils/render-with-client.tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render } from '@testing-library/react-native'; +import type { ReactNode } from 'react'; + +export function renderWithClient(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + {ui} + , + ); +} +``` + +--- + ## 테스트 작성 규칙 -### 네이밍 컨벤션 +### 파일 네이밍 -- 테스트 파일: `{name}.spec.ts` 또는 `{name}.test.ts` -- Stub 파일: `{name}.stub.ts` -- 테스트 설명: **한국어로 작성** +| 파일 유형 | 패턴 | 예시 | +|----------|------|------| +| Mapper 테스트 | `{feature}.mapper.spec.ts` | `todo.mapper.spec.ts` | +| Policy 테스트 | `{feature}.model.spec.ts` | `todo.model.spec.ts` | +| Repository 테스트 | `{feature}.repository.spec.ts` | `todo.repository.spec.ts` | +| Service 테스트 | `{feature}.service.spec.ts` | `todo.service.spec.ts` | +| ErrorBoundary 테스트 | `QueryErrorBoundary.spec.tsx` | — | ### 테스트 구조 (Given-When-Then) ```typescript it('특정 조건에서 기대하는 결과가 나와야 한다', async () => { - // Given - 테스트 데이터 및 상태 설정 - - // When - 테스트 대상 실행 - - // Then - 결과 검증 + // Given — 테스트 데이터 및 mock 설정 + repository.getTodos.mockResolvedValue(ok(data)); + + // When — 테스트 대상 실행 + const result = await service.getTodos({ size: 10 }); + + // Then — 결과 검증 + expect(result.ok).toBe(true); }); ``` -### Stub 설계 원칙 +### Result 타입 검증 패턴 + +```typescript +// 성공 검증 +expect(result.ok).toBe(true); +if (result.ok) { + expect(result.value).toEqual(expected); +} + +// 실패 검증 +expect(result.ok).toBe(false); +if (!result.ok) { + expect(result.error.code).toBe('ERROR_CODE'); +} +``` + +### mock 설정 패턴 + +```typescript +// 성공 응답 +repository.getTodos.mockResolvedValue(ok(data)); + +// 에러 응답 +repository.getTodos.mockResolvedValue(err(new ApiError('CODE', '메시지', 404))); + +// throw (InfraError 시뮬레이션) +httpClient.get.mockRejectedValue(new ServerError(500)); + +// 한 번만 다른 응답 +repository.getTodos.mockResolvedValueOnce(ok(firstData)); +repository.getTodos.mockResolvedValueOnce(ok(secondData)); +``` -1. **인터페이스 구현**: Repository/Client 인터페이스를 완전히 구현 -2. **호출 추적**: 메서드 호출 여부 및 인자 확인용 필드 -3. **데이터 조작**: `setResponse()`, `setFake*()` 헬퍼로 테스트 데이터 설정 -4. **에러 시뮬레이션**: `setError()` 헬퍼로 에러 케이스 테스트 -5. **초기화**: `reset()` 메서드로 상태 초기화 +--- ## 테스트 실행 @@ -258,20 +810,69 @@ pnpm --filter @aido/mobile test # 특정 파일 테스트 pnpm --filter @aido/mobile test -- todo.service.spec.ts +# 특정 패턴 테스트 +pnpm --filter @aido/mobile test -- --testPathPattern=mapper + # 커버리지 확인 pnpm --filter @aido/mobile test -- --coverage ``` +--- + ## 체크리스트 -- [ ] Repository 테스트가 API 응답 파싱을 검증 -- [ ] Repository 테스트가 Zod 스키마 검증을 확인 -- [ ] Service 테스트가 Stub을 통해 Repository 호출 검증 +### Mapper 테스트 + +- [ ] DTO → Domain 변환이 올바른지 검증 +- [ ] 날짜 문자열 → Date 변환 확인 +- [ ] nullable 필드 처리 확인 + +### Policy 테스트 + +- [ ] 비즈니스 규칙 경계값 테스트 +- [ ] 유효/무효 입력 모두 검증 + +### Repository 테스트 + +- [ ] mock HttpClient로 성공 응답 → ok Result 반환 검증 +- [ ] mock HttpClient로 4xx 에러 → err Result 전파 검증 +- [ ] Zod 검증 실패 시 ParseError throw 확인 +- [ ] InfraError throw 전파 확인 + +### Service 테스트 + +- [ ] mock Repository로 호출 검증 +- [ ] ok Result 반환 시 데이터 변환 검증 +- [ ] err Result 전파 검증 +- [ ] Policy 검증 로직이 err Result 반환하는지 확인 +- [ ] Policy 실패 시 Repository가 호출되지 않는지 확인 + +### ErrorBoundary 통합 테스트 + +- [ ] InfraError throw 시 fallback UI 렌더링 확인 +- [ ] 기본 fallback 텍스트 ("오류가 발생했어요", "재시도") 확인 +- [ ] 커스텀 fallback 전달 시 해당 UI 렌더링 확인 +- [ ] 재시도 버튼으로 쿼리 재실행 검증 + +### 공통 + - [ ] 테스트 설명이 한국어로 작성됨 - [ ] Given-When-Then 구조 준수 -- [ ] 에러 케이스 테스트 포함 +- [ ] 에러 케이스 포함 +- [ ] 팩토리 함수로 테스트 데이터 생성 (인라인 객체 지양) + +--- ## 참고 파일 -- `src/features/auth/repositories/auth.repository.stub.ts` - Stub 예시 -- `src/features/auth/services/auth.service.spec.ts` - Service 테스트 예시 +| 파일 | 설명 | +|------|------| +| `src/core/ports/http.ts` | HttpClient 인터페이스 | +| `src/shared/errors/result.ts` | Result 타입, ok/err/unwrap | +| `src/shared/errors/api-error.ts` | ApiError (4xx) | +| `src/shared/errors/infra-error.ts` | InfraError (5xx, 네트워크, 파싱) | +| `src/shared/ui/QueryErrorBoundary/` | ErrorBoundary 컴포넌트 | +| `src/shared/testing/factories/` | 테스트 데이터 팩토리 | +| `src/shared/testing/utils/` | 테스트 유틸 (renderWithClient 등) | +| `src/features/*/repositories/*.repository.ts` | Repository 인터페이스 | +| `src/features/*/services/*.service.ts` | Service 구현 | diff --git a/apps/mobile/CLAUDE.md b/apps/mobile/CLAUDE.md index 6fa18a11..8f45e91d 100644 --- a/apps/mobile/CLAUDE.md +++ b/apps/mobile/CLAUDE.md @@ -27,14 +27,15 @@ Expo 기반 React Native 모바일 앱. Feature-based Layered Architecture + Por │ └── presentations/ ← 컴포넌트, React Query 훅 │ ├─────────────────────────────────────────────────────────────┤ │ 🔧 Application Layer │ -│ └── services/ ← 비즈니스 로직 조합 + DTO 변환 │ +│ └── services/ ← 클라이언트 비즈니스 로직 (Policy 검증, 부수효과 조합) │ ├─────────────────────────────────────────────────────────────┤ │ 📦 Domain Layer │ │ └── models/ ← 도메인 모델 + Zod 스키마 + Policy │ ├─────────────────────────────────────────────────────────────┤ │ 🔌 Infrastructure Layer │ │ ├── repositories/ ← Repository 인터페이스 + 구현체 │ -│ └── shared/infra/ ← HTTP 클라이언트, Storage 구현 │ +│ ├── shared/infra/ ← HTTP 클라이언트, Storage 구현 │ +│ └── shared/types/ ← 공통 타입 (Page 등) │ ├─────────────────────────────────────────────────────────────┤ │ 🎯 Core Layer │ │ └── core/ports/ ← 외부 의존성 추상화 인터페이스 │ @@ -52,13 +53,13 @@ Expo 기반 React Native 모바일 앱. Feature-based Layered Architecture + Por features/{feature}/ ├── models/ │ ├── {feature}.model.ts # Zod 스키마 + 타입 + Policy -│ └── {feature}.error.ts # ClientError 클래스 +│ └── {feature}.error.ts # {Feature}Error 클래스 (BusinessError 구현) ├── repositories/ -│ ├── {feature}.repository.ts # 인터페이스 -│ └── {feature}.repository.impl.ts # 구현체 +│ ├── {feature}.mapper.ts # DTO → Domain 변환 +│ ├── {feature}.repository.ts # 인터페이스 (도메인 타입 반환) +│ └── {feature}.repository.impl.ts # 구현체 (Zod 검증 + mapper 호출) ├── services/ -│ ├── {feature}.service.ts # 비즈니스 로직 -│ └── {feature}.mapper.ts # DTO → Domain 변환 +│ └── {feature}.service.ts # 비즈니스 로직 (Policy 검증) └── presentations/ ├── constants/ │ └── {feature}-query-keys.constant.ts @@ -73,7 +74,24 @@ features/{feature}/ ## 레이어별 패턴 -### 1. Model (도메인 모델 + Policy) +### 책임 요약 + +| 레이어 | 책임 | 아는 것 | 모르는 것 | +|--------|------|--------|----------| +| Model | 도메인 타입 + Policy + Error 정의 | Zod | 다른 모든 레이어 | +| Mapper | DTO → Domain 변환 | validators DTO, 도메인 타입 | HTTP, Service | +| Repository (인터페이스) | 데이터 접근 계약 | 도메인 타입 | HTTP, DTO | +| Repository (구현체) | HTTP 통신 + Zod 검증 + mapper 호출 | DTO, HttpClient, mapper | Service, UI | +| Service | 클라이언트 비즈니스 로직 | Policy, Repository 인터페이스 | HTTP, DTO, ErrorCode | +| Mutation/Query | 에러별 UI 반응, 캐시 관리 | Service, ErrorCode, {Feature}Error | HTTP, DTO | + +### 1. Model (클라이언트 도메인 모델) + +서버 DTO(`@aido/validators`)와 **독립적인 클라이언트 전용 타입**을 정의한다. +서버 응답 구조가 변경되어도 Mapper만 수정하면 되고, 앱 전체에 영향이 퍼지지 않는다. + +- **Zod 스키마**: 도메인 타입 정의 (서버 DTO 스키마와 별개) +- **Policy**: 클라이언트 비즈니스 규칙 (Service에서 사용) ```typescript // models/{feature}.model.ts @@ -93,35 +111,100 @@ export const {Feature}Policy = { } as const; ``` -### 2. Error (클라이언트 에러) +```typescript +// 실제 예시: FriendPolicy — 서버 호출 전 태그 형식 검증 +export const FriendPolicy = { + isValidTag(tag: string): boolean { + return /^#\d{4}$/.test(tag); + }, +} as const; +``` + +### 2. Error (클라이언트 비즈니스 에러) + +**서버 에러(ApiError)와 다르다.** Policy 검증 실패 시 Service가 생성하는 **클라이언트 전용 에러**다. +서버에 요청을 보내기 전에 사전에 잘못된 입력을 차단하는 역할. + +예시: +- `FriendError.EMPTY_TAG` — 빈 태그로 친구 요청 시도 → 서버 호출 전 차단 +- `FriendError.INVALID_TAG` — 형식이 틀린 태그 → 서버 호출 전 차단 ```typescript // models/{feature}.error.ts -import { ClientError } from '@src/shared/infra/errors/client-error'; +import type { BusinessError } from '@src/shared/errors'; + +export const {Feature}ErrorCode = { + INVALID_INPUT: '{FEATURE}_INVALID_INPUT', +} as const; + +export type {Feature}ErrorCode = (typeof {Feature}ErrorCode)[keyof typeof {Feature}ErrorCode]; -export type {Feature}ErrorReason = 'INVALID_INPUT' | 'NOT_FOUND'; +export class {Feature}Error extends Error implements BusinessError { + override readonly name = '{Feature}Error'; -export class {Feature}ClientError extends ClientError<{Feature}ErrorReason> { - static invalidInput() { - return new {Feature}ClientError('INVALID_INPUT', '입력값이 올바르지 않습니다'); + constructor( + public readonly code: {Feature}ErrorCode, + message: string, + ) { + super(message); } } + +export const {Feature}Errors = { + invalidInput: () => + new {Feature}Error({Feature}ErrorCode.INVALID_INPUT, '입력값이 올바르지 않아요'), +} as const; + +export const is{Feature}Error = (error: unknown): error is {Feature}Error => + error instanceof {Feature}Error; ``` -### 3. Repository (인터페이스 + 구현체) +### 3. Mapper (DTO → Domain 변환, repositories/ 하위) ```typescript -// repositories/{feature}.repository.ts +// repositories/{feature}.mapper.ts +import type { {Feature}DTO } from '@aido/validators'; +import type { {Feature} } from '../models/{feature}.model'; + +export const to{Feature} = (dto: {Feature}DTO): {Feature} => ({ + id: dto.id, + // ... 필드 매핑 + createdAt: new Date(dto.createdAt), // 문자열 → Date 변환 +}); +``` + +**네이밍 규칙:** + +| 함수명 | 용도 | 예시 | +|--------|------|------| +| `to{Entity}` | 단일 DTO → Domain | `toTodoItem(dto)` | +| `to{Entity}s` | 배열 DTO → Domain[] | `toTodoItems(dtos)` | +| `to{Entity}Page` | 페이지네이션 응답 → `Page` | `toFriendsPage(dto)` | + +### 4. Repository (인터페이스 + 구현체) + +```typescript +// repositories/{feature}.repository.ts — 도메인 타입 반환 +import type { ApiError } from '@src/shared/errors/api-error'; +import type { Result } from '@src/shared/errors/result'; +import type { {Feature} } from '../models/{feature}.model'; + export interface {Feature}Repository { - getById(id: string): Promise<{Feature}DTO>; - create(input: Create{Feature}Input): Promise<{Feature}DTO>; + getById(id: string): Promise>; } ``` ```typescript -// repositories/{feature}.repository.impl.ts +// repositories/{feature}.repository.impl.ts — Zod 검증 + mapper 호출 +import { type {Feature}DTO, {feature}DtoSchema } from '@aido/validators'; import type { HttpClient } from '@src/core/ports/http'; -import { {feature}Schema } from '@aido/validators'; +import type { ApiError } from '@src/shared/errors/api-error'; +import { ParseError } from '@src/shared/errors/infra-error'; +import { ok, type Result } from '@src/shared/errors/result'; + +import type { {Feature} } from '../models/{feature}.model'; +import { to{Feature} } from './{feature}.mapper'; +import type { {Feature}Repository } from './{feature}.repository'; export class {Feature}RepositoryImpl implements {Feature}Repository { readonly #httpClient: HttpClient; @@ -130,27 +213,37 @@ export class {Feature}RepositoryImpl implements {Feature}Repository { this.#httpClient = httpClient; } - async getById(id: string): Promise<{Feature}DTO> { - const { data } = await this.#httpClient.get<{Feature}DTO>(`v1/{feature}s/${id}`); + async getById(id: string): Promise> { + const result = await this.#httpClient.get<{Feature}DTO>(`v1/{feature}s/${id}`); + if (!result.ok) { + return result; + } - const result = {feature}Schema.safeParse(data); - if (!result.success) { - throw new Error('Invalid API response format'); + const parsed = {feature}DtoSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[{Feature}Repository] Invalid response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(to{Feature}(parsed.data)); } } ``` -### 4. Service (비즈니스 로직) +### 5. Service (클라이언트 비즈니스 로직 — 서버 에러 번역 X, pass-through + Policy 검증) + +**규칙**: Service는 서버 에러(ApiError)를 번역하거나 변환하지 않는다. 그대로 pass-through한다. ```typescript // services/{feature}.service.ts -import { {Feature}ClientError } from '../models/{feature}.error'; -import { {Feature}Policy } from '../models/{feature}.model'; +import type { ApiError } from '@src/shared/errors/api-error'; +import { err, type Result } from '@src/shared/errors/result'; + +import { type {Feature}Error, {Feature}Errors } from '../models/{feature}.error'; +import { {Feature}Policy, type {Feature} } from '../models/{feature}.model'; import type { {Feature}Repository } from '../repositories/{feature}.repository'; -import { to{Feature} } from './{feature}.mapper'; + +export type {Feature}ServiceError = ApiError | {Feature}Error; export class {Feature}Service { readonly #repository: {Feature}Repository; @@ -159,36 +252,21 @@ export class {Feature}Service { this.#repository = repository; } - getById = async (id: string): Promise<{Feature}> => { - const dto = await this.#repository.getById(id); - return to{Feature}(dto); + // 단순 조회 → pass-through + getById = async (id: string): Promise> => { + return this.#repository.getById(id); }; - create = async (input: CreateInput): Promise<{Feature}> => { + // 클라이언트 검증이 필요한 경우 → Policy 사용 + create = async (input: CreateInput): Promise> => { if (!{Feature}Policy.someRule(input.value)) { - throw {Feature}ClientError.invalidInput(); + return err({Feature}Errors.invalidInput()); } - - const dto = await this.#repository.create(input); - return to{Feature}(dto); + return this.#repository.create(input); }; } ``` -### 5. Mapper (DTO → Domain 변환) - -```typescript -// services/{feature}.mapper.ts -import type { {Feature}DTO } from '@aido/validators'; -import type { {Feature} } from '../models/{feature}.model'; - -export const to{Feature} = (dto: {Feature}DTO): {Feature} => ({ - id: dto.id, - // ... 필드 매핑 - createdAt: new Date(dto.createdAt), // 문자열 → Date 변환 -}); -``` - ### 6. Query Keys ```typescript @@ -206,6 +284,7 @@ export const {FEATURE}_QUERY_KEYS = { // presentations/queries/get-{feature}-query-options.ts import { use{Feature}Service } from '@src/bootstrap/providers/di-provider'; import { queryOptions } from '@tanstack/react-query'; +import { unwrap } from '@src/shared/errors/result'; import { {FEATURE}_QUERY_KEYS } from '../constants/{feature}-query-keys.constant'; export const get{Feature}QueryOptions = (id: string) => { @@ -213,15 +292,24 @@ export const get{Feature}QueryOptions = (id: string) => { return queryOptions({ queryKey: {FEATURE}_QUERY_KEYS.detail(id), - queryFn: () => service.getById(id), + queryFn: async () => { + const result = await service.getById(id); + return unwrap(result); + }, }); }; ``` +**Mutation — 에러 코드로 직접 UI 분기:** + ```typescript // presentations/queries/create-{feature}-mutation-options.ts import { use{Feature}Service } from '@src/bootstrap/providers/di-provider'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; +import { unwrap } from '@src/shared/errors/result'; +import { isApiError } from '@src/shared/errors/api-error'; +import { ErrorCode } from '@src/shared/errors'; +import { is{Feature}Error } from '../models/{feature}.error'; import { {FEATURE}_QUERY_KEYS } from '../constants/{feature}-query-keys.constant'; export const create{Feature}MutationOptions = () => { @@ -229,16 +317,123 @@ export const create{Feature}MutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: service.create, + mutationFn: async (input: CreateInput) => { + const result = await service.create(input); + return unwrap(result); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: {FEATURE}_QUERY_KEYS.all }); }, + onError: (error) => { + // 1. 클라이언트 비즈니스 에러 (Service → Policy 검증) + if (is{Feature}Error(error)) { + Toast.show(error.message); + return; + } + + // 2. 서버 비즈니스 에러 (Repository → ApiError) + if (isApiError(error)) { + if (error.hasCode(ErrorCode.SOME_SPECIFIC_ERROR)) { + Toast.show(error.message); + return; + } + } + + // 3. 예상치 못한 에러 + Toast.show('문제가 발생했어요'); + }, }); }; ``` --- +## Result 시스템 + +`Result`는 **예상된 에러**를 타입 안전하게 전달하는 패턴이다. + +### 타입 + +| 타입/함수 | 설명 | 사용처 | +|----------|------|--------| +| `Result` | 성공 `{ ok: true, value: T }` 또는 실패 `{ ok: false, error: E }` | 모든 레이어 | +| `ok(value)` | 성공 Result 생성 | Repository, Service | +| `err(error)` | 실패 Result 생성 | Repository (ApiError), Service ({Feature}Error) | +| `unwrap(result)` | 성공 → 값 반환, 실패 → `throw error` | Mutation/Query `mutationFn`/`queryFn` | +| `isOk(result)` | 성공 타입 가드 | 조건부 처리 | +| `isErr(result)` | 실패 타입 가드 | 조건부 처리 | + +### 핵심 규칙 + +- **예상된 에러**만 `Result`로 전달 (ApiError, {Feature}Error) +- **예기치 못한 에러**는 `throw` (InfraError) → ErrorBoundary가 자동 처리 +- Mutation에서 `unwrap`으로 Result → throw 변환하면, React Query `onError`에서 처리 가능 + +### unwrap 패턴 + +```typescript +// mutationFn에서 unwrap → 실패 시 throw → onError에서 catch +mutationFn: async (input) => { + const result = await service.create(input); + return unwrap(result); // 실패 시 error가 throw됨 +}, +onError: (error) => { + // unwrap이 throw한 error가 여기로 옴 + if (is{Feature}Error(error)) { ... } + if (isApiError(error)) { ... } +}, +``` + +--- + +## 에러 처리 흐름 + +에러는 **예상/예기치 못한** 2가지로 분류된다: + +| 분류 | 타입 | 전달 방식 | 처리 위치 | 예시 | +|------|------|----------|----------|------| +| 예기치 못한 에러 | `InfraError` | `throw` | ErrorBoundary (자동) | 5xx, 네트워크, 타임아웃 | +| 예기치 못한 에러 | `ParseError` | `throw` | ErrorBoundary (자동) | Zod safeParse 실패 (Repository에서) | +| 예상된 에러 | `ApiError` | `Result.err` | Mutation `onError` | 4xx 서버 비즈니스 에러 | +| 예상된 에러 | `{Feature}Error` | `Result.err` | Mutation `onError` | Policy 검증 실패 (서버 호출 전) | + +### 흐름도 + +``` +서버 응답 + ├── 5xx/네트워크/타임아웃/파싱 → throw InfraError → ErrorBoundary (자동) + └── 4xx → Result.err(ApiError) → Service (pass-through) → Mutation onError + ├── error.hasCode(ErrorCode.XXX) → Toast/UI 처리 + └── 기타 → 일반 에러 메시지 + +클라이언트 검증 + └── Policy 실패 → Result.err({Feature}Error) → Mutation onError + └── is{Feature}Error(error) → Toast/UI 처리 +``` + +### Zod 파싱 에러 (ParseError) + +Repository 구현체에서 서버 응답을 Zod로 검증할 때 발생. **예기치 못한 에러**로 분류. + +```typescript +// Repository.impl에서 발생 +const parsed = schema.safeParse(result.value); +if (!parsed.success) { + throw new ParseError(); // → ErrorBoundary로 전파 +} +``` + +### 핵심 규칙 + +- **InfraError는 throw** — 복구 불가능한 에러이므로 ErrorBoundary가 자동 처리 +- **ApiError는 Result** — 서버가 내려준 비즈니스 에러, Service가 번역하지 않고 pass-through +- **{Feature}Error는 Result** — 클라이언트 Policy 검증 실패, Service에서 생성 +- **{Feature}Error는 서버 호출 전 차단** — Policy 기반 클라이언트 검증, 불필요한 네트워크 요청 방지 +- **ParseError는 Repository에서 throw** — Zod safeParse 실패 시 InfraError로 분류 +- **Service는 서버 에러를 번역하지 않는다** — ErrorCode 기반 분기는 Mutation에서 담당 + +--- + ## 파일 네이밍 규칙 | 파일 유형 | 패턴 | 예시 | @@ -246,7 +441,7 @@ export const create{Feature}MutationOptions = () => { | 모델 | `{feature}.model.ts` | `todo.model.ts` | | 에러 | `{feature}.error.ts` | `todo.error.ts` | | 서비스 | `{feature}.service.ts` | `todo.service.ts` | -| 매퍼 | `{feature}.mapper.ts` | `todo.mapper.ts` | +| 매퍼 | `{feature}.mapper.ts` | `todo.mapper.ts` (repositories/ 하위) | | Repository 인터페이스 | `{feature}.repository.ts` | `todo.repository.ts` | | Repository 구현 | `{feature}.repository.impl.ts` | `todo.repository.impl.ts` | | Query Options | `{action}-query-options.ts` | `get-todos-query-options.ts` | @@ -264,22 +459,23 @@ export const create{Feature}MutationOptions = () => { - Policy 정의 (비즈니스 규칙) - [ ] `features/{feature}/models/{feature}.error.ts` 생성 - ErrorReason 타입 정의 - - ClientError 클래스 정의 + - {Feature}Error 클래스 정의 (BusinessError 구현) ### 2단계: 데이터 레이어 +- [ ] `features/{feature}/repositories/{feature}.mapper.ts` 생성 + - DTO → Domain 변환 함수 (standalone 함수) - [ ] `features/{feature}/repositories/{feature}.repository.ts` 생성 - - Repository 인터페이스 정의 + - Repository 인터페이스 정의 (도메인 타입 반환) - [ ] `features/{feature}/repositories/{feature}.repository.impl.ts` 생성 - HttpClient 주입 - - Zod safeParse로 응답 검증 + - Zod safeParse로 DTO 검증 + - mapper 호출하여 도메인 모델 반환 ### 3단계: 비즈니스 로직 -- [ ] `features/{feature}/services/{feature}.mapper.ts` 생성 - - DTO → Domain 변환 함수 - [ ] `features/{feature}/services/{feature}.service.ts` 생성 - Repository 주입 - Policy 검증 적용 - - Mapper로 변환 후 반환 + - 단순 조회는 pass-through (mapper 호출 X) ### 4단계: DI 등록 - [ ] `bootstrap/providers/di-provider.tsx` 수정 @@ -301,15 +497,17 @@ export const create{Feature}MutationOptions = () => { ## 의존성 방향 ``` -UI (app/, presentations/) - ↓ - Service - ↓ - Repository (인터페이스) - ↓ - Repository.impl → HttpClient (Port) - ↓ - KyHttpClient (Adapter) + Model (Domain) ← 핵심, 외부 의존 없음 + ↑ ↑ ↑ ↑ + UI Service Repo Repo.impl+Mapper + ↓ ↓ ↑ ↓ + Service Repo.impl HttpClient (Port) + ↓ + KyHttpClient (Adapter) ``` -**규칙**: 상위 레이어는 하위 레이어만 의존. 역방향 의존 금지. +**규칙:** +- 모든 레이어 → Model 의존 (OK) +- UI → Service → Repository 인터페이스 (OK) +- Repository.impl → Repository 인터페이스 구현 (OK) +- **역방향 의존 금지** — Model이 다른 레이어를 알면 안 됨 diff --git a/apps/mobile/app/(app)/(tabs)/feed/index.tsx b/apps/mobile/app/(app)/(tabs)/feed/index.tsx index 05462eb2..b1b0d466 100644 --- a/apps/mobile/app/(app)/(tabs)/feed/index.tsx +++ b/apps/mobile/app/(app)/(tabs)/feed/index.tsx @@ -6,6 +6,7 @@ import { QueryErrorBoundary } from '@src/shared/ui/QueryErrorBoundary/QueryError import { StyledSafeAreaView } from '@src/shared/ui/SafeAreaView/SafeAreaView'; import { Spacing } from '@src/shared/ui/Spacing/Spacing'; import { VStack } from '@src/shared/ui/VStack/VStack'; +import { formatDate } from '@src/shared/utils/date'; import { Suspense, useState } from 'react'; import { View } from 'react-native'; @@ -29,7 +30,7 @@ const FeedScreen = () => { - + }> diff --git a/apps/mobile/app/(app)/settings/terms.tsx b/apps/mobile/app/(app)/settings/terms.tsx index 6a485143..85a5458c 100644 --- a/apps/mobile/app/(app)/settings/terms.tsx +++ b/apps/mobile/app/(app)/settings/terms.tsx @@ -32,9 +32,8 @@ function TermsSettingsForm() { const { data: consent } = useSuspenseQuery(getConsentQueryOptions()); const updateMutation = useMutation(updateMarketingConsentMutationOptions()); - const formatDate = (dateString: string | null) => { - if (!dateString) return '미동의'; - const date = new Date(dateString); + const formatDate = (date: Date | null) => { + if (!date) return '미동의'; return date.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', diff --git a/apps/mobile/docs/error-handling.md b/apps/mobile/docs/error-handling.md index ca79f847..c3b3f81f 100644 --- a/apps/mobile/docs/error-handling.md +++ b/apps/mobile/docs/error-handling.md @@ -1,354 +1,421 @@ -# 모바일 에러 처리 가이드 +# 에러 처리 가이드 -이 문서는 모바일 앱의 에러 처리 체계를 설명합니다. +## 핵심 원칙 ---- - -## 에러 분류 체계 +> **"이 에러를 UI가 예상하고 있는가?"** -### 1. 서버 에러 (ApiError) - -서버 HTTP 응답(4xx, 5xx)에서 발생하는 에러입니다. +``` +예상한 에러 (Result로 전달) +├── Track 1: ApiError (서버 4xx) — 이미 친구, 인증 코드 만료, 중복 가입 +└── Track 2: BusinessError (클라이언트) — 태그 형식 오류, 로그인 취소, SDK 에러 -```typescript -// 위치: shared/errors/api-error.ts -export class ApiError extends Error { - readonly name = 'ApiError'; - constructor( - public readonly code: string, // 서버 에러 코드 (AUTH_0101, TODO_0801 등) - message: string, // 사용자 메시지 - public readonly status: number, // HTTP 상태 코드 - ) { - super(message); - } -} +예상하지 못한 에러 (throw) +└── Track 3: InfraError — 5xx, 네트워크 끊김, 타임아웃, 파싱 실패 ``` -**특징:** -- `code`: 서버에서 정의한 에러 코드 (예: `AUTH_0101`, `FOLLOW_0901`) -- `message`: 사용자에게 표시할 메시지 (error-handler.ts에서 매핑) -- `status`: HTTP 상태 코드 (401, 404, 500 등) +| 트랙 | 에러 타입 | 전달 방식 | 처리 위치 | +|------|----------|----------|----------| +| Track 1 | `ApiError` (4xx) | `err()` → `unwrap()` → throw | Mutation `onError` | +| Track 2 | `{Feature}Error` (Policy/SDK) | `err()` → `unwrap()` → throw | Mutation `onError` | +| Track 3 | `InfraError` (5xx/네트워크) | 직접 `throw` | `` | -**헬퍼 메서드:** -- `hasCode(code)`: 특정 에러 코드인지 확인 -- `isDomain(prefix)`: 특정 도메인 에러인지 확인 (예: `err.isDomain('AUTH_')`) +- 예상한 에러: 사용자에게 구체적 안내 가능 → `err()`로 반환 +- 예상하지 못한 에러: 구체적 안내 불가, "재시도"만 가능 → `throw`로 ErrorBoundary 위임 -### 2. 클라이언트 에러 (ClientError) +> Result 타입(`ok`, `err`, `unwrap`)과 레이어별 에러 처리 패턴은 [CLAUDE.md](../CLAUDE.md)를 참고하세요. -앱 내부에서 발생하는 에러입니다 (네이티브 SDK, 입력 검증, 네트워크 등). +--- -```typescript -// 위치: shared/errors/client-error.ts -export abstract class ClientError extends Error { - abstract readonly name: string; - abstract readonly code: string; // UPPER_SNAKE_CASE - - constructor(message: string) { - super(message); - } -} -``` +## 레이어별 데이터 흐름 ---- +이메일 로그인 플로우를 예시로 성공/실패 경로를 추적합니다. -## 에러 흐름도 +### 성공 경로 ``` -서버 에러 흐름: -┌──────────────────────────────────────────────────────────────┐ -│ HTTP 응답 (4xx/5xx) │ -│ ↓ │ -│ error-handler.ts (AfterResponseHook) │ -│ ↓ MOBILE_ERROR_MESSAGES[code] 매핑 │ -│ throw new ApiError(code, userMessage, status) │ -│ ↓ │ -│ TanStack Query onError → UI 처리 │ -└──────────────────────────────────────────────────────────────┘ - -클라이언트 에러 흐름: -┌──────────────────────────────────────────────────────────────┐ -│ 네이티브 SDK (Expo) Repository (Zod) Service │ -│ ↓ ↓ ↓ │ -│ fromExpoError() validation 실패 cancelled │ -│ ↓ ↓ ↓ │ -│ throw DomainError (AuthCancelledError, ValidationError 등) │ -│ ↓ │ -│ TanStack Query onError → UI 처리 │ -└──────────────────────────────────────────────────────────────┘ +KyHttpClient.post('v1/auth/login', { email, password }) + → 200 OK + → return ok(data) + │ + ▼ +AuthRepositoryImpl.emailLogin() + → result.ok === true + → safeParse(result.value) 성공 + → return ok(toAuthTokens(parsed.data)) + │ + ▼ +AuthService.emailLogin() + → result.ok === true + → saveTokens(accessToken, refreshToken) + → return result + │ + ▼ +emailLoginMutationOptions.mutationFn() + → unwrap(result) → result.value 반환 + │ + ▼ +onSuccess() + → setStatus('authenticated') ``` ---- +### 실패 경로 — 4xx 서버 에러 (예: 잘못된 비밀번호) -## 도메인 에러 작성 규칙 +``` +KyHttpClient.post('v1/auth/login', { email, password }) + → 401 Unauthorized + → AfterResponseHook: MOBILE_ERROR_MESSAGES 한국어 매핑 + → return err(new ApiError('AUTH_0401', '이메일 또는 비밀번호가 틀렸어요', 401)) + │ + ▼ +AuthRepositoryImpl.emailLogin() + → result.ok === false + → return result (에러 그대로 전파) + │ + ▼ +AuthService.emailLogin() + → result.ok === false + → return result (에러 그대로 전파, saveTokens 호출 안 됨) + │ + ▼ +emailLoginMutationOptions.mutationFn() + → unwrap(result) → throw result.error + │ + ▼ +onError(error) + → error.message === '이메일 또는 비밀번호가 틀렸어요' + → toast.error(error) 또는 특수 처리 +``` -### 디렉토리 구조 +### 실패 경로 — 인프라 에러 (예: 서버 다운) ``` -features/ -└── {domain}/ - └── models/ - └── {domain}.error.ts +KyHttpClient.post('v1/auth/login', { email, password }) + → 500 Internal Server Error + → throw new ServerError(500) ← 여기서 바로 throw + │ + ▼ + (Repository, Service, mutationFn 모두 거치지 않음) + │ + ▼ + 가 잡음 + → "오류가 발생했어요" + [재시도] 버튼 ``` -### 기본 패턴 - -```typescript -import { ClientError } from '@src/shared/errors'; +### 요약 다이어그램 -// ============================================ -// {Domain} 도메인 에러 (클라이언트 검증용) -// ============================================ +``` +HttpClient Repository Service Mutation Options UI +───────────────────────────────────────────────────────────────────────────────────────── +성공: + ok(data) ──→ safeParse + mapper ──→ saveTokens ──→ unwrap → value ──→ onSuccess + ok(domain) ok(domain) + +4xx 에러: + err(ApiError) ──→ return result ──→ return result ──→ unwrap → throw ──→ onError + (그대로 전파) (그대로 전파) + +InfraError: + throw ──────────────────────────────────────────────────────────────────→ ErrorBoundary + (5xx/네트워크/타임아웃) (catch 없이 전파) +``` -/** 도메인 기본 에러 */ -export class {Domain}Error extends ClientError { - override readonly name: string = '{Domain}Error'; - readonly code: string = '{DOMAIN}_ERROR'; +--- - constructor(message: string = '기본 에러 메시지') { - super(message); - } -} +## 예상한 에러: ApiError (Track 1) -/** 구체적인 에러 서브클래스 */ -export class {Domain}ValidationError extends {Domain}Error { - override readonly name = '{Domain}ValidationError'; - override readonly code = '{DOMAIN}_VALIDATION'; +서버가 4xx 응답을 보낼 때 발생. 사용자에게 구체적 안내가 가능한 에러. - constructor() { - super('잘못된 응답 형식이에요'); - } +```typescript +// shared/errors/api-error.ts +class ApiError extends Error implements BusinessError { + constructor( + public readonly code: string, // 서버 에러 코드 (AUTH_0101 등) + message: string, // 한국어 사용자 메시지 + public readonly status: number, // HTTP 상태 코드 + public readonly details?: Record, + ) { super(message); } + + hasCode(code: C): this is ApiError & { code: C }; + isDomain(prefix: string): boolean; } - -// 타입 가드 -export const is{Domain}Error = (error: unknown): error is {Domain}Error => - error instanceof {Domain}Error; ``` -### 네이밍 컨벤션 +### 에러 코드 → 사용자 메시지 매핑 -| 요소 | 규칙 | 예시 | -|------|------|------| -| 클래스명 | PascalCase | `AuthCancelledError`, `InvalidTagError` | -| code 필드 | UPPER_SNAKE_CASE | `AUTH_CANCELLED`, `INVALID_TAG` | -| 타입 가드 | is + 클래스명 | `isAuthError`, `isFriendError` | +서버 에러 코드를 Ky AfterResponseHook에서 한국어 메시지로 변환합니다. -### 일반적인 클라이언트 에러 카테고리 +```typescript +// shared/infra/http/error-handler.ts +const MOBILE_ERROR_MESSAGES: Partial> = { + FOLLOW_0901: '이미 친구 요청을 보냈어요', + FOLLOW_0904: '자기 자신에게는 친구 요청을 보낼 수 없어요', + VERIFY_0752: '인증 코드가 만료되었어요. 다시 요청해주세요.', + TODO_0801: '할 일을 찾을 수 없어요', + // ... +}; +``` -| 카테고리 | 설명 | code 접미사 예시 | -|---------|------|-----------------| -| Validation | 입력/응답 검증 실패 | `_VALIDATION` | -| Network | 네트워크 연결 문제 | `_NETWORK` | -| Cancelled | 사용자 작업 취소 | `_CANCELLED` | -| Permission | 권한 부족 (로컬) | `_PERMISSION` | +`ApiError.message`가 이미 사용자 친화적이므로, UI에서는 `toast.error(err)`만 하면 됩니다. --- -## SDK 에러 변환 패턴 +## 예상한 에러: BusinessError (Track 2) -외부 SDK 에러를 도메인 에러로 변환할 때 사용합니다. +### Policy 에러 + +서버 요청 전에 클라이언트에서 검증하는 에러. 불필요한 네트워크 요청을 방지합니다. ```typescript -import { match } from 'ts-pattern'; +// features/friend/services/friend.service.ts +sendRequestByTag = async (userTag: string): Promise> => { + if (!userTag.trim()) return err(FriendErrors.emptyTag()); // 즉시 반환 + if (!FriendPolicy.isValidTag(userTag)) return err(FriendErrors.invalidTag()); // 즉시 반환 + return this.#repository.sendRequest(userTag); // 검증 통과 후 서버 요청 +}; +``` -/** Expo 에러 코드 정의 */ -const SdkErrorCode = { - REQUEST_CANCELED: 'ERR_REQUEST_CANCELED', - REQUEST_FAILED: 'ERR_REQUEST_FAILED', - // ... -} as const; +### SDK 에러 변환 -type SdkErrorCodeType = (typeof SdkErrorCode)[keyof typeof SdkErrorCode]; +외부 SDK 에러를 도메인 에러로 변환. SDK 에러가 앱 외부로 유출되지 않도록 합니다. -export class SomeAuthError extends AuthError { - /** SDK 에러 → 도메인 에러 변환 */ - static fromSdkError(error: SdkCodedError): AuthError { - return match(error.code as SdkErrorCodeType) - .with(SdkErrorCode.REQUEST_CANCELED, () => new AuthCancelledError()) - .with(SdkErrorCode.REQUEST_FAILED, () => - new SomeAuthError('인증 정보가 올바르지 않아요')) - .otherwise(() => new AuthError(error.message)); +```typescript +// features/auth/services/auth.service.ts +openAppleLogin = async (): Promise> => { + try { + const credential = await AppleAuthentication.signInAsync({ ... }); + const result = await this.#authRepository.appleLogin(input); + if (!result.ok) return result; + await this.saveTokens(result.value.accessToken, result.value.refreshToken); + return result; + } catch (error) { + if (isAuthError(error)) return err(error); + if (isExpoCodedError(error)) return err(AuthErrors.fromExpoAppleError(error)); + return err(AuthErrors.fromUnknown(error)); // 마지막 방어선 } -} +}; ``` -**핵심 포인트:** -- `ts-pattern`의 `match`로 에러 코드별 분기 -- 취소는 별도 에러 클래스 (`AuthCancelledError`)로 분리 -- `otherwise()`로 예상치 못한 에러 처리 +### 도메인 에러 정의 패턴 ---- +```typescript +// features/{domain}/models/{domain}.error.ts +// 1) 에러 코드 상수 +export const FriendErrorCode = { + INVALID_TAG: 'FRIEND_INVALID_TAG', + EMPTY_TAG: 'FRIEND_EMPTY_TAG', +} as const; -## UI 에러 처리 패턴 +// 2) 에러 클래스 (Error + BusinessError 구현) +export class FriendError extends Error implements BusinessError { + override readonly name = 'FriendError'; + constructor(public readonly code: FriendErrorCode, message: string) { super(message); } +} -### TanStack Query Mutation onError +// 3) 팩토리 객체 +export const FriendErrors = { + invalidTag: () => new FriendError(FriendErrorCode.INVALID_TAG, '올바른 태그 형식이 아니에요'), + emptyTag: () => new FriendError(FriendErrorCode.EMPTY_TAG, '태그를 입력해주세요'), +} as const; -```typescript -import { isApiError, isClientError } from '@src/shared/errors'; -import { AuthCancelledError, isAuthError } from '@src/features/auth/models/auth.error'; - -const mutation = useMutation({ - mutationFn: someAsyncFunction, - onError: (err) => { - // 1. 사용자 취소: 토스트 생략 - if (err instanceof AuthCancelledError) { - return; - } - - // 2. 서버 에러: 사용자 메시지 표시 - if (isApiError(err)) { - toast.error(err.message); - - // 필요시 특정 에러 코드 처리 - if (err.hasCode('FOLLOW_0901')) { - // 이미 친구 요청을 보낸 경우 특수 처리 - } - return; - } - - // 3. 클라이언트 에러: 사용자 메시지 표시 - if (isClientError(err)) { - toast.error(err.message); - return; - } - - // 4. 예상치 못한 에러 - toast.error('알 수 없는 오류가 발생했어요'); - }, -}); +// 4) 타입 가드 +export const isFriendError = (error: unknown): error is FriendError => error instanceof FriendError; ``` -### 에러 처리 우선순위 +--- + +## 예상하지 못한 에러: InfraError (Track 3) -1. **사용자 취소** → 무시 (토스트 없음) -2. **서버 에러 (ApiError)** → `err.message` 표시 -3. **클라이언트 에러 (ClientError)** → `err.message` 표시 -4. **예상치 못한 에러** → 기본 메시지 표시 +복구 불가능한 에러. ErrorBoundary에서 일괄 처리하고 "재시도"만 제공합니다. -### 서버 에러 도메인별 분기 +| 서브클래스 | 발생 시점 | 메시지 | +|-----------|----------|--------| +| `ServerError(status)` | HTTP 5xx | "서버에 문제가 발생했어요" | +| `NetworkError` | 네트워크 끊김 | "네트워크 연결을 확인해주세요" | +| `TimeoutError` | 요청 시간 초과 | "요청 시간이 초과되었어요" | +| `ParseError` | Zod safeParse 실패 | "응답 형식이 올바르지 않아요" | + +### InfraError가 throw되는 곳 ```typescript -if (isApiError(err)) { - // 인증 관련 서버 에러 - if (err.isDomain('AUTH_')) { - // 로그아웃 처리 등 - } - - // 특정 에러 코드 - if (err.hasCode('USER_0607')) { - // 계정 잠김 처리 +// KyHttpClient.#request() — 4xx만 err(), 나머지는 throw +try { + const response = await request(); + return ok((await response.json()).data); +} catch (error) { + if (error instanceof KyTimeoutError) throw new TimeoutError(); + if (error instanceof HTTPError) { + if (error.response.status >= 500) throw new ServerError(status); + return err(new ApiError(...)); // 4xx만 err() } - - toast.error(err.message); + if (error instanceof TypeError) throw new NetworkError(); + throw error; } ``` ---- - -## 서비스 레이어 에러 처리 +```typescript +// Repository — Zod 검증 실패도 InfraError +const parsed = todoListResponseSchema.safeParse(result.value); +if (!parsed.success) throw new ParseError(); // 서버 응답이 깨진 건 인프라 문제 +``` -### Repository에서 에러 throw +### QueryErrorBoundary ```typescript -// features/{domain}/infra/{domain}.repository.impl.ts - -async function fetchData(): Promise { - const response = await api.get('endpoint').json(); - - // Zod 파싱 실패 시 ValidationError throw - const result = DataSchema.safeParse(response); - if (!result.success) { - throw new DomainValidationError(); - } - - return result.data; +// shared/ui/QueryErrorBoundary/QueryErrorBoundary.tsx +export function QueryErrorBoundary({ children, fallback }: QueryErrorBoundaryProps) { + return ( + + {({ reset }) => ( + + fallback ? fallback({ error, reset: resetErrorBoundary }) : ( + 재시도} + /> + ) + } + > + {children} + + )} + + ); } ``` -### Service에서 에러 변환 +`QueryErrorResetBoundary`는 에러 reset 시 쿼리 에러 상태도 초기화하여, 재시도가 실제로 데이터를 다시 fetch합니다. -```typescript -// features/{domain}/services/{domain}.service.ts +### ErrorBoundary 배치 전략 -async function performSdkAction(): Promise { - try { - const sdkResult = await nativeSdk.doSomething(); - return transformResult(sdkResult); - } catch (error) { - // SDK 에러 → 도메인 에러 변환 - if (isSdkCodedError(error)) { - throw DomainError.fromSdkError(error); - } - throw error; - } -} +독립된 데이터 영역마다 개별 ErrorBoundary를 배치하여 부분 실패를 허용합니다: + +```typescript +// app/(app)/(tabs)/feed/index.tsx + + {/* 아바타 실패해도 캘린더와 투두는 동작 */} + + }> + + + + + + + {/* 투두 실패해도 아바타와 캘린더는 동작 */} + + }> + + + + ``` +배치 원칙: +1. 독립된 데이터 영역마다 개별 ErrorBoundary +2. `ErrorBoundary` → `Suspense` → `Component` 순서 +3. `key` prop으로 데이터 변경 시 에러 상태 초기화 + --- -## 타입 가드 사용 +## UI 에러 처리 패턴 + +### 패턴 1: toast.error 기본 처리 -### 기본 타입 가드 +대부분의 경우. `error.message`에 이미 한국어 메시지가 있으므로: ```typescript -import { isApiError, isClientError } from '@src/shared/errors'; -import { isAuthError } from '@src/features/auth/models/auth.error'; +mutation.mutate(data, { + onError: (error) => toast.error(error, { fallback: '작업에 실패했어요' }), +}); +``` -function handleError(error: unknown) { - if (isApiError(error)) { - // error는 ApiError 타입 - console.log(error.code, error.status); - } - - if (isClientError(error)) { - // error는 ClientError 타입 - console.log(error.code); - } - - if (isAuthError(error)) { - // error는 AuthError 타입 (또는 서브클래스) - console.log(error.code); +### 패턴 2: 에러 코드별 분기 + +특정 에러에 페이지 이동, 다이얼로그 등 특수 동작이 필요할 때: + +```typescript +onError: (error) => { + if (isApiError(error) && error.hasCode(ErrorCode.EMAIL_0503)) { + router.push({ pathname: './verify-email', params: { email } }); + return; } -} + toast.error(error, { fallback: '로그인에 실패했어요' }); +}, ``` -### instanceof로 구체적 분기 +### 패턴 3: Optimistic Update 롤백 ```typescript -if (error instanceof AuthCancelledError) { - // 취소 처리 -} else if (error instanceof AuthNetworkError) { - // 네트워크 에러 처리 -} else if (error instanceof AuthValidationError) { - // 검증 에러 처리 -} +return mutationOptions({ + mutationFn: async (input) => unwrap(await authService.updateMarketingConsent(input)), + onMutate: async (input) => { + await queryClient.cancelQueries({ queryKey: AUTH_QUERY_KEYS.consent() }); + const previousData = queryClient.getQueryData(AUTH_QUERY_KEYS.consent()); + queryClient.setQueryData(AUTH_QUERY_KEYS.consent(), (old) => ({ ...old, ... })); + return { previousData }; + }, + onError: (_error, _input, context) => { + if (context?.previousData) { + queryClient.setQueryData(AUTH_QUERY_KEYS.consent(), context.previousData); + } + }, +}); +``` + +### 패턴 4: retry 제어 + +```typescript +retry: (failureCount, error) => { + if (isNotificationError(error)) { + if (isNotPhysicalDeviceError(error)) return false; + if (isPermissionDeniedError(error)) return false; + } + return failureCount < MAX_RETRY_COUNT; +}, ``` --- -## 체크리스트 +## 도메인 에러 현황 -### 새 도메인 에러 추가 시 +| 도메인 | 에러 코드 | 추가 기능 | +|--------|----------|----------| +| Auth | `LOGIN_CANCELLED`, `PROVIDER_ERROR`, `VALIDATION_FAILED`, `NO_CODE_RECEIVED`, `UNKNOWN` | `fromExpoAppleError()`, `fromUnknown()` | +| Friend | `INVALID_TAG`, `EMPTY_TAG` | - | +| Todo | `VALIDATION_FAILED` | - | +| Notification | `PERMISSION_DENIED`, `NOT_PHYSICAL_DEVICE`, `VALIDATION_FAILED` | `isPermissionDeniedError()` 등 | + +### 네이밍 규칙 -- [ ] `features/{domain}/models/{domain}.error.ts` 파일 생성 -- [ ] 기본 에러 클래스가 `ClientError` 상속 -- [ ] `name`, `code` 필드 정의 (override) -- [ ] 타입 가드 함수 export (`is{Domain}Error`) -- [ ] 필요한 서브클래스 정의 (Validation, Cancelled 등) +| 요소 | 패턴 | 예시 | +|------|------|------| +| 에러 코드 상수 | `{Feature}ErrorCode` | `AuthErrorCode` | +| 에러 클래스 | `{Feature}Error` | `AuthError` | +| 팩토리 객체 | `{Feature}Errors` | `AuthErrors` | +| 타입 가드 | `is{Feature}Error` | `isAuthError` | +| 파일 위치 | `features/{domain}/models/{domain}.error.ts` | `auth.error.ts` | + +--- -### SDK 에러 변환 추가 시 +## 체크리스트 + +### 새 도메인 에러 추가 시 -- [ ] SDK 에러 코드 상수 정의 -- [ ] `ExpoCodedError` 유사 인터페이스 정의 -- [ ] `fromSdkError` 정적 메서드 구현 -- [ ] `ts-pattern` match로 코드별 분기 -- [ ] 취소는 별도 에러 클래스로 분리 +- [ ] `features/{domain}/models/{domain}.error.ts` 생성 +- [ ] `{Feature}ErrorCode` 상수 + 타입 정의 +- [ ] `{Feature}Error` 클래스 — `Error` 상속, `BusinessError` 구현 +- [ ] `{Feature}Errors` 팩토리 객체 +- [ ] `is{Feature}Error` 타입 가드 ### UI 에러 처리 시 -- [ ] 사용자 취소 먼저 체크 (토스트 생략) -- [ ] `isApiError` → `isClientError` 순서로 체크 -- [ ] 예상치 못한 에러용 기본 메시지 제공 -- [ ] 필요시 `hasCode()`, `isDomain()`으로 특수 처리 +- [ ] `mutationFn`에서 `unwrap(result)` 사용 +- [ ] `onError`에서 `toast.error(err, { fallback })` 사용 +- [ ] 특수 처리 필요하면 `isApiError` + `hasCode()` 분기 +- [ ] Suspense Query 영역은 ``로 감싸기 +- [ ] 독립 데이터 섹션마다 개별 ErrorBoundary 배치 --- @@ -356,7 +423,10 @@ if (error instanceof AuthCancelledError) { | 파일 | 설명 | |------|------| -| `shared/errors/client-error.ts` | ClientError 추상 클래스 | -| `shared/errors/api-error.ts` | ApiError 클래스 | -| `shared/infra/http/error-handler.ts` | 서버 에러 매핑 | -| `features/auth/models/auth.error.ts` | Auth 도메인 에러 (참고용) | +| `shared/errors/result.ts` | Result 타입, ok/err/unwrap | +| `shared/errors/api-error.ts` | ApiError (4xx) | +| `shared/errors/infra-error.ts` | InfraError (5xx, 네트워크, 파싱) | +| `shared/infra/http/ky-client.ts` | KyHttpClient 구현 | +| `shared/infra/http/error-handler.ts` | 에러 코드 → 메시지 매핑 | +| `shared/ui/QueryErrorBoundary/` | QueryErrorBoundary 컴포넌트 | +| `features/*/models/*.error.ts` | 각 도메인 에러 정의 | diff --git a/apps/mobile/src/bootstrap/providers/di-provider.tsx b/apps/mobile/src/bootstrap/providers/di-provider.tsx index 100a5261..dc3b655b 100644 --- a/apps/mobile/src/bootstrap/providers/di-provider.tsx +++ b/apps/mobile/src/bootstrap/providers/di-provider.tsx @@ -12,7 +12,7 @@ import { TodoRepositoryImpl } from '@src/features/todo/repositories/todo.reposit import { TodoService } from '@src/features/todo/services/todo.service'; import { createAuthClient } from '@src/shared/infra/http/auth-client'; -import { KyHttpClient } from '@src/shared/infra/http/ky-http-client'; +import { KyHttpClient } from '@src/shared/infra/http/ky-client'; import { createPublicClient } from '@src/shared/infra/http/public-client'; import { SecureStorage } from '@src/shared/infra/storage/secure-storage'; @@ -42,8 +42,8 @@ export const DIProvider = ({ children }: PropsWithChildren) => { const authHttpClient = new KyHttpClient(authKyInstance); // Auth - const authRepository = new AuthRepositoryImpl(publicHttpClient, authHttpClient, storage); - const authService = new AuthService(authRepository); + const authRepository = new AuthRepositoryImpl(publicHttpClient, authHttpClient); + const authService = new AuthService(authRepository, storage); // Friend const friendRepository = new FriendRepositoryImpl(authHttpClient); diff --git a/apps/mobile/src/bootstrap/providers/query-provider.tsx b/apps/mobile/src/bootstrap/providers/query-provider.tsx index 945f4f0c..bcd41d63 100644 --- a/apps/mobile/src/bootstrap/providers/query-provider.tsx +++ b/apps/mobile/src/bootstrap/providers/query-provider.tsx @@ -1,4 +1,4 @@ -import { isApiError, isClientError } from '@src/shared/errors'; +import { isApiError, isBusinessError } from '@src/shared/errors'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { PropsWithChildren } from 'react'; @@ -16,8 +16,8 @@ const queryClient = new QueryClient({ return false; } - // 클라이언트 에러 (취소, 검증 실패): 재시도 무의미 - if (isClientError(error)) { + // 비즈니스 에러 (취소, 검증 실패 등): 재시도 무의미 + if (isBusinessError(error)) { return false; } diff --git a/apps/mobile/src/core/ports/http.ts b/apps/mobile/src/core/ports/http.ts index 74237ef7..c8b1c4e1 100644 --- a/apps/mobile/src/core/ports/http.ts +++ b/apps/mobile/src/core/ports/http.ts @@ -1,8 +1,5 @@ -export interface HttpClientConfig { - baseUrl?: string; - headers?: Record; - timeout?: number; -} +import type { ApiError } from '@src/shared/errors/api-error'; +import type { Result } from '@src/shared/errors/result'; export interface RequestConfig { params?: Record; @@ -10,15 +7,15 @@ export interface RequestConfig { timeout?: number; } -export interface HttpClientResponse { - data: T; - status: number; -} - +/** + * Result 기반 HttpClient + * - 4xx 에러: Result.err(ApiError) 반환 + * - 5xx/네트워크/타임아웃: throw InfraError → ErrorBoundary 처리 + */ export interface HttpClient { - get(url: string, config?: RequestConfig): Promise>; - post(url: string, data?: unknown, config?: RequestConfig): Promise>; - put(url: string, data?: unknown, config?: RequestConfig): Promise>; - patch(url: string, data?: unknown, config?: RequestConfig): Promise>; - delete(url: string, config?: RequestConfig): Promise>; + get(url: string, config?: RequestConfig): Promise>; + post(url: string, data?: unknown, config?: RequestConfig): Promise>; + put(url: string, data?: unknown, config?: RequestConfig): Promise>; + patch(url: string, data?: unknown, config?: RequestConfig): Promise>; + delete(url: string, config?: RequestConfig): Promise>; } diff --git a/apps/mobile/src/features/auth/models/auth-tokens.model.ts b/apps/mobile/src/features/auth/models/auth-tokens.model.ts deleted file mode 100644 index 6592579d..00000000 --- a/apps/mobile/src/features/auth/models/auth-tokens.model.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface AuthTokens { - userId: string; - accessToken: string; - refreshToken: string; - userName: string | null; - userProfileImage: string | null; -} diff --git a/apps/mobile/src/features/auth/models/auth.error.ts b/apps/mobile/src/features/auth/models/auth.error.ts index 83118f06..0828215e 100644 --- a/apps/mobile/src/features/auth/models/auth.error.ts +++ b/apps/mobile/src/features/auth/models/auth.error.ts @@ -1,5 +1,4 @@ -import { ClientError } from '@src/shared/errors'; -import type { z } from 'zod'; +import type { BusinessError } from '@src/shared/errors'; export interface ExpoCodedError extends Error { code?: string; @@ -12,88 +11,71 @@ const EXPO_APPLE_ERROR_CODES = { NOT_AVAILABLE: 'ERR_NOT_AVAILABLE', } as const; -/** Auth 도메인 기본 에러 */ -export class AuthError extends ClientError { - override readonly name: string = 'AuthError'; - readonly code: string = 'AUTH_ERROR'; +export const AuthErrorCode = { + LOGIN_CANCELLED: 'AUTH_LOGIN_CANCELLED', + PROVIDER_ERROR: 'AUTH_PROVIDER_ERROR', + VALIDATION_FAILED: 'AUTH_VALIDATION_FAILED', + NO_CODE_RECEIVED: 'AUTH_NO_CODE_RECEIVED', + UNKNOWN: 'AUTH_UNKNOWN', +} as const; + +export type AuthErrorCode = (typeof AuthErrorCode)[keyof typeof AuthErrorCode]; - constructor(message: string = '인증 작업에 실패했어요') { +export class AuthError extends Error implements BusinessError { + override readonly name = 'AuthError'; + + constructor( + public readonly code: AuthErrorCode | string, + message: string, + ) { super(message); } +} + +export const AuthErrors = { + loginCancelled: () => new AuthError(AuthErrorCode.LOGIN_CANCELLED, '로그인이 취소되었어요'), + + providerError: (provider: string, msg?: string) => + new AuthError(AuthErrorCode.PROVIDER_ERROR, msg ?? `${provider} 로그인에 실패했어요`), + + validationFailed: (endpoint?: string) => + new AuthError( + AuthErrorCode.VALIDATION_FAILED, + endpoint ? `잘못된 인증 응답이에요 (${endpoint})` : '잘못된 인증 응답이에요', + ), - /** Expo Apple 에러 → AuthError 변환 */ - static fromExpoAppleError(error: ExpoCodedError): AuthError { + noCodeReceived: () => new AuthError(AuthErrorCode.NO_CODE_RECEIVED, '인증 코드를 받지 못했어요'), + + unknown: (msg?: string) => new AuthError(AuthErrorCode.UNKNOWN, msg ?? '인증 작업에 실패했어요'), + + fromExpoAppleError: (error: ExpoCodedError): AuthError => { switch (error.code) { case EXPO_APPLE_ERROR_CODES.REQUEST_CANCELED: - return new AuthLoginCancelledError(); + return AuthErrors.loginCancelled(); case EXPO_APPLE_ERROR_CODES.REQUEST_FAILED: case EXPO_APPLE_ERROR_CODES.INVALID_RESPONSE: - return new AuthProviderError('apple', 'Apple 인증 응답이 올바르지 않아요'); + return AuthErrors.providerError('apple', 'Apple 인증 응답이 올바르지 않아요'); case EXPO_APPLE_ERROR_CODES.NOT_AVAILABLE: - return new AuthProviderError('apple', 'Apple 로그인을 사용할 수 없어요'); + return AuthErrors.providerError('apple', 'Apple 로그인을 사용할 수 없어요'); default: - return new AuthError(error.message); + return AuthErrors.unknown(error.message); } - } + }, - /** 알 수 없는 에러 → AuthError 변환 */ - static fromUnknown(error: unknown): AuthError { + fromUnknown: (error: unknown): AuthError => { if (error instanceof AuthError) return error; - if (error instanceof Error) return new AuthError(error.message); - return new AuthError(); - } -} - -/** 로그인 취소 */ -export class AuthLoginCancelledError extends AuthError { - override readonly name = 'AuthLoginCancelledError'; - override readonly code = 'AUTH_LOGIN_CANCELLED'; - - constructor() { - super('로그인이 취소되었어요'); - } -} - -/** 응답 검증 실패 */ -export class AuthValidationError extends AuthError { - override readonly name = 'AuthValidationError'; - override readonly code = 'AUTH_VALIDATION'; - readonly zodError: z.ZodError | null; - readonly endpoint: string | null; - - constructor(zodError: z.ZodError | null = null, endpoint: string | null = null) { - super('잘못된 인증 응답이에요'); - this.zodError = zodError; - this.endpoint = endpoint; - } - - getValidationDetails(): string | null { - if (!this.zodError) return null; - return this.zodError.issues - .map((issue) => `${issue.path.join('.')}: ${issue.message}`) - .join(', '); - } -} - -/** Provider별 로그인 실패 (Apple, Google 등) */ -export class AuthProviderError extends AuthError { - override readonly name = 'AuthProviderError'; - override readonly code = 'AUTH_PROVIDER_ERROR'; - readonly provider: string; - - constructor(provider: string, message?: string) { - super(message ?? `${provider} 로그인에 실패했어요`); - this.provider = provider; - } -} + if (error instanceof Error) return AuthErrors.unknown(error.message); + return AuthErrors.unknown(); + }, +} as const; export const isAuthError = (error: unknown): error is AuthError => error instanceof AuthError; export const isExpoCodedError = (error: unknown): error is ExpoCodedError => error instanceof Error && 'code' in error && typeof error.code === 'string'; -export const isCancelledError = (error: unknown): error is AuthLoginCancelledError => - error instanceof AuthLoginCancelledError; +export const isCancelledError = (error: unknown): boolean => + error instanceof AuthError && error.code === AuthErrorCode.LOGIN_CANCELLED; -export const isValidationError = (error: unknown): error is AuthValidationError => - error instanceof AuthValidationError; +export const isValidationError = (error: unknown): boolean => + error instanceof AuthError && error.code === AuthErrorCode.VALIDATION_FAILED; diff --git a/apps/mobile/src/features/auth/models/auth.model.ts b/apps/mobile/src/features/auth/models/auth.model.ts new file mode 100644 index 00000000..bc427729 --- /dev/null +++ b/apps/mobile/src/features/auth/models/auth.model.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +// === Auth Tokens === +export const authTokensSchema = z.object({ + userId: z.string(), + accessToken: z.string(), + refreshToken: z.string(), + userName: z.string().nullable(), + userProfileImage: z.string().nullable(), +}); +export type AuthTokens = z.infer; + +// === User === +export const subscriptionStatusSchema = z.enum(['FREE', 'ACTIVE', 'EXPIRED', 'CANCELLED']); +export type SubscriptionStatus = z.infer; + +export const userSchema = z.object({ + id: z.string(), + email: z.string(), + name: z.string().nullable(), + profileImage: z.string().nullable(), + userTag: z.string(), + subscriptionStatus: subscriptionStatusSchema, + isSubscribed: z.boolean(), + createdAt: z.date(), +}); +export type User = z.infer; + +// === Preference === +export const preferenceSchema = z.object({ + pushEnabled: z.boolean(), + nightPushEnabled: z.boolean(), +}); +export type Preference = z.infer; + +// === Consent === +export const consentSchema = z.object({ + termsAgreedAt: z.date().nullable(), + privacyAgreedAt: z.date().nullable(), + agreedTermsVersion: z.string().nullable(), + marketingAgreedAt: z.date().nullable(), +}); +export type Consent = z.infer; + +// === Register === +export const registerResultSchema = z.object({ + message: z.string(), + email: z.string(), +}); +export type RegisterResult = z.infer; + +// === Resend Verification === +export const resendVerificationResultSchema = z.object({ + message: z.string(), + email: z.string(), + retryAfterSeconds: z.number().optional(), +}); +export type ResendVerificationResult = z.infer; + +// === Update Marketing Consent === +export const updateMarketingConsentResultSchema = z.object({ + marketingAgreedAt: z.date().nullable(), +}); +export type UpdateMarketingConsentResult = z.infer; + +// === Policy === +export const AuthPolicy = { + isSubscriptionActive: (status: SubscriptionStatus): boolean => status === 'ACTIVE', +} as const; diff --git a/apps/mobile/src/features/auth/models/auth.policy.ts b/apps/mobile/src/features/auth/models/auth.policy.ts deleted file mode 100644 index 05993e52..00000000 --- a/apps/mobile/src/features/auth/models/auth.policy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { SubscriptionStatus } from './user.model'; - -/** Auth 도메인 비즈니스 규칙 */ -export const AuthPolicy = { - /** 구독 상태가 활성 상태인지 확인 */ - isSubscriptionActive: (status: SubscriptionStatus): boolean => status === 'ACTIVE', -} as const; diff --git a/apps/mobile/src/features/auth/models/user.model.ts b/apps/mobile/src/features/auth/models/user.model.ts deleted file mode 100644 index 9addb049..00000000 --- a/apps/mobile/src/features/auth/models/user.model.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type SubscriptionStatus = 'FREE' | 'ACTIVE' | 'EXPIRED' | 'CANCELLED'; - -export interface User { - id: string; - email: string; - name: string | null; - profileImage: string | null; - userTag: string; - subscriptionStatus: SubscriptionStatus; - isSubscribed: boolean; - createdAt: Date; -} diff --git a/apps/mobile/src/features/auth/presentations/components/SignUpVerificationForm.tsx b/apps/mobile/src/features/auth/presentations/components/SignUpVerificationForm.tsx index b1b09b74..ebbae068 100644 --- a/apps/mobile/src/features/auth/presentations/components/SignUpVerificationForm.tsx +++ b/apps/mobile/src/features/auth/presentations/components/SignUpVerificationForm.tsx @@ -1,3 +1,4 @@ +import { ErrorCode } from '@aido/errors'; import { VERIFICATION_CODE, type VerifyEmailInput, verifyEmailSchema } from '@aido/validators'; import { zodResolver } from '@hookform/resolvers/zod'; import { useCooldown } from '@src/features/auth/presentations/hooks/useCooldown'; @@ -5,6 +6,7 @@ import { resendVerificationMutationOptions } from '@src/features/auth/presentati import { verifyEmailMutationOptions } from '@src/features/auth/presentations/queries/verify-email-mutation-options'; import type { SignUpFormData } from '@src/features/auth/presentations/schemas/sign-up-form.schema'; import { ANIMATION } from '@src/shared/constants/animation.constants'; +import { ApiError } from '@src/shared/errors/api-error'; import { useAppToast } from '@src/shared/hooks/useAppToast'; import { HStack } from '@src/shared/ui/HStack/HStack'; import { Text } from '@src/shared/ui/Text/Text'; @@ -66,6 +68,13 @@ export const SignUpVerificationForm = () => { toast.success('인증 코드가 재발송되었습니다'); }, onError: (error) => { + // 타입 세이프: VERIFY_0753 쿨다운 에러 처리 + if (error instanceof ApiError && error.hasCode(ErrorCode.VERIFY_0753)) { + const remaining = error.details?.remainingSeconds; + if (typeof remaining === 'number') { + setCooldown(remaining); + } + } toast.error(error, { fallback: '인증 코드 재발송에 실패했습니다' }); }, }, diff --git a/apps/mobile/src/features/auth/presentations/queries/email-login-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/email-login-mutation-options.ts index b6c128e6..722ceaef 100644 --- a/apps/mobile/src/features/auth/presentations/queries/email-login-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/email-login-mutation-options.ts @@ -1,5 +1,6 @@ import { useAuth } from '@src/bootstrap/providers/auth-provider'; import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; export const emailLoginMutationOptions = () => { @@ -7,8 +8,10 @@ export const emailLoginMutationOptions = () => { const { setStatus } = useAuth(); return mutationOptions({ - mutationFn: ({ email, password }: { email: string; password: string }) => - authService.emailLogin(email, password), + mutationFn: async ({ email, password }: { email: string; password: string }) => { + const result = await authService.emailLogin(email, password); + return unwrap(result); + }, onSuccess: () => { setStatus('authenticated'); }, diff --git a/apps/mobile/src/features/auth/presentations/queries/exchange-code-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/exchange-code-mutation-options.ts index 572bfd56..6b0c1a40 100644 --- a/apps/mobile/src/features/auth/presentations/queries/exchange-code-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/exchange-code-mutation-options.ts @@ -1,5 +1,6 @@ import { useAuth } from '@src/bootstrap/providers/auth-provider'; import { useAuthService, useNotificationService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; export const exchangeCodeMutationOptions = () => { @@ -8,16 +9,22 @@ export const exchangeCodeMutationOptions = () => { const { setStatus } = useAuth(); return mutationOptions({ - mutationFn: authService.exchangeCode, + mutationFn: async (request: Parameters[0]) => { + const result = await authService.exchangeCode(request); + return unwrap(result); + }, onSuccess: async () => { setStatus('authenticated'); // Register push token after successful authentication try { - await notificationService.setupPushNotifications(); + const tokenResult = await notificationService.setupPushNotifications(); + if (!tokenResult.ok) { + console.log('[PushNotification] Setup skipped:', tokenResult.error); + } } catch (error) { // Silently fail - push notification is optional - console.log('[PushNotification] Setup skipped:', error); + console.log('[PushNotification] Setup error:', error); } }, }); diff --git a/apps/mobile/src/features/auth/presentations/queries/get-consent-query-options.ts b/apps/mobile/src/features/auth/presentations/queries/get-consent-query-options.ts index 56691eff..26858ea5 100644 --- a/apps/mobile/src/features/auth/presentations/queries/get-consent-query-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/get-consent-query-options.ts @@ -1,4 +1,5 @@ import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { queryOptions } from '@tanstack/react-query'; import { AUTH_QUERY_KEYS } from '../constants/auth-query-keys.constant'; @@ -8,6 +9,9 @@ export const getConsentQueryOptions = () => { return queryOptions({ queryKey: AUTH_QUERY_KEYS.consent(), - queryFn: () => authService.getConsent(), + queryFn: async () => { + const result = await authService.getConsent(); + return unwrap(result); + }, }); }; diff --git a/apps/mobile/src/features/auth/presentations/queries/get-me-query-options.ts b/apps/mobile/src/features/auth/presentations/queries/get-me-query-options.ts index a4ffba88..95f2874c 100644 --- a/apps/mobile/src/features/auth/presentations/queries/get-me-query-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/get-me-query-options.ts @@ -1,4 +1,5 @@ import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { queryOptions } from '@tanstack/react-query'; import { AUTH_QUERY_KEYS } from '../constants/auth-query-keys.constant'; @@ -7,6 +8,9 @@ export const getMeQueryOptions = () => { return queryOptions({ queryKey: AUTH_QUERY_KEYS.me(), - queryFn: () => authService.getCurrentUser(), + queryFn: async () => { + const result = await authService.getCurrentUser(); + return unwrap(result); + }, }); }; diff --git a/apps/mobile/src/features/auth/presentations/queries/get-preference-query-options.ts b/apps/mobile/src/features/auth/presentations/queries/get-preference-query-options.ts index b51c7630..0acc49ec 100644 --- a/apps/mobile/src/features/auth/presentations/queries/get-preference-query-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/get-preference-query-options.ts @@ -1,4 +1,5 @@ import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { queryOptions } from '@tanstack/react-query'; import { AUTH_QUERY_KEYS } from '../constants/auth-query-keys.constant'; @@ -8,6 +9,9 @@ export const getPreferenceQueryOptions = () => { return queryOptions({ queryKey: AUTH_QUERY_KEYS.preference(), - queryFn: () => authService.getPreference(), + queryFn: async () => { + const result = await authService.getPreference(); + return unwrap(result); + }, }); }; diff --git a/apps/mobile/src/features/auth/presentations/queries/logout-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/logout-mutation-options.ts index 47da30b1..3ffeac64 100644 --- a/apps/mobile/src/features/auth/presentations/queries/logout-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/logout-mutation-options.ts @@ -1,5 +1,6 @@ import { useAuth } from '@src/bootstrap/providers/auth-provider'; import { useAuthService, useNotificationService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; export const logoutMutationOptions = () => { @@ -13,13 +14,17 @@ export const logoutMutationOptions = () => { mutationFn: async () => { // Unregister push token before logout try { - await notificationService.unregisterPushToken(); + const unregisterResult = await notificationService.unregisterPushToken(); + if (!unregisterResult.ok) { + console.log('[PushNotification] Unregister skipped:', unregisterResult.error); + } } catch (error) { // Silently fail - continue with logout - console.log('[PushNotification] Unregister skipped:', error); + console.log('[PushNotification] Unregister error:', error); } - return authService.logout(); + const result = await authService.logout(); + return unwrap(result); }, // API 성공/실패 관계없이 항상 로그아웃 처리 onSuccess: () => { diff --git a/apps/mobile/src/features/auth/presentations/queries/open-apple-login-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/open-apple-login-mutation-options.ts index 9cff5aed..e750980a 100644 --- a/apps/mobile/src/features/auth/presentations/queries/open-apple-login-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/open-apple-login-mutation-options.ts @@ -1,5 +1,6 @@ import { useAuth } from '@src/bootstrap/providers/auth-provider'; import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; export const openAppleLoginMutationOptions = () => { @@ -7,9 +8,12 @@ export const openAppleLoginMutationOptions = () => { const { setStatus } = useAuth(); return mutationOptions({ - mutationFn: () => authService.openAppleLogin(), + mutationFn: async () => { + const result = await authService.openAppleLogin(); + return unwrap(result); + }, onSuccess: () => { - // Apple 로그인은 Repository에서 토큰 저장까지 완료 + // Apple 로그인은 Service에서 토큰 저장까지 완료 // AuthProvider status만 변경하면 자동으로 메인 화면 이동 setStatus('authenticated'); }, diff --git a/apps/mobile/src/features/auth/presentations/queries/open-google-login-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/open-google-login-mutation-options.ts index 477d2153..95ded08b 100644 --- a/apps/mobile/src/features/auth/presentations/queries/open-google-login-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/open-google-login-mutation-options.ts @@ -1,10 +1,14 @@ import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; export const openGoogleLoginMutationOptions = () => { const authService = useAuthService(); return mutationOptions({ - mutationFn: () => authService.openGoogleLogin(), + mutationFn: async () => { + const result = await authService.openGoogleLogin(); + return unwrap(result); + }, }); }; diff --git a/apps/mobile/src/features/auth/presentations/queries/open-kakao-login-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/open-kakao-login-mutation-options.ts index 95140b37..435182fc 100644 --- a/apps/mobile/src/features/auth/presentations/queries/open-kakao-login-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/open-kakao-login-mutation-options.ts @@ -1,10 +1,14 @@ import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; export const openKakaoLoginMutationOptions = () => { const authService = useAuthService(); return mutationOptions({ - mutationFn: () => authService.openKakaoLogin(), + mutationFn: async () => { + const result = await authService.openKakaoLogin(); + return unwrap(result); + }, }); }; diff --git a/apps/mobile/src/features/auth/presentations/queries/open-naver-login-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/open-naver-login-mutation-options.ts index 37c20ab9..16756014 100644 --- a/apps/mobile/src/features/auth/presentations/queries/open-naver-login-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/open-naver-login-mutation-options.ts @@ -1,10 +1,14 @@ import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; export const openNaverLoginMutationOptions = () => { const authService = useAuthService(); return mutationOptions({ - mutationFn: () => authService.openNaverLogin(), + mutationFn: async () => { + const result = await authService.openNaverLogin(); + return unwrap(result); + }, }); }; diff --git a/apps/mobile/src/features/auth/presentations/queries/register-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/register-mutation-options.ts index 71af673a..bd0b7479 100644 --- a/apps/mobile/src/features/auth/presentations/queries/register-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/register-mutation-options.ts @@ -1,11 +1,15 @@ import type { RegisterInput } from '@aido/validators'; import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; export const registerMutationOptions = () => { const authService = useAuthService(); return mutationOptions({ - mutationFn: (input: RegisterInput) => authService.register(input), + mutationFn: async (input: RegisterInput) => { + const result = await authService.register(input); + return unwrap(result); + }, }); }; diff --git a/apps/mobile/src/features/auth/presentations/queries/resend-verification-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/resend-verification-mutation-options.ts index 38f1d713..50b1dc70 100644 --- a/apps/mobile/src/features/auth/presentations/queries/resend-verification-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/resend-verification-mutation-options.ts @@ -1,11 +1,15 @@ import type { ResendVerificationInput } from '@aido/validators'; import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; export const resendVerificationMutationOptions = () => { const authService = useAuthService(); return mutationOptions({ - mutationFn: (input: ResendVerificationInput) => authService.resendVerification(input), + mutationFn: async (input: ResendVerificationInput) => { + const result = await authService.resendVerification(input); + return unwrap(result); + }, }); }; diff --git a/apps/mobile/src/features/auth/presentations/queries/update-marketing-consent-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/update-marketing-consent-mutation-options.ts index 45006e6f..b8f12a1f 100644 --- a/apps/mobile/src/features/auth/presentations/queries/update-marketing-consent-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/update-marketing-consent-mutation-options.ts @@ -1,7 +1,9 @@ -import type { ConsentResponse, UpdateMarketingConsentInput } from '@aido/validators'; +import type { UpdateMarketingConsentInput } from '@aido/validators'; import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; +import type { Consent } from '../../models/auth.model'; import { AUTH_QUERY_KEYS } from '../constants/auth-query-keys.constant'; export const updateMarketingConsentMutationOptions = () => { @@ -9,18 +11,21 @@ export const updateMarketingConsentMutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: (input: UpdateMarketingConsentInput) => authService.updateMarketingConsent(input), + mutationFn: async (input: UpdateMarketingConsentInput) => { + const result = await authService.updateMarketingConsent(input); + return unwrap(result); + }, onMutate: async (input) => { await queryClient.cancelQueries({ queryKey: AUTH_QUERY_KEYS.consent() }); - const previousData = queryClient.getQueryData(AUTH_QUERY_KEYS.consent()); + const previousData = queryClient.getQueryData(AUTH_QUERY_KEYS.consent()); - queryClient.setQueryData(AUTH_QUERY_KEYS.consent(), (old) => { + queryClient.setQueryData(AUTH_QUERY_KEYS.consent(), (old) => { if (!old) return old; return { ...old, - marketingAgreedAt: input.agreed ? new Date().toISOString() : null, + marketingAgreedAt: input.agreed ? new Date() : null, }; }); @@ -28,7 +33,7 @@ export const updateMarketingConsentMutationOptions = () => { }, onSuccess: (data) => { - queryClient.setQueryData(AUTH_QUERY_KEYS.consent(), (old) => { + queryClient.setQueryData(AUTH_QUERY_KEYS.consent(), (old) => { if (!old) return old; return { ...old, diff --git a/apps/mobile/src/features/auth/presentations/queries/update-preference-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/update-preference-mutation-options.ts index 8b8e48c3..a59b35b3 100644 --- a/apps/mobile/src/features/auth/presentations/queries/update-preference-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/update-preference-mutation-options.ts @@ -1,7 +1,9 @@ -import type { PreferenceResponse, UpdatePreferenceInput } from '@aido/validators'; +import type { UpdatePreferenceInput } from '@aido/validators'; import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; +import type { Preference } from '../../models/auth.model'; import { AUTH_QUERY_KEYS } from '../constants/auth-query-keys.constant'; export const updatePreferenceMutationOptions = () => { @@ -9,15 +11,16 @@ export const updatePreferenceMutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: (input: UpdatePreferenceInput) => authService.updatePreference(input), + mutationFn: async (input: UpdatePreferenceInput) => { + const result = await authService.updatePreference(input); + return unwrap(result); + }, onMutate: async (input) => { await queryClient.cancelQueries({ queryKey: AUTH_QUERY_KEYS.preference() }); - const previousData = queryClient.getQueryData( - AUTH_QUERY_KEYS.preference(), - ); + const previousData = queryClient.getQueryData(AUTH_QUERY_KEYS.preference()); - queryClient.setQueryData(AUTH_QUERY_KEYS.preference(), (old) => { + queryClient.setQueryData(AUTH_QUERY_KEYS.preference(), (old) => { if (!old) return old; return { ...old, ...input }; }); @@ -26,7 +29,7 @@ export const updatePreferenceMutationOptions = () => { }, onSuccess: (data) => { // 서버 응답으로 캐시를 정확하게 업데이트 (invalidate 대신 직접 업데이트로 블링킹 방지) - queryClient.setQueryData(AUTH_QUERY_KEYS.preference(), data); + queryClient.setQueryData(AUTH_QUERY_KEYS.preference(), data); }, onError: (_error, _input, context) => { if (context?.previousData) { diff --git a/apps/mobile/src/features/auth/presentations/queries/verify-email-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/verify-email-mutation-options.ts index 83d08e55..86ded5df 100644 --- a/apps/mobile/src/features/auth/presentations/queries/verify-email-mutation-options.ts +++ b/apps/mobile/src/features/auth/presentations/queries/verify-email-mutation-options.ts @@ -1,6 +1,7 @@ import type { VerifyEmailInput } from '@aido/validators'; import { useAuth } from '@src/bootstrap/providers/auth-provider'; import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; export const verifyEmailMutationOptions = () => { @@ -8,7 +9,10 @@ export const verifyEmailMutationOptions = () => { const { setStatus } = useAuth(); return mutationOptions({ - mutationFn: (input: VerifyEmailInput) => authService.verifyEmail(input), + mutationFn: async (input: VerifyEmailInput) => { + const result = await authService.verifyEmail(input); + return unwrap(result); + }, onSuccess: () => { setStatus('authenticated'); }, diff --git a/apps/mobile/src/features/auth/repositories/auth.mapper.ts b/apps/mobile/src/features/auth/repositories/auth.mapper.ts new file mode 100644 index 00000000..c066bd54 --- /dev/null +++ b/apps/mobile/src/features/auth/repositories/auth.mapper.ts @@ -0,0 +1,69 @@ +import type { + AuthTokens as AuthTokensDTO, + ConsentResponse, + CurrentUser, + PreferenceResponse, + RegisterResponse, + ResendVerificationResponse, + UpdateMarketingConsentResponse, +} from '@aido/validators'; +import type { + AuthTokens, + Consent, + Preference, + RegisterResult, + ResendVerificationResult, + UpdateMarketingConsentResult, + User, +} from '../models/auth.model'; +import { AuthPolicy } from '../models/auth.model'; + +export const toAuthTokens = (dto: AuthTokensDTO): AuthTokens => ({ + userId: dto.userId, + accessToken: dto.accessToken, + refreshToken: dto.refreshToken, + userName: dto.name, + userProfileImage: dto.profileImage, +}); + +export const toUser = (dto: CurrentUser): User => ({ + id: dto.userId, + email: dto.email, + name: dto.name, + profileImage: dto.profileImage, + userTag: dto.userTag, + subscriptionStatus: dto.subscriptionStatus, + createdAt: new Date(dto.createdAt), + isSubscribed: AuthPolicy.isSubscriptionActive(dto.subscriptionStatus), +}); + +export const toPreference = (dto: PreferenceResponse): Preference => ({ + pushEnabled: dto.pushEnabled, + nightPushEnabled: dto.nightPushEnabled, +}); + +export const toConsent = (dto: ConsentResponse): Consent => ({ + termsAgreedAt: dto.termsAgreedAt ? new Date(dto.termsAgreedAt) : null, + privacyAgreedAt: dto.privacyAgreedAt ? new Date(dto.privacyAgreedAt) : null, + agreedTermsVersion: dto.agreedTermsVersion, + marketingAgreedAt: dto.marketingAgreedAt ? new Date(dto.marketingAgreedAt) : null, +}); + +export const toRegisterResult = (dto: RegisterResponse): RegisterResult => ({ + message: dto.message, + email: dto.email, +}); + +export const toResendVerificationResult = ( + dto: ResendVerificationResponse, +): ResendVerificationResult => ({ + message: dto.message, + email: dto.email, + retryAfterSeconds: dto.retryAfterSeconds, +}); + +export const toUpdateMarketingConsentResult = ( + dto: UpdateMarketingConsentResponse, +): UpdateMarketingConsentResult => ({ + marketingAgreedAt: dto.marketingAgreedAt ? new Date(dto.marketingAgreedAt) : null, +}); diff --git a/apps/mobile/src/features/auth/repositories/auth.repository.impl.ts b/apps/mobile/src/features/auth/repositories/auth.repository.impl.ts index d25bc7dd..78d96b88 100644 --- a/apps/mobile/src/features/auth/repositories/auth.repository.impl.ts +++ b/apps/mobile/src/features/auth/repositories/auth.repository.impl.ts @@ -1,11 +1,13 @@ import { type AppleMobileCallbackInput, - type AuthTokens, - authTokensSchema, + type AuthTokens as AuthTokensDTO, + authTokensSchema as authTokensDtoSchema, + type ConsentResponse, type CurrentUser, consentResponseSchema, currentUserSchema, type ExchangeCodeInput, + type PreferenceResponse, preferenceResponseSchema, type RegisterInput, type RegisterResponse, @@ -14,79 +16,117 @@ import { registerResponseSchema, resendVerificationResponseSchema, type UpdateMarketingConsentInput, + type UpdateMarketingConsentResponse, type UpdatePreferenceInput, updateMarketingConsentResponseSchema, updatePreferenceResponseSchema, type VerifyEmailInput, } from '@aido/validators'; import type { HttpClient } from '@src/core/ports/http'; -import type { Storage } from '@src/core/ports/storage'; import { ENV } from '@src/shared/config/env'; +import type { ApiError } from '@src/shared/errors/api-error'; +import { ParseError } from '@src/shared/errors/infra-error'; +import { ok, type Result } from '@src/shared/errors/result'; import { Platform } from 'react-native'; -import { AuthValidationError } from '../models/auth.error'; + +import type { + AuthTokens, + Consent, + Preference, + RegisterResult, + ResendVerificationResult, + UpdateMarketingConsentResult, + User, +} from '../models/auth.model'; +import { + toAuthTokens, + toConsent, + toPreference, + toRegisterResult, + toResendVerificationResult, + toUpdateMarketingConsentResult, + toUser, +} from './auth.mapper'; import type { AuthRepository } from './auth.repository'; export class AuthRepositoryImpl implements AuthRepository { readonly #publicHttpClient: HttpClient; readonly #authHttpClient: HttpClient; - readonly #storage: Storage; - constructor(publicHttpClient: HttpClient, authHttpClient: HttpClient, storage: Storage) { + constructor(publicHttpClient: HttpClient, authHttpClient: HttpClient) { this.#publicHttpClient = publicHttpClient; this.#authHttpClient = authHttpClient; - this.#storage = storage; } - async exchangeCode(request: ExchangeCodeInput): Promise { - const { data } = await this.#publicHttpClient.post('v1/auth/exchange', request); + async exchangeCode(request: ExchangeCodeInput): Promise> { + const result = await this.#publicHttpClient.post('v1/auth/exchange', request); - const result = authTokensSchema.safeParse(data); - if (!result.success) { - throw new AuthValidationError(result.error, 'v1/auth/exchange'); - } + if (!result.ok) return result; - await Promise.all([ - this.#storage.set('accessToken', result.data.accessToken), - this.#storage.set('refreshToken', result.data.refreshToken), - ]); + const parsed = authTokensDtoSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[AuthRepository] Invalid exchangeCode response:', parsed.error); + throw new ParseError(); + } - return result.data; + return ok(toAuthTokens(parsed.data)); } - async emailLogin(email: string, password: string): Promise { - const { data } = await this.#publicHttpClient.post('v1/auth/login', { + async emailLogin(email: string, password: string): Promise> { + const result = await this.#publicHttpClient.post('v1/auth/login', { email, password, deviceType: Platform.OS === 'ios' ? 'IOS' : 'ANDROID', }); - const result = authTokensSchema.safeParse(data); - if (!result.success) { - throw new AuthValidationError(result.error, 'v1/auth/login'); + if (!result.ok) return result; + + const parsed = authTokensDtoSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[AuthRepository] Invalid emailLogin response:', parsed.error); + throw new ParseError(); } - await Promise.all([ - this.#storage.set('accessToken', result.data.accessToken), - this.#storage.set('refreshToken', result.data.refreshToken), - ]); + return ok(toAuthTokens(parsed.data)); + } + + async appleLogin(input: AppleMobileCallbackInput): Promise> { + const result = await this.#publicHttpClient.post( + 'v1/auth/apple/callback', + input, + ); + + if (!result.ok) return result; + + const parsed = authTokensDtoSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[AuthRepository] Invalid appleLogin response:', parsed.error); + throw new ParseError(); + } - return result.data; + return ok(toAuthTokens(parsed.data)); } - async getCurrentUser(): Promise { - const { data } = await this.#authHttpClient.get('v1/auth/me'); + async getCurrentUser(): Promise> { + const result = await this.#authHttpClient.get('v1/auth/me'); - const result = currentUserSchema.safeParse(data); - if (!result.success) { - throw new AuthValidationError(result.error, 'v1/auth/me'); + if (!result.ok) return result; + + const parsed = currentUserSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[AuthRepository] Invalid getCurrentUser response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toUser(parsed.data)); } - async logout(): Promise { - await this.#authHttpClient.post('v1/auth/logout'); - await Promise.all([this.#storage.remove('accessToken'), this.#storage.remove('refreshToken')]); + async logout(): Promise> { + const result = await this.#authHttpClient.post('v1/auth/logout'); + + if (!result.ok) return result; + + return ok(undefined); } getKakaoAuthUrl(redirectUri: string): string { @@ -101,105 +141,114 @@ export class AuthRepositoryImpl implements AuthRepository { return `${ENV.API_URL}/v1/auth/google/start?redirect_uri=${encodeURIComponent(redirectUri)}`; } - async getPreference() { - const { data } = await this.#authHttpClient.get('v1/auth/preference'); + async getPreference(): Promise> { + const result = await this.#authHttpClient.get('v1/auth/preference'); - const result = preferenceResponseSchema.safeParse(data); - if (!result.success) { - throw new AuthValidationError(result.error, 'v1/auth/preference'); - } + if (!result.ok) return result; - return result.data; - } - - async updatePreference(input: UpdatePreferenceInput) { - const { data } = await this.#authHttpClient.patch('v1/auth/preference', input); - - const result = updatePreferenceResponseSchema.safeParse(data); - if (!result.success) { - throw new AuthValidationError(result.error, 'v1/auth/preference'); + const parsed = preferenceResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[AuthRepository] Invalid getPreference response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toPreference(parsed.data)); } - async getConsent() { - const { data } = await this.#authHttpClient.get('v1/auth/consent'); + async updatePreference(input: UpdatePreferenceInput): Promise> { + const result = await this.#authHttpClient.patch( + 'v1/auth/preference', + input, + ); + + if (!result.ok) return result; - const result = consentResponseSchema.safeParse(data); - if (!result.success) { - throw new AuthValidationError(result.error, 'v1/auth/consent'); + const parsed = updatePreferenceResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[AuthRepository] Invalid updatePreference response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toPreference(parsed.data)); } - async updateMarketingConsent(input: UpdateMarketingConsentInput) { - const { data } = await this.#authHttpClient.patch('v1/auth/consent/marketing', input); + async getConsent(): Promise> { + const result = await this.#authHttpClient.get('v1/auth/consent'); - const result = updateMarketingConsentResponseSchema.safeParse(data); - if (!result.success) { - throw new AuthValidationError(result.error, 'v1/auth/consent/marketing'); + if (!result.ok) return result; + + const parsed = consentResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[AuthRepository] Invalid getConsent response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toConsent(parsed.data)); } - async appleLogin(input: AppleMobileCallbackInput): Promise { - const { data } = await this.#publicHttpClient.post('v1/auth/apple/callback', input); + async updateMarketingConsent( + input: UpdateMarketingConsentInput, + ): Promise> { + const result = await this.#authHttpClient.patch( + 'v1/auth/consent/marketing', + input, + ); - const result = authTokensSchema.safeParse(data); - if (!result.success) { - throw new AuthValidationError(result.error, 'v1/auth/apple/callback'); - } + if (!result.ok) return result; - await Promise.all([ - this.#storage.set('accessToken', result.data.accessToken), - this.#storage.set('refreshToken', result.data.refreshToken), - ]); + const parsed = updateMarketingConsentResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[AuthRepository] Invalid updateMarketingConsent response:', parsed.error); + throw new ParseError(); + } - return result.data; + return ok(toUpdateMarketingConsentResult(parsed.data)); } - async register(input: RegisterInput): Promise { - const { data } = await this.#publicHttpClient.post('v1/auth/register', input); + async register(input: RegisterInput): Promise> { + const result = await this.#publicHttpClient.post('v1/auth/register', input); + + if (!result.ok) return result; - const result = registerResponseSchema.safeParse(data); - if (!result.success) { - throw new AuthValidationError(result.error, 'v1/auth/register'); + const parsed = registerResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[AuthRepository] Invalid register response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toRegisterResult(parsed.data)); } - async verifyEmail(input: VerifyEmailInput): Promise { - const { data } = await this.#publicHttpClient.post('v1/auth/verify-email', input); + async verifyEmail(input: VerifyEmailInput): Promise> { + const result = await this.#publicHttpClient.post('v1/auth/verify-email', input); - const result = authTokensSchema.safeParse(data); - if (!result.success) { - throw new AuthValidationError(result.error, 'v1/auth/verify-email'); - } + if (!result.ok) return result; - // Store tokens on successful verification - await Promise.all([ - this.#storage.set('accessToken', result.data.accessToken), - this.#storage.set('refreshToken', result.data.refreshToken), - ]); + const parsed = authTokensDtoSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[AuthRepository] Invalid verifyEmail response:', parsed.error); + throw new ParseError(); + } - return result.data; + return ok(toAuthTokens(parsed.data)); } - async resendVerification(input: ResendVerificationInput): Promise { - const { data } = await this.#publicHttpClient.post( + async resendVerification( + input: ResendVerificationInput, + ): Promise> { + const result = await this.#publicHttpClient.post( 'v1/auth/resend-verification', input, ); - const result = resendVerificationResponseSchema.safeParse(data); - if (!result.success) { - throw new AuthValidationError(result.error, 'v1/auth/resend-verification'); + if (!result.ok) return result; + + const parsed = resendVerificationResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[AuthRepository] Invalid resendVerification response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toResendVerificationResult(parsed.data)); } } diff --git a/apps/mobile/src/features/auth/repositories/auth.repository.ts b/apps/mobile/src/features/auth/repositories/auth.repository.ts index e611882b..23c65c8d 100644 --- a/apps/mobile/src/features/auth/repositories/auth.repository.ts +++ b/apps/mobile/src/features/auth/repositories/auth.repository.ts @@ -1,51 +1,54 @@ import type { AppleMobileCallbackInput, - AuthTokens, - ConsentResponse, - CurrentUser, ExchangeCodeInput, - PreferenceResponse, RegisterInput, - RegisterResponse, ResendVerificationInput, - ResendVerificationResponse, UpdateMarketingConsentInput, - UpdateMarketingConsentResponse, UpdatePreferenceInput, - UpdatePreferenceResponse, VerifyEmailInput, } from '@aido/validators'; +import type { ApiError } from '@src/shared/errors/api-error'; +import type { Result } from '@src/shared/errors/result'; -export interface AuthRepository { - exchangeCode(request: ExchangeCodeInput): Promise; - - emailLogin(email: string, password: string): Promise; - - getCurrentUser(): Promise; - - logout(): Promise; - - getKakaoAuthUrl(redirectUri: string): string; - - getNaverAuthUrl(redirectUri: string): string; - - getGoogleAuthUrl(redirectUri: string): string; +import type { + AuthTokens, + Consent, + Preference, + RegisterResult, + ResendVerificationResult, + UpdateMarketingConsentResult, + User, +} from '../models/auth.model'; - getPreference(): Promise; +export interface AuthRepository { + // 인증 + exchangeCode(request: ExchangeCodeInput): Promise>; + emailLogin(email: string, password: string): Promise>; + appleLogin(input: AppleMobileCallbackInput): Promise>; + logout(): Promise>; - updatePreference(input: UpdatePreferenceInput): Promise; + // 사용자 정보 + getCurrentUser(): Promise>; - getConsent(): Promise; + // 설정 + getPreference(): Promise>; + updatePreference(input: UpdatePreferenceInput): Promise>; + // 동의 + getConsent(): Promise>; updateMarketingConsent( input: UpdateMarketingConsentInput, - ): Promise; - - appleLogin(input: AppleMobileCallbackInput): Promise; + ): Promise>; - register(input: RegisterInput): Promise; + // 회원가입 + register(input: RegisterInput): Promise>; + verifyEmail(input: VerifyEmailInput): Promise>; + resendVerification( + input: ResendVerificationInput, + ): Promise>; - verifyEmail(input: VerifyEmailInput): Promise; - - resendVerification(input: ResendVerificationInput): Promise; + // OAuth URL 생성 (동기 메서드) + getKakaoAuthUrl(redirectUri: string): string; + getNaverAuthUrl(redirectUri: string): string; + getGoogleAuthUrl(redirectUri: string): string; } diff --git a/apps/mobile/src/features/auth/services/auth.mapper.ts b/apps/mobile/src/features/auth/services/auth.mapper.ts deleted file mode 100644 index c458db20..00000000 --- a/apps/mobile/src/features/auth/services/auth.mapper.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { AuthTokens as AuthTokensDTO, CurrentUser } from '@aido/validators'; -import { AuthPolicy } from '../models/auth.policy'; -import type { AuthTokens } from '../models/auth-tokens.model'; -import type { User } from '../models/user.model'; - -export const toUser = (dto: CurrentUser): User => ({ - id: dto.userId, - email: dto.email, - name: dto.name, - profileImage: dto.profileImage, - userTag: dto.userTag, - subscriptionStatus: dto.subscriptionStatus, - createdAt: new Date(dto.createdAt), - isSubscribed: AuthPolicy.isSubscriptionActive(dto.subscriptionStatus), -}); - -export const toAuthTokens = (dto: AuthTokensDTO): AuthTokens => ({ - userId: dto.userId, - accessToken: dto.accessToken, - refreshToken: dto.refreshToken, - userName: dto.name, - userProfileImage: dto.profileImage, -}); diff --git a/apps/mobile/src/features/auth/services/auth.service.ts b/apps/mobile/src/features/auth/services/auth.service.ts index 2a6ddc69..7ee2c9fd 100644 --- a/apps/mobile/src/features/auth/services/auth.service.ts +++ b/apps/mobile/src/features/auth/services/auth.service.ts @@ -1,36 +1,32 @@ import type { - AppleMobileCallbackInput, - ConsentResponse, ExchangeCodeInput, - PreferenceResponse, RegisterInput, - RegisterResponse, ResendVerificationInput, - ResendVerificationResponse, UpdateMarketingConsentInput, - UpdateMarketingConsentResponse, UpdatePreferenceInput, - UpdatePreferenceResponse, VerifyEmailInput, } from '@aido/validators'; +import type { Storage } from '@src/core/ports/storage'; import { ENV } from '@src/shared/config/env'; +import type { ApiError } from '@src/shared/errors/api-error'; +import { err, ok, type Result } from '@src/shared/errors/result'; import * as AppleAuthentication from 'expo-apple-authentication'; import { makeRedirectUri } from 'expo-auth-session'; import * as Linking from 'expo-linking'; import * as WebBrowser from 'expo-web-browser'; import { WebBrowserResultType } from 'expo-web-browser'; -import { - AuthError, - AuthLoginCancelledError, - AuthProviderError, - AuthValidationError, - isAuthError, - isExpoCodedError, -} from '../models/auth.error'; -import type { AuthTokens } from '../models/auth-tokens.model'; -import type { User } from '../models/user.model'; + +import { type AuthError, AuthErrors, isAuthError, isExpoCodedError } from '../models/auth.error'; +import type { + AuthTokens, + Consent, + Preference, + RegisterResult, + ResendVerificationResult, + UpdateMarketingConsentResult, + User, +} from '../models/auth.model'; import type { AuthRepository } from '../repositories/auth.repository'; -import { toAuthTokens, toUser } from './auth.mapper'; type OAuthProvider = 'kakao' | 'naver' | 'google'; @@ -40,11 +36,15 @@ const OAUTH_PATHS: Record = { google: 'auth/google', }; +export type AuthServiceError = ApiError | AuthError; + export class AuthService { readonly #authRepository: AuthRepository; + readonly #storage: Storage; - constructor(authRepository: AuthRepository) { + constructor(authRepository: AuthRepository, storage: Storage) { this.#authRepository = authRepository; + this.#storage = storage; } private getRedirectUri = (provider: OAuthProvider): string => @@ -73,7 +73,18 @@ export class AuthService { return null; }; - private openOAuthLogin = async (provider: OAuthProvider): Promise => { + private saveTokens = async (accessToken: string, refreshToken: string): Promise => { + await Promise.all([ + this.#storage.set('accessToken', accessToken), + this.#storage.set('refreshToken', refreshToken), + ]); + }; + + private clearTokens = async (): Promise => { + await Promise.all([this.#storage.remove('accessToken'), this.#storage.remove('refreshToken')]); + }; + + private openOAuthLogin = async (provider: OAuthProvider): Promise> => { const redirectUri = this.getRedirectUri(provider); const authUrlGetters: Record string> = { @@ -91,35 +102,35 @@ export class AuthService { if (result.type === 'success') { const code = this.extractCodeFromUrl(result.url); if (!code) { - throw new AuthValidationError(null, null); + return err(AuthErrors.noCodeReceived()); } - return code; + return ok(code); } if ( result.type === WebBrowserResultType.CANCEL || result.type === WebBrowserResultType.DISMISS ) { - throw new AuthLoginCancelledError(); + return err(AuthErrors.loginCancelled()); } // WebBrowserResultType.OPENED, WebBrowserResultType.LOCKED - throw new AuthError('OAuth 인증 중 문제가 발생했어요'); + return err(AuthErrors.unknown('OAuth 인증 중 문제가 발생했어요')); }; - openKakaoLogin = (): Promise => { + openKakaoLogin = (): Promise> => { return this.openOAuthLogin('kakao'); }; - openNaverLogin = (): Promise => { + openNaverLogin = (): Promise> => { return this.openOAuthLogin('naver'); }; - openGoogleLogin = (): Promise => { + openGoogleLogin = (): Promise> => { return this.openOAuthLogin('google'); }; - openAppleLogin = async (): Promise => { + openAppleLogin = async (): Promise> => { try { const credential = await AppleAuthentication.signInAsync({ requestedScopes: [ @@ -130,77 +141,91 @@ export class AuthService { const idToken = credential.identityToken; if (!idToken) { - throw new AuthProviderError('apple', 'Apple 인증 토큰을 받지 못했어요'); + return err(AuthErrors.providerError('apple', 'Apple 인증 토큰을 받지 못했어요')); } - const input: AppleMobileCallbackInput = { + const result = await this.#authRepository.appleLogin({ idToken, userName: credential.fullName?.givenName ?? undefined, deviceType: 'IOS', - }; + }); + if (!result.ok) return result; - const dto = await this.#authRepository.appleLogin(input); - return toAuthTokens(dto); + await this.saveTokens(result.value.accessToken, result.value.refreshToken); + return result; } catch (error) { if (isAuthError(error)) { - throw error; + return err(error); } if (isExpoCodedError(error)) { - throw AuthError.fromExpoAppleError(error); + return err(AuthErrors.fromExpoAppleError(error)); } - throw AuthError.fromUnknown(error); + return err(AuthErrors.fromUnknown(error)); } }; - emailLogin = async (email: string, password: string): Promise => { - const dto = await this.#authRepository.emailLogin(email, password); - return toAuthTokens(dto); + emailLogin = async (email: string, password: string): Promise> => { + const result = await this.#authRepository.emailLogin(email, password); + if (!result.ok) return result; + + await this.saveTokens(result.value.accessToken, result.value.refreshToken); + return result; }; - exchangeCode = async (request: ExchangeCodeInput): Promise => { - const dto = await this.#authRepository.exchangeCode(request); - return toAuthTokens(dto); + exchangeCode = async (request: ExchangeCodeInput): Promise> => { + const result = await this.#authRepository.exchangeCode(request); + if (!result.ok) return result; + + await this.saveTokens(result.value.accessToken, result.value.refreshToken); + return result; }; - getCurrentUser = async (): Promise => { - const dto = await this.#authRepository.getCurrentUser(); - return toUser(dto); + getCurrentUser = async (): Promise> => { + return this.#authRepository.getCurrentUser(); }; - logout = async (): Promise => { - return this.#authRepository.logout(); + logout = async (): Promise> => { + const result = await this.#authRepository.logout(); + // 성공/실패 관계없이 로컬 토큰은 삭제 + await this.clearTokens(); + return result; }; - getPreference = async (): Promise => { + getPreference = async (): Promise> => { return this.#authRepository.getPreference(); }; - updatePreference = async (input: UpdatePreferenceInput): Promise => { + updatePreference = async ( + input: UpdatePreferenceInput, + ): Promise> => { return this.#authRepository.updatePreference(input); }; - getConsent = async (): Promise => { + getConsent = async (): Promise> => { return this.#authRepository.getConsent(); }; updateMarketingConsent = async ( input: UpdateMarketingConsentInput, - ): Promise => { + ): Promise> => { return this.#authRepository.updateMarketingConsent(input); }; - register = async (input: RegisterInput): Promise => { + register = async (input: RegisterInput): Promise> => { return this.#authRepository.register(input); }; - verifyEmail = async (input: VerifyEmailInput): Promise => { - const dto = await this.#authRepository.verifyEmail(input); - return toAuthTokens(dto); + verifyEmail = async (input: VerifyEmailInput): Promise> => { + const result = await this.#authRepository.verifyEmail(input); + if (!result.ok) return result; + + await this.saveTokens(result.value.accessToken, result.value.refreshToken); + return result; }; resendVerification = async ( input: ResendVerificationInput, - ): Promise => { + ): Promise> => { return this.#authRepository.resendVerification(input); }; } diff --git a/apps/mobile/src/features/friend/models/friend.error.ts b/apps/mobile/src/features/friend/models/friend.error.ts index 7992d134..d55ca0e3 100644 --- a/apps/mobile/src/features/friend/models/friend.error.ts +++ b/apps/mobile/src/features/friend/models/friend.error.ts @@ -1,47 +1,26 @@ -import { ClientError } from '@src/shared/errors'; +import type { BusinessError } from '@src/shared/errors'; -/** Friend 도메인 기본 에러 */ -export class FriendError extends ClientError { - override readonly name: string = 'FriendError'; - readonly code: string = 'FRIEND_ERROR'; +export const FriendErrorCode = { + INVALID_TAG: 'FRIEND_INVALID_TAG', + EMPTY_TAG: 'FRIEND_EMPTY_TAG', +} as const; - constructor(message: string = '친구 기능에 실패했어요') { - super(message); - } - - /** 알 수 없는 에러 → FriendError 변환 */ - static fromUnknown(error: unknown): FriendError { - if (error instanceof FriendError) return error; - if (error instanceof Error) return new FriendError(error.message); - return new FriendError(); - } -} +export type FriendErrorCode = (typeof FriendErrorCode)[keyof typeof FriendErrorCode]; -/** 응답 검증 실패 */ -export class FriendValidationError extends FriendError { - override readonly name = 'FriendValidationError'; - override readonly code = 'FRIEND_VALIDATION'; +export class FriendError extends Error implements BusinessError { + override readonly name = 'FriendError'; - constructor() { - super('잘못된 응답 형식이에요'); + constructor( + public readonly code: FriendErrorCode, + message: string, + ) { + super(message); } } -/** 태그 형식 오류 */ -export class InvalidTagError extends FriendError { - override readonly name = 'InvalidTagError'; - override readonly code = 'INVALID_TAG'; +export const FriendErrors = { + invalidTag: () => new FriendError(FriendErrorCode.INVALID_TAG, '올바른 태그 형식이 아니에요'), + emptyTag: () => new FriendError(FriendErrorCode.EMPTY_TAG, '태그를 입력해주세요'), +} as const; - constructor() { - super('올바른 태그 형식이 아니에요'); - } -} - -// 타입 가드 export const isFriendError = (error: unknown): error is FriendError => error instanceof FriendError; - -export const isValidationError = (error: unknown): error is FriendValidationError => - error instanceof FriendValidationError; - -export const isInvalidTagError = (error: unknown): error is InvalidTagError => - error instanceof InvalidTagError; diff --git a/apps/mobile/src/features/friend/models/friend.model.ts b/apps/mobile/src/features/friend/models/friend.model.ts index 11fa3c38..f24db963 100644 --- a/apps/mobile/src/features/friend/models/friend.model.ts +++ b/apps/mobile/src/features/friend/models/friend.model.ts @@ -1,80 +1,29 @@ import { z } from 'zod'; -// ============================================================ -// 친구 요청 사용자 스키마 -// ============================================================ - -/** 친구 요청 사용자 도메인 모델 */ -export const friendRequestUserSchema = z.object({ +export const FriendUserSchema = z.object({ id: z.string(), userTag: z.string(), name: z.string().nullable(), profileImage: z.string().nullable(), - requestedAt: z.date(), -}); -export type FriendRequestUser = z.infer; - -// ============================================================ -// 친구 요청 목록 스키마 -// ============================================================ - -/** 받은 친구 요청 목록 결과 */ -export const receivedRequestsResultSchema = z.object({ - requests: z.array(friendRequestUserSchema), - totalCount: z.number(), - hasMore: z.boolean(), -}); -export type ReceivedRequestsResult = z.infer; - -/** 보낸 친구 요청 목록 결과 */ -export const sentRequestsResultSchema = z.object({ - requests: z.array(friendRequestUserSchema), - totalCount: z.number(), - hasMore: z.boolean(), + followId: z.string(), + friendsSince: z.date(), }); -export type SentRequestsResult = z.infer; +export type FriendUser = z.infer; -// ============================================================ -// 친구 스키마 -// ============================================================ - -/** 친구 도메인 모델 */ -export const friendUserSchema = z.object({ - followId: z.string(), +export const FriendRequestSchema = z.object({ id: z.string(), userTag: z.string(), name: z.string().nullable(), profileImage: z.string().nullable(), - friendsSince: z.date(), -}); -export type FriendUser = z.infer; - -/** 친구 목록 결과 */ -export const friendsResultSchema = z.object({ - friends: z.array(friendUserSchema), - totalCount: z.number(), - hasMore: z.boolean(), -}); -export type FriendsResult = z.infer; - -// ============================================================ -// 친구 요청 보내기 스키마 -// ============================================================ - -/** 친구 요청 보내기 결과 */ -export const sendRequestResultSchema = z.object({ - message: z.string(), - autoAccepted: z.boolean(), + requestedAt: z.date(), }); -export type SendRequestResult = z.infer; +export type FriendRequest = z.infer; -// ============================================================ -// Policy -// ============================================================ +export interface SendRequestResult { + autoAccepted: boolean; +} -/** Friend 도메인 비즈니스 규칙 */ export const FriendPolicy = { - /** 태그 형식: #0000 ~ #9999 */ isValidTag(tag: string): boolean { return /^#\d{4}$/.test(tag); }, diff --git a/apps/mobile/src/features/friend/presentations/components/FriendList.tsx b/apps/mobile/src/features/friend/presentations/components/FriendList.tsx index 700b92da..8ac73241 100644 --- a/apps/mobile/src/features/friend/presentations/components/FriendList.tsx +++ b/apps/mobile/src/features/friend/presentations/components/FriendList.tsx @@ -22,7 +22,7 @@ export function FriendList() { ); const removeMutation = useMutation(removeFriendMutationOptions()); - const allFriends = data.pages.flatMap((page) => page.friends); + const allFriends = data.pages.flatMap((page) => page.items); const totalCount = data.pages[0]?.totalCount ?? 0; return ( diff --git a/apps/mobile/src/features/friend/presentations/components/FriendRequestRow.tsx b/apps/mobile/src/features/friend/presentations/components/FriendRequestRow.tsx index 7554d7d7..7a37e362 100644 --- a/apps/mobile/src/features/friend/presentations/components/FriendRequestRow.tsx +++ b/apps/mobile/src/features/friend/presentations/components/FriendRequestRow.tsx @@ -1,10 +1,10 @@ import { ListRow } from '@src/shared/ui/ListRow/ListRow'; import { Avatar } from 'heroui-native'; import type { ReactNode } from 'react'; -import type { FriendRequestUser } from '../../models/friend.model'; +import type { FriendRequest } from '../../models/friend.model'; interface FriendRequestRowProps { - user: FriendRequestUser; + user: FriendRequest; actions: ReactNode; } diff --git a/apps/mobile/src/features/friend/presentations/components/ReceivedRequestList.tsx b/apps/mobile/src/features/friend/presentations/components/ReceivedRequestList.tsx index 8c5fea9f..6f62fa7f 100644 --- a/apps/mobile/src/features/friend/presentations/components/ReceivedRequestList.tsx +++ b/apps/mobile/src/features/friend/presentations/components/ReceivedRequestList.tsx @@ -12,7 +12,7 @@ import { useMutation, useSuspenseInfiniteQuery } from '@tanstack/react-query'; import { times } from 'es-toolkit/compat'; import { Skeleton } from 'heroui-native'; import { ActivityIndicator, ScrollView } from 'react-native'; -import type { FriendRequestUser } from '../../models/friend.model'; +import type { FriendRequest } from '../../models/friend.model'; import { acceptRequestMutationOptions } from '../queries/accept-request-mutation-options'; import { getReceivedRequestsQueryOptions } from '../queries/get-received-requests-query-options'; import { rejectRequestMutationOptions } from '../queries/reject-request-mutation-options'; @@ -38,7 +38,7 @@ export function ReceivedRequestList() { }); }; - const allRequests = data.pages.flatMap((page) => page.requests); + const allRequests = data.pages.flatMap((page) => page.items); const totalCount = data.pages[0]?.totalCount ?? 0; return ( @@ -51,7 +51,7 @@ export function ReceivedRequestList() { } data={allRequests} - renderItem={({ item }: { item: FriendRequestUser }) => { + renderItem={({ item }: { item: FriendRequest }) => { const isProcessing = (acceptMutation.isPending && acceptMutation.variables === item.id) || (rejectMutation.isPending && rejectMutation.variables === item.id); diff --git a/apps/mobile/src/features/friend/presentations/components/SentRequestList.tsx b/apps/mobile/src/features/friend/presentations/components/SentRequestList.tsx index 7ffd2717..1f54016b 100644 --- a/apps/mobile/src/features/friend/presentations/components/SentRequestList.tsx +++ b/apps/mobile/src/features/friend/presentations/components/SentRequestList.tsx @@ -11,7 +11,7 @@ import { useMutation, useSuspenseInfiniteQuery } from '@tanstack/react-query'; import { times } from 'es-toolkit/compat'; import { Skeleton } from 'heroui-native'; import { ActivityIndicator, ScrollView } from 'react-native'; -import type { FriendRequestUser } from '../../models/friend.model'; +import type { FriendRequest } from '../../models/friend.model'; import { cancelRequestMutationOptions } from '../queries/cancel-request-mutation-options'; import { getSentRequestsQueryOptions } from '../queries/get-sent-requests-query-options'; import { FriendRequestRow } from './FriendRequestRow'; @@ -22,7 +22,7 @@ export function SentRequestList() { ); const cancelMutation = useMutation(cancelRequestMutationOptions()); - const allRequests = data.pages.flatMap((page) => page.requests); + const allRequests = data.pages.flatMap((page) => page.items); const totalCount = data.pages[0]?.totalCount ?? 0; return ( @@ -35,7 +35,7 @@ export function SentRequestList() { } data={allRequests} - renderItem={({ item }: { item: FriendRequestUser }) => { + renderItem={({ item }: { item: FriendRequest }) => { const isProcessing = cancelMutation.isPending && cancelMutation.variables === item.id; return ( diff --git a/apps/mobile/src/features/friend/presentations/queries/accept-request-mutation-options.ts b/apps/mobile/src/features/friend/presentations/queries/accept-request-mutation-options.ts index 4e8f01b3..2e831717 100644 --- a/apps/mobile/src/features/friend/presentations/queries/accept-request-mutation-options.ts +++ b/apps/mobile/src/features/friend/presentations/queries/accept-request-mutation-options.ts @@ -1,4 +1,5 @@ import { useFriendService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; import { FRIEND_QUERY_KEYS } from '../constants/friend-query-keys.constant'; @@ -7,10 +8,15 @@ export const acceptRequestMutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: (userId: string) => friendService.acceptRequest(userId), + mutationFn: async (userId: string) => { + const result = await friendService.acceptRequest(userId); + return unwrap(result); + }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: FRIEND_QUERY_KEYS.received() }); - queryClient.invalidateQueries({ queryKey: FRIEND_QUERY_KEYS.friends() }); + Promise.all([ + queryClient.invalidateQueries({ queryKey: FRIEND_QUERY_KEYS.received() }), + queryClient.invalidateQueries({ queryKey: FRIEND_QUERY_KEYS.sent() }), + ]); }, }); }; diff --git a/apps/mobile/src/features/friend/presentations/queries/cancel-request-mutation-options.ts b/apps/mobile/src/features/friend/presentations/queries/cancel-request-mutation-options.ts index 09ab3d60..1f9a86eb 100644 --- a/apps/mobile/src/features/friend/presentations/queries/cancel-request-mutation-options.ts +++ b/apps/mobile/src/features/friend/presentations/queries/cancel-request-mutation-options.ts @@ -1,4 +1,5 @@ import { useFriendService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; import { FRIEND_QUERY_KEYS } from '../constants/friend-query-keys.constant'; @@ -7,7 +8,10 @@ export const cancelRequestMutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: (userId: string) => friendService.cancelRequest(userId), + mutationFn: async (userId: string) => { + const result = await friendService.cancelRequest(userId); + return unwrap(result); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: FRIEND_QUERY_KEYS.sent() }); }, diff --git a/apps/mobile/src/features/friend/presentations/queries/get-friends-query-options.ts b/apps/mobile/src/features/friend/presentations/queries/get-friends-query-options.ts index 092799d4..53a373fe 100644 --- a/apps/mobile/src/features/friend/presentations/queries/get-friends-query-options.ts +++ b/apps/mobile/src/features/friend/presentations/queries/get-friends-query-options.ts @@ -1,4 +1,5 @@ import { useFriendService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { infiniteQueryOptions } from '@tanstack/react-query'; import { FRIEND_QUERY_KEYS } from '../constants/friend-query-keys.constant'; @@ -7,13 +8,16 @@ export const getFriendsQueryOptions = () => { return infiniteQueryOptions({ queryKey: FRIEND_QUERY_KEYS.friends(), - queryFn: ({ pageParam }) => friendService.getFriends({ cursor: pageParam }), + queryFn: async ({ pageParam }) => { + const result = await friendService.getFriends({ cursor: pageParam }); + return unwrap(result); + }, initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => { - if (!lastPage.hasMore || lastPage.friends.length === 0) { + if (!lastPage.hasMore || lastPage.items.length === 0) { return undefined; } - return lastPage.friends[lastPage.friends.length - 1]?.followId; + return lastPage.items[lastPage.items.length - 1]?.followId; }, }); }; diff --git a/apps/mobile/src/features/friend/presentations/queries/get-received-requests-query-options.ts b/apps/mobile/src/features/friend/presentations/queries/get-received-requests-query-options.ts index 503b821a..68905494 100644 --- a/apps/mobile/src/features/friend/presentations/queries/get-received-requests-query-options.ts +++ b/apps/mobile/src/features/friend/presentations/queries/get-received-requests-query-options.ts @@ -1,4 +1,5 @@ import { useFriendService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { infiniteQueryOptions } from '@tanstack/react-query'; import { FRIEND_QUERY_KEYS } from '../constants/friend-query-keys.constant'; @@ -7,13 +8,16 @@ export const getReceivedRequestsQueryOptions = () => { return infiniteQueryOptions({ queryKey: FRIEND_QUERY_KEYS.received(), - queryFn: ({ pageParam }) => friendService.getReceivedRequests({ cursor: pageParam }), + queryFn: async ({ pageParam }) => { + const result = await friendService.getReceivedRequests({ cursor: pageParam }); + return unwrap(result); + }, initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => { - if (!lastPage.hasMore || lastPage.requests.length === 0) { + if (!lastPage.hasMore || lastPage.items.length === 0) { return undefined; } - return lastPage.requests[lastPage.requests.length - 1]?.id; + return lastPage.items[lastPage.items.length - 1]?.id; }, }); }; diff --git a/apps/mobile/src/features/friend/presentations/queries/get-sent-requests-query-options.ts b/apps/mobile/src/features/friend/presentations/queries/get-sent-requests-query-options.ts index 22927d46..f8bcf0e7 100644 --- a/apps/mobile/src/features/friend/presentations/queries/get-sent-requests-query-options.ts +++ b/apps/mobile/src/features/friend/presentations/queries/get-sent-requests-query-options.ts @@ -1,4 +1,5 @@ import { useFriendService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { infiniteQueryOptions } from '@tanstack/react-query'; import { FRIEND_QUERY_KEYS } from '../constants/friend-query-keys.constant'; @@ -7,13 +8,16 @@ export const getSentRequestsQueryOptions = () => { return infiniteQueryOptions({ queryKey: FRIEND_QUERY_KEYS.sent(), - queryFn: ({ pageParam }) => friendService.getSentRequests({ cursor: pageParam }), + queryFn: async ({ pageParam }) => { + const result = await friendService.getSentRequests({ cursor: pageParam }); + return unwrap(result); + }, initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => { - if (!lastPage.hasMore || lastPage.requests.length === 0) { + if (!lastPage.hasMore || lastPage.items.length === 0) { return undefined; } - return lastPage.requests[lastPage.requests.length - 1]?.id; + return lastPage.items[lastPage.items.length - 1]?.id; }, }); }; diff --git a/apps/mobile/src/features/friend/presentations/queries/reject-request-mutation-options.ts b/apps/mobile/src/features/friend/presentations/queries/reject-request-mutation-options.ts index c25a2522..d05e4841 100644 --- a/apps/mobile/src/features/friend/presentations/queries/reject-request-mutation-options.ts +++ b/apps/mobile/src/features/friend/presentations/queries/reject-request-mutation-options.ts @@ -1,4 +1,5 @@ import { useFriendService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; import { FRIEND_QUERY_KEYS } from '../constants/friend-query-keys.constant'; @@ -7,7 +8,10 @@ export const rejectRequestMutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: (userId: string) => friendService.rejectRequest(userId), + mutationFn: async (userId: string) => { + const result = await friendService.rejectRequest(userId); + return unwrap(result); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: FRIEND_QUERY_KEYS.received() }); }, diff --git a/apps/mobile/src/features/friend/presentations/queries/remove-friend-mutation-options.ts b/apps/mobile/src/features/friend/presentations/queries/remove-friend-mutation-options.ts index efe5d08b..e955406f 100644 --- a/apps/mobile/src/features/friend/presentations/queries/remove-friend-mutation-options.ts +++ b/apps/mobile/src/features/friend/presentations/queries/remove-friend-mutation-options.ts @@ -1,4 +1,5 @@ import { useFriendService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; import { FRIEND_QUERY_KEYS } from '../constants/friend-query-keys.constant'; @@ -7,7 +8,10 @@ export const removeFriendMutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: (userId: string) => friendService.removeFriend(userId), + mutationFn: async (userId: string) => { + const result = await friendService.removeFriend(userId); + return unwrap(result); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: FRIEND_QUERY_KEYS.friends() }); }, diff --git a/apps/mobile/src/features/friend/presentations/queries/send-request-by-tag-mutation-options.ts b/apps/mobile/src/features/friend/presentations/queries/send-request-by-tag-mutation-options.ts index efc8e170..bd203178 100644 --- a/apps/mobile/src/features/friend/presentations/queries/send-request-by-tag-mutation-options.ts +++ b/apps/mobile/src/features/friend/presentations/queries/send-request-by-tag-mutation-options.ts @@ -1,4 +1,5 @@ import { useFriendService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; import { FRIEND_QUERY_KEYS } from '../constants/friend-query-keys.constant'; @@ -7,7 +8,10 @@ export const sendRequestByTagMutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: (userTag: string) => friendService.sendRequestByTag(userTag), + mutationFn: async (userTag: string) => { + const result = await friendService.sendRequestByTag(userTag); + return unwrap(result); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: FRIEND_QUERY_KEYS.sent() }); }, diff --git a/apps/mobile/src/features/friend/repositories/friend.mapper.ts b/apps/mobile/src/features/friend/repositories/friend.mapper.ts new file mode 100644 index 00000000..b9bab74b --- /dev/null +++ b/apps/mobile/src/features/friend/repositories/friend.mapper.ts @@ -0,0 +1,47 @@ +import type { + FriendRequestUser as FriendRequestDTO, + FriendsListResponse, + FriendUser as FriendUserDTO, + ReceivedRequestsResponse, + SendFriendRequestResponse, + SentRequestsResponse, +} from '@aido/validators'; +import type { Page } from '@src/shared/types/page.type'; +import type { FriendRequest, FriendUser, SendRequestResult } from '../models/friend.model'; + +// DTO → Domain 변환 + +export const toFriendUser = (dto: FriendUserDTO): FriendUser => ({ + id: dto.id, + userTag: dto.userTag, + name: dto.name, + profileImage: dto.profileImage, + followId: dto.followId, + friendsSince: new Date(dto.friendsSince), +}); + +export const toFriendRequest = (dto: FriendRequestDTO): FriendRequest => ({ + id: dto.id, + userTag: dto.userTag, + name: dto.name, + profileImage: dto.profileImage, + requestedAt: new Date(dto.requestedAt), +}); + +export const toFriendsPage = (dto: FriendsListResponse): Page => ({ + items: dto.friends.map(toFriendUser), + totalCount: dto.totalCount, + hasMore: dto.hasMore, +}); + +export const toFriendRequestsPage = ( + dto: ReceivedRequestsResponse | SentRequestsResponse, +): Page => ({ + items: dto.requests.map(toFriendRequest), + totalCount: dto.totalCount, + hasMore: dto.hasMore, +}); + +export const toSendRequestResult = (dto: SendFriendRequestResponse): SendRequestResult => ({ + autoAccepted: dto.autoAccepted, +}); diff --git a/apps/mobile/src/features/friend/repositories/friend.repository.impl.ts b/apps/mobile/src/features/friend/repositories/friend.repository.impl.ts index b82ba85c..82010228 100644 --- a/apps/mobile/src/features/friend/repositories/friend.repository.impl.ts +++ b/apps/mobile/src/features/friend/repositories/friend.repository.impl.ts @@ -17,8 +17,13 @@ import { sentRequestsResponseSchema, } from '@aido/validators'; import type { HttpClient } from '@src/core/ports/http'; +import type { ApiError } from '@src/shared/errors/api-error'; +import { ParseError } from '@src/shared/errors/infra-error'; +import { ok, type Result } from '@src/shared/errors/result'; +import type { Page } from '@src/shared/types/page.type'; -import { FriendValidationError } from '../models/friend.error'; +import type { FriendRequest, FriendUser, SendRequestResult } from '../models/friend.model'; +import { toFriendRequestsPage, toFriendsPage, toSendRequestResult } from './friend.mapper'; import type { FriendRepository, PaginationParams } from './friend.repository'; export class FriendRepositoryImpl implements FriendRepository { @@ -28,122 +33,134 @@ export class FriendRepositoryImpl implements FriendRepository { this.#httpClient = httpClient; } - async sendRequest(userTag: string): Promise { - const { data } = await this.#httpClient.post( - `v1/follows/${userTag}`, + async sendRequest(userTag: string): Promise> { + const result = await this.#httpClient.post( + `v1/follows/${encodeURIComponent(userTag)}`, ); - const result = sendFriendRequestResponseSchema.safeParse(data); - if (!result.success) { - console.error('[FriendRepository] Invalid sendRequest response:', result.error); - throw new FriendValidationError(); - } - - return result.data; - } + if (!result.ok) return result; - #buildPaginatedUrl(basePath: string, params?: PaginationParams): string { - const searchParams = new URLSearchParams(); - if (params?.cursor) searchParams.set('cursor', params.cursor); - if (params?.limit) searchParams.set('limit', params.limit.toString()); + const parsed = sendFriendRequestResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[FriendRepository] Invalid sendRequest response:', parsed.error); + throw new ParseError(); + } - const queryString = searchParams.toString(); - return queryString ? `${basePath}?${queryString}` : basePath; + return ok(toSendRequestResult(parsed.data)); } - async getReceivedRequests(params?: PaginationParams): Promise { - const url = this.#buildPaginatedUrl('v1/follows/requests/received', params); + async getReceivedRequests( + params?: PaginationParams, + ): Promise, ApiError>> { + const result = await this.#httpClient.get( + 'v1/follows/requests/received', + { params: { cursor: params?.cursor, limit: params?.limit } }, + ); - const { data } = await this.#httpClient.get(url); + if (!result.ok) return result; - const result = receivedRequestsResponseSchema.safeParse(data); - if (!result.success) { - console.error('[FriendRepository] Invalid getReceivedRequests response:', result.error); - throw new FriendValidationError(); + const parsed = receivedRequestsResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[FriendRepository] Invalid getReceivedRequests response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toFriendRequestsPage(parsed.data)); } - async getSentRequests(params?: PaginationParams): Promise { - const url = this.#buildPaginatedUrl('v1/follows/requests/sent', params); + async getSentRequests(params?: PaginationParams): Promise, ApiError>> { + const result = await this.#httpClient.get('v1/follows/requests/sent', { + params: { cursor: params?.cursor, limit: params?.limit }, + }); - const { data } = await this.#httpClient.get(url); + if (!result.ok) return result; - const result = sentRequestsResponseSchema.safeParse(data); - if (!result.success) { - console.error('[FriendRepository] Invalid getSentRequests response:', result.error); - throw new FriendValidationError(); + const parsed = sentRequestsResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[FriendRepository] Invalid getSentRequests response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toFriendRequestsPage(parsed.data)); } - async acceptRequest(userId: string): Promise { - const { data } = await this.#httpClient.patch( - `v1/follows/${userId}/accept`, + async acceptRequest(userId: string): Promise> { + const result = await this.#httpClient.patch( + `v1/follows/${encodeURIComponent(userId)}/accept`, ); - const result = acceptFriendRequestResponseSchema.safeParse(data); - if (!result.success) { - console.error('[FriendRepository] Invalid acceptRequest response:', result.error); - throw new FriendValidationError(); + if (!result.ok) return result; + + const parsed = acceptFriendRequestResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[FriendRepository] Invalid acceptRequest response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(undefined); } - async rejectRequest(userId: string): Promise { - const { data } = await this.#httpClient.patch( - `v1/follows/${userId}/reject`, + async rejectRequest(userId: string): Promise> { + const result = await this.#httpClient.patch( + `v1/follows/${encodeURIComponent(userId)}/reject`, ); - const result = rejectFriendRequestResponseSchema.safeParse(data); - if (!result.success) { - console.error('[FriendRepository] Invalid rejectRequest response:', result.error); - throw new FriendValidationError(); + if (!result.ok) return result; + + const parsed = rejectFriendRequestResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[FriendRepository] Invalid rejectRequest response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(undefined); } - async cancelRequest(userId: string): Promise { - const { data } = await this.#httpClient.delete( - `v1/follows/${userId}`, + async cancelRequest(userId: string): Promise> { + const result = await this.#httpClient.delete( + `v1/follows/${encodeURIComponent(userId)}`, ); - const result = cancelFriendRequestResponseSchema.safeParse(data); - if (!result.success) { - console.error('[FriendRepository] Invalid cancelRequest response:', result.error); - throw new FriendValidationError(); + if (!result.ok) return result; + + const parsed = cancelFriendRequestResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[FriendRepository] Invalid cancelRequest response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(undefined); } - async getFriends(params?: PaginationParams): Promise { - const url = this.#buildPaginatedUrl('v1/follows/friends', params); + async getFriends(params?: PaginationParams): Promise, ApiError>> { + const result = await this.#httpClient.get('v1/follows/friends', { + params: { cursor: params?.cursor, limit: params?.limit }, + }); - const { data } = await this.#httpClient.get(url); + if (!result.ok) return result; - const result = friendsListResponseSchema.safeParse(data); - if (!result.success) { - console.error('[FriendRepository] Invalid getFriends response:', result.error); - throw new FriendValidationError(); + const parsed = friendsListResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[FriendRepository] Invalid getFriends response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toFriendsPage(parsed.data)); } - async removeFriend(userId: string): Promise { - const { data } = await this.#httpClient.delete(`v1/follows/${userId}`); + async removeFriend(userId: string): Promise> { + const result = await this.#httpClient.delete( + `v1/follows/${encodeURIComponent(userId)}`, + ); + + if (!result.ok) return result; - const result = removeFriendResponseSchema.safeParse(data); - if (!result.success) { - console.error('[FriendRepository] Invalid removeFriend response:', result.error); - throw new FriendValidationError(); + const parsed = removeFriendResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[FriendRepository] Invalid removeFriend response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(undefined); } } diff --git a/apps/mobile/src/features/friend/repositories/friend.repository.ts b/apps/mobile/src/features/friend/repositories/friend.repository.ts index d3363f2f..e0f0f7a2 100644 --- a/apps/mobile/src/features/friend/repositories/friend.repository.ts +++ b/apps/mobile/src/features/friend/repositories/friend.repository.ts @@ -1,13 +1,7 @@ -import type { - AcceptFriendRequestResponse, - CancelFriendRequestResponse, - FriendsListResponse, - ReceivedRequestsResponse, - RejectFriendRequestResponse, - RemoveFriendResponse, - SendFriendRequestResponse, - SentRequestsResponse, -} from '@aido/validators'; +import type { ApiError } from '@src/shared/errors/api-error'; +import type { Result } from '@src/shared/errors/result'; +import type { Page } from '@src/shared/types/page.type'; +import type { FriendRequest, FriendUser, SendRequestResult } from '../models/friend.model'; export interface PaginationParams { cursor?: string; @@ -15,12 +9,12 @@ export interface PaginationParams { } export interface FriendRepository { - sendRequest(userTag: string): Promise; - getReceivedRequests(params?: PaginationParams): Promise; - getSentRequests(params?: PaginationParams): Promise; - acceptRequest(userId: string): Promise; - rejectRequest(userId: string): Promise; - cancelRequest(userId: string): Promise; - getFriends(params?: PaginationParams): Promise; - removeFriend(userId: string): Promise; + sendRequest(userTag: string): Promise>; + getReceivedRequests(params?: PaginationParams): Promise, ApiError>>; + getSentRequests(params?: PaginationParams): Promise, ApiError>>; + acceptRequest(userId: string): Promise>; + rejectRequest(userId: string): Promise>; + cancelRequest(userId: string): Promise>; + getFriends(params?: PaginationParams): Promise, ApiError>>; + removeFriend(userId: string): Promise>; } diff --git a/apps/mobile/src/features/friend/services/friend.mapper.ts b/apps/mobile/src/features/friend/services/friend.mapper.ts deleted file mode 100644 index 510d7f35..00000000 --- a/apps/mobile/src/features/friend/services/friend.mapper.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { - FriendRequestUser as FriendRequestUserDTO, - FriendsListResponse, - FriendUser as FriendUserDTO, - ReceivedRequestsResponse, - SendFriendRequestResponse, - SentRequestsResponse, -} from '@aido/validators'; -import type { - FriendRequestUser, - FriendsResult, - FriendUser, - ReceivedRequestsResult, - SendRequestResult, - SentRequestsResult, -} from '../models/friend.model'; - -export const toFriendRequestUser = (dto: FriendRequestUserDTO): FriendRequestUser => ({ - id: dto.id, - userTag: dto.userTag, - name: dto.name, - profileImage: dto.profileImage, - requestedAt: new Date(dto.requestedAt), -}); - -export const toReceivedRequestsResult = ( - dto: ReceivedRequestsResponse, -): ReceivedRequestsResult => ({ - requests: dto.requests.map(toFriendRequestUser), - totalCount: dto.totalCount, - hasMore: dto.hasMore, -}); - -export const toSentRequestsResult = (dto: SentRequestsResponse): SentRequestsResult => ({ - requests: dto.requests.map(toFriendRequestUser), - totalCount: dto.totalCount, - hasMore: dto.hasMore, -}); - -export const toFriendUser = (dto: FriendUserDTO): FriendUser => ({ - followId: dto.followId, - id: dto.id, - userTag: dto.userTag, - name: dto.name, - profileImage: dto.profileImage, - friendsSince: new Date(dto.friendsSince), -}); - -export const toFriendsResult = (dto: FriendsListResponse): FriendsResult => ({ - friends: dto.friends.map(toFriendUser), - totalCount: dto.totalCount, - hasMore: dto.hasMore, -}); - -export const toSendRequestResult = (dto: SendFriendRequestResponse): SendRequestResult => ({ - message: dto.message, - autoAccepted: dto.autoAccepted, -}); diff --git a/apps/mobile/src/features/friend/services/friend.service.ts b/apps/mobile/src/features/friend/services/friend.service.ts index 9a5d67ad..07ac9ea1 100644 --- a/apps/mobile/src/features/friend/services/friend.service.ts +++ b/apps/mobile/src/features/friend/services/friend.service.ts @@ -1,18 +1,17 @@ -import { InvalidTagError } from '../models/friend.error'; +import type { ApiError } from '@src/shared/errors/api-error'; +import { err, type Result } from '@src/shared/errors/result'; +import type { Page } from '@src/shared/types/page.type'; + +import { type FriendError, FriendErrors } from '../models/friend.error'; import { FriendPolicy, - type FriendsResult, - type ReceivedRequestsResult, + type FriendRequest, + type FriendUser, type SendRequestResult, - type SentRequestsResult, } from '../models/friend.model'; import type { FriendRepository, PaginationParams } from '../repositories/friend.repository'; -import { - toFriendsResult, - toReceivedRequestsResult, - toSendRequestResult, - toSentRequestsResult, -} from './friend.mapper'; + +export type FriendServiceError = ApiError | FriendError; export class FriendService { readonly #repository: FriendRepository; @@ -21,43 +20,52 @@ export class FriendService { this.#repository = repository; } - sendRequestByTag = async (userTag: string): Promise => { + sendRequestByTag = async ( + userTag: string, + ): Promise> => { + // 1. 빈 값 검증 + if (!userTag.trim()) { + return err(FriendErrors.emptyTag()); + } + + // 2. 태그 형식 검증 if (!FriendPolicy.isValidTag(userTag)) { - throw new InvalidTagError(); + return err(FriendErrors.invalidTag()); } - const dto = await this.#repository.sendRequest(userTag); - return toSendRequestResult(dto); + // 3. Repository 호출 + return this.#repository.sendRequest(userTag); }; - getReceivedRequests = async (params?: PaginationParams): Promise => { - const dto = await this.#repository.getReceivedRequests(params); - return toReceivedRequestsResult(dto); + getReceivedRequests = async ( + params?: PaginationParams, + ): Promise, ApiError>> => { + return this.#repository.getReceivedRequests(params); }; - getSentRequests = async (params?: PaginationParams): Promise => { - const dto = await this.#repository.getSentRequests(params); - return toSentRequestsResult(dto); + getSentRequests = async ( + params?: PaginationParams, + ): Promise, ApiError>> => { + return this.#repository.getSentRequests(params); }; - acceptRequest = async (userId: string): Promise => { - await this.#repository.acceptRequest(userId); + acceptRequest = async (userId: string): Promise> => { + return this.#repository.acceptRequest(userId); }; - rejectRequest = async (userId: string): Promise => { - await this.#repository.rejectRequest(userId); + rejectRequest = async (userId: string): Promise> => { + return this.#repository.rejectRequest(userId); }; - cancelRequest = async (userId: string): Promise => { - await this.#repository.cancelRequest(userId); + cancelRequest = async (userId: string): Promise> => { + return this.#repository.cancelRequest(userId); }; - getFriends = async (params?: PaginationParams): Promise => { - const dto = await this.#repository.getFriends(params); - return toFriendsResult(dto); + getFriends = async (params?: PaginationParams): Promise, ApiError>> => { + return this.#repository.getFriends(params); }; - removeFriend = async (userId: string): Promise => { - await this.#repository.removeFriend(userId); + removeFriend = async (userId: string): Promise> => { + return this.#repository.removeFriend(userId); }; } diff --git a/apps/mobile/src/features/notification/models/notification.error.ts b/apps/mobile/src/features/notification/models/notification.error.ts index 16da19a2..e98d6f9f 100644 --- a/apps/mobile/src/features/notification/models/notification.error.ts +++ b/apps/mobile/src/features/notification/models/notification.error.ts @@ -1,71 +1,45 @@ -import { ClientError } from '@src/shared/errors'; +import type { BusinessError } from '@src/shared/errors'; -export class NotificationError extends ClientError { - override readonly name: string = 'NotificationError'; - readonly code: string = 'NOTIFICATION_ERROR'; +export const NotificationErrorCode = { + PERMISSION_DENIED: 'NOTIFICATION_PERMISSION_DENIED', + NOT_PHYSICAL_DEVICE: 'NOTIFICATION_NOT_PHYSICAL_DEVICE', + VALIDATION_FAILED: 'NOTIFICATION_VALIDATION_FAILED', +} as const; - constructor(message = '알림 작업에 실패했어요') { - super(message); - } - - /** 알 수 없는 에러 → NotificationError 변환 */ - static fromUnknown(error: unknown): NotificationError { - if (error instanceof NotificationError) return error; - if (error instanceof Error) return new NotificationError(error.message); - return new NotificationError(); - } -} - -/** - * Thrown when user denies notification permission - */ -export class NotificationPermissionDeniedError extends NotificationError { - override readonly name: string = 'NotificationPermissionDeniedError'; - override readonly code: string = 'NOTIFICATION_PERMISSION_DENIED'; +export type NotificationErrorCode = + (typeof NotificationErrorCode)[keyof typeof NotificationErrorCode]; - constructor() { - super('알림 권한이 거부되었어요. 설정에서 알림을 허용해주세요.'); - } -} +export class NotificationError extends Error implements BusinessError { + override readonly name = 'NotificationError'; -/** - * Thrown when push notifications are not available (simulator/emulator) - */ -export class NotificationNotPhysicalDeviceError extends NotificationError { - override readonly name: string = 'NotificationNotPhysicalDeviceError'; - override readonly code: string = 'NOTIFICATION_NOT_PHYSICAL_DEVICE'; - - constructor() { - super('푸시 알림은 실제 기기에서만 사용할 수 있어요.'); - } -} - -/** - * Thrown when push token validation fails - */ -export class NotificationValidationError extends NotificationError { - override readonly name: string = 'NotificationValidationError'; - override readonly code: string = 'NOTIFICATION_VALIDATION_ERROR'; - - constructor(message = '알림 데이터 검증에 실패했어요') { + constructor( + public readonly code: NotificationErrorCode, + message: string, + ) { super(message); } } -/** - * Type guard for notification errors - */ +export const NotificationErrors = { + permissionDenied: () => + new NotificationError( + NotificationErrorCode.PERMISSION_DENIED, + '알림 권한이 거부되었어요. 설정에서 알림을 허용해주세요.', + ), + notPhysicalDevice: () => + new NotificationError( + NotificationErrorCode.NOT_PHYSICAL_DEVICE, + '푸시 알림은 실제 기기에서만 사용할 수 있어요.', + ), + validationFailed: () => + new NotificationError(NotificationErrorCode.VALIDATION_FAILED, '알림 데이터 검증에 실패했어요'), +} as const; + export const isNotificationError = (error: unknown): error is NotificationError => error instanceof NotificationError; -export const isPermissionDeniedError = ( - error: unknown, -): error is NotificationPermissionDeniedError => error instanceof NotificationPermissionDeniedError; - -export const isNotPhysicalDeviceError = ( - error: unknown, -): error is NotificationNotPhysicalDeviceError => - error instanceof NotificationNotPhysicalDeviceError; +export const isPermissionDeniedError = (error: unknown): boolean => + error instanceof NotificationError && error.code === NotificationErrorCode.PERMISSION_DENIED; -export const isValidationError = (error: unknown): error is NotificationValidationError => - error instanceof NotificationValidationError; +export const isNotPhysicalDeviceError = (error: unknown): boolean => + error instanceof NotificationError && error.code === NotificationErrorCode.NOT_PHYSICAL_DEVICE; diff --git a/apps/mobile/src/features/notification/presentations/hooks/use-notification-handler.ts b/apps/mobile/src/features/notification/presentations/hooks/use-notification-handler.ts index 0eec3908..29a86f0c 100644 --- a/apps/mobile/src/features/notification/presentations/hooks/use-notification-handler.ts +++ b/apps/mobile/src/features/notification/presentations/hooks/use-notification-handler.ts @@ -13,15 +13,6 @@ interface UseNotificationHandlerOptions { isAuthenticated: boolean; } -/** - * 클라이언트 중심 라우팅 결정 - * type + context 기반으로 Internal route 생성 - * - * 장점: - * - 라우트 변경 시 클라이언트만 수정 - * - 타입별 fallback 로직 유연하게 처리 - * - 서버는 context만 전달하면 됨 - */ const getInternalRoute = (type: NotificationType, context?: NotificationContext): string | null => { switch (type) { // 친구 요청 @@ -68,12 +59,6 @@ export const useNotificationHandler = ({ isAuthenticated }: UseNotificationHandl const notificationService = useNotificationService(); const queryClient = useQueryClient(); - /** - * 알림 탭 시 실행 - * - * 1. 읽음 처리 (markAsRead → syncBadgeCount → invalidateQueries) - * 2. 라우팅 (action.type enum 기반 분기) - */ const handleNotificationResponse = useCallback( async (response: Notifications.NotificationResponse): Promise => { const rawData = response.notification.request.content.data; @@ -129,10 +114,6 @@ export const useNotificationHandler = ({ isAuthenticated }: UseNotificationHandl [isAuthenticated, notificationService, queryClient], ); - /** - * 포그라운드 알림 수신 시 실행 - * 읽음 처리 없이 배지/쿼리만 갱신 - */ const handleForegroundNotification = useCallback(() => { if (isAuthenticated) { notificationService.syncBadgeCount().catch(console.error); diff --git a/apps/mobile/src/features/notification/presentations/queries/get-notifications-infinite-query-options.ts b/apps/mobile/src/features/notification/presentations/queries/get-notifications-infinite-query-options.ts index 982ac2e5..784d00b0 100644 --- a/apps/mobile/src/features/notification/presentations/queries/get-notifications-infinite-query-options.ts +++ b/apps/mobile/src/features/notification/presentations/queries/get-notifications-infinite-query-options.ts @@ -1,4 +1,5 @@ import { useNotificationService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { infiniteQueryOptions } from '@tanstack/react-query'; import { notificationQueryKeys } from '../constants/notification-query-keys.constant'; @@ -8,12 +9,14 @@ export const getNotificationsInfiniteQueryOptions = (unreadOnly = false) => { return infiniteQueryOptions({ queryKey: notificationQueryKeys.list({ unreadOnly }), - queryFn: ({ pageParam }) => - notificationService.getNotifications({ + queryFn: async ({ pageParam }) => { + const result = await notificationService.getNotifications({ cursor: pageParam, limit: 20, unreadOnly, - }), + }); + return unwrap(result); + }, initialPageParam: undefined as number | undefined, getNextPageParam: (lastPage) => lastPage.hasMore ? (lastPage.nextCursor ?? undefined) : undefined, diff --git a/apps/mobile/src/features/notification/presentations/queries/get-unread-count-query-options.ts b/apps/mobile/src/features/notification/presentations/queries/get-unread-count-query-options.ts index ad579a43..ec0040e0 100644 --- a/apps/mobile/src/features/notification/presentations/queries/get-unread-count-query-options.ts +++ b/apps/mobile/src/features/notification/presentations/queries/get-unread-count-query-options.ts @@ -1,4 +1,5 @@ import { useNotificationService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { queryOptions } from '@tanstack/react-query'; import { notificationQueryKeys } from '../constants/notification-query-keys.constant'; @@ -8,7 +9,10 @@ export const getUnreadCountQueryOptions = () => { return queryOptions({ queryKey: notificationQueryKeys.unreadCount(), - queryFn: () => notificationService.getUnreadCount(), + queryFn: async () => { + const result = await notificationService.getUnreadCount(); + return unwrap(result); + }, staleTime: 30 * 1000, // 30초 }); }; diff --git a/apps/mobile/src/features/notification/presentations/queries/mark-all-as-read-mutation-options.ts b/apps/mobile/src/features/notification/presentations/queries/mark-all-as-read-mutation-options.ts index cd6f4331..6c9b4553 100644 --- a/apps/mobile/src/features/notification/presentations/queries/mark-all-as-read-mutation-options.ts +++ b/apps/mobile/src/features/notification/presentations/queries/mark-all-as-read-mutation-options.ts @@ -1,4 +1,5 @@ import { useNotificationService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; import { notificationQueryKeys } from '../constants/notification-query-keys.constant'; @@ -8,7 +9,10 @@ export const markAllAsReadMutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: () => notificationService.markAllAsRead(), + mutationFn: async () => { + const result = await notificationService.markAllAsRead(); + return unwrap(result); + }, onSuccess: async () => { // Invalidate notification queries to refresh data await queryClient.invalidateQueries({ diff --git a/apps/mobile/src/features/notification/presentations/queries/mark-as-read-mutation-options.ts b/apps/mobile/src/features/notification/presentations/queries/mark-as-read-mutation-options.ts index 79bb4e00..1c59b00d 100644 --- a/apps/mobile/src/features/notification/presentations/queries/mark-as-read-mutation-options.ts +++ b/apps/mobile/src/features/notification/presentations/queries/mark-as-read-mutation-options.ts @@ -1,4 +1,5 @@ import { useNotificationService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; import { notificationQueryKeys } from '../constants/notification-query-keys.constant'; @@ -8,7 +9,10 @@ export const markAsReadMutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: (notificationId: number) => notificationService.markAsRead(notificationId), + mutationFn: async (notificationId: number) => { + const result = await notificationService.markAsRead(notificationId); + return unwrap(result); + }, onSuccess: async () => { // Invalidate notification queries to refresh data await queryClient.invalidateQueries({ diff --git a/apps/mobile/src/features/notification/presentations/queries/register-push-token-mutation-options.ts b/apps/mobile/src/features/notification/presentations/queries/register-push-token-mutation-options.ts index 7ec405b2..873f7cca 100644 --- a/apps/mobile/src/features/notification/presentations/queries/register-push-token-mutation-options.ts +++ b/apps/mobile/src/features/notification/presentations/queries/register-push-token-mutation-options.ts @@ -1,9 +1,11 @@ import { useNotificationService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; import { - NotificationNotPhysicalDeviceError, - NotificationPermissionDeniedError, + isNotificationError, + isNotPhysicalDeviceError, + isPermissionDeniedError, } from '../../models/notification.error'; const MAX_RETRY_COUNT = 3; @@ -12,22 +14,27 @@ export const registerPushTokenMutationOptions = () => { const notificationService = useNotificationService(); return mutationOptions({ - mutationFn: notificationService.setupPushNotifications, + mutationFn: async () => { + const result = await notificationService.setupPushNotifications(); + return unwrap(result); + }, retry: (failureCount, error) => { // 재시도하지 않을 에러들 - if (error instanceof NotificationNotPhysicalDeviceError) return false; - if (error instanceof NotificationPermissionDeniedError) return false; + if (isNotificationError(error)) { + if (isNotPhysicalDeviceError(error)) return false; + if (isPermissionDeniedError(error)) return false; + } // 네트워크 에러 등은 최대 3회 재시도 return failureCount < MAX_RETRY_COUNT; }, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), onError: (error) => { - if (error instanceof NotificationNotPhysicalDeviceError) { + if (isNotPhysicalDeviceError(error)) { console.log('[PushNotification] Skipping: not a physical device'); return; } - if (error instanceof NotificationPermissionDeniedError) { + if (isPermissionDeniedError(error)) { console.log('[PushNotification] Permission denied by user'); return; } diff --git a/apps/mobile/src/features/notification/presentations/queries/unregister-push-token-mutation-options.ts b/apps/mobile/src/features/notification/presentations/queries/unregister-push-token-mutation-options.ts index 6846b724..6dac38c2 100644 --- a/apps/mobile/src/features/notification/presentations/queries/unregister-push-token-mutation-options.ts +++ b/apps/mobile/src/features/notification/presentations/queries/unregister-push-token-mutation-options.ts @@ -1,11 +1,15 @@ import { useNotificationService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; export const unregisterPushTokenMutationOptions = () => { const notificationService = useNotificationService(); return mutationOptions({ - mutationFn: notificationService.unregisterPushToken, + mutationFn: async () => { + const result = await notificationService.unregisterPushToken(); + return unwrap(result); + }, onError: (error) => { console.error('[PushNotification] Failed to unregister:', error); }, diff --git a/apps/mobile/src/features/notification/repositories/device-id.repository.ts b/apps/mobile/src/features/notification/repositories/device-id.repository.ts index 200e8d36..ed509f38 100644 --- a/apps/mobile/src/features/notification/repositories/device-id.repository.ts +++ b/apps/mobile/src/features/notification/repositories/device-id.repository.ts @@ -1,7 +1,3 @@ -/** - * Repository interface for device ID persistence - * Uses SecureStore for iOS Keychain persistence - */ export interface DeviceIdRepository { get: () => Promise; save: (deviceId: string) => Promise; diff --git a/apps/mobile/src/features/notification/repositories/notification.mapper.ts b/apps/mobile/src/features/notification/repositories/notification.mapper.ts new file mode 100644 index 00000000..5bd17190 --- /dev/null +++ b/apps/mobile/src/features/notification/repositories/notification.mapper.ts @@ -0,0 +1,27 @@ +import type { + Notification, + NotificationListResponse, + NotificationListResult, + ServerNotification, +} from '../models/notification.model'; + +export const toNotification = (server: ServerNotification): Notification => ({ + id: server.id, + userId: server.userId, + type: server.type as Notification['type'], + title: server.title, + body: server.body, + isRead: server.isRead, + metadata: server.metadata, + createdAt: new Date(server.createdAt), + readAt: server.readAt ? new Date(server.readAt) : null, +}); + +export const toNotificationListResult = ( + server: NotificationListResponse, +): NotificationListResult => ({ + notifications: server.notifications.map(toNotification), + unreadCount: server.unreadCount, + hasMore: server.hasMore, + nextCursor: server.nextCursor, +}); diff --git a/apps/mobile/src/features/notification/repositories/notification.repository.impl.ts b/apps/mobile/src/features/notification/repositories/notification.repository.impl.ts index 833e3a3b..2f060d8f 100644 --- a/apps/mobile/src/features/notification/repositories/notification.repository.impl.ts +++ b/apps/mobile/src/features/notification/repositories/notification.repository.impl.ts @@ -1,17 +1,20 @@ import type { HttpClient } from '@src/core/ports/http'; +import type { ApiError } from '@src/shared/errors/api-error'; +import { ParseError } from '@src/shared/errors/infra-error'; +import { ok, type Result } from '@src/shared/errors/result'; -import { NotificationValidationError } from '../models/notification.error'; import { type GetNotificationsQuery, type MarkReadResult, markReadResultSchema, - type NotificationListResponse, + type NotificationListResult, notificationListResponseSchema, type RegisterTokenResult, registerTokenResultSchema, type UnreadCountResult, unreadCountResultSchema, } from '../models/notification.model'; +import { toNotificationListResult } from './notification.mapper'; import type { NotificationRepository } from './notification.repository'; export class NotificationRepositoryImpl implements NotificationRepository { @@ -21,76 +24,97 @@ export class NotificationRepositoryImpl implements NotificationRepository { this.#httpClient = httpClient; } - async registerToken(token: string, deviceId: string): Promise { - const { data } = await this.#httpClient.post('v1/notifications/token', { + async registerToken( + token: string, + deviceId: string, + ): Promise> { + const result = await this.#httpClient.post('v1/notifications/token', { token, deviceId, }); - const result = registerTokenResultSchema.safeParse(data); - if (!result.success) { - console.error('[NotificationRepository] Invalid registerToken response:', result.error); - throw new NotificationValidationError(); + if (!result.ok) return result; + + const parsed = registerTokenResultSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[NotificationRepository] Invalid registerToken response:', parsed.error); + throw new ParseError(); } - return result.data; - } - async unregisterToken(deviceId?: string): Promise { - const params = deviceId ? `?deviceId=${encodeURIComponent(deviceId)}` : ''; - await this.#httpClient.delete(`v1/notifications/token${params}`); + return ok(parsed.data); } - async getNotifications(query?: GetNotificationsQuery): Promise { - const params = new URLSearchParams(); - if (query?.limit) params.set('limit', String(query.limit)); - if (query?.cursor) params.set('cursor', String(query.cursor)); - if (query?.unreadOnly) params.set('unreadOnly', 'true'); + async unregisterToken(deviceId?: string): Promise> { + const result = await this.#httpClient.delete('v1/notifications/token', { + params: deviceId ? { deviceId } : undefined, + }); + + if (!result.ok) return result; - const queryString = params.toString(); - const url = `v1/notifications${queryString ? `?${queryString}` : ''}`; + return ok(undefined); + } + + async getNotifications( + query?: GetNotificationsQuery, + ): Promise> { + const result = await this.#httpClient.get('v1/notifications', { + params: { + limit: query?.limit, + cursor: query?.cursor, + unreadOnly: query?.unreadOnly, + }, + }); - const { data } = await this.#httpClient.get(url); + if (!result.ok) return result; - const result = notificationListResponseSchema.safeParse(data); - if (!result.success) { - console.error('[NotificationRepository] Invalid getNotifications response:', result.error); - throw new NotificationValidationError(); + const parsed = notificationListResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[NotificationRepository] Invalid getNotifications response:', parsed.error); + throw new ParseError(); } - return result.data; + + return ok(toNotificationListResult(parsed.data)); } - async getUnreadCount(): Promise { - const { data } = await this.#httpClient.get('v1/notifications/unread-count'); + async getUnreadCount(): Promise> { + const result = await this.#httpClient.get('v1/notifications/unread-count'); + + if (!result.ok) return result; - const result = unreadCountResultSchema.safeParse(data); - if (!result.success) { - console.error('[NotificationRepository] Invalid getUnreadCount response:', result.error); - throw new NotificationValidationError(); + const parsed = unreadCountResultSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[NotificationRepository] Invalid getUnreadCount response:', parsed.error); + throw new ParseError(); } - return result.data; + + return ok(parsed.data); } - async markAsRead(notificationId: number): Promise { - const { data } = await this.#httpClient.patch( - `v1/notifications/${notificationId}/read`, - ); + async markAsRead(notificationId: number): Promise> { + const result = await this.#httpClient.patch(`v1/notifications/${notificationId}/read`); + + if (!result.ok) return result; - const result = markReadResultSchema.safeParse(data); - if (!result.success) { - console.error('[NotificationRepository] Invalid markAsRead response:', result.error); - throw new NotificationValidationError(); + const parsed = markReadResultSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[NotificationRepository] Invalid markAsRead response:', parsed.error); + throw new ParseError(); } - return result.data; + + return ok(parsed.data); } - async markAllAsRead(): Promise { - const { data } = await this.#httpClient.patch('v1/notifications/read-all'); + async markAllAsRead(): Promise> { + const result = await this.#httpClient.patch('v1/notifications/read-all'); + + if (!result.ok) return result; - const result = markReadResultSchema.safeParse(data); - if (!result.success) { - console.error('[NotificationRepository] Invalid markAllAsRead response:', result.error); - throw new NotificationValidationError(); + const parsed = markReadResultSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[NotificationRepository] Invalid markAllAsRead response:', parsed.error); + throw new ParseError(); } - return result.data; + + return ok(parsed.data); } } diff --git a/apps/mobile/src/features/notification/repositories/notification.repository.ts b/apps/mobile/src/features/notification/repositories/notification.repository.ts index db0d4c52..f18085fc 100644 --- a/apps/mobile/src/features/notification/repositories/notification.repository.ts +++ b/apps/mobile/src/features/notification/repositories/notification.repository.ts @@ -1,22 +1,24 @@ +import type { ApiError } from '@src/shared/errors/api-error'; +import type { Result } from '@src/shared/errors/result'; + import type { GetNotificationsQuery, MarkReadResult, - NotificationListResponse, + NotificationListResult, RegisterTokenResult, UnreadCountResult, } from '../models/notification.model'; -/** - * Repository interface for notification API operations - */ export interface NotificationRepository { // Push Token - registerToken: (token: string, deviceId: string) => Promise; - unregisterToken: (deviceId?: string) => Promise; + registerToken(token: string, deviceId: string): Promise>; + unregisterToken(deviceId?: string): Promise>; // Notifications - getNotifications: (query?: GetNotificationsQuery) => Promise; - getUnreadCount: () => Promise; - markAsRead: (notificationId: number) => Promise; - markAllAsRead: () => Promise; + getNotifications( + query?: GetNotificationsQuery, + ): Promise>; + getUnreadCount(): Promise>; + markAsRead(notificationId: number): Promise>; + markAllAsRead(): Promise>; } diff --git a/apps/mobile/src/features/notification/services/notification.mapper.ts b/apps/mobile/src/features/notification/services/notification.mapper.ts deleted file mode 100644 index 29b28eed..00000000 --- a/apps/mobile/src/features/notification/services/notification.mapper.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { - Notification, - NotificationListResponse, - NotificationListResult, - ServerNotification, -} from '../models/notification.model'; - -export const NotificationMapper = { - toNotification: (server: ServerNotification): Notification => ({ - id: server.id, - userId: server.userId, - type: server.type as Notification['type'], - title: server.title, - body: server.body, - isRead: server.isRead, - metadata: server.metadata, - createdAt: new Date(server.createdAt), - readAt: server.readAt ? new Date(server.readAt) : null, - }), - - toNotificationListResult: (server: NotificationListResponse): NotificationListResult => ({ - notifications: server.notifications.map(NotificationMapper.toNotification), - unreadCount: server.unreadCount, - hasMore: server.hasMore, - nextCursor: server.nextCursor, - }), -} as const; diff --git a/apps/mobile/src/features/notification/services/notification.service.ts b/apps/mobile/src/features/notification/services/notification.service.ts index ae9e404c..60a7b41b 100644 --- a/apps/mobile/src/features/notification/services/notification.service.ts +++ b/apps/mobile/src/features/notification/services/notification.service.ts @@ -1,5 +1,8 @@ +import type { ApiError } from '@src/shared/errors/api-error'; +import { ok, type Result } from '@src/shared/errors/result'; import * as Notifications from 'expo-notifications'; +import type { NotificationError } from '../models/notification.error'; import type { GetNotificationsQuery, MarkReadResult, @@ -8,9 +11,10 @@ import type { } from '../models/notification.model'; import type { NotificationRepository } from '../repositories/notification.repository'; import type { DeviceIdService } from './device-id.service'; -import { NotificationMapper } from './notification.mapper'; import type { PushTokenService } from './push-token.service'; +export type NotificationServiceError = ApiError | NotificationError; + export class NotificationService { readonly #notificationRepository: NotificationRepository; readonly #deviceIdService: DeviceIdService; @@ -27,38 +31,45 @@ export class NotificationService { } // 푸시 토큰 등록 (로그인 후 호출) - setupPushNotifications = async (): Promise => { - const [token, deviceId] = await Promise.all([ + setupPushNotifications = async (): Promise< + Result + > => { + const [tokenResult, deviceId] = await Promise.all([ this.#pushTokenService.getExpoPushToken(), this.#deviceIdService.get(), ]); - return this.#notificationRepository.registerToken(token, deviceId); + if (!tokenResult.ok) return tokenResult; + + return this.#notificationRepository.registerToken(tokenResult.value, deviceId); }; // 푸시 토큰 해제 (로그아웃 시 호출) - unregisterPushToken = async (): Promise => { + unregisterPushToken = async (): Promise> => { const deviceId = await this.#deviceIdService.get(); - await this.#notificationRepository.unregisterToken(deviceId); + return this.#notificationRepository.unregisterToken(deviceId); }; isSupported = (): boolean => this.#pushTokenService.isPhysicalDevice(); - getNotifications = async (query?: GetNotificationsQuery): Promise => { - const response = await this.#notificationRepository.getNotifications(query); - return NotificationMapper.toNotificationListResult(response); + getNotifications = async ( + query?: GetNotificationsQuery, + ): Promise> => { + return this.#notificationRepository.getNotifications(query); }; - getUnreadCount = async (): Promise => { + getUnreadCount = async (): Promise> => { const result = await this.#notificationRepository.getUnreadCount(); - return result.unreadCount; + if (!result.ok) return result; + + return ok(result.value.unreadCount); }; - markAsRead = async (notificationId: number): Promise => { + markAsRead = async (notificationId: number): Promise> => { return this.#notificationRepository.markAsRead(notificationId); }; - markAllAsRead = async (): Promise => { + markAllAsRead = async (): Promise> => { return this.#notificationRepository.markAllAsRead(); }; @@ -71,7 +82,9 @@ export class NotificationService { }; syncBadgeCount = async (): Promise => { - const unreadCount = await this.getUnreadCount(); - await this.setBadgeCount(unreadCount); + const result = await this.getUnreadCount(); + if (result.ok) { + await this.setBadgeCount(result.value); + } }; } diff --git a/apps/mobile/src/features/notification/services/push-token.service.ts b/apps/mobile/src/features/notification/services/push-token.service.ts index ede80574..892d9506 100644 --- a/apps/mobile/src/features/notification/services/push-token.service.ts +++ b/apps/mobile/src/features/notification/services/push-token.service.ts @@ -1,12 +1,9 @@ +import { err, ok, type Result } from '@src/shared/errors/result'; import Constants from 'expo-constants'; import * as Device from 'expo-device'; import * as Notifications from 'expo-notifications'; import { Platform } from 'react-native'; - -import { - NotificationNotPhysicalDeviceError, - NotificationPermissionDeniedError, -} from '../models/notification.error'; +import { type NotificationError, NotificationErrors } from '../models/notification.error'; export class PushTokenService { isPhysicalDevice = (): boolean => Device.isDevice; @@ -23,14 +20,14 @@ export class PushTokenService { return status === 'granted'; }; - getExpoPushToken = async (): Promise => { + getExpoPushToken = async (): Promise> => { if (!this.isPhysicalDevice()) { - throw new NotificationNotPhysicalDeviceError(); + return err(NotificationErrors.notPhysicalDevice()); } const isGranted = await this.requestPermission(); if (!isGranted) { - throw new NotificationPermissionDeniedError(); + return err(NotificationErrors.permissionDenied()); } if (Platform.OS === 'android') { @@ -45,7 +42,7 @@ export class PushTokenService { console.log('[PushToken]', tokenData.data); } - return tokenData.data; + return ok(tokenData.data); }; async #setupAndroidChannel(): Promise { diff --git a/apps/mobile/src/features/todo/models/todo.error.ts b/apps/mobile/src/features/todo/models/todo.error.ts index 83267090..0f76b38e 100644 --- a/apps/mobile/src/features/todo/models/todo.error.ts +++ b/apps/mobile/src/features/todo/models/todo.error.ts @@ -1,34 +1,25 @@ -import { ClientError } from '@src/shared/errors'; +import type { BusinessError } from '@src/shared/errors'; -/** Todo 도메인 기본 에러 */ -export class TodoError extends ClientError { - override readonly name: string = 'TodoError'; - readonly code: string = 'TODO_ERROR'; +export const TodoErrorCode = { + VALIDATION_FAILED: 'TODO_VALIDATION_FAILED', +} as const; - constructor(message: string = '할 일 기능에 실패했어요') { - super(message); - } - - /** 알 수 없는 에러 → TodoError 변환 */ - static fromUnknown(error: unknown): TodoError { - if (error instanceof TodoError) return error; - if (error instanceof Error) return new TodoError(error.message); - return new TodoError(); - } -} +export type TodoErrorCode = (typeof TodoErrorCode)[keyof typeof TodoErrorCode]; -/** 응답 검증 실패 */ -export class TodoValidationError extends TodoError { - override readonly name = 'TodoValidationError'; - override readonly code = 'TODO_VALIDATION'; +export class TodoError extends Error implements BusinessError { + override readonly name = 'TodoError'; - constructor() { - super('잘못된 응답 형식이에요'); + constructor( + public readonly code: TodoErrorCode, + message: string, + ) { + super(message); } } -// 타입 가드 -export const isTodoError = (error: unknown): error is TodoError => error instanceof TodoError; +export const TodoErrors = { + validationFailed: () => + new TodoError(TodoErrorCode.VALIDATION_FAILED, '응답 형식이 올바르지 않아요'), +} as const; -export const isValidationError = (error: unknown): error is TodoValidationError => - error instanceof TodoValidationError; +export const isTodoError = (error: unknown): error is TodoError => error instanceof TodoError; diff --git a/apps/mobile/src/features/todo/models/todo.model.ts b/apps/mobile/src/features/todo/models/todo.model.ts index 20459f73..1aaa3a6c 100644 --- a/apps/mobile/src/features/todo/models/todo.model.ts +++ b/apps/mobile/src/features/todo/models/todo.model.ts @@ -1,18 +1,8 @@ import { z } from 'zod'; -// ============================================================ -// Enum Schemas -// ============================================================ - -/** Todo 공개 범위 */ export const todoVisibilitySchema = z.enum(['PUBLIC', 'PRIVATE']); export type TodoVisibility = z.infer; -// ============================================================ -// Category 스키마 -// ============================================================ - -/** 카테고리 요약 정보 */ export const todoCategorySchema = z.object({ id: z.number(), name: z.string(), @@ -20,30 +10,23 @@ export const todoCategorySchema = z.object({ }); export type TodoCategory = z.infer; -// ============================================================ -// Todo 도메인 스키마 (프론트엔드 전용) -// ============================================================ - -/** Todo 아이템 - 프론트엔드 도메인 모델 */ export const todoItemSchema = z.object({ id: z.number(), title: z.string(), category: todoCategorySchema, completed: z.boolean(), - scheduledTime: z.date().nullable(), // Date 객체 (서버는 string) + scheduledTime: z.date().nullable(), isAllDay: z.boolean(), visibility: todoVisibilitySchema, }); export type TodoItem = z.infer; -/** 날짜별 Todo 목록 */ export const todosByDateSchema = z.object({ - date: z.string(), // YYYY-MM-DD + date: z.string(), todos: z.array(todoItemSchema), }); export type TodosByDate = z.infer; -/** Todo 목록 조회 결과 (페이지네이션 포함) */ export const todosResultSchema = z.object({ todos: z.array(todoItemSchema), hasNext: z.boolean(), @@ -51,28 +34,21 @@ export const todosResultSchema = z.object({ }); export type TodosResult = z.infer; -// ============================================================ -// AI 파싱 도메인 스키마 (프론트엔드 전용) -// ============================================================ - -/** 파싱된 Todo 데이터 - 프론트엔드 도메인 모델 */ export const parsedTodoDataSchema = z.object({ title: z.string(), - startDate: z.string(), // YYYY-MM-DD + startDate: z.string(), endDate: z.string().nullable(), - scheduledTime: z.string().nullable(), // HH:mm + scheduledTime: z.string().nullable(), isAllDay: z.boolean(), }); export type ParsedTodoData = z.infer; -/** 토큰 사용량 */ export const tokenUsageSchema = z.object({ input: z.number(), output: z.number(), }); export type TokenUsage = z.infer; -/** 파싱 메타데이터 */ export const parseTodoMetaSchema = z.object({ model: z.string(), processingTimeMs: z.number(), @@ -80,50 +56,21 @@ export const parseTodoMetaSchema = z.object({ }); export type ParseTodoMeta = z.infer; -/** AI 파싱 결과 */ export const parsedTodoResultSchema = z.object({ data: parsedTodoDataSchema, meta: parseTodoMetaSchema, }); export type ParsedTodoResult = z.infer; -/** AI 사용량 */ export const aiUsageSchema = z.object({ used: z.number(), limit: z.number(), - resetsAt: z.string(), // ISO datetime + resetsAt: z.string(), }); export type AiUsage = z.infer; -// ============================================================ -// Form Schemas -// ============================================================ - -/** 시간 형식 검증 (HH:mm) */ -const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; - -/** Todo 추가 폼 스키마 (startDate 제외 - prop에서 주입) */ -export const addTodoFormSchema = z.object({ - title: z.string().min(1, '제목을 입력해주세요').max(200, '제목은 200자 이하로 입력해주세요'), - scheduledTime: z.string().regex(timeRegex, '시간 형식이 올바르지 않습니다 (HH:mm)').nullish(), - isAllDay: z.boolean().default(true), - visibility: todoVisibilitySchema.default('PUBLIC'), - categoryId: z.number().int().default(1), -}); -export type AddTodoFormInput = z.input; - -// ============================================================ -// Policy (비즈니스 규칙) -// ============================================================ - -/** Todo Policy (비즈니스 규칙) */ export const TodoPolicy = { - /** 기본 Todo 색상 */ DEFAULT_COLOR: '#FF9500', - - /** Todo 색상 반환 (카테고리 색상 사용) */ getColor: (todo: TodoItem): string => todo.category.color, - - /** 공개 Todo인지 확인 */ isPublic: (todo: TodoItem): boolean => todo.visibility === 'PUBLIC', } as const; diff --git a/apps/mobile/src/features/todo/presentations/components/AddTodoBottomSheet.tsx b/apps/mobile/src/features/todo/presentations/components/AddTodoBottomSheet.tsx index b0a7bdc5..5f93806b 100644 --- a/apps/mobile/src/features/todo/presentations/components/AddTodoBottomSheet.tsx +++ b/apps/mobile/src/features/todo/presentations/components/AddTodoBottomSheet.tsx @@ -14,8 +14,8 @@ import { BottomSheet, Tabs } from 'heroui-native'; import { useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { Keyboard, View } from 'react-native'; -import { type AddTodoFormInput, addTodoFormSchema } from '../../models/todo.model'; import { createTodoMutationOptions } from '../queries/create-todo-mutation-options'; +import { type AddTodoFormInput, addTodoFormSchema } from '../schemas/add-todo-form.schema'; interface AddTodoBottomSheetProps { selectedDate: Date; diff --git a/apps/mobile/src/features/todo/presentations/components/UserAvatarList.tsx b/apps/mobile/src/features/todo/presentations/components/UserAvatarList.tsx index 276caaaa..402d9383 100644 --- a/apps/mobile/src/features/todo/presentations/components/UserAvatarList.tsx +++ b/apps/mobile/src/features/todo/presentations/components/UserAvatarList.tsx @@ -19,7 +19,7 @@ export function UserAvatarList() { } = useSuspenseInfiniteQuery(getFriendsQueryOptions()); const friends = useMemo( - () => friendsData.pages.flatMap((page) => page.friends), + () => friendsData.pages.flatMap((page) => page.items), [friendsData.pages], ); diff --git a/apps/mobile/src/features/todo/presentations/constants/todo-query-keys.constant.ts b/apps/mobile/src/features/todo/presentations/constants/todo-query-keys.constant.ts index b847b2e1..0ab04929 100644 --- a/apps/mobile/src/features/todo/presentations/constants/todo-query-keys.constant.ts +++ b/apps/mobile/src/features/todo/presentations/constants/todo-query-keys.constant.ts @@ -1,4 +1,11 @@ export const TODO_QUERY_KEYS = { all: ['todo'] as const, - byDate: (date: string) => [...TODO_QUERY_KEYS.all, 'byDate', date] as const, + + // 범위 쿼리 (캘린더 뷰: 한 달/한 주 전체 데이터) + ranges: () => [...TODO_QUERY_KEYS.all, 'range'] as const, + byRange: (start: string, end: string) => [...TODO_QUERY_KEYS.ranges(), start, end] as const, + + // 무한 스크롤 리스트 (선택된 날짜의 상세 목록) + lists: () => [...TODO_QUERY_KEYS.all, 'list'] as const, + listByDate: (date: string) => [...TODO_QUERY_KEYS.lists(), date] as const, } as const; diff --git a/apps/mobile/src/features/todo/presentations/queries/create-todo-mutation-options.ts b/apps/mobile/src/features/todo/presentations/queries/create-todo-mutation-options.ts index dce94183..4f6abbed 100644 --- a/apps/mobile/src/features/todo/presentations/queries/create-todo-mutation-options.ts +++ b/apps/mobile/src/features/todo/presentations/queries/create-todo-mutation-options.ts @@ -1,5 +1,6 @@ import type { CreateTodoInput } from '@aido/validators'; import { useTodoService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; import { TODO_QUERY_KEYS } from '../constants/todo-query-keys.constant'; @@ -9,7 +10,10 @@ export const createTodoMutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: (params: CreateTodoInput) => todoService.createTodo(params), + mutationFn: async (params: CreateTodoInput) => { + const result = await todoService.createTodo(params); + return unwrap(result); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: TODO_QUERY_KEYS.all }); }, diff --git a/apps/mobile/src/features/todo/presentations/queries/get-todos-by-date-query-options.ts b/apps/mobile/src/features/todo/presentations/queries/get-todos-by-date-query-options.ts deleted file mode 100644 index f8c7836a..00000000 --- a/apps/mobile/src/features/todo/presentations/queries/get-todos-by-date-query-options.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useTodoService } from '@src/bootstrap/providers/di-provider'; -import { formatTime } from '@src/shared/utils/date'; -import { queryOptions } from '@tanstack/react-query'; - -import type { TodoItem } from '../../models/todo.model'; -import { TodoPolicy } from '../../models/todo.model'; -import { TODO_QUERY_KEYS } from '../constants/todo-query-keys.constant'; - -/** Todo ViewModel (표시용) */ -export interface TodoItemViewModel extends TodoItem { - formattedTime: string | null; - color: string; -} - -export const getTodosByDateQueryOptions = (date: string, size: number) => { - const todoService = useTodoService(); - - return queryOptions({ - queryKey: TODO_QUERY_KEYS.byDate(date), - queryFn: () => todoService.getTodos({ startDate: date, size }), - select: (data): { todos: TodoItemViewModel[] } => ({ - todos: data.todos.map((todo) => ({ - ...todo, - formattedTime: todo.scheduledTime ? formatTime(todo.scheduledTime) : null, - color: TodoPolicy.getColor(todo), - })), - }), - }); -}; diff --git a/apps/mobile/src/features/todo/presentations/queries/get-todos-by-range-query-options.ts b/apps/mobile/src/features/todo/presentations/queries/get-todos-by-range-query-options.ts new file mode 100644 index 00000000..108ad4ef --- /dev/null +++ b/apps/mobile/src/features/todo/presentations/queries/get-todos-by-range-query-options.ts @@ -0,0 +1,39 @@ +import { useTodoService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; +import { formatTime } from '@src/shared/utils/date'; +import { queryOptions } from '@tanstack/react-query'; + +import type { TodoItem } from '../../models/todo.model'; +import { TodoPolicy } from '../../models/todo.model'; +import { TODO_QUERY_KEYS } from '../constants/todo-query-keys.constant'; + +export interface TodoItemViewModel extends TodoItem { + formattedTime: string | null; + color: string; +} + +const toViewModel = (todo: TodoItem): TodoItemViewModel => ({ + ...todo, + formattedTime: todo.scheduledTime ? formatTime(todo.scheduledTime) : null, + color: TodoPolicy.getColor(todo), +}); + +export const getTodosByRangeQueryOptions = (rangeStart: string, rangeEnd: string) => { + const todoService = useTodoService(); + + return queryOptions({ + queryKey: TODO_QUERY_KEYS.byRange(rangeStart, rangeEnd), + queryFn: async () => { + const result = await todoService.getTodos({ + startDate: rangeStart, + endDate: rangeEnd, + size: 200, + }); + return unwrap(result); + }, + select: (data) => ({ + todos: data.todos.map(toViewModel), + }), + staleTime: 30_000, + }); +}; diff --git a/apps/mobile/src/features/todo/presentations/queries/get-todos-infinite-query-options.ts b/apps/mobile/src/features/todo/presentations/queries/get-todos-infinite-query-options.ts index 9b1dab22..c9f128de 100644 --- a/apps/mobile/src/features/todo/presentations/queries/get-todos-infinite-query-options.ts +++ b/apps/mobile/src/features/todo/presentations/queries/get-todos-infinite-query-options.ts @@ -1,4 +1,5 @@ import { useTodoService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { formatTime } from '@src/shared/utils/date'; import { infiniteQueryOptions } from '@tanstack/react-query'; @@ -6,7 +7,6 @@ import type { TodoItem } from '../../models/todo.model'; import { TodoPolicy } from '../../models/todo.model'; import { TODO_QUERY_KEYS } from '../constants/todo-query-keys.constant'; -/** Todo ViewModel (표시용) */ export interface TodoItemViewModel extends TodoItem { formattedTime: string | null; color: string; @@ -22,20 +22,22 @@ export const getTodosInfiniteQueryOptions = (date: string) => { const todoService = useTodoService(); return infiniteQueryOptions({ - queryKey: TODO_QUERY_KEYS.byDate(date), - queryFn: ({ pageParam }) => - todoService.getTodos({ + queryKey: TODO_QUERY_KEYS.listByDate(date), + queryFn: async ({ pageParam }) => { + const result = await todoService.getTodos({ startDate: date, endDate: date, cursor: pageParam, size: 20, - }), + }); + return unwrap(result); + }, initialPageParam: undefined as number | undefined, getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined), select: (data) => ({ todos: data.pages.flatMap((page) => page.todos.map(toViewModel)), hasNextPage: data.pages.at(-1)?.hasNext ?? false, }), - placeholderData: (previousData) => previousData, + staleTime: 30_000, }); }; diff --git a/apps/mobile/src/features/todo/presentations/queries/parse-todo-mutation-options.ts b/apps/mobile/src/features/todo/presentations/queries/parse-todo-mutation-options.ts index 1c99c20d..c946bb2c 100644 --- a/apps/mobile/src/features/todo/presentations/queries/parse-todo-mutation-options.ts +++ b/apps/mobile/src/features/todo/presentations/queries/parse-todo-mutation-options.ts @@ -1,10 +1,14 @@ import { useTodoService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions } from '@tanstack/react-query'; export const parseTodoMutationOptions = () => { const todoService = useTodoService(); return mutationOptions({ - mutationFn: (text: string) => todoService.parseTodo(text), + mutationFn: async (text: string) => { + const result = await todoService.parseTodo(text); + return unwrap(result); + }, }); }; diff --git a/apps/mobile/src/features/todo/presentations/queries/toggle-todo-mutation-options.ts b/apps/mobile/src/features/todo/presentations/queries/toggle-todo-mutation-options.ts index 91397a91..c361b156 100644 --- a/apps/mobile/src/features/todo/presentations/queries/toggle-todo-mutation-options.ts +++ b/apps/mobile/src/features/todo/presentations/queries/toggle-todo-mutation-options.ts @@ -1,5 +1,6 @@ import type { ToggleTodoCompleteInput } from '@aido/validators'; import { useTodoService } from '@src/bootstrap/providers/di-provider'; +import { unwrap } from '@src/shared/errors/result'; import { mutationOptions, useQueryClient } from '@tanstack/react-query'; import { TODO_QUERY_KEYS } from '../constants/todo-query-keys.constant'; @@ -14,8 +15,10 @@ export const toggleTodoMutationOptions = () => { const queryClient = useQueryClient(); return mutationOptions({ - mutationFn: ({ todoId, body }: ToggleTodoMutationParams) => - todoService.toggleTodoComplete(todoId, body), + mutationFn: async ({ todoId, body }: ToggleTodoMutationParams) => { + const result = await todoService.toggleTodoComplete(todoId, body); + return unwrap(result); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: TODO_QUERY_KEYS.all }); }, diff --git a/apps/mobile/src/features/todo/presentations/schemas/add-todo-form.schema.ts b/apps/mobile/src/features/todo/presentations/schemas/add-todo-form.schema.ts new file mode 100644 index 00000000..28d7264b --- /dev/null +++ b/apps/mobile/src/features/todo/presentations/schemas/add-todo-form.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { todoVisibilitySchema } from '../../models/todo.model'; + +const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; + +export const addTodoFormSchema = z.object({ + title: z.string().min(1, '제목을 입력해주세요').max(200, '제목은 200자 이하로 입력해주세요'), + scheduledTime: z.string().regex(timeRegex, '시간 형식이 올바르지 않습니다 (HH:mm)').nullish(), + isAllDay: z.boolean().default(true), + visibility: todoVisibilitySchema.default('PUBLIC'), + categoryId: z.number().int().default(1), +}); + +export type AddTodoFormInput = z.input; diff --git a/apps/mobile/src/features/todo/services/todo.mapper.ts b/apps/mobile/src/features/todo/repositories/todo.mapper.ts similarity index 100% rename from apps/mobile/src/features/todo/services/todo.mapper.ts rename to apps/mobile/src/features/todo/repositories/todo.mapper.ts diff --git a/apps/mobile/src/features/todo/repositories/todo.repository.impl.ts b/apps/mobile/src/features/todo/repositories/todo.repository.impl.ts index 7aab08ad..90b6d8bb 100644 --- a/apps/mobile/src/features/todo/repositories/todo.repository.impl.ts +++ b/apps/mobile/src/features/todo/repositories/todo.repository.impl.ts @@ -12,8 +12,12 @@ import { todoSchema, } from '@aido/validators'; import type { HttpClient } from '@src/core/ports/http'; -import { TodoValidationError } from '../models/todo.error'; +import type { ApiError } from '@src/shared/errors/api-error'; +import { ParseError } from '@src/shared/errors/infra-error'; +import { ok, type Result } from '@src/shared/errors/result'; +import type { AiUsage, ParsedTodoResult, TodoItem, TodosResult } from '../models/todo.model'; +import { toAiUsage, toParsedTodoResult, toTodoItem, toTodoItems } from './todo.mapper'; import type { TodoRepository } from './todo.repository'; export class TodoRepositoryImpl implements TodoRepository { @@ -23,90 +27,91 @@ export class TodoRepositoryImpl implements TodoRepository { this.#httpClient = httpClient; } - #buildUrl( - basePath: string, - params: Record, - ): string { - const searchParams = new URLSearchParams(); - - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - searchParams.set(key, String(value)); - } - } - - const queryString = searchParams.toString(); - return queryString ? `${basePath}?${queryString}` : basePath; - } - - async getTodos(params: GetTodosQuery): Promise { - const url = this.#buildUrl('v1/todos', { - cursor: params.cursor, - size: params.size, - completed: params.completed, - startDate: params.startDate, - endDate: params.endDate, + async getTodos(params: GetTodosQuery): Promise> { + const result = await this.#httpClient.get('v1/todos', { + params: { + cursor: params.cursor, + size: params.size, + completed: params.completed, + startDate: params.startDate, + endDate: params.endDate, + }, }); - const { data } = await this.#httpClient.get(url); + if (!result.ok) return result; - const result = todoListResponseSchema.safeParse(data); - if (!result.success) { - console.error('[TodoRepository] Invalid getTodos response:', result.error); - throw new TodoValidationError(); + const parsed = todoListResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[TodoRepository] Invalid getTodos response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok({ + todos: toTodoItems(parsed.data.items), + hasNext: parsed.data.pagination.hasNext, + nextCursor: parsed.data.pagination.nextCursor, + }); } - async toggleTodoComplete(todoId: number, body: ToggleTodoCompleteInput): Promise { - const { data } = await this.#httpClient.patch<{ todo: Todo }>( + async toggleTodoComplete( + todoId: number, + body: ToggleTodoCompleteInput, + ): Promise> { + const result = await this.#httpClient.patch<{ todo: Todo }>( `v1/todos/${todoId}/complete`, body, ); - const result = todoSchema.safeParse(data.todo); - if (!result.success) { - console.error('[TodoRepository] Invalid toggleTodoComplete response:', result.error); - throw new TodoValidationError(); + if (!result.ok) return result; + + const parsed = todoSchema.safeParse(result.value.todo); + if (!parsed.success) { + console.error('[TodoRepository] Invalid toggleTodoComplete response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toTodoItem(parsed.data)); } - async createTodo(params: CreateTodoInput): Promise { - const { data } = await this.#httpClient.post<{ todo: Todo }>('v1/todos', params); + async createTodo(params: CreateTodoInput): Promise> { + const result = await this.#httpClient.post<{ todo: Todo }>('v1/todos', params); + + if (!result.ok) return result; - const result = todoSchema.safeParse(data.todo); - if (!result.success) { - console.error('[TodoRepository] Invalid createTodo response:', result.error); - throw new TodoValidationError(); + const parsed = todoSchema.safeParse(result.value.todo); + if (!parsed.success) { + console.error('[TodoRepository] Invalid createTodo response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toTodoItem(parsed.data)); } - async parseTodo(text: string): Promise { - const { data } = await this.#httpClient.post('v1/ai/parse-todo', { text }); + async parseTodo(text: string): Promise> { + const result = await this.#httpClient.post('v1/ai/parse-todo', { text }); - const result = parseTodoResponseSchema.safeParse(data); - if (!result.success) { - console.error('[TodoRepository] Invalid parseTodo response:', result.error); - throw new TodoValidationError(); + if (!result.ok) return result; + + const parsed = parseTodoResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[TodoRepository] Invalid parseTodo response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toParsedTodoResult(parsed.data)); } - async getAiUsage(): Promise { - const { data } = await this.#httpClient.get('v1/ai/usage'); + async getAiUsage(): Promise> { + const result = await this.#httpClient.get('v1/ai/usage'); + + if (!result.ok) return result; - const result = aiUsageResponseSchema.safeParse(data); - if (!result.success) { - console.error('[TodoRepository] Invalid getAiUsage response:', result.error); - throw new TodoValidationError(); + const parsed = aiUsageResponseSchema.safeParse(result.value); + if (!parsed.success) { + console.error('[TodoRepository] Invalid getAiUsage response:', parsed.error); + throw new ParseError(); } - return result.data; + return ok(toAiUsage(parsed.data)); } } diff --git a/apps/mobile/src/features/todo/repositories/todo.repository.ts b/apps/mobile/src/features/todo/repositories/todo.repository.ts index 88a42865..de8807d1 100644 --- a/apps/mobile/src/features/todo/repositories/todo.repository.ts +++ b/apps/mobile/src/features/todo/repositories/todo.repository.ts @@ -1,17 +1,16 @@ -import type { - AiUsageResponse, - CreateTodoInput, - GetTodosQuery, - ParseTodoResponse, - Todo, - TodoListResponse, - ToggleTodoCompleteInput, -} from '@aido/validators'; +import type { CreateTodoInput, GetTodosQuery, ToggleTodoCompleteInput } from '@aido/validators'; +import type { ApiError } from '@src/shared/errors/api-error'; +import type { Result } from '@src/shared/errors/result'; + +import type { AiUsage, ParsedTodoResult, TodoItem, TodosResult } from '../models/todo.model'; export interface TodoRepository { - getTodos(params: GetTodosQuery): Promise; - toggleTodoComplete(todoId: number, body: ToggleTodoCompleteInput): Promise; - createTodo(params: CreateTodoInput): Promise; - parseTodo(text: string): Promise; - getAiUsage(): Promise; + getTodos(params: GetTodosQuery): Promise>; + toggleTodoComplete( + todoId: number, + body: ToggleTodoCompleteInput, + ): Promise>; + createTodo(params: CreateTodoInput): Promise>; + parseTodo(text: string): Promise>; + getAiUsage(): Promise>; } diff --git a/apps/mobile/src/features/todo/services/todo.service.ts b/apps/mobile/src/features/todo/services/todo.service.ts index 5e93d03a..40458a30 100644 --- a/apps/mobile/src/features/todo/services/todo.service.ts +++ b/apps/mobile/src/features/todo/services/todo.service.ts @@ -1,8 +1,9 @@ import type { CreateTodoInput, GetTodosQuery, ToggleTodoCompleteInput } from '@aido/validators'; +import type { ApiError } from '@src/shared/errors/api-error'; +import type { Result } from '@src/shared/errors/result'; import type { AiUsage, ParsedTodoResult, TodoItem, TodosResult } from '../models/todo.model'; import type { TodoRepository } from '../repositories/todo.repository'; -import { toAiUsage, toParsedTodoResult, toTodoItem, toTodoItems } from './todo.mapper'; export class TodoService { readonly #todoRepository: TodoRepository; @@ -11,33 +12,26 @@ export class TodoService { this.#todoRepository = todoRepository; } - getTodos = async (params: GetTodosQuery): Promise => { - const response = await this.#todoRepository.getTodos(params); - - return { - todos: toTodoItems(response.items), - hasNext: response.pagination.hasNext, - nextCursor: response.pagination.nextCursor, - }; + getTodos = async (params: GetTodosQuery): Promise> => { + return this.#todoRepository.getTodos(params); }; - toggleTodoComplete = async (todoId: number, body: ToggleTodoCompleteInput): Promise => { - const todo = await this.#todoRepository.toggleTodoComplete(todoId, body); - return toTodoItem(todo); + toggleTodoComplete = async ( + todoId: number, + body: ToggleTodoCompleteInput, + ): Promise> => { + return this.#todoRepository.toggleTodoComplete(todoId, body); }; - createTodo = async (params: CreateTodoInput): Promise => { - const todo = await this.#todoRepository.createTodo(params); - return toTodoItem(todo); + createTodo = async (params: CreateTodoInput): Promise> => { + return this.#todoRepository.createTodo(params); }; - parseTodo = async (text: string): Promise => { - const response = await this.#todoRepository.parseTodo(text); - return toParsedTodoResult(response); + parseTodo = async (text: string): Promise> => { + return this.#todoRepository.parseTodo(text); }; - getAiUsage = async (): Promise => { - const response = await this.#todoRepository.getAiUsage(); - return toAiUsage(response); + getAiUsage = async (): Promise> => { + return this.#todoRepository.getAiUsage(); }; } diff --git a/apps/mobile/src/shared/errors/api-error.ts b/apps/mobile/src/shared/errors/api-error.ts index a11f296e..178dd550 100644 --- a/apps/mobile/src/shared/errors/api-error.ts +++ b/apps/mobile/src/shared/errors/api-error.ts @@ -1,26 +1,30 @@ +import type { ErrorCodeType } from '@aido/errors'; +import type { BusinessError } from './result'; + /** - * 서버 API 에러 - * - 서버에서 반환하는 에러 응답 (4xx, 5xx) - * - code: 서버 에러 코드 (AUTH_0101, TODO_0801 등) + * 서버 비즈니스 에러 (4xx) + * - code: @aido/errors의 비즈니스 에러 코드 * - status: HTTP 상태 코드 + * - details: 서버가 제공하는 추가 정보 */ -export class ApiError extends Error { +export class ApiError extends Error implements BusinessError { override readonly name = 'ApiError'; constructor( public readonly code: string, message: string, public readonly status: number, + public readonly details?: Record, ) { super(message); } - /** 특정 에러 코드인지 확인 */ - hasCode(code: string): boolean { + /** 타입 세이프 에러 코드 체크 - ErrorCode 상수 사용 권장 */ + hasCode(code: C): this is ApiError & { code: C } { return this.code === code; } - /** 특정 도메인 에러인지 확인 (prefix 기반) */ + /** 도메인 prefix 체크 (VERIFY_, FOLLOW_ 등) */ isDomain(prefix: string): boolean { return this.code.startsWith(prefix); } diff --git a/apps/mobile/src/shared/errors/client-error.ts b/apps/mobile/src/shared/errors/client-error.ts deleted file mode 100644 index 779af82b..00000000 --- a/apps/mobile/src/shared/errors/client-error.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 클라이언트 에러 인터페이스 - * - 모든 도메인 에러가 구현해야 하는 계약 - */ -export interface IClientError { - readonly name: string; - readonly code: string; // UPPER_SNAKE_CASE (예: INVALID_TAG) - readonly message: string; -} - -/** - * 클라이언트 에러 기본 클래스 - * - 앱 내부에서 발생하는 에러 (서버 에러와 구분) - * - 각 도메인별 에러 클래스가 상속하여 사용 - */ -export abstract class ClientError extends Error implements IClientError { - override readonly name: string = 'ClientError'; - abstract readonly code: string; -} - -/** ClientError 타입 가드 */ -export const isClientError = (error: unknown): error is ClientError => error instanceof ClientError; diff --git a/apps/mobile/src/shared/errors/index.ts b/apps/mobile/src/shared/errors/index.ts index 7904b610..1878bb48 100644 --- a/apps/mobile/src/shared/errors/index.ts +++ b/apps/mobile/src/shared/errors/index.ts @@ -1,4 +1,24 @@ // Base Classes - export { ApiError, isApiError } from './api-error'; -export { ClientError, type IClientError, isClientError } from './client-error'; + +// Infrastructure Errors +export { + InfraError, + isInfraError, + NetworkError, + ParseError, + ServerError, + TimeoutError, +} from './infra-error'; + +// Result Type +export { + type BusinessError, + err, + isBusinessError, + isErr, + isOk, + ok, + type Result, + unwrap, +} from './result'; diff --git a/apps/mobile/src/shared/errors/infra-error.ts b/apps/mobile/src/shared/errors/infra-error.ts new file mode 100644 index 00000000..e32c7240 --- /dev/null +++ b/apps/mobile/src/shared/errors/infra-error.ts @@ -0,0 +1,53 @@ +/** + * 인프라 에러 - ErrorBoundary에서 처리 + * - 5xx 서버 에러 + * - 네트워크 에러 + * - 타임아웃 + * - 응답 파싱 실패 + * + * UI에서 개별 처리하지 않고 ErrorBoundary로 전파됨 + */ +export abstract class InfraError extends Error { + override readonly name = 'InfraError'; + abstract readonly statusCode: number | null; +} + +/** 네트워크 연결 에러 */ +export class NetworkError extends InfraError { + readonly statusCode = null; + + constructor() { + super('네트워크 연결을 확인해주세요'); + } +} + +/** 요청 타임아웃 */ +export class TimeoutError extends InfraError { + readonly statusCode = 504; + + constructor() { + super('요청 시간이 초과되었어요'); + } +} + +/** 5xx 서버 에러 */ +export class ServerError extends InfraError { + readonly statusCode: number; + + constructor(status: number) { + super('서버에 문제가 발생했어요'); + this.statusCode = status; + } +} + +/** 응답 파싱/검증 에러 */ +export class ParseError extends InfraError { + readonly statusCode = null; + + constructor(message = '응답 형식이 올바르지 않아요') { + super(message); + } +} + +/** InfraError 타입 가드 */ +export const isInfraError = (error: unknown): error is InfraError => error instanceof InfraError; diff --git a/apps/mobile/src/shared/errors/result.ts b/apps/mobile/src/shared/errors/result.ts new file mode 100644 index 00000000..fdf13605 --- /dev/null +++ b/apps/mobile/src/shared/errors/result.ts @@ -0,0 +1,51 @@ +/** + * Result 타입 (객체 기반) + * - 성공: { ok: true, value: T } + * - 실패: { ok: false, error: E } + * + * 4xx 에러는 Result.err로 반환하여 UI에서 처리 + * 5xx/네트워크/타임아웃은 throw InfraError로 ErrorBoundary 처리 + */ + +export interface BusinessError { + readonly code: string; + readonly message: string; + readonly details?: Record; +} + +export type Result = + | { ok: true; value: T } + | { ok: false; error: E }; + +/** 성공 Result 생성 */ +export const ok = (value: T): Result => ({ ok: true, value }); + +/** 실패 Result 생성 */ +export const err = (error: E): Result => ({ ok: false, error }); + +/** Result가 성공인지 확인 (타입 가드) */ +export const isOk = ( + result: Result, +): result is { ok: true; value: T } => result.ok === true; + +/** Result가 실패인지 확인 (타입 가드) */ +export const isErr = ( + result: Result, +): result is { ok: false; error: E } => result.ok === false; + +/** + * Result를 unwrap하여 성공 시 데이터 반환, 실패 시 throw + * - React Query mutationFn에서 사용 + * - 에러는 onError 콜백에서 처리 + */ +export const unwrap = (result: Result): T => { + if (result.ok) return result.value; + throw result.error; +}; + +/** + * BusinessError 타입 가드 + * - code와 message를 가진 Error 객체인지 확인 + */ +export const isBusinessError = (error: unknown): error is BusinessError => + error instanceof Error && 'code' in error && typeof (error as BusinessError).code === 'string'; diff --git a/apps/mobile/src/shared/infra/http/auth-client.ts b/apps/mobile/src/shared/infra/http/auth-client.ts index 39660f34..3f717347 100644 --- a/apps/mobile/src/shared/infra/http/auth-client.ts +++ b/apps/mobile/src/shared/infra/http/auth-client.ts @@ -1,5 +1,6 @@ import type { Storage } from '@src/core/ports/storage'; import { ENV } from '@src/shared/config/env'; +import { getDeviceTimezone } from '@src/shared/utils/timezone'; import ky, { type KyInstance } from 'ky'; import { handleApiErrors } from './error-handler'; @@ -22,6 +23,7 @@ export const createAuthClient = (storage: Storage): KyInstance => { timeout: 10_000, headers: { 'Content-Type': 'application/json', + 'X-Timezone': getDeviceTimezone(), }, hooks: { beforeRequest: [ diff --git a/apps/mobile/src/shared/infra/http/ky-client.ts b/apps/mobile/src/shared/infra/http/ky-client.ts new file mode 100644 index 00000000..35c89879 --- /dev/null +++ b/apps/mobile/src/shared/infra/http/ky-client.ts @@ -0,0 +1,144 @@ +import type { HttpClient, RequestConfig } from '@src/core/ports/http'; +import { ApiError } from '@src/shared/errors/api-error'; +import { NetworkError, ServerError, TimeoutError } from '@src/shared/errors/infra-error'; +import { err, ok, type Result } from '@src/shared/errors/result'; +import { HTTPError, type KyInstance, TimeoutError as KyTimeoutError, type Options } from 'ky'; + +interface ServerResponse { + success: boolean; + data: T; + timestamp: number; +} + +interface ServerErrorBody { + error: { + code: string; + message: string; + details?: Record; + }; +} + +/** + * Ky 기반 Result HttpClient + * - 4xx 에러: Result.err(ApiError) 반환 + * - 5xx/네트워크/타임아웃: throw InfraError → ErrorBoundary + */ +export class KyHttpClient implements HttpClient { + readonly #client: KyInstance; + + constructor(client: KyInstance) { + this.#client = client; + } + + async get(url: string, config?: RequestConfig): Promise> { + return this.#request(() => this.#client.get(url, this.#buildOptions(config))); + } + + async post(url: string, data?: unknown, config?: RequestConfig): Promise> { + return this.#request(() => + this.#client.post(url, { ...this.#buildOptions(config), json: data }), + ); + } + + async put(url: string, data?: unknown, config?: RequestConfig): Promise> { + return this.#request(() => + this.#client.put(url, { ...this.#buildOptions(config), json: data }), + ); + } + + async patch( + url: string, + data?: unknown, + config?: RequestConfig, + ): Promise> { + return this.#request(() => + this.#client.patch(url, { ...this.#buildOptions(config), json: data }), + ); + } + + async delete(url: string, config?: RequestConfig): Promise> { + return this.#request(() => this.#client.delete(url, this.#buildOptions(config))); + } + + async #request(request: () => Promise): Promise> { + try { + const response = await request(); + const { data } = (await response.json()) as ServerResponse; + return ok(data); + } catch (error) { + if (error instanceof KyTimeoutError) { + throw new TimeoutError(); + } + + if (error instanceof HTTPError) { + const { response } = error; + + if (response.status >= 500) { + throw new ServerError(response.status); + } + + const body = await this.#parseErrorBody(response); + return err( + new ApiError( + body?.error.code ?? `HTTP_${response.status}`, + body?.error.message ?? response.statusText, + response.status, + body?.error.details, + ), + ); + } + + if (error instanceof TypeError) { + throw new NetworkError(); + } + + throw error; + } + } + + async #parseErrorBody(response: Response): Promise { + try { + const body: unknown = await response.json(); + if (this.#isServerErrorBody(body)) { + return body; + } + return null; + } catch { + return null; + } + } + + #isServerErrorBody(body: unknown): body is ServerErrorBody { + return ( + body !== null && + typeof body === 'object' && + 'error' in body && + typeof (body as ServerErrorBody).error === 'object' && + (body as ServerErrorBody).error !== null + ); + } + + #buildOptions(config?: RequestConfig): Options { + if (!config) return {}; + + const options: Options = {}; + + if (config.headers) { + options.headers = config.headers; + } + + if (config.timeout) { + options.timeout = config.timeout; + } + + if (config.params) { + options.searchParams = Object.fromEntries( + Object.entries(config.params) + .filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined) + .map(([key, value]) => [key, String(value)]), + ); + } + + return options; + } +} diff --git a/apps/mobile/src/shared/infra/http/ky-http-client.ts b/apps/mobile/src/shared/infra/http/ky-http-client.ts deleted file mode 100644 index a2db378f..00000000 --- a/apps/mobile/src/shared/infra/http/ky-http-client.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { - HttpClient, - HttpClientConfig, - HttpClientResponse, - RequestConfig, -} from '@src/core/ports/http'; -import ky, { type KyInstance, type Options } from 'ky'; - -export class KyHttpClient implements HttpClient { - readonly #client: KyInstance; - - constructor(configOrInstance?: HttpClientConfig | KyInstance) { - if (configOrInstance && 'extend' in configOrInstance) { - // KyInstance가 전달된 경우 - 이미 hooks가 설정되어 있으므로 그대로 사용 - this.#client = configOrInstance; - } else { - // HttpClientConfig가 전달된 경우 - const config = configOrInstance as HttpClientConfig | undefined; - const options: Options = { - prefixUrl: config?.baseUrl, - headers: config?.headers, - timeout: config?.timeout, - }; - this.#client = ky.create(options); - } - } - - async get(url: string, config?: RequestConfig): Promise> { - const response = await this.#client.get(url, this.#buildOptions(config)); - return this.#buildResponse(response); - } - - async post( - url: string, - data?: unknown, - config?: RequestConfig, - ): Promise> { - const response = await this.#client.post(url, { - ...this.#buildOptions(config), - json: data, - }); - return this.#buildResponse(response); - } - - async put( - url: string, - data?: unknown, - config?: RequestConfig, - ): Promise> { - const response = await this.#client.put(url, { - ...this.#buildOptions(config), - json: data, - }); - return this.#buildResponse(response); - } - - async patch( - url: string, - data?: unknown, - config?: RequestConfig, - ): Promise> { - const response = await this.#client.patch(url, { - ...this.#buildOptions(config), - json: data, - }); - return this.#buildResponse(response); - } - - async delete(url: string, config?: RequestConfig): Promise> { - const response = await this.#client.delete(url, this.#buildOptions(config)); - return this.#buildResponse(response); - } - - #buildOptions(config?: RequestConfig): Options { - const options: Options = {}; - - if (config?.headers) { - options.headers = config.headers; - } - - if (config?.timeout) { - options.timeout = config.timeout; - } - - if (config?.params) { - const searchParams = new URLSearchParams(); - Object.entries(config.params).forEach(([key, value]) => { - if (value !== undefined) { - searchParams.append(key, String(value)); - } - }); - options.searchParams = searchParams; - } - - return options; - } - - async #buildResponse(response: Response): Promise> { - // 서버 응답: { success, data, timestamp } 에서 data 추출 - const json = (await response.json()) as { success: boolean; data: T; timestamp: number }; - return { - data: json.data, - status: response.status, - }; - } -} diff --git a/apps/mobile/src/shared/infra/http/public-client.ts b/apps/mobile/src/shared/infra/http/public-client.ts index 9af3f0c7..8dbb5d53 100644 --- a/apps/mobile/src/shared/infra/http/public-client.ts +++ b/apps/mobile/src/shared/infra/http/public-client.ts @@ -1,4 +1,5 @@ import { ENV } from '@src/shared/config/env'; +import { getDeviceTimezone } from '@src/shared/utils/timezone'; import ky, { type KyInstance } from 'ky'; import { handlePublicApiErrors } from './error-handler'; @@ -12,6 +13,7 @@ export const createPublicClient = (): KyInstance => { timeout: 10_000, headers: { 'Content-Type': 'application/json', + 'X-Timezone': getDeviceTimezone(), }, hooks: { afterResponse: [handlePublicApiErrors], diff --git a/apps/mobile/src/shared/types/page.type.ts b/apps/mobile/src/shared/types/page.type.ts new file mode 100644 index 00000000..af35c7eb --- /dev/null +++ b/apps/mobile/src/shared/types/page.type.ts @@ -0,0 +1,5 @@ +export interface Page { + items: T[]; + totalCount: number; + hasMore: boolean; +} diff --git a/apps/mobile/src/shared/utils/date.ts b/apps/mobile/src/shared/utils/date.ts index 36bb85b7..2f86b779 100644 --- a/apps/mobile/src/shared/utils/date.ts +++ b/apps/mobile/src/shared/utils/date.ts @@ -109,3 +109,33 @@ export const getCurrentWeekStart = (): Date => { export const getCurrentMonthStart = (): Date => { return dayjs().startOf('month').toDate(); }; + +// Todo 날짜 유틸 + +/** 투두가 특정 날짜에 해당하는지 (YYYY-MM-DD 문자열 사전순 비교) */ +export const todoOverlapsDate = ( + todoStartDate: string, + todoEndDate: string | null, + targetDate: string, +): boolean => { + const effectiveEnd = todoEndDate ?? todoStartDate; + return todoStartDate <= targetDate && effectiveEnd >= targetDate; +}; + +/** 캘린더 그리드 범위 (월간 뷰 - 앞뒤 패딩 주 포함) */ +export const getCalendarRange = (displayDate: Date): { rangeStart: string; rangeEnd: string } => { + const d = dayjs(displayDate); + return { + rangeStart: d.startOf('month').startOf('week').format('YYYY-MM-DD'), + rangeEnd: d.endOf('month').endOf('week').format('YYYY-MM-DD'), + }; +}; + +/** 주간 뷰 범위 */ +export const getWeekRange = (displayDate: Date): { rangeStart: string; rangeEnd: string } => { + const d = dayjs(displayDate); + return { + rangeStart: d.startOf('week').format('YYYY-MM-DD'), + rangeEnd: d.endOf('week').format('YYYY-MM-DD'), + }; +}; diff --git a/apps/mobile/src/shared/utils/timezone.ts b/apps/mobile/src/shared/utils/timezone.ts new file mode 100644 index 00000000..f5de6a9b --- /dev/null +++ b/apps/mobile/src/shared/utils/timezone.ts @@ -0,0 +1,11 @@ +/** + * 디바이스의 IANA 타임존을 반환 + * @example getDeviceTimezone() // 'Asia/Seoul' + */ +export const getDeviceTimezone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'; + } catch { + return 'UTC'; + } +};