generated from ttipo/project-base
feat(backend): 일정 API — 날짜 일정·반복 일정·완료 표시
앱의 일정(ScheduleBook: 날짜 일정 + 반복 규칙 + 완료 표시)을 서버로 옮긴다. 어느 날에 무엇이 놓이는지는 지금처럼 앱이 계산하고, 서버는 원본을 보관한다. - /schedule-events: 목록·여러 건 만들기(기간 설정, 요청당 500건)·내용 바꾸기·삭제·완료 표시 - /repeat-rules: 목록·만들기·주기/내용 바꾸기·삭제, 날짜별 완료 표시(/completions/:date) - /repeat-rule-completions: 반복 일정 완료 표시 목록 - 대상은 "self" 또는 내 아이만 받는다. 아이를 지우거나 보관하면 그 아이만 떼고 대상이 빈 일정은 지운다 - 날짜 일정과 반복 규칙은 같은 내용 컬럼(ScheduleDetails)을 쓴다 - @nestjs/schedule 의 ScheduleModule 과 이름이 겹쳐 크론 쪽을 별칭으로 부른다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
// 도메인 일정 모듈(modules/schedule)과 이름이 겹쳐 크론 모듈을 별칭으로 부른다
|
||||
import { ScheduleModule as CronScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
@@ -11,6 +12,7 @@ import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { AuthModule } from '@/modules/auth/auth.module';
|
||||
import { HealthModule } from '@/modules/health/health.module';
|
||||
import { PetModule } from '@/modules/pet/pet.module';
|
||||
import { ScheduleModule } from '@/modules/schedule/schedule.module';
|
||||
import { UploadModule } from '@/modules/upload/upload.module';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
@@ -24,7 +26,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
CronScheduleModule.forRoot(),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
|
||||
@@ -65,6 +67,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
UserModule,
|
||||
AuthModule,
|
||||
PetModule,
|
||||
ScheduleModule,
|
||||
],
|
||||
providers: [
|
||||
ShutdownLogger,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsString,
|
||||
Matches,
|
||||
@@ -52,3 +53,41 @@ export function IsShortStringList(maxItems: number, maxLength: number) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 일정·다이어리 대상의 "나(보호자 본인)" — 앱의 kSelfTargetId 와 같다 */
|
||||
export const SELF_TARGET_ID = 'self';
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* 대상 목록 — 각 값은 "self" 이거나 아이 id(UUID). 최소 1개.
|
||||
* 형식만 본다 — 내 아이인지는 서비스가 PetService.assertOwnedTargets 로 확인한다.
|
||||
*/
|
||||
export function IsTargetIdList(maxItems: number) {
|
||||
return applyDecorators(
|
||||
IsArray({ message: '대상을 골라 주세요.' }),
|
||||
ArrayMinSize(1, { message: '대상을 골라 주세요.' }),
|
||||
ArrayMaxSize(maxItems, { message: `대상은 최대 ${maxItems}개까지예요.` }),
|
||||
ValidateBy({
|
||||
name: 'isTargetId',
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(v) =>
|
||||
typeof v === 'string' &&
|
||||
(v === SELF_TARGET_ID || UUID_PATTERN.test(v)),
|
||||
),
|
||||
defaultMessage: () => '대상 정보가 올바르지 않습니다.',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 하루 중 시각 `HH:mm` (00:00 ~ 23:59) */
|
||||
export const HOUR_MINUTE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
export function IsHourMinute(message = '시각은 HH:mm 형식으로 입력해 주세요.') {
|
||||
return Matches(HOUR_MINUTE_PATTERN, { message });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSchedule1791706872774 implements MigrationInterface {
|
||||
name = 'AddSchedule1791706872774';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "schedule_events" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "date" date NOT NULL, "is_done" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "title" character varying(50) NOT NULL, "kind" character varying(12) NOT NULL, "category" character varying(12) NOT NULL, "target_ids" text array NOT NULL, "start_time" character varying(5), "end_time" character varying(5), "place" character varying(60), "label_color" character varying(7), CONSTRAINT "PK_c14624cf0aa0f238ace86e789aa" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_3351bce510c509278ac3d0a5ad" ON "schedule_events" ("owner_id", "date") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "repeat_rules" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "from_date" date NOT NULL, "mode" character varying(10) NOT NULL, "unit" character varying(5) NOT NULL, "n" smallint NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "title" character varying(50) NOT NULL, "kind" character varying(12) NOT NULL, "category" character varying(12) NOT NULL, "target_ids" text array NOT NULL, "start_time" character varying(5), "end_time" character varying(5), "place" character varying(60), "label_color" character varying(7), CONSTRAINT "PK_a77f6c9f8f34779343443201bbc" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_c3fbe53b49bb6e4c5fb15deb85" ON "repeat_rules" ("owner_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "repeat_rule_completions" ("rule_id" uuid NOT NULL, "date" date NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_087b6885d4535901d3387d3f8a7" PRIMARY KEY ("rule_id", "date"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_d5be69de4c90f931ccede796f1a" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "repeat_rules" ADD CONSTRAINT "FK_c3fbe53b49bb6e4c5fb15deb850" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "repeat_rule_completions" ADD CONSTRAINT "FK_e400c99c6640f922ca900709adc" FOREIGN KEY ("rule_id") REFERENCES "repeat_rules"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "repeat_rule_completions" DROP CONSTRAINT "FK_e400c99c6640f922ca900709adc"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "repeat_rules" DROP CONSTRAINT "FK_c3fbe53b49bb6e4c5fb15deb850"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_d5be69de4c90f931ccede796f1a"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "repeat_rule_completions"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_c3fbe53b49bb6e4c5fb15deb85"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "repeat_rules"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_3351bce510c509278ac3d0a5ad"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "schedule_events"`);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { SELF_TARGET_ID } from '@/common/validators';
|
||||
import { UploadService } from '@/modules/upload/upload.service';
|
||||
import {
|
||||
ArchivePetDto,
|
||||
@@ -20,9 +21,6 @@ import { Pet } from './entities/pet.entity';
|
||||
import { PetLifecycleListener } from './pet-lifecycle';
|
||||
import { reorderIds } from './pet-order';
|
||||
|
||||
/** 일정·다이어리의 대상 중 "나(보호자 본인)" — 앱의 kSelfTargetId 와 같다 */
|
||||
export const SELF_TARGET_ID = 'self';
|
||||
|
||||
// 반려동물 — 등록·수정·삭제·순서·추억 보관함
|
||||
@Injectable()
|
||||
export class PetService {
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import {
|
||||
IsHexColor,
|
||||
IsHourMinute,
|
||||
IsIsoDate,
|
||||
IsTargetIdList,
|
||||
} from '@/common/validators';
|
||||
import {
|
||||
EVENT_CATEGORIES,
|
||||
EVENT_KINDS,
|
||||
SCHEDULE_LIMITS,
|
||||
} from '../entities/schedule-details';
|
||||
import type {
|
||||
EventCategory,
|
||||
EventKind,
|
||||
ScheduleDetails,
|
||||
} from '../entities/schedule-details';
|
||||
import { REPEAT_MODES, REPEAT_UNITS } from '../entities/repeat-rule.entity';
|
||||
import type {
|
||||
RepeatMode,
|
||||
RepeatRule,
|
||||
RepeatRuleCompletion,
|
||||
RepeatUnit,
|
||||
} from '../entities/repeat-rule.entity';
|
||||
import type { ScheduleEvent } from '../entities/schedule-event.entity';
|
||||
|
||||
const trim = ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.trim() : value;
|
||||
|
||||
/** 앞뒤 공백을 떼고, 비면 null */
|
||||
const trimToNull = ({ value }: { value: unknown }) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' ? null : trimmed;
|
||||
};
|
||||
|
||||
/** 일정 내용 — 날짜 일정과 반복 규칙이 같이 쓴다 (앱의 EventDetails) */
|
||||
export class EventDetailsDto {
|
||||
@ApiProperty({
|
||||
description: `일정 이름 (최대 ${SCHEDULE_LIMITS.title}자)`,
|
||||
example: '심장사상충 약',
|
||||
})
|
||||
@Transform(trim)
|
||||
@IsString({ message: '일정 이름을 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '일정 이름을 입력해 주세요.' })
|
||||
@MaxLength(SCHEDULE_LIMITS.title, {
|
||||
message: `일정 이름은 ${SCHEDULE_LIMITS.title}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ enum: EVENT_KINDS, example: 'general' })
|
||||
@IsIn(EVENT_KINDS, { message: '일정 종류가 올바르지 않습니다.' })
|
||||
kind!: EventKind;
|
||||
|
||||
@ApiProperty({ enum: EVENT_CATEGORIES, example: 'med' })
|
||||
@IsIn(EVENT_CATEGORIES, { message: '일정 분류가 올바르지 않습니다.' })
|
||||
category!: EventCategory;
|
||||
|
||||
@ApiProperty({
|
||||
description: '대상 — 아이 id 또는 "self"(나). 1개 이상',
|
||||
type: [String],
|
||||
example: ['self'],
|
||||
})
|
||||
@IsTargetIdList(SCHEDULE_LIMITS.targets)
|
||||
targetIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '시작 시각 HH:mm — 없으면(null) 하루 종일',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '08:00',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHourMinute()
|
||||
start?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '종료 시각 HH:mm (선택)',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHourMinute()
|
||||
end?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: '장소', nullable: true, type: String })
|
||||
@IsOptional()
|
||||
@Transform(trimToNull)
|
||||
@IsString()
|
||||
@MaxLength(SCHEDULE_LIMITS.place, {
|
||||
message: `장소는 ${SCHEDULE_LIMITS.place}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
place?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '라벨 색 #RRGGBB — 없으면 분류 기본 색',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
labelColor?: string | null;
|
||||
}
|
||||
|
||||
/** 새 날짜 일정 하나 */
|
||||
export class NewEventDto extends EventDetailsDto {
|
||||
@ApiProperty({ description: '날짜 (YYYY-MM-DD)', example: '2026-10-11' })
|
||||
@IsIsoDate()
|
||||
date!: string;
|
||||
}
|
||||
|
||||
/** 날짜 일정 여러 건 만들기 — 기간 설정은 날짜마다 한 건씩 */
|
||||
export class CreateEventsDto {
|
||||
@ApiProperty({
|
||||
description: `만들 일정 (1~${SCHEDULE_LIMITS.eventsPerRequest}건 — 더 많으면 나눠 보낸다)`,
|
||||
type: [NewEventDto],
|
||||
})
|
||||
@IsArray({ message: '일정 정보가 올바르지 않습니다.' })
|
||||
@ArrayMinSize(1, { message: '일정 정보가 올바르지 않습니다.' })
|
||||
@ArrayMaxSize(SCHEDULE_LIMITS.eventsPerRequest, {
|
||||
message: `한 번에 ${SCHEDULE_LIMITS.eventsPerRequest}건까지 만들 수 있습니다.`,
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NewEventDto)
|
||||
events!: NewEventDto[];
|
||||
}
|
||||
|
||||
/** 반복 주기 — "2주마다" (앱의 RepeatSpec) */
|
||||
export class RepeatSpecDto {
|
||||
@ApiProperty({ enum: REPEAT_MODES, example: 'every' })
|
||||
@IsIn(REPEAT_MODES, { message: '반복 방식이 올바르지 않습니다.' })
|
||||
mode!: RepeatMode;
|
||||
|
||||
@ApiProperty({ enum: REPEAT_UNITS, example: 'week' })
|
||||
@IsIn(REPEAT_UNITS, { message: '반복 단위가 올바르지 않습니다.' })
|
||||
unit!: RepeatUnit;
|
||||
|
||||
@ApiProperty({
|
||||
description: `주기 N (1~${SCHEDULE_LIMITS.maxRepeatN})`,
|
||||
example: 2,
|
||||
})
|
||||
@IsInt({ message: '반복 주기는 정수로 입력해 주세요.' })
|
||||
@Min(1, { message: '반복 주기는 1 이상이어야 합니다.' })
|
||||
@Max(SCHEDULE_LIMITS.maxRepeatN, {
|
||||
message: `반복 주기는 ${SCHEDULE_LIMITS.maxRepeatN} 이하여야 합니다.`,
|
||||
})
|
||||
n!: number;
|
||||
}
|
||||
|
||||
/** 반복 규칙 내용 바꾸기 — 주기와 내용을 통째로 바꾼다 */
|
||||
export class UpdateRuleDto {
|
||||
@ApiProperty({ type: RepeatSpecDto })
|
||||
@ValidateNested()
|
||||
@Type(() => RepeatSpecDto)
|
||||
spec!: RepeatSpecDto;
|
||||
|
||||
@ApiProperty({ type: EventDetailsDto })
|
||||
@ValidateNested()
|
||||
@Type(() => EventDetailsDto)
|
||||
details!: EventDetailsDto;
|
||||
}
|
||||
|
||||
/** 반복 규칙 만들기 */
|
||||
export class CreateRuleDto extends UpdateRuleDto {
|
||||
@ApiProperty({
|
||||
description: '반복을 시작하는 날 (YYYY-MM-DD)',
|
||||
example: '2026-10-11',
|
||||
})
|
||||
@IsIsoDate()
|
||||
from!: string;
|
||||
}
|
||||
|
||||
/** 일정 내용 응답 */
|
||||
export class EventDetailsResponseDto {
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ enum: EVENT_KINDS }) kind!: EventKind;
|
||||
@ApiProperty({ enum: EVENT_CATEGORIES }) category!: EventCategory;
|
||||
@ApiProperty({ type: [String] }) targetIds!: string[];
|
||||
@ApiProperty({ nullable: true, type: String }) start!: string | null;
|
||||
@ApiProperty({ nullable: true, type: String }) end!: string | null;
|
||||
@ApiProperty({ nullable: true, type: String }) place!: string | null;
|
||||
@ApiProperty({ nullable: true, type: String }) labelColor!: string | null;
|
||||
|
||||
static from(details: ScheduleDetails): EventDetailsResponseDto {
|
||||
return {
|
||||
title: details.title,
|
||||
kind: details.kind,
|
||||
category: details.category,
|
||||
targetIds: details.targetIds,
|
||||
start: details.startTime,
|
||||
end: details.endTime,
|
||||
place: details.place,
|
||||
labelColor: details.labelColor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 날짜 일정 응답 */
|
||||
export class ScheduleEventResponseDto extends EventDetailsResponseDto {
|
||||
@ApiProperty() id!: string;
|
||||
|
||||
@ApiProperty({ description: '날짜 (YYYY-MM-DD)' })
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ description: '완료 표시' })
|
||||
isDone!: boolean;
|
||||
|
||||
static fromEvent(event: ScheduleEvent): ScheduleEventResponseDto {
|
||||
return {
|
||||
id: event.id,
|
||||
date: event.date,
|
||||
isDone: event.isDone,
|
||||
...EventDetailsResponseDto.from(event.details),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 반복 규칙 응답 (앱의 RepeatRule) */
|
||||
export class RepeatRuleResponseDto {
|
||||
@ApiProperty() id!: string;
|
||||
|
||||
@ApiProperty({ description: '반복을 시작한 날 (YYYY-MM-DD)' })
|
||||
from!: string;
|
||||
|
||||
@ApiProperty({ type: RepeatSpecDto })
|
||||
spec!: RepeatSpecDto;
|
||||
|
||||
@ApiProperty({ type: EventDetailsResponseDto })
|
||||
details!: EventDetailsResponseDto;
|
||||
|
||||
static from(rule: RepeatRule): RepeatRuleResponseDto {
|
||||
return {
|
||||
id: rule.id,
|
||||
from: rule.fromDate,
|
||||
spec: { mode: rule.mode, unit: rule.unit, n: rule.n },
|
||||
details: EventDetailsResponseDto.from(rule.details),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 반복 일정의 날짜별 완료 표시 응답 */
|
||||
export class RuleCompletionResponseDto {
|
||||
@ApiProperty() ruleId!: string;
|
||||
|
||||
@ApiProperty({ description: '완료한 날 (YYYY-MM-DD)' })
|
||||
date!: string;
|
||||
|
||||
static from(completion: RepeatRuleCompletion): RuleCompletionResponseDto {
|
||||
return { ruleId: completion.ruleId, date: completion.date };
|
||||
}
|
||||
}
|
||||
|
||||
/** 일정 내용 DTO → 컬럼 값 */
|
||||
export function toDetailsColumns(dto: EventDetailsDto): ScheduleDetails {
|
||||
return {
|
||||
title: dto.title,
|
||||
kind: dto.kind,
|
||||
category: dto.category,
|
||||
targetIds: [...new Set(dto.targetIds)],
|
||||
startTime: dto.start ?? null,
|
||||
// 하루 종일 일정에는 종료 시각을 두지 않는다
|
||||
endTime: dto.start ? (dto.end ?? null) : null,
|
||||
place: dto.place ?? null,
|
||||
labelColor: dto.labelColor ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { User } from '@/modules/user/entities/user.entity';
|
||||
import { ScheduleDetails } from './schedule-details';
|
||||
|
||||
/** 반복 방식 — 매일 / 평일만 / 주말만 / N일·N주·N개월마다 (앱의 RepeatPattern) */
|
||||
export const REPEAT_MODES = ['daily', 'weekday', 'weekend', 'every'] as const;
|
||||
export type RepeatMode = (typeof REPEAT_MODES)[number];
|
||||
|
||||
/** every 의 주기 단위 (앱의 RepeatUnit) */
|
||||
export const REPEAT_UNITS = ['day', 'week', 'month'] as const;
|
||||
export type RepeatUnit = (typeof REPEAT_UNITS)[number];
|
||||
|
||||
/**
|
||||
* 끝나는 날 없이 반복되는 일정 — 날짜를 미리 만들지 않고 규칙으로 보관한다.
|
||||
* 어느 날에 놓이는지는 앱이 계산한다 (앱의 RepeatSpec.matches).
|
||||
*/
|
||||
@Entity('repeat_rules')
|
||||
export class RepeatRule {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner?: User;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'uuid' })
|
||||
ownerId!: string;
|
||||
|
||||
/** 반복을 시작한 날 (YYYY-MM-DD) */
|
||||
@Column({ type: 'date' })
|
||||
fromDate!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 10 })
|
||||
mode!: RepeatMode;
|
||||
|
||||
@Column({ type: 'varchar', length: 5 })
|
||||
unit!: RepeatUnit;
|
||||
|
||||
/** N — every 의 주기 (1~31) */
|
||||
@Column({ type: 'smallint' })
|
||||
n!: number;
|
||||
|
||||
@Column(() => ScheduleDetails, { prefix: false })
|
||||
details!: ScheduleDetails;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
/** 반복 일정의 날짜별 완료 표시 — 규칙을 지우면 함께 지운다 */
|
||||
@Entity('repeat_rule_completions')
|
||||
export class RepeatRuleCompletion {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
ruleId!: string;
|
||||
|
||||
@ManyToOne(() => RepeatRule, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'rule_id' })
|
||||
rule?: RepeatRule;
|
||||
|
||||
/** 완료한 날 (YYYY-MM-DD) */
|
||||
@PrimaryColumn({ type: 'date' })
|
||||
date!: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Column } from 'typeorm';
|
||||
|
||||
/** 입력 한도 — 앱 화면에는 제한이 없어 넉넉하게 두고, 과도한 입력만 막는다 */
|
||||
export const SCHEDULE_LIMITS = {
|
||||
title: 50,
|
||||
place: 60,
|
||||
targets: 20,
|
||||
/** 한 번에 만드는 일정 수 — 기간 설정은 앱이 나눠 보낸다 */
|
||||
eventsPerRequest: 500,
|
||||
maxRepeatN: 31,
|
||||
} as const;
|
||||
|
||||
/** 예약 일정(병원·미용 등 날짜가 정해진 약속) / 일반 일정(데일리 케어) */
|
||||
export const EVENT_KINDS = ['reservation', 'general'] as const;
|
||||
export type EventKind = (typeof EVENT_KINDS)[number];
|
||||
|
||||
/** 일정 분류 — 앱의 EventCategory 와 같은 이름 */
|
||||
export const EVENT_CATEGORIES = [
|
||||
'hospital',
|
||||
'vaccine',
|
||||
'grooming',
|
||||
'walk',
|
||||
'food',
|
||||
'med',
|
||||
'etc',
|
||||
] as const;
|
||||
export type EventCategory = (typeof EVENT_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* 일정 내용 — 날짜 일정과 반복 규칙이 같은 컬럼을 쓴다 (앱의 EventDetails).
|
||||
* 엔티티에 `@Column(() => ScheduleDetails, { prefix: false })` 로 끼워 넣는다.
|
||||
*/
|
||||
export class ScheduleDetails {
|
||||
@Column({ type: 'varchar', length: SCHEDULE_LIMITS.title })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 12 })
|
||||
kind!: EventKind;
|
||||
|
||||
@Column({ type: 'varchar', length: 12 })
|
||||
category!: EventCategory;
|
||||
|
||||
/** 대상 — 아이 id 또는 'self'. 아이를 지우거나 보관하면 그 아이만 뗀다 */
|
||||
@Column({ type: 'text', array: true })
|
||||
targetIds!: string[];
|
||||
|
||||
/** 시작 시각 `HH:mm` — null 이면 하루 종일 */
|
||||
@Column({ type: 'varchar', length: 5, nullable: true })
|
||||
startTime!: string | null;
|
||||
|
||||
/** 종료 시각 `HH:mm` (선택) */
|
||||
@Column({ type: 'varchar', length: 5, nullable: true })
|
||||
endTime!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: SCHEDULE_LIMITS.place, nullable: true })
|
||||
place!: string | null;
|
||||
|
||||
/** 직접 고른 라벨 색 `#RRGGBB` — null 이면 분류 기본 색 */
|
||||
@Column({ type: 'varchar', length: 7, nullable: true })
|
||||
labelColor!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { User } from '@/modules/user/entities/user.entity';
|
||||
import { ScheduleDetails } from './schedule-details';
|
||||
|
||||
/** 달력에 놓인 일정 하나 (날짜가 정해진 일정) */
|
||||
@Entity('schedule_events')
|
||||
@Index(['ownerId', 'date'])
|
||||
export class ScheduleEvent {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner?: User;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
ownerId!: string;
|
||||
|
||||
/** 날짜 (시각 없음, YYYY-MM-DD) */
|
||||
@Column({ type: 'date' })
|
||||
date!: string;
|
||||
|
||||
@Column(() => ScheduleDetails, { prefix: false })
|
||||
details!: ScheduleDetails;
|
||||
|
||||
/** 완료 표시 */
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isDone!: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiNoContentResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiPaginatedResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { CurrentUser } from '@/common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '@/common/decorators/current-user.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import {
|
||||
CursorPaginationQueryDto,
|
||||
PaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isValidIsoDate } from '@/shared/utils/iso-date';
|
||||
import {
|
||||
CreateRuleDto,
|
||||
RepeatRuleResponseDto,
|
||||
RuleCompletionResponseDto,
|
||||
UpdateRuleDto,
|
||||
} from './dto/schedule.dto';
|
||||
import { ScheduleService } from './schedule.service';
|
||||
|
||||
/** 경로의 날짜(YYYY-MM-DD)를 확인한다 — 틀리면 VAL_001 */
|
||||
function parseDateParam(date: string): string {
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'invalid-date', date },
|
||||
});
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
// 반복 일정 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
|
||||
@ApiTags('Schedule')
|
||||
@ApiBearerAuth()
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@Controller('repeat-rules')
|
||||
export class RepeatRulesController {
|
||||
constructor(private readonly schedule: ScheduleService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '반복 일정 목록 (만든 순, 커서 페이지네이션)' })
|
||||
@ApiPaginatedResponse(RepeatRuleResponseDto, '반복 일정 목록')
|
||||
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query() query: CursorPaginationQueryDto,
|
||||
): Promise<PaginatedResult<RepeatRuleResponseDto>> {
|
||||
return this.schedule.listRules(user.userId, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '반복 일정 만들기 (끝나는 날 없음)' })
|
||||
@ApiStandardResponse(RepeatRuleResponseDto, {
|
||||
status: 201,
|
||||
description: '만든 반복 일정',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 아이가 아닌 대상')
|
||||
create(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: CreateRuleDto,
|
||||
): Promise<RepeatRuleResponseDto> {
|
||||
return this.schedule.createRule(user.userId, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '반복 주기·내용 바꾸기 (시작한 날은 그대로)' })
|
||||
@ApiStandardResponse(RepeatRuleResponseDto, { description: '바꾼 반복 일정' })
|
||||
@ApiErrorResponse('RES_001', '반복 일정을 찾을 수 없음')
|
||||
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateRuleDto,
|
||||
): Promise<RepeatRuleResponseDto> {
|
||||
return this.schedule.updateRule(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform() // 204 — 본문이 없다
|
||||
@ApiOperation({ summary: '반복 일정 삭제 (규칙 전체·완료 표시 함께)' })
|
||||
@ApiNoContentResponse({ description: '삭제 완료' })
|
||||
@ApiErrorResponse('RES_001', '반복 일정을 찾을 수 없음')
|
||||
async remove(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<void> {
|
||||
await this.schedule.deleteRule(user.userId, id);
|
||||
}
|
||||
|
||||
@Put(':id/completions/:date')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform() // 204 — 본문이 없다
|
||||
@ApiOperation({ summary: '그날 완료 표시' })
|
||||
@ApiParam({ name: 'date', description: 'YYYY-MM-DD', example: '2026-10-11' })
|
||||
@ApiNoContentResponse({ description: '완료로 표시함' })
|
||||
@ApiErrorResponse('RES_001', '반복 일정을 찾을 수 없음')
|
||||
@ApiErrorResponse('VAL_001', '날짜 형식 오류')
|
||||
async complete(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('date') date: string,
|
||||
): Promise<void> {
|
||||
await this.schedule.setRuleDone(
|
||||
user.userId,
|
||||
id,
|
||||
parseDateParam(date),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id/completions/:date')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform() // 204 — 본문이 없다
|
||||
@ApiOperation({ summary: '그날 완료 표시 풀기' })
|
||||
@ApiParam({ name: 'date', description: 'YYYY-MM-DD', example: '2026-10-11' })
|
||||
@ApiNoContentResponse({ description: '완료 표시를 풀었음' })
|
||||
@ApiErrorResponse('RES_001', '반복 일정을 찾을 수 없음')
|
||||
@ApiErrorResponse('VAL_001', '날짜 형식 오류')
|
||||
async uncomplete(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('date') date: string,
|
||||
): Promise<void> {
|
||||
await this.schedule.setRuleDone(
|
||||
user.userId,
|
||||
id,
|
||||
parseDateParam(date),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 반복 일정의 날짜별 완료 표시 목록 — 앱이 처음 불러올 때 한 번 읽는다
|
||||
@ApiTags('Schedule')
|
||||
@ApiBearerAuth()
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@Controller('repeat-rule-completions')
|
||||
export class RepeatRuleCompletionsController {
|
||||
constructor(private readonly schedule: ScheduleService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '반복 일정 완료 표시 목록 (커서 페이지네이션)' })
|
||||
@ApiPaginatedResponse(RuleCompletionResponseDto, '완료 표시 목록')
|
||||
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query() query: CursorPaginationQueryDto,
|
||||
): Promise<PaginatedResult<RuleCompletionResponseDto>> {
|
||||
return this.schedule.listCompletions(user.userId, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiNoContentResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiPaginatedResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { CurrentUser } from '@/common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '@/common/decorators/current-user.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import {
|
||||
CursorPaginationQueryDto,
|
||||
PaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
CreateEventsDto,
|
||||
EventDetailsDto,
|
||||
ScheduleEventResponseDto,
|
||||
} from './dto/schedule.dto';
|
||||
import { ScheduleService } from './schedule.service';
|
||||
|
||||
// 날짜 일정 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
|
||||
@ApiTags('Schedule')
|
||||
@ApiBearerAuth()
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@Controller('schedule-events')
|
||||
export class ScheduleEventsController {
|
||||
constructor(private readonly schedule: ScheduleService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '날짜 일정 목록 (날짜순, 커서 페이지네이션)' })
|
||||
@ApiPaginatedResponse(ScheduleEventResponseDto, '날짜 일정 목록')
|
||||
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query() query: CursorPaginationQueryDto,
|
||||
): Promise<PaginatedResult<ScheduleEventResponseDto>> {
|
||||
return this.schedule.listEvents(user.userId, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: '날짜 일정 만들기 (여러 건)',
|
||||
description:
|
||||
'기간 설정은 날짜·회차마다 한 건씩 보낸다. 하나라도 틀리면 아무것도 만들지 않는다',
|
||||
})
|
||||
@ApiStandardResponse(ScheduleEventResponseDto, {
|
||||
status: 201,
|
||||
description: '만든 일정 (보낸 순서대로)',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 아이가 아닌 대상')
|
||||
create(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: CreateEventsDto,
|
||||
): Promise<ScheduleEventResponseDto[]> {
|
||||
return this.schedule.createEvents(user.userId, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '일정 내용 바꾸기 (날짜·완료 표시는 그대로)' })
|
||||
@ApiStandardResponse(ScheduleEventResponseDto, { description: '바꾼 일정' })
|
||||
@ApiErrorResponse('RES_001', '일정을 찾을 수 없음')
|
||||
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: EventDetailsDto,
|
||||
): Promise<ScheduleEventResponseDto> {
|
||||
return this.schedule.updateEvent(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform() // 204 — 본문이 없다
|
||||
@ApiOperation({ summary: '일정 삭제' })
|
||||
@ApiNoContentResponse({ description: '삭제 완료' })
|
||||
@ApiErrorResponse('RES_001', '일정을 찾을 수 없음')
|
||||
async remove(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<void> {
|
||||
await this.schedule.deleteEvent(user.userId, id);
|
||||
}
|
||||
|
||||
@Put(':id/completion')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform() // 204 — 본문이 없다
|
||||
@ApiOperation({ summary: '완료 표시' })
|
||||
@ApiNoContentResponse({ description: '완료로 표시함' })
|
||||
@ApiErrorResponse('RES_001', '일정을 찾을 수 없음')
|
||||
async complete(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<void> {
|
||||
await this.schedule.setEventDone(user.userId, id, true);
|
||||
}
|
||||
|
||||
@Delete(':id/completion')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform() // 204 — 본문이 없다
|
||||
@ApiOperation({ summary: '완료 표시 풀기' })
|
||||
@ApiNoContentResponse({ description: '완료 표시를 풀었음' })
|
||||
@ApiErrorResponse('RES_001', '일정을 찾을 수 없음')
|
||||
async uncomplete(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<void> {
|
||||
await this.schedule.setEventDone(user.userId, id, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { PetModule } from '@/modules/pet/pet.module';
|
||||
import {
|
||||
RepeatRule,
|
||||
RepeatRuleCompletion,
|
||||
} from './entities/repeat-rule.entity';
|
||||
import { ScheduleEvent } from './entities/schedule-event.entity';
|
||||
import {
|
||||
RepeatRuleCompletionsController,
|
||||
RepeatRulesController,
|
||||
} from './repeat-rules.controller';
|
||||
import { ScheduleEventsController } from './schedule-events.controller';
|
||||
import { ScheduleService } from './schedule.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ScheduleEvent, RepeatRule, RepeatRuleCompletion]),
|
||||
PetModule,
|
||||
],
|
||||
controllers: [
|
||||
ScheduleEventsController,
|
||||
RepeatRulesController,
|
||||
RepeatRuleCompletionsController,
|
||||
],
|
||||
providers: [ScheduleService],
|
||||
})
|
||||
export class ScheduleModule {}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { PetService } from '@/modules/pet/pet.service';
|
||||
import { EventDetailsDto, toDetailsColumns } from './dto/schedule.dto';
|
||||
import {
|
||||
RepeatRule,
|
||||
RepeatRuleCompletion,
|
||||
} from './entities/repeat-rule.entity';
|
||||
import { ScheduleEvent } from './entities/schedule-event.entity';
|
||||
import { ScheduleService } from './schedule.service';
|
||||
|
||||
const details = (
|
||||
overrides: Partial<EventDetailsDto> = {},
|
||||
): EventDetailsDto => ({
|
||||
title: '산책',
|
||||
kind: 'general',
|
||||
category: 'walk',
|
||||
targetIds: ['self'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('toDetailsColumns', () => {
|
||||
it('선택 값이 없으면 null 로 저장한다', () => {
|
||||
expect(toDetailsColumns(details())).toEqual({
|
||||
title: '산책',
|
||||
kind: 'general',
|
||||
category: 'walk',
|
||||
targetIds: ['self'],
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
place: null,
|
||||
labelColor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('하루 종일 일정(시작 시각 없음)에는 종료 시각을 두지 않는다', () => {
|
||||
expect(toDetailsColumns(details({ end: '09:00' })).endTime).toBeNull();
|
||||
expect(
|
||||
toDetailsColumns(details({ start: '08:00', end: '09:00' })).endTime,
|
||||
).toBe('09:00');
|
||||
});
|
||||
|
||||
it('같은 대상이 두 번 오면 한 번만 남긴다', () => {
|
||||
expect(
|
||||
toDetailsColumns(details({ targetIds: ['self', 'self'] })).targetIds,
|
||||
).toEqual(['self']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScheduleService', () => {
|
||||
const events = { findOneBy: jest.fn(), delete: jest.fn(), update: jest.fn() };
|
||||
const rules = { findOneBy: jest.fn(), delete: jest.fn() };
|
||||
const completions = { delete: jest.fn() };
|
||||
const pets = { registerListener: jest.fn(), assertOwnedTargets: jest.fn() };
|
||||
const service = new ScheduleService(
|
||||
events as unknown as Repository<ScheduleEvent>,
|
||||
rules as unknown as Repository<RepeatRule>,
|
||||
completions as unknown as Repository<RepeatRuleCompletion>,
|
||||
pets as unknown as PetService,
|
||||
);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('시작할 때 아이 삭제·보관 리스너로 등록한다', () => {
|
||||
service.onModuleInit();
|
||||
expect(pets.registerListener).toHaveBeenCalledWith(service);
|
||||
});
|
||||
|
||||
it('아이를 지우면 두 일정 테이블에서 그 아이를 떼고, 대상이 빈 일정을 지운다', async () => {
|
||||
const query = jest
|
||||
.fn<Promise<unknown[]>, [string, unknown[]]>()
|
||||
.mockResolvedValue([]);
|
||||
const manager = { query } as unknown as EntityManager;
|
||||
|
||||
await service.onPetDeleting({ ownerId: 'o', petId: 'p', manager });
|
||||
|
||||
const sql = query.mock.calls.map(([text]) => text);
|
||||
expect(sql).toHaveLength(4);
|
||||
expect(sql[0]).toMatch(
|
||||
/UPDATE schedule_events SET target_ids = array_remove/,
|
||||
);
|
||||
expect(sql[1]).toMatch(/DELETE FROM schedule_events .*cardinality/);
|
||||
expect(sql[2]).toMatch(/UPDATE repeat_rules/);
|
||||
expect(sql[3]).toMatch(/DELETE FROM repeat_rules/);
|
||||
// 값은 항상 매개변수로 넘긴다
|
||||
expect(query.mock.calls[0][1]).toEqual(['o', 'p']);
|
||||
});
|
||||
|
||||
it('남의 일정은 지울 수 없다 (RES_001)', async () => {
|
||||
events.delete.mockResolvedValue({ affected: 0 });
|
||||
|
||||
await expect(service.deleteEvent('o', 'e')).rejects.toMatchObject({
|
||||
code: 'RES_001',
|
||||
});
|
||||
});
|
||||
|
||||
it('남의 반복 일정에는 완료 표시를 할 수 없다 (RES_001)', async () => {
|
||||
rules.findOneBy.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.setRuleDone('o', 'r', '2026-10-11', false),
|
||||
).rejects.toMatchObject({ code: 'RES_001' });
|
||||
expect(completions.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
|
||||
import {
|
||||
CursorPaginationQueryDto,
|
||||
PaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
PetLifecycleEvent,
|
||||
PetLifecycleListener,
|
||||
} from '@/modules/pet/pet-lifecycle';
|
||||
import { PetService } from '@/modules/pet/pet.service';
|
||||
import {
|
||||
CreateEventsDto,
|
||||
CreateRuleDto,
|
||||
EventDetailsDto,
|
||||
RepeatRuleResponseDto,
|
||||
RuleCompletionResponseDto,
|
||||
ScheduleEventResponseDto,
|
||||
UpdateRuleDto,
|
||||
toDetailsColumns,
|
||||
} from './dto/schedule.dto';
|
||||
import {
|
||||
RepeatRule,
|
||||
RepeatRuleCompletion,
|
||||
} from './entities/repeat-rule.entity';
|
||||
import { ScheduleEvent } from './entities/schedule-event.entity';
|
||||
|
||||
/** 대상(target_ids)을 가진 일정 테이블 */
|
||||
const TARGET_TABLES = ['schedule_events', 'repeat_rules'] as const;
|
||||
|
||||
// 일정 — 날짜 일정·반복 규칙·완료 표시. 어느 날에 무엇이 놓이는지는 앱이 계산한다.
|
||||
@Injectable()
|
||||
export class ScheduleService implements OnModuleInit, PetLifecycleListener {
|
||||
constructor(
|
||||
@InjectRepository(ScheduleEvent)
|
||||
private readonly events: Repository<ScheduleEvent>,
|
||||
@InjectRepository(RepeatRule)
|
||||
private readonly rules: Repository<RepeatRule>,
|
||||
@InjectRepository(RepeatRuleCompletion)
|
||||
private readonly completions: Repository<RepeatRuleCompletion>,
|
||||
private readonly pets: PetService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.pets.registerListener(this);
|
||||
}
|
||||
|
||||
// ── 날짜 일정 ───────────────────────────────────────────
|
||||
async listEvents(
|
||||
ownerId: string,
|
||||
query: CursorPaginationQueryDto,
|
||||
): Promise<PaginatedResult<ScheduleEventResponseDto>> {
|
||||
const qb = this.events
|
||||
.createQueryBuilder('event')
|
||||
.where('event.owner_id = :ownerId', { ownerId });
|
||||
const page = await keysetPaginate(
|
||||
qb,
|
||||
[
|
||||
{ column: 'event.date', cast: 'date', read: (e) => e.date },
|
||||
{ column: 'event.id', cast: 'uuid', read: (e) => e.id },
|
||||
],
|
||||
query,
|
||||
);
|
||||
return mapPage(page, (e) => ScheduleEventResponseDto.fromEvent(e));
|
||||
}
|
||||
|
||||
/** 여러 건을 한 번에 만든다 (기간 설정) — 하나라도 틀리면 아무것도 만들지 않는다 */
|
||||
async createEvents(
|
||||
ownerId: string,
|
||||
dto: CreateEventsDto,
|
||||
): Promise<ScheduleEventResponseDto[]> {
|
||||
await this.pets.assertOwnedTargets(
|
||||
ownerId,
|
||||
dto.events.flatMap((e) => e.targetIds),
|
||||
);
|
||||
const saved = await this.events.save(
|
||||
dto.events.map((e) =>
|
||||
this.events.create({
|
||||
ownerId,
|
||||
date: e.date,
|
||||
details: toDetailsColumns(e),
|
||||
isDone: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return saved.map((e) => ScheduleEventResponseDto.fromEvent(e));
|
||||
}
|
||||
|
||||
/** 일정 내용 바꾸기 — 날짜와 완료 표시는 그대로 둔다 */
|
||||
async updateEvent(
|
||||
ownerId: string,
|
||||
eventId: string,
|
||||
dto: EventDetailsDto,
|
||||
): Promise<ScheduleEventResponseDto> {
|
||||
const event = await this.findOwnedEvent(ownerId, eventId);
|
||||
await this.pets.assertOwnedTargets(ownerId, dto.targetIds);
|
||||
event.details = toDetailsColumns(dto);
|
||||
return ScheduleEventResponseDto.fromEvent(await this.events.save(event));
|
||||
}
|
||||
|
||||
async deleteEvent(ownerId: string, eventId: string): Promise<void> {
|
||||
const result = await this.events.delete({ id: eventId, ownerId });
|
||||
if (!result.affected) {
|
||||
throw new BusinessException('RES_001', { context: { eventId } });
|
||||
}
|
||||
}
|
||||
|
||||
async setEventDone(
|
||||
ownerId: string,
|
||||
eventId: string,
|
||||
done: boolean,
|
||||
): Promise<void> {
|
||||
const result = await this.events.update(
|
||||
{ id: eventId, ownerId },
|
||||
{ isDone: done },
|
||||
);
|
||||
if (!result.affected) {
|
||||
throw new BusinessException('RES_001', { context: { eventId } });
|
||||
}
|
||||
}
|
||||
|
||||
// ── 반복 규칙 ───────────────────────────────────────────
|
||||
async listRules(
|
||||
ownerId: string,
|
||||
query: CursorPaginationQueryDto,
|
||||
): Promise<PaginatedResult<RepeatRuleResponseDto>> {
|
||||
const qb = this.rules
|
||||
.createQueryBuilder('rule')
|
||||
.where('rule.owner_id = :ownerId', { ownerId });
|
||||
const page = await keysetPaginate(
|
||||
qb,
|
||||
[
|
||||
{
|
||||
column: 'rule.created_at',
|
||||
cast: 'timestamptz',
|
||||
read: (r) => r.createdAt.toISOString(),
|
||||
},
|
||||
{ column: 'rule.id', cast: 'uuid', read: (r) => r.id },
|
||||
],
|
||||
query,
|
||||
);
|
||||
return mapPage(page, (r) => RepeatRuleResponseDto.from(r));
|
||||
}
|
||||
|
||||
async createRule(
|
||||
ownerId: string,
|
||||
dto: CreateRuleDto,
|
||||
): Promise<RepeatRuleResponseDto> {
|
||||
await this.pets.assertOwnedTargets(ownerId, dto.details.targetIds);
|
||||
const rule = await this.rules.save(
|
||||
this.rules.create({
|
||||
ownerId,
|
||||
fromDate: dto.from,
|
||||
...dto.spec,
|
||||
details: toDetailsColumns(dto.details),
|
||||
}),
|
||||
);
|
||||
return RepeatRuleResponseDto.from(rule);
|
||||
}
|
||||
|
||||
/** 주기·내용 바꾸기 — 시작한 날과 날짜별 완료 표시는 그대로 둔다 */
|
||||
async updateRule(
|
||||
ownerId: string,
|
||||
ruleId: string,
|
||||
dto: UpdateRuleDto,
|
||||
): Promise<RepeatRuleResponseDto> {
|
||||
const rule = await this.findOwnedRule(ownerId, ruleId);
|
||||
await this.pets.assertOwnedTargets(ownerId, dto.details.targetIds);
|
||||
Object.assign(rule, dto.spec, { details: toDetailsColumns(dto.details) });
|
||||
return RepeatRuleResponseDto.from(await this.rules.save(rule));
|
||||
}
|
||||
|
||||
/** 반복 일정 삭제 — 규칙 전체와 날짜별 완료 표시가 함께 지워진다 */
|
||||
async deleteRule(ownerId: string, ruleId: string): Promise<void> {
|
||||
const result = await this.rules.delete({ id: ruleId, ownerId });
|
||||
if (!result.affected) {
|
||||
throw new BusinessException('RES_001', { context: { ruleId } });
|
||||
}
|
||||
}
|
||||
|
||||
async listCompletions(
|
||||
ownerId: string,
|
||||
query: CursorPaginationQueryDto,
|
||||
): Promise<PaginatedResult<RuleCompletionResponseDto>> {
|
||||
const qb = this.completions
|
||||
.createQueryBuilder('done')
|
||||
.innerJoin('done.rule', 'rule')
|
||||
.where('rule.owner_id = :ownerId', { ownerId });
|
||||
const page = await keysetPaginate(
|
||||
qb,
|
||||
[
|
||||
{ column: 'done.rule_id', cast: 'uuid', read: (c) => c.ruleId },
|
||||
{ column: 'done.date', cast: 'date', read: (c) => c.date },
|
||||
],
|
||||
query,
|
||||
);
|
||||
return mapPage(page, (c) => RuleCompletionResponseDto.from(c));
|
||||
}
|
||||
|
||||
/** 반복 일정의 그날 완료 표시 — 이미 같은 값이면 그대로 둔다 */
|
||||
async setRuleDone(
|
||||
ownerId: string,
|
||||
ruleId: string,
|
||||
date: string,
|
||||
done: boolean,
|
||||
): Promise<void> {
|
||||
await this.findOwnedRule(ownerId, ruleId);
|
||||
if (done) {
|
||||
await this.completions
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values({ ruleId, date })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
} else {
|
||||
await this.completions.delete({ ruleId, date });
|
||||
}
|
||||
}
|
||||
|
||||
// ── 아이 삭제·보관 ─────────────────────────────────────
|
||||
/** 아이를 지우면 일정에서 그 아이만 뗀다 — 대상이 남지 않은 일정은 지운다 (앱의 dropTarget) */
|
||||
async onPetDeleting(event: PetLifecycleEvent): Promise<void> {
|
||||
await this.dropTarget(event);
|
||||
}
|
||||
|
||||
/** 보관한 아이의 일정도 지운다 — 다시 불러와도 돌아오지 않는다 */
|
||||
async onPetArchived(event: PetLifecycleEvent): Promise<void> {
|
||||
await this.dropTarget(event);
|
||||
}
|
||||
|
||||
private async dropTarget({
|
||||
ownerId,
|
||||
petId,
|
||||
manager,
|
||||
}: PetLifecycleEvent): Promise<void> {
|
||||
// 테이블 이름은 아래 상수만 쓴다 (외부 입력을 SQL 에 넣지 않는다)
|
||||
for (const table of TARGET_TABLES) {
|
||||
await this.removeTargetFrom(manager, table, ownerId, petId);
|
||||
}
|
||||
}
|
||||
|
||||
private async removeTargetFrom(
|
||||
manager: EntityManager,
|
||||
table: string,
|
||||
ownerId: string,
|
||||
petId: string,
|
||||
): Promise<void> {
|
||||
await manager.query(
|
||||
`UPDATE ${table} SET target_ids = array_remove(target_ids, $2)
|
||||
WHERE owner_id = $1 AND $2 = ANY(target_ids)`,
|
||||
[ownerId, petId],
|
||||
);
|
||||
await manager.query(
|
||||
`DELETE FROM ${table} WHERE owner_id = $1 AND cardinality(target_ids) = 0`,
|
||||
[ownerId],
|
||||
);
|
||||
}
|
||||
|
||||
private async findOwnedEvent(
|
||||
ownerId: string,
|
||||
eventId: string,
|
||||
): Promise<ScheduleEvent> {
|
||||
const event = await this.events.findOneBy({ id: eventId, ownerId });
|
||||
if (!event)
|
||||
throw new BusinessException('RES_001', { context: { eventId } });
|
||||
return event;
|
||||
}
|
||||
|
||||
private async findOwnedRule(
|
||||
ownerId: string,
|
||||
ruleId: string,
|
||||
): Promise<RepeatRule> {
|
||||
const rule = await this.rules.findOneBy({ id: ruleId, ownerId });
|
||||
if (!rule) throw new BusinessException('RES_001', { context: { ruleId } });
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user