feat(backend): 펫 명함 API — 만들기·꾸미기·삭제·대표 명함

앱의 펫 명함(PetCardRepository)을 서버로 옮긴다.

- /pet-cards: 목록·만들기·꾸미기 바꾸기·삭제, 한 사람 최대 10장 → CARD_001
- /pets/:petId/representative-card: 대표 명함 정하기·풀기 (아이마다 하나 — 부분 유니크 인덱스)
- 명함 코드("0000-000")를 서버가 무작위로 발급한다 — 지금은 앱이 카드 id 로 계산해 기기마다 달라질 수 있었다.
  명함 받기에서 이 코드로 찾을 수 있다
- 배경은 색·앱 그림(assets/…)·올린 사진 중 하나만 저장한다. 그림 경로는 경로 탈출을 막는 형식만 받는다
- 아이를 지우면 명함은 FK 로 함께 지워지고 배경 사진은 커밋 뒤 정리한다
- 에러 코드 추가에 따라 프론트·Flutter 사전을 다시 생성했다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 17:41:20 +09:00
parent 77862249ee
commit 377c9d2f9a
13 changed files with 802 additions and 0 deletions
+2
View File
@@ -13,6 +13,7 @@ import { AuthModule } from '@/modules/auth/auth.module';
import { DiaryModule } from '@/modules/diary/diary.module';
import { HealthModule } from '@/modules/health/health.module';
import { PetModule } from '@/modules/pet/pet.module';
import { PetCardModule } from '@/modules/pet-card/pet-card.module';
import { ScheduleModule } from '@/modules/schedule/schedule.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
@@ -70,6 +71,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
PetModule,
ScheduleModule,
DiaryModule,
PetCardModule,
],
providers: [
ShutdownLogger,
+5
View File
@@ -83,6 +83,11 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.CONFLICT,
message: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.',
},
// 펫 명함 — 한 사람이 최대 10장
CARD_001: {
status: HttpStatus.CONFLICT,
message: '펫 명함은 최대 10장까지 만들 수 있어요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPetCards1791707889244 implements MigrationInterface {
name = 'AddPetCards1791707889244';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "pet_cards" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "pet_id" uuid NOT NULL, "code" character varying(8) NOT NULL, "tint" character varying(7) NOT NULL, "tint2" character varying(7) NOT NULL, "name_bg" character varying(7), "name_bg_asset" character varying(120), "name_bg_file_id" uuid, "text_light" boolean NOT NULL DEFAULT false, "scrim" boolean NOT NULL DEFAULT false, "scrim_tone" character varying(5) NOT NULL DEFAULT 'light', "is_representative" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_b0cdb9b6d8843b8ccdd8189475e" UNIQUE ("code"), CONSTRAINT "PK_cb97f59edc0aab4f315acd5c1fa" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_71ffe780c984a78a864a07465f" ON "pet_cards" ("owner_id") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_pet_cards_representative" ON "pet_cards" ("pet_id") WHERE "is_representative"`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_71ffe780c984a78a864a07465f8" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_5d2fc9ac2b34f34dfaca776fc7f" FOREIGN KEY ("pet_id") REFERENCES "pets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_672dc0c78f4a9ffde6239f1db86" FOREIGN KEY ("name_bg_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 "pet_cards" DROP CONSTRAINT "FK_672dc0c78f4a9ffde6239f1db86"`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" DROP CONSTRAINT "FK_5d2fc9ac2b34f34dfaca776fc7f"`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" DROP CONSTRAINT "FK_71ffe780c984a78a864a07465f8"`,
);
await queryRunner.query(
`DROP INDEX "public"."UQ_pet_cards_representative"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_71ffe780c984a78a864a07465f"`,
);
await queryRunner.query(`DROP TABLE "pet_cards"`);
}
}
@@ -0,0 +1,14 @@
import { formatCardCode, randomCardCode } from './card-code';
describe('명함 코드', () => {
it('7자리를 0000-000 형식으로 쓴다', () => {
expect(formatCardCode(4_281_170)).toBe('4281-170');
expect(formatCardCode(42)).toBe('0000-042');
});
it('무작위 코드도 같은 형식이다', () => {
for (let i = 0; i < 20; i += 1) {
expect(randomCardCode()).toMatch(/^\d{4}-\d{3}$/);
}
});
});
+17
View File
@@ -0,0 +1,17 @@
import { randomInt } from 'crypto';
/** 명함 코드 자릿수 — "0000-000" (앱이 보여 주는 형식) */
const CODE_DIGITS = 7;
const CODE_SPACE = 10 ** CODE_DIGITS;
const CODE_HEAD = 4;
/** 0 ~ 9,999,999 → "0000-000" */
export function formatCardCode(value: number): string {
const digits = String(value % CODE_SPACE).padStart(CODE_DIGITS, '0');
return `${digits.slice(0, CODE_HEAD)}-${digits.slice(CODE_HEAD)}`;
}
/** 새 명함 코드 — 추측하기 어렵게 무작위로 뽑는다 (겹치면 호출한 쪽이 다시 뽑는다) */
export function randomCardCode(): string {
return formatCardCode(randomInt(0, CODE_SPACE));
}
@@ -0,0 +1,168 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsOptional,
IsUUID,
Matches,
ValidateNested,
} from 'class-validator';
import { IsHexColor } from '@/common/validators';
import { SCRIM_TONES } from '../entities/pet-card.entity';
import type { PetCard, ScrimTone } from '../entities/pet-card.entity';
/** 앱에 들어 있는 배경 그림 경로 — 경로 탈출·다른 형식을 막는다 */
const ASSET_PATH_PATTERN =
/^assets\/[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*\.(png|jpg|jpeg|webp)$/;
/** 명함 꾸미기 값 (앱의 CardStyle) — 배경은 색·그림·사진 중 하나만 */
export class CardStyleDto {
@ApiProperty({
description: '상단 그라데이션 첫 색 (#RRGGBB)',
example: '#FF9B6A',
})
@IsHexColor()
tint!: string;
@ApiProperty({
description: '상단 그라데이션 둘째 색 (#RRGGBB)',
example: '#FFC59B',
})
@IsHexColor()
tint2!: string;
@ApiPropertyOptional({
description: '배경색 (#RRGGBB) — 없으면 흰색',
nullable: true,
type: String,
})
@IsOptional()
@IsHexColor()
nameBg?: string | null;
@ApiPropertyOptional({
description: '앱에 들어 있는 배경 그림 경로 (assets/…)',
nullable: true,
type: String,
})
@IsOptional()
@Matches(ASSET_PATH_PATTERN, {
message: '배경 그림 정보가 올바르지 않습니다.',
})
nameBgAsset?: string | null;
@ApiPropertyOptional({
description: '배경 사진 파일 ID (POST /api/files 로 올린 이미지)',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
nameBgFileId?: string | null;
@ApiProperty({ description: '밝은 글자 (어두운 배경용)', example: false })
@IsBoolean()
textLight!: boolean;
@ApiProperty({ description: '글자 뒤 막 덮기', example: false })
@IsBoolean()
scrim!: boolean;
@ApiProperty({ enum: SCRIM_TONES, example: 'light' })
@IsIn(SCRIM_TONES, { message: '막 색이 올바르지 않습니다.' })
scrimTone!: ScrimTone;
}
/** 새 명함 */
export class CreatePetCardDto {
@ApiProperty({ description: '명함을 만들 아이 ID' })
@IsUUID('4', { message: '아이 정보가 올바르지 않습니다.' })
petId!: string;
@ApiProperty({ type: CardStyleDto })
@ValidateNested()
@Type(() => CardStyleDto)
style!: CardStyleDto;
}
/** 명함 꾸미기 바꾸기 — 꾸미기 값을 통째로 바꾼다 */
export class UpdatePetCardDto {
@ApiProperty({ type: CardStyleDto })
@ValidateNested()
@Type(() => CardStyleDto)
style!: CardStyleDto;
}
/** 대표 명함 지정 */
export class SetRepresentativeDto {
@ApiProperty({ description: '대표로 둘 명함 ID (그 아이의 명함이어야 한다)' })
@IsUUID('4', { message: '명함 정보가 올바르지 않습니다.' })
cardId!: string;
}
/** 명함 꾸미기 응답 */
export class CardStyleResponseDto {
@ApiProperty() tint!: string;
@ApiProperty() tint2!: string;
@ApiProperty({ nullable: true, type: String }) nameBg!: string | null;
@ApiProperty({ nullable: true, type: String }) nameBgAsset!: string | null;
@ApiProperty({ nullable: true, type: String }) nameBgFileId!: string | null;
@ApiProperty() textLight!: boolean;
@ApiProperty() scrim!: boolean;
@ApiProperty({ enum: SCRIM_TONES }) scrimTone!: ScrimTone;
}
/** 펫 명함 응답 (앱의 PetCard) */
export class PetCardResponseDto {
@ApiProperty() id!: string;
@ApiProperty() petId!: string;
@ApiProperty({ description: '명함 코드', example: '0428-117' })
code!: string;
@ApiProperty({ type: CardStyleResponseDto })
style!: CardStyleResponseDto;
@ApiProperty({ description: '아이의 대표 명함인지' })
isRepresentative!: boolean;
static from(card: PetCard): PetCardResponseDto {
return {
id: card.id,
petId: card.petId,
code: card.code,
style: {
tint: card.tint,
tint2: card.tint2,
nameBg: card.nameBg,
nameBgAsset: card.nameBgAsset,
nameBgFileId: card.nameBgFileId,
textLight: card.textLight,
scrim: card.scrim,
scrimTone: card.scrimTone,
},
isRepresentative: card.isRepresentative,
};
}
}
/**
* 꾸미기 DTO → 컬럼 값. 배경은 하나만 남긴다 — 사진 > 그림 > 색 순으로 우선한다
* (앱도 하나를 고르면 나머지를 비운다).
*/
export function toStyleColumns(style: CardStyleDto) {
const file = style.nameBgFileId ?? null;
const asset = file ? null : (style.nameBgAsset ?? null);
return {
tint: style.tint,
tint2: style.tint2,
nameBg: file || asset ? null : (style.nameBg ?? null),
nameBgAsset: asset,
nameBgFileId: file,
textLight: style.textLight,
scrim: style.scrim,
scrimTone: style.scrimTone,
};
}
@@ -0,0 +1,101 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Pet } from '@/modules/pet/entities/pet.entity';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
import { User } from '@/modules/user/entities/user.entity';
/** 한 사람이 만들 수 있는 펫 명함 수 — 앱의 kMaxPetCards */
export const MAX_PET_CARDS = 10;
/** 글자 뒤에 덮는 막의 색 (앱의 ScrimTone) */
export const SCRIM_TONES = ['light', 'dark'] as const;
export type ScrimTone = (typeof SCRIM_TONES)[number];
/**
* 펫 명함 — 어느 아이의 명함인지 + 꾸미기 값(앱의 CardStyle).
* 이름·품종 등 내용은 아이 정보를 그대로 쓰고 모양만 저장한다.
* 배경은 색(nameBg) · 앱에 들어 있는 그림(nameBgAsset) · 올린 사진(nameBgFileId) 중 하나만 쓴다.
*/
@Entity('pet_cards')
// 아이마다 대표 명함은 하나
@Index('UQ_pet_cards_representative', ['petId'], {
unique: true,
where: '"is_representative"',
})
export class PetCard {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
/** 아이를 지우면 그 아이의 명함도 지운다 */
@ManyToOne(() => Pet, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'pet_id' })
pet?: Pet;
@Column({ type: 'uuid' })
petId!: string;
/** 명함 코드 "0000-000" — 서버가 발급한다. 명함 받기에서 이 코드로 찾는다 */
@Column({ type: 'varchar', length: 8, unique: true })
code!: string;
/** 카드 상단 그라데이션 두 색 `#RRGGBB` */
@Column({ type: 'varchar', length: 7 })
tint!: string;
@Column({ type: 'varchar', length: 7 })
tint2!: string;
/** 배경색 — null 이면 흰색 */
@Column({ type: 'varchar', length: 7, nullable: true })
nameBg!: string | null;
/** 앱에 들어 있는 배경 그림 경로 (assets/…) */
@Column({ type: 'varchar', length: 120, nullable: true })
nameBgAsset!: string | null;
/** 올린 배경 사진 — 참조 중인 파일은 지울 수 없게 둔다 (UploadService.releaseIfUnused) */
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'name_bg_file_id' })
nameBgFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
nameBgFileId!: string | null;
/** 밝은 글자 (어두운 배경용) */
@Column({ type: 'boolean', default: false })
textLight!: boolean;
/** 글자 뒤에 은은한 막을 덮을지 */
@Column({ type: 'boolean', default: false })
scrim!: boolean;
@Column({ type: 'varchar', length: 5, default: 'light' })
scrimTone!: ScrimTone;
/** 아이의 대표 명함인지 */
@Column({ type: 'boolean', default: false })
isRepresentative!: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PetModule } from '@/modules/pet/pet.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { PetCard } from './entities/pet-card.entity';
import { PetCardService } from './pet-card.service';
import {
PetCardsController,
RepresentativeCardController,
} from './pet-cards.controller';
@Module({
imports: [TypeOrmModule.forFeature([PetCard]), PetModule, UploadModule],
controllers: [PetCardsController, RepresentativeCardController],
providers: [PetCardService],
})
export class PetCardModule {}
@@ -0,0 +1,98 @@
import { DataSource, EntityManager, Repository } from 'typeorm';
import { PetService } from '@/modules/pet/pet.service';
import { UploadService } from '@/modules/upload/upload.service';
import { CardStyleDto, toStyleColumns } from './dto/pet-card.dto';
import { PetCard } from './entities/pet-card.entity';
import { PetCardService } from './pet-card.service';
const style = (overrides: Partial<CardStyleDto> = {}): CardStyleDto => ({
tint: '#FF9B6A',
tint2: '#FFC59B',
textLight: false,
scrim: false,
scrimTone: 'light',
...overrides,
});
describe('toStyleColumns', () => {
it('배경은 하나만 남긴다 — 사진 > 그림 > 색', () => {
expect(
toStyleColumns(
style({
nameBg: '#FFFFFF',
nameBgAsset: 'assets/a.png',
nameBgFileId: 'f',
}),
),
).toMatchObject({ nameBg: null, nameBgAsset: null, nameBgFileId: 'f' });
expect(
toStyleColumns(style({ nameBg: '#FFFFFF', nameBgAsset: 'assets/a.png' })),
).toMatchObject({ nameBg: null, nameBgAsset: 'assets/a.png' });
expect(toStyleColumns(style({ nameBg: '#FFFFFF' }))).toMatchObject({
nameBg: '#FFFFFF',
nameBgAsset: null,
nameBgFileId: null,
});
});
});
describe('PetCardService', () => {
const cards = { findOneBy: jest.fn(), update: jest.fn() };
const manager = { update: jest.fn(), find: jest.fn() };
const dataSource = {
transaction: jest.fn((work: (m: EntityManager) => Promise<unknown>) =>
work(manager as unknown as EntityManager),
),
};
const pets = { registerListener: jest.fn(), findOwned: jest.fn() };
const uploads = { assertOwnedImage: jest.fn(), releaseIfUnused: jest.fn() };
const service = new PetCardService(
cards as unknown as Repository<PetCard>,
dataSource as unknown as DataSource,
pets as unknown as PetService,
uploads as unknown as UploadService,
);
beforeEach(() => jest.clearAllMocks());
it('다른 아이의 명함은 대표로 정할 수 없다 (VAL_001)', async () => {
cards.findOneBy.mockResolvedValue({ id: 'c1', petId: 'pet-b' });
await expect(
service.setRepresentative('o', 'pet-a', 'c1'),
).rejects.toMatchObject({ code: 'VAL_001' });
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('대표를 바꾸면 그 아이의 기존 대표를 먼저 푼다', async () => {
cards.findOneBy.mockResolvedValue({ id: 'c1', petId: 'pet-a' });
await service.setRepresentative('o', 'pet-a', 'c1');
expect(manager.update).toHaveBeenNthCalledWith(
1,
PetCard,
{ petId: 'pet-a', isRepresentative: true },
{ isRepresentative: false },
);
expect(manager.update).toHaveBeenNthCalledWith(
2,
PetCard,
{ id: 'c1' },
{ isRepresentative: true },
);
});
it('아이를 지울 때 그 아이 명함의 배경 사진을 정리 대상으로 돌려준다', async () => {
manager.find.mockResolvedValue([{ nameBgFileId: 'bg-1' }]);
await expect(
service.onPetDeleting({
ownerId: 'o',
petId: 'pet-a',
manager: manager as unknown as EntityManager,
}),
).resolves.toEqual(['bg-1']);
});
});
@@ -0,0 +1,194 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, IsNull, Not, Repository } from 'typeorm';
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import {
PetLifecycleEvent,
PetLifecycleListener,
} from '@/modules/pet/pet-lifecycle';
import { PetService } from '@/modules/pet/pet.service';
import { UploadService } from '@/modules/upload/upload.service';
import { isUniqueViolation } from '@/shared/utils/db-error';
import { randomCardCode } from './card-code';
import {
CreatePetCardDto,
PetCardResponseDto,
UpdatePetCardDto,
toStyleColumns,
} from './dto/pet-card.dto';
import { MAX_PET_CARDS, PetCard } from './entities/pet-card.entity';
/** 명함 코드가 겹치면 다시 뽑는 횟수 — 천만 개 중에서 뽑으므로 거의 겹치지 않는다 */
const CODE_RETRY = 5;
// 펫 명함 — 만들기·꾸미기·삭제·대표 명함
@Injectable()
export class PetCardService implements OnModuleInit, PetLifecycleListener {
constructor(
@InjectRepository(PetCard)
private readonly cards: Repository<PetCard>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly pets: PetService,
private readonly uploads: UploadService,
) {}
onModuleInit(): void {
this.pets.registerListener(this);
}
/** 내 명함 목록 (만든 순) — 추억 보관함의 아이 명함도 함께 온다 */
async list(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<PetCardResponseDto>> {
const qb = this.cards
.createQueryBuilder('card')
.where('card.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
[
{
column: 'card.created_at',
cast: 'timestamptz',
read: (c) => c.createdAt.toISOString(),
},
{ column: 'card.id', cast: 'uuid', read: (c) => c.id },
],
query,
);
return mapPage(page, (c) => PetCardResponseDto.from(c));
}
/** 새 명함 — 최대 10장 (CARD_001). 코드는 서버가 발급한다 */
async create(
ownerId: string,
dto: CreatePetCardDto,
): Promise<PetCardResponseDto> {
await this.pets.findOwned(ownerId, dto.petId);
await this.uploads.assertOwnedImage(dto.style.nameBgFileId, ownerId);
for (let attempt = 1; ; attempt += 1) {
try {
return await this.insertCard(ownerId, dto);
} catch (error) {
// 코드가 겹쳤으면 다시 뽑는다 (그 밖의 유니크 위반은 없다)
if (!isUniqueViolation(error) || attempt >= CODE_RETRY) throw error;
}
}
}
async update(
ownerId: string,
cardId: string,
dto: UpdatePetCardDto,
): Promise<PetCardResponseDto> {
const card = await this.findOwnedCard(ownerId, cardId);
await this.uploads.assertOwnedImage(dto.style.nameBgFileId, ownerId);
const previousBg = card.nameBgFileId;
const saved = await this.cards.save(
this.cards.merge(card, toStyleColumns(dto.style)),
);
if (previousBg !== saved.nameBgFileId) {
await this.uploads.releaseIfUnused([previousBg]);
}
return PetCardResponseDto.from(saved);
}
/** 명함 삭제 — 대표 명함이었으면 대표 지정도 함께 사라진다 */
async remove(ownerId: string, cardId: string): Promise<void> {
const card = await this.findOwnedCard(ownerId, cardId);
await this.cards.delete({ id: card.id });
await this.uploads.releaseIfUnused([card.nameBgFileId]);
}
/** 아이의 대표 명함 정하기 — 그 아이의 명함이어야 한다 */
async setRepresentative(
ownerId: string,
petId: string,
cardId: string,
): Promise<void> {
const card = await this.findOwnedCard(ownerId, cardId);
if (card.petId !== petId) {
throw new BusinessException('VAL_001', {
context: { reason: 'card-of-other-pet', petId, cardId },
});
}
await this.dataSource.transaction(async (manager) => {
await manager.update(
PetCard,
{ petId, isRepresentative: true },
{ isRepresentative: false },
);
await manager.update(PetCard, { id: cardId }, { isRepresentative: true });
});
}
/** 대표 명함 지정 풀기 — 앱은 그 아이의 첫 명함을 보여 준다 */
async clearRepresentative(ownerId: string, petId: string): Promise<void> {
await this.pets.findOwned(ownerId, petId);
await this.cards.update(
{ petId, ownerId, isRepresentative: true },
{ isRepresentative: false },
);
}
/** 아이를 지우면 명함은 FK 로 함께 지워진다 — 배경 사진은 커밋 뒤 정리하도록 돌려준다 */
async onPetDeleting({
petId,
manager,
}: PetLifecycleEvent): Promise<Array<string | null>> {
const withPhoto = await manager.find(PetCard, {
where: { petId, nameBgFileId: Not(IsNull()) },
select: { nameBgFileId: true },
});
return withPhoto.map((c) => c.nameBgFileId);
}
private async insertCard(
ownerId: string,
dto: CreatePetCardDto,
): Promise<PetCardResponseDto> {
return this.dataSource.transaction(async (manager) => {
// 동시에 만들어 한도를 넘지 않도록 회원의 명함을 잠그고 센다
const existing = await manager.find(PetCard, {
where: { ownerId },
select: { id: true },
lock: { mode: 'pessimistic_write' },
});
if (existing.length >= MAX_PET_CARDS) {
throw new BusinessException('CARD_001', {
context: { count: existing.length },
});
}
const card = await manager.save(
manager.create(PetCard, {
ownerId,
petId: dto.petId,
code: randomCardCode(),
...toStyleColumns(dto.style),
isRepresentative: false,
}),
);
return PetCardResponseDto.from(card);
});
}
private async findOwnedCard(
ownerId: string,
cardId: string,
): Promise<PetCard> {
const card = await this.cards.findOneBy({ id: cardId, ownerId });
if (!card) throw new BusinessException('RES_001', { context: { cardId } });
return card;
}
}
@@ -0,0 +1,138 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import {
CreatePetCardDto,
PetCardResponseDto,
SetRepresentativeDto,
UpdatePetCardDto,
} from './dto/pet-card.dto';
import { PetCardService } from './pet-card.service';
// 펫 명함 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('PetCard')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('pet-cards')
export class PetCardsController {
constructor(private readonly cards: PetCardService) {}
@Get()
@ApiOperation({ summary: '내 명함 목록 (만든 순, 커서 페이지네이션)' })
@ApiPaginatedResponse(PetCardResponseDto, '명함 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<PetCardResponseDto>> {
return this.cards.list(user.userId, query);
}
@Post()
@ApiOperation({ summary: '새 명함 (최대 10장, 코드는 서버가 발급)' })
@ApiStandardResponse(PetCardResponseDto, {
status: 201,
description: '만든 명함',
})
@ApiErrorResponse('CARD_001', '명함이 가득 참 (최대 10장)')
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreatePetCardDto,
): Promise<PetCardResponseDto> {
return this.cards.create(user.userId, dto);
}
@Put(':id')
@ApiOperation({ summary: '명함 꾸미기 바꾸기' })
@ApiStandardResponse(PetCardResponseDto, { description: '바꾼 명함' })
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePetCardDto,
): Promise<PetCardResponseDto> {
return this.cards.update(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '명함 삭제' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.cards.remove(user.userId, id);
}
}
// 아이의 대표 명함 — 아이의 하위 리소스로 둔다
@ApiTags('PetCard')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('pets')
export class RepresentativeCardController {
constructor(private readonly cards: PetCardService) {}
@Put(':petId/representative-card')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '대표 명함 정하기' })
@ApiNoContentResponse({ description: '대표 명함으로 정함' })
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '그 아이의 명함이 아님')
async set(
@CurrentUser() user: AuthUser,
@Param('petId', ParseUUIDPipe) petId: string,
@Body() dto: SetRepresentativeDto,
): Promise<void> {
await this.cards.setRepresentative(user.userId, petId, dto.cardId);
}
@Delete(':petId/representative-card')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '대표 명함 지정 풀기' })
@ApiNoContentResponse({ description: '지정을 풀었음' })
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
async clear(
@CurrentUser() user: AuthUser,
@Param('petId', ParseUUIDPipe) petId: string,
): Promise<void> {
await this.cards.clearRepresentative(user.userId, petId);
}
}
@@ -13,6 +13,7 @@ const Map<String, String> kErrorCodeMessages = {
'FILE_002': '파일 크기가 허용 범위를 초과했습니다.',
'DIARY_001': '다이어리는 최대 6권까지 만들 수 있어요.',
'DIARY_002': '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.',
'CARD_001': '펫 명함은 최대 10장까지 만들 수 있어요.',
};
/// 매핑 실패 시 사용하는 폴백 코드
@@ -13,6 +13,7 @@ export const ERROR_CODE_MESSAGES = {
FILE_002: '파일 크기가 허용 범위를 초과했습니다.',
DIARY_001: '다이어리는 최대 6권까지 만들 수 있어요.',
DIARY_002: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.',
CARD_001: '펫 명함은 최대 10장까지 만들 수 있어요.',
} as const
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES