Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
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");
});
});
57 changes: 57 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,47 @@ 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();
}

/**
* 지정된 타임존에서 특정 시점의 날짜 시작(자정)을 UTC Date로 반환
* @example startOfDayInTimezone(new Date(), 'Asia/Seoul') → 해당 시점의 KST 자정을 UTC로 표현
*/
/**
* 사용자의 로컬 시간(날짜 + 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";
},
);
19 changes: 12 additions & 7 deletions apps/api/src/modules/cheer/cheer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
UseGuards,
} from "@nestjs/common";
import { ApiBearerAuth, ApiParam, ApiTags } from "@nestjs/swagger";

import { Timezone } from "@/common/decorators";
import {
ApiBadRequestError,
ApiConflictError,
Expand Down Expand Up @@ -100,16 +100,20 @@ export class CheerController {
async sendCheer(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: SendCheerDto,
@Timezone() tz: string,
): Promise<CreateCheerResponseDto> {
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}`,
Expand Down Expand Up @@ -206,8 +210,9 @@ export class CheerController {
@ApiUnauthorizedError(ErrorCode.AUTH_0107)
async getLimitInfo(
@CurrentUser() user: CurrentUserPayload,
@Timezone() tz: string,
): Promise<CheerLimitInfoDto> {
const limitInfo = await this.cheerService.getLimitInfo(user.userId);
const limitInfo = await this.cheerService.getLimitInfo(user.userId, tz);

return CheerMapper.toLimitInfoDto(limitInfo);
}
Expand Down
38 changes: 20 additions & 18 deletions apps/api/src/modules/cheer/cheer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -60,7 +61,10 @@ export class CheerService {
*
* @note 트랜잭션으로 감싸서 TOCTOU 경합 조건을 방지합니다.
*/
async sendCheer(params: SendCheerParams): Promise<CheerWithRelations> {
async sendCheer(
params: SendCheerParams,
tz: string = "UTC",
): Promise<CheerWithRelations> {
const { senderId, receiverId, message } = params;

// 1. 자기 자신 체크
Expand All @@ -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 },
Expand All @@ -103,7 +102,7 @@ export class CheerService {
where: {
senderId,
createdAt: {
gte: startOfDay,
gte: todayStart,
},
},
});
Expand All @@ -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,
Expand Down Expand Up @@ -270,7 +269,10 @@ export class CheerService {
/**
* 일일 응원 제한 정보 조회
*/
async getLimitInfo(userId: string): Promise<CheerLimitInfo> {
async getLimitInfo(
userId: string,
tz: string = "UTC",
): Promise<CheerLimitInfo> {
// 구독 상태 조회 (캐시 우선)
let subscriptionStatus: "FREE" | "ACTIVE" | "EXPIRED" | "CANCELLED" | null;

Expand All @@ -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,
Expand Down Expand Up @@ -386,18 +388,18 @@ 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,
canCheerAt: null,
};
}

const remainingMs = canCheerAt.getTime() - now.getTime();
const remainingMs = canCheerAt.getTime() - currentTime.getTime();
const remainingSeconds = Math.ceil(remainingMs / 1000);

return {
Expand Down
Loading