generated from ttipo/project-base
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:
@@ -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,
|
||||
|
||||
@@ -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}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user