Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cf6b57c
feat(api): scheduledTime에 TIMESTAMPTZ 타입 적용
dydals3440 Feb 6, 2026
c009b19
feat(api): 타임존 지원 날짜 유틸리티 및 데코레이터 추가
dydals3440 Feb 6, 2026
c5066d1
refactor(api): Todo 모듈 타임존 지원 및 Overlapping Intervals 필터링
dydals3440 Feb 6, 2026
2daff1a
test(api): Todo 타임존 및 날짜 범위 필터링 테스트 추가
dydals3440 Feb 6, 2026
3af7623
feat(api): 알림 중복 발송 방지 로직 및 테스트 추가
dydals3440 Feb 6, 2026
0a78fee
refactor(api): Cheer/Nudge 모듈 타임존 지원 추가
dydals3440 Feb 6, 2026
80e6f21
feat(mobile): Result 패턴 및 에러 인프라 도입
dydals3440 Feb 6, 2026
befc2de
refactor(mobile): Auth 기능 레이어 재구성
dydals3440 Feb 6, 2026
ed82a75
refactor(mobile): Friend 기능 레이어 재구성
dydals3440 Feb 6, 2026
e00ab99
refactor(mobile): Notification 기능 레이어 재구성
dydals3440 Feb 6, 2026
f665815
refactor(mobile): Todo 기능 레이어 재구성
dydals3440 Feb 6, 2026
f54a3d2
refactor(mobile): DI 컨테이너 및 공유 유틸리티 업데이트
dydals3440 Feb 6, 2026
a6499d2
docs(mobile): 아키텍처 문서 및 에러 처리 가이드 업데이트
dydals3440 Feb 6, 2026
afbb965
style(api): startOfDayInTimezone 중복 JSDoc 주석 제거
dydals3440 Feb 6, 2026
5ff0c2d
docs(api): 문서 현행화 및 Swagger X-Timezone 헤더 추가
dydals3440 Feb 6, 2026
58c941a
docs(mobile): CLAUDE.md ClientError 용어 및 구조도 수정
dydals3440 Feb 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 83 additions & 4 deletions apps/api/.claude/api-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ if (confirmed) {

| 플랜 | 일일 제한 |
|------|----------|
| FREE | 10회 |
| FREE | 5회 |
| PREMIUM | 100회 |

---
Expand Down Expand Up @@ -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"
}
```

Expand All @@ -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"
}
```

Expand All @@ -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"
}
```

Expand Down Expand Up @@ -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);
```
6 changes: 3 additions & 3 deletions apps/api/.claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ POST /v1/ai/parse-todo
│ AI Service │
│ - Google Gemini API 호출 │
│ - 토큰 최적화 프롬프트 (~200 tokens) │
│ - 일일 사용량 제한 (FREE: 10회, PREMIUM: 100회) │
│ - 일일 사용량 제한 (FREE: 5회, PREMIUM: 100회)
└─────────────────────────────────────────────────────────┘
Expand Down Expand Up @@ -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"
}
```

Expand All @@ -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"
}
```

Expand Down
4 changes: 2 additions & 2 deletions apps/api/.claude/testing-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,5 +455,5 @@ pnpm --filter @aido/api test:e2e -- -t "패턴" # 특정 테스트

---

**문서 버전**: 1.0.0
**최종 수정일**: 2026-02-03
**문서 버전**: 1.0.1
**최종 수정일**: 2026-02-06
6 changes: 4 additions & 2 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -20,6 +20,8 @@ NestJS 11 기반 RESTful API 서버. Prisma 7 + PostgreSQL.
src/
├── common/ # 공통 모듈
│ ├── database/ # DB 유틸리티
│ ├── date/ # 날짜/타임존 유틸리티
│ ├── decorators/ # 커스텀 데코레이터 (@Timezone 등)
│ ├── exception/ # 예외 처리
│ ├── logger/ # 로깅
│ ├── pagination/ # 페이지네이션
Expand Down Expand Up @@ -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)

## 배포

Expand Down
6 changes: 3 additions & 3 deletions apps/api/docs/PUSH_NOTIFICATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
| 모바일 앱 | expo-notifications 패키지 | 설치됨 (v0.32.16) |
| | app.config.ts 설정 | 완료 |
| | 알림 설정 화면 UI | 완료 |
| | 푸시 토큰 요청/등록 | **미구현** |
| | 알림 수신 리스너 | **미구현** |
| | 알림 클릭 핸들링 | **미구현** |
| | 푸시 토큰 요청/등록 | 완료 |
| | 알림 수신 리스너 | 완료 |
| | 알림 클릭 핸들링 | 완료 |

---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Todo" ALTER COLUMN "scheduledTime" SET DATA TYPE TIMESTAMPTZ(3);
2 changes: 1 addition & 1 deletion apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)

// 가시성
Expand Down
73 changes: 73 additions & 0 deletions apps/api/src/common/date/utils/date.util.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
53 changes: 53 additions & 0 deletions apps/api/src/common/date/utils/date.util.ts
Original file line number Diff line number Diff line change
@@ -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";

// ============================================
// 현재 시각
Expand Down Expand Up @@ -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();
}

// ============================================
// 포맷팅
// ============================================
Expand Down Expand Up @@ -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();
}
1 change: 1 addition & 0 deletions apps/api/src/common/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Timezone } from "./timezone.decorator";
8 changes: 8 additions & 0 deletions apps/api/src/common/decorators/timezone.decorator.ts
Original file line number Diff line number Diff line change
@@ -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";
},
);
Loading