diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts index b92f411..f54e043 100644 --- a/backend/src/common/errors/error-codes.spec.ts +++ b/backend/src/common/errors/error-codes.spec.ts @@ -2,6 +2,7 @@ import { HttpStatus } from '@nestjs/common'; import { ERROR_CODES, + ErrorCodeDefinition, FALLBACK_ERROR_CODE, getErrorDefinition, isErrorCode, @@ -46,7 +47,9 @@ describe('에러 코드 레지스트리', () => { it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => { const clientOnlyCodes = Object.entries(ERROR_CODES) - .filter(([, definition]) => definition.clientOnly) + .filter(([, definition]: [string, ErrorCodeDefinition]) => + Boolean(definition.clientOnly), + ) .map(([code]) => code); expect(clientOnlyCodes).toContain('AUTH_002'); diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index a57c576..b9a45d5 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -83,6 +83,21 @@ const DOMAIN_ERROR_CODES = { status: HttpStatus.CONFLICT, message: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.', }, + // 공유 다이어리 — 초대 코드에 맞는(공유 중인) 다이어리가 없다 + DIARY_003: { + status: HttpStatus.NOT_FOUND, + message: '초대 코드를 찾을 수 없어요. 코드를 다시 확인해 주세요.', + }, + // 공유 다이어리 — 함께하는 사람은 주인 포함 최대 4명 + DIARY_004: { + status: HttpStatus.CONFLICT, + message: '참여 인원이 가득 찼어요. (최대 4명)', + }, + // 공유 다이어리 — 이미 함께하고 있다 (주인 포함) + DIARY_005: { + status: HttpStatus.CONFLICT, + message: '이미 참여하고 있는 다이어리예요.', + }, // 펫 명함 — 한 사람이 최대 10장 CARD_001: { status: HttpStatus.CONFLICT, diff --git a/backend/src/migrations/1791717026547-AddDiaryMembers.ts b/backend/src/migrations/1791717026547-AddDiaryMembers.ts new file mode 100644 index 0000000..2c28ebb --- /dev/null +++ b/backend/src/migrations/1791717026547-AddDiaryMembers.ts @@ -0,0 +1,70 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 공유 다이어리 — 함께하는 사람(diary_members)을 두고, 알림 설정을 사람별로 옮긴다. + * 자동 생성본은 diaries.muted_notices 를 먼저 지워 설정이 사라지므로 순서를 손으로 바꿨다: + * 표를 만들고 → 지금 있는 다이어리마다 주인 한 줄을 넣으며 알림 설정을 옮긴 뒤 → 예전 컬럼을 지운다. + */ +export class AddDiaryMembers1791717026547 implements MigrationInterface { + name = 'AddDiaryMembers1791717026547'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "diary_members" ("diary_id" uuid NOT NULL, "user_id" uuid NOT NULL, "muted_notices" text array NOT NULL DEFAULT '{}', "joined_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_9ee05cf02f0820ef2b8855620ff" PRIMARY KEY ("diary_id", "user_id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_9efd539d83a9f2e26f4cb34e7f" ON "diary_members" ("user_id") `, + ); + await queryRunner.query( + `ALTER TABLE "diary_members" ADD CONSTRAINT "FK_0780a472fccfacd4889777672d9" FOREIGN KEY ("diary_id") REFERENCES "diaries"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "diary_members" ADD CONSTRAINT "FK_9efd539d83a9f2e26f4cb34e7f4" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + // 지금 있는 다이어리 — 주인을 함께하는 사람으로 넣고 알림 설정을 옮긴다 (참여한 때 = 만든 때) + await queryRunner.query( + `INSERT INTO "diary_members" ("diary_id", "user_id", "muted_notices", "joined_at") SELECT "id", "owner_id", "muted_notices", "created_at" FROM "diaries"`, + ); + + await queryRunner.query( + `ALTER TABLE "diaries" ADD "shared" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "diaries" ADD "invite_code" character varying(10)`, + ); + await queryRunner.query( + `ALTER TABLE "diaries" ADD CONSTRAINT "UQ_c73eaac887ff63f901ab189de80" UNIQUE ("invite_code")`, + ); + await queryRunner.query( + `ALTER TABLE "diaries" DROP COLUMN "muted_notices"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // 알림 설정을 다이어리로 되돌린다 — 주인의 설정만 남는다 (참여한 사람의 설정·참여 기록은 사라진다) + await queryRunner.query( + `ALTER TABLE "diaries" ADD "muted_notices" text array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `UPDATE "diaries" d SET "muted_notices" = m."muted_notices" FROM "diary_members" m WHERE m."diary_id" = d."id" AND m."user_id" = d."owner_id"`, + ); + + await queryRunner.query( + `ALTER TABLE "diaries" DROP CONSTRAINT "UQ_c73eaac887ff63f901ab189de80"`, + ); + await queryRunner.query(`ALTER TABLE "diaries" DROP COLUMN "invite_code"`); + await queryRunner.query(`ALTER TABLE "diaries" DROP COLUMN "shared"`); + + await queryRunner.query( + `ALTER TABLE "diary_members" DROP CONSTRAINT "FK_9efd539d83a9f2e26f4cb34e7f4"`, + ); + await queryRunner.query( + `ALTER TABLE "diary_members" DROP CONSTRAINT "FK_0780a472fccfacd4889777672d9"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_9efd539d83a9f2e26f4cb34e7f"`, + ); + await queryRunner.query(`DROP TABLE "diary_members"`); + } +} diff --git a/backend/src/modules/diary/diaries.controller.ts b/backend/src/modules/diary/diaries.controller.ts index 10eae4c..4ac7158 100644 --- a/backend/src/modules/diary/diaries.controller.ts +++ b/backend/src/modules/diary/diaries.controller.ts @@ -9,6 +9,7 @@ import { ParseUUIDPipe, Patch, Post, + Put, Query, } from '@nestjs/common'; import { @@ -36,6 +37,8 @@ import { CreatePostDto, DiaryPostResponseDto, DiaryResponseDto, + DiarySharingDto, + MutedNoticesDto, UpdateDiaryDto, } from './dto/diary.dto'; @@ -48,7 +51,10 @@ export class DiariesController { constructor(private readonly diary: DiaryService) {} @Get() - @ApiOperation({ summary: '내 다이어리 목록 (만든 순, 커서 페이지네이션)' }) + @ApiOperation({ + summary: '내가 함께하는 다이어리 목록 (만든 순, 커서 페이지네이션)', + description: '내가 만든 다이어리와 초대 코드로 참여한 다이어리가 함께 온다', + }) @ApiPaginatedResponse(DiaryResponseDto, '다이어리 목록') @ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류') list( @@ -59,7 +65,11 @@ export class DiariesController { } @Post() - @ApiOperation({ summary: '새 다이어리' }) + @ApiOperation({ + summary: '새 다이어리', + description: + 'shared=true 면 초대 코드를 함께 만든다. 책장은 참여한 다이어리도 함께 센다', + }) @ApiStandardResponse(DiaryResponseDto, { status: 201, description: '만든 다이어리', @@ -75,10 +85,11 @@ export class DiariesController { @Patch(':id') @ApiOperation({ - summary: '다이어리 수정 (이름·대상·리본·표지·알림, 보낸 값만)', + summary: '다이어리 수정 (이름·대상·리본·표지, 보낸 값만) — 주인만', }) @ApiStandardResponse(DiaryResponseDto, { description: '수정한 다이어리' }) @ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음') + @ApiErrorResponse('AUTH_003', '주인이 아님') @ApiErrorResponse('VAL_001', '입력값 검증 실패') update( @CurrentUser() user: AuthUser, @@ -91,9 +102,12 @@ export class DiariesController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) @SkipTransform() // 204 — 본문이 없다 - @ApiOperation({ summary: '다이어리 삭제 (기록·댓글 함께)' }) + @ApiOperation({ + summary: '다이어리 삭제 (기록·댓글·함께하는 사람 함께) — 주인만', + }) @ApiNoContentResponse({ description: '삭제 완료' }) @ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음') + @ApiErrorResponse('AUTH_003', '주인이 아님 (나가기를 쓴다)') async remove( @CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @@ -101,8 +115,79 @@ export class DiariesController { await this.diary.deleteDiary(user.userId, id); } + @Put(':id/sharing') + @ApiOperation({ + summary: '공유 켜기·끄기 — 주인만', + description: + '켜면 초대 코드가 생긴다(이미 있으면 그대로). 끄면 주인 말고는 모두 빠지고 코드도 지워진다 — 남긴 기록·댓글은 다이어리에 남는다', + }) + @ApiStandardResponse(DiaryResponseDto, { description: '바뀐 다이어리' }) + @ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음') + @ApiErrorResponse('AUTH_003', '주인이 아님') + @ApiErrorResponse('VAL_001', '입력값 검증 실패') + setSharing( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DiarySharingDto, + ): Promise { + return this.diary.setSharing(user.userId, id, dto); + } + + @Put(':id/muted-notices') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() // 204 — 본문이 없다 + @ApiOperation({ summary: '내 알림 설정 (함께하는 사람마다 따로)' }) + @ApiNoContentResponse({ description: '저장 완료' }) + @ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음') + @ApiErrorResponse('VAL_001', '입력값 검증 실패') + async setMutedNotices( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: MutedNoticesDto, + ): Promise { + await this.diary.setMutedNotices(user.userId, id, dto); + } + + @Delete(':id/membership') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() // 204 — 본문이 없다 + @ApiOperation({ + summary: '다이어리에서 나가기 (남긴 기록은 남는다)', + description: + '초대 코드가 새로 바뀐다 — 주인이 다시 초대해야 돌아올 수 있다. 주인은 나갈 수 없다 — 다이어리를 지운다 (BIZ_001)', + }) + @ApiNoContentResponse({ description: '나가기 완료' }) + @ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음') + @ApiErrorResponse('BIZ_001', '주인은 나갈 수 없음') + async leave( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.diary.leaveDiary(user.userId, id); + } + + @Delete(':id/members/:userId') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() // 204 — 본문이 없다 + @ApiOperation({ + summary: '함께하는 사람 내보내기 — 주인만', + description: + '초대 코드가 새로 바뀐다 — 내보낸 사람은 예전 코드로 다시 들어오지 못한다. 남긴 기록은 남는다', + }) + @ApiNoContentResponse({ description: '내보내기 완료' }) + @ApiErrorResponse('RES_001', '다이어리 또는 그 사람을 찾을 수 없음') + @ApiErrorResponse('AUTH_003', '주인이 아님') + @ApiErrorResponse('BIZ_001', '주인은 내보낼 수 없음') + async removeMember( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Param('userId', ParseUUIDPipe) memberId: string, + ): Promise { + await this.diary.removeMember(user.userId, id, memberId); + } + @Post(':id/posts') - @ApiOperation({ summary: '기록 남기기 (하루 한 번)' }) + @ApiOperation({ summary: '기록 남기기 (함께하는 사람 누구나, 하루 한 번)' }) @ApiStandardResponse(DiaryPostResponseDto, { status: 201, description: '남긴 기록', diff --git a/backend/src/modules/diary/diary-comments.controller.ts b/backend/src/modules/diary/diary-comments.controller.ts index 4cf3e1a..73d00d9 100644 --- a/backend/src/modules/diary/diary-comments.controller.ts +++ b/backend/src/modules/diary/diary-comments.controller.ts @@ -42,7 +42,7 @@ export class DiaryCommentsController { @Get() @ApiOperation({ - summary: '내 다이어리들의 댓글 목록 (쓴 순, 커서 페이지네이션)', + summary: '내가 함께하는 다이어리들의 댓글 목록 (쓴 순, 커서 페이지네이션)', }) @ApiPaginatedResponse(DiaryCommentResponseDto, '댓글 목록') @ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류') diff --git a/backend/src/modules/diary/diary-file-access.ts b/backend/src/modules/diary/diary-file-access.ts new file mode 100644 index 0000000..3474bcd --- /dev/null +++ b/backend/src/modules/diary/diary-file-access.ts @@ -0,0 +1,77 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import type { FileAccessPolicy } from '@/modules/upload/file-access-policy'; +import { UploadService } from '@/modules/upload/upload.service'; + +/** + * 함께하는 다이어리에서 보이는 남의 사진 — 내가 함께하는 다이어리 중 하나라도 이 파일을 보여 주면 허락한다. + * - 다이어리 표지 + * - 그 다이어리 기록의 사진 (나간 사람이 남긴 기록 포함) + * - 그 다이어리 대상 아이의 사진 (주인의 아이) + * - 그 다이어리에 함께하거나 기록·댓글을 남긴 회원의 지금 프로필 사진 + */ +const MEMBER_CAN_READ_FILE = ` +SELECT EXISTS ( + SELECT 1 + FROM diary_members me + JOIN diaries d ON d.id = me.diary_id + JOIN files f ON f.id = $2 + WHERE me.user_id = $1 + AND ( + d.cover_file_id = f.id + OR EXISTS ( + SELECT 1 FROM diary_posts p + WHERE p.diary_id = d.id AND p.photo_file_id = f.id + ) + OR EXISTS ( + SELECT 1 FROM pets pet + WHERE pet.owner_id = d.owner_id + AND pet.photo_file_id = f.id + AND pet.id::text = ANY (d.target_ids) + ) + OR EXISTS ( + SELECT 1 FROM users u + WHERE u.id = f.owner_id + AND u.photo_file_id = f.id + AND ( + EXISTS ( + SELECT 1 FROM diary_members m + WHERE m.diary_id = d.id AND m.user_id = u.id + ) + OR EXISTS ( + SELECT 1 FROM diary_posts p + WHERE p.diary_id = d.id AND p.author_id = u.id + ) + OR EXISTS ( + SELECT 1 FROM diary_comments c + JOIN diary_posts cp ON cp.id = c.post_id + WHERE cp.diary_id = d.id AND c.author_id = u.id + ) + ) + ) + ) +) AS allowed`; + +// 공유 다이어리의 사진 내려받기 규칙 — UploadService 에 등록한다 (file-access-policy.ts) +@Injectable() +export class DiaryFileAccess implements FileAccessPolicy, OnModuleInit { + constructor( + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly uploads: UploadService, + ) {} + + onModuleInit(): void { + this.uploads.registerAccessPolicy(this); + } + + async canRead(userId: string, fileId: string): Promise { + const rows = await this.dataSource.query<{ allowed: boolean }[]>( + MEMBER_CAN_READ_FILE, + [userId, fileId], + ); + return rows[0]?.allowed === true; + } +} diff --git a/backend/src/modules/diary/diary-invitations.controller.ts b/backend/src/modules/diary/diary-invitations.controller.ts new file mode 100644 index 0000000..cdf6d72 --- /dev/null +++ b/backend/src/modules/diary/diary-invitations.controller.ts @@ -0,0 +1,84 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; + +import { + ApiErrorResponse, + 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 { DiaryService } from './diary.service'; +import { + DiaryInvitationDto, + DiaryResponseDto, + JoinDiaryDto, +} from './dto/diary.dto'; + +/** + * 초대 코드로 찾는 엔드포인트는 코드 추측의 표적이 된다 — 전역보다 엄격하게 막는다. + * 분당 30회 — 코드를 몇 번 잘못 입력해도 충분하다. + */ +const INVITE_LOOKUP_THROTTLE = { default: { ttl: 60_000, limit: 30 } }; + +// 공유 다이어리 초대 — 코드를 받은 회원이 참여하기 전에 미리 본다 +@ApiTags('Diary') +@ApiBearerAuth() +@Throttle(INVITE_LOOKUP_THROTTLE) +@ApiErrorResponse('AUTH_001', '로그인 필요') +@Controller('diary-invitations') +export class DiaryInvitationsController { + constructor(private readonly diary: DiaryService) {} + + @Get(':code') + @ApiOperation({ + summary: '초대 코드로 다이어리 미리보기 (참여 전)', + description: '코드는 대소문자를 가리지 않는다', + }) + @ApiParam({ name: 'code', example: 'RUP-7KQ2MX' }) + @ApiStandardResponse(DiaryInvitationDto, { description: '초대된 다이어리' }) + @ApiErrorResponse('DIARY_003', '코드에 맞는(공유 중인) 다이어리가 없음') + preview( + @CurrentUser() user: AuthUser, + @Param('code') code: string, + ): Promise { + return this.diary.previewInvitation(user.userId, code); + } +} + +// 공유 다이어리 참여 +@ApiTags('Diary') +@ApiBearerAuth() +@ApiErrorResponse('AUTH_001', '로그인 필요') +@Controller('diary-memberships') +export class DiaryMembershipsController { + constructor(private readonly diary: DiaryService) {} + + @Post() + @Throttle(INVITE_LOOKUP_THROTTLE) + @ApiOperation({ + summary: '초대 코드로 다이어리 참여', + description: + '확인 순서: DIARY_003(코드 없음·공유 중 아님) → DIARY_005(이미 함께함) → DIARY_004(인원 가득) → DIARY_001(내 책장 가득)', + }) + @ApiStandardResponse(DiaryResponseDto, { + status: 201, + description: '참여한 다이어리', + }) + @ApiErrorResponse('DIARY_003', '코드에 맞는(공유 중인) 다이어리가 없음') + @ApiErrorResponse('DIARY_005', '이미 함께하고 있음 (주인 포함)') + @ApiErrorResponse('DIARY_004', '참여 인원이 가득 참 (최대 4명)') + @ApiErrorResponse('DIARY_001', '내 책장이 가득 참 (최대 6권)') + @ApiErrorResponse('VAL_001', '입력값 검증 실패') + join( + @CurrentUser() user: AuthUser, + @Body() dto: JoinDiaryDto, + ): Promise { + return this.diary.joinDiary(user.userId, dto); + } +} diff --git a/backend/src/modules/diary/diary-posts.controller.ts b/backend/src/modules/diary/diary-posts.controller.ts index c0aaba1..c37062b 100644 --- a/backend/src/modules/diary/diary-posts.controller.ts +++ b/backend/src/modules/diary/diary-posts.controller.ts @@ -48,7 +48,7 @@ export class DiaryPostsController { @Get() @ApiOperation({ - summary: '내 다이어리들의 기록 목록 (쓴 순, 커서 페이지네이션)', + summary: '내가 함께하는 다이어리들의 기록 목록 (쓴 순, 커서 페이지네이션)', }) @ApiPaginatedResponse(DiaryPostResponseDto, '기록 목록') @ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류') @@ -75,7 +75,7 @@ export class DiaryPostsController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) @SkipTransform() // 204 — 본문이 없다 - @ApiOperation({ summary: '기록 삭제 (댓글 함께)' }) + @ApiOperation({ summary: '기록 삭제 (댓글 함께, 내가 쓴 기록만)' }) @ApiNoContentResponse({ description: '삭제 완료' }) @ApiErrorResponse('RES_001', '기록을 찾을 수 없음') @ApiErrorResponse('AUTH_003', '내가 쓴 기록이 아님') @@ -87,7 +87,7 @@ export class DiaryPostsController { } @Post(':id/comments') - @ApiOperation({ summary: '댓글 쓰기' }) + @ApiOperation({ summary: '댓글 쓰기 (함께하는 사람 누구나)' }) @ApiStandardResponse(DiaryCommentResponseDto, { status: 201, description: '쓴 댓글', diff --git a/backend/src/modules/diary/diary-view.spec.ts b/backend/src/modules/diary/diary-view.spec.ts new file mode 100644 index 0000000..4a42491 --- /dev/null +++ b/backend/src/modules/diary/diary-view.spec.ts @@ -0,0 +1,89 @@ +import { toMemberViews, toTargetViews } from './diary-view'; +import type { TargetPetSource } from './diary-view'; + +const OWNER = 'owner-1'; + +function pet(overrides: Partial = {}): TargetPetSource { + return { + id: 'pet-1', + ownerId: OWNER, + name: '콩이', + photoFileId: 'pet-photo', + tint: '#FF9B6A', + archivedAt: null, + ...overrides, + }; +} + +describe('함께하는 사람', () => { + it('주인이 맨 앞, 나머지는 들어온 순서', () => { + const views = toMemberViews( + [ + { userId: 'b', joinedAt: new Date('2026-10-03T00:00:00Z') }, + { userId: OWNER, joinedAt: new Date('2026-10-05T00:00:00Z') }, + { userId: 'a', joinedAt: new Date('2026-10-01T00:00:00Z') }, + ], + OWNER, + ); + + expect(views.map((m) => m.userId)).toEqual([OWNER, 'a', 'b']); + expect(views.map((m) => m.isOwner)).toEqual([true, false, false]); + expect(views[1].joinedAt).toBe('2026-10-01T00:00:00.000Z'); + }); + + it('회원 정보가 없으면 닉네임·사진은 null', () => { + const [view] = toMemberViews( + [{ userId: OWNER, joinedAt: new Date(0), user: null }], + OWNER, + ); + expect(view.nickname).toBeNull(); + expect(view.photoFileId).toBeNull(); + }); +}); + +describe('다이어리 대상', () => { + const owner = { nickname: '콩이맘', photoFileId: 'owner-photo' }; + + it('targetIds 순서대로 아이와 주인 자신(self)을 보여 준다', () => { + const pets = new Map([ + ['pet-1', pet()], + ['pet-2', pet({ id: 'pet-2', name: '두부', archivedAt: new Date() })], + ]); + + expect( + toTargetViews(['pet-2', 'self', 'pet-1'], OWNER, pets, owner), + ).toEqual([ + { + id: 'pet-2', + name: '두부', + photoFileId: 'pet-photo', + tint: '#FF9B6A', + archived: true, + }, + { + id: 'self', + name: '콩이맘', + photoFileId: 'owner-photo', + tint: null, + archived: false, + }, + { + id: 'pet-1', + name: '콩이', + photoFileId: 'pet-photo', + tint: '#FF9B6A', + archived: false, + }, + ]); + }); + + it('더 이상 없는 아이와 주인의 아이가 아닌 아이는 뺀다', () => { + const pets = new Map([['pet-x', pet({ id: 'pet-x', ownerId: 'someone' })]]); + + expect(toTargetViews(['gone', 'pet-x'], OWNER, pets, owner)).toEqual([]); + }); + + it('주인 정보를 모르면 self 도 뺀다', () => { + expect(toTargetViews(['self'], OWNER, new Map(), null)).toEqual([]); + }); +}); diff --git a/backend/src/modules/diary/diary-view.ts b/backend/src/modules/diary/diary-view.ts new file mode 100644 index 0000000..852e4f9 --- /dev/null +++ b/backend/src/modules/diary/diary-view.ts @@ -0,0 +1,83 @@ +// 다이어리 응답 조립 — 부수효과 없는 순수 함수 (함께하는 사람·대상 표시) + +import { SELF_TARGET_ID } from '@/common/validators'; +import type { DiaryMemberDto, DiaryTargetDto } from './dto/diary.dto'; + +/** 회원의 보이는 정보 — 닉네임·프로필 사진 */ +export interface ProfileSource { + readonly nickname: string | null; + readonly photoFileId: string | null; +} + +/** 함께하는 사람 한 줄 (diary_members + users) */ +export interface MemberSource { + readonly userId: string; + readonly joinedAt: Date; + readonly user?: ProfileSource | null; +} + +/** 다이어리 대상이 될 수 있는 아이 */ +export interface TargetPetSource { + readonly id: string; + readonly ownerId: string; + readonly name: string; + readonly photoFileId: string | null; + readonly tint: string; + readonly archivedAt: Date | null; +} + +/** 함께하는 사람 — 주인이 맨 앞, 나머지는 들어온 순서 */ +export function toMemberViews( + members: readonly MemberSource[], + ownerId: string, +): DiaryMemberDto[] { + return [...members] + .sort((a, b) => { + if (a.userId === ownerId) return -1; + if (b.userId === ownerId) return 1; + return a.joinedAt.getTime() - b.joinedAt.getTime(); + }) + .map((m) => ({ + userId: m.userId, + nickname: m.user?.nickname ?? null, + photoFileId: m.user?.photoFileId ?? null, + isOwner: m.userId === ownerId, + joinedAt: m.joinedAt.toISOString(), + })); +} + +/** + * 다이어리 대상 — targetIds 순서대로. 아이는 다이어리 주인의 아이만, "self" 는 주인 자신이다. + * 더 이상 찾을 수 없는 대상은 뺀다. + */ +export function toTargetViews( + targetIds: readonly string[], + ownerId: string, + pets: ReadonlyMap, + owner: ProfileSource | null, +): DiaryTargetDto[] { + const views: DiaryTargetDto[] = []; + for (const id of targetIds) { + if (id === SELF_TARGET_ID) { + if (owner === null) continue; + views.push({ + id, + name: owner.nickname, + photoFileId: owner.photoFileId, + tint: null, + archived: false, + }); + continue; + } + const pet = pets.get(id); + if (!pet || pet.ownerId !== ownerId) continue; + views.push({ + id, + name: pet.name, + photoFileId: pet.photoFileId, + tint: pet.tint, + archived: pet.archivedAt !== null, + }); + } + return views; +} diff --git a/backend/src/modules/diary/diary.module.ts b/backend/src/modules/diary/diary.module.ts index 749319c..290f95c 100644 --- a/backend/src/modules/diary/diary.module.ts +++ b/backend/src/modules/diary/diary.module.ts @@ -3,15 +3,32 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { PetModule } from '@/modules/pet/pet.module'; import { UploadModule } from '@/modules/upload/upload.module'; +import { User } from '@/modules/user/entities/user.entity'; import { DiariesController } from './diaries.controller'; import { DiaryCommentsController } from './diary-comments.controller'; +import { DiaryFileAccess } from './diary-file-access'; +import { + DiaryInvitationsController, + DiaryMembershipsController, +} from './diary-invitations.controller'; import { DiaryPostsController } from './diary-posts.controller'; import { DiaryService } from './diary.service'; -import { Diary, DiaryComment, DiaryPost } from './entities/diary.entity'; +import { + Diary, + DiaryComment, + DiaryMember, + DiaryPost, +} from './entities/diary.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Diary, DiaryPost, DiaryComment]), + TypeOrmModule.forFeature([ + Diary, + DiaryMember, + DiaryPost, + DiaryComment, + User, + ]), PetModule, UploadModule, ], @@ -19,7 +36,9 @@ import { Diary, DiaryComment, DiaryPost } from './entities/diary.entity'; DiariesController, DiaryPostsController, DiaryCommentsController, + DiaryInvitationsController, + DiaryMembershipsController, ], - providers: [DiaryService], + providers: [DiaryService, DiaryFileAccess], }) export class DiaryModule {} diff --git a/backend/src/modules/diary/diary.service.ts b/backend/src/modules/diary/diary.service.ts index 3b4f0fb..bcb115e 100644 --- a/backend/src/modules/diary/diary.service.ts +++ b/backend/src/modules/diary/diary.service.ts @@ -1,6 +1,15 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { DataSource, IsNull, Not, Repository } from 'typeorm'; +import { + DataSource, + EntityManager, + In, + IsNull, + Not, + ObjectLiteral, + Repository, + SelectQueryBuilder, +} from 'typeorm'; import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination'; import { @@ -8,6 +17,7 @@ import { PaginatedResult, } from '@/common/dto/pagination.dto'; import { BusinessException } from '@/common/errors/business.exception'; +import { SELF_TARGET_ID } from '@/common/validators'; import { Pet } from '@/modules/pet/entities/pet.entity'; import { PetLifecycleEvent, @@ -15,14 +25,19 @@ import { } from '@/modules/pet/pet-lifecycle'; import { PetService } from '@/modules/pet/pet.service'; import { UploadService } from '@/modules/upload/upload.service'; +import { User } from '@/modules/user/entities/user.entity'; import { isUniqueViolation } from '@/shared/utils/db-error'; import { CommentDto, CreateDiaryDto, CreatePostDto, DiaryCommentResponseDto, + DiaryInvitationDto, DiaryPostResponseDto, DiaryResponseDto, + DiarySharingDto, + JoinDiaryDto, + MutedNoticesDto, UpdateDiaryDto, UpdatePostDto, } from './dto/diary.dto'; @@ -30,9 +45,14 @@ import { DIARY_LIMITS, Diary, DiaryComment, + DiaryMember, DiaryPost, } from './entities/diary.entity'; import { countShelfDiaries } from './diary-shelf'; +import { normalizeInviteCode, randomInviteCode } from './invite-code'; + +/** 초대 코드가 겹쳤을 때 다시 뽑는 횟수 — 32^6 가지라 실제로 겹치는 일은 드물다 */ +const CODE_RETRY = 5; const createdAtKeys = ( alias: string, @@ -45,16 +65,50 @@ const createdAtKeys = ( { column: `${alias}.id`, cast: 'uuid', read: (row: T) => row.id }, ]; -// 다이어리 — 다이어리·기록·댓글. 지금은 주인만 읽고 쓴다 (공유 다이어리는 다음 단계) +/** [diaryColumn] 의 다이어리에 [userId] 가 함께하는 행만 남긴다 */ +function onlyMemberOf( + qb: SelectQueryBuilder, + diaryColumn: string, + userId: string, +): SelectQueryBuilder { + return qb.innerJoin( + DiaryMember, + 'member', + `member.diary_id = ${diaryColumn} AND member.user_id = :memberUserId`, + { memberUserId: userId }, + ); +} + +/** 초대 코드가 겹치면(유니크 위반) 새 코드로 처음부터 다시 한다 */ +async function withFreshInviteCode(run: () => Promise): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + return await run(); + } catch (error) { + if (!isUniqueViolation(error) || attempt >= CODE_RETRY) throw error; + } + } +} + +/** + * 다이어리 — 다이어리·기록·댓글, 그리고 함께 쓰기(초대 코드·참여·나가기). + * 다이어리에 함께하는 사람(diary_members, 주인 포함)만 읽고 쓴다 — 그 밖의 사람에게는 없는 것과 똑같이 RES_001. + */ @Injectable() export class DiaryService implements OnModuleInit, PetLifecycleListener { + private readonly logger = new Logger(DiaryService.name); + constructor( @InjectRepository(Diary) private readonly diaries: Repository, + @InjectRepository(DiaryMember) + private readonly members: Repository, @InjectRepository(DiaryPost) private readonly posts: Repository, @InjectRepository(DiaryComment) private readonly comments: Repository, + @InjectRepository(User) + private readonly users: Repository, @InjectDataSource() private readonly dataSource: DataSource, private readonly pets: PetService, @@ -66,113 +120,310 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { } // ── 다이어리 ─────────────────────────────────────────── + /** 내가 함께하는 다이어리 (내가 만든 것 + 참여한 것) */ async listDiaries( - ownerId: string, + userId: string, query: CursorPaginationQueryDto, ): Promise> { - const qb = this.diaries - .createQueryBuilder('diary') - .where('diary.owner_id = :ownerId', { ownerId }); + const qb = onlyMemberOf( + this.diaries.createQueryBuilder('diary'), + 'diary.id', + userId, + ); const page = await keysetPaginate(qb, createdAtKeys('diary'), query); - return mapPage(page, (d) => DiaryResponseDto.from(d)); + return { + items: await this.toResponses(userId, page.items), + pageInfo: page.pageInfo, + }; } - /** 새 다이어리 — 책장이 가득 찼으면 DIARY_001 (추억 보관함의 아이만 담은 다이어리는 세지 않는다) */ + /** 새 다이어리 — 책장이 가득 찼으면 DIARY_001. 만든 사람이 주인으로 함께한다 */ async createDiary( - ownerId: string, + userId: string, dto: CreateDiaryDto, ): Promise { - await this.pets.assertOwnedTargets(ownerId, dto.targetIds); - await this.uploads.assertOwnedImage(dto.coverFileId, ownerId); + await this.pets.assertOwnedTargets(userId, dto.targetIds); + await this.uploads.assertOwnedImage(dto.coverFileId, userId); + const shared = dto.shared ?? false; - 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 withFreshInviteCode(() => + this.dataSource.transaction(async (manager) => { + await this.assertShelfRoom(manager, userId); - 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, + const saved = await manager.save( + manager.create(Diary, { + ownerId: userId, + name: dto.name, + targetIds: [...new Set(dto.targetIds)], + ribbon: dto.ribbon, + tint: dto.tint, + coverFileId: dto.coverFileId ?? null, + shared, + inviteCode: shared ? randomInviteCode() : null, + }), + ); + await manager.insert(DiaryMember, { + diaryId: saved.id, + userId, mutedNotices: [], - }), - ); - return DiaryResponseDto.from(diary); - }); + }); + return saved; + }), + ); + return this.toResponse(userId, diary); } + /** 다이어리 수정 — 주인만 (함께하는 사람은 AUTH_003) */ async updateDiary( - ownerId: string, + userId: string, diaryId: string, dto: UpdateDiaryDto, ): Promise { - const diary = await this.findOwnedDiary(ownerId, diaryId); + const diary = await this.findOwnedDiary(userId, diaryId); const previousCover = diary.coverFileId; if (dto.targetIds !== undefined) { - await this.pets.assertOwnedTargets(ownerId, dto.targetIds); + await this.pets.assertOwnedTargets(userId, dto.targetIds); diary.targetIds = [...new Set(dto.targetIds)]; } if (dto.coverFileId !== undefined) { - await this.uploads.assertOwnedImage(dto.coverFileId, ownerId); + await this.uploads.assertOwnedImage(dto.coverFileId, userId); 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); + return this.toResponse(userId, saved); } - /** 다이어리를 지우면 그 안의 기록·댓글도 모두 지운다 (FK) — 쓰지 않게 된 사진도 정리한다 */ - async deleteDiary(ownerId: string, diaryId: string): Promise { - const diary = await this.findOwnedDiary(ownerId, diaryId); + /** + * 다이어리를 지우면 그 안의 기록·댓글과 함께하는 사람도 모두 지운다 (FK) — 쓰지 않게 된 사진도 정리한다. + * 주인만 (함께하는 사람은 AUTH_003 — 나가기를 쓴다) + */ + async deleteDiary(userId: string, diaryId: string): Promise { + const diary = await this.findOwnedDiary(userId, diaryId); const photos = await this.posts.find({ where: { diaryId, photoFileId: Not(IsNull()) }, select: { photoFileId: true }, }); - await this.diaries.delete({ id: diaryId, ownerId }); + await this.diaries.delete({ id: diaryId, ownerId: userId }); await this.uploads.releaseIfUnused([ diary.coverFileId, ...photos.map((p) => p.photoFileId), ]); } + // ── 함께 쓰기 ───────────────────────────────────────── + /** + * 공유 켜기·끄기 — 주인만. + * 켜면 초대 코드를 만든다(이미 있으면 그대로). 끄면 주인 말고는 모두 빠지고 코드도 지운다 — + * 그 사람들이 남긴 기록·댓글은 다이어리에 남는다. + */ + async setSharing( + userId: string, + diaryId: string, + dto: DiarySharingDto, + ): Promise { + const diary = await this.findOwnedDiary(userId, diaryId); + + if (dto.shared) { + if (!diary.shared || diary.inviteCode === null) { + await withFreshInviteCode(async () => { + const inviteCode = diary.inviteCode ?? randomInviteCode(); + await this.diaries.update( + { id: diaryId }, + { shared: true, inviteCode }, + ); + diary.inviteCode = inviteCode; + }); + diary.shared = true; + } + } else { + await this.dataSource.transaction(async (manager) => { + // 다이어리를 먼저 바꿔 행을 잠근다 — 동시에 들어오던 참여는 꺼진 것을 보고 DIARY_003 이 된다 + await manager.update( + Diary, + { id: diaryId }, + { shared: false, inviteCode: null }, + ); + await manager.delete(DiaryMember, { diaryId, userId: Not(userId) }); + }); + diary.shared = false; + diary.inviteCode = null; + } + return this.toResponse(userId, diary); + } + + /** 내 알림 설정 — 함께하는 사람 누구나 자기 것만 */ + async setMutedNotices( + userId: string, + diaryId: string, + dto: MutedNoticesDto, + ): Promise { + const result = await this.members.update( + { diaryId, userId }, + { mutedNotices: [...new Set(dto.mutedNotices)] }, + ); + if (!result.affected) { + throw new BusinessException('RES_001', { context: { diaryId } }); + } + } + + /** + * 다이어리에서 나가기 — 주인은 나갈 수 없다(BIZ_001, 다이어리를 지운다). 남긴 기록은 남는다. + * 초대 코드가 바뀌므로 주인이 다시 초대해야 돌아올 수 있다 + */ + async leaveDiary(userId: string, diaryId: string): Promise { + const diary = await this.findMemberDiary(userId, diaryId); + if (diary.ownerId === userId) { + throw new BusinessException('BIZ_001', { + context: { diaryId, reason: 'owner-cannot-leave' }, + }); + } + await this.dropMember(diaryId, userId); + this.logger.log(`공유 다이어리 나가기: ${diaryId} (회원 ${userId})`); + } + + /** + * 함께하는 사람 내보내기 — 주인만. 주인 자신은 내보낼 수 없다(BIZ_001). + * 초대 코드가 바뀌므로 예전 코드로는 다시 들어오지 못한다 + */ + async removeMember( + userId: string, + diaryId: string, + memberId: string, + ): Promise { + const diary = await this.findOwnedDiary(userId, diaryId); + if (memberId === diary.ownerId) { + throw new BusinessException('BIZ_001', { + context: { diaryId, reason: 'cannot-remove-owner' }, + }); + } + await this.dropMember(diaryId, memberId); + this.logger.log(`공유 다이어리 내보내기: ${diaryId} (회원 ${memberId})`); + } + + /** + * 함께하는 사람 한 명을 빼고 초대 코드를 새로 바꾼다 (한 트랜잭션) — 빠진 사람이 갖고 있던 코드로는 + * 다시 들어오지 못한다. 그 사람이 함께하고 있지 않았으면 RES_001 (코드도 그대로) + */ + private async dropMember(diaryId: string, userId: string): Promise { + await withFreshInviteCode(() => + this.dataSource.transaction(async (manager) => { + // 다이어리를 먼저 바꿔 행을 잠근다 — 예전 코드로 동시에 들어오던 참여는 DIARY_003 이 된다 + await manager.update( + Diary, + { id: diaryId, shared: true }, + { inviteCode: randomInviteCode() }, + ); + const result = await manager.delete(DiaryMember, { diaryId, userId }); + if (!result.affected) { + throw new BusinessException('RES_001', { + context: { diaryId, memberId: userId }, + }); + } + }), + ); + } + + /** 초대 코드로 미리 보기 — 참여하기 전. 없거나 공유 중이 아니면 DIARY_003 */ + async previewInvitation( + userId: string, + rawCode: string, + ): Promise { + const code = normalizeInviteCode(rawCode); + const diary = + code === null + ? null + : await this.diaries.findOne({ + where: { inviteCode: code, shared: true }, + relations: { owner: true }, + }); + if (!diary) { + throw new BusinessException('DIARY_003', { context: { code: rawCode } }); + } + + const members = await this.members.find({ + where: { diaryId: diary.id }, + select: { diaryId: true, userId: true }, + }); + return { + diaryId: diary.id, + name: diary.name, + ownerNickname: diary.owner?.nickname ?? null, + memberCount: members.length, + maxMembers: DIARY_LIMITS.maxMembers, + isMember: members.some((m) => m.userId === userId), + }; + } + + /** + * 초대 코드로 참여 — 이 순서로 확인한다: + * 코드가 없거나 공유 중이 아님(DIARY_003) → 이미 함께함(DIARY_005) → 인원 가득(DIARY_004) → 내 책장 가득(DIARY_001) + */ + async joinDiary( + userId: string, + dto: JoinDiaryDto, + ): Promise { + const code = normalizeInviteCode(dto.code); + if (code === null) { + throw new BusinessException('DIARY_003', { context: { code: dto.code } }); + } + + const diary = await this.dataSource.transaction(async (manager) => { + // 다이어리를 잠그고 센다 — 동시에 참여해 인원을 넘지 않게 + const found = await manager.findOne(Diary, { + where: { inviteCode: code, shared: true }, + lock: { mode: 'pessimistic_write' }, + }); + if (!found) { + throw new BusinessException('DIARY_003', { context: { code } }); + } + + const members = await manager.find(DiaryMember, { + where: { diaryId: found.id }, + select: { diaryId: true, userId: true }, + }); + if (members.some((m) => m.userId === userId)) { + throw new BusinessException('DIARY_005', { + context: { diaryId: found.id }, + }); + } + if (members.length >= DIARY_LIMITS.maxMembers) { + throw new BusinessException('DIARY_004', { + context: { diaryId: found.id, members: members.length }, + }); + } + await this.assertShelfRoom(manager, userId); + + await manager.insert(DiaryMember, { + diaryId: found.id, + userId, + mutedNotices: [], + }); + return found; + }); + + this.logger.log(`공유 다이어리 참여: ${diary.id} (회원 ${userId})`); + return this.toResponse(userId, diary); + } + // ── 기록 ─────────────────────────────────────────────── - /** 내 다이어리들의 모든 기록 (쓴 순서) */ + /** 내가 함께하는 다이어리들의 모든 기록 (쓴 순서) */ async listPosts( - ownerId: string, + userId: string, query: CursorPaginationQueryDto, ): Promise> { - const qb = this.posts - .createQueryBuilder('post') - .innerJoin('post.diary', 'diary') - .where('diary.owner_id = :ownerId', { ownerId }); + const qb = onlyMemberOf( + this.posts.createQueryBuilder('post'), + 'post.diary_id', + userId, + ).leftJoinAndSelect('post.author', 'author'); const page = await keysetPaginate( qb, createdAtKeys('post'), @@ -181,13 +432,13 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { return mapPage(page, (p) => DiaryPostResponseDto.from(p)); } - /** 기록 남기기 — 한 사람이 한 다이어리에 하루 한 번 (DIARY_002) */ + /** 기록 남기기 — 함께하는 사람 누구나. 한 사람이 한 다이어리에 하루 한 번 (DIARY_002) */ async createPost( userId: string, diaryId: string, dto: CreatePostDto, ): Promise { - await this.findOwnedDiary(userId, diaryId); + await this.findMemberDiary(userId, diaryId); await this.uploads.assertOwnedImage(dto.photoFileId, userId); try { @@ -201,6 +452,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { photoFileId: dto.photoFileId ?? null, }), ); + post.author = (await this.users.findOneBy({ id: userId })) ?? undefined; return DiaryPostResponseDto.from(post); } catch (error) { if (isUniqueViolation(error)) { @@ -230,16 +482,18 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { } // ── 댓글 ─────────────────────────────────────────────── - /** 내 다이어리들의 모든 댓글 (쓴 순서) */ + /** 내가 함께하는 다이어리들의 모든 댓글 (쓴 순서) */ async listComments( - ownerId: string, + userId: 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 qb = onlyMemberOf( + this.comments + .createQueryBuilder('comment') + .innerJoin('comment.post', 'post'), + 'post.diary_id', + userId, + ).leftJoinAndSelect('comment.author', 'author'); const page = await keysetPaginate( qb, createdAtKeys('comment'), @@ -248,6 +502,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { return mapPage(page, (c) => DiaryCommentResponseDto.from(c)); } + /** 댓글 쓰기 — 함께하는 사람 누구나 */ async createComment( userId: string, postId: string, @@ -257,6 +512,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { const comment = await this.comments.save( this.comments.create({ postId, authorId: userId, text: dto.text }), ); + comment.author = (await this.users.findOneBy({ id: userId })) ?? undefined; return DiaryCommentResponseDto.from(comment); } @@ -289,30 +545,136 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { ); } + // ── 책장 ─────────────────────────────────────────────── + /** + * 책장에 한 권 더 꽂을 수 있는지 — 내가 함께하는 다이어리를 센다(추억 보관함의 내 아이만 담은 다이어리는 빼고). + * 동시에 만들거나 참여해 한도를 넘지 않도록 내 참여 행을 잠그고 센다. + */ + private async assertShelfRoom( + manager: EntityManager, + userId: string, + ): Promise { + const memberships = await manager.find(DiaryMember, { + where: { userId }, + select: { diaryId: true, userId: true }, + lock: { mode: 'pessimistic_write' }, + }); + const diaryIds = memberships.map((m) => m.diaryId); + const diaries = + diaryIds.length === 0 + ? [] + : await manager.find(Diary, { + where: { id: In(diaryIds) }, + select: { id: true, targetIds: true }, + }); + const archived = await manager.find(Pet, { + where: { ownerId: userId, archivedAt: Not(IsNull()) }, + select: { id: true }, + }); + + const shelf = countShelfDiaries( + diaries.map((d) => d.targetIds), + new Set(archived.map((p) => p.id)), + ); + if (shelf >= DIARY_LIMITS.maxDiaries) { + throw new BusinessException('DIARY_001', { context: { shelf } }); + } + } + + // ── 응답 조립 ───────────────────────────────────────── + private async toResponse( + viewerId: string, + diary: Diary, + ): Promise { + const [response] = await this.toResponses(viewerId, [diary]); + return response; + } + + /** 다이어리 응답 — 함께하는 사람과 대상 아이를 한 번에 읽어 붙인다 */ + private async toResponses( + viewerId: string, + diaries: readonly Diary[], + ): Promise { + if (diaries.length === 0) return []; + + const members = await this.members.find({ + where: { diaryId: In(diaries.map((d) => d.id)) }, + relations: { user: true }, + }); + const petIds = [...new Set(diaries.flatMap((d) => d.targetIds))].filter( + (id) => id !== SELF_TARGET_ID, + ); + const pets = + petIds.length === 0 + ? [] + : await this.dataSource.manager.find(Pet, { + where: { id: In(petIds) }, + select: { + id: true, + ownerId: true, + name: true, + photoFileId: true, + tint: true, + archivedAt: true, + }, + }); + const petById = new Map(pets.map((p) => [p.id, p])); + + return diaries.map((diary) => + DiaryResponseDto.from(diary, { + viewerId, + members: members.filter((m) => m.diaryId === diary.id), + pets: petById, + }), + ); + } + // ── 조회 도우미 ─────────────────────────────────────── - private async findOwnedDiary( - ownerId: string, + /** 내가 함께하는 다이어리 — 아니면 없는 것과 똑같이 RES_001 */ + private async findMemberDiary( + userId: string, diaryId: string, ): Promise { - const diary = await this.diaries.findOneBy({ id: diaryId, ownerId }); - if (!diary) + const member = await this.members.findOne({ + where: { diaryId, userId }, + relations: { diary: true }, + }); + if (!member?.diary) { throw new BusinessException('RES_001', { context: { diaryId } }); + } + return member.diary; + } + + /** 내가 주인인 다이어리 — 함께하는 사람이면 AUTH_003, 아니면 RES_001 */ + private async findOwnedDiary( + userId: string, + diaryId: string, + ): Promise { + const diary = await this.findMemberDiary(userId, diaryId); + if (diary.ownerId !== userId) { + throw new BusinessException('AUTH_003', { context: { diaryId } }); + } return diary; } - /** 볼 수 있는 기록 — 내 다이어리의 기록 */ + /** 볼 수 있는 기록 — 내가 함께하는 다이어리의 기록 (쓴 사람 정보 포함) */ private async findReadablePost( userId: string, postId: string, ): Promise { - const post = await this.posts.findOne({ - where: { id: postId, diary: { ownerId: userId } }, - }); + const post = await onlyMemberOf( + this.posts.createQueryBuilder('post'), + 'post.diary_id', + userId, + ) + .leftJoinAndSelect('post.author', 'author') + .where('post.id = :postId', { postId }) + .getOne(); if (!post) throw new BusinessException('RES_001', { context: { postId } }); return post; } - /** 고칠 수 있는 기록 — 내 다이어리에 내가 쓴 기록 */ + /** 고칠 수 있는 기록 — 내가 함께하는 다이어리에 내가 쓴 기록 (나간 뒤에는 RES_001) */ private async findOwnPost( userId: string, postId: string, @@ -324,14 +686,21 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { return post; } - /** 고칠 수 있는 댓글 — 내 다이어리에서 내가 쓴 댓글 */ + /** 고칠 수 있는 댓글 — 내가 함께하는 다이어리에서 내가 쓴 댓글 (나간 뒤에는 RES_001) */ private async findOwnComment( userId: string, commentId: string, ): Promise { - const comment = await this.comments.findOne({ - where: { id: commentId, post: { diary: { ownerId: userId } } }, - }); + const comment = await onlyMemberOf( + this.comments + .createQueryBuilder('comment') + .innerJoin('comment.post', 'post'), + 'post.diary_id', + userId, + ) + .leftJoinAndSelect('comment.author', 'author') + .where('comment.id = :commentId', { commentId }) + .getOne(); if (!comment) { throw new BusinessException('RES_001', { context: { commentId } }); } diff --git a/backend/src/modules/diary/dto/diary.dto.ts b/backend/src/modules/diary/dto/diary.dto.ts index 35c502c..934a707 100644 --- a/backend/src/modules/diary/dto/diary.dto.ts +++ b/backend/src/modules/diary/dto/diary.dto.ts @@ -8,6 +8,7 @@ import { Transform } from 'class-transformer'; import { ArrayMaxSize, IsArray, + IsBoolean, IsIn, IsNotEmpty, IsOptional, @@ -17,10 +18,14 @@ import { } from 'class-validator'; import { IsHexColor, IsIsoDate, IsTargetIdList } from '@/common/validators'; +import type { User } from '@/modules/user/entities/user.entity'; +import { toMemberViews, toTargetViews } from '../diary-view'; +import type { TargetPetSource } from '../diary-view'; import { DIARY_LIMITS, DIARY_NOTICES, MOODS } from '../entities/diary.entity'; import type { Diary, DiaryComment, + DiaryMember, DiaryNotice, DiaryPost, Mood, @@ -29,6 +34,9 @@ import type { const trim = ({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value; +/** 초대 코드 입력 길이 상한 — 형식 검사는 서비스가 한다 (틀린 코드는 DIARY_003) */ +const INVITE_CODE_INPUT_MAX_LENGTH = 20; + /** 새 다이어리 */ export class CreateDiaryDto { @ApiProperty({ @@ -69,25 +77,65 @@ export class CreateDiaryDto { @IsOptional() @IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' }) coverFileId?: string | null; + + @ApiPropertyOptional({ + description: + '함께 쓰는 다이어리로 만들지 (기본 false) — 켜면 초대 코드가 생긴다', + default: false, + }) + @IsOptional() + @IsBoolean({ message: '공유 설정이 올바르지 않습니다.' }) + shared?: boolean; } -/** 다이어리 수정 — 보낸 값만 바꾼다. 바탕색(tint)은 만들 때 정한 값을 유지한다 */ +/** + * 다이어리 수정 — 보낸 값만 바꾼다. 바탕색(tint)은 만들 때 정한 값을 유지한다. + * 공유는 PUT /diaries/:id/sharing, 알림은 PUT /diaries/:id/muted-notices 로 바꾼다. + */ export class UpdateDiaryDto extends PartialType( - OmitType(CreateDiaryDto, ['tint'] as const), -) { - @ApiPropertyOptional({ + OmitType(CreateDiaryDto, ['tint', 'shared'] as const), +) {} + +/** 공유 켜기·끄기 */ +export class DiarySharingDto { + @ApiProperty({ + description: + '켜면 초대 코드가 생긴다. 끄면 주인 말고는 모두 빠지고 초대 코드도 지워진다 (남긴 기록은 다이어리에 남는다)', + }) + @IsBoolean({ message: '공유 설정이 올바르지 않습니다.' }) + shared!: boolean; +} + +/** 내 알림 설정 — 함께하는 사람마다 따로 둔다 */ +export class MutedNoticesDto { + @ApiProperty({ description: '끈 알림 목록 (기본은 모두 켜짐)', enum: DIARY_NOTICES, isArray: true, }) - @IsOptional() @IsArray({ message: '알림 설정이 올바르지 않습니다.' }) - @ArrayMaxSize(DIARY_NOTICES.length) + @ArrayMaxSize(DIARY_NOTICES.length, { + message: '알림 설정이 올바르지 않습니다.', + }) @IsIn(DIARY_NOTICES, { each: true, message: '알림 설정이 올바르지 않습니다.', }) - mutedNotices?: DiaryNotice[]; + mutedNotices!: DiaryNotice[]; +} + +/** 초대 코드로 참여하기 */ +export class JoinDiaryDto { + @ApiProperty({ + description: '초대 코드 (RUP-XXXXXX, 대소문자 구분 없음)', + example: 'RUP-7KQ2MX', + }) + @IsString({ message: '초대 코드를 입력해 주세요.' }) + @IsNotEmpty({ message: '초대 코드를 입력해 주세요.' }) + @MaxLength(INVITE_CODE_INPUT_MAX_LENGTH, { + message: '초대 코드가 올바르지 않습니다.', + }) + code!: string; } /** 기록 남기기 — 하루 한 번 */ @@ -148,6 +196,93 @@ export class CommentDto { text!: string; } +/** 함께하는 사람 */ +export class DiaryMemberDto { + @ApiProperty({ description: '회원 ID' }) userId!: string; + + @ApiProperty({ + description: '닉네임 — 모르면 null', + nullable: true, + type: String, + }) + nickname!: string | null; + + @ApiProperty({ + description: '프로필 사진 파일 ID — GET /api/files/{id}', + nullable: true, + type: String, + }) + photoFileId!: string | null; + + @ApiProperty({ description: '다이어리 주인인지' }) isOwner!: boolean; + @ApiProperty({ description: '참여한 때 (ISO 8601 UTC)' }) joinedAt!: string; +} + +/** 다이어리 대상 — 아이 또는 주인 자신("self") */ +export class DiaryTargetDto { + @ApiProperty({ description: '아이 ID 또는 "self"' }) id!: string; + + @ApiProperty({ + description: '아이 이름 · self 면 주인 닉네임', + nullable: true, + type: String, + }) + name!: string | null; + + @ApiProperty({ + description: '아이 사진 · self 면 주인 프로필 사진 (파일 ID)', + nullable: true, + type: String, + }) + photoFileId!: string | null; + + @ApiProperty({ + description: '아이의 고유색 (#RRGGBB) · self 면 null', + nullable: true, + type: String, + }) + tint!: string | null; + + @ApiProperty({ description: '추억 보관함의 아이인지' }) archived!: boolean; +} + +/** 기록·댓글을 쓴 사람 */ +export class DiaryAuthorDto { + @ApiProperty({ description: '회원 ID' }) userId!: string; + + @ApiProperty({ + description: '닉네임 — 모르면 null', + nullable: true, + type: String, + }) + nickname!: string | null; + + @ApiProperty({ + description: '프로필 사진 파일 ID — GET /api/files/{id}', + nullable: true, + type: String, + }) + photoFileId!: string | null; + + static from(userId: string, user?: User | null): DiaryAuthorDto { + return { + userId, + nickname: user?.nickname ?? null, + photoFileId: user?.photoFileId ?? null, + }; + } +} + +/** 다이어리 응답을 만들 때 필요한 주변 정보 */ +export interface DiaryViewContext { + /** 응답을 받는 회원 */ + readonly viewerId: string; + /** 이 다이어리에 함께하는 사람 (회원 정보 포함) */ + readonly members: readonly DiaryMember[]; + /** 대상 아이 (id → 아이) */ + readonly pets: ReadonlyMap; +} + /** 다이어리 응답 (앱의 Diary) */ export class DiaryResponseDto { @ApiProperty() id!: string; @@ -163,13 +298,44 @@ export class DiaryResponseDto { }) coverFileId!: string | null; - @ApiProperty({ enum: DIARY_NOTICES, isArray: true }) + @ApiProperty({ + description: '내가 끈 알림 (함께하는 사람마다 따로)', + enum: DIARY_NOTICES, + isArray: true, + }) mutedNotices!: DiaryNotice[]; @ApiProperty({ description: '만든 때 (ISO 8601 UTC)' }) createdAt!: string; - static from(diary: Diary): DiaryResponseDto { + @ApiProperty({ description: '함께 쓰는 다이어리인지' }) shared!: boolean; + @ApiProperty({ description: '주인 (회원 ID)' }) ownerId!: string; + @ApiProperty({ description: '내가 주인인지' }) isOwner!: boolean; + + @ApiProperty({ + description: '초대 코드 — 내가 주인이고 공유 중일 때만, 아니면 null', + nullable: true, + type: String, + example: 'RUP-7KQ2MX', + }) + inviteCode!: string | null; + + @ApiProperty({ + description: '함께하는 사람 — 주인이 맨 앞, 나머지는 들어온 순서', + type: [DiaryMemberDto], + }) + members!: DiaryMemberDto[]; + + @ApiProperty({ + description: '대상 — targetIds 순서. 더 이상 없는 대상은 빠진다', + type: [DiaryTargetDto], + }) + targets!: DiaryTargetDto[]; + + static from(diary: Diary, context: DiaryViewContext): DiaryResponseDto { + const isOwner = diary.ownerId === context.viewerId; + const me = context.members.find((m) => m.userId === context.viewerId); + const owner = context.members.find((m) => m.userId === diary.ownerId); return { id: diary.id, name: diary.name, @@ -177,12 +343,45 @@ export class DiaryResponseDto { ribbon: diary.ribbon, tint: diary.tint, coverFileId: diary.coverFileId, - mutedNotices: diary.mutedNotices, + mutedNotices: me?.mutedNotices ?? [], createdAt: diary.createdAt.toISOString(), + shared: diary.shared, + ownerId: diary.ownerId, + isOwner, + inviteCode: isOwner && diary.shared ? diary.inviteCode : null, + members: toMemberViews(context.members, diary.ownerId), + targets: toTargetViews( + diary.targetIds, + diary.ownerId, + context.pets, + owner?.user ?? null, + ), }; } } +/** 초대 코드로 본 다이어리 — 참여하기 전에 보여 준다 */ +export class DiaryInvitationDto { + @ApiProperty() diaryId!: string; + @ApiProperty({ description: '다이어리 이름' }) name!: string; + + @ApiProperty({ + description: '주인 닉네임 — 모르면 null', + nullable: true, + type: String, + }) + ownerNickname!: string | null; + + @ApiProperty({ description: '지금 함께하는 사람 수 (주인 포함)' }) + memberCount!: number; + + @ApiProperty({ description: '함께할 수 있는 최대 인원 (주인 포함)' }) + maxMembers!: number; + + @ApiProperty({ description: '내가 이미 함께하고 있는지' }) + isMember!: boolean; +} + /** 기록 응답 (앱의 DiaryPost) */ export class DiaryPostResponseDto { @ApiProperty() id!: string; @@ -196,6 +395,10 @@ export class DiaryPostResponseDto { @ApiProperty({ nullable: true, type: String }) photoFileId!: string | null; + @ApiProperty({ description: '기록한 사람', type: DiaryAuthorDto }) + author!: DiaryAuthorDto; + + /** [post] 는 author 를 함께 읽어 와야 닉네임·사진이 채워진다 */ static from(post: DiaryPost): DiaryPostResponseDto { return { id: post.id, @@ -206,6 +409,7 @@ export class DiaryPostResponseDto { text: post.text, mood: post.mood, photoFileId: post.photoFileId, + author: DiaryAuthorDto.from(post.authorId, post.author), }; } } @@ -218,6 +422,10 @@ export class DiaryCommentResponseDto { @ApiProperty() text!: string; @ApiProperty({ description: '쓴 때 (ISO 8601 UTC)' }) createdAt!: string; + @ApiProperty({ description: '쓴 사람', type: DiaryAuthorDto }) + author!: DiaryAuthorDto; + + /** [comment] 는 author 를 함께 읽어 와야 닉네임·사진이 채워진다 */ static from(comment: DiaryComment): DiaryCommentResponseDto { return { id: comment.id, @@ -225,6 +433,7 @@ export class DiaryCommentResponseDto { authorId: comment.authorId, text: comment.text, createdAt: comment.createdAt.toISOString(), + author: DiaryAuthorDto.from(comment.authorId, comment.author), }; } } diff --git a/backend/src/modules/diary/entities/diary.entity.ts b/backend/src/modules/diary/entities/diary.entity.ts index 77d51af..d34306f 100644 --- a/backend/src/modules/diary/entities/diary.entity.ts +++ b/backend/src/modules/diary/entities/diary.entity.ts @@ -5,6 +5,8 @@ import { Index, JoinColumn, ManyToOne, + OneToMany, + PrimaryColumn, PrimaryGeneratedColumn, Unique, UpdateDateColumn, @@ -12,11 +14,14 @@ import { import { StoredFile } from '@/modules/upload/entities/stored-file.entity'; import { User } from '@/modules/user/entities/user.entity'; +import { INVITE_CODE_LENGTH } from '../invite-code'; /** 다이어리 규칙·입력 한도 — 앱의 diary.dart 상수와 맞춘다 */ export const DIARY_LIMITS = { /** 책장에 꽂을 수 있는 다이어리 수 */ maxDiaries: 6, + /** 공유 다이어리에 함께하는 사람 수 (주인 포함) */ + maxMembers: 4, name: 18, targets: 20, postText: 2000, @@ -75,9 +80,22 @@ export class Diary { @Column({ type: 'uuid', nullable: true }) coverFileId!: string | null; - /** 끈 알림 (기본은 모두 켜짐) */ - @Column({ type: 'text', array: true, default: '{}' }) - mutedNotices!: DiaryNotice[]; + /** 함께 쓰는 다이어리인지 — 켜면 초대 코드로 다른 회원이 참여한다 */ + @Column({ type: 'boolean', default: false }) + shared!: boolean; + + /** 초대 코드 `RUP-XXXXXX` — 공유 중일 때만 있다. 공유를 끄면 지워 예전 코드로는 들어올 수 없다 */ + @Column({ + type: 'varchar', + length: INVITE_CODE_LENGTH, + nullable: true, + unique: true, + }) + inviteCode!: string | null; + + /** 함께하는 사람 — 주인도 한 줄로 들어 있다 */ + @OneToMany(() => DiaryMember, (member) => member.diary) + members?: DiaryMember[]; @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; @@ -86,6 +104,35 @@ export class Diary { updatedAt!: Date; } +/** + * 다이어리에 함께하는 사람 — 주인도 다이어리를 만들 때 한 줄로 들어간다. + * 알림 설정은 사람마다 다르므로 여기에 둔다. + */ +@Entity('diary_members') +export class DiaryMember { + @PrimaryColumn({ type: 'uuid' }) + diaryId!: string; + + @Index() + @PrimaryColumn({ type: 'uuid' }) + userId!: string; + + @ManyToOne(() => Diary, (diary) => diary.members, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'diary_id' }) + diary?: Diary; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user?: User; + + /** 끈 알림 (기본은 모두 켜짐) */ + @Column({ type: 'text', array: true, default: '{}' }) + mutedNotices!: DiaryNotice[]; + + @CreateDateColumn({ type: 'timestamptz' }) + joinedAt!: Date; +} + /** 다이어리의 기록 한 건 — 한 사람이 한 다이어리에 하루 한 번 */ @Entity('diary_posts') @Unique(['diaryId', 'authorId', 'date']) @@ -101,7 +148,7 @@ export class DiaryPost { @Column({ type: 'uuid' }) diaryId!: string; - /** 기록한 사람 — 지금은 다이어리 주인. 공유 다이어리가 생기면 멤버가 된다 */ + /** 기록한 사람 — 다이어리에 함께하는 사람. 나가도 기록은 남는다 */ @ManyToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'author_id' }) author?: User; diff --git a/backend/src/modules/diary/invite-code.spec.ts b/backend/src/modules/diary/invite-code.spec.ts new file mode 100644 index 0000000..33c183b --- /dev/null +++ b/backend/src/modules/diary/invite-code.spec.ts @@ -0,0 +1,38 @@ +import { + INVITE_CODE_ALPHABET, + INVITE_CODE_LENGTH, + normalizeInviteCode, + randomInviteCode, +} from './invite-code'; + +describe('초대 코드', () => { + it('RUP- 뒤에 6자를 붙여 10자로 만든다', () => { + for (let i = 0; i < 50; i += 1) { + const code = randomInviteCode(); + expect(code).toHaveLength(INVITE_CODE_LENGTH); + expect(code).toMatch(/^RUP-[A-Z2-9]{6}$/); + } + }); + + it('헷갈리는 글자(0·O·1·I)는 쓰지 않는다', () => { + expect(INVITE_CODE_ALPHABET).not.toMatch(/[0O1I]/); + for (let i = 0; i < 50; i += 1) { + const body = randomInviteCode().slice('RUP-'.length); + for (const ch of body) expect(INVITE_CODE_ALPHABET).toContain(ch); + } + }); + + it('입력한 코드는 앞뒤 공백을 지우고 대문자로 맞춘다', () => { + expect(normalizeInviteCode(' rup-7kq2mx ')).toBe('RUP-7KQ2MX'); + expect(normalizeInviteCode('RUP-7KQ2MX')).toBe('RUP-7KQ2MX'); + }); + + it('형식이 아니면 null — 길이가 다르거나 쓰지 않는 글자가 있으면', () => { + expect(normalizeInviteCode('RUP-7KQ2M')).toBeNull(); + expect(normalizeInviteCode('RUP-7KQ2MXX')).toBeNull(); + expect(normalizeInviteCode('RUP-0KQ2MX')).toBeNull(); + expect(normalizeInviteCode('ABC-7KQ2MX')).toBeNull(); + expect(normalizeInviteCode('7KQ2MX')).toBeNull(); + expect(normalizeInviteCode('')).toBeNull(); + }); +}); diff --git a/backend/src/modules/diary/invite-code.ts b/backend/src/modules/diary/invite-code.ts new file mode 100644 index 0000000..7616ebe --- /dev/null +++ b/backend/src/modules/diary/invite-code.ts @@ -0,0 +1,33 @@ +import { randomInt } from 'crypto'; + +// 공유 다이어리 초대 코드 — 부수효과 없는 순수 함수 (무작위 값만 crypto 에서 얻는다) + +/** 코드 앞머리 — 앱이 보여 주는 형식 `RUP-XXXXXX` */ +const INVITE_CODE_PREFIX = 'RUP-'; +const INVITE_CODE_BODY_LENGTH = 6; + +/** 헷갈리는 글자(0·O·1·I)를 뺀 대문자·숫자 */ +export const INVITE_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +/** 저장 길이 — `RUP-` + 6자 */ +export const INVITE_CODE_LENGTH = + INVITE_CODE_PREFIX.length + INVITE_CODE_BODY_LENGTH; + +export const INVITE_CODE_PATTERN = new RegExp( + `^${INVITE_CODE_PREFIX}[${INVITE_CODE_ALPHABET}]{${INVITE_CODE_BODY_LENGTH}}$`, +); + +/** 새 초대 코드 — 추측하기 어렵게 무작위로 뽑는다 (겹치면 호출한 쪽이 다시 뽑는다) */ +export function randomInviteCode(): string { + let body = ''; + for (let i = 0; i < INVITE_CODE_BODY_LENGTH; i += 1) { + body += INVITE_CODE_ALPHABET[randomInt(0, INVITE_CODE_ALPHABET.length)]; + } + return `${INVITE_CODE_PREFIX}${body}`; +} + +/** 사람이 입력한 코드 → 저장 형식 (앞뒤 공백 제거·대문자). 형식이 아니면 null */ +export function normalizeInviteCode(input: string): string | null { + const code = input.trim().toUpperCase(); + return INVITE_CODE_PATTERN.test(code) ? code : null; +} diff --git a/backend/src/modules/upload/file-access-policy.ts b/backend/src/modules/upload/file-access-policy.ts new file mode 100644 index 0000000..e85e1f3 --- /dev/null +++ b/backend/src/modules/upload/file-access-policy.ts @@ -0,0 +1,9 @@ +/** + * 내 것이 아닌 파일을 읽어도 되는지 정하는 규칙 — 함께 쓰는 기능(공유 다이어리 등)이 구현한다. + * 각 모듈이 onModuleInit 에서 UploadService.registerAccessPolicy 로 등록한다 — + * 업로드 모듈이 다른 도메인을 알지 않아도 된다(의존 방향: 다른 도메인 → 업로드). + */ +export interface FileAccessPolicy { + /** [userId] 가 남의 파일 [fileId] 를 내려받아도 되면 true. 모르는 파일이면 false */ + canRead(userId: string, fileId: string): Promise; +} diff --git a/backend/src/modules/upload/upload.controller.ts b/backend/src/modules/upload/upload.controller.ts index c34662b..5a9753c 100644 --- a/backend/src/modules/upload/upload.controller.ts +++ b/backend/src/modules/upload/upload.controller.ts @@ -77,9 +77,13 @@ export class UploadController { @Get(':id') @SkipTransform() // 파일 본문 — { success, data } 로 감싸면 깨진다 @Header('Cache-Control', FILE_CACHE_CONTROL) - @ApiOperation({ summary: '내 파일 내려받기' }) + @ApiOperation({ + summary: '파일 내려받기', + description: + '내 파일, 그리고 함께 쓰는 다이어리에서 보이는 사진(표지·기록 사진·대상 아이·함께하는 사람의 프로필)', + }) @ApiOkResponse({ description: '파일 본문 (Content-Type 은 서버가 정한다)' }) - @ApiErrorResponse('RES_001', '파일이 없거나 내 파일이 아님') + @ApiErrorResponse('RES_001', '파일이 없거나 볼 수 없는 파일') async download( @CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, diff --git a/backend/src/modules/upload/upload.service.spec.ts b/backend/src/modules/upload/upload.service.spec.ts new file mode 100644 index 0000000..f2e2c7b --- /dev/null +++ b/backend/src/modules/upload/upload.service.spec.ts @@ -0,0 +1,80 @@ +import { Readable } from 'stream'; +import { Repository } from 'typeorm'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { StoredFile } from './entities/stored-file.entity'; +import { UploadService } from './upload.service'; + +// 실제 디스크를 열지 않는다 — 내려받기 권한 판단만 본다 +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + createReadStream: jest.fn(() => Readable.from([])), +})); + +const OWNER = 'owner-1'; +const OTHER = 'other-1'; + +const file = { + id: 'file-1', + ownerId: OWNER, + storedName: 'stored.png', + originalName: 'cover.png', + mimeType: 'image/png', + size: 10, +} as StoredFile; + +describe('UploadService 내려받기', () => { + const files = { findOneBy: jest.fn() }; + let service: UploadService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new UploadService(files as unknown as Repository); + }); + + const expectCode = async (promise: Promise, code: string) => { + await expect(promise).rejects.toBeInstanceOf(BusinessException); + await expect(promise).rejects.toMatchObject({ code }); + }; + + it('내 파일은 규칙 없이 내려받는다', async () => { + files.findOneBy.mockResolvedValue(file); + const canRead = jest.fn(); + service.registerAccessPolicy({ canRead }); + + const download = await service.download(file.id, OWNER); + + expect(download.mimeType).toBe('image/png'); + expect(canRead).not.toHaveBeenCalled(); + }); + + it('남의 파일은 등록된 규칙이 허락해야 내려받는다', async () => { + files.findOneBy.mockResolvedValue(file); + const deny = jest.fn().mockResolvedValue(false); + const allow = jest.fn().mockResolvedValue(true); + service.registerAccessPolicy({ canRead: deny }); + service.registerAccessPolicy({ canRead: allow }); + + await expect(service.download(file.id, OTHER)).resolves.toBeDefined(); + expect(deny).toHaveBeenCalledWith(OTHER, file.id); + expect(allow).toHaveBeenCalledWith(OTHER, file.id); + }); + + it('허락하는 규칙이 없으면 없는 파일과 똑같이 RES_001', async () => { + files.findOneBy.mockResolvedValue(file); + service.registerAccessPolicy({ + canRead: jest.fn().mockResolvedValue(false), + }); + + await expectCode(service.download(file.id, OTHER), 'RES_001'); + }); + + it('없는 파일은 RES_001 — 규칙을 묻지 않는다', async () => { + files.findOneBy.mockResolvedValue(null); + const canRead = jest.fn(); + service.registerAccessPolicy({ canRead }); + + await expectCode(service.download('missing', OTHER), 'RES_001'); + expect(canRead).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/upload/upload.service.ts b/backend/src/modules/upload/upload.service.ts index 7f1f347..b3ad95a 100644 --- a/backend/src/modules/upload/upload.service.ts +++ b/backend/src/modules/upload/upload.service.ts @@ -10,6 +10,7 @@ import { Repository } from 'typeorm'; import { BusinessException } from '@/common/errors/business.exception'; import { isForeignKeyViolation } from '@/shared/utils/db-error'; import { StoredFile } from './entities/stored-file.entity'; +import type { FileAccessPolicy } from './file-access-policy'; import { SIGNATURE_BYTES, UPLOAD_DIR, @@ -31,6 +32,7 @@ export interface FileDownload { @Injectable() export class UploadService { private readonly logger = new Logger(UploadService.name); + private readonly accessPolicies: FileAccessPolicy[] = []; constructor( @InjectRepository(StoredFile) @@ -65,15 +67,31 @@ export class UploadService { return UploadResponseDto.from(saved); } - /** 내 파일을 내려받는다 — 남의 파일이나 없는 파일은 똑같이 RES_001 */ - async download(fileId: string, ownerId: string): Promise { - const file = await this.files.findOneBy({ id: fileId, ownerId }); - if (!file) { + /** 남의 파일을 읽어도 되는 경우를 등록한다 (file-access-policy.ts) */ + registerAccessPolicy(policy: FileAccessPolicy): void { + this.accessPolicies.push(policy); + } + + /** + * 파일을 내려받는다 — 내 파일이거나, 등록된 규칙이 허락한 남의 파일(예: 함께 쓰는 다이어리의 사진)만. + * 볼 수 없는 파일과 없는 파일은 똑같이 RES_001 + */ + async download(fileId: string, userId: string): Promise { + const file = await this.files.findOneBy({ id: fileId }); + if (!file || !(await this.canRead(file, userId))) { throw new BusinessException('RES_001', { context: { fileId } }); } return this.toDownload(file); } + private async canRead(file: StoredFile, userId: string): Promise { + if (file.ownerId === userId) return true; + for (const policy of this.accessPolicies) { + if (await policy.canRead(userId, file.id)) return true; + } + return false; + } + /** * 소유자를 따지지 않고 내려받는다 — 호출한 쪽이 볼 권한을 이미 확인한 경우만 쓴다 * (예: 명함 코드를 아는 사람에게 그 명함의 사진을 보여 줄 때). diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 4872f8c..10669fc 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -1,14 +1,9 @@ -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import type { Response } from 'supertest'; import { App } from 'supertest/types'; -import { AppModule } from '@/app.module'; -import { HttpExceptionFilter } from '@/common/filters/http-exception.filter'; -import { TransformInterceptor } from '@/common/interceptors/transform.interceptor'; -import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware'; +import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app'; /** * 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다. @@ -35,7 +30,7 @@ interface ContractBody { const body = (res: Response): ContractBody => res.body as ContractBody; /** 매 실행마다 새 회원으로 시작한다 */ -const E2E_DEVICE_KEY = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +const E2E_DEVICE_KEY = uniqueDeviceKey(); describe('응답 계약 (e2e)', () => { let app: INestApplication; @@ -43,34 +38,10 @@ describe('응답 계약 (e2e)', () => { const auth = () => ({ Authorization: `Bearer ${accessToken}` }); beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - - // main.ts 와 동일한 전역 설정을 적용해야 실제 응답과 같아진다 - app.setGlobalPrefix('api'); - app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware())); - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ); - app.useGlobalFilters(new HttpExceptionFilter()); - app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); - - await app.init(); - + // main.ts 와 동일한 전역 설정을 적용한 앱 (support/e2e-app.ts) + app = await createE2eApp(); // 개발용 로그인으로 테스트 회원을 만든다 (NODE_ENV=test 에서는 열려 있다) - const login = await request(app.getHttpServer()) - .post('/api/auth/dev-sessions') - .send({ provider: 'kakao', deviceKey: E2E_DEVICE_KEY }) - .expect(201); - accessToken = (login.body as { data: { accessToken: string } }).data - .accessToken; + accessToken = await devLogin(app, E2E_DEVICE_KEY); }); afterAll(async () => { diff --git a/backend/test/diary-sharing.e2e-spec.ts b/backend/test/diary-sharing.e2e-spec.ts new file mode 100644 index 0000000..f764a38 --- /dev/null +++ b/backend/test/diary-sharing.e2e-spec.ts @@ -0,0 +1,529 @@ +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import type { Response } from 'supertest'; +import { App } from 'supertest/types'; + +import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app'; + +/** + * 공유 다이어리 — 초대 코드로 참여하고, 함께 기록·댓글을 남기고, 내보내기·나가기·공유 끄기까지. + * 회원 여럿을 개발용 로그인으로 만들고 끝나면 모두 탈퇴한다. + */ + +/** 1×1 PNG — 업로드가 매직 바이트를 확인하므로 진짜 PNG 여야 한다 */ +const TINY_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', +); + +interface Member { + userId: string; + nickname: string | null; + isOwner: boolean; +} + +interface DiaryBody { + id: string; + shared: boolean; + ownerId: string; + isOwner: boolean; + inviteCode: string | null; + mutedNotices: string[]; + members: Member[]; + targets: { id: string; name: string | null }[]; +} + +interface Author { + userId: string; + nickname: string | null; +} + +interface Page { + items: T[]; +} + +/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */ +const data = (res: Response): T => (res.body as { data: T }).data; +const errorCode = (res: Response): string | undefined => + (res.body as { error?: { code?: string } }).error?.code; + +describe('공유 다이어리 (e2e)', () => { + let app: INestApplication; + const tokens: Record = {}; + const ids: Record = {}; + const people = ['owner', 'member', 'outsider', 'third', 'fifth'] as const; + type Person = (typeof people)[number]; + + const as = (who: Person) => ({ Authorization: `Bearer ${tokens[who]}` }); + const http = () => request(app.getHttpServer()); + + let diaryId = ''; + let inviteCode = ''; + let coverFileId = ''; + let ownerPhotoId = ''; + let petId = ''; + let petPhotoId = ''; + let postId = ''; + + const uploadPng = async (who: Person): Promise => { + const res = await http() + .post('/api/files') + .set(as(who)) + .attach('file', TINY_PNG, { + filename: 'photo.png', + contentType: 'image/png', + }) + .expect(201); + return data<{ id: string }>(res).id; + }; + const fileStatus = async (who: Person, fileId: string): Promise => + (await http().get(`/api/files/${fileId}`).set(as(who))).status; + + /** + * 누가 빠지면 초대 코드가 바뀐다 — 빠진 사람이 예전 코드로 다시 들어오면 DIARY_003, 주인은 새 코드를 본다. + * 뒤의 테스트를 위해 새 코드를 기억해 둔다 + */ + const expectCodeRotated = async (leaver: Person): Promise => { + const previous = inviteCode; + const stale = await http() + .post('/api/diary-memberships') + .set(as(leaver)) + .send({ code: previous }) + .expect(404); + expect(errorCode(stale)).toBe('DIARY_003'); + + const list = await http().get('/api/diaries').set(as('owner')).expect(200); + const current = data>(list).items.find( + (d) => d.id === diaryId, + )?.inviteCode; + expect(current).toMatch(/^RUP-[A-Z2-9]{6}$/); + expect(current).not.toBe(previous); + inviteCode = current ?? ''; + }; + + beforeAll(async () => { + app = await createE2eApp(); + for (const who of people) { + tokens[who] = await devLogin(app, uniqueDeviceKey(who)); + const me = await http() + .patch('/api/users/me') + .set(as(who)) + .send({ nickname: who }) + .expect(200); + ids[who] = data<{ id: string }>(me).id; + } + }); + + afterAll(async () => { + for (const who of people) { + await http().delete('/api/users/me').set(as(who)); + } + await app.close(); + }); + + it('주인이 공유 다이어리를 만들면 초대 코드가 생긴다', async () => { + coverFileId = await uploadPng('owner'); + ownerPhotoId = await uploadPng('owner'); + petPhotoId = await uploadPng('owner'); + await http() + .patch('/api/users/me') + .set(as('owner')) + .send({ photoFileId: ownerPhotoId }) + .expect(200); + const pet = await http() + .post('/api/pets') + .set(as('owner')) + .send({ + tint: '#FF9B6A', + name: '콩이', + species: '강아지', + breed: '', + gender: 'male', + neutered: true, + birthDate: '2021-09-14', + weight: 3.2, + weightUnit: 'kg', + allergies: [], + conditions: [], + watchConditions: [], + photoFileId: petPhotoId, + }) + .expect(201); + petId = data<{ id: string }>(pet).id; + + const res = await http() + .post('/api/diaries') + .set(as('owner')) + .send({ + name: '함께 쓰는 다이어리', + targetIds: ['self', petId], + ribbon: '#FF7500', + tint: '#FF8A5B', + coverFileId, + shared: true, + }) + .expect(201); + const diary = data(res); + diaryId = diary.id; + inviteCode = diary.inviteCode ?? ''; + + expect(inviteCode).toMatch(/^RUP-[A-Z2-9]{6}$/); + expect(diary.shared).toBe(true); + expect(diary.isOwner).toBe(true); + expect(diary.ownerId).toBe(ids.owner); + expect(diary.members).toEqual([ + expect.objectContaining({ userId: ids.owner, isOwner: true }), + ]); + expect(diary.targets).toEqual([ + { + id: 'self', + name: 'owner', + photoFileId: ownerPhotoId, + tint: null, + archived: false, + }, + { + id: petId, + name: '콩이', + photoFileId: petPhotoId, + tint: '#FF9B6A', + archived: false, + }, + ]); + }); + + it('코드를 받은 사람은 참여 전에 미리 본다 (대소문자 무관)', async () => { + const res = await http() + .get(`/api/diary-invitations/${inviteCode.toLowerCase()}`) + .set(as('member')) + .expect(200); + + expect(data(res)).toEqual({ + diaryId, + name: '함께 쓰는 다이어리', + ownerNickname: 'owner', + memberCount: 1, + maxMembers: 4, + isMember: false, + }); + }); + + it('참여하면 목록에 보이고, 주인이 아니므로 초대 코드는 없다', async () => { + const joined = await http() + .post('/api/diary-memberships') + .set(as('member')) + .send({ code: inviteCode }) + .expect(201); + expect(data(joined).isOwner).toBe(false); + + const list = await http().get('/api/diaries').set(as('member')).expect(200); + const diary = data>(list).items.find( + (d) => d.id === diaryId, + ); + + expect(diary?.isOwner).toBe(false); + expect(diary?.inviteCode).toBeNull(); + expect(diary?.members.map((m) => m.userId)).toEqual([ + ids.owner, + ids.member, + ]); + }); + + it('이미 함께하고 있으면 DIARY_005 (주인 포함)', async () => { + for (const who of ['member', 'owner'] as const) { + const res = await http() + .post('/api/diary-memberships') + .set(as(who)) + .send({ code: inviteCode }) + .expect(409); + expect(errorCode(res)).toBe('DIARY_005'); + } + }); + + it('주인 포함 4명이 차면 DIARY_004', async () => { + for (const who of ['outsider', 'third'] as const) { + await http() + .post('/api/diary-memberships') + .set(as(who)) + .send({ code: inviteCode }) + .expect(201); + } + const full = await http() + .post('/api/diary-memberships') + .set(as('fifth')) + .send({ code: inviteCode }) + .expect(409); + expect(errorCode(full)).toBe('DIARY_004'); + + // 뒤의 테스트를 위해 둘은 다시 나간다 — outsider 는 바깥 사람으로 쓴다 + for (const who of ['outsider', 'third'] as const) { + await http() + .delete(`/api/diaries/${diaryId}/membership`) + .set(as(who)) + .expect(204); + await expectCodeRotated(who); + } + }); + + it('함께하는 사람이 남긴 기록·댓글을 주인이 쓴 사람과 함께 본다', async () => { + const post = await http() + .post(`/api/diaries/${diaryId}/posts`) + .set(as('member')) + .send({ date: '2026-10-11', text: '오늘 산책했어요' }) + .expect(201); + postId = data<{ id: string }>(post).id; + expect(data<{ author: Author }>(post).author).toEqual({ + userId: ids.member, + nickname: 'member', + photoFileId: null, + }); + + await http() + .post(`/api/diary-posts/${postId}/comments`) + .set(as('member')) + .send({ text: '귀여워' }) + .expect(201); + + const posts = await http() + .get('/api/diary-posts') + .set(as('owner')) + .expect(200); + const seen = data>(posts).items.find( + (p) => p.id === postId, + ); + expect(seen?.author.nickname).toBe('member'); + + const comments = await http() + .get('/api/diary-comments') + .set(as('owner')) + .expect(200); + expect( + data>(comments).items, + ).toContainEqual( + expect.objectContaining({ + postId, + author: expect.objectContaining({ nickname: 'member' }) as Author, + }), + ); + }); + + it('함께하는 사람은 표지 사진을 받고, 바깥 사람은 RES_001', async () => { + const ok = await http() + .get(`/api/files/${coverFileId}`) + .set(as('member')) + .expect(200); + expect(ok.headers['content-type']).toContain('image/png'); + + const denied = await http() + .get(`/api/files/${coverFileId}`) + .set(as('outsider')) + .expect(404); + expect(errorCode(denied)).toBe('RES_001'); + }); + + it('다이어리에서 보이는 사진(프로필·대상 아이·기록 사진)은 함께하는 사람끼리만 받는다', async () => { + const memberPhotoId = await uploadPng('member'); + const postPhotoId = await uploadPng('member'); + const unrelatedId = await uploadPng('owner'); + await http() + .patch('/api/users/me') + .set(as('member')) + .send({ photoFileId: memberPhotoId }) + .expect(200); + await http() + .post(`/api/diaries/${diaryId}/posts`) + .set(as('member')) + .send({ date: '2026-10-10', text: '사진도', photoFileId: postPhotoId }) + .expect(201); + + // 함께하는 사람: 주인 프로필·대상 아이 사진을 받는다 + expect(await fileStatus('member', ownerPhotoId)).toBe(200); + expect(await fileStatus('member', petPhotoId)).toBe(200); + // 주인: 함께하는 사람의 프로필·기록 사진을 받는다 + expect(await fileStatus('owner', memberPhotoId)).toBe(200); + expect(await fileStatus('owner', postPhotoId)).toBe(200); + // 다이어리에 보이지 않는 주인의 다른 파일은 받지 못한다 + expect(await fileStatus('member', unrelatedId)).toBe(404); + // 바깥 사람은 어느 것도 받지 못한다 + for (const id of [ownerPhotoId, petPhotoId, memberPhotoId, postPhotoId]) { + expect(await fileStatus('outsider', id)).toBe(404); + } + }); + + it('바깥 사람에게는 다이어리가 없는 것과 같다 (RES_001)', async () => { + const res = await http() + .post(`/api/diaries/${diaryId}/posts`) + .set(as('outsider')) + .send({ date: '2026-10-11', text: '몰래' }) + .expect(404); + expect(errorCode(res)).toBe('RES_001'); + }); + + it('주인만 할 수 있는 일은 함께하는 사람에게 AUTH_003', async () => { + const patch = await http() + .patch(`/api/diaries/${diaryId}`) + .set(as('member')) + .send({ name: '바꿔 볼까' }) + .expect(403); + expect(errorCode(patch)).toBe('AUTH_003'); + + const sharing = await http() + .put(`/api/diaries/${diaryId}/sharing`) + .set(as('member')) + .send({ shared: false }) + .expect(403); + expect(errorCode(sharing)).toBe('AUTH_003'); + }); + + it('알림 설정은 사람마다 따로다', async () => { + await http() + .put(`/api/diaries/${diaryId}/muted-notices`) + .set(as('member')) + .send({ mutedNotices: ['reminder'] }) + .expect(204); + + const mine = await http().get('/api/diaries').set(as('member')).expect(200); + const owners = await http() + .get('/api/diaries') + .set(as('owner')) + .expect(200); + const pick = (res: Response) => + data>(res).items.find((d) => d.id === diaryId); + + expect(pick(mine)?.mutedNotices).toEqual(['reminder']); + expect(pick(owners)?.mutedNotices).toEqual([]); + }); + + it('주인은 나갈 수도, 자신을 내보낼 수도 없다 (BIZ_001)', async () => { + const leave = await http() + .delete(`/api/diaries/${diaryId}/membership`) + .set(as('owner')) + .expect(400); + expect(errorCode(leave)).toBe('BIZ_001'); + + const kickSelf = await http() + .delete(`/api/diaries/${diaryId}/members/${ids.owner}`) + .set(as('owner')) + .expect(400); + expect(errorCode(kickSelf)).toBe('BIZ_001'); + }); + + it('내보내진 사람은 다이어리에 닿지 못하고 예전 코드로 돌아오지 못한다 (기록은 남는다)', async () => { + await http() + .delete(`/api/diaries/${diaryId}/members/${ids.member}`) + .set(as('owner')) + .expect(204); + await expectCodeRotated('member'); + + const write = await http() + .post(`/api/diaries/${diaryId}/posts`) + .set(as('member')) + .send({ date: '2026-10-12', text: '다시' }) + .expect(404); + expect(errorCode(write)).toBe('RES_001'); + + const edit = await http() + .patch(`/api/diary-posts/${postId}`) + .set(as('member')) + .send({ text: '고칠래' }) + .expect(404); + expect(errorCode(edit)).toBe('RES_001'); + + const cover = await http() + .get(`/api/files/${coverFileId}`) + .set(as('member')) + .expect(404); + expect(errorCode(cover)).toBe('RES_001'); + + const posts = await http() + .get('/api/diary-posts') + .set(as('owner')) + .expect(200); + expect(data>(posts).items.map((p) => p.id)).toContain( + postId, + ); + + // 이미 빠진 사람을 다시 내보내면 RES_001 — 실패했으므로 코드도 그대로다 + const again = await http() + .delete(`/api/diaries/${diaryId}/members/${ids.member}`) + .set(as('owner')) + .expect(404); + expect(errorCode(again)).toBe('RES_001'); + await http() + .get(`/api/diary-invitations/${inviteCode}`) + .set(as('member')) + .expect(200); + }); + + it('공유를 끄면 주인 말고는 모두 빠지고 예전 코드는 쓸 수 없다', async () => { + await http() + .post('/api/diary-memberships') + .set(as('member')) + .send({ code: inviteCode }) + .expect(201); + + const off = await http() + .put(`/api/diaries/${diaryId}/sharing`) + .set(as('owner')) + .send({ shared: false }) + .expect(200); + const diary = data(off); + expect(diary.shared).toBe(false); + expect(diary.inviteCode).toBeNull(); + expect(diary.members.map((m) => m.userId)).toEqual([ids.owner]); + + const list = await http().get('/api/diaries').set(as('member')).expect(200); + expect(data>(list).items.map((d) => d.id)).not.toContain( + diaryId, + ); + + const preview = await http() + .get(`/api/diary-invitations/${inviteCode}`) + .set(as('member')) + .expect(404); + expect(errorCode(preview)).toBe('DIARY_003'); + }); + + it('다시 켜면 새 코드가 생기고, 참여한 사람은 스스로 나갈 수 있다', async () => { + const on = await http() + .put(`/api/diaries/${diaryId}/sharing`) + .set(as('owner')) + .send({ shared: true }) + .expect(200); + const fresh = data(on).inviteCode ?? ''; + expect(fresh).toMatch(/^RUP-[A-Z2-9]{6}$/); + expect(fresh).not.toBe(inviteCode); + inviteCode = fresh; + + await http() + .post('/api/diary-memberships') + .set(as('member')) + .send({ code: fresh }) + .expect(201); + await http() + .delete(`/api/diaries/${diaryId}/membership`) + .set(as('member')) + .expect(204); + await expectCodeRotated('member'); + + const list = await http().get('/api/diaries').set(as('member')).expect(200); + expect(data>(list).items.map((d) => d.id)).not.toContain( + diaryId, + ); + }); + + it('없는 코드는 DIARY_003', async () => { + const preview = await http() + .get('/api/diary-invitations/RUP-ZZZZZZ') + .set(as('member')) + .expect(404); + expect(errorCode(preview)).toBe('DIARY_003'); + + const join = await http() + .post('/api/diary-memberships') + .set(as('member')) + .send({ code: '엉뚱한 코드' }) + .expect(404); + expect(errorCode(join)).toBe('DIARY_003'); + }); +}); diff --git a/backend/test/support/e2e-app.ts b/backend/test/support/e2e-app.ts new file mode 100644 index 0000000..73bba6f --- /dev/null +++ b/backend/test/support/e2e-app.ts @@ -0,0 +1,55 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { App } from 'supertest/types'; + +import { AppModule } from '@/app.module'; +import { HttpExceptionFilter } from '@/common/filters/http-exception.filter'; +import { TransformInterceptor } from '@/common/interceptors/transform.interceptor'; +import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware'; + +/** + * e2e 용 앱 — main.ts 와 동일한 전역 설정을 적용해야 실제 응답과 같아진다. + * + * ⚠️ DB 연결이 필요하다 (AppModule 이 TypeORM 을 초기화한다). + * 로컬: docker 개발 스택을 띄운 뒤 실행. CI: postgres 서비스 컨테이너 사용. + */ +export async function createE2eApp(): Promise> { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + const app = moduleFixture.createNestApplication>(); + app.setGlobalPrefix('api'); + app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware())); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); + + await app.init(); + return app; +} + +/** 매 실행마다 새 회원으로 시작한다 — [label] 은 한 테스트에서 회원을 여럿 만들 때 구분용 */ +export function uniqueDeviceKey(label = 'user'): string { + return `e2e-${label}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** 개발용 로그인으로 회원을 만들고 액세스 토큰을 받는다 (NODE_ENV=test 에서는 열려 있다) */ +export async function devLogin( + app: INestApplication, + deviceKey: string, +): Promise { + const login = await request(app.getHttpServer()) + .post('/api/auth/dev-sessions') + .send({ provider: 'kakao', deviceKey }) + .expect(201); + return (login.body as { data: { accessToken: string } }).data.accessToken; +} diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 89a7d90..8b3eddd 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -13,6 +13,9 @@ const Map kErrorCodeMessages = { 'FILE_002': '파일 크기가 허용 범위를 초과했습니다.', 'DIARY_001': '다이어리는 최대 6권까지 만들 수 있어요.', 'DIARY_002': '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.', + 'DIARY_003': '초대 코드를 찾을 수 없어요. 코드를 다시 확인해 주세요.', + 'DIARY_004': '참여 인원이 가득 찼어요. (최대 4명)', + 'DIARY_005': '이미 참여하고 있는 다이어리예요.', 'CARD_001': '펫 명함은 최대 10장까지 만들 수 있어요.', 'CARD_002': '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.', 'CARD_003': '내 명함은 받을 수 없어요.', diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 827eb7b..1ae54c1 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -13,6 +13,9 @@ export const ERROR_CODE_MESSAGES = { FILE_002: '파일 크기가 허용 범위를 초과했습니다.', DIARY_001: '다이어리는 최대 6권까지 만들 수 있어요.', DIARY_002: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.', + DIARY_003: '초대 코드를 찾을 수 없어요. 코드를 다시 확인해 주세요.', + DIARY_004: '참여 인원이 가득 찼어요. (최대 4명)', + DIARY_005: '이미 참여하고 있는 다이어리예요.', CARD_001: '펫 명함은 최대 10장까지 만들 수 있어요.', CARD_002: '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.', CARD_003: '내 명함은 받을 수 없어요.',