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:
2026-10-11 17:26:14 +09:00
parent 720b2cd858
commit 8380e35277
13 changed files with 1285 additions and 5 deletions
+5 -2
View File
@@ -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,
+39
View File
@@ -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"`);
}
}
+1 -3
View File
@@ -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;
}
}