generated from ttipo/project-base
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 688e72bf04 | |||
| 7701be3795 | |||
| f045e1599f |
@@ -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,11 +83,36 @@ 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,
|
||||
message: '펫 명함은 최대 10장까지 만들 수 있어요.',
|
||||
},
|
||||
// 명함 받기 — 코드에 맞는 명함이 없다
|
||||
CARD_002: {
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
message: '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.',
|
||||
},
|
||||
// 명함 받기 — 내 명함은 받을 수 없다
|
||||
CARD_003: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '내 명함은 받을 수 없어요.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
export const ERROR_CODES = {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddReceivedCards1791714286898 implements MigrationInterface {
|
||||
name = 'AddReceivedCards1791714286898';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "received_cards" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "source_card_id" uuid, "code" character varying(8) NOT NULL, "mode" character varying(6) NOT NULL, "sender_nickname" character varying(20), "sender_photo_file_id" uuid, "pet" jsonb NOT NULL, "pet_photo_file_id" uuid, "style" jsonb NOT NULL, "background_file_id" uuid, "note" character varying(200), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_43872dae929b5dd22d6b81e744d" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_3b8508528d21833ebec7c3e7b1" ON "received_cards" ("owner_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "pet_cards" ADD "share_mode" character varying(6) NOT NULL DEFAULT 'simple'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_3b8508528d21833ebec7c3e7b1b" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_65e8b70115d2de13ce0380b8ade" FOREIGN KEY ("source_card_id") REFERENCES "pet_cards"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_ea06e251a9ed53e63e66ef5bb65" FOREIGN KEY ("sender_photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_21733fe1b0dfce3e27726b322e6" FOREIGN KEY ("pet_photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_549a5fe176831cb7cebb29f9e22" FOREIGN KEY ("background_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_549a5fe176831cb7cebb29f9e22"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_21733fe1b0dfce3e27726b322e6"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_ea06e251a9ed53e63e66ef5bb65"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_65e8b70115d2de13ce0380b8ade"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_3b8508528d21833ebec7c3e7b1b"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "pet_cards" DROP COLUMN "share_mode"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_3b8508528d21833ebec7c3e7b1"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "received_cards"`);
|
||||
}
|
||||
}
|
||||
@@ -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,51 @@
|
||||
import type { Pet } from '@/modules/pet/entities/pet.entity';
|
||||
import { petSnapshot } from './card-snapshot';
|
||||
|
||||
const pet = {
|
||||
name: '콩이',
|
||||
species: '강아지',
|
||||
breed: '말티즈',
|
||||
gender: 'male',
|
||||
birthDate: '2021-09-14',
|
||||
weight: 3.2,
|
||||
weightUnit: 'kg',
|
||||
microchip: '410123456789012',
|
||||
tint: '#FF9B6A',
|
||||
neutered: true,
|
||||
bloodType: 'DEA 1.1',
|
||||
vet: '드림 동물병원',
|
||||
memo: '간식 좋아함',
|
||||
allergies: ['닭고기'],
|
||||
conditions: [],
|
||||
watchConditions: ['슬개골'],
|
||||
} as unknown as Pet;
|
||||
|
||||
describe('petSnapshot', () => {
|
||||
it('간략히로 공유하면 건강 정보·병원·메모를 넘기지 않는다', () => {
|
||||
const snapshot = petSnapshot(pet, 'simple');
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
name: '콩이',
|
||||
species: '강아지',
|
||||
breed: '말티즈',
|
||||
gender: 'male',
|
||||
birthDate: '2021-09-14',
|
||||
weight: 3.2,
|
||||
weightUnit: 'kg',
|
||||
microchip: '410123456789012',
|
||||
tint: '#FF9B6A',
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toContain('드림 동물병원');
|
||||
});
|
||||
|
||||
it('상세히로 공유하면 상세 명함의 정보까지 넘긴다', () => {
|
||||
expect(petSnapshot(pet, 'detail')).toMatchObject({
|
||||
neutered: true,
|
||||
bloodType: 'DEA 1.1',
|
||||
vet: '드림 동물병원',
|
||||
memo: '간식 좋아함',
|
||||
allergies: ['닭고기'],
|
||||
watchConditions: ['슬개골'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// 공유할 명함 정보 만들기 — 부수효과 없는 순수 함수
|
||||
|
||||
import type { Pet } from '@/modules/pet/entities/pet.entity';
|
||||
import type { CardShareMode, PetCard } from './entities/pet-card.entity';
|
||||
import type {
|
||||
SharedCardStyle,
|
||||
SharedPetSnapshot,
|
||||
} from './entities/received-card.entity';
|
||||
|
||||
/**
|
||||
* 공유한 모양에 맞춰 아이 정보를 잘라 낸다.
|
||||
* 간략히 명함에 보이지 않는 정보(건강·병원·메모)는 상세히로 공유했을 때만 넘긴다 — 받는 사람에게 더 보여 주지 않는다.
|
||||
*/
|
||||
export function petSnapshot(pet: Pet, mode: CardShareMode): SharedPetSnapshot {
|
||||
const basics: SharedPetSnapshot = {
|
||||
name: pet.name,
|
||||
species: pet.species,
|
||||
breed: pet.breed,
|
||||
gender: pet.gender,
|
||||
birthDate: pet.birthDate,
|
||||
weight: pet.weight,
|
||||
weightUnit: pet.weightUnit,
|
||||
microchip: pet.microchip,
|
||||
tint: pet.tint,
|
||||
};
|
||||
if (mode === 'simple') return basics;
|
||||
|
||||
return {
|
||||
...basics,
|
||||
neutered: pet.neutered,
|
||||
bloodType: pet.bloodType,
|
||||
vet: pet.vet,
|
||||
memo: pet.memo,
|
||||
allergies: pet.allergies,
|
||||
conditions: pet.conditions,
|
||||
watchConditions: pet.watchConditions,
|
||||
};
|
||||
}
|
||||
|
||||
/** 명함의 꾸미기 값 — 배경 사진은 따로 다룬다 */
|
||||
export function cardStyleSnapshot(card: PetCard): SharedCardStyle {
|
||||
return {
|
||||
tint: card.tint,
|
||||
tint2: card.tint2,
|
||||
nameBg: card.nameBg,
|
||||
nameBgAsset: card.nameBgAsset,
|
||||
textLight: card.textLight,
|
||||
scrim: card.scrim,
|
||||
scrimTone: card.scrimTone,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CARD_SHARE_MODES, SCRIM_TONES } from '../entities/pet-card.entity';
|
||||
import type { CardShareMode, ScrimTone } from '../entities/pet-card.entity';
|
||||
import { RECEIVED_NOTE_MAX_LENGTH } from '../entities/received-card.entity';
|
||||
import type {
|
||||
ReceivedCard,
|
||||
SharedCardStyle,
|
||||
SharedPetSnapshot,
|
||||
} from '../entities/received-card.entity';
|
||||
|
||||
/** 명함 코드 "0000-000" */
|
||||
export const CARD_CODE_PATTERN = /^\d{4}-\d{3}$/;
|
||||
|
||||
/** 공유한 모양 정하기 — 공유 시트를 열 때 앱이 보낸다 */
|
||||
export class ShareModeDto {
|
||||
@ApiProperty({ enum: CARD_SHARE_MODES, example: 'detail' })
|
||||
@IsIn(CARD_SHARE_MODES, { message: '공유 모양이 올바르지 않습니다.' })
|
||||
mode!: CardShareMode;
|
||||
}
|
||||
|
||||
/** 명함 받기 — 코드와 나의 메모 */
|
||||
export class ReceiveCardDto {
|
||||
@ApiProperty({ description: '명함 코드 (0000-000)', example: '0428-117' })
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
)
|
||||
@Matches(CARD_CODE_PATTERN, { message: '명함 코드가 올바르지 않습니다.' })
|
||||
code!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `나의 메모 (최대 ${RECEIVED_NOTE_MAX_LENGTH}자)`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' ? null : trimmed;
|
||||
})
|
||||
@IsString()
|
||||
@MaxLength(RECEIVED_NOTE_MAX_LENGTH, {
|
||||
message: `메모는 ${RECEIVED_NOTE_MAX_LENGTH}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/** 공유된 아이 정보 — 간략히면 건강 정보·병원·메모가 없다 */
|
||||
export class SharedPetSnapshotDto implements SharedPetSnapshot {
|
||||
@ApiProperty() name!: string;
|
||||
@ApiProperty() species!: string;
|
||||
@ApiProperty() breed!: string;
|
||||
@ApiProperty({ example: 'male' }) gender!: string;
|
||||
@ApiProperty({ example: '2021-09-14' }) birthDate!: string;
|
||||
@ApiProperty() weight!: number;
|
||||
@ApiProperty({ example: 'kg' }) weightUnit!: string;
|
||||
@ApiProperty({ nullable: true, type: String }) microchip!: string | null;
|
||||
@ApiProperty({ example: '#FF9B6A' }) tint!: string;
|
||||
@ApiPropertyOptional() neutered?: boolean;
|
||||
@ApiPropertyOptional({ nullable: true, type: String }) bloodType?:
|
||||
| string
|
||||
| null;
|
||||
@ApiPropertyOptional({ nullable: true, type: String }) vet?: string | null;
|
||||
@ApiPropertyOptional({ nullable: true, type: String }) memo?: string | null;
|
||||
@ApiPropertyOptional({ type: [String] }) allergies?: string[];
|
||||
@ApiPropertyOptional({ type: [String] }) conditions?: string[];
|
||||
@ApiPropertyOptional({ type: [String] }) watchConditions?: string[];
|
||||
}
|
||||
|
||||
/** 공유된 꾸미기 값 */
|
||||
export class SharedCardStyleDto implements SharedCardStyle {
|
||||
@ApiProperty() tint!: string;
|
||||
@ApiProperty() tint2!: string;
|
||||
@ApiProperty({ nullable: true, type: String }) nameBg!: string | null;
|
||||
@ApiProperty({ nullable: true, type: String }) nameBgAsset!: string | null;
|
||||
@ApiProperty() textLight!: boolean;
|
||||
@ApiProperty() scrim!: boolean;
|
||||
@ApiProperty({ enum: SCRIM_TONES }) scrimTone!: ScrimTone;
|
||||
}
|
||||
|
||||
/**
|
||||
* 받기 전 미리보기 — 사진은 GET /shared-pet-cards/{code}/images/{pet|sender|background} 로 받는다
|
||||
* (아직 내 보관함에 없어 파일 id 를 주지 않는다).
|
||||
*/
|
||||
export class SharedCardPreviewDto {
|
||||
@ApiProperty({ example: '0428-117' }) code!: string;
|
||||
@ApiProperty({ enum: CARD_SHARE_MODES }) mode!: CardShareMode;
|
||||
@ApiProperty({ nullable: true, type: String }) senderNickname!: string | null;
|
||||
@ApiProperty() hasSenderPhoto!: boolean;
|
||||
@ApiProperty({ type: SharedPetSnapshotDto }) pet!: SharedPetSnapshotDto;
|
||||
@ApiProperty() hasPetPhoto!: boolean;
|
||||
@ApiProperty({ type: SharedCardStyleDto }) style!: SharedCardStyleDto;
|
||||
@ApiProperty() hasBackgroundPhoto!: boolean;
|
||||
}
|
||||
|
||||
/** 보관함의 받은 명함 — 사진은 내 소유 사본이라 GET /api/files/{id} 로 받는다 */
|
||||
export class ReceivedCardResponseDto {
|
||||
@ApiProperty() id!: string;
|
||||
@ApiProperty({ example: '0428-117' }) code!: string;
|
||||
@ApiProperty({ enum: CARD_SHARE_MODES }) mode!: CardShareMode;
|
||||
@ApiProperty({ nullable: true, type: String }) senderNickname!: string | null;
|
||||
@ApiProperty({ nullable: true, type: String }) senderPhotoFileId!:
|
||||
| string
|
||||
| null;
|
||||
@ApiProperty({ type: SharedPetSnapshotDto }) pet!: SharedPetSnapshotDto;
|
||||
@ApiProperty({ nullable: true, type: String }) petPhotoFileId!: string | null;
|
||||
@ApiProperty({ type: SharedCardStyleDto }) style!: SharedCardStyleDto;
|
||||
@ApiProperty({ nullable: true, type: String }) backgroundFileId!:
|
||||
| string
|
||||
| null;
|
||||
@ApiProperty({ nullable: true, type: String }) note!: string | null;
|
||||
@ApiProperty({ description: '받은 때 (ISO 8601 UTC)' }) receivedAt!: string;
|
||||
|
||||
static from(card: ReceivedCard): ReceivedCardResponseDto {
|
||||
return {
|
||||
id: card.id,
|
||||
code: card.code,
|
||||
mode: card.mode,
|
||||
senderNickname: card.senderNickname,
|
||||
senderPhotoFileId: card.senderPhotoFileId,
|
||||
pet: card.pet,
|
||||
petPhotoFileId: card.petPhotoFileId,
|
||||
style: card.style,
|
||||
backgroundFileId: card.backgroundFileId,
|
||||
note: card.note,
|
||||
receivedAt: card.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ import { User } from '@/modules/user/entities/user.entity';
|
||||
/** 한 사람이 만들 수 있는 펫 명함 수 — 앱의 kMaxPetCards */
|
||||
export const MAX_PET_CARDS = 10;
|
||||
|
||||
/** 명함을 공유한 모양 — 간략히(이름·종·생일 등만) / 상세히(건강 정보·병원·메모까지) (앱의 CardViewMode) */
|
||||
export const CARD_SHARE_MODES = ['simple', 'detail'] as const;
|
||||
export type CardShareMode = (typeof CARD_SHARE_MODES)[number];
|
||||
|
||||
/** 글자 뒤에 덮는 막의 색 (앱의 ScrimTone) */
|
||||
export const SCRIM_TONES = ['light', 'dark'] as const;
|
||||
export type ScrimTone = (typeof SCRIM_TONES)[number];
|
||||
@@ -93,6 +97,13 @@ export class PetCard {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isRepresentative!: boolean;
|
||||
|
||||
/**
|
||||
* 공유한 모양 — 명함을 받은 사람에게 이 모양에 담긴 정보만 넘긴다.
|
||||
* 공유 시트를 열 때 앱이 정한다. 한 번도 공유하지 않았으면 정보가 적은 간략히.
|
||||
*/
|
||||
@Column({ type: 'varchar', length: 6, default: 'simple' })
|
||||
shareMode!: CardShareMode;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
|
||||
import { User } from '@/modules/user/entities/user.entity';
|
||||
import { PetCard } from './pet-card.entity';
|
||||
import type { CardShareMode, ScrimTone } from './pet-card.entity';
|
||||
|
||||
/** 받은 명함에 남기는 나의 메모 최대 길이 (시안 maxLength 200) */
|
||||
export const RECEIVED_NOTE_MAX_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* 받은 명함에 담는 아이 정보 — 받을 때 공유한 모양에 맞춰 잘라 낸 사본.
|
||||
* 간략히는 이름·종·품종·성별·생일·몸무게·등록번호만, 상세히는 건강 정보·병원·메모까지.
|
||||
*/
|
||||
export interface SharedPetSnapshot {
|
||||
name: string;
|
||||
species: string;
|
||||
breed: string;
|
||||
gender: string;
|
||||
birthDate: string;
|
||||
weight: number;
|
||||
weightUnit: string;
|
||||
microchip: string | null;
|
||||
/** 아이의 고유색 — 사진이 없을 때 아바타 바탕 */
|
||||
tint: string;
|
||||
neutered?: boolean;
|
||||
bloodType?: string | null;
|
||||
vet?: string | null;
|
||||
memo?: string | null;
|
||||
allergies?: string[];
|
||||
conditions?: string[];
|
||||
watchConditions?: string[];
|
||||
}
|
||||
|
||||
/** 받은 명함의 꾸미기 값 — 배경 사진은 별도 컬럼(받는 사람 소유 사본) */
|
||||
export interface SharedCardStyle {
|
||||
tint: string;
|
||||
tint2: string;
|
||||
nameBg: string | null;
|
||||
nameBgAsset: string | null;
|
||||
textLight: boolean;
|
||||
scrim: boolean;
|
||||
scrimTone: ScrimTone;
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은 명함(보관함) — 받는 순간의 사본이다. 보낸 사람이 아이 정보를 바꾸거나 명함을 지워도 그대로 남는다.
|
||||
* 사진도 받는 사람 소유로 복사해 둔다 (원본이 지워져도 보이도록).
|
||||
*/
|
||||
@Entity('received_cards')
|
||||
export class ReceivedCard {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
/** 받은 사람 — 탈퇴하면 함께 지운다 */
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner?: User;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'uuid' })
|
||||
ownerId!: string;
|
||||
|
||||
/** 원래 명함 — 보낸 사람이 지우면 비운다 (사본은 남는다) */
|
||||
@ManyToOne(() => PetCard, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'source_card_id' })
|
||||
sourceCard?: PetCard | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
sourceCardId!: string | null;
|
||||
|
||||
/** 받을 때의 명함 코드 */
|
||||
@Column({ type: 'varchar', length: 8 })
|
||||
code!: string;
|
||||
|
||||
/** 공유된 모양 — 보관함에서도 이 모양으로 보여 준다 */
|
||||
@Column({ type: 'varchar', length: 6 })
|
||||
mode!: CardShareMode;
|
||||
|
||||
/** 보낸 사람의 닉네임 (받은 때 기준) */
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
senderNickname!: string | null;
|
||||
|
||||
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
|
||||
@JoinColumn({ name: 'sender_photo_file_id' })
|
||||
senderPhotoFile?: StoredFile | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
senderPhotoFileId!: string | null;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
pet!: SharedPetSnapshot;
|
||||
|
||||
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
|
||||
@JoinColumn({ name: 'pet_photo_file_id' })
|
||||
petPhotoFile?: StoredFile | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
petPhotoFileId!: string | null;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
style!: SharedCardStyle;
|
||||
|
||||
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
|
||||
@JoinColumn({ name: 'background_file_id' })
|
||||
backgroundFile?: StoredFile | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
backgroundFileId!: string | null;
|
||||
|
||||
/** 받은 사람이 남긴 메모 */
|
||||
@Column({ type: 'varchar', length: RECEIVED_NOTE_MAX_LENGTH, nullable: true })
|
||||
note!: string | null;
|
||||
|
||||
/** 받은 때 — 보관함 순서와 "받은 날짜" */
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -4,15 +4,30 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PetModule } from '@/modules/pet/pet.module';
|
||||
import { UploadModule } from '@/modules/upload/upload.module';
|
||||
import { PetCard } from './entities/pet-card.entity';
|
||||
import { ReceivedCard } from './entities/received-card.entity';
|
||||
import { PetCardService } from './pet-card.service';
|
||||
import {
|
||||
PetCardsController,
|
||||
RepresentativeCardController,
|
||||
} from './pet-cards.controller';
|
||||
import { ReceivedCardService } from './received-card.service';
|
||||
import {
|
||||
ReceivedCardsController,
|
||||
SharedPetCardsController,
|
||||
} from './received-cards.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PetCard]), PetModule, UploadModule],
|
||||
controllers: [PetCardsController, RepresentativeCardController],
|
||||
providers: [PetCardService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PetCard, ReceivedCard]),
|
||||
PetModule,
|
||||
UploadModule,
|
||||
],
|
||||
controllers: [
|
||||
PetCardsController,
|
||||
RepresentativeCardController,
|
||||
SharedPetCardsController,
|
||||
ReceivedCardsController,
|
||||
],
|
||||
providers: [PetCardService, ReceivedCardService],
|
||||
})
|
||||
export class PetCardModule {}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
toStyleColumns,
|
||||
} from './dto/pet-card.dto';
|
||||
import { MAX_PET_CARDS, PetCard } from './entities/pet-card.entity';
|
||||
import type { CardShareMode } from './entities/pet-card.entity';
|
||||
|
||||
/** 명함 코드가 겹치면 다시 뽑는 횟수 — 천만 개 중에서 뽑으므로 거의 겹치지 않는다 */
|
||||
const CODE_RETRY = 5;
|
||||
@@ -132,6 +133,21 @@ export class PetCardService implements OnModuleInit, PetLifecycleListener {
|
||||
});
|
||||
}
|
||||
|
||||
/** 공유 모양 정하기 — 공유 시트를 열 때 앱이 보낸다. 받는 사람에게는 이 모양의 정보만 간다 */
|
||||
async setShareMode(
|
||||
ownerId: string,
|
||||
cardId: string,
|
||||
mode: CardShareMode,
|
||||
): Promise<void> {
|
||||
const result = await this.cards.update(
|
||||
{ id: cardId, ownerId },
|
||||
{ shareMode: mode },
|
||||
);
|
||||
if (!result.affected) {
|
||||
throw new BusinessException('RES_001', { context: { cardId } });
|
||||
}
|
||||
}
|
||||
|
||||
/** 대표 명함 지정 풀기 — 앱은 그 아이의 첫 명함을 보여 준다 */
|
||||
async clearRepresentative(ownerId: string, petId: string): Promise<void> {
|
||||
await this.pets.findOwned(ownerId, petId);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
SetRepresentativeDto,
|
||||
UpdatePetCardDto,
|
||||
} from './dto/pet-card.dto';
|
||||
import { ShareModeDto } from './dto/received-card.dto';
|
||||
import { PetCardService } from './pet-card.service';
|
||||
|
||||
// 펫 명함 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
|
||||
@@ -86,6 +87,24 @@ export class PetCardsController {
|
||||
return this.cards.update(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Put(':id/share-mode')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform() // 204 — 본문이 없다
|
||||
@ApiOperation({
|
||||
summary: '공유 모양 정하기',
|
||||
description:
|
||||
'공유 시트를 열 때 보낸다. 명함을 받는 사람에게는 이 모양(간략히·상세히)의 정보만 간다',
|
||||
})
|
||||
@ApiNoContentResponse({ description: '저장 완료' })
|
||||
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
|
||||
async setShareMode(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ShareModeDto,
|
||||
): Promise<void> {
|
||||
await this.cards.setShareMode(user.userId, id, dto.mode);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform() // 204 — 본문이 없다
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { UploadService } from '@/modules/upload/upload.service';
|
||||
import { PetCard } from './entities/pet-card.entity';
|
||||
import { ReceivedCard } from './entities/received-card.entity';
|
||||
import { ReceivedCardService } from './received-card.service';
|
||||
|
||||
const SENDER = 'sender-1';
|
||||
const VIEWER = 'viewer-1';
|
||||
|
||||
function card(overrides: Partial<PetCard> = {}): PetCard {
|
||||
return {
|
||||
id: 'card-1',
|
||||
ownerId: SENDER,
|
||||
code: '0428-117',
|
||||
shareMode: 'simple',
|
||||
tint: '#FF9B6A',
|
||||
tint2: '#FFC59B',
|
||||
nameBg: null,
|
||||
nameBgAsset: null,
|
||||
nameBgFileId: 'bg-1',
|
||||
textLight: false,
|
||||
scrim: false,
|
||||
scrimTone: 'light',
|
||||
owner: { nickname: '초코아빠', photoFileId: 'sender-photo' },
|
||||
pet: {
|
||||
name: '초코',
|
||||
species: '강아지',
|
||||
breed: '닥스훈트',
|
||||
gender: 'male',
|
||||
birthDate: '2021-05-14',
|
||||
weight: 7,
|
||||
weightUnit: 'kg',
|
||||
microchip: null,
|
||||
tint: '#C9794A',
|
||||
photoFileId: 'pet-photo',
|
||||
vet: '드림 동물병원',
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as PetCard;
|
||||
}
|
||||
|
||||
describe('ReceivedCardService', () => {
|
||||
const cards = { findOne: jest.fn() };
|
||||
const received = {
|
||||
save: jest.fn(),
|
||||
create: jest.fn((v: Partial<ReceivedCard>) => v),
|
||||
};
|
||||
const uploads = {
|
||||
copyFor: jest.fn((id: string | null) =>
|
||||
Promise.resolve(id === null ? null : `copy-of-${id}`),
|
||||
),
|
||||
releaseIfUnused: jest.fn(),
|
||||
};
|
||||
const service = new ReceivedCardService(
|
||||
cards as unknown as Repository<PetCard>,
|
||||
received as unknown as Repository<ReceivedCard>,
|
||||
uploads as unknown as UploadService,
|
||||
);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('형식이 틀린 코드는 DB 를 보지 않고 CARD_002', async () => {
|
||||
await expect(service.preview(VIEWER, 'abc')).rejects.toMatchObject({
|
||||
code: 'CARD_002',
|
||||
});
|
||||
expect(cards.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('내 명함은 받을 수 없다 (CARD_003)', async () => {
|
||||
cards.findOne.mockResolvedValue(card());
|
||||
|
||||
await expect(service.preview(SENDER, '0428-117')).rejects.toMatchObject({
|
||||
code: 'CARD_003',
|
||||
});
|
||||
});
|
||||
|
||||
it('미리보기는 공유한 모양의 정보만 담고 사진 id 는 주지 않는다', async () => {
|
||||
cards.findOne.mockResolvedValue(card());
|
||||
|
||||
const preview = await service.preview(VIEWER, '0428-117');
|
||||
|
||||
expect(preview.mode).toBe('simple');
|
||||
expect(preview.pet).not.toHaveProperty('vet');
|
||||
expect(preview).toMatchObject({
|
||||
senderNickname: '초코아빠',
|
||||
hasSenderPhoto: true,
|
||||
hasPetPhoto: true,
|
||||
hasBackgroundPhoto: true,
|
||||
});
|
||||
expect(JSON.stringify(preview)).not.toContain('pet-photo');
|
||||
});
|
||||
|
||||
it('받으면 사진을 내 소유로 복사해 사본을 저장한다', async () => {
|
||||
cards.findOne.mockResolvedValue(card());
|
||||
received.save.mockImplementation((v: Partial<ReceivedCard>) =>
|
||||
Promise.resolve({ ...v, id: 'r1', createdAt: new Date() }),
|
||||
);
|
||||
|
||||
const result = await service.receive(VIEWER, {
|
||||
code: '0428-117',
|
||||
note: '산책 친구',
|
||||
});
|
||||
|
||||
expect(uploads.copyFor).toHaveBeenCalledWith('sender-photo', VIEWER);
|
||||
expect(uploads.copyFor).toHaveBeenCalledWith('pet-photo', VIEWER);
|
||||
expect(uploads.copyFor).toHaveBeenCalledWith('bg-1', VIEWER);
|
||||
expect(result).toMatchObject({
|
||||
petPhotoFileId: 'copy-of-pet-photo',
|
||||
senderPhotoFileId: 'copy-of-sender-photo',
|
||||
backgroundFileId: 'copy-of-bg-1',
|
||||
note: '산책 친구',
|
||||
});
|
||||
});
|
||||
|
||||
it('저장하지 못하면 복사한 사진을 지운다', async () => {
|
||||
cards.findOne.mockResolvedValue(card());
|
||||
received.save.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(service.receive(VIEWER, { code: '0428-117' })).rejects.toThrow(
|
||||
'db down',
|
||||
);
|
||||
expect(uploads.releaseIfUnused).toHaveBeenCalledWith([
|
||||
'copy-of-sender-photo',
|
||||
'copy-of-pet-photo',
|
||||
'copy-of-bg-1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
|
||||
import {
|
||||
CursorPaginationQueryDto,
|
||||
PaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { FileDownload, UploadService } from '@/modules/upload/upload.service';
|
||||
import { cardStyleSnapshot, petSnapshot } from './card-snapshot';
|
||||
import {
|
||||
CARD_CODE_PATTERN,
|
||||
ReceiveCardDto,
|
||||
ReceivedCardResponseDto,
|
||||
SharedCardPreviewDto,
|
||||
} from './dto/received-card.dto';
|
||||
import { PetCard } from './entities/pet-card.entity';
|
||||
import { ReceivedCard } from './entities/received-card.entity';
|
||||
|
||||
/** 공유된 명함에서 내려받을 수 있는 사진 */
|
||||
export const SHARED_IMAGE_KINDS = ['pet', 'sender', 'background'] as const;
|
||||
export type SharedImageKind = (typeof SHARED_IMAGE_KINDS)[number];
|
||||
|
||||
// 명함 받기 — 코드로 다른 회원의 명함을 미리 보고, 보관함에 사본으로 저장한다
|
||||
@Injectable()
|
||||
export class ReceivedCardService {
|
||||
private readonly logger = new Logger(ReceivedCardService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PetCard)
|
||||
private readonly cards: Repository<PetCard>,
|
||||
@InjectRepository(ReceivedCard)
|
||||
private readonly received: Repository<ReceivedCard>,
|
||||
private readonly uploads: UploadService,
|
||||
) {}
|
||||
|
||||
/** 받기 전 미리보기 — 아직 보관하지 않는다 */
|
||||
async preview(viewerId: string, code: string): Promise<SharedCardPreviewDto> {
|
||||
const card = await this.findShared(viewerId, code);
|
||||
const pet = card.pet!;
|
||||
return {
|
||||
code: card.code,
|
||||
mode: card.shareMode,
|
||||
senderNickname: card.owner?.nickname ?? null,
|
||||
hasSenderPhoto: card.owner?.photoFileId != null,
|
||||
pet: petSnapshot(pet, card.shareMode),
|
||||
hasPetPhoto: pet.photoFileId !== null,
|
||||
style: cardStyleSnapshot(card),
|
||||
hasBackgroundPhoto: card.nameBgFileId !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 미리보기 사진 — 코드를 아는 회원에게만, 그 명함에 보이는 사진만 */
|
||||
async previewImage(
|
||||
viewerId: string,
|
||||
code: string,
|
||||
kind: SharedImageKind,
|
||||
): Promise<FileDownload> {
|
||||
const card = await this.findShared(viewerId, code);
|
||||
const fileId = sharedFileId(card, kind);
|
||||
if (fileId === null) {
|
||||
throw new BusinessException('RES_001', { context: { code, kind } });
|
||||
}
|
||||
return this.uploads.downloadUnchecked(fileId);
|
||||
}
|
||||
|
||||
/** 보관함에 저장 — 받는 순간의 사본을 만들고 사진도 내 소유로 복사한다 */
|
||||
async receive(
|
||||
viewerId: string,
|
||||
dto: ReceiveCardDto,
|
||||
): Promise<ReceivedCardResponseDto> {
|
||||
const card = await this.findShared(viewerId, dto.code);
|
||||
const pet = card.pet!;
|
||||
|
||||
const copies = await Promise.all([
|
||||
this.uploads.copyFor(card.owner?.photoFileId ?? null, viewerId),
|
||||
this.uploads.copyFor(pet.photoFileId, viewerId),
|
||||
this.uploads.copyFor(card.nameBgFileId, viewerId),
|
||||
]);
|
||||
const [senderPhotoFileId, petPhotoFileId, backgroundFileId] = copies;
|
||||
|
||||
try {
|
||||
const saved = await this.received.save(
|
||||
this.received.create({
|
||||
ownerId: viewerId,
|
||||
sourceCardId: card.id,
|
||||
code: card.code,
|
||||
mode: card.shareMode,
|
||||
senderNickname: card.owner?.nickname ?? null,
|
||||
senderPhotoFileId,
|
||||
pet: petSnapshot(pet, card.shareMode),
|
||||
petPhotoFileId,
|
||||
style: cardStyleSnapshot(card),
|
||||
backgroundFileId,
|
||||
note: dto.note ?? null,
|
||||
}),
|
||||
);
|
||||
this.logger.log(
|
||||
`명함 받기: ${saved.id} (명함 ${card.id} → 회원 ${viewerId})`,
|
||||
);
|
||||
return ReceivedCardResponseDto.from(saved);
|
||||
} catch (error) {
|
||||
// 저장하지 못했으면 복사한 사진도 지운다
|
||||
await this.uploads.releaseIfUnused(copies);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 보관함 (받은 순) */
|
||||
async list(
|
||||
ownerId: string,
|
||||
query: CursorPaginationQueryDto,
|
||||
): Promise<PaginatedResult<ReceivedCardResponseDto>> {
|
||||
const qb = this.received
|
||||
.createQueryBuilder('received')
|
||||
.where('received.owner_id = :ownerId', { ownerId });
|
||||
const page = await keysetPaginate(
|
||||
qb,
|
||||
[
|
||||
{
|
||||
column: 'received.created_at',
|
||||
cast: 'timestamptz',
|
||||
read: (r) => r.createdAt.toISOString(),
|
||||
},
|
||||
{ column: 'received.id', cast: 'uuid', read: (r) => r.id },
|
||||
],
|
||||
query,
|
||||
);
|
||||
return mapPage(page, (r) => ReceivedCardResponseDto.from(r));
|
||||
}
|
||||
|
||||
/** 보관함에서 삭제 — 복사해 둔 사진도 정리한다 */
|
||||
async remove(ownerId: string, receivedId: string): Promise<void> {
|
||||
const card = await this.received.findOneBy({ id: receivedId, ownerId });
|
||||
if (!card) {
|
||||
throw new BusinessException('RES_001', { context: { receivedId } });
|
||||
}
|
||||
await this.received.delete({ id: card.id });
|
||||
await this.uploads.releaseIfUnused([
|
||||
card.senderPhotoFileId,
|
||||
card.petPhotoFileId,
|
||||
card.backgroundFileId,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 코드로 공유된 명함 찾기 — 없거나 형식이 틀리면 CARD_002, 내 명함이면 CARD_003 */
|
||||
private async findShared(viewerId: string, code: string): Promise<PetCard> {
|
||||
const card = CARD_CODE_PATTERN.test(code)
|
||||
? await this.cards.findOne({
|
||||
where: { code },
|
||||
relations: { pet: true, owner: true },
|
||||
})
|
||||
: null;
|
||||
if (!card?.pet) {
|
||||
throw new BusinessException('CARD_002', { context: { code } });
|
||||
}
|
||||
if (card.ownerId === viewerId) {
|
||||
throw new BusinessException('CARD_003', { context: { code } });
|
||||
}
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
/** 공유된 명함에서 [kind] 사진의 파일 id */
|
||||
function sharedFileId(card: PetCard, kind: SharedImageKind): string | null {
|
||||
switch (kind) {
|
||||
case 'pet':
|
||||
return card.pet?.photoFileId ?? null;
|
||||
case 'sender':
|
||||
return card.owner?.photoFileId ?? null;
|
||||
case 'background':
|
||||
return card.nameBgFileId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Header,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiPaginatedResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { CurrentUser } from '@/common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '@/common/decorators/current-user.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import {
|
||||
CursorPaginationQueryDto,
|
||||
PaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
FILE_CACHE_CONTROL,
|
||||
toStreamableFile,
|
||||
} from '@/modules/upload/file-response';
|
||||
import {
|
||||
ReceiveCardDto,
|
||||
ReceivedCardResponseDto,
|
||||
SharedCardPreviewDto,
|
||||
} from './dto/received-card.dto';
|
||||
import {
|
||||
ReceivedCardService,
|
||||
SHARED_IMAGE_KINDS,
|
||||
} from './received-card.service';
|
||||
import type { SharedImageKind } from './received-card.service';
|
||||
|
||||
/**
|
||||
* 명함 코드로 찾는 엔드포인트는 코드 추측의 표적이 된다 — 전역보다 엄격하게 막는다.
|
||||
* 분당 30회 — 사진 세 장을 포함해 명함 몇 장을 받는 데는 충분하다.
|
||||
*/
|
||||
const CODE_LOOKUP_THROTTLE = { default: { ttl: 60_000, limit: 30 } };
|
||||
|
||||
// 공유된 명함 — 코드를 아는 회원이 받기 전에 미리 본다
|
||||
@ApiTags('PetCard')
|
||||
@ApiBearerAuth()
|
||||
@Throttle(CODE_LOOKUP_THROTTLE)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@Controller('shared-pet-cards')
|
||||
export class SharedPetCardsController {
|
||||
constructor(private readonly received: ReceivedCardService) {}
|
||||
|
||||
@Get(':code')
|
||||
@ApiOperation({
|
||||
summary: '명함 미리보기 (받기 전)',
|
||||
description: '보낸 사람이 공유한 모양(간략히·상세히)의 정보만 온다',
|
||||
})
|
||||
@ApiParam({ name: 'code', example: '0428-117' })
|
||||
@ApiStandardResponse(SharedCardPreviewDto, { description: '공유된 명함' })
|
||||
@ApiErrorResponse('CARD_002', '코드에 맞는 명함이 없음')
|
||||
@ApiErrorResponse('CARD_003', '내 명함')
|
||||
preview(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('code') code: string,
|
||||
): Promise<SharedCardPreviewDto> {
|
||||
return this.received.preview(user.userId, code);
|
||||
}
|
||||
|
||||
@Get(':code/images/:kind')
|
||||
@SkipTransform() // 파일 본문 — { success, data } 로 감싸면 깨진다
|
||||
@Header('Cache-Control', FILE_CACHE_CONTROL)
|
||||
@ApiOperation({ summary: '미리보기 사진 (아이·보낸 사람·배경)' })
|
||||
@ApiParam({ name: 'kind', enum: SHARED_IMAGE_KINDS })
|
||||
@ApiOkResponse({ description: '사진 본문' })
|
||||
@ApiErrorResponse('CARD_002', '코드에 맞는 명함이 없음')
|
||||
@ApiErrorResponse('RES_001', '그 사진이 없음')
|
||||
async image(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('code') code: string,
|
||||
@Param('kind') kind: string,
|
||||
): Promise<StreamableFile> {
|
||||
if (!isSharedImageKind(kind)) {
|
||||
throw new BusinessException('RES_001', { context: { kind } });
|
||||
}
|
||||
return toStreamableFile(
|
||||
await this.received.previewImage(user.userId, code, kind),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 받은 명함 보관함
|
||||
@ApiTags('PetCard')
|
||||
@ApiBearerAuth()
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@Controller('received-cards')
|
||||
export class ReceivedCardsController {
|
||||
constructor(private readonly received: ReceivedCardService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '받은 명함 목록 (받은 순, 커서 페이지네이션)' })
|
||||
@ApiPaginatedResponse(ReceivedCardResponseDto, '받은 명함 목록')
|
||||
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query() query: CursorPaginationQueryDto,
|
||||
): Promise<PaginatedResult<ReceivedCardResponseDto>> {
|
||||
return this.received.list(user.userId, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Throttle(CODE_LOOKUP_THROTTLE)
|
||||
@ApiOperation({
|
||||
summary: '명함 받기 (보관함에 저장)',
|
||||
description:
|
||||
'받는 순간의 사본을 저장한다 — 보낸 사람이 정보를 바꾸거나 명함을 지워도 그대로 남는다',
|
||||
})
|
||||
@ApiStandardResponse(ReceivedCardResponseDto, {
|
||||
status: 201,
|
||||
description: '보관한 명함',
|
||||
})
|
||||
@ApiErrorResponse('CARD_002', '코드에 맞는 명함이 없음')
|
||||
@ApiErrorResponse('CARD_003', '내 명함')
|
||||
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
|
||||
receive(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: ReceiveCardDto,
|
||||
): Promise<ReceivedCardResponseDto> {
|
||||
return this.received.receive(user.userId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform() // 204 — 본문이 없다
|
||||
@ApiOperation({ summary: '보관함에서 삭제' })
|
||||
@ApiNoContentResponse({ description: '삭제 완료' })
|
||||
@ApiErrorResponse('RES_001', '받은 명함을 찾을 수 없음')
|
||||
async remove(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<void> {
|
||||
await this.received.remove(user.userId, id);
|
||||
}
|
||||
}
|
||||
|
||||
function isSharedImageKind(kind: string): kind is SharedImageKind {
|
||||
return (SHARED_IMAGE_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 내 것이 아닌 파일을 읽어도 되는지 정하는 규칙 — 함께 쓰는 기능(공유 다이어리 등)이 구현한다.
|
||||
* 각 모듈이 onModuleInit 에서 UploadService.registerAccessPolicy 로 등록한다 —
|
||||
* 업로드 모듈이 다른 도메인을 알지 않아도 된다(의존 방향: 다른 도메인 → 업로드).
|
||||
*/
|
||||
export interface FileAccessPolicy {
|
||||
/** [userId] 가 남의 파일 [fileId] 를 내려받아도 되면 true. 모르는 파일이면 false */
|
||||
canRead(userId: string, fileId: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { StreamableFile } from '@nestjs/common';
|
||||
|
||||
import type { FileDownload } from './upload.service';
|
||||
|
||||
/** 파일은 id 별로 내용이 바뀌지 않으므로 오래 캐시해도 된다 (로그인한 사람만 받으므로 private) */
|
||||
export const FILE_CACHE_CONTROL = 'private, max-age=31536000, immutable';
|
||||
|
||||
/**
|
||||
* 파일 본문 응답 — 업로드 디렉터리를 정적으로 서빙하지 않는다.
|
||||
* 형식은 서버 기록으로 정하고, 브라우저가 열어 실행하지 않도록 attachment 로 내려준다 (.claude/rules/security.md).
|
||||
* 핸들러에는 @SkipTransform() 과 @Header('Cache-Control', ...) 를 함께 붙인다.
|
||||
*/
|
||||
export function toStreamableFile(file: FileDownload): StreamableFile {
|
||||
return new StreamableFile(file.stream, {
|
||||
type: file.mimeType,
|
||||
length: file.size,
|
||||
disposition: `attachment; filename="${file.downloadName}"`,
|
||||
});
|
||||
}
|
||||
@@ -28,11 +28,9 @@ import type { AuthUser } from '@/common/decorators/current-user.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { UploadResponseDto } from './dto/upload-response.dto';
|
||||
import { FILE_CACHE_CONTROL, toStreamableFile } from './file-response';
|
||||
import { UploadService } from './upload.service';
|
||||
|
||||
/** 파일은 id 별로 내용이 바뀌지 않으므로 오래 캐시해도 된다 (로그인한 본인만 받으므로 private) */
|
||||
const FILE_CACHE_CONTROL = 'private, max-age=31536000, immutable';
|
||||
|
||||
// 파일 업로드·내려받기 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이,
|
||||
// 내용(시그니처) 검사와 소유자 기록은 UploadService 가 담당한다.
|
||||
@@ -79,21 +77,17 @@ 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,
|
||||
): Promise<StreamableFile> {
|
||||
const file = await this.uploadService.download(id, user.userId);
|
||||
|
||||
// 업로드 디렉터리를 정적으로 서빙하지 않는다 — 형식은 서버 기록으로 정하고,
|
||||
// 브라우저가 열어 실행하지 않도록 attachment 로 내려준다 (.claude/rules/security.md)
|
||||
return new StreamableFile(file.stream, {
|
||||
type: file.mimeType,
|
||||
length: file.size,
|
||||
disposition: `attachment; filename="${file.downloadName}"`,
|
||||
});
|
||||
return toStreamableFile(await this.uploadService.download(id, user.userId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createReadStream } from 'fs';
|
||||
import { open, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { copyFile, open, unlink } from 'fs/promises';
|
||||
import { extname, join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
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,
|
||||
@@ -30,6 +32,7 @@ export interface FileDownload {
|
||||
@Injectable()
|
||||
export class UploadService {
|
||||
private readonly logger = new Logger(UploadService.name);
|
||||
private readonly accessPolicies: FileAccessPolicy[] = [];
|
||||
|
||||
constructor(
|
||||
@InjectRepository(StoredFile)
|
||||
@@ -64,13 +67,74 @@ 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 });
|
||||
/** 남의 파일을 읽어도 되는 경우를 등록한다 (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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자를 따지지 않고 내려받는다 — 호출한 쪽이 볼 권한을 이미 확인한 경우만 쓴다
|
||||
* (예: 명함 코드를 아는 사람에게 그 명함의 사진을 보여 줄 때).
|
||||
*/
|
||||
async downloadUnchecked(fileId: string): Promise<FileDownload> {
|
||||
const file = await this.files.findOneBy({ id: fileId });
|
||||
if (!file) {
|
||||
throw new BusinessException('RES_001', { context: { fileId } });
|
||||
}
|
||||
return this.toDownload(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일을 [ownerId] 의 것으로 복사한다 — 명함을 받을 때처럼 원본이 지워져도 남아야 하는 경우.
|
||||
* 없는 파일(null 포함)이면 null.
|
||||
*/
|
||||
async copyFor(
|
||||
fileId: string | null,
|
||||
ownerId: string,
|
||||
): Promise<string | null> {
|
||||
if (fileId === null) return null;
|
||||
const source = await this.files.findOneBy({ id: fileId });
|
||||
if (!source) return null;
|
||||
|
||||
// 저장 파일명은 새로 만든다 — 확장자는 원본(서버가 검증해 만든 이름)을 따른다
|
||||
const storedName = `${randomUUID()}${extname(source.storedName)}`;
|
||||
await copyFile(
|
||||
join(UPLOAD_DIR, source.storedName),
|
||||
join(UPLOAD_DIR, storedName),
|
||||
);
|
||||
const copy = await this.files.save(
|
||||
this.files.create({
|
||||
ownerId,
|
||||
storedName,
|
||||
originalName: source.originalName,
|
||||
mimeType: source.mimeType,
|
||||
size: source.size,
|
||||
}),
|
||||
);
|
||||
return copy.id;
|
||||
}
|
||||
|
||||
private toDownload(file: StoredFile): FileDownload {
|
||||
return {
|
||||
stream: createReadStream(join(UPLOAD_DIR, file.storedName)),
|
||||
mimeType: file.mimeType,
|
||||
|
||||
@@ -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<App>;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<T> {
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */
|
||||
const data = <T>(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<App>;
|
||||
const tokens: Record<string, string> = {};
|
||||
const ids: Record<string, string> = {};
|
||||
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<string> => {
|
||||
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<number> =>
|
||||
(await http().get(`/api/files/${fileId}`).set(as(who))).status;
|
||||
|
||||
/**
|
||||
* 누가 빠지면 초대 코드가 바뀐다 — 빠진 사람이 예전 코드로 다시 들어오면 DIARY_003, 주인은 새 코드를 본다.
|
||||
* 뒤의 테스트를 위해 새 코드를 기억해 둔다
|
||||
*/
|
||||
const expectCodeRotated = async (leaver: Person): Promise<void> => {
|
||||
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<Page<DiaryBody>>(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<DiaryBody>(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<DiaryBody>(joined).isOwner).toBe(false);
|
||||
|
||||
const list = await http().get('/api/diaries').set(as('member')).expect(200);
|
||||
const diary = data<Page<DiaryBody>>(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<Page<{ id: string; author: Author }>>(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<Page<{ postId: string; author: Author }>>(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<Page<DiaryBody>>(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<Page<{ id: string }>>(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<DiaryBody>(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<Page<DiaryBody>>(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<DiaryBody>(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<Page<DiaryBody>>(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');
|
||||
});
|
||||
});
|
||||
@@ -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<INestApplication<App>> {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<INestApplication<App>>();
|
||||
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<App>,
|
||||
deviceKey: string,
|
||||
): Promise<string> {
|
||||
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;
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Tma App</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>다른 반려인의 펫 명함 QR 코드를 읽기 위해 카메라를 사용합니다.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>펫 명함 이미지를 사진 보관함에 저장합니다.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
|
||||
@@ -13,7 +13,12 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'FILE_002': '파일 크기가 허용 범위를 초과했습니다.',
|
||||
'DIARY_001': '다이어리는 최대 6권까지 만들 수 있어요.',
|
||||
'DIARY_002': '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.',
|
||||
'DIARY_003': '초대 코드를 찾을 수 없어요. 코드를 다시 확인해 주세요.',
|
||||
'DIARY_004': '참여 인원이 가득 찼어요. (최대 4명)',
|
||||
'DIARY_005': '이미 참여하고 있는 다이어리예요.',
|
||||
'CARD_001': '펫 명함은 최대 10장까지 만들 수 있어요.',
|
||||
'CARD_002': '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.',
|
||||
'CARD_003': '내 명함은 받을 수 없어요.',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
/// 서버에 올린 파일 표시 — `server-file:<파일 id>`
|
||||
const String kServerFilePrefix = 'server-file:';
|
||||
|
||||
/// 서버의 다른 경로에서 받는 사진 — `server-url:/shared-pet-cards/0428-117/images/pet`
|
||||
/// (내 파일이 아니라 파일 id 를 받지 못한 경우: 받기 전 명함 미리보기 등)
|
||||
const String kServerUrlPrefix = 'server-url:';
|
||||
|
||||
/// 앱에 들어 있는 그림 (명함 기본 배경 등)
|
||||
const String kBundledAssetPrefix = 'assets/';
|
||||
|
||||
@@ -18,3 +22,15 @@ String? serverFileRefOrNull(String? fileId) => fileId == null ? null : serverFil
|
||||
|
||||
/// 앱에 들어 있는 그림인지
|
||||
bool isBundledAsset(String path) => path.startsWith(kBundledAssetPrefix);
|
||||
|
||||
/// 서버 경로(baseUrl 아래) → 사진 경로 자리에 넣을 값
|
||||
String serverUrlRef(String apiPath) => '$kServerUrlPrefix$apiPath';
|
||||
|
||||
/// 사진을 받아 올 서버 경로 — 서버 파일이면 `/files/<id>`, 서버 경로면 그 경로, 아니면 null(기기 사진)
|
||||
String? serverPathOf(String? path) {
|
||||
if (path == null) return null;
|
||||
final fileId = serverFileIdOf(path);
|
||||
if (fileId != null) return '/files/$fileId';
|
||||
if (path.startsWith(kServerUrlPrefix)) return path.substring(kServerUrlPrefix.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -26,10 +26,14 @@ import 'package:tma_app/features/notifications/presentation/pages/notifications_
|
||||
import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart';
|
||||
import 'package:tma_app/features/pet/presentation/pages/pet_info_page.dart';
|
||||
import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/card_scanner_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/pet_card_detail_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/pet_card_editor_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/pet_card_manage_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/pet_cards_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/receive_card_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/received_card_detail_page.dart';
|
||||
import 'package:tma_app/features/records/presentation/pages/records_page.dart';
|
||||
import 'package:tma_app/features/schedule/presentation/pages/event_editor_page.dart';
|
||||
import 'package:tma_app/features/shell/presentation/pages/main_shell.dart';
|
||||
@@ -126,12 +130,30 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
],
|
||||
),
|
||||
// 펫 명함 — 경로가 겹치므로 고정 경로(manage·new)를 :cardId 보다 먼저 둔다
|
||||
// 펫 명함 — 경로가 겹치므로 고정 경로(manage·new·scan…)를 :cardId 보다 먼저 둔다
|
||||
GoRoute(
|
||||
path: AppRoutes.petCardManage,
|
||||
parentNavigatorKey: rootKey,
|
||||
builder: (context, state) => const PetCardManagePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.petCardScan,
|
||||
parentNavigatorKey: rootKey,
|
||||
pageBuilder: (context, state) => _fadePage(state, const CardScannerPage()),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.petCardReceive,
|
||||
parentNavigatorKey: rootKey,
|
||||
// 찾은 명함 없이 들어오면(주소로 직접 등) 펫 명함 탭으로
|
||||
redirect: (context, state) => state.extra is SharedCard ? null : AppRoutes.petCards,
|
||||
builder: (context, state) => ReceiveCardPage(card: state.extra! as SharedCard),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.receivedCardPattern,
|
||||
parentNavigatorKey: rootKey,
|
||||
builder: (context, state) =>
|
||||
ReceivedCardDetailPage(receivedId: state.pathParameters['receivedId']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.petCardCreatePattern,
|
||||
parentNavigatorKey: rootKey,
|
||||
|
||||
@@ -61,6 +61,12 @@ abstract final class AppRoutes {
|
||||
static String petCardDetail(String cardId) => '/pet-cards/$cardId';
|
||||
static String petCardEdit(String cardId) => '/pet-cards/$cardId/edit';
|
||||
|
||||
// 명함 받기 — 스캐너 · 저장 확인(찾은 명함을 extra 로 넘긴다) · 보관함의 받은 명함
|
||||
static const String petCardScan = '/pet-cards/scan';
|
||||
static const String petCardReceive = '/pet-cards/receive';
|
||||
static const String receivedCardPattern = '/pet-cards/received/:receivedId';
|
||||
static String receivedCard(String receivedId) => '/pet-cards/received/$receivedId';
|
||||
|
||||
/// 로그인 전에만 머무를 수 있는 화면
|
||||
static const Set<String> _signedOutOnly = {intro, login};
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class _HomePetsSectionState extends ConsumerState<HomePetsSection> {
|
||||
child: NameCard(
|
||||
pet: selected,
|
||||
style: card.style,
|
||||
onShare: () => unawaited(sharePetCard(context, pet: selected, card: card, capture: _capture)),
|
||||
onShare: () => unawaited(sharePetCard(context, ref, pet: selected, card: card, capture: _capture)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/core/utils/color_utils.dart';
|
||||
import 'package:tma_app/features/pet_card/data/pet_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
|
||||
|
||||
/// 펫 명함 경로 (backend PetCardsController · RepresentativeCardController)
|
||||
@@ -100,6 +101,10 @@ class ApiPetCardRepository implements PetCardRepository {
|
||||
@override
|
||||
Future<void> clearRepresentative(String petId) => _dio.delete<void>(_representativePath(petId));
|
||||
|
||||
@override
|
||||
Future<void> setShareMode(String cardId, CardViewMode mode) =>
|
||||
_dio.put<void>('$_kCardsPath/$cardId/share-mode', data: {'mode': mode.name});
|
||||
|
||||
/// 서버가 아이를 지우면서 명함도 함께 지웠다
|
||||
@override
|
||||
Future<void> dropPet(String petId) async {}
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:tma_app/core/config/app_config.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/features/pet_card/data/api_pet_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
|
||||
|
||||
/// 내 명함 + 아이별 대표 명함 (petId → cardId)
|
||||
@@ -29,6 +30,9 @@ abstract interface class PetCardRepository {
|
||||
|
||||
Future<void> clearRepresentative(String petId);
|
||||
|
||||
/// 공유 시트를 열 때 — 받는 사람이 볼 모양(간략히·상세히). 기기 저장 모드는 할 일이 없다
|
||||
Future<void> setShareMode(String cardId, CardViewMode mode);
|
||||
|
||||
/// 아이를 지웠을 때 그 아이의 명함을 지운다.
|
||||
/// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우면서 명함도 함께 지웠다.
|
||||
Future<void> dropPet(String petId);
|
||||
@@ -96,6 +100,9 @@ class LocalPetCardRepository implements PetCardRepository {
|
||||
@override
|
||||
Future<void> clearRepresentative(String petId) => _writeRep(_readRep()..remove(petId));
|
||||
|
||||
@override
|
||||
Future<void> setShareMode(String cardId, CardViewMode mode) async {}
|
||||
|
||||
@override
|
||||
Future<void> dropPet(String petId) async {
|
||||
await _write(_read().where((c) => c.petId != petId).toList());
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/api/api_paging.dart';
|
||||
import 'package:tma_app/core/api/dio_provider.dart';
|
||||
import 'package:tma_app/core/api/server_file.dart';
|
||||
import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/core/config/app_config.dart';
|
||||
import 'package:tma_app/core/utils/color_utils.dart';
|
||||
import 'package:tma_app/core/utils/date_format.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
|
||||
/// 명함 받기 경로 (backend SharedPetCardsController · ReceivedCardsController)
|
||||
const String _kSharedPath = '/shared-pet-cards';
|
||||
const String _kReceivedPath = '/received-cards';
|
||||
|
||||
/// 받은 명함 저장소 계약 — 다른 회원의 명함을 받으므로 서버가 있어야 한다
|
||||
abstract interface class ReceivedCardRepository {
|
||||
/// 명함 받기를 쓸 수 있는지 — 기기 저장 모드에서는 받을 명함이 없다
|
||||
bool get available;
|
||||
|
||||
/// 보관함 — 최근에 받은 명함이 앞
|
||||
Future<List<ReceivedCard>> fetch();
|
||||
|
||||
/// 받기 전 미리보기
|
||||
Future<SharedCard> preview(String code);
|
||||
|
||||
/// 보관함에 저장
|
||||
Future<ReceivedCard> receive(String code, {String? note});
|
||||
|
||||
Future<void> delete(String id);
|
||||
}
|
||||
|
||||
/// 기기 저장 모드 — 명함 받기를 쓸 수 없다 (화면은 안내만 띄운다)
|
||||
class UnavailableReceivedCardRepository implements ReceivedCardRepository {
|
||||
const UnavailableReceivedCardRepository();
|
||||
|
||||
@override
|
||||
bool get available => false;
|
||||
|
||||
@override
|
||||
Future<List<ReceivedCard>> fetch() async => const [];
|
||||
|
||||
@override
|
||||
Future<SharedCard> preview(String code) => throw StateError('명함 받기는 서버 모드에서만 쓸 수 있다');
|
||||
|
||||
@override
|
||||
Future<ReceivedCard> receive(String code, {String? note}) =>
|
||||
throw StateError('명함 받기는 서버 모드에서만 쓸 수 있다');
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {}
|
||||
}
|
||||
|
||||
/// 공유된 아이 정보 → 명함 위젯이 그리는 Pet (간략히면 건강 정보·병원·메모가 비어 있다)
|
||||
Pet sharedPetFromApi(String id, Map<String, Object?> json, {String? photoPath}) => Pet(
|
||||
id: id,
|
||||
tint: colorFromHex(json['tint'] as String?) ?? kPetTints.first,
|
||||
name: json['name']! as String,
|
||||
species: json['species']! as String,
|
||||
breed: json['breed'] as String? ?? '',
|
||||
gender: PetGender.values.asNameMap()[json['gender']] ?? PetGender.male,
|
||||
neutered: json['neutered'] as bool? ?? false,
|
||||
birthDate: parseIsoDate(json['birthDate']! as String),
|
||||
weight: (json['weight']! as num).toDouble(),
|
||||
weightUnit: WeightUnit.values.asNameMap()[json['weightUnit']] ?? WeightUnit.kg,
|
||||
photoPath: photoPath,
|
||||
microchip: json['microchip'] as String?,
|
||||
bloodType: json['bloodType'] as String?,
|
||||
vet: json['vet'] as String?,
|
||||
memo: json['memo'] as String?,
|
||||
allergies: _strings(json['allergies']),
|
||||
conditions: _strings(json['conditions']),
|
||||
watchConditions: _strings(json['watchConditions']),
|
||||
);
|
||||
|
||||
List<String> _strings(Object? value) => value is List ? value.whereType<String>().toList() : const [];
|
||||
|
||||
/// 공유된 꾸미기 값 → CardStyle ([backgroundPath] 는 배경 사진이 있을 때)
|
||||
CardStyle sharedStyleFromApi(Map<String, Object?> json, {String? backgroundPath}) {
|
||||
final fallback = CardStyle.initial();
|
||||
return CardStyle(
|
||||
tint: colorFromHex(json['tint'] as String?) ?? fallback.tint,
|
||||
tint2: colorFromHex(json['tint2'] as String?) ?? fallback.tint2,
|
||||
nameBg: colorFromHex(json['nameBg'] as String?),
|
||||
nameBgImage: (json['nameBgAsset'] as String?) ?? backgroundPath,
|
||||
textLight: json['textLight'] as bool? ?? false,
|
||||
scrim: json['scrim'] as bool? ?? false,
|
||||
scrimTone: ScrimTone.values.asNameMap()[json['scrimTone']] ?? ScrimTone.light,
|
||||
);
|
||||
}
|
||||
|
||||
/// 받기 전 미리보기 — 사진은 아직 내 것이 아니라 명함 코드 경로로 받는다
|
||||
SharedCard sharedCardPreviewFromApi(Map<String, Object?> json) {
|
||||
final code = json['code']! as String;
|
||||
String? image(String flag, String kind) =>
|
||||
json[flag] == true ? serverUrlRef('$_kSharedPath/$code/images/$kind') : null;
|
||||
return SharedCard(
|
||||
code: code,
|
||||
mode: CardViewMode.byName(json['mode'] as String?),
|
||||
senderNickname: json['senderNickname'] as String?,
|
||||
senderPhotoPath: image('hasSenderPhoto', 'sender'),
|
||||
pet: sharedPetFromApi('shared-$code', json['pet']! as Map<String, Object?>, photoPath: image('hasPetPhoto', 'pet')),
|
||||
style: sharedStyleFromApi(
|
||||
json['style']! as Map<String, Object?>,
|
||||
backgroundPath: image('hasBackgroundPhoto', 'background'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 보관함의 받은 명함 — 사진은 내 소유 사본(server-file:)
|
||||
ReceivedCard receivedCardFromApi(Map<String, Object?> json) {
|
||||
final id = json['id']! as String;
|
||||
return ReceivedCard(
|
||||
id: id,
|
||||
code: json['code']! as String,
|
||||
mode: CardViewMode.byName(json['mode'] as String?),
|
||||
senderNickname: json['senderNickname'] as String?,
|
||||
senderPhotoPath: serverFileRefOrNull(json['senderPhotoFileId'] as String?),
|
||||
pet: sharedPetFromApi(
|
||||
'received-$id',
|
||||
json['pet']! as Map<String, Object?>,
|
||||
photoPath: serverFileRefOrNull(json['petPhotoFileId'] as String?),
|
||||
),
|
||||
style: sharedStyleFromApi(
|
||||
json['style']! as Map<String, Object?>,
|
||||
backgroundPath: serverFileRefOrNull(json['backgroundFileId'] as String?),
|
||||
),
|
||||
note: json['note'] as String?,
|
||||
receivedAt: DateTime.parse(json['receivedAt']! as String).toLocal(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 서버 저장소
|
||||
class ApiReceivedCardRepository implements ReceivedCardRepository {
|
||||
ApiReceivedCardRepository({required Dio dio, required SessionStore session})
|
||||
: _dio = dio,
|
||||
_session = session;
|
||||
|
||||
final Dio _dio;
|
||||
final SessionStore _session;
|
||||
|
||||
@override
|
||||
bool get available => true;
|
||||
|
||||
@override
|
||||
Future<List<ReceivedCard>> fetch() async {
|
||||
if (!_session.hasSession) return const [];
|
||||
final cards = await fetchAllPages(_dio, _kReceivedPath, receivedCardFromApi);
|
||||
// 시안: 새로 받은 명함이 위로
|
||||
return cards..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SharedCard> preview(String code) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('$_kSharedPath/$code');
|
||||
return sharedCardPreviewFromApi(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ReceivedCard> receive(String code, {String? note}) async {
|
||||
final response = await _dio.post<Map<String, dynamic>>(_kReceivedPath, data: {'code': code, 'note': note});
|
||||
return receivedCardFromApi(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) => _dio.delete<void>('$_kReceivedPath/$id');
|
||||
}
|
||||
|
||||
final receivedCardRepositoryProvider = Provider<ReceivedCardRepository>((ref) {
|
||||
return switch (ref.watch(dataModeProvider)) {
|
||||
DataMode.local => const UnavailableReceivedCardRepository(),
|
||||
DataMode.api => ApiReceivedCardRepository(dio: ref.watch(dioProvider), session: ref.watch(sessionStoreProvider)),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/// 명함을 보는 모양 — 간략히(명함) / 상세히(명함 + 건강 정보·병원·메모).
|
||||
/// 공유할 때도 이 모양으로 간다 — 받는 사람은 공유된 모양의 정보만 받는다.
|
||||
enum CardViewMode {
|
||||
simple('간략히'),
|
||||
detail('상세히');
|
||||
|
||||
const CardViewMode(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
static CardViewMode byName(String? name) =>
|
||||
CardViewMode.values.where((m) => m.name == name).firstOrNull ?? CardViewMode.simple;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:tma_app/features/pet/domain/pet.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
|
||||
/// 받은 명함 메모 최대 길이 (시안 maxLength 200)
|
||||
const int kReceivedNoteMaxLength = 200;
|
||||
|
||||
/// 다른 회원이 공유한 명함 — 받기 전 미리보기와 보관함의 받은 명함이 같은 모양을 쓴다.
|
||||
/// [pet] 은 공유된 모양에 담긴 정보만 채워져 있다 (간략히면 건강 정보·병원·메모가 비어 있다).
|
||||
class SharedCard {
|
||||
const SharedCard({
|
||||
required this.code,
|
||||
required this.mode,
|
||||
required this.senderNickname,
|
||||
required this.senderPhotoPath,
|
||||
required this.pet,
|
||||
required this.style,
|
||||
});
|
||||
|
||||
final String code;
|
||||
|
||||
/// 보낸 사람이 공유한 모양 — 이 모양으로만 보여 준다
|
||||
final CardViewMode mode;
|
||||
|
||||
/// 보낸 사람 닉네임 — 모르면 null
|
||||
final String? senderNickname;
|
||||
final String? senderPhotoPath;
|
||||
final Pet pet;
|
||||
final CardStyle style;
|
||||
|
||||
/// "초코아빠" — 닉네임이 없으면 기본 호칭
|
||||
String get senderName => senderNickname ?? kUnknownSender;
|
||||
}
|
||||
|
||||
/// 보낸 사람의 닉네임을 모를 때
|
||||
const String kUnknownSender = '집사';
|
||||
|
||||
/// 보관함에 저장한 받은 명함 — 받는 순간의 사본이다
|
||||
class ReceivedCard extends SharedCard {
|
||||
const ReceivedCard({
|
||||
required this.id,
|
||||
required super.code,
|
||||
required super.mode,
|
||||
required super.senderNickname,
|
||||
required super.senderPhotoPath,
|
||||
required super.pet,
|
||||
required super.style,
|
||||
required this.note,
|
||||
required this.receivedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// 받은 사람이 남긴 메모
|
||||
final String? note;
|
||||
final DateTime receivedAt;
|
||||
|
||||
/// 보관함 검색 — 아이 이름·보낸 사람 (시안: name + from)
|
||||
bool matches(String query) {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return true;
|
||||
return '${pet.name} ${senderNickname ?? ''}'.toLowerCase().contains(q);
|
||||
}
|
||||
}
|
||||
|
||||
/// 명함 QR 에 담는 주소 — 앱의 스캐너가 읽는다 (시스템 카메라로 찍으면 앱을 연다)
|
||||
String petCardDeepLink(String code) => 'rup://pet-card/$code';
|
||||
|
||||
final RegExp _kCardCode = RegExp(r'^\d{4}-\d{3}$');
|
||||
|
||||
/// QR 또는 직접 입력한 값에서 명함 코드를 꺼낸다 — 주소(rup://pet-card/0428-117)·코드(0428-117)·
|
||||
/// 하이픈 없이 쓴 7자리(0428117) 모두 받는다. 명함 코드가 아니면 null
|
||||
String? parseCardCode(String raw) {
|
||||
var text = raw.trim();
|
||||
const prefix = 'rup://pet-card/';
|
||||
if (text.startsWith(prefix)) text = text.substring(prefix.length);
|
||||
text = text.split('?').first.trim();
|
||||
if (RegExp(r'^\d{7}$').hasMatch(text)) text = '${text.substring(0, 4)}-${text.substring(4)}';
|
||||
return _kCardCode.hasMatch(text) ? text : null;
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/api/error_lexicon.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/pressable.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
|
||||
// 시안 .rup-scanner 치수·색
|
||||
const Color _kScannerBg = Color(0xFF14110E);
|
||||
const Color _kDim = Color(0x99120F0C); // rgba(18,15,12,.6)
|
||||
const Color _kFoundGreen = Color(0xFF4ED08A);
|
||||
const double _kFrame = 244;
|
||||
const double _kFrameRadius = 22;
|
||||
const double _kCorner = 36;
|
||||
const double _kCornerStroke = 4;
|
||||
const double _kCloseSize = 38;
|
||||
const double _kCheckSize = 70;
|
||||
|
||||
/// 스캔 줄이 위아래로 한 번 오가는 시간 (시안 2.3s)
|
||||
const Duration _kSweep = Duration(milliseconds: 1150);
|
||||
|
||||
/// 명함을 찾은 뒤 체크 표시를 보여 주는 최소 시간 (시안 820ms)
|
||||
const Duration _kFoundHold = Duration(milliseconds: 820);
|
||||
|
||||
/// 같은 QR 을 실패한 뒤 다시 읽기까지 기다리는 시간 — 같은 알림이 계속 뜨지 않게
|
||||
const Duration _kRetryGap = Duration(seconds: 3);
|
||||
|
||||
/// 스캐너를 닫은 결과
|
||||
sealed class ScanOutcome {
|
||||
const ScanOutcome();
|
||||
}
|
||||
|
||||
/// QR 로 명함을 찾았다
|
||||
final class ScanFound extends ScanOutcome {
|
||||
const ScanFound(this.card);
|
||||
|
||||
final SharedCard card;
|
||||
}
|
||||
|
||||
/// "명함 코드 입력하기"를 눌렀다
|
||||
final class ScanTypeCode extends ScanOutcome {
|
||||
const ScanTypeCode();
|
||||
}
|
||||
|
||||
/// QR 을 읽어 알려 주는 카메라 화면
|
||||
typedef ScannerCamera = Widget Function(ValueChanged<String> onCode);
|
||||
|
||||
/// 스캐너 카메라 — 테스트에서 실제 카메라 대신 끼운다 (null 이면 기기 카메라)
|
||||
final scannerCameraProvider = Provider<ScannerCamera?>((ref) => null);
|
||||
|
||||
/// 명함 받기 스캐너 — 상대 명함의 QR 을 비추면 명함을 불러온다
|
||||
class CardScannerPage extends ConsumerStatefulWidget {
|
||||
const CardScannerPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CardScannerPage> createState() => _CardScannerPageState();
|
||||
}
|
||||
|
||||
class _CardScannerPageState extends ConsumerState<CardScannerPage> {
|
||||
late final ScannerCamera? _fakeCamera = ref.read(scannerCameraProvider);
|
||||
MobileScannerController? _controller;
|
||||
bool _found = false;
|
||||
bool _busy = false;
|
||||
String? _lastRaw;
|
||||
DateTime _lastAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_fakeCamera == null) _controller = MobileScannerController(formats: const [BarcodeFormat.qrCode]);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_controller?.dispose());
|
||||
// 다른 화면은 상태 표시줄 모양을 따로 정하지 않는다 — 어두운 글자(앱 기본)로 되돌린다
|
||||
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
final raw = capture.barcodes.map((b) => b.rawValue).nonNulls.firstOrNull;
|
||||
if (raw != null) unawaited(_onCode(raw));
|
||||
}
|
||||
|
||||
Future<void> _onCode(String raw) async {
|
||||
if (_busy) return;
|
||||
final now = DateTime.now();
|
||||
if (raw == _lastRaw && now.difference(_lastAt) < _kRetryGap) return;
|
||||
_lastRaw = raw;
|
||||
_lastAt = now;
|
||||
|
||||
final code = parseCardCode(raw);
|
||||
if (code == null) {
|
||||
// 펫 명함 QR 이 아니다 — 서버가 못 찾았을 때와 같은 안내
|
||||
ref.read(toastProvider.notifier).show(ErrorLexicon.getMessage('CARD_002'), icon: RupIconName.alert);
|
||||
return;
|
||||
}
|
||||
_busy = true;
|
||||
setState(() => _found = true);
|
||||
final shown = Stopwatch()..start();
|
||||
try {
|
||||
final card = await ref.read(receivedCardsProvider.notifier).preview(code);
|
||||
final rest = _kFoundHold - shown.elapsed;
|
||||
if (rest > Duration.zero) await Future<void>.delayed(rest);
|
||||
if (mounted) context.pop(ScanFound(card));
|
||||
} catch (e) {
|
||||
if (!isReportedApiFailure(e)) rethrow;
|
||||
// 실패 알림은 인터셉터가 띄웠다 — 다시 비출 수 있게 되돌린다
|
||||
_lastAt = DateTime.now();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_found = false;
|
||||
_busy = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _camera() {
|
||||
final camera = _fakeCamera;
|
||||
if (camera != null) return camera(_onCode);
|
||||
return MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
// 카메라를 켜는 동안·쓸 수 없을 때는 시안의 어두운 바탕 (안내는 아래 문구가 맡는다)
|
||||
placeholderBuilder: (context) => const _Feed(),
|
||||
errorBuilder: (context, error) => const _Feed(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final top = MediaQuery.paddingOf(context).top;
|
||||
final topBarHeight = top + 8 + _kCloseSize;
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: Scaffold(
|
||||
backgroundColor: _kScannerBg,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _camera()),
|
||||
Positioned.fill(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: topBarHeight),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: _Frame(found: _found),
|
||||
),
|
||||
),
|
||||
_Bottom(
|
||||
found: _found,
|
||||
camera: _controller,
|
||||
onManual: () => context.pop(const ScanTypeCode()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 조준 프레임 바깥을 어둡게 덮는 막이 위 막대를 가리지 않게 맨 위에 둔다
|
||||
Positioned(
|
||||
top: top + 8,
|
||||
left: 18,
|
||||
right: 18,
|
||||
child: _TopBar(onClose: () => context.pop()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 카메라 화면이 아직 없을 때의 바탕 (시안 .rup-scanner__feed)
|
||||
class _Feed extends StatelessWidget {
|
||||
const _Feed();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: Alignment(0, -.2),
|
||||
radius: 1.1,
|
||||
colors: [Color(0xFF3A332B), Color(0xFF221D17), _kScannerBg],
|
||||
stops: [0, .56, 1],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({required this.onClose});
|
||||
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
label: '닫기',
|
||||
child: Pressable(
|
||||
pressedScale: .9,
|
||||
onTap: onClose,
|
||||
child: Container(
|
||||
width: _kCloseSize,
|
||||
height: _kCloseSize,
|
||||
decoration: const BoxDecoration(color: Color(0x24FFFFFF), shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.x, size: 20, stroke: 2.4, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'명함 받기',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _kCloseSize),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 조준 프레임 — 바깥은 어둡게, 모서리 표시 + 스캔 줄, 찾으면 초록 체크
|
||||
class _Frame extends StatefulWidget {
|
||||
const _Frame({required this.found});
|
||||
|
||||
final bool found;
|
||||
|
||||
@override
|
||||
State<_Frame> createState() => _FrameState();
|
||||
}
|
||||
|
||||
class _FrameState extends State<_Frame> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _sweep = AnimationController(vsync: this, duration: _kSweep)
|
||||
..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sweep.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final found = widget.found;
|
||||
return SizedBox.square(
|
||||
dimension: _kFrame,
|
||||
child: CustomPaint(
|
||||
painter: const _DimOutside(),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: TweenAnimationBuilder<Color?>(
|
||||
tween: ColorTween(end: found ? _kFoundGreen : AppColors.primary),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
builder: (context, color, _) => CustomPaint(painter: _Corners(color ?? AppColors.primary)),
|
||||
),
|
||||
),
|
||||
if (!found)
|
||||
AnimatedBuilder(
|
||||
animation: _sweep,
|
||||
builder: (context, child) {
|
||||
final t = Curves.easeInOut.transform(_sweep.value);
|
||||
return Positioned(left: 10, right: 10, top: 12 + (_kFrame - 28) * t, child: child!);
|
||||
},
|
||||
child: Container(
|
||||
height: 2,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(colors: [Color(0x00FF7500), AppColors.primary, Color(0x00FF7500)]),
|
||||
boxShadow: [BoxShadow(color: Color(0xB3FF7500), blurRadius: 14, spreadRadius: 2)],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
opacity: found ? 1 : 0,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: const Cubic(.34, 1.5, .5, 1),
|
||||
scale: found ? 1 : .5,
|
||||
child: Container(
|
||||
width: _kCheckSize,
|
||||
height: _kCheckSize,
|
||||
decoration: const BoxDecoration(
|
||||
color: _kFoundGreen,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Color(0x804ED08A), offset: Offset(0, 10), blurRadius: 30)],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.check, size: 36, stroke: 3, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 프레임 바깥 화면 전체를 어둡게 (시안 box-shadow 0 0 0 2000px)
|
||||
class _DimOutside extends CustomPainter {
|
||||
const _DimOutside();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const reach = 4000.0;
|
||||
final outside = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Rect.fromLTRB(-reach, -reach, size.width + reach, size.height + reach))
|
||||
..addRRect(RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(_kFrameRadius)));
|
||||
canvas.drawPath(outside, Paint()..color = _kDim);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DimOutside oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// 네 모서리 표시 — 프레임 테두리를 따라 굵기 4, 길이 36
|
||||
class _Corners extends CustomPainter {
|
||||
const _Corners(this.color);
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = _kCornerStroke
|
||||
..strokeCap = StrokeCap.butt;
|
||||
const r = 16.0; // 시안 바깥 반경 18 − 굵기 절반
|
||||
const reach = _kCorner - _kCornerStroke / 2;
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
final rect = Rect.fromCircle(center: Offset.zero, radius: r);
|
||||
final corners = <(Offset, double)>[
|
||||
(Offset.zero, math.pi), // 왼쪽 위
|
||||
(Offset(w, 0), -math.pi / 2), // 오른쪽 위
|
||||
(Offset(w, h), 0), // 오른쪽 아래
|
||||
(Offset(0, h), math.pi / 2), // 왼쪽 아래
|
||||
];
|
||||
for (final (corner, start) in corners) {
|
||||
// 모서리를 원점으로 옮겨 같은 모양(가로·세로 직선 + 사분원)을 돌려 그린다
|
||||
canvas
|
||||
..save()
|
||||
..translate(corner.dx, corner.dy)
|
||||
..rotate(start - math.pi);
|
||||
final path = Path()
|
||||
..moveTo(0, reach)
|
||||
..lineTo(0, r)
|
||||
..arcTo(rect.shift(const Offset(r, r)), math.pi, math.pi / 2, false)
|
||||
..lineTo(reach, 0);
|
||||
canvas
|
||||
..drawPath(path, paint)
|
||||
..restore();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_Corners oldDelegate) => oldDelegate.color != color;
|
||||
}
|
||||
|
||||
/// 안내 문구 + "명함 코드 입력하기"
|
||||
class _Bottom extends StatelessWidget {
|
||||
const _Bottom({required this.found, required this.camera, required this.onManual});
|
||||
|
||||
final bool found;
|
||||
|
||||
/// 카메라 상태 — 쓸 수 없으면 안내 문구를 바꾼다 (테스트 카메라면 null)
|
||||
final MobileScannerController? camera;
|
||||
final VoidCallback onManual;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final camera = this.camera;
|
||||
final hint = camera == null
|
||||
? _hint(found: found, unavailable: false)
|
||||
: ValueListenableBuilder<MobileScannerState>(
|
||||
valueListenable: camera,
|
||||
builder: (context, state, _) => _hint(found: found, unavailable: state.error != null),
|
||||
);
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, 0, 24, math.max(MediaQuery.paddingOf(context).bottom + 96, 150)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
hint,
|
||||
// 찾은 뒤에는 버튼을 숨기되 자리는 지켜 프레임이 움직이지 않게 한다
|
||||
Visibility.maintain(
|
||||
visible: !found,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 22),
|
||||
child: Pressable(
|
||||
pressedScale: .96,
|
||||
onTap: onManual,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x33FF7500),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: const Color(0x9EFF963C), width: 1.5),
|
||||
boxShadow: const [BoxShadow(color: Color(0x38FF7500), offset: Offset(0, 4), blurRadius: 18)],
|
||||
),
|
||||
child: const Text(
|
||||
'명함 코드 입력하기',
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 15.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _hint({required bool found, required bool unavailable}) {
|
||||
final text = found
|
||||
? '명함을 찾았어요! 정보 불러오는 중…'
|
||||
: unavailable
|
||||
? '카메라를 쓸 수 없어요.\n설정에서 카메라 권한을 허용하거나 명함 코드를 입력해 주세요.'
|
||||
: '상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.';
|
||||
return Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13.5, height: 1.55, color: Color(0xD1FFFFFF)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/router/app_routes.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
|
||||
@@ -23,16 +24,6 @@ import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
import 'package:tma_app/shared/widgets/seg_toggle.dart';
|
||||
import 'package:tma_app/shared/widgets/sub_page_header.dart';
|
||||
|
||||
/// 명함 보기 방식 — 간략히(명함만) / 상세히(건강 정보까지)
|
||||
enum CardViewMode {
|
||||
simple('간략히'),
|
||||
detail('상세히');
|
||||
|
||||
const CardViewMode(this.label);
|
||||
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 펫 명함 상세 — 보기 방식 전환, 공유, 꾸미기, 삭제, 대표 명함 지정
|
||||
class PetCardDetailPage extends ConsumerStatefulWidget {
|
||||
const PetCardDetailPage({super.key, required this.cardId});
|
||||
@@ -47,15 +38,19 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
|
||||
CardViewMode _mode = CardViewMode.detail;
|
||||
final _capture = CaptureController();
|
||||
|
||||
Future<void> _share(String petName) {
|
||||
/// 지금 보이는 모양(간략히·상세히)이 그대로 공유된다 — 받는 사람도 이 모양으로 본다
|
||||
Future<void> _share(String petName) async {
|
||||
final card = ref.read(petCardsProvider).value?.cards.where((c) => c.id == widget.cardId).firstOrNull;
|
||||
final mode = _mode;
|
||||
if (!await succeeded(ref.read(petCardsProvider.notifier).setShareMode(widget.cardId, mode))) return;
|
||||
if (!mounted) return;
|
||||
return showPetCardShareSheet(
|
||||
context,
|
||||
petName: petName,
|
||||
code: card == null ? petCardCode(widget.cardId) : displayCardCode(card),
|
||||
capture: _capture.capturePng,
|
||||
who: TextSpan(children: [
|
||||
TextSpan(text: '${_mode.label} 모드', style: const TextStyle(color: AppColors.primary600)),
|
||||
TextSpan(text: '${mode.label} 모드', style: const TextStyle(color: AppColors.primary600)),
|
||||
const TextSpan(text: ' 펫 명함으로 공유돼요'),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -118,17 +118,17 @@ class _PetCardManagePageState extends ConsumerState<PetCardManagePage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _ManagedCard extends StatefulWidget {
|
||||
class _ManagedCard extends ConsumerStatefulWidget {
|
||||
const _ManagedCard({required this.card, required this.pet});
|
||||
|
||||
final PetCard card;
|
||||
final Pet pet;
|
||||
|
||||
@override
|
||||
State<_ManagedCard> createState() => _ManagedCardState();
|
||||
ConsumerState<_ManagedCard> createState() => _ManagedCardState();
|
||||
}
|
||||
|
||||
class _ManagedCardState extends State<_ManagedCard> {
|
||||
class _ManagedCardState extends ConsumerState<_ManagedCard> {
|
||||
final _capture = CaptureController();
|
||||
|
||||
@override
|
||||
@@ -140,7 +140,7 @@ class _ManagedCardState extends State<_ManagedCard> {
|
||||
child: NameCard(
|
||||
pet: widget.pet,
|
||||
style: widget.card.style,
|
||||
onShare: () => unawaited(sharePetCard(context, pet: widget.pet, card: widget.card, capture: _capture)),
|
||||
onShare: () => unawaited(sharePetCard(context, ref, pet: widget.pet, card: widget.card, capture: _capture)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/router/app_routes.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet.dart';
|
||||
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
|
||||
import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.dart';
|
||||
import 'package:tma_app/features/pet/presentation/widgets/pet_picker.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/receive_card_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/crown_mark.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/name_card_empty.dart';
|
||||
@@ -20,13 +27,18 @@ import 'package:tma_app/features/pet_card/presentation/widgets/share_sheet.dart'
|
||||
import 'package:tma_app/shared/widgets/capture_boundary.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/pressable.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
import 'package:tma_app/shared/widgets/section_title.dart';
|
||||
|
||||
const double _kSearchSize = 38;
|
||||
const Duration _kSearchAnim = Duration(milliseconds: 340);
|
||||
const Cubic _kSearchCurve = Cubic(.4, 0, .2, 1);
|
||||
|
||||
/// 펼친 검색창 내용(돋보기·입력·닫기)이 넘치지 않는 최소 폭 — 펼쳐지는 동안은 이 폭으로 그리고 잘라 낸다
|
||||
const double _kSearchOpenMinWidth = 140;
|
||||
|
||||
/// 받은 명함 줄 끝 화살표 (시안 #CFC6B9)
|
||||
const Color _kChevron = Color(0xFFCFC6B9);
|
||||
|
||||
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유
|
||||
const double _kBottomGap = 30;
|
||||
|
||||
@@ -127,7 +139,7 @@ class _RepresentativeCard extends ConsumerWidget {
|
||||
child: NameCard(
|
||||
pet: pet,
|
||||
style: card.style,
|
||||
onShare: () => unawaited(sharePetCard(context, pet: pet, card: card, capture: capture)),
|
||||
onShare: () => unawaited(sharePetCard(context, ref, pet: pet, card: card, capture: capture)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -136,13 +148,17 @@ class _RepresentativeCard extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 목록에서 명함 공유 — 명함 아래 안내에 아이 이름을 쓴다
|
||||
/// 목록에서 명함 공유 — 명함 아래 안내에 아이 이름을 쓴다.
|
||||
/// 목록에 보이는 간략히 명함이 그대로 공유된다 (받는 사람도 간략히로 본다)
|
||||
Future<void> sharePetCard(
|
||||
BuildContext context, {
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required Pet pet,
|
||||
required PetCard card,
|
||||
required CaptureController capture,
|
||||
}) {
|
||||
}) async {
|
||||
if (!await succeeded(ref.read(petCardsProvider.notifier).setShareMode(card.id, CardViewMode.simple))) return;
|
||||
if (!context.mounted) return;
|
||||
return showPetCardShareSheet(
|
||||
context,
|
||||
petName: pet.name,
|
||||
@@ -155,7 +171,7 @@ Future<void> sharePetCard(
|
||||
);
|
||||
}
|
||||
|
||||
/// 보관된(받은) 명함 — 받기 기능은 서버 연동 후 연다
|
||||
/// 보관된(받은) 명함 — 명함 받기 · 검색 · 목록
|
||||
class _ReceivedSection extends ConsumerStatefulWidget {
|
||||
const _ReceivedSection();
|
||||
|
||||
@@ -168,6 +184,13 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
|
||||
final _query = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 검색어가 바뀌면 목록을 다시 거른다
|
||||
_query.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_query.dispose();
|
||||
@@ -191,8 +214,9 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
// 받은 명함 저장소는 명함 받기(서버 조회)와 함께 만든다 — 지금은 항상 비어 있다
|
||||
const receivedCount = 0;
|
||||
final received = ref.watch(receivedCardsProvider).value ?? const <ReceivedCard>[];
|
||||
final query = _query.text.trim();
|
||||
final shown = received.where((c) => c.matches(query)).toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -212,7 +236,7 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
|
||||
duration: const Duration(milliseconds: 180),
|
||||
opacity: _searchOpen ? 0 : 1,
|
||||
child: Text(
|
||||
'보관된 명함 $receivedCount',
|
||||
'보관된 명함 ${received.length}',
|
||||
style: textTheme.bodyMedium!.copyWith(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.muted),
|
||||
),
|
||||
),
|
||||
@@ -237,7 +261,7 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
|
||||
const SizedBox(width: 5),
|
||||
Pressable(
|
||||
pressedScale: .96,
|
||||
onTap: () => ref.read(toastProvider.notifier).show('명함 받기는 곧 열릴 예정이에요', icon: RupIconName.qrcode),
|
||||
onTap: () => unawaited(startReceivingCard(context, ref)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: AppSpacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
@@ -260,20 +284,78 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
|
||||
),
|
||||
// 시안: 제목 줄 아래 12 + 목록 위 2
|
||||
const SizedBox(height: 14),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 64, 28, 48),
|
||||
// 시안: 받은 명함이 한 장도 없으면 검색어와 상관없이 이 문구
|
||||
child: Text(
|
||||
'받은 명함이 없어요.',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted),
|
||||
),
|
||||
),
|
||||
if (received.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 64, 28, 48),
|
||||
// 시안: 받은 명함이 한 장도 없으면 검색어와 상관없이 이 문구
|
||||
child: Text(
|
||||
'받은 명함이 없어요.',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted),
|
||||
),
|
||||
)
|
||||
else if (shown.isEmpty)
|
||||
EmptyMessage("'$query' 검색 결과가 없어요.", padding: 28)
|
||||
else
|
||||
for (final card in shown)
|
||||
_ReceivedRow(card: card, onTap: () => unawaited(context.push(AppRoutes.receivedCard(card.id)))),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 받은 명함 한 줄 — 아이 사진 · 이름 · 보낸 사람 (시안 .rup-received__row)
|
||||
class _ReceivedRow extends StatelessWidget {
|
||||
const _ReceivedRow({required this.card, required this.onTap});
|
||||
|
||||
final ReceivedCard card;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final sub = textTheme.bodySmall!.copyWith(fontSize: 13, color: AppColors.muted);
|
||||
return Pressable(
|
||||
pressedScale: 1,
|
||||
pressedOpacity: .7,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
PetAvatar(pet: card.pet, size: 46),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(card.pet.name, style: textTheme.bodyLarge!.copyWith(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text.rich(
|
||||
TextSpan(children: [
|
||||
TextSpan(
|
||||
text: card.senderName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.ink2),
|
||||
),
|
||||
const TextSpan(text: '님이 보냈어요.'),
|
||||
]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: sub,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
const RupIcon(RupIconName.chevR, size: 19, color: _kChevron),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 오른쪽 돋보기 버튼에서 왼쪽으로 펼쳐지는 검색창 (시안 .rup-searchx)
|
||||
class _SearchBox extends StatelessWidget {
|
||||
const _SearchBox({
|
||||
@@ -312,42 +394,51 @@ class _SearchBox extends StatelessWidget {
|
||||
boxShadow: open ? [BoxShadow(color: AppColors.primary.withValues(alpha: .14), spreadRadius: 3)] : null,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Row(
|
||||
mainAxisAlignment: open ? MainAxisAlignment.start : MainAxisAlignment.center,
|
||||
children: [
|
||||
const RupIcon(RupIconName.search, size: 20, color: AppColors.ink2),
|
||||
if (open) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
style: Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14.5, height: 1.2),
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: '명함 검색',
|
||||
hintStyle: Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14.5, color: AppColors.muted),
|
||||
),
|
||||
),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: '검색 닫기',
|
||||
child: GestureDetector(
|
||||
onTap: onClose,
|
||||
child: Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(color: AppColors.surface2, borderRadius: BorderRadius.circular(AppSpacing.sm)),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.x, size: 17, stroke: 2.2, color: AppColors.ink2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = math.max(constraints.maxWidth, open ? _kSearchOpenMinWidth : 0.0);
|
||||
return OverflowBox(alignment: Alignment.centerLeft, minWidth: width, maxWidth: width, child: _row(context));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: open ? MainAxisAlignment.start : MainAxisAlignment.center,
|
||||
children: [
|
||||
const RupIcon(RupIconName.search, size: 20, color: AppColors.ink2),
|
||||
if (open) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
style: Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14.5, height: 1.2),
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: '명함 검색',
|
||||
hintStyle: Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14.5, color: AppColors.muted),
|
||||
),
|
||||
),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: '검색 닫기',
|
||||
child: GestureDetector(
|
||||
onTap: onClose,
|
||||
child: Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(color: AppColors.surface2, borderRadius: BorderRadius.circular(AppSpacing.sm)),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.x, size: 17, stroke: 2.2, color: AppColors.ink2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/router/app_routes.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/card_scanner_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/card_code_sheet.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/received_card_info.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_button.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
import 'package:tma_app/shared/widgets/section_title.dart';
|
||||
|
||||
/// 명함 받기 — 스캐너(또는 코드 입력)로 명함을 찾아 저장 확인 화면을 연다
|
||||
Future<void> startReceivingCard(BuildContext context, WidgetRef ref) async {
|
||||
if (!ref.read(receivedCardsProvider.notifier).available) {
|
||||
ref.read(toastProvider.notifier).show('기기 저장 모드에서는 명함을 받을 수 없어요', icon: RupIconName.qrcode);
|
||||
return;
|
||||
}
|
||||
final outcome = await context.push<ScanOutcome>(AppRoutes.petCardScan);
|
||||
if (!context.mounted) return;
|
||||
final card = switch (outcome) {
|
||||
ScanFound(:final card) => card,
|
||||
ScanTypeCode() => await showCardCodeSheet(context),
|
||||
null => null,
|
||||
};
|
||||
if (card == null || !context.mounted) return;
|
||||
await context.push(AppRoutes.petCardReceive, extra: card);
|
||||
}
|
||||
|
||||
/// 받은 명함 저장 확인 — 명함을 보고 메모를 남긴 뒤 보관함에 저장한다
|
||||
class ReceiveCardPage extends ConsumerStatefulWidget {
|
||||
const ReceiveCardPage({super.key, required this.card, this.today});
|
||||
|
||||
final SharedCard card;
|
||||
|
||||
/// 받은 날짜 기준 (테스트용). 없으면 오늘
|
||||
final DateTime? today;
|
||||
|
||||
@override
|
||||
ConsumerState<ReceiveCardPage> createState() => _ReceiveCardPageState();
|
||||
}
|
||||
|
||||
class _ReceiveCardPageState extends ConsumerState<ReceiveCardPage> {
|
||||
final _memo = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 글자 수 표시를 갱신한다
|
||||
_memo.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_memo.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_saving) return;
|
||||
setState(() => _saving = true);
|
||||
final note = _memo.text.trim();
|
||||
final name = widget.card.pet.name;
|
||||
final saved = await succeeded(
|
||||
ref.read(receivedCardsProvider.notifier).receive(widget.card.code, note: note.isEmpty ? null : note),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!saved) {
|
||||
setState(() => _saving = false);
|
||||
return;
|
||||
}
|
||||
context.pop();
|
||||
ref.read(toastProvider.notifier).show('$name 명함을 보관함에 저장했어요');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = widget.card;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xs,
|
||||
AppSpacing.xl,
|
||||
MediaQuery.paddingOf(context).bottom + AppSpacing.xxl,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Header(title: '${card.pet.name}의 명함', onBack: () => context.pop()),
|
||||
NameCard(pet: card.pet, style: card.style, today: widget.today),
|
||||
const DividerBand(top: 30),
|
||||
ReceivedCardInfo(card: card, receivedAt: widget.today ?? DateTime.now()),
|
||||
const SizedBox(height: 14),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: AppSpacing.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
const RupIcon(RupIconName.edit, size: 15, stroke: 2.2, color: AppColors.ink),
|
||||
const SizedBox(width: 6),
|
||||
Text('내 메모', style: textTheme.bodyMedium!.copyWith(fontSize: 13.5, fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
_MemoField(controller: _memo),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 5),
|
||||
child: Text(
|
||||
'${_memo.text.characters.length}/$kReceivedNoteMaxLength',
|
||||
textAlign: TextAlign.right,
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 11.5, color: AppColors.muted),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
RupButton(label: '보관함에 저장', dimmed: _saving, onPressed: () => unawaited(_save())),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 가운데 제목 머리 — 아래 구분선 (시안 저장 확인 화면)
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({required this.title, required this.onBack});
|
||||
|
||||
final String title;
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 18),
|
||||
padding: const EdgeInsets.only(top: 2, bottom: AppSpacing.lg),
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.line2))),
|
||||
child: Row(
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
label: '뒤로',
|
||||
child: GestureDetector(
|
||||
onTap: onBack,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.xs),
|
||||
child: RupIcon(RupIconName.chevL, color: AppColors.ink),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 30),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 받은 명함 메모칸 (시안 textarea — 바탕 surface-2, 모서리 14, 최소 높이 92)
|
||||
class _MemoField extends StatelessWidget {
|
||||
const _MemoField({required this.controller});
|
||||
|
||||
final TextEditingController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14, height: 1.55, color: AppColors.ink);
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: 92),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface2,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.line),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.multiline,
|
||||
minLines: 3,
|
||||
maxLines: null,
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(kReceivedNoteMaxLength)],
|
||||
style: style,
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: '이 명함에 대해 기억할 내용을 적어두세요. (어디서 만났는지, 특징 등)',
|
||||
hintStyle: style.copyWith(color: AppColors.muted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/pet_id_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/received_card_info.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
import 'package:tma_app/shared/widgets/section_title.dart';
|
||||
import 'package:tma_app/shared/widgets/sub_page_header.dart';
|
||||
|
||||
/// 보관함의 받은 명함 — 보낸 사람이 공유한 모양 그대로 보여 준다
|
||||
class ReceivedCardDetailPage extends ConsumerWidget {
|
||||
const ReceivedCardDetailPage({super.key, required this.receivedId});
|
||||
|
||||
final String receivedId;
|
||||
|
||||
Future<void> _delete(BuildContext context, WidgetRef ref, ReceivedCard card) async {
|
||||
// 화면을 닫은 뒤에도 쓰도록 먼저 꺼내 둔다
|
||||
final cards = ref.read(receivedCardsProvider.notifier);
|
||||
final toast = ref.read(toastProvider.notifier);
|
||||
// 먼저 화면을 닫아 지워진 명함을 그리지 않게 한다 (시안: 확인 없이 바로 삭제)
|
||||
context.pop();
|
||||
if (!await succeeded(cards.delete(card.id))) return;
|
||||
toast.show('보관함에서 삭제했어요');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final card = ref.watch(receivedCardByIdProvider(receivedId));
|
||||
if (card == null) return const Scaffold(body: SizedBox.shrink());
|
||||
final detail = card.mode == CardViewMode.detail;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xs,
|
||||
AppSpacing.xl,
|
||||
MediaQuery.paddingOf(context).bottom + 40,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SubPageHeader(
|
||||
title: card.pet.name,
|
||||
subtitle: '받은 명함 · ${card.senderName}님',
|
||||
displayFont: true,
|
||||
bottomGap: 16,
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
if (detail)
|
||||
PetIdCard(pet: card.pet, style: card.style)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 18),
|
||||
child: NameCard(pet: card.pet, style: card.style),
|
||||
),
|
||||
DividerBand(top: detail ? 52 : 30),
|
||||
ReceivedCardInfo(card: card, receivedAt: card.receivedAt, showNote: true, note: card.note),
|
||||
const SizedBox(height: 52),
|
||||
_DeleteButton(onTap: () => unawaited(_delete(context, ref, card))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteButton extends StatelessWidget {
|
||||
const _DeleteButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xs),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const RupIcon(RupIconName.x, size: 16, stroke: 2.2, color: AppColors.catHospital),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'보관함에서 삭제',
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.catHospital,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:tma_app/features/pet/domain/pet.dart';
|
||||
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
|
||||
import 'package:tma_app/features/pet_card/data/pet_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
|
||||
|
||||
/// 내가 만든 명함 + 아이별 대표 명함
|
||||
@@ -66,6 +67,9 @@ class PetCardNotifier extends AsyncNotifier<PetCardState> {
|
||||
));
|
||||
}
|
||||
|
||||
/// 공유 시트를 열 때 — 받는 사람이 볼 모양을 서버에 알린다
|
||||
Future<void> setShareMode(String cardId, CardViewMode mode) => _repo.setShareMode(cardId, mode);
|
||||
|
||||
Future<void> setRepresentative(PetCard card) async {
|
||||
await _repo.setRepresentative(card.petId, card.id);
|
||||
_emit(_current.copyWith(representatives: {..._current.representatives, card.petId: card.id}));
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/features/pet_card/data/received_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
|
||||
/// 보관함의 받은 명함 — 최근에 받은 명함이 앞
|
||||
class ReceivedCardNotifier extends AsyncNotifier<List<ReceivedCard>> {
|
||||
ReceivedCardRepository get _repo => ref.read(receivedCardRepositoryProvider);
|
||||
|
||||
@override
|
||||
Future<List<ReceivedCard>> build() async {
|
||||
// 회원이 바뀌면(로그인·로그아웃) 다시 읽는다
|
||||
ref.watch(sessionEpochProvider);
|
||||
return ref.watch(receivedCardRepositoryProvider).fetch();
|
||||
}
|
||||
|
||||
List<ReceivedCard> get _current => state.value ?? const [];
|
||||
|
||||
void _emit(List<ReceivedCard> next) {
|
||||
if (ref.mounted) state = AsyncData(next);
|
||||
}
|
||||
|
||||
/// 명함 받기를 쓸 수 있는지 — 기기 저장 모드에서는 받을 명함이 없다
|
||||
bool get available => _repo.available;
|
||||
|
||||
/// 받기 전 미리보기 (명함 코드로 조회)
|
||||
Future<SharedCard> preview(String code) => _repo.preview(code);
|
||||
|
||||
Future<ReceivedCard> receive(String code, {String? note}) async {
|
||||
final card = await _repo.receive(code, note: note);
|
||||
_emit([card, ..._current]);
|
||||
return card;
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await _repo.delete(id);
|
||||
_emit(_current.where((c) => c.id != id).toList());
|
||||
}
|
||||
}
|
||||
|
||||
final receivedCardsProvider = AsyncNotifierProvider<ReceivedCardNotifier, List<ReceivedCard>>(
|
||||
ReceivedCardNotifier.new,
|
||||
);
|
||||
|
||||
final receivedCardByIdProvider = Provider.family<ReceivedCard?, String>((ref, id) {
|
||||
return ref.watch(receivedCardsProvider).value?.where((c) => c.id == id).firstOrNull;
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/api/error_lexicon.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_sheet.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
|
||||
/// 명함 코드 직접 입력 — 찾은 명함을 돌려준다 (못 찾았거나 닫으면 null)
|
||||
Future<SharedCard?> showCardCodeSheet(BuildContext context) {
|
||||
return showRupSheet<SharedCard>(
|
||||
context,
|
||||
title: '명함코드 입력하기',
|
||||
flushBottom: true,
|
||||
builder: (context) => const _CodeBody(),
|
||||
);
|
||||
}
|
||||
|
||||
class _CodeBody extends ConsumerStatefulWidget {
|
||||
const _CodeBody();
|
||||
|
||||
@override
|
||||
ConsumerState<_CodeBody> createState() => _CodeBodyState();
|
||||
}
|
||||
|
||||
class _CodeBodyState extends ConsumerState<_CodeBody> {
|
||||
final _code = TextEditingController();
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 입력이 생기면 버튼을 켠다
|
||||
_code.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_code.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_busy || _code.text.trim().isEmpty) return;
|
||||
final code = parseCardCode(_code.text);
|
||||
if (code == null) {
|
||||
// 명함 코드 모양이 아니다 — 서버가 못 찾았을 때와 같은 안내
|
||||
ref.read(toastProvider.notifier).show(ErrorLexicon.getMessage('CARD_002'), icon: RupIconName.alert);
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final card = await ref.read(receivedCardsProvider.notifier).preview(code);
|
||||
if (mounted) Navigator.of(context).pop(card);
|
||||
} catch (e) {
|
||||
if (!isReportedApiFailure(e)) rethrow;
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final ready = _code.text.trim().isNotEmpty && !_busy;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 18),
|
||||
child: Text(
|
||||
'아이 명함의 코드를 입력해주세요.\n명함 코드는 내 아이 명함 공유하기 버튼을 누르면 확인할 수 있어요.',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: 1.5),
|
||||
),
|
||||
),
|
||||
RupField(
|
||||
label: '명함 코드',
|
||||
child: RupTextField(
|
||||
controller: _code,
|
||||
hintText: '예: 0428-117',
|
||||
autofocus: true,
|
||||
// 하이픈 없이 7자리만 넣어도 받는다 (parseCardCode)
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
textAlign: TextAlign.center,
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 18,
|
||||
letterSpacing: 1,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
),
|
||||
onSubmitted: (_) => unawaited(_submit()),
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity: ready ? 1 : .45,
|
||||
child: RupSheetBleedButton(label: '보관함에 저장', onPressed: () => unawaited(_submit())),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/core/utils/date_format.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/photo_image.dart';
|
||||
|
||||
// 시안 InfoRow 아이콘 색
|
||||
const Color _kDateTint = Color(0xFFC084FC);
|
||||
const Color _kNoteTint = Color(0xFF5BA8D8);
|
||||
const double _kIconBox = 36;
|
||||
|
||||
/// 받은 명함의 "명함 정보" — 보낸 사람 · 받은 날짜 (+ 내 메모)
|
||||
class ReceivedCardInfo extends StatelessWidget {
|
||||
const ReceivedCardInfo({
|
||||
super.key,
|
||||
required this.card,
|
||||
required this.receivedAt,
|
||||
this.showNote = false,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final SharedCard card;
|
||||
final DateTime receivedAt;
|
||||
|
||||
/// 보관함의 받은 명함이면 내 메모 줄도 보여 준다 (없으면 "남긴 메모가 없어요.")
|
||||
final bool showNote;
|
||||
final String? note;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final value = textTheme.bodyMedium!.copyWith(fontSize: 14.5, fontWeight: FontWeight.w700, color: AppColors.ink);
|
||||
final memo = note;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(2, 0, 2, 6),
|
||||
child: Text('명함 정보', style: textTheme.titleSmall!.copyWith(fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_InfoRow(
|
||||
first: true,
|
||||
tint: AppColors.primary,
|
||||
icon: _SenderMark(card: card),
|
||||
label: '보낸 사람',
|
||||
child: Text.rich(
|
||||
TextSpan(children: [
|
||||
TextSpan(text: card.senderName),
|
||||
const TextSpan(text: '님', style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.ink2)),
|
||||
]),
|
||||
style: value,
|
||||
),
|
||||
),
|
||||
_InfoRow(
|
||||
tint: _kDateTint,
|
||||
icon: const RupIcon(RupIconName.calendar, size: 18, stroke: 2, color: _kDateTint),
|
||||
label: '받은 날짜',
|
||||
child: Text(formatYmd(receivedAt), style: value),
|
||||
),
|
||||
if (showNote)
|
||||
_InfoRow(
|
||||
tint: _kNoteTint,
|
||||
icon: const RupIcon(RupIconName.text, size: 18, stroke: 2, color: _kNoteTint),
|
||||
label: '내 메모',
|
||||
child: Text(
|
||||
memo ?? '남긴 메모가 없어요.',
|
||||
style: textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 13.5,
|
||||
height: 1.6,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: memo == null ? AppColors.muted : AppColors.ink2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 시안 InfoRow — 색 상자 아이콘 + 라벨 + 값
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({
|
||||
required this.tint,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.first = false,
|
||||
});
|
||||
|
||||
final Color tint;
|
||||
final Widget icon;
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool first;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: _kIconBox,
|
||||
height: _kIconBox,
|
||||
decoration: BoxDecoration(
|
||||
// 시안 color-mix(tint 14%, transparent)
|
||||
color: tint.withValues(alpha: .14),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
alignment: Alignment.center,
|
||||
child: icon,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 보낸 사람 — 프로필 사진이 있으면 사진, 없으면 닉네임 첫 글자 (시안 SenderAvatar)
|
||||
class _SenderMark extends StatelessWidget {
|
||||
const _SenderMark({required this.card});
|
||||
|
||||
final SharedCard card;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initial = Text(
|
||||
card.senderName.characters.take(1).toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
);
|
||||
final photo = card.senderPhotoPath;
|
||||
if (photo == null) return initial;
|
||||
return SizedBox.expand(child: PhotoImage(path: photo, fallback: Center(child: initial)));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/storage/album_saver.dart';
|
||||
import 'package:tma_app/core/theme/app_shadows.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/shared/widgets/blurred_modal.dart';
|
||||
import 'package:tma_app/shared/widgets/capture_boundary.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
@@ -16,9 +17,6 @@ import 'package:tma_app/shared/widgets/rup_button.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_sheet.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
|
||||
/// QR 에 담는 주소 — 앱 딥링크. 서버가 명함 조회 API 를 열면 같은 코드로 찾는다
|
||||
String petCardDeepLink(String code) => 'rup://pet-card/$code';
|
||||
|
||||
const double _kQrSize = 220;
|
||||
const double _kQrBadge = 54;
|
||||
|
||||
|
||||
@@ -7,16 +7,16 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/api/dio_client.dart';
|
||||
import 'package:tma_app/core/api/dio_provider.dart';
|
||||
import 'package:tma_app/core/api/file_uploader.dart';
|
||||
import 'package:tma_app/core/api/server_file.dart';
|
||||
|
||||
/// 서버에 올린 사진 — 앱의 HTTP 클라이언트(Dio)로 받아 토큰 첨부·갱신을 그대로 쓴다.
|
||||
/// 서버의 사진 — 앱의 HTTP 클라이언트(Dio)로 받아 토큰 첨부·갱신을 그대로 쓴다.
|
||||
/// (Image.network 에 토큰을 직접 붙이면 액세스 토큰이 만료된 뒤 사진만 깨진다)
|
||||
@immutable
|
||||
class ServerFileImage extends ImageProvider<ServerFileImage> {
|
||||
const ServerFileImage(this.fileId, this.dio);
|
||||
const ServerFileImage(this.apiPath, this.dio);
|
||||
|
||||
final String fileId;
|
||||
/// baseUrl 아래 경로 — `/files/<id>` 또는 `/shared-pet-cards/<code>/images/pet` 등
|
||||
final String apiPath;
|
||||
final Dio dio;
|
||||
|
||||
@override
|
||||
@@ -29,7 +29,7 @@ class ServerFileImage extends ImageProvider<ServerFileImage> {
|
||||
|
||||
Future<ui.Codec> _load(ImageDecoderCallback decode) async {
|
||||
final response = await dio.get<List<int>>(
|
||||
'$kFilesPath/$fileId',
|
||||
apiPath,
|
||||
// 사진 하나가 안 보여도 알림을 띄우지 않는다 — 화면은 빈칸(기본 그림)으로 둔다
|
||||
options: Options(responseType: ResponseType.bytes, extra: {kSilentExtra: true}),
|
||||
);
|
||||
@@ -37,12 +37,12 @@ class ServerFileImage extends ImageProvider<ServerFileImage> {
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
/// 파일 id 가 같으면 같은 사진이다 — 서버 파일은 id 별로 내용이 바뀌지 않는다
|
||||
/// 경로가 같으면 같은 사진이다 — 서버 파일은 id 별로 내용이 바뀌지 않는다
|
||||
@override
|
||||
bool operator ==(Object other) => other is ServerFileImage && other.fileId == fileId;
|
||||
bool operator ==(Object other) => other is ServerFileImage && other.apiPath == apiPath;
|
||||
|
||||
@override
|
||||
int get hashCode => fileId.hashCode;
|
||||
int get hashCode => apiPath.hashCode;
|
||||
}
|
||||
|
||||
/// 사진 한 장 — 서버 파일 · 기기 파일 · 웹 미리보기(브라우저 임시 주소)를 같은 자리에 그린다.
|
||||
@@ -63,9 +63,9 @@ class PhotoImage extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget error(BuildContext context, Object e, StackTrace? s) => fallback;
|
||||
|
||||
final fileId = serverFileIdOf(path);
|
||||
if (fileId != null) {
|
||||
return Image(image: ServerFileImage(fileId, ref.watch(dioProvider)), fit: fit, errorBuilder: error);
|
||||
final apiPath = serverPathOf(path);
|
||||
if (apiPath != null) {
|
||||
return Image(image: ServerFileImage(apiPath, ref.watch(dioProvider)), fit: fit, errorBuilder: error);
|
||||
}
|
||||
if (kIsWeb) return Image.network(path, fit: fit, errorBuilder: error);
|
||||
return Image.file(File(path), fit: fit, errorBuilder: error);
|
||||
|
||||
@@ -10,6 +10,7 @@ class Pressable extends StatefulWidget {
|
||||
required this.onTap,
|
||||
this.pressedScale = 0.95,
|
||||
this.pressedBrightness = 1,
|
||||
this.pressedOpacity = 1,
|
||||
this.semanticLabel,
|
||||
this.behavior = HitTestBehavior.opaque,
|
||||
});
|
||||
@@ -21,6 +22,9 @@ class Pressable extends StatefulWidget {
|
||||
/// 누르는 동안 밝기 — 시안 filter: brightness(.94) 등 (1 이면 그대로)
|
||||
final double pressedBrightness;
|
||||
|
||||
/// 누르는 동안 불투명도 — 시안 opacity: .7 (받은 명함 줄 등)
|
||||
final double pressedOpacity;
|
||||
|
||||
/// 스크린리더용 이름. 없으면 자식의 텍스트를 그대로 읽는다.
|
||||
final String? semanticLabel;
|
||||
final HitTestBehavior behavior;
|
||||
@@ -59,9 +63,13 @@ class _PressableState extends State<Pressable> {
|
||||
scale: _pressed ? widget.pressedScale : 1,
|
||||
duration: AppDurations.press,
|
||||
curve: Curves.easeOut,
|
||||
child: _pressed && widget.pressedBrightness != 1
|
||||
? ColorFiltered(colorFilter: _brightness(widget.pressedBrightness), child: widget.child)
|
||||
: widget.child,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _pressed ? widget.pressedOpacity : 1,
|
||||
duration: AppDurations.press,
|
||||
child: _pressed && widget.pressedBrightness != 1
|
||||
? ColorFiltered(colorFilter: _brightness(widget.pressedBrightness), child: widget.child)
|
||||
: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -124,6 +124,8 @@ class RupTextField extends StatefulWidget {
|
||||
this.prefix,
|
||||
this.suffix,
|
||||
this.focusNode,
|
||||
this.textAlign = TextAlign.start,
|
||||
this.textStyle,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
@@ -147,6 +149,10 @@ class RupTextField extends StatefulWidget {
|
||||
final Widget? suffix;
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// 글자 정렬·모양 — 명함 코드처럼 가운데 큰 글씨로 받는 칸
|
||||
final TextAlign textAlign;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
@override
|
||||
State<RupTextField> createState() => _RupTextFieldState();
|
||||
}
|
||||
@@ -174,7 +180,7 @@ class _RupTextFieldState extends State<RupTextField> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final base = _inputText(context);
|
||||
final style = widget.multiline ? base.copyWith(height: _kTextareaLineHeight) : base;
|
||||
final style = (widget.multiline ? base.copyWith(height: _kTextareaLineHeight) : base).merge(widget.textStyle);
|
||||
final field = TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focus,
|
||||
@@ -190,6 +196,7 @@ class _RupTextFieldState extends State<RupTextField> {
|
||||
minLines: widget.multiline ? 3 : 1,
|
||||
maxLines: widget.multiline ? null : 1,
|
||||
style: style,
|
||||
textAlign: widget.textAlign,
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: widget.hintText,
|
||||
|
||||
@@ -8,6 +8,7 @@ import Foundation
|
||||
import file_selector_macos
|
||||
import flutter_secure_storage_darwin
|
||||
import gal
|
||||
import mobile_scanner
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
|
||||
@@ -15,6 +16,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
|
||||
@@ -536,6 +536,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: "5a51ea79f0cf8293f616a5d08baae185b59b5e6bb24df01f2a63037ade0bade0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.4.2"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -46,6 +46,7 @@ dependencies:
|
||||
share_plus: ^13.3.0
|
||||
qr_flutter: ^4.1.0
|
||||
gal: ^2.3.3
|
||||
mobile_scanner: ^7.4.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/api/file_uploader.dart';
|
||||
import 'package:tma_app/features/pet_card/data/api_pet_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/data/received_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
|
||||
import 'support/fake_api.dart';
|
||||
|
||||
Map<String, Object?> _petJson({bool detail = false}) => {
|
||||
'name': '초코',
|
||||
'species': '강아지',
|
||||
'breed': '닥스훈트',
|
||||
'gender': 'male',
|
||||
'birthDate': '2021-05-14',
|
||||
'weight': 7,
|
||||
'weightUnit': 'kg',
|
||||
'microchip': null,
|
||||
'tint': '#C9794A',
|
||||
if (detail) ...{
|
||||
'neutered': true,
|
||||
'bloodType': 'DEA 1.1-',
|
||||
'vet': '드림 동물병원',
|
||||
'memo': '공놀이를 좋아해요',
|
||||
'allergies': <String>[],
|
||||
'conditions': <String>[],
|
||||
'watchConditions': ['허리'],
|
||||
},
|
||||
};
|
||||
|
||||
const Map<String, Object?> _styleJson = {
|
||||
'tint': '#C9794A',
|
||||
'tint2': '#E2A878',
|
||||
'nameBg': null,
|
||||
'nameBgAsset': null,
|
||||
'textLight': false,
|
||||
'scrim': false,
|
||||
'scrimTone': 'light',
|
||||
};
|
||||
|
||||
Map<String, Object?> _receivedJson({
|
||||
String id = 'r1',
|
||||
String receivedAt = '2026-10-10T03:00:00.000Z',
|
||||
String? note,
|
||||
String mode = 'simple',
|
||||
String? petPhotoFileId,
|
||||
}) =>
|
||||
{
|
||||
'id': id,
|
||||
'code': '0428-117',
|
||||
'mode': mode,
|
||||
'senderNickname': '초코아빠',
|
||||
'senderPhotoFileId': null,
|
||||
'pet': _petJson(detail: mode == 'detail'),
|
||||
'petPhotoFileId': petPhotoFileId,
|
||||
'style': _styleJson,
|
||||
'backgroundFileId': null,
|
||||
'note': note,
|
||||
'receivedAt': receivedAt,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('parseCardCode', () {
|
||||
test('QR 주소·코드·하이픈 없는 7자리를 모두 명함 코드로 읽는다', () {
|
||||
expect(parseCardCode('rup://pet-card/0428-117'), '0428-117');
|
||||
expect(parseCardCode(' 0428-117 '), '0428-117');
|
||||
expect(parseCardCode('0428117'), '0428-117');
|
||||
});
|
||||
|
||||
test('명함 코드가 아니면 null', () {
|
||||
expect(parseCardCode('https://example.com'), isNull);
|
||||
expect(parseCardCode('0428-11'), isNull);
|
||||
expect(parseCardCode('rup://pet-card/../files'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ApiReceivedCardRepository', () {
|
||||
late FakeApi api;
|
||||
late ApiReceivedCardRepository repo;
|
||||
|
||||
setUp(() {
|
||||
api = FakeApi();
|
||||
repo = ApiReceivedCardRepository(dio: api.dio, session: api.session);
|
||||
});
|
||||
|
||||
test('미리보기 사진은 명함 코드 경로로 받는다 (아직 내 파일이 아니다)', () async {
|
||||
api.on('GET', '/shared-pet-cards/:code', (r) => ok({
|
||||
'code': r.params['code'],
|
||||
'mode': 'detail',
|
||||
'senderNickname': null,
|
||||
'hasSenderPhoto': false,
|
||||
'pet': _petJson(detail: true),
|
||||
'hasPetPhoto': true,
|
||||
'style': _styleJson,
|
||||
'hasBackgroundPhoto': true,
|
||||
}));
|
||||
|
||||
final card = await repo.preview('0428-117');
|
||||
|
||||
expect(card.mode, CardViewMode.detail);
|
||||
expect(card.senderName, kUnknownSender);
|
||||
expect(card.senderPhotoPath, isNull);
|
||||
expect(card.pet.photoPath, 'server-url:/shared-pet-cards/0428-117/images/pet');
|
||||
expect(card.style.nameBgImage, 'server-url:/shared-pet-cards/0428-117/images/background');
|
||||
expect(card.pet.vet, '드림 동물병원');
|
||||
expect(card.pet.watchConditions, ['허리']);
|
||||
});
|
||||
|
||||
test('보관함은 최근에 받은 명함이 앞이고, 간략히 명함은 건강 정보가 비어 있다', () async {
|
||||
api.on('GET', '/received-cards', (r) => ok(page([
|
||||
_receivedJson(),
|
||||
_receivedJson(id: 'r2', receivedAt: '2026-10-11T03:00:00.000Z', petPhotoFileId: 'f9'),
|
||||
])));
|
||||
|
||||
final cards = await repo.fetch();
|
||||
|
||||
expect(cards.map((c) => c.id), ['r2', 'r1']);
|
||||
expect(cards.first.pet.photoPath, 'server-file:f9');
|
||||
expect(cards.last.mode, CardViewMode.simple);
|
||||
expect(cards.last.pet.vet, isNull);
|
||||
expect(cards.last.pet.neutered, isFalse);
|
||||
});
|
||||
|
||||
test('받을 때 코드와 메모를 보낸다', () async {
|
||||
api.on('POST', '/received-cards', (r) {
|
||||
expect(r.json, {'code': '0428-117', 'note': '산책 친구'});
|
||||
return ok(_receivedJson(note: '산책 친구'), status: 201);
|
||||
});
|
||||
|
||||
final card = await repo.receive('0428-117', note: '산책 친구');
|
||||
|
||||
expect(card.note, '산책 친구');
|
||||
expect(card.senderName, '초코아빠');
|
||||
});
|
||||
|
||||
test('보관함에서 지운다', () async {
|
||||
api.on('DELETE', '/received-cards/:id', (r) => noContent);
|
||||
|
||||
await repo.delete('r1');
|
||||
|
||||
expect(api.sent('DELETE', '/received-cards/r1'), hasLength(1));
|
||||
});
|
||||
|
||||
test('로그인 전에는 보관함을 묻지 않는다', () async {
|
||||
final signedOut = FakeApi(tokens: null);
|
||||
final cards = await ApiReceivedCardRepository(dio: signedOut.dio, session: signedOut.session).fetch();
|
||||
|
||||
expect(cards, isEmpty);
|
||||
expect(signedOut.requests, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('공유 시트를 열면 받는 사람이 볼 모양을 서버에 알린다', () async {
|
||||
final api = FakeApi();
|
||||
api.on('PUT', '/pet-cards/:id/share-mode', (r) {
|
||||
expect(r.params['id'], 'c1');
|
||||
expect(r.json, {'mode': 'detail'});
|
||||
return noContent;
|
||||
});
|
||||
final repo = ApiPetCardRepository(dio: api.dio, session: api.session, uploader: FileUploader(api.dio, api.reported.add));
|
||||
|
||||
await repo.setShareMode('c1', CardViewMode.detail);
|
||||
|
||||
expect(api.sent('PUT', '/pet-cards/c1/share-mode'), hasLength(1));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/api/api_exception.dart';
|
||||
import 'package:tma_app/features/pet_card/data/received_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/card_scanner_page.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
|
||||
import 'support/app_harness.dart';
|
||||
|
||||
const String _kCode = '0428-117';
|
||||
|
||||
SharedCard _shared({CardViewMode mode = CardViewMode.simple}) => SharedCard(
|
||||
code: _kCode,
|
||||
mode: mode,
|
||||
senderNickname: '초코아빠',
|
||||
senderPhotoPath: null,
|
||||
pet: samplePet(id: 'shared', name: '초코'),
|
||||
style: CardStyle.initial(),
|
||||
);
|
||||
|
||||
ReceivedCard _received(
|
||||
String id, {
|
||||
String name = '초코',
|
||||
String sender = '초코아빠',
|
||||
String? note,
|
||||
CardViewMode mode = CardViewMode.simple,
|
||||
}) =>
|
||||
ReceivedCard(
|
||||
id: id,
|
||||
code: _kCode,
|
||||
mode: mode,
|
||||
senderNickname: sender,
|
||||
senderPhotoPath: null,
|
||||
pet: samplePet(id: 'received-$id', name: name),
|
||||
style: CardStyle.initial(),
|
||||
note: note,
|
||||
receivedAt: DateTime(2026, 10, 9),
|
||||
);
|
||||
|
||||
/// 서버 없이 받은 명함을 기억하는 저장소
|
||||
class _FakeReceivedRepo implements ReceivedCardRepository {
|
||||
_FakeReceivedRepo({List<ReceivedCard> cards = const [], this.failure}) : cards = [...cards];
|
||||
|
||||
final List<ReceivedCard> cards;
|
||||
|
||||
/// 미리보기를 이 코드로 실패시킨다 (인터셉터가 알린 실패처럼)
|
||||
final String? failure;
|
||||
final previewed = <String>[];
|
||||
String? savedNote;
|
||||
|
||||
@override
|
||||
bool get available => true;
|
||||
|
||||
@override
|
||||
Future<List<ReceivedCard>> fetch() async => [...cards];
|
||||
|
||||
@override
|
||||
Future<SharedCard> preview(String code) async {
|
||||
previewed.add(code);
|
||||
if (failure case final code?) throw ApiException.fromCode(code);
|
||||
return _shared();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ReceivedCard> receive(String code, {String? note}) async {
|
||||
savedNote = note;
|
||||
final card = _received('new', note: note);
|
||||
cards.insert(0, card);
|
||||
return card;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async => cards.removeWhere((c) => c.id == id);
|
||||
}
|
||||
|
||||
/// 실제 카메라 대신 — 누르면 QR 을 읽은 것처럼 알린다
|
||||
Widget _fakeCamera(ValueChanged<String> onCode) => Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 120),
|
||||
child: TextButton(onPressed: () => onCode('rup://pet-card/$_kCode'), child: const Text('QR 비추기')),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _openCardsTab(WidgetTester tester, ReceivedCardRepository repo) async {
|
||||
await launchSignedIn(
|
||||
tester,
|
||||
overrides: [
|
||||
receivedCardRepositoryProvider.overrideWithValue(repo),
|
||||
scannerCameraProvider.overrideWithValue(_fakeCamera),
|
||||
],
|
||||
);
|
||||
await tester.tap(find.bySemanticsLabel('펫 명함'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
Future<void> _openScanner(WidgetTester tester) async {
|
||||
await tester.ensureVisible(find.text('명함 받기'));
|
||||
await tester.tap(find.text('명함 받기'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('보관함은 받은 명함 수와 보낸 사람을 보여 주고 검색으로 거른다', (tester) async {
|
||||
await _openCardsTab(
|
||||
tester,
|
||||
_FakeReceivedRepo(cards: [_received('r1'), _received('r2', name: '나비', sender: '나비집사')]),
|
||||
);
|
||||
|
||||
expect(find.text('보관된 명함 2'), findsOneWidget);
|
||||
expect(find.text('초코'), findsOneWidget);
|
||||
expect(find.textContaining('님이 보냈어요.'), findsNWidgets(2));
|
||||
|
||||
// 돋보기를 누르면 검색창이 펼쳐진다
|
||||
await tester.tap(find.byWidgetPredicate((w) => w is RupIcon && w.name == RupIconName.search));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField), '나비집사');
|
||||
await settle(tester);
|
||||
expect(find.text('나비'), findsOneWidget);
|
||||
expect(find.text('초코'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '보리');
|
||||
await settle(tester);
|
||||
expect(find.text("'보리' 검색 결과가 없어요."), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('QR 을 비추면 명함을 불러와 메모와 함께 보관함에 저장한다', (tester) async {
|
||||
final repo = _FakeReceivedRepo();
|
||||
await _openCardsTab(tester, repo);
|
||||
await _openScanner(tester);
|
||||
expect(find.text('상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('QR 비추기'));
|
||||
await tester.pump();
|
||||
expect(find.text('명함을 찾았어요! 정보 불러오는 중…'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.previewed, [_kCode]);
|
||||
expect(find.text('초코의 명함'), findsOneWidget);
|
||||
expect(find.text('초코아빠님'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '산책 친구');
|
||||
await tester.pump();
|
||||
expect(find.text('5/200'), findsOneWidget);
|
||||
await tester.ensureVisible(find.text('보관함에 저장'));
|
||||
await tester.tap(find.text('보관함에 저장'));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.savedNote, '산책 친구');
|
||||
expect(find.text('초코 명함을 보관함에 저장했어요'), findsOneWidget);
|
||||
expect(find.text('보관된 명함 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('명함 코드를 직접 입력해도 받을 수 있다', (tester) async {
|
||||
final repo = _FakeReceivedRepo();
|
||||
await _openCardsTab(tester, repo);
|
||||
await _openScanner(tester);
|
||||
|
||||
await tester.tap(find.text('명함 코드 입력하기'));
|
||||
await settle(tester);
|
||||
expect(find.text('명함코드 입력하기'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '0428117');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('보관함에 저장'));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.previewed, [_kCode]);
|
||||
expect(find.text('초코의 명함'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('명함 코드 모양이 아니면 서버에 묻지 않고 알린다', (tester) async {
|
||||
final repo = _FakeReceivedRepo();
|
||||
await _openCardsTab(tester, repo);
|
||||
await _openScanner(tester);
|
||||
await tester.tap(find.text('명함 코드 입력하기'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'PET-1A2B');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('보관함에 저장'));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.previewed, isEmpty);
|
||||
expect(find.text('명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.'), findsOneWidget);
|
||||
expect(find.text('명함코드 입력하기'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('명함을 받지 못하면 스캐너로 돌아가 다시 비출 수 있다', (tester) async {
|
||||
final repo = _FakeReceivedRepo(failure: 'CARD_003');
|
||||
await _openCardsTab(tester, repo);
|
||||
await _openScanner(tester);
|
||||
|
||||
await tester.tap(find.text('QR 비추기'));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.previewed, [_kCode]);
|
||||
expect(find.text('상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.'), findsOneWidget);
|
||||
expect(find.text('명함 코드 입력하기'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('받은 명함은 보낸 모양대로 보이고 보관함에서 지울 수 있다', (tester) async {
|
||||
final repo = _FakeReceivedRepo(cards: [_received('r1')]);
|
||||
await _openCardsTab(tester, repo);
|
||||
|
||||
await tester.ensureVisible(find.text('초코'));
|
||||
await tester.tap(find.text('초코'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('받은 명함 · 초코아빠님'), findsOneWidget);
|
||||
// 간략히로 공유된 명함 — 상세 명함(건강 정보)은 없다
|
||||
expect(find.text('건강 정보'), findsNothing);
|
||||
expect(find.text('남긴 메모가 없어요.'), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.text('보관함에서 삭제'));
|
||||
await tester.tap(find.text('보관함에서 삭제'));
|
||||
await settle(tester);
|
||||
|
||||
expect(repo.cards, isEmpty);
|
||||
expect(find.text('보관함에서 삭제했어요'), findsOneWidget);
|
||||
expect(find.text('받은 명함이 없어요.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('기기 저장 모드에서는 명함 받기 대신 안내를 띄운다', (tester) async {
|
||||
await _openCardsTab(tester, const UnavailableReceivedCardRepository());
|
||||
await _openScanner(tester);
|
||||
|
||||
expect(find.text('기기 저장 모드에서는 명함을 받을 수 없어요'), findsOneWidget);
|
||||
expect(find.text('명함 코드 입력하기'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,12 @@ 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: '내 명함은 받을 수 없어요.',
|
||||
} as const
|
||||
|
||||
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
|
||||
|
||||
Reference in New Issue
Block a user