From 8380e35277a10628b0bfe5ee7dbd657e631b7ba6 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Sun, 11 Oct 2026 17:26:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=9D=BC=EC=A0=95=20API=20?= =?UTF-8?q?=E2=80=94=20=EB=82=A0=EC=A7=9C=20=EC=9D=BC=EC=A0=95=C2=B7?= =?UTF-8?q?=EB=B0=98=EB=B3=B5=20=EC=9D=BC=EC=A0=95=C2=B7=EC=99=84=EB=A3=8C?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱의 일정(ScheduleBook: 날짜 일정 + 반복 규칙 + 완료 표시)을 서버로 옮긴다. 어느 날에 무엇이 놓이는지는 지금처럼 앱이 계산하고, 서버는 원본을 보관한다. - /schedule-events: 목록·여러 건 만들기(기간 설정, 요청당 500건)·내용 바꾸기·삭제·완료 표시 - /repeat-rules: 목록·만들기·주기/내용 바꾸기·삭제, 날짜별 완료 표시(/completions/:date) - /repeat-rule-completions: 반복 일정 완료 표시 목록 - 대상은 "self" 또는 내 아이만 받는다. 아이를 지우거나 보관하면 그 아이만 떼고 대상이 빈 일정은 지운다 - 날짜 일정과 반복 규칙은 같은 내용 컬럼(ScheduleDetails)을 쓴다 - @nestjs/schedule 의 ScheduleModule 과 이름이 겹쳐 크론 쪽을 별칭으로 부른다 Co-Authored-By: Claude Opus 5.5 --- backend/src/app.module.ts | 7 +- backend/src/common/validators/index.ts | 39 +++ .../migrations/1791706872774-AddSchedule.ts | 53 ++++ backend/src/modules/pet/pet.service.ts | 4 +- .../src/modules/schedule/dto/schedule.dto.ts | 282 ++++++++++++++++++ .../schedule/entities/repeat-rule.entity.ts | 81 +++++ .../schedule/entities/schedule-details.ts | 61 ++++ .../entities/schedule-event.entity.ts | 45 +++ .../schedule/repeat-rules.controller.ts | 174 +++++++++++ .../schedule/schedule-events.controller.ts | 129 ++++++++ .../src/modules/schedule/schedule.module.ts | 29 ++ .../modules/schedule/schedule.service.spec.ts | 105 +++++++ .../src/modules/schedule/schedule.service.ts | 281 +++++++++++++++++ 13 files changed, 1285 insertions(+), 5 deletions(-) create mode 100644 backend/src/migrations/1791706872774-AddSchedule.ts create mode 100644 backend/src/modules/schedule/dto/schedule.dto.ts create mode 100644 backend/src/modules/schedule/entities/repeat-rule.entity.ts create mode 100644 backend/src/modules/schedule/entities/schedule-details.ts create mode 100644 backend/src/modules/schedule/entities/schedule-event.entity.ts create mode 100644 backend/src/modules/schedule/repeat-rules.controller.ts create mode 100644 backend/src/modules/schedule/schedule-events.controller.ts create mode 100644 backend/src/modules/schedule/schedule.module.ts create mode 100644 backend/src/modules/schedule/schedule.service.spec.ts create mode 100644 backend/src/modules/schedule/schedule.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index bcfdfad..ddb3370 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/common/validators/index.ts b/backend/src/common/validators/index.ts index 3a217b5..98fa0bc 100644 --- a/backend/src/common/validators/index.ts +++ b/backend/src/common/validators/index.ts @@ -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 }); +} diff --git a/backend/src/migrations/1791706872774-AddSchedule.ts b/backend/src/migrations/1791706872774-AddSchedule.ts new file mode 100644 index 0000000..202b863 --- /dev/null +++ b/backend/src/migrations/1791706872774-AddSchedule.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSchedule1791706872774 implements MigrationInterface { + name = 'AddSchedule1791706872774'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/pet/pet.service.ts b/backend/src/modules/pet/pet.service.ts index 5279dc7..45f6468 100644 --- a/backend/src/modules/pet/pet.service.ts +++ b/backend/src/modules/pet/pet.service.ts @@ -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 { diff --git a/backend/src/modules/schedule/dto/schedule.dto.ts b/backend/src/modules/schedule/dto/schedule.dto.ts new file mode 100644 index 0000000..325b4a3 --- /dev/null +++ b/backend/src/modules/schedule/dto/schedule.dto.ts @@ -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, + }; +} diff --git a/backend/src/modules/schedule/entities/repeat-rule.entity.ts b/backend/src/modules/schedule/entities/repeat-rule.entity.ts new file mode 100644 index 0000000..13cd64f --- /dev/null +++ b/backend/src/modules/schedule/entities/repeat-rule.entity.ts @@ -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; +} diff --git a/backend/src/modules/schedule/entities/schedule-details.ts b/backend/src/modules/schedule/entities/schedule-details.ts new file mode 100644 index 0000000..1cfb5da --- /dev/null +++ b/backend/src/modules/schedule/entities/schedule-details.ts @@ -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; +} diff --git a/backend/src/modules/schedule/entities/schedule-event.entity.ts b/backend/src/modules/schedule/entities/schedule-event.entity.ts new file mode 100644 index 0000000..bcb8792 --- /dev/null +++ b/backend/src/modules/schedule/entities/schedule-event.entity.ts @@ -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; +} diff --git a/backend/src/modules/schedule/repeat-rules.controller.ts b/backend/src/modules/schedule/repeat-rules.controller.ts new file mode 100644 index 0000000..c713ace --- /dev/null +++ b/backend/src/modules/schedule/repeat-rules.controller.ts @@ -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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + return this.schedule.listCompletions(user.userId, query); + } +} diff --git a/backend/src/modules/schedule/schedule-events.controller.ts b/backend/src/modules/schedule/schedule-events.controller.ts new file mode 100644 index 0000000..ee136e2 --- /dev/null +++ b/backend/src/modules/schedule/schedule-events.controller.ts @@ -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> { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.schedule.setEventDone(user.userId, id, false); + } +} diff --git a/backend/src/modules/schedule/schedule.module.ts b/backend/src/modules/schedule/schedule.module.ts new file mode 100644 index 0000000..7a9daae --- /dev/null +++ b/backend/src/modules/schedule/schedule.module.ts @@ -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 {} diff --git a/backend/src/modules/schedule/schedule.service.spec.ts b/backend/src/modules/schedule/schedule.service.spec.ts new file mode 100644 index 0000000..25a6ab3 --- /dev/null +++ b/backend/src/modules/schedule/schedule.service.spec.ts @@ -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 => ({ + 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, + rules as unknown as Repository, + completions as unknown as Repository, + pets as unknown as PetService, + ); + + beforeEach(() => jest.clearAllMocks()); + + it('시작할 때 아이 삭제·보관 리스너로 등록한다', () => { + service.onModuleInit(); + expect(pets.registerListener).toHaveBeenCalledWith(service); + }); + + it('아이를 지우면 두 일정 테이블에서 그 아이를 떼고, 대상이 빈 일정을 지운다', async () => { + const query = jest + .fn, [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(); + }); +}); diff --git a/backend/src/modules/schedule/schedule.service.ts b/backend/src/modules/schedule/schedule.service.ts new file mode 100644 index 0000000..fcf44a8 --- /dev/null +++ b/backend/src/modules/schedule/schedule.service.ts @@ -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, + @InjectRepository(RepeatRule) + private readonly rules: Repository, + @InjectRepository(RepeatRuleCompletion) + private readonly completions: Repository, + private readonly pets: PetService, + ) {} + + onModuleInit(): void { + this.pets.registerListener(this); + } + + // ── 날짜 일정 ─────────────────────────────────────────── + async listEvents( + ownerId: string, + query: CursorPaginationQueryDto, + ): Promise> { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + await this.dropTarget(event); + } + + /** 보관한 아이의 일정도 지운다 — 다시 불러와도 돌아오지 않는다 */ + async onPetArchived(event: PetLifecycleEvent): Promise { + await this.dropTarget(event); + } + + private async dropTarget({ + ownerId, + petId, + manager, + }: PetLifecycleEvent): Promise { + // 테이블 이름은 아래 상수만 쓴다 (외부 입력을 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 { + 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 { + 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 { + const rule = await this.rules.findOneBy({ id: ruleId, ownerId }); + if (!rule) throw new BusinessException('RES_001', { context: { ruleId } }); + return rule; + } +}