diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ddb3370..56bb825 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index b742aac..f26d906 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -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; export const ERROR_CODES = { diff --git a/backend/src/common/validators/index.ts b/backend/src/common/validators/index.ts index 98fa0bc..2cf63e6 100644 --- a/backend/src/common/validators/index.ts +++ b/backend/src/common/validators/index.ts @@ -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', diff --git a/backend/src/migrations/1791707431165-AddDiaries.ts b/backend/src/migrations/1791707431165-AddDiaries.ts new file mode 100644 index 0000000..377ae1b --- /dev/null +++ b/backend/src/migrations/1791707431165-AddDiaries.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDiaries1791707431165 implements MigrationInterface { + name = 'AddDiaries1791707431165'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/diary/diaries.controller.ts b/backend/src/modules/diary/diaries.controller.ts new file mode 100644 index 0000000..10eae4c --- /dev/null +++ b/backend/src/modules/diary/diaries.controller.ts @@ -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> { + 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 { + 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 { + 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 { + 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 { + return this.diary.createPost(user.userId, id, dto); + } +} diff --git a/backend/src/modules/diary/diary-comments.controller.ts b/backend/src/modules/diary/diary-comments.controller.ts new file mode 100644 index 0000000..4cf3e1a --- /dev/null +++ b/backend/src/modules/diary/diary-comments.controller.ts @@ -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> { + 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 { + 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 { + await this.diary.deleteComment(user.userId, id); + } +} diff --git a/backend/src/modules/diary/diary-posts.controller.ts b/backend/src/modules/diary/diary-posts.controller.ts new file mode 100644 index 0000000..c0aaba1 --- /dev/null +++ b/backend/src/modules/diary/diary-posts.controller.ts @@ -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> { + 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 { + 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 { + 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 { + return this.diary.createComment(user.userId, id, dto); + } +} diff --git a/backend/src/modules/diary/diary-shelf.spec.ts b/backend/src/modules/diary/diary-shelf.spec.ts new file mode 100644 index 0000000..982dab6 --- /dev/null +++ b/backend/src/modules/diary/diary-shelf.spec.ts @@ -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); + }); +}); diff --git a/backend/src/modules/diary/diary-shelf.ts b/backend/src/modules/diary/diary-shelf.ts new file mode 100644 index 0000000..865c2be --- /dev/null +++ b/backend/src/modules/diary/diary-shelf.ts @@ -0,0 +1,15 @@ +// 다이어리 책장 — 부수효과 없는 순수 함수 (앱의 DiaryBook.shelf 와 같은 규칙) + +/** + * 책장에 꽂힌 다이어리 수 — 대상이 모두 추억 보관함의 아이인 다이어리는 보관함에서 보므로 세지 않는다. + * 대상이 없는 다이어리는 책장에 둔다. + */ +export function countShelfDiaries( + diaryTargets: ReadonlyArray, + archivedPetIds: ReadonlySet, +): number { + return diaryTargets.filter( + (targets) => + !(targets.length > 0 && targets.every((id) => archivedPetIds.has(id))), + ).length; +} diff --git a/backend/src/modules/diary/diary.module.ts b/backend/src/modules/diary/diary.module.ts new file mode 100644 index 0000000..749319c --- /dev/null +++ b/backend/src/modules/diary/diary.module.ts @@ -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 {} diff --git a/backend/src/modules/diary/diary.service.ts b/backend/src/modules/diary/diary.service.ts new file mode 100644 index 0000000..3b4f0fb --- /dev/null +++ b/backend/src/modules/diary/diary.service.ts @@ -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 = ( + 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, + @InjectRepository(DiaryPost) + private readonly posts: Repository, + @InjectRepository(DiaryComment) + private readonly comments: Repository, + @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> { + const qb = this.diaries + .createQueryBuilder('diary') + .where('diary.owner_id = :ownerId', { ownerId }); + const page = await keysetPaginate(qb, createdAtKeys('diary'), query); + return mapPage(page, (d) => DiaryResponseDto.from(d)); + } + + /** 새 다이어리 — 책장이 가득 찼으면 DIARY_001 (추억 보관함의 아이만 담은 다이어리는 세지 않는다) */ + async createDiary( + ownerId: string, + dto: CreateDiaryDto, + ): Promise { + 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 { + 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 { + 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> { + const qb = this.posts + .createQueryBuilder('post') + .innerJoin('post.diary', 'diary') + .where('diary.owner_id = :ownerId', { ownerId }); + const page = await keysetPaginate( + qb, + createdAtKeys('post'), + query, + ); + return mapPage(page, (p) => DiaryPostResponseDto.from(p)); + } + + /** 기록 남기기 — 한 사람이 한 다이어리에 하루 한 번 (DIARY_002) */ + async createPost( + userId: string, + diaryId: string, + dto: CreatePostDto, + ): Promise { + 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 { + 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 { + 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> { + 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('comment'), + query, + ); + return mapPage(page, (c) => DiaryCommentResponseDto.from(c)); + } + + async createComment( + userId: string, + postId: string, + dto: CommentDto, + ): Promise { + 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 { + 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 { + const comment = await this.findOwnComment(userId, commentId); + await this.comments.delete({ id: comment.id }); + } + + // ── 아이 삭제 ───────────────────────────────────────── + /** 아이를 지우면 다이어리 대상에서만 뗀다 — 다이어리와 기록은 남긴다 (앱의 dropTarget) */ + async onPetDeleting({ + ownerId, + petId, + manager, + }: PetLifecycleEvent): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/backend/src/modules/diary/dto/diary.dto.ts b/backend/src/modules/diary/dto/diary.dto.ts new file mode 100644 index 0000000..35c502c --- /dev/null +++ b/backend/src/modules/diary/dto/diary.dto.ts @@ -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(), + }; + } +} diff --git a/backend/src/modules/diary/entities/diary.entity.ts b/backend/src/modules/diary/entities/diary.entity.ts new file mode 100644 index 0000000..77d51af --- /dev/null +++ b/backend/src/modules/diary/entities/diary.entity.ts @@ -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; +} diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index dad4a42..6cbe568 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -11,6 +11,8 @@ const Map kErrorCodeMessages = { 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', 'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', 'FILE_002': '파일 크기가 허용 범위를 초과했습니다.', + 'DIARY_001': '다이어리는 최대 6권까지 만들 수 있어요.', + 'DIARY_002': '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 7071d62..9cf42b2 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -11,6 +11,8 @@ export const ERROR_CODE_MESSAGES = { BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', FILE_001: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', FILE_002: '파일 크기가 허용 범위를 초과했습니다.', + DIARY_001: '다이어리는 최대 6권까지 만들 수 있어요.', + DIARY_002: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES