From 720b2cd858a87045b73ead533f422a985f225619 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Sun, 11 Oct 2026 17:16:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EB=B0=98=EB=A0=A4=EB=8F=99?= =?UTF-8?q?=EB=AC=BC=20API=20=E2=80=94=20=EB=93=B1=EB=A1=9D=C2=B7=EC=88=98?= =?UTF-8?q?=EC=A0=95=C2=B7=EC=82=AD=EC=A0=9C=C2=B7=EC=88=9C=EC=84=9C=C2=B7?= =?UTF-8?q?=EC=B6=94=EC=96=B5=20=EB=B3=B4=EA=B4=80=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱의 우리 아이 기록(PetRepository)을 서버로 옮긴다. - GET/POST /pets, PATCH/DELETE /pets/:id, PUT /pets/order, PUT/DELETE /pets/:id/archive - 목록은 정한 순서(sort_order) 기준 커서 페이지네이션 — 공통 keysetPaginate 로 뽑았다 - 아이를 지우거나 보관할 때 일정·다이어리·명함이 따라 정리할 수 있게 PetLifecycleListener 를 두었다 (반려동물 모듈은 다른 도메인을 모르고, 정리는 같은 트랜잭션에서 한다) - 사진을 바꾸면 쓰지 않는 파일만 지운다 — 다른 기록이 참조하면 FK 가 막는다 - 목록 응답 Swagger 를 실제 { items, pageInfo } 모양으로 그리는 ApiPaginatedResponse 추가 - e2e 에 목록 pageInfo 계약·미정의 쿼리 거부 검사를 되살렸다 Co-Authored-By: Claude Opus 5.5 --- backend/src/app.module.ts | 2 + .../api-standard-response.decorator.ts | 33 +++ backend/src/common/dto/keyset-pagination.ts | 64 +++++ backend/src/common/validators/index.ts | 54 ++++ .../src/migrations/1791706303884-AddPets.ts | 33 +++ backend/src/modules/pet/dto/pet-input.dto.ts | 211 +++++++++++++++ .../src/modules/pet/dto/pet-response.dto.ts | 126 +++++++++ .../src/modules/pet/entities/pet.entity.ts | 133 ++++++++++ backend/src/modules/pet/pet-lifecycle.ts | 27 ++ backend/src/modules/pet/pet-order.spec.ts | 22 ++ backend/src/modules/pet/pet-order.ts | 15 ++ backend/src/modules/pet/pet.controller.ts | 152 +++++++++++ backend/src/modules/pet/pet.module.ts | 15 ++ backend/src/modules/pet/pet.service.spec.ts | 136 ++++++++++ backend/src/modules/pet/pet.service.ts | 244 ++++++++++++++++++ backend/src/shared/utils/iso-date.spec.ts | 20 ++ backend/src/shared/utils/iso-date.ts | 20 ++ backend/test/app.e2e-spec.ts | 58 +++++ 18 files changed, 1365 insertions(+) create mode 100644 backend/src/common/dto/keyset-pagination.ts create mode 100644 backend/src/common/validators/index.ts create mode 100644 backend/src/migrations/1791706303884-AddPets.ts create mode 100644 backend/src/modules/pet/dto/pet-input.dto.ts create mode 100644 backend/src/modules/pet/dto/pet-response.dto.ts create mode 100644 backend/src/modules/pet/entities/pet.entity.ts create mode 100644 backend/src/modules/pet/pet-lifecycle.ts create mode 100644 backend/src/modules/pet/pet-order.spec.ts create mode 100644 backend/src/modules/pet/pet-order.ts create mode 100644 backend/src/modules/pet/pet.controller.ts create mode 100644 backend/src/modules/pet/pet.module.ts create mode 100644 backend/src/modules/pet/pet.service.spec.ts create mode 100644 backend/src/modules/pet/pet.service.ts create mode 100644 backend/src/shared/utils/iso-date.spec.ts create mode 100644 backend/src/shared/utils/iso-date.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 033990a..bcfdfad 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -10,6 +10,7 @@ import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; 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 { UploadModule } from '@/modules/upload/upload.module'; import { UserModule } from '@/modules/user/user.module'; import { parsePositiveInt } from '@/shared/utils/env'; @@ -63,6 +64,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 UploadModule, UserModule, AuthModule, + PetModule, ], providers: [ ShutdownLogger, diff --git a/backend/src/common/decorators/api-standard-response.decorator.ts b/backend/src/common/decorators/api-standard-response.decorator.ts index 994ef86..f5e42d2 100644 --- a/backend/src/common/decorators/api-standard-response.decorator.ts +++ b/backend/src/common/decorators/api-standard-response.decorator.ts @@ -1,6 +1,7 @@ import { Type, applyDecorators } from '@nestjs/common'; import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger'; +import { PageInfoDto } from '@/common/dto/pagination.dto'; import { ErrorCode, getErrorDefinition } from '@/common/errors/error-codes'; /** @@ -38,6 +39,38 @@ export function ApiStandardResponse>( ); } +/** + * 목록(페이지네이션) 응답을 문서화한다 — 실제 본문은 { success, data: { items, pageInfo } } 다. + * `ApiStandardResponse(Dto, { isArray: true })` 는 data 를 배열로 그려 실응답과 어긋난다. + * + * 사용 예: + * @ApiPaginatedResponse(PetResponseDto, '아이 목록') + */ +export function ApiPaginatedResponse>( + model: TModel, + description = '목록 조회 성공', +) { + return applyDecorators( + ApiExtraModels(model, PageInfoDto), + ApiResponse({ + status: 200, + description, + schema: { + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + items: { type: 'array', items: { $ref: getSchemaPath(model) } }, + pageInfo: { $ref: getSchemaPath(PageInfoDto) }, + }, + }, + }, + }, + }), + ); +} + /** * 표준 실패 응답을 에러 코드와 함께 문서화한다. * 상태 코드·메시지는 레지스트리에서 가져오므로 문서와 구현이 자동으로 일치한다. diff --git a/backend/src/common/dto/keyset-pagination.ts b/backend/src/common/dto/keyset-pagination.ts new file mode 100644 index 0000000..4ac263f --- /dev/null +++ b/backend/src/common/dto/keyset-pagination.ts @@ -0,0 +1,64 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { CursorValue, decodeCursor, keysetPage } from '@/shared/utils/cursor'; +import { PaginatedResult, toPaginatedResult } from './pagination.dto'; + +/** 커서 정렬 키 — 앞의 키부터 오름차순으로 정렬하고, 마지막 키(보통 id)로 순서를 확정한다 */ +export interface KeysetColumn { + /** 쿼리 빌더 별칭을 붙인 컬럼 (예: `pet.sort_order`) */ + readonly column: string; + /** 커서 값을 비교할 때 쓰는 DB 타입 (예: `integer`·`timestamptz`·`uuid`) */ + readonly cast: string; + /** 행에서 커서에 담을 값 */ + readonly read: (row: T) => CursorValue; +} + +/** + * 커서 기반 목록 조회 — [query] 의 cursor 다음부터 limit 개를 읽는다. + * total 은 커서 조건 없이 센 전체 개수다. 형식이 틀린 커서는 VAL_001. + */ +export async function keysetPaginate( + qb: SelectQueryBuilder, + columns: readonly KeysetColumn[], + query: { readonly limit: number; readonly cursor?: string }, +): Promise> { + const total = await qb.clone().getCount(); + + if (query.cursor !== undefined) { + const values = decodeCursor(query.cursor, columns.length); + if (!values) { + throw new BusinessException('VAL_001', { + context: { reason: 'invalid-cursor' }, + }); + } + const left = columns.map((c) => c.column).join(', '); + const right = columns.map((c, i) => `:cursor${i}::${c.cast}`).join(', '); + qb.andWhere( + `(${left}) > (${right})`, + Object.fromEntries(values.map((v, i) => [`cursor${i}`, v])), + ); + } + + columns.forEach((c, i) => + i === 0 ? qb.orderBy(c.column, 'ASC') : qb.addOrderBy(c.column, 'ASC'), + ); + const rows = await qb.limit(query.limit + 1).getMany(); + const page = keysetPage(rows, query.limit, (row) => + columns.map((c) => c.read(row)), + ); + + return toPaginatedResult(page.items, { + hasNext: page.hasNext, + nextCursor: page.nextCursor, + total, + }); +} + +/** 목록의 각 항목을 응답 DTO 로 바꾼다 (pageInfo 는 그대로) */ +export function mapPage( + page: PaginatedResult, + map: (item: T) => R, +): PaginatedResult { + return { items: page.items.map(map), pageInfo: page.pageInfo }; +} diff --git a/backend/src/common/validators/index.ts b/backend/src/common/validators/index.ts new file mode 100644 index 0000000..3a217b5 --- /dev/null +++ b/backend/src/common/validators/index.ts @@ -0,0 +1,54 @@ +import { applyDecorators } from '@nestjs/common'; +import { + ArrayMaxSize, + IsArray, + IsString, + Matches, + MaxLength, + ValidateBy, + ValidationOptions, + buildMessage, +} from 'class-validator'; + +import { isValidIsoDate } from '@/shared/utils/iso-date'; + +/** `#RRGGBB` 색 — 앱(color_utils hexOf)이 쓰는 형식 */ +export const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/; + +/** 시각 없는 날짜 `YYYY-MM-DD` — 달력에 있는 날짜만 */ +export function IsIsoDate(options?: ValidationOptions): PropertyDecorator { + return ValidateBy( + { + name: 'isIsoDate', + validator: { + validate: (value: unknown) => + typeof value === 'string' && isValidIsoDate(value), + defaultMessage: buildMessage( + () => '날짜는 YYYY-MM-DD 형식으로 입력해 주세요.', + options, + ), + }, + }, + options, + ); +} + +/** `#RRGGBB` 색 */ +export function IsHexColor(message = '색 값이 올바르지 않습니다.') { + return Matches(HEX_COLOR_PATTERN, { message }); +} + +/** 짧은 문자열 목록 (알레르기·질환처럼 칩으로 고르는 값) */ +export function IsShortStringList(maxItems: number, maxLength: number) { + return applyDecorators( + IsArray({ message: '목록 형식이 올바르지 않습니다.' }), + ArrayMaxSize(maxItems, { + message: `최대 ${maxItems}개까지 입력할 수 있습니다.`, + }), + IsString({ each: true, message: '목록의 값은 문자로 입력해 주세요.' }), + MaxLength(maxLength, { + each: true, + message: `목록의 값은 ${maxLength}자 이하로 입력해 주세요.`, + }), + ); +} diff --git a/backend/src/migrations/1791706303884-AddPets.ts b/backend/src/migrations/1791706303884-AddPets.ts new file mode 100644 index 0000000..77675cb --- /dev/null +++ b/backend/src/migrations/1791706303884-AddPets.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPets1791706303884 implements MigrationInterface { + name = 'AddPets1791706303884'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "pets" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "sort_order" integer NOT NULL, "tint" character varying(7) NOT NULL, "name" character varying(30) NOT NULL, "species" character varying(20) NOT NULL, "breed" character varying(60) NOT NULL DEFAULT '', "gender" character varying(6) NOT NULL, "neutered" boolean NOT NULL, "birth_date" date NOT NULL, "weight" double precision NOT NULL, "weight_unit" character varying(2) NOT NULL, "photo_file_id" uuid, "microchip" character varying(30), "blood_type" character varying(20), "vet" character varying(60), "memo" character varying(1000), "allergies" text array NOT NULL DEFAULT '{}', "conditions" text array NOT NULL DEFAULT '{}', "watch_conditions" text array NOT NULL DEFAULT '{}', "archive_from" date, "archive_to" date, "archived_at" TIMESTAMP WITH TIME ZONE, "archive_memo" character varying(1000), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_d01e9e7b4ada753c826720bee8b" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d6c565fded8031d4cdd54fe104" ON "pets" ("owner_id") `, + ); + await queryRunner.query( + `ALTER TABLE "pets" ADD CONSTRAINT "FK_d6c565fded8031d4cdd54fe1043" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "pets" ADD CONSTRAINT "FK_816320a5aa9d3cafbdddcdde33c" FOREIGN KEY ("photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "pets" DROP CONSTRAINT "FK_816320a5aa9d3cafbdddcdde33c"`, + ); + await queryRunner.query( + `ALTER TABLE "pets" DROP CONSTRAINT "FK_d6c565fded8031d4cdd54fe1043"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_d6c565fded8031d4cdd54fe104"`, + ); + await queryRunner.query(`DROP TABLE "pets"`); + } +} diff --git a/backend/src/modules/pet/dto/pet-input.dto.ts b/backend/src/modules/pet/dto/pet-input.dto.ts new file mode 100644 index 0000000..769da52 --- /dev/null +++ b/backend/src/modules/pet/dto/pet-input.dto.ts @@ -0,0 +1,211 @@ +import { + ApiProperty, + ApiPropertyOptional, + OmitType, + PartialType, +} from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsIn, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + ValidateIf, +} from 'class-validator'; + +import { IsHexColor, IsIsoDate, IsShortStringList } from '@/common/validators'; +import { PET_GENDERS, PET_LIMITS, WEIGHT_UNITS } from '../entities/pet.entity'; +import type { PetGender, WeightUnit } from '../entities/pet.entity'; + +/** 앞뒤 공백을 떼고, 비면 null — 선택 입력(혈액형·병원·메모)은 빈 칸을 "없음"으로 저장한다 */ +const trimToNull = ({ value }: { value: unknown }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +}; + +const trim = ({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value; + +/** 아이 등록 — 앱의 등록 화면 입력값 (PetInput) */ +export class CreatePetDto { + @ApiProperty({ + description: + '목록 띠·칩에 쓰는 고유색 (#RRGGBB) — 앱이 등록 순서대로 정한다', + example: '#FF9B6A', + }) + @IsHexColor() + tint!: string; + + @ApiProperty({ + description: `이름 (최대 ${PET_LIMITS.name}자)`, + example: '콩이', + }) + @Transform(trim) + @IsString({ message: '이름은 문자로 입력해 주세요.' }) + @IsNotEmpty({ message: '이름을 입력해 주세요.' }) + @MaxLength(PET_LIMITS.name, { + message: `이름은 ${PET_LIMITS.name}자 이하로 입력해 주세요.`, + }) + name!: string; + + @ApiProperty({ description: '대분류 (강아지·고양이 …)', example: '강아지' }) + @IsString({ message: '종류를 골라 주세요.' }) + @IsNotEmpty({ message: '종류를 골라 주세요.' }) + @MaxLength(PET_LIMITS.species, { message: '종류가 올바르지 않습니다.' }) + species!: string; + + @ApiProperty({ + description: '세부 품종 — 고르지 않았으면 빈 문자열', + example: '말티즈', + }) + @Transform(trim) + @IsString({ message: '품종은 문자로 입력해 주세요.' }) + @MaxLength(PET_LIMITS.breed, { + message: `품종은 ${PET_LIMITS.breed}자 이하로 입력해 주세요.`, + }) + breed!: string; + + @ApiProperty({ description: '성별', enum: PET_GENDERS, example: 'male' }) + @IsIn(PET_GENDERS, { message: '성별을 골라 주세요.' }) + gender!: PetGender; + + @ApiProperty({ description: '중성화 여부', example: true }) + @IsBoolean({ message: '중성화 여부를 골라 주세요.' }) + neutered!: boolean; + + @ApiProperty({ description: '생일 (YYYY-MM-DD)', example: '2021-09-14' }) + @IsIsoDate() + birthDate!: string; + + @ApiProperty({ description: '몸무게 (weightUnit 단위)', example: 3.2 }) + @IsNumber( + { allowNaN: false, allowInfinity: false }, + { message: '몸무게는 숫자로 입력해 주세요.' }, + ) + @Min(0, { message: '몸무게는 0 이상이어야 합니다.' }) + @Max(PET_LIMITS.maxWeight, { message: '몸무게가 너무 큽니다.' }) + weight!: number; + + @ApiProperty({ + description: '몸무게 단위', + enum: WEIGHT_UNITS, + example: 'kg', + }) + @IsIn(WEIGHT_UNITS, { message: '몸무게 단위를 골라 주세요.' }) + weightUnit!: WeightUnit; + + @ApiPropertyOptional({ + description: '사진 파일 ID (POST /api/files 로 올린 이미지). 없으면 null', + nullable: true, + type: String, + }) + @IsOptional() + @IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' }) + photoFileId?: string | null; + + @ApiPropertyOptional({ + description: '동물등록번호', + nullable: true, + type: String, + }) + @IsOptional() + @Transform(trimToNull) + @IsString() + @MaxLength(PET_LIMITS.microchip, { message: '동물등록번호가 너무 깁니다.' }) + microchip?: string | null; + + @ApiPropertyOptional({ description: '혈액형', nullable: true, type: String }) + @IsOptional() + @Transform(trimToNull) + @IsString() + @MaxLength(PET_LIMITS.bloodType, { message: '혈액형이 너무 깁니다.' }) + bloodType?: string | null; + + @ApiPropertyOptional({ + description: '다니는 병원', + nullable: true, + type: String, + }) + @IsOptional() + @Transform(trimToNull) + @IsString() + @MaxLength(PET_LIMITS.vet, { + message: `병원 이름은 ${PET_LIMITS.vet}자 이하로 입력해 주세요.`, + }) + vet?: string | null; + + @ApiPropertyOptional({ description: '메모', nullable: true, type: String }) + @IsOptional() + @Transform(trimToNull) + @IsString() + @MaxLength(PET_LIMITS.memo, { + message: `메모는 ${PET_LIMITS.memo}자 이하로 입력해 주세요.`, + }) + memo?: string | null; + + @ApiProperty({ description: '알레르기', type: [String], example: ['닭고기'] }) + @IsShortStringList(PET_LIMITS.listItems, PET_LIMITS.listItemLength) + allergies!: string[]; + + @ApiProperty({ description: '현재 보유 질환', type: [String] }) + @IsShortStringList(PET_LIMITS.listItems, PET_LIMITS.listItemLength) + conditions!: string[]; + + @ApiProperty({ description: '염려되는 질환', type: [String] }) + @IsShortStringList(PET_LIMITS.listItems, PET_LIMITS.listItemLength) + watchConditions!: string[]; +} + +/** + * 아이 정보 수정 — 보낸 값만 바꾼다. + * 고유색은 앱이 정한 값이라 바꾸지 않고, 보관 기록은 /archive 로만 바꾼다. + */ +export class UpdatePetDto extends PartialType( + OmitType(CreatePetDto, ['tint'] as const), +) {} + +/** 추억 보관함으로 옮기기 */ +export class ArchivePetDto { + @ApiProperty({ + description: '함께한 첫날 (YYYY-MM-DD)', + example: '2015-04-02', + }) + @IsIsoDate() + from!: string; + + @ApiProperty({ description: '마지막 날 (YYYY-MM-DD)', example: '2026-10-01' }) + @IsIsoDate() + to!: string; + + @ApiPropertyOptional({ + description: '함께한 시간에 대한 메모', + nullable: true, + type: String, + }) + @IsOptional() + @Transform(trimToNull) + @ValidateIf((dto: ArchivePetDto) => dto.memo !== null) + @IsString() + @MaxLength(PET_LIMITS.memo, { + message: `메모는 ${PET_LIMITS.memo}자 이하로 입력해 주세요.`, + }) + memo?: string | null; +} + +/** 우리 아이 순서 바꾸기 — [ids] 순서로 두고, 빠진 아이는 원래 순서대로 뒤에 둔다 */ +export class ReorderPetsDto { + @ApiProperty({ description: '앞에서부터 놓을 아이 id', type: [String] }) + @IsArray({ message: '순서 정보가 올바르지 않습니다.' }) + @ArrayMaxSize(500, { message: '순서 정보가 올바르지 않습니다.' }) + @IsUUID('4', { each: true, message: '순서 정보가 올바르지 않습니다.' }) + ids!: string[]; +} diff --git a/backend/src/modules/pet/dto/pet-response.dto.ts b/backend/src/modules/pet/dto/pet-response.dto.ts new file mode 100644 index 0000000..fb564f2 --- /dev/null +++ b/backend/src/modules/pet/dto/pet-response.dto.ts @@ -0,0 +1,126 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { PET_GENDERS, WEIGHT_UNITS } from '../entities/pet.entity'; +import type { Pet, PetGender, WeightUnit } from '../entities/pet.entity'; + +/** 추억 보관함 기록 */ +export class PetArchiveDto { + @ApiProperty({ + description: '함께한 첫날 (YYYY-MM-DD)', + example: '2015-04-02', + }) + from!: string; + + @ApiProperty({ description: '마지막 날 (YYYY-MM-DD)', example: '2026-10-01' }) + to!: string; + + @ApiProperty({ description: '보관한 때 (ISO 8601 UTC)' }) + archivedAt!: string; + + @ApiProperty({ description: '메모', nullable: true, type: String }) + memo!: string | null; +} + +/** 반려동물 — 앱의 Pet 모델과 같은 모양 */ +export class PetResponseDto { + @ApiProperty({ description: '아이 ID' }) + id!: string; + + @ApiProperty({ description: '고유색 (#RRGGBB)', example: '#FF9B6A' }) + tint!: string; + + @ApiProperty({ example: '콩이' }) + name!: string; + + @ApiProperty({ description: '대분류', example: '강아지' }) + species!: string; + + @ApiProperty({ + description: '세부 품종 (없으면 빈 문자열)', + example: '말티즈', + }) + breed!: string; + + @ApiProperty({ enum: PET_GENDERS }) + gender!: PetGender; + + @ApiProperty() + neutered!: boolean; + + @ApiProperty({ description: '생일 (YYYY-MM-DD)', example: '2021-09-14' }) + birthDate!: string; + + @ApiProperty({ example: 3.2 }) + weight!: number; + + @ApiProperty({ enum: WEIGHT_UNITS }) + weightUnit!: WeightUnit; + + @ApiProperty({ + description: '사진 파일 ID — GET /api/files/{id}', + nullable: true, + type: String, + }) + photoFileId!: string | null; + + @ApiProperty({ nullable: true, type: String }) + microchip!: string | null; + + @ApiProperty({ nullable: true, type: String }) + bloodType!: string | null; + + @ApiProperty({ nullable: true, type: String }) + vet!: string | null; + + @ApiProperty({ nullable: true, type: String }) + memo!: string | null; + + @ApiProperty({ type: [String] }) + allergies!: string[]; + + @ApiProperty({ type: [String] }) + conditions!: string[]; + + @ApiProperty({ type: [String] }) + watchConditions!: string[]; + + @ApiProperty({ + description: '추억 보관함 기록 — 함께 지내는 아이는 null', + nullable: true, + type: PetArchiveDto, + }) + archive!: PetArchiveDto | null; + + static from(pet: Pet): PetResponseDto { + const { archiveFrom, archiveTo, archivedAt } = pet; + return { + id: pet.id, + tint: pet.tint, + name: pet.name, + species: pet.species, + breed: pet.breed, + gender: pet.gender, + neutered: pet.neutered, + birthDate: pet.birthDate, + weight: pet.weight, + weightUnit: pet.weightUnit, + photoFileId: pet.photoFileId, + microchip: pet.microchip, + bloodType: pet.bloodType, + vet: pet.vet, + memo: pet.memo, + allergies: pet.allergies, + conditions: pet.conditions, + watchConditions: pet.watchConditions, + archive: + archiveFrom !== null && archiveTo !== null && archivedAt !== null + ? { + from: archiveFrom, + to: archiveTo, + archivedAt: archivedAt.toISOString(), + memo: pet.archiveMemo, + } + : null, + }; + } +} diff --git a/backend/src/modules/pet/entities/pet.entity.ts b/backend/src/modules/pet/entities/pet.entity.ts new file mode 100644 index 0000000..2714c7c --- /dev/null +++ b/backend/src/modules/pet/entities/pet.entity.ts @@ -0,0 +1,133 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { StoredFile } from '@/modules/upload/entities/stored-file.entity'; +import { User } from '@/modules/user/entities/user.entity'; + +/** 입력 한도 — 앱 화면에는 제한이 없어 넉넉하게 두고, 과도한 입력만 막는다 */ +export const PET_LIMITS = { + name: 30, + species: 20, + breed: 60, + microchip: 30, + bloodType: 20, + vet: 60, + memo: 1000, + listItems: 50, + listItemLength: 30, + maxWeight: 100_000, +} as const; + +export const PET_GENDERS = ['male', 'female'] as const; +export type PetGender = (typeof PET_GENDERS)[number]; + +export const WEIGHT_UNITS = ['kg', 'g'] as const; +export type WeightUnit = (typeof WEIGHT_UNITS)[number]; + +/** 등록한 반려동물 — 추억 보관함으로 옮긴 아이도 같은 테이블에 남는다(archive* 값이 채워진다) */ +@Entity('pets') +export class Pet { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'owner_id' }) + owner?: User; + + @Index() + @Column({ type: 'uuid' }) + ownerId!: string; + + /** 우리 아이 순서 — 작은 값이 앞 */ + @Column({ type: 'integer' }) + sortOrder!: number; + + /** 목록 띠·칩에 쓰는 아이의 고유색 `#RRGGBB` (앱이 등록 순서대로 정한다) */ + @Column({ type: 'varchar', length: 7 }) + tint!: string; + + @Column({ type: 'varchar', length: PET_LIMITS.name }) + name!: string; + + /** 대분류 — 강아지·고양이 … */ + @Column({ type: 'varchar', length: PET_LIMITS.species }) + species!: string; + + /** 세부 품종 — 고르지 않았으면 빈 문자열 */ + @Column({ type: 'varchar', length: PET_LIMITS.breed, default: '' }) + breed!: string; + + @Column({ type: 'varchar', length: 6 }) + gender!: PetGender; + + @Column({ type: 'boolean' }) + neutered!: boolean; + + /** 생일 (시각 없음, YYYY-MM-DD) */ + @Column({ type: 'date' }) + birthDate!: string; + + @Column({ type: 'double precision' }) + weight!: number; + + @Column({ type: 'varchar', length: 2 }) + weightUnit!: WeightUnit; + + /** 아이 사진 — 참조 중인 파일은 지울 수 없게 둔다 (UploadService.releaseIfUnused) */ + @ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' }) + @JoinColumn({ name: 'photo_file_id' }) + photoFile?: StoredFile | null; + + @Column({ type: 'uuid', nullable: true }) + photoFileId!: string | null; + + @Column({ type: 'varchar', length: PET_LIMITS.microchip, nullable: true }) + microchip!: string | null; + + @Column({ type: 'varchar', length: PET_LIMITS.bloodType, nullable: true }) + bloodType!: string | null; + + @Column({ type: 'varchar', length: PET_LIMITS.vet, nullable: true }) + vet!: string | null; + + @Column({ type: 'varchar', length: PET_LIMITS.memo, nullable: true }) + memo!: string | null; + + @Column({ type: 'text', array: true, default: '{}' }) + allergies!: string[]; + + /** 현재 보유 질환 */ + @Column({ type: 'text', array: true, default: '{}' }) + conditions!: string[]; + + /** 염려되는 질환 */ + @Column({ type: 'text', array: true, default: '{}' }) + watchConditions!: string[]; + + /** 추억 보관함 — 함께한 첫날·마지막 날·보관한 때·메모. 함께 지내는 아이는 모두 null */ + @Column({ type: 'date', nullable: true }) + archiveFrom!: string | null; + + @Column({ type: 'date', nullable: true }) + archiveTo!: string | null; + + @Column({ type: 'timestamptz', nullable: true }) + archivedAt!: Date | null; + + @Column({ type: 'varchar', length: PET_LIMITS.memo, nullable: true }) + archiveMemo!: string | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/pet/pet-lifecycle.ts b/backend/src/modules/pet/pet-lifecycle.ts new file mode 100644 index 0000000..940e8ae --- /dev/null +++ b/backend/src/modules/pet/pet-lifecycle.ts @@ -0,0 +1,27 @@ +import { EntityManager } from 'typeorm'; + +/** 아이에게 생긴 일 — 다른 도메인이 자기 기록을 맞춘다 */ +export interface PetLifecycleEvent { + readonly ownerId: string; + readonly petId: string; + /** 아이를 바꾸는 트랜잭션 — 같은 트랜잭션에서 정리해야 반쯤 지워진 상태가 남지 않는다 */ + readonly manager: EntityManager; +} + +/** + * 아이의 삭제·보관을 따라 정리해야 하는 도메인(일정·다이어리·명함)이 구현한다. + * 각 모듈이 onModuleInit 에서 PetService.registerListener 로 등록한다 — + * 반려동물 모듈이 다른 도메인을 알지 않아도 되고(의존 방향: 다른 도메인 → 반려동물), 정리는 한 트랜잭션에 묶인다. + */ +export interface PetLifecycleListener { + /** + * 아이를 지우기 직전. 그 아이를 대상에서 떼는 등 정리를 한다. + * 정리하면서 더 쓰지 않게 된 파일 id 를 돌려주면 커밋한 뒤 지운다. + */ + onPetDeleting?( + event: PetLifecycleEvent, + ): Promise | void>; + + /** 아이를 추억 보관함으로 옮긴 직후 */ + onPetArchived?(event: PetLifecycleEvent): Promise; +} diff --git a/backend/src/modules/pet/pet-order.spec.ts b/backend/src/modules/pet/pet-order.spec.ts new file mode 100644 index 0000000..e18ccf9 --- /dev/null +++ b/backend/src/modules/pet/pet-order.spec.ts @@ -0,0 +1,22 @@ +import { reorderIds } from './pet-order'; + +describe('reorderIds', () => { + const current = ['a', 'b', 'c', 'd']; + + it('요청한 순서로 앞에 두고 빠진 아이는 원래 순서대로 뒤에 둔다', () => { + expect(reorderIds(current, ['c', 'a'])).toEqual(['c', 'a', 'b', 'd']); + }); + + it('모르는 id 와 중복은 무시한다', () => { + expect(reorderIds(current, ['x', 'b', 'b', 'a'])).toEqual([ + 'b', + 'a', + 'c', + 'd', + ]); + }); + + it('빈 요청이면 순서가 그대로다', () => { + expect(reorderIds(current, [])).toEqual(current); + }); +}); diff --git a/backend/src/modules/pet/pet-order.ts b/backend/src/modules/pet/pet-order.ts new file mode 100644 index 0000000..401162c --- /dev/null +++ b/backend/src/modules/pet/pet-order.ts @@ -0,0 +1,15 @@ +// 우리 아이 순서 — 부수효과 없는 순수 함수 (앱의 reorderPets 와 같은 규칙) + +/** + * [requested] 순서로 줄 세운 id 목록. 빠진 아이(추억 보관함 등)는 원래 순서대로 뒤에 붙인다. + * [current] 에 없는 id 는 무시하고, 중복은 처음 나온 자리만 쓴다. + */ +export function reorderIds( + current: readonly string[], + requested: readonly string[], +): string[] { + const known = new Set(current); + const front = [...new Set(requested)].filter((id) => known.has(id)); + const placed = new Set(front); + return [...front, ...current.filter((id) => !placed.has(id))]; +} diff --git a/backend/src/modules/pet/pet.controller.ts b/backend/src/modules/pet/pet.controller.ts new file mode 100644 index 0000000..dad1e1a --- /dev/null +++ b/backend/src/modules/pet/pet.controller.ts @@ -0,0 +1,152 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + 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 { + ArchivePetDto, + CreatePetDto, + ReorderPetsDto, + UpdatePetDto, +} from './dto/pet-input.dto'; +import { PetResponseDto } from './dto/pet-response.dto'; +import { PetService } from './pet.service'; + +// 반려동물 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임 +@ApiTags('Pet') +@ApiBearerAuth() +@ApiErrorResponse('AUTH_001', '로그인 필요') +@Controller('pets') +export class PetController { + constructor(private readonly petService: PetService) {} + + @Get() + @ApiOperation({ + summary: '우리 아이 목록 (커서 페이지네이션, 정한 순서대로)', + description: + '추억 보관함의 아이도 함께 온다 — archive 가 null 이 아니면 보관된 아이', + }) + @ApiPaginatedResponse(PetResponseDto, '아이 목록') + @ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류') + list( + @CurrentUser() user: AuthUser, + @Query() query: CursorPaginationQueryDto, + ): Promise> { + return this.petService.list(user.userId, query); + } + + @Post() + @ApiOperation({ summary: '아이 등록' }) + @ApiStandardResponse(PetResponseDto, { + status: 201, + description: '등록한 아이', + }) + @ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 이미지 파일이 아님') + create( + @CurrentUser() user: AuthUser, + @Body() dto: CreatePetDto, + ): Promise { + return this.petService.create(user.userId, dto); + } + + @Put('order') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() // 204 — 본문이 없다 + @ApiOperation({ + summary: '우리 아이 순서 바꾸기', + description: 'ids 순서로 앞에 두고, 빠진 아이는 원래 순서대로 뒤에 둔다', + }) + @ApiNoContentResponse({ description: '순서 저장 완료' }) + @ApiErrorResponse('VAL_001', '내 아이가 아닌 id 가 섞여 있음') + async reorder( + @CurrentUser() user: AuthUser, + @Body() dto: ReorderPetsDto, + ): Promise { + await this.petService.reorder(user.userId, dto); + } + + @Patch(':id') + @ApiOperation({ summary: '아이 정보 수정 (보낸 값만)' }) + @ApiStandardResponse(PetResponseDto, { description: '수정한 아이' }) + @ApiErrorResponse('RES_001', '아이를 찾을 수 없음') + @ApiErrorResponse('VAL_001', '입력값 검증 실패') + update( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePetDto, + ): Promise { + return this.petService.update(user.userId, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() // 204 — 본문이 없다 + @ApiOperation({ + summary: '아이 삭제', + description: + '명함은 함께 지워지고, 일정·다이어리에서는 그 아이만 대상에서 뗀다', + }) + @ApiNoContentResponse({ description: '삭제 완료' }) + @ApiErrorResponse('RES_001', '아이를 찾을 수 없음') + async remove( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.petService.remove(user.userId, id); + } + + @Put(':id/archive') + @ApiOperation({ + summary: '추억 보관함으로 옮기기', + description: '정보·명함·다이어리는 그대로 두고, 일정에서는 그 아이를 뗀다', + }) + @ApiStandardResponse(PetResponseDto, { description: '보관한 아이' }) + @ApiErrorResponse('RES_001', '아이를 찾을 수 없음') + @ApiErrorResponse('VAL_001', '날짜 형식 오류 · 첫날이 마지막 날보다 늦음') + archive( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ArchivePetDto, + ): Promise { + return this.petService.archive(user.userId, id, dto); + } + + @Delete(':id/archive') + @ApiOperation({ summary: '추억 보관함에서 다시 불러오기' }) + @ApiStandardResponse(PetResponseDto, { description: '다시 불러온 아이' }) + @ApiErrorResponse('RES_001', '아이를 찾을 수 없음') + restore( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.petService.restore(user.userId, id); + } +} diff --git a/backend/src/modules/pet/pet.module.ts b/backend/src/modules/pet/pet.module.ts new file mode 100644 index 0000000..b898ea1 --- /dev/null +++ b/backend/src/modules/pet/pet.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { UploadModule } from '@/modules/upload/upload.module'; +import { Pet } from './entities/pet.entity'; +import { PetController } from './pet.controller'; +import { PetService } from './pet.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Pet]), UploadModule], + controllers: [PetController], + providers: [PetService], + exports: [PetService], +}) +export class PetModule {} diff --git a/backend/src/modules/pet/pet.service.spec.ts b/backend/src/modules/pet/pet.service.spec.ts new file mode 100644 index 0000000..7f6a587 --- /dev/null +++ b/backend/src/modules/pet/pet.service.spec.ts @@ -0,0 +1,136 @@ +import { DataSource, EntityManager, Repository } from 'typeorm'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { UploadService } from '@/modules/upload/upload.service'; +import { Pet } from './entities/pet.entity'; +import { PetLifecycleListener } from './pet-lifecycle'; +import { PetService } from './pet.service'; + +const OWNER = 'owner-1'; +const PET_ID = 'pet-1'; + +function pet(overrides: Partial = {}): Pet { + return { + id: PET_ID, + ownerId: OWNER, + photoFileId: 'photo-1', + archiveFrom: null, + archiveTo: null, + archivedAt: null, + archiveMemo: null, + ...overrides, + } as Pet; +} + +describe('PetService', () => { + const manager = { + delete: jest.fn(), + save: jest.fn((v: unknown) => Promise.resolve(v)), + merge: jest.fn((_e: unknown, a: object, b: object) => ({ ...a, ...b })), + }; + const dataSource = { + transaction: jest.fn((work: (m: EntityManager) => Promise) => + work(manager as unknown as EntityManager), + ), + }; + const repo = { findOneBy: jest.fn(), count: jest.fn() }; + const uploads = { releaseIfUnused: jest.fn(), assertOwnedImage: jest.fn() }; + let service: PetService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new PetService( + repo as unknown as Repository, + dataSource as unknown as DataSource, + uploads as unknown as UploadService, + ); + }); + + it('아이를 지우면 리스너가 같은 트랜잭션에서 정리하고, 커밋 뒤 쓰지 않는 파일을 지운다', async () => { + repo.findOneBy.mockResolvedValue(pet()); + const order: string[] = []; + const listener: PetLifecycleListener = { + onPetDeleting: jest.fn((event) => { + order.push('listener'); + expect(event).toEqual({ ownerId: OWNER, petId: PET_ID, manager }); + return Promise.resolve(['card-bg-1']); + }), + }; + manager.delete.mockImplementation(() => { + order.push('delete'); + return Promise.resolve({ affected: 1 }); + }); + uploads.releaseIfUnused.mockImplementation(() => { + order.push('release'); + return Promise.resolve(); + }); + service.registerListener(listener); + + await service.remove(OWNER, PET_ID); + + expect(order).toEqual(['listener', 'delete', 'release']); + expect(uploads.releaseIfUnused).toHaveBeenCalledWith([ + 'photo-1', + 'card-bg-1', + ]); + }); + + it('남의 아이는 지울 수 없다 (RES_001)', async () => { + repo.findOneBy.mockResolvedValue(null); + + await expect(service.remove(OWNER, PET_ID)).rejects.toMatchObject({ + code: 'RES_001', + }); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('보관하면 리스너에 알린다', async () => { + repo.findOneBy.mockResolvedValue(pet()); + const onPetArchived = jest.fn(() => Promise.resolve()); + service.registerListener({ onPetArchived }); + + const result = await service.archive(OWNER, PET_ID, { + from: '2021-09-14', + to: '2026-10-01', + memo: null, + }); + + expect(onPetArchived).toHaveBeenCalledWith( + expect.objectContaining({ ownerId: OWNER, petId: PET_ID }), + ); + expect(result.archive).toMatchObject({ + from: '2021-09-14', + to: '2026-10-01', + }); + }); + + it('보관 기간의 첫날이 마지막 날보다 늦으면 VAL_001', async () => { + await expect( + service.archive(OWNER, PET_ID, { from: '2026-10-02', to: '2026-10-01' }), + ).rejects.toBeInstanceOf(BusinessException); + expect(repo.findOneBy).not.toHaveBeenCalled(); + }); + + describe('대상 확인', () => { + it('나(self)만 있으면 DB 를 보지 않는다', async () => { + await service.assertOwnedTargets(OWNER, ['self']); + expect(repo.count).not.toHaveBeenCalled(); + }); + + it('내 아이가 아닌 id 가 섞이면 VAL_001', async () => { + repo.count.mockResolvedValue(1); + + await expect( + service.assertOwnedTargets(OWNER, ['self', 'pet-a', 'pet-b']), + ).rejects.toMatchObject({ code: 'VAL_001' }); + }); + + it('같은 아이가 두 번 있어도 한 번으로 센다', async () => { + repo.count.mockResolvedValue(1); + + await expect( + service.assertOwnedTargets(OWNER, ['pet-a', 'pet-a']), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/backend/src/modules/pet/pet.service.ts b/backend/src/modules/pet/pet.service.ts new file mode 100644 index 0000000..5279dc7 --- /dev/null +++ b/backend/src/modules/pet/pet.service.ts @@ -0,0 +1,244 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, 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 { UploadService } from '@/modules/upload/upload.service'; +import { + ArchivePetDto, + CreatePetDto, + ReorderPetsDto, + UpdatePetDto, +} from './dto/pet-input.dto'; +import { PetResponseDto } from './dto/pet-response.dto'; +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 { + private readonly logger = new Logger(PetService.name); + private readonly listeners: PetLifecycleListener[] = []; + + constructor( + @InjectRepository(Pet) + private readonly pets: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly uploads: UploadService, + ) {} + + /** 아이의 삭제·보관을 따라 정리할 도메인을 등록한다 (pet-lifecycle.ts) */ + registerListener(listener: PetLifecycleListener): void { + this.listeners.push(listener); + } + + /** 우리 아이 목록 — 정한 순서대로. 추억 보관함의 아이도 함께 온다(archive 값으로 구분) */ + async list( + ownerId: string, + query: CursorPaginationQueryDto, + ): Promise> { + const qb = this.pets + .createQueryBuilder('pet') + .where('pet.owner_id = :ownerId', { ownerId }); + const page = await keysetPaginate( + qb, + [ + { column: 'pet.sort_order', cast: 'integer', read: (p) => p.sortOrder }, + { column: 'pet.id', cast: 'uuid', read: (p) => p.id }, + ], + query, + ); + return mapPage(page, (p) => PetResponseDto.from(p)); + } + + async create(ownerId: string, dto: CreatePetDto): Promise { + await this.uploads.assertOwnedImage(dto.photoFileId, ownerId); + + const last = await this.pets.findOne({ + where: { ownerId }, + order: { sortOrder: 'DESC' }, + select: { sortOrder: true }, + }); + const pet = await this.pets.save( + this.pets.create({ + ...dto, + ownerId, + sortOrder: (last?.sortOrder ?? -1) + 1, + photoFileId: dto.photoFileId ?? null, + microchip: dto.microchip ?? null, + bloodType: dto.bloodType ?? null, + vet: dto.vet ?? null, + memo: dto.memo ?? null, + archiveFrom: null, + archiveTo: null, + archivedAt: null, + archiveMemo: null, + }), + ); + return PetResponseDto.from(pet); + } + + async update( + ownerId: string, + petId: string, + dto: UpdatePetDto, + ): Promise { + const pet = await this.findOwned(ownerId, petId); + const previousPhoto = pet.photoFileId; + if (dto.photoFileId !== undefined) { + await this.uploads.assertOwnedImage(dto.photoFileId, ownerId); + } + + // 보낸 값만 바꾼다 (undefined 는 그대로) + const changes = Object.fromEntries( + Object.entries(dto).filter(([, value]) => value !== undefined), + ) as Partial; + const saved = await this.pets.save(this.pets.merge(pet, changes)); + + if (previousPhoto !== saved.photoFileId) { + await this.uploads.releaseIfUnused([previousPhoto]); + } + return PetResponseDto.from(saved); + } + + /** + * 아이를 지운다 — 명함은 FK 로 함께 지워지고, 일정·다이어리는 등록된 리스너가 그 아이를 대상에서 뗀다. + * 정리로 쓰지 않게 된 사진은 커밋한 뒤 지운다. + */ + async remove(ownerId: string, petId: string): Promise { + const pet = await this.findOwned(ownerId, petId); + + const released = await this.dataSource.transaction(async (manager) => { + const files: Array = [pet.photoFileId]; + for (const listener of this.listeners) { + const more = await listener.onPetDeleting?.({ + ownerId, + petId, + manager, + }); + if (more) files.push(...more); + } + await manager.delete(Pet, { id: petId, ownerId }); + return files; + }); + + await this.uploads.releaseIfUnused(released); + this.logger.log(`아이 삭제: ${petId} (회원 ${ownerId})`); + } + + /** 추억 보관함으로 옮긴다 — 정보·명함·다이어리는 그대로, 일정은 리스너가 정리한다 */ + async archive( + ownerId: string, + petId: string, + dto: ArchivePetDto, + ): Promise { + if (dto.from > dto.to) { + throw new BusinessException('VAL_001', { + context: { reason: 'archive-range', from: dto.from, to: dto.to }, + }); + } + const pet = await this.findOwned(ownerId, petId); + + const saved = await this.dataSource.transaction(async (manager) => { + const archived = await manager.save( + manager.merge(Pet, pet, { + archiveFrom: dto.from, + archiveTo: dto.to, + archivedAt: new Date(), + archiveMemo: dto.memo ?? null, + }), + ); + for (const listener of this.listeners) { + await listener.onPetArchived?.({ ownerId, petId, manager }); + } + return archived; + }); + return PetResponseDto.from(saved); + } + + /** 추억 보관함에서 다시 불러온다 — 보관할 때 지운 일정은 돌아오지 않는다 */ + async restore(ownerId: string, petId: string): Promise { + const pet = await this.findOwned(ownerId, petId); + const saved = await this.pets.save( + this.pets.merge(pet, { + archiveFrom: null, + archiveTo: null, + archivedAt: null, + archiveMemo: null, + }), + ); + return PetResponseDto.from(saved); + } + + /** 순서 바꾸기 — 요청에 없는 아이는 원래 순서대로 뒤에 둔다 */ + async reorder(ownerId: string, dto: ReorderPetsDto): Promise { + await this.dataSource.transaction(async (manager) => { + const pets = await manager.find(Pet, { + where: { ownerId }, + order: { sortOrder: 'ASC', id: 'ASC' }, + select: { id: true, sortOrder: true }, + // 동시에 순서를 바꾸면 한쪽 결과만 남게 잠근다 + lock: { mode: 'pessimistic_write' }, + }); + + const known = new Set(pets.map((p) => p.id)); + const unknown = dto.ids.filter((id) => !known.has(id)); + if (unknown.length > 0) { + throw new BusinessException('VAL_001', { + context: { reason: 'unknown-pet', petIds: unknown }, + }); + } + + const order = reorderIds( + pets.map((p) => p.id), + dto.ids, + ); + const current = new Map(pets.map((p) => [p.id, p.sortOrder])); + for (const [index, id] of order.entries()) { + if (current.get(id) !== index) { + await manager.update(Pet, { id }, { sortOrder: index }); + } + } + }); + } + + /** + * 일정·다이어리 대상 확인 — 모두 "나(self)" 이거나 내 아이여야 한다. 하나라도 아니면 VAL_001. + * 추억 보관함의 아이도 내 아이로 본다 (보관된 아이의 다이어리를 고칠 수 있다). + */ + async assertOwnedTargets( + ownerId: string, + targetIds: readonly string[], + ): Promise { + const petIds = [...new Set(targetIds)].filter( + (id) => id !== SELF_TARGET_ID, + ); + if (petIds.length === 0) return; + + const found = await this.pets.count({ + where: { ownerId, id: In(petIds) }, + }); + if (found !== petIds.length) { + throw new BusinessException('VAL_001', { + context: { reason: 'unknown-target', targetIds }, + }); + } + } + + /** 내 아이 하나 — 남의 아이나 없는 아이는 똑같이 RES_001 */ + async findOwned(ownerId: string, petId: string): Promise { + const pet = await this.pets.findOneBy({ id: petId, ownerId }); + if (!pet) throw new BusinessException('RES_001', { context: { petId } }); + return pet; + } +} diff --git a/backend/src/shared/utils/iso-date.spec.ts b/backend/src/shared/utils/iso-date.spec.ts new file mode 100644 index 0000000..69f50d0 --- /dev/null +++ b/backend/src/shared/utils/iso-date.spec.ts @@ -0,0 +1,20 @@ +import { isValidIsoDate } from './iso-date'; + +describe('isValidIsoDate', () => { + it('달력에 있는 날짜면 통과한다', () => { + expect(isValidIsoDate('2026-10-11')).toBe(true); + expect(isValidIsoDate('2024-02-29')).toBe(true); + }); + + it('형식만 맞고 없는 날짜는 거절한다', () => { + expect(isValidIsoDate('2026-02-30')).toBe(false); + expect(isValidIsoDate('2025-02-29')).toBe(false); + expect(isValidIsoDate('2026-13-01')).toBe(false); + }); + + it('시각이 붙었거나 형식이 다르면 거절한다', () => { + expect(isValidIsoDate('2026-10-11T00:00:00Z')).toBe(false); + expect(isValidIsoDate('2026/10/11')).toBe(false); + expect(isValidIsoDate('26-10-11')).toBe(false); + }); +}); diff --git a/backend/src/shared/utils/iso-date.ts b/backend/src/shared/utils/iso-date.ts new file mode 100644 index 0000000..6b54015 --- /dev/null +++ b/backend/src/shared/utils/iso-date.ts @@ -0,0 +1,20 @@ +// 날짜 문자열(YYYY-MM-DD) 다루기 — 부수효과 없는 순수 함수 + +const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** + * 달력에 있는 날짜인지 — `2026-02-30` 처럼 형식만 맞는 값은 거절한다. + * 시각 없는 날짜(생일·일정 날짜)는 시간대 변환 없이 문자열 그대로 주고받는다. + */ +export function isValidIsoDate(value: string): boolean { + const match = ISO_DATE_PATTERN.exec(value); + if (!match) return false; + + const [year, month, day] = match.slice(1).map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + return ( + date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day + ); +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index c803f8a..4872f8c 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -89,6 +89,64 @@ describe('응답 계약 (e2e)', () => { expect(body(res).data).toHaveProperty('id'); }); + it('목록 응답의 pageInfo 는 공유 계약 형태를 지킨다', async () => { + const pet = { + tint: '#FF9B6A', + name: '콩이', + species: '강아지', + breed: '', + gender: 'male', + neutered: true, + birthDate: '2021-09-14', + weight: 3.2, + weightUnit: 'kg', + allergies: [], + conditions: [], + watchConditions: [], + }; + for (const name of ['콩이', '두부']) { + await request(app.getHttpServer()) + .post('/api/pets') + .set(auth()) + .send({ ...pet, name }) + .expect(201); + } + + const first = await request(app.getHttpServer()) + .get('/api/pets?limit=1') + .set(auth()) + .expect(200); + const pageInfo = body(first).data?.pageInfo; + + expect(body(first).data?.items).toHaveLength(1); + expect(Object.keys(pageInfo ?? {}).sort()).toEqual([ + 'hasNext', + 'nextCursor', + 'total', + ]); + expect(pageInfo?.hasNext).toBe(true); + expect(typeof pageInfo?.nextCursor).toBe('string'); + expect(pageInfo?.total).toBe(2); + + const last = await request(app.getHttpServer()) + .get(`/api/pets?limit=1&cursor=${pageInfo?.nextCursor ?? ''}`) + .set(auth()) + .expect(200); + + expect(body(last).data?.items).toHaveLength(1); + expect(body(last).data?.pageInfo?.hasNext).toBe(false); + expect(body(last).data?.pageInfo?.nextCursor).toBeNull(); + }); + + it('정의되지 않은 쿼리 파라미터는 거부된다 (forbidNonWhitelisted)', async () => { + const res = await request(app.getHttpServer()) + .get('/api/pets?정의되지않은파라미터=1') + .set(auth()) + .expect(400); + + expect(body(res).error?.code).toBe('VAL_001'); + }); + it('로그인하지 않으면 AUTH_001 과 traceId 를 반환한다', async () => { const res = await request(app.getHttpServer()) .get('/api/users/me')