generated from ttipo/project-base
feat(backend): 공유 다이어리 API — 초대 코드로 함께 쓰기
다이어리를 가족·지인과 함께 쓸 수 있게 한다. - diary_members 로 함께 쓰는 사람을 두고(주인 포함 최대 4명), 알림 설정을 사람별로 옮긴다 - 초대 코드(RUP-6자) 미리보기·참여, 개인↔공유 전환, 내보내기·나가기 — 내보내거나 나가면 코드를 바꾼다 - 목록·쓰기는 멤버, 수정·삭제는 쓴 사람, 다이어리 정보·삭제는 주인만 - 응답에 멤버·대상 요약·작성자를 담는다 - 파일 열람 정책 — 함께 쓰는 다이어리의 표지·기록 사진·아이 사진·멤버 프로필 사진은 멤버도 받을 수 있다 - 에러 코드 DIARY_003(코드 없음)·004(가득 참)·005(이미 참여) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// 알림 설정을 다이어리로 되돌린다 — 주인의 설정만 남는다 (참여한 사람의 설정·참여 기록은 사라진다)
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -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<DiaryResponseDto> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.diary.removeMember(user.userId, id, memberId);
|
||||
}
|
||||
|
||||
@Post(':id/posts')
|
||||
@ApiOperation({ summary: '기록 남기기 (하루 한 번)' })
|
||||
@ApiOperation({ summary: '기록 남기기 (함께하는 사람 누구나, 하루 한 번)' })
|
||||
@ApiStandardResponse(DiaryPostResponseDto, {
|
||||
status: 201,
|
||||
description: '남긴 기록',
|
||||
|
||||
@@ -42,7 +42,7 @@ export class DiaryCommentsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '내 다이어리들의 댓글 목록 (쓴 순, 커서 페이지네이션)',
|
||||
summary: '내가 함께하는 다이어리들의 댓글 목록 (쓴 순, 커서 페이지네이션)',
|
||||
})
|
||||
@ApiPaginatedResponse(DiaryCommentResponseDto, '댓글 목록')
|
||||
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
|
||||
|
||||
@@ -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<boolean> {
|
||||
const rows = await this.dataSource.query<{ allowed: boolean }[]>(
|
||||
MEMBER_CAN_READ_FILE,
|
||||
[userId, fileId],
|
||||
);
|
||||
return rows[0]?.allowed === true;
|
||||
}
|
||||
}
|
||||
@@ -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<DiaryInvitationDto> {
|
||||
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<DiaryResponseDto> {
|
||||
return this.diary.joinDiary(user.userId, dto);
|
||||
}
|
||||
}
|
||||
@@ -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: '쓴 댓글',
|
||||
|
||||
@@ -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> = {}): 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, TargetPetSource>,
|
||||
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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 = <T extends { createdAt: Date; id: string }>(
|
||||
alias: string,
|
||||
@@ -45,16 +65,50 @@ const createdAtKeys = <T extends { createdAt: Date; id: string }>(
|
||||
{ column: `${alias}.id`, cast: 'uuid', read: (row: T) => row.id },
|
||||
];
|
||||
|
||||
// 다이어리 — 다이어리·기록·댓글. 지금은 주인만 읽고 쓴다 (공유 다이어리는 다음 단계)
|
||||
/** [diaryColumn] 의 다이어리에 [userId] 가 함께하는 행만 남긴다 */
|
||||
function onlyMemberOf<T extends ObjectLiteral>(
|
||||
qb: SelectQueryBuilder<T>,
|
||||
diaryColumn: string,
|
||||
userId: string,
|
||||
): SelectQueryBuilder<T> {
|
||||
return qb.innerJoin(
|
||||
DiaryMember,
|
||||
'member',
|
||||
`member.diary_id = ${diaryColumn} AND member.user_id = :memberUserId`,
|
||||
{ memberUserId: userId },
|
||||
);
|
||||
}
|
||||
|
||||
/** 초대 코드가 겹치면(유니크 위반) 새 코드로 처음부터 다시 한다 */
|
||||
async function withFreshInviteCode<T>(run: () => Promise<T>): Promise<T> {
|
||||
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<Diary>,
|
||||
@InjectRepository(DiaryMember)
|
||||
private readonly members: Repository<DiaryMember>,
|
||||
@InjectRepository(DiaryPost)
|
||||
private readonly posts: Repository<DiaryPost>,
|
||||
@InjectRepository(DiaryComment)
|
||||
private readonly comments: Repository<DiaryComment>,
|
||||
@InjectRepository(User)
|
||||
private readonly users: Repository<User>,
|
||||
@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<PaginatedResult<DiaryResponseDto>> {
|
||||
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>('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<DiaryResponseDto> {
|
||||
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<DiaryResponseDto> {
|
||||
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<void> {
|
||||
const diary = await this.findOwnedDiary(ownerId, diaryId);
|
||||
/**
|
||||
* 다이어리를 지우면 그 안의 기록·댓글과 함께하는 사람도 모두 지운다 (FK) — 쓰지 않게 된 사진도 정리한다.
|
||||
* 주인만 (함께하는 사람은 AUTH_003 — 나가기를 쓴다)
|
||||
*/
|
||||
async deleteDiary(userId: string, diaryId: string): Promise<void> {
|
||||
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<DiaryResponseDto> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<DiaryInvitationDto> {
|
||||
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<DiaryResponseDto> {
|
||||
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<PaginatedResult<DiaryPostResponseDto>> {
|
||||
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<DiaryPost>('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<DiaryPostResponseDto> {
|
||||
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<PaginatedResult<DiaryCommentResponseDto>> {
|
||||
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<DiaryComment>('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<void> {
|
||||
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<DiaryResponseDto> {
|
||||
const [response] = await this.toResponses(viewerId, [diary]);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** 다이어리 응답 — 함께하는 사람과 대상 아이를 한 번에 읽어 붙인다 */
|
||||
private async toResponses(
|
||||
viewerId: string,
|
||||
diaries: readonly Diary[],
|
||||
): Promise<DiaryResponseDto[]> {
|
||||
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<Diary> {
|
||||
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<Diary> {
|
||||
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<DiaryPost> {
|
||||
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<DiaryComment> {
|
||||
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 } });
|
||||
}
|
||||
|
||||
@@ -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<string, TargetPetSource>;
|
||||
}
|
||||
|
||||
/** 다이어리 응답 (앱의 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 내 것이 아닌 파일을 읽어도 되는지 정하는 규칙 — 함께 쓰는 기능(공유 다이어리 등)이 구현한다.
|
||||
* 각 모듈이 onModuleInit 에서 UploadService.registerAccessPolicy 로 등록한다 —
|
||||
* 업로드 모듈이 다른 도메인을 알지 않아도 된다(의존 방향: 다른 도메인 → 업로드).
|
||||
*/
|
||||
export interface FileAccessPolicy {
|
||||
/** [userId] 가 남의 파일 [fileId] 를 내려받아도 되면 true. 모르는 파일이면 false */
|
||||
canRead(userId: string, fileId: string): Promise<boolean>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof import('fs')>('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<StoredFile>);
|
||||
});
|
||||
|
||||
const expectCode = async (promise: Promise<unknown>, 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();
|
||||
});
|
||||
});
|
||||
@@ -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<FileDownload> {
|
||||
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<FileDownload> {
|
||||
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<boolean> {
|
||||
if (file.ownerId === userId) return true;
|
||||
for (const policy of this.accessPolicies) {
|
||||
if (await policy.canRead(userId, file.id)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자를 따지지 않고 내려받는다 — 호출한 쪽이 볼 권한을 이미 확인한 경우만 쓴다
|
||||
* (예: 명함 코드를 아는 사람에게 그 명함의 사진을 보여 줄 때).
|
||||
|
||||
Reference in New Issue
Block a user