generated from ttipo/project-base
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:
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user