diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 8e7c99c..a57c576 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -88,6 +88,16 @@ const DOMAIN_ERROR_CODES = { status: HttpStatus.CONFLICT, message: '펫 명함은 최대 10장까지 만들 수 있어요.', }, + // 명함 받기 — 코드에 맞는 명함이 없다 + CARD_002: { + status: HttpStatus.NOT_FOUND, + message: '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.', + }, + // 명함 받기 — 내 명함은 받을 수 없다 + CARD_003: { + status: HttpStatus.BAD_REQUEST, + message: '내 명함은 받을 수 없어요.', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/migrations/1791714286898-AddReceivedCards.ts b/backend/src/migrations/1791714286898-AddReceivedCards.ts new file mode 100644 index 0000000..6344f0e --- /dev/null +++ b/backend/src/migrations/1791714286898-AddReceivedCards.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddReceivedCards1791714286898 implements MigrationInterface { + name = 'AddReceivedCards1791714286898'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/pet-card/card-snapshot.spec.ts b/backend/src/modules/pet-card/card-snapshot.spec.ts new file mode 100644 index 0000000..ee64417 --- /dev/null +++ b/backend/src/modules/pet-card/card-snapshot.spec.ts @@ -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: ['슬개골'], + }); + }); +}); diff --git a/backend/src/modules/pet-card/card-snapshot.ts b/backend/src/modules/pet-card/card-snapshot.ts new file mode 100644 index 0000000..046aa45 --- /dev/null +++ b/backend/src/modules/pet-card/card-snapshot.ts @@ -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, + }; +} diff --git a/backend/src/modules/pet-card/dto/received-card.dto.ts b/backend/src/modules/pet-card/dto/received-card.dto.ts new file mode 100644 index 0000000..a2a593a --- /dev/null +++ b/backend/src/modules/pet-card/dto/received-card.dto.ts @@ -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(), + }; + } +} diff --git a/backend/src/modules/pet-card/entities/pet-card.entity.ts b/backend/src/modules/pet-card/entities/pet-card.entity.ts index 0f884a9..f1aebaa 100644 --- a/backend/src/modules/pet-card/entities/pet-card.entity.ts +++ b/backend/src/modules/pet-card/entities/pet-card.entity.ts @@ -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; diff --git a/backend/src/modules/pet-card/entities/received-card.entity.ts b/backend/src/modules/pet-card/entities/received-card.entity.ts new file mode 100644 index 0000000..0fcc3a8 --- /dev/null +++ b/backend/src/modules/pet-card/entities/received-card.entity.ts @@ -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; +} diff --git a/backend/src/modules/pet-card/pet-card.module.ts b/backend/src/modules/pet-card/pet-card.module.ts index 939c3f0..9e41637 100644 --- a/backend/src/modules/pet-card/pet-card.module.ts +++ b/backend/src/modules/pet-card/pet-card.module.ts @@ -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 {} diff --git a/backend/src/modules/pet-card/pet-card.service.ts b/backend/src/modules/pet-card/pet-card.service.ts index 02876ee..2a6d81c 100644 --- a/backend/src/modules/pet-card/pet-card.service.ts +++ b/backend/src/modules/pet-card/pet-card.service.ts @@ -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 { + 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 { await this.pets.findOwned(ownerId, petId); diff --git a/backend/src/modules/pet-card/pet-cards.controller.ts b/backend/src/modules/pet-card/pet-cards.controller.ts index c523219..a81c726 100644 --- a/backend/src/modules/pet-card/pet-cards.controller.ts +++ b/backend/src/modules/pet-card/pet-cards.controller.ts @@ -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 { + await this.cards.setShareMode(user.userId, id, dto.mode); + } + @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) @SkipTransform() // 204 — 본문이 없다 diff --git a/backend/src/modules/pet-card/received-card.service.spec.ts b/backend/src/modules/pet-card/received-card.service.spec.ts new file mode 100644 index 0000000..41d5039 --- /dev/null +++ b/backend/src/modules/pet-card/received-card.service.spec.ts @@ -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 { + 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) => 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, + received as unknown as Repository, + 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) => + 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', + ]); + }); +}); diff --git a/backend/src/modules/pet-card/received-card.service.ts b/backend/src/modules/pet-card/received-card.service.ts new file mode 100644 index 0000000..f9a0dc7 --- /dev/null +++ b/backend/src/modules/pet-card/received-card.service.ts @@ -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, + @InjectRepository(ReceivedCard) + private readonly received: Repository, + private readonly uploads: UploadService, + ) {} + + /** 받기 전 미리보기 — 아직 보관하지 않는다 */ + async preview(viewerId: string, code: string): Promise { + 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 { + 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 { + 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> { + 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 { + 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 { + 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; + } +} diff --git a/backend/src/modules/pet-card/received-cards.controller.ts b/backend/src/modules/pet-card/received-cards.controller.ts new file mode 100644 index 0000000..aac4780 --- /dev/null +++ b/backend/src/modules/pet-card/received-cards.controller.ts @@ -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 { + 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 { + 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> { + 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 { + 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 { + await this.received.remove(user.userId, id); + } +} + +function isSharedImageKind(kind: string): kind is SharedImageKind { + return (SHARED_IMAGE_KINDS as readonly string[]).includes(kind); +} diff --git a/backend/src/modules/upload/file-response.ts b/backend/src/modules/upload/file-response.ts new file mode 100644 index 0000000..cb873d4 --- /dev/null +++ b/backend/src/modules/upload/file-response.ts @@ -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}"`, + }); +} diff --git a/backend/src/modules/upload/upload.controller.ts b/backend/src/modules/upload/upload.controller.ts index 547edfe..c34662b 100644 --- a/backend/src/modules/upload/upload.controller.ts +++ b/backend/src/modules/upload/upload.controller.ts @@ -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 가 담당한다. @@ -86,14 +84,6 @@ export class UploadController { @CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, ): Promise { - 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)); } } diff --git a/backend/src/modules/upload/upload.service.ts b/backend/src/modules/upload/upload.service.ts index e602a02..7f1f347 100644 --- a/backend/src/modules/upload/upload.service.ts +++ b/backend/src/modules/upload/upload.service.ts @@ -1,8 +1,9 @@ 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'; @@ -70,7 +71,52 @@ export class UploadService { if (!file) { throw new BusinessException('RES_001', { context: { fileId } }); } + return this.toDownload(file); + } + /** + * 소유자를 따지지 않고 내려받는다 — 호출한 쪽이 볼 권한을 이미 확인한 경우만 쓴다 + * (예: 명함 코드를 아는 사람에게 그 명함의 사진을 보여 줄 때). + */ + async downloadUnchecked(fileId: string): Promise { + 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 { + 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, diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index d27e14e..89a7d90 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -14,6 +14,8 @@ const Map kErrorCodeMessages = { 'DIARY_001': '다이어리는 최대 6권까지 만들 수 있어요.', 'DIARY_002': '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.', 'CARD_001': '펫 명함은 최대 10장까지 만들 수 있어요.', + 'CARD_002': '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.', + 'CARD_003': '내 명함은 받을 수 없어요.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 6fe83d5..827eb7b 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -14,6 +14,8 @@ export const ERROR_CODE_MESSAGES = { DIARY_001: '다이어리는 최대 6권까지 만들 수 있어요.', DIARY_002: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.', CARD_001: '펫 명함은 최대 10장까지 만들 수 있어요.', + CARD_002: '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.', + CARD_003: '내 명함은 받을 수 없어요.', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES