feat(backend): 다이어리 API — 다이어리·기록·댓글

앱의 다이어리(DiaryBook: 다이어리 + 기록 + 댓글)를 서버로 옮긴다.

- /diaries: 목록·만들기·수정(이름·대상·리본·표지·알림 끄기)·삭제, /diaries/:id/posts 로 기록 남기기
- /diary-posts: 목록·고치기·삭제, /diary-posts/:id/comments 로 댓글 쓰기
- /diary-comments: 목록·고치기·삭제
- 책장은 최대 6권 (추억 보관함의 아이만 담은 다이어리는 세지 않는다) → DIARY_001
- 한 사람이 한 다이어리에 하루 한 번 (유니크 제약) → DIARY_002
- 아이를 지우면 다이어리 대상에서만 떼고 다이어리·기록은 남긴다
- 지금은 주인만 읽고 쓴다 — 기록·댓글에 작성자를 두어 공유 다이어리로 넓힐 수 있게 했다
- 에러 코드 추가에 따라 프론트·Flutter 사전을 다시 생성했다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 17:35:08 +09:00
parent 8380e35277
commit 77862249ee
15 changed files with 1212 additions and 3 deletions
+2
View File
@@ -10,6 +10,7 @@ import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { AuthModule } from '@/modules/auth/auth.module';
import { DiaryModule } from '@/modules/diary/diary.module';
import { HealthModule } from '@/modules/health/health.module';
import { PetModule } from '@/modules/pet/pet.module';
import { ScheduleModule } from '@/modules/schedule/schedule.module';
@@ -68,6 +69,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
AuthModule,
PetModule,
ScheduleModule,
DiaryModule,
],
providers: [
ShutdownLogger,
+10
View File
@@ -73,6 +73,16 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.PAYLOAD_TOO_LARGE,
message: '파일 크기가 허용 범위를 초과했습니다.',
},
// 다이어리 — 책장에는 최대 6권 (추억 보관함의 아이만 담은 다이어리는 세지 않는다)
DIARY_001: {
status: HttpStatus.CONFLICT,
message: '다이어리는 최대 6권까지 만들 수 있어요.',
},
// 다이어리 — 한 사람이 한 다이어리에 하루 한 번 기록한다
DIARY_002: {
status: HttpStatus.CONFLICT,
message: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
+3 -3
View File
@@ -61,13 +61,13 @@ 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개.
* 대상 목록 — 각 값은 "self" 이거나 아이 id(UUID). 기본은 1개 이상(일정), 다이어리는 0개도 된다.
* 형식만 본다 — 내 아이인지는 서비스가 PetService.assertOwnedTargets 로 확인한다.
*/
export function IsTargetIdList(maxItems: number) {
export function IsTargetIdList(maxItems: number, minItems = 1) {
return applyDecorators(
IsArray({ message: '대상을 골라 주세요.' }),
ArrayMinSize(1, { message: '대상을 골라 주세요.' }),
ArrayMinSize(minItems, { message: '대상을 골라 주세요.' }),
ArrayMaxSize(maxItems, { message: `대상은 최대 ${maxItems}개까지예요.` }),
ValidateBy({
name: 'isTargetId',
@@ -0,0 +1,83 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDiaries1791707431165 implements MigrationInterface {
name = 'AddDiaries1791707431165';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "diaries" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "name" character varying(18) NOT NULL, "target_ids" text array NOT NULL DEFAULT '{}', "ribbon" character varying(7) NOT NULL, "tint" character varying(7) NOT NULL, "cover_file_id" uuid, "muted_notices" text array NOT NULL DEFAULT '{}', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_ffd738e7d40dcfa59283dcaae87" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_66a03e13cfd64933272bd90721" ON "diaries" ("owner_id") `,
);
await queryRunner.query(
`CREATE TABLE "diary_posts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "diary_id" uuid NOT NULL, "author_id" uuid NOT NULL, "date" date NOT NULL, "text" character varying(2000) NOT NULL, "mood" character varying(10), "photo_file_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_582c1c407ea8c8288009260222d" UNIQUE ("diary_id", "author_id", "date"), CONSTRAINT "PK_e5a9335997f1abec691b448fdd3" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_a9bbbf83bc3e7765878dec1f10" ON "diary_posts" ("diary_id") `,
);
await queryRunner.query(
`CREATE TABLE "diary_comments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "post_id" uuid NOT NULL, "author_id" uuid NOT NULL, "text" character varying(50) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_02bcbae3dcc56ec77d072c232d4" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7a76737dfe5d0818b867696df6" ON "diary_comments" ("post_id") `,
);
await queryRunner.query(
`ALTER TABLE "diaries" ADD CONSTRAINT "FK_66a03e13cfd64933272bd90721a" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diaries" ADD CONSTRAINT "FK_49e7cfa49fed5dd22a4e8c25dab" FOREIGN KEY ("cover_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" ADD CONSTRAINT "FK_a9bbbf83bc3e7765878dec1f10e" FOREIGN KEY ("diary_id") REFERENCES "diaries"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" ADD CONSTRAINT "FK_c25aa8312dc21996eb00531bb68" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" ADD CONSTRAINT "FK_07e1d0110b6bf6db265e9963882" FOREIGN KEY ("photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_comments" ADD CONSTRAINT "FK_7a76737dfe5d0818b867696df6b" FOREIGN KEY ("post_id") REFERENCES "diary_posts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_comments" ADD CONSTRAINT "FK_ac7b686ceee2547592e05072dca" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "diary_comments" DROP CONSTRAINT "FK_ac7b686ceee2547592e05072dca"`,
);
await queryRunner.query(
`ALTER TABLE "diary_comments" DROP CONSTRAINT "FK_7a76737dfe5d0818b867696df6b"`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" DROP CONSTRAINT "FK_07e1d0110b6bf6db265e9963882"`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" DROP CONSTRAINT "FK_c25aa8312dc21996eb00531bb68"`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" DROP CONSTRAINT "FK_a9bbbf83bc3e7765878dec1f10e"`,
);
await queryRunner.query(
`ALTER TABLE "diaries" DROP CONSTRAINT "FK_49e7cfa49fed5dd22a4e8c25dab"`,
);
await queryRunner.query(
`ALTER TABLE "diaries" DROP CONSTRAINT "FK_66a03e13cfd64933272bd90721a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7a76737dfe5d0818b867696df6"`,
);
await queryRunner.query(`DROP TABLE "diary_comments"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_a9bbbf83bc3e7765878dec1f10"`,
);
await queryRunner.query(`DROP TABLE "diary_posts"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_66a03e13cfd64933272bd90721"`,
);
await queryRunner.query(`DROP TABLE "diaries"`);
}
}
@@ -0,0 +1,120 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
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 { DiaryService } from './diary.service';
import {
CreateDiaryDto,
CreatePostDto,
DiaryPostResponseDto,
DiaryResponseDto,
UpdateDiaryDto,
} from './dto/diary.dto';
// 다이어리 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Diary')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('diaries')
export class DiariesController {
constructor(private readonly diary: DiaryService) {}
@Get()
@ApiOperation({ summary: '내 다이어리 목록 (만든 순, 커서 페이지네이션)' })
@ApiPaginatedResponse(DiaryResponseDto, '다이어리 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryResponseDto>> {
return this.diary.listDiaries(user.userId, query);
}
@Post()
@ApiOperation({ summary: '새 다이어리' })
@ApiStandardResponse(DiaryResponseDto, {
status: 201,
description: '만든 다이어리',
})
@ApiErrorResponse('DIARY_001', '책장이 가득 참 (최대 6권)')
@ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 아이가 아닌 대상')
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreateDiaryDto,
): Promise<DiaryResponseDto> {
return this.diary.createDiary(user.userId, dto);
}
@Patch(':id')
@ApiOperation({
summary: '다이어리 수정 (이름·대상·리본·표지·알림, 보낸 값만)',
})
@ApiStandardResponse(DiaryResponseDto, { description: '수정한 다이어리' })
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDiaryDto,
): Promise<DiaryResponseDto> {
return this.diary.updateDiary(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.diary.deleteDiary(user.userId, id);
}
@Post(':id/posts')
@ApiOperation({ summary: '기록 남기기 (하루 한 번)' })
@ApiStandardResponse(DiaryPostResponseDto, {
status: 201,
description: '남긴 기록',
})
@ApiErrorResponse('DIARY_002', '오늘 이미 기록함')
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
createPost(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreatePostDto,
): Promise<DiaryPostResponseDto> {
return this.diary.createPost(user.userId, id, dto);
}
}
@@ -0,0 +1,82 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
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 { DiaryService } from './diary.service';
import { CommentDto, DiaryCommentResponseDto } from './dto/diary.dto';
// 다이어리 댓글 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Diary')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('diary-comments')
export class DiaryCommentsController {
constructor(private readonly diary: DiaryService) {}
@Get()
@ApiOperation({
summary: '내 다이어리들의 댓글 목록 (쓴 순, 커서 페이지네이션)',
})
@ApiPaginatedResponse(DiaryCommentResponseDto, '댓글 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryCommentResponseDto>> {
return this.diary.listComments(user.userId, query);
}
@Patch(':id')
@ApiOperation({ summary: '댓글 고치기 (내가 쓴 댓글만)' })
@ApiStandardResponse(DiaryCommentResponseDto, { description: '고친 댓글' })
@ApiErrorResponse('RES_001', '댓글을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 댓글이 아님')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
return this.diary.updateComment(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '댓글 삭제 (내가 쓴 댓글만)' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '댓글을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 댓글이 아님')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.diary.deleteComment(user.userId, id);
}
}
@@ -0,0 +1,104 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
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 { DiaryService } from './diary.service';
import {
CommentDto,
DiaryCommentResponseDto,
DiaryPostResponseDto,
UpdatePostDto,
} from './dto/diary.dto';
// 다이어리 기록 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Diary')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('diary-posts')
export class DiaryPostsController {
constructor(private readonly diary: DiaryService) {}
@Get()
@ApiOperation({
summary: '내 다이어리들의 기록 목록 (쓴 순, 커서 페이지네이션)',
})
@ApiPaginatedResponse(DiaryPostResponseDto, '기록 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryPostResponseDto>> {
return this.diary.listPosts(user.userId, query);
}
@Patch(':id')
@ApiOperation({ summary: '기록 내용 고치기 (내가 쓴 기록만)' })
@ApiStandardResponse(DiaryPostResponseDto, { description: '고친 기록' })
@ApiErrorResponse('RES_001', '기록을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 기록이 아님')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePostDto,
): Promise<DiaryPostResponseDto> {
return this.diary.updatePost(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '기록 삭제 (댓글 함께)' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '기록을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 기록이 아님')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.diary.deletePost(user.userId, id);
}
@Post(':id/comments')
@ApiOperation({ summary: '댓글 쓰기' })
@ApiStandardResponse(DiaryCommentResponseDto, {
status: 201,
description: '쓴 댓글',
})
@ApiErrorResponse('RES_001', '기록을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
createComment(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
return this.diary.createComment(user.userId, id, dto);
}
}
@@ -0,0 +1,25 @@
import { countShelfDiaries } from './diary-shelf';
describe('countShelfDiaries', () => {
const archived = new Set(['old']);
it('대상이 모두 보관된 아이인 다이어리는 세지 않는다', () => {
expect(countShelfDiaries([['old'], ['old', 'old']], archived)).toBe(0);
});
it('함께 지내는 아이나 나(self)가 섞이면 책장에 둔다', () => {
expect(
countShelfDiaries(
[
['old', 'kong'],
['old', 'self'],
],
archived,
),
).toBe(2);
});
it('대상이 없는 다이어리도 책장에 둔다', () => {
expect(countShelfDiaries([[]], archived)).toBe(1);
});
});
+15
View File
@@ -0,0 +1,15 @@
// 다이어리 책장 — 부수효과 없는 순수 함수 (앱의 DiaryBook.shelf 와 같은 규칙)
/**
* 책장에 꽂힌 다이어리 수 — 대상이 모두 추억 보관함의 아이인 다이어리는 보관함에서 보므로 세지 않는다.
* 대상이 없는 다이어리는 책장에 둔다.
*/
export function countShelfDiaries(
diaryTargets: ReadonlyArray<readonly string[]>,
archivedPetIds: ReadonlySet<string>,
): number {
return diaryTargets.filter(
(targets) =>
!(targets.length > 0 && targets.every((id) => archivedPetIds.has(id))),
).length;
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PetModule } from '@/modules/pet/pet.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { DiariesController } from './diaries.controller';
import { DiaryCommentsController } from './diary-comments.controller';
import { DiaryPostsController } from './diary-posts.controller';
import { DiaryService } from './diary.service';
import { Diary, DiaryComment, DiaryPost } from './entities/diary.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Diary, DiaryPost, DiaryComment]),
PetModule,
UploadModule,
],
controllers: [
DiariesController,
DiaryPostsController,
DiaryCommentsController,
],
providers: [DiaryService],
})
export class DiaryModule {}
+343
View File
@@ -0,0 +1,343 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, IsNull, Not, 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 { Pet } from '@/modules/pet/entities/pet.entity';
import {
PetLifecycleEvent,
PetLifecycleListener,
} from '@/modules/pet/pet-lifecycle';
import { PetService } from '@/modules/pet/pet.service';
import { UploadService } from '@/modules/upload/upload.service';
import { isUniqueViolation } from '@/shared/utils/db-error';
import {
CommentDto,
CreateDiaryDto,
CreatePostDto,
DiaryCommentResponseDto,
DiaryPostResponseDto,
DiaryResponseDto,
UpdateDiaryDto,
UpdatePostDto,
} from './dto/diary.dto';
import {
DIARY_LIMITS,
Diary,
DiaryComment,
DiaryPost,
} from './entities/diary.entity';
import { countShelfDiaries } from './diary-shelf';
const createdAtKeys = <T extends { createdAt: Date; id: string }>(
alias: string,
) => [
{
column: `${alias}.created_at`,
cast: 'timestamptz',
read: (row: T) => row.createdAt.toISOString(),
},
{ column: `${alias}.id`, cast: 'uuid', read: (row: T) => row.id },
];
// 다이어리 — 다이어리·기록·댓글. 지금은 주인만 읽고 쓴다 (공유 다이어리는 다음 단계)
@Injectable()
export class DiaryService implements OnModuleInit, PetLifecycleListener {
constructor(
@InjectRepository(Diary)
private readonly diaries: Repository<Diary>,
@InjectRepository(DiaryPost)
private readonly posts: Repository<DiaryPost>,
@InjectRepository(DiaryComment)
private readonly comments: Repository<DiaryComment>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly pets: PetService,
private readonly uploads: UploadService,
) {}
onModuleInit(): void {
this.pets.registerListener(this);
}
// ── 다이어리 ───────────────────────────────────────────
async listDiaries(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryResponseDto>> {
const qb = this.diaries
.createQueryBuilder('diary')
.where('diary.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(qb, createdAtKeys<Diary>('diary'), query);
return mapPage(page, (d) => DiaryResponseDto.from(d));
}
/** 새 다이어리 — 책장이 가득 찼으면 DIARY_001 (추억 보관함의 아이만 담은 다이어리는 세지 않는다) */
async createDiary(
ownerId: string,
dto: CreateDiaryDto,
): Promise<DiaryResponseDto> {
await this.pets.assertOwnedTargets(ownerId, dto.targetIds);
await this.uploads.assertOwnedImage(dto.coverFileId, ownerId);
return this.dataSource.transaction(async (manager) => {
// 동시에 만들어 한도를 넘지 않도록 회원의 다이어리를 잠그고 센다
const existing = await manager.find(Diary, {
where: { ownerId },
select: { id: true, targetIds: true },
lock: { mode: 'pessimistic_write' },
});
const archived = await manager.find(Pet, {
where: { ownerId, archivedAt: Not(IsNull()) },
select: { id: true },
});
const shelf = countShelfDiaries(
existing.map((d) => d.targetIds),
new Set(archived.map((p) => p.id)),
);
if (shelf >= DIARY_LIMITS.maxDiaries) {
throw new BusinessException('DIARY_001', { context: { shelf } });
}
const diary = await manager.save(
manager.create(Diary, {
ownerId,
name: dto.name,
targetIds: [...new Set(dto.targetIds)],
ribbon: dto.ribbon,
tint: dto.tint,
coverFileId: dto.coverFileId ?? null,
mutedNotices: [],
}),
);
return DiaryResponseDto.from(diary);
});
}
async updateDiary(
ownerId: string,
diaryId: string,
dto: UpdateDiaryDto,
): Promise<DiaryResponseDto> {
const diary = await this.findOwnedDiary(ownerId, diaryId);
const previousCover = diary.coverFileId;
if (dto.targetIds !== undefined) {
await this.pets.assertOwnedTargets(ownerId, dto.targetIds);
diary.targetIds = [...new Set(dto.targetIds)];
}
if (dto.coverFileId !== undefined) {
await this.uploads.assertOwnedImage(dto.coverFileId, ownerId);
diary.coverFileId = dto.coverFileId;
}
if (dto.name !== undefined) diary.name = dto.name;
if (dto.ribbon !== undefined) diary.ribbon = dto.ribbon;
if (dto.mutedNotices !== undefined) {
diary.mutedNotices = [...new Set(dto.mutedNotices)];
}
const saved = await this.diaries.save(diary);
if (previousCover !== saved.coverFileId) {
await this.uploads.releaseIfUnused([previousCover]);
}
return DiaryResponseDto.from(saved);
}
/** 다이어리를 지우면 그 안의 기록·댓글도 모두 지운다 (FK) — 쓰지 않게 된 사진도 정리한다 */
async deleteDiary(ownerId: string, diaryId: string): Promise<void> {
const diary = await this.findOwnedDiary(ownerId, diaryId);
const photos = await this.posts.find({
where: { diaryId, photoFileId: Not(IsNull()) },
select: { photoFileId: true },
});
await this.diaries.delete({ id: diaryId, ownerId });
await this.uploads.releaseIfUnused([
diary.coverFileId,
...photos.map((p) => p.photoFileId),
]);
}
// ── 기록 ───────────────────────────────────────────────
/** 내 다이어리들의 모든 기록 (쓴 순서) */
async listPosts(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryPostResponseDto>> {
const qb = this.posts
.createQueryBuilder('post')
.innerJoin('post.diary', 'diary')
.where('diary.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
createdAtKeys<DiaryPost>('post'),
query,
);
return mapPage(page, (p) => DiaryPostResponseDto.from(p));
}
/** 기록 남기기 — 한 사람이 한 다이어리에 하루 한 번 (DIARY_002) */
async createPost(
userId: string,
diaryId: string,
dto: CreatePostDto,
): Promise<DiaryPostResponseDto> {
await this.findOwnedDiary(userId, diaryId);
await this.uploads.assertOwnedImage(dto.photoFileId, userId);
try {
const post = await this.posts.save(
this.posts.create({
diaryId,
authorId: userId,
date: dto.date,
text: dto.text,
mood: dto.mood ?? null,
photoFileId: dto.photoFileId ?? null,
}),
);
return DiaryPostResponseDto.from(post);
} catch (error) {
if (isUniqueViolation(error)) {
throw new BusinessException('DIARY_002', {
context: { diaryId, date: dto.date },
});
}
throw error;
}
}
async updatePost(
userId: string,
postId: string,
dto: UpdatePostDto,
): Promise<DiaryPostResponseDto> {
const post = await this.findOwnPost(userId, postId);
post.text = dto.text;
return DiaryPostResponseDto.from(await this.posts.save(post));
}
/** 기록을 지우면 댓글도 함께 지워진다 (FK) */
async deletePost(userId: string, postId: string): Promise<void> {
const post = await this.findOwnPost(userId, postId);
await this.posts.delete({ id: post.id });
await this.uploads.releaseIfUnused([post.photoFileId]);
}
// ── 댓글 ───────────────────────────────────────────────
/** 내 다이어리들의 모든 댓글 (쓴 순서) */
async listComments(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryCommentResponseDto>> {
const qb = this.comments
.createQueryBuilder('comment')
.innerJoin('comment.post', 'post')
.innerJoin('post.diary', 'diary')
.where('diary.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
createdAtKeys<DiaryComment>('comment'),
query,
);
return mapPage(page, (c) => DiaryCommentResponseDto.from(c));
}
async createComment(
userId: string,
postId: string,
dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
await this.findReadablePost(userId, postId);
const comment = await this.comments.save(
this.comments.create({ postId, authorId: userId, text: dto.text }),
);
return DiaryCommentResponseDto.from(comment);
}
async updateComment(
userId: string,
commentId: string,
dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
const comment = await this.findOwnComment(userId, commentId);
comment.text = dto.text;
return DiaryCommentResponseDto.from(await this.comments.save(comment));
}
async deleteComment(userId: string, commentId: string): Promise<void> {
const comment = await this.findOwnComment(userId, commentId);
await this.comments.delete({ id: comment.id });
}
// ── 아이 삭제 ─────────────────────────────────────────
/** 아이를 지우면 다이어리 대상에서만 뗀다 — 다이어리와 기록은 남긴다 (앱의 dropTarget) */
async onPetDeleting({
ownerId,
petId,
manager,
}: PetLifecycleEvent): Promise<void> {
await manager.query(
`UPDATE diaries SET target_ids = array_remove(target_ids, $2)
WHERE owner_id = $1 AND $2 = ANY(target_ids)`,
[ownerId, petId],
);
}
// ── 조회 도우미 ───────────────────────────────────────
private async findOwnedDiary(
ownerId: string,
diaryId: string,
): Promise<Diary> {
const diary = await this.diaries.findOneBy({ id: diaryId, ownerId });
if (!diary)
throw new BusinessException('RES_001', { context: { diaryId } });
return diary;
}
/** 볼 수 있는 기록 — 내 다이어리의 기록 */
private async findReadablePost(
userId: string,
postId: string,
): Promise<DiaryPost> {
const post = await this.posts.findOne({
where: { id: postId, diary: { ownerId: userId } },
});
if (!post) throw new BusinessException('RES_001', { context: { postId } });
return post;
}
/** 고칠 수 있는 기록 — 내 다이어리에 내가 쓴 기록 */
private async findOwnPost(
userId: string,
postId: string,
): Promise<DiaryPost> {
const post = await this.findReadablePost(userId, postId);
if (post.authorId !== userId) {
throw new BusinessException('AUTH_003', { context: { postId } });
}
return post;
}
/** 고칠 수 있는 댓글 — 내 다이어리에서 내가 쓴 댓글 */
private async findOwnComment(
userId: string,
commentId: string,
): Promise<DiaryComment> {
const comment = await this.comments.findOne({
where: { id: commentId, post: { diary: { ownerId: userId } } },
});
if (!comment) {
throw new BusinessException('RES_001', { context: { commentId } });
}
if (comment.authorId !== userId) {
throw new BusinessException('AUTH_003', { context: { commentId } });
}
return comment;
}
}
+230
View File
@@ -0,0 +1,230 @@
import {
ApiProperty,
ApiPropertyOptional,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { IsHexColor, IsIsoDate, IsTargetIdList } from '@/common/validators';
import { DIARY_LIMITS, DIARY_NOTICES, MOODS } from '../entities/diary.entity';
import type {
Diary,
DiaryComment,
DiaryNotice,
DiaryPost,
Mood,
} from '../entities/diary.entity';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** 새 다이어리 */
export class CreateDiaryDto {
@ApiProperty({
description: `이름 (1~${DIARY_LIMITS.name}자)`,
example: '콩이의 하루',
})
@Transform(trim)
@IsString({ message: '다이어리 이름을 입력해 주세요.' })
@IsNotEmpty({ message: '다이어리 이름을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.name, {
message: `다이어리 이름은 ${DIARY_LIMITS.name}자 이하로 입력해 주세요.`,
})
name!: string;
@ApiProperty({
description: '누구의 다이어리인지 — 아이 id 또는 "self". 비어 있어도 된다',
type: [String],
})
@IsTargetIdList(DIARY_LIMITS.targets, 0)
targetIds!: string[];
@ApiProperty({ description: '책갈피 리본 색 (#RRGGBB)', example: '#FF7500' })
@IsHexColor()
ribbon!: string;
@ApiProperty({
description: '표지 사진이 없을 때 바탕색 (#RRGGBB) — 앱이 고른다',
example: '#FF8A5B',
})
@IsHexColor()
tint!: string;
@ApiPropertyOptional({
description: '표지 사진 파일 ID (POST /api/files 로 올린 이미지)',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
coverFileId?: string | null;
}
/** 다이어리 수정 — 보낸 값만 바꾼다. 바탕색(tint)은 만들 때 정한 값을 유지한다 */
export class UpdateDiaryDto extends PartialType(
OmitType(CreateDiaryDto, ['tint'] as const),
) {
@ApiPropertyOptional({
description: '끈 알림 목록 (기본은 모두 켜짐)',
enum: DIARY_NOTICES,
isArray: true,
})
@IsOptional()
@IsArray({ message: '알림 설정이 올바르지 않습니다.' })
@ArrayMaxSize(DIARY_NOTICES.length)
@IsIn(DIARY_NOTICES, {
each: true,
message: '알림 설정이 올바르지 않습니다.',
})
mutedNotices?: DiaryNotice[];
}
/** 기록 남기기 — 하루 한 번 */
export class CreatePostDto {
@ApiProperty({
description:
'기록한 날 (앱 기준 오늘, YYYY-MM-DD) — 같은 날 두 번이면 DIARY_002',
example: '2026-10-11',
})
@IsIsoDate()
date!: string;
@ApiProperty({ description: `내용 (최대 ${DIARY_LIMITS.postText}자)` })
@Transform(trim)
@IsString({ message: '내용을 입력해 주세요.' })
@IsNotEmpty({ message: '내용을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.postText, {
message: `내용은 ${DIARY_LIMITS.postText}자 이하로 입력해 주세요.`,
})
text!: string;
@ApiPropertyOptional({ enum: MOODS, nullable: true })
@IsOptional()
@IsIn(MOODS, { message: '기분이 올바르지 않습니다.' })
mood?: Mood | null;
@ApiPropertyOptional({
description: '사진 파일 ID',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
photoFileId?: string | null;
}
/** 기록 내용 고치기 */
export class UpdatePostDto {
@ApiProperty({ description: `내용 (최대 ${DIARY_LIMITS.postText}자)` })
@Transform(trim)
@IsString({ message: '내용을 입력해 주세요.' })
@IsNotEmpty({ message: '내용을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.postText, {
message: `내용은 ${DIARY_LIMITS.postText}자 이하로 입력해 주세요.`,
})
text!: string;
}
/** 댓글 쓰기·고치기 */
export class CommentDto {
@ApiProperty({ description: `댓글 (최대 ${DIARY_LIMITS.comment}자)` })
@Transform(trim)
@IsString({ message: '댓글을 입력해 주세요.' })
@IsNotEmpty({ message: '댓글을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.comment, {
message: `댓글은 ${DIARY_LIMITS.comment}자 이하로 입력해 주세요.`,
})
text!: string;
}
/** 다이어리 응답 (앱의 Diary) */
export class DiaryResponseDto {
@ApiProperty() id!: string;
@ApiProperty() name!: string;
@ApiProperty({ type: [String] }) targetIds!: string[];
@ApiProperty({ example: '#FF7500' }) ribbon!: string;
@ApiProperty({ example: '#FF8A5B' }) tint!: string;
@ApiProperty({
description: '표지 사진 파일 ID — GET /api/files/{id}',
nullable: true,
type: String,
})
coverFileId!: string | null;
@ApiProperty({ enum: DIARY_NOTICES, isArray: true })
mutedNotices!: DiaryNotice[];
@ApiProperty({ description: '만든 때 (ISO 8601 UTC)' })
createdAt!: string;
static from(diary: Diary): DiaryResponseDto {
return {
id: diary.id,
name: diary.name,
targetIds: diary.targetIds,
ribbon: diary.ribbon,
tint: diary.tint,
coverFileId: diary.coverFileId,
mutedNotices: diary.mutedNotices,
createdAt: diary.createdAt.toISOString(),
};
}
}
/** 기록 응답 (앱의 DiaryPost) */
export class DiaryPostResponseDto {
@ApiProperty() id!: string;
@ApiProperty() diaryId!: string;
@ApiProperty({ description: '기록한 사람 (회원 ID)' }) authorId!: string;
@ApiProperty({ description: '기록한 날 (YYYY-MM-DD)' }) date!: string;
@ApiProperty({ description: '쓴 때 (ISO 8601 UTC)' }) createdAt!: string;
@ApiProperty() text!: string;
@ApiProperty({ enum: MOODS, nullable: true }) mood!: Mood | null;
@ApiProperty({ nullable: true, type: String })
photoFileId!: string | null;
static from(post: DiaryPost): DiaryPostResponseDto {
return {
id: post.id,
diaryId: post.diaryId,
authorId: post.authorId,
date: post.date,
createdAt: post.createdAt.toISOString(),
text: post.text,
mood: post.mood,
photoFileId: post.photoFileId,
};
}
}
/** 댓글 응답 (앱의 DiaryComment) */
export class DiaryCommentResponseDto {
@ApiProperty() id!: string;
@ApiProperty() postId!: string;
@ApiProperty({ description: '쓴 사람 (회원 ID)' }) authorId!: string;
@ApiProperty() text!: string;
@ApiProperty({ description: '쓴 때 (ISO 8601 UTC)' }) createdAt!: string;
static from(comment: DiaryComment): DiaryCommentResponseDto {
return {
id: comment.id,
postId: comment.postId,
authorId: comment.authorId,
text: comment.text,
createdAt: comment.createdAt.toISOString(),
};
}
}
@@ -0,0 +1,166 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
import { User } from '@/modules/user/entities/user.entity';
/** 다이어리 규칙·입력 한도 — 앱의 diary.dart 상수와 맞춘다 */
export const DIARY_LIMITS = {
/** 책장에 꽂을 수 있는 다이어리 수 */
maxDiaries: 6,
name: 18,
targets: 20,
postText: 2000,
comment: 50,
} as const;
/** 다이어리별 알림 — 새 글 / 내 글에 새 댓글 / 오늘 기록 잊지 않게 (앱의 DiaryNotice) */
export const DIARY_NOTICES = ['newPost', 'newComment', 'reminder'] as const;
export type DiaryNotice = (typeof DIARY_NOTICES)[number];
/** 기록할 때 고르는 오늘의 기분 (앱의 Mood) */
export const MOODS = [
'happy',
'excited',
'calm',
'meh',
'sad',
'sick',
] as const;
export type Mood = (typeof MOODS)[number];
/** 다이어리 한 권 */
@Entity('diaries')
export class Diary {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
@Column({ type: 'varchar', length: DIARY_LIMITS.name })
name!: string;
/** 누구의 다이어리인지 — 아이 id 또는 'self'. 아이를 지우면 그 아이만 뗀다(다이어리는 남긴다) */
@Column({ type: 'text', array: true, default: '{}' })
targetIds!: string[];
/** 책갈피 리본 색 `#RRGGBB` */
@Column({ type: 'varchar', length: 7 })
ribbon!: string;
/** 표지 사진이 없을 때 바탕색 `#RRGGBB` — 만들 때 앱이 고른다 */
@Column({ type: 'varchar', length: 7 })
tint!: string;
/** 표지 사진 — 참조 중인 파일은 지울 수 없게 둔다 (UploadService.releaseIfUnused) */
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'cover_file_id' })
coverFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
coverFileId!: string | null;
/** 끈 알림 (기본은 모두 켜짐) */
@Column({ type: 'text', array: true, default: '{}' })
mutedNotices!: DiaryNotice[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
/** 다이어리의 기록 한 건 — 한 사람이 한 다이어리에 하루 한 번 */
@Entity('diary_posts')
@Unique(['diaryId', 'authorId', 'date'])
export class DiaryPost {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => Diary, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'diary_id' })
diary?: Diary;
@Index()
@Column({ type: 'uuid' })
diaryId!: string;
/** 기록한 사람 — 지금은 다이어리 주인. 공유 다이어리가 생기면 멤버가 된다 */
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author?: User;
@Column({ type: 'uuid' })
authorId!: string;
/** 기록한 날 (앱 기준 날짜, YYYY-MM-DD) — 하루 한 번 쓰기와 달력에 쓴다 */
@Column({ type: 'date' })
date!: string;
@Column({ type: 'varchar', length: DIARY_LIMITS.postText })
text!: string;
@Column({ type: 'varchar', length: 10, nullable: true })
mood!: Mood | null;
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'photo_file_id' })
photoFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
photoFileId!: string | null;
/** 같은 날 기록끼리 순서를 정한다 */
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
/** 기록에 단 댓글 */
@Entity('diary_comments')
export class DiaryComment {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => DiaryPost, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'post_id' })
post?: DiaryPost;
@Index()
@Column({ type: 'uuid' })
postId!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author?: User;
@Column({ type: 'uuid' })
authorId!: string;
@Column({ type: 'varchar', length: DIARY_LIMITS.comment })
text!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}