Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/common/errors/error-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,10 @@ export const ERROR_CATALOG = {
status: HttpStatus.NOT_FOUND,
message: '커스텀 텍스트 토큰을 찾을 수 없습니다.',
},
CUSTOM_TEXT_TOKEN_KEY_TAKEN: {
status: HttpStatus.CONFLICT,
message: '이 템플릿에 이미 같은 키의 커스텀 텍스트 토큰이 있습니다.',
},
INVALID_DAY_OF_WEEK: {
status: HttpStatus.BAD_REQUEST,
message: 'dayOfWeek는 0~6이어야 합니다.',
Expand Down
20 changes: 12 additions & 8 deletions src/features/product/product-seller.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ extend type Mutation {
): SellerCustomTemplate!
"""
커스텀 텍스트 토큰을 등록 또는 수정한다. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND.
등록 시 같은 템플릿에 같은 tokenKey의 삭제된 슬롯이 있으면 그 슬롯(같은 id)을 복구해 입력값으로 갱신한다.
활성 슬롯과 tokenKey가 겹치면(수정으로 삭제된 슬롯의 키로 바꾸는 경우 포함) CUSTOM_TEXT_TOKEN_KEY_TAKEN.
"""
sellerUpsertProductCustomTextToken(
input: SellerUpsertProductCustomTextTokenInput!
Expand Down Expand Up @@ -309,19 +311,20 @@ type SellerCustomTextToken {
"""
isRequired: Boolean!
"""
베이스 이미지 위에서 문구가 놓일 가로 위치. 미지정 시 null이고 배치는 클라이언트가 정한다.
문구 영역 왼쪽 위 모서리의 가로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000)이고 서버는 범위를 검사하지 않는다.
미지정 시 null이고 배치는 클라이언트가 정한다.
"""
posX: Int
"""
베이스 이미지 위에서 문구가 놓일 세로 위치. 미지정 시 null.
문구 영역 왼쪽 위 모서리의 세로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000). 미지정 시 null.
"""
posY: Int
"""
문구 영역 너비. 미지정 시 null.
문구 영역 너비. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000). 미지정 시 null.
"""
width: Int
"""
문구 영역 높이. 미지정 시 null.
문구 영역 높이. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000). 미지정 시 null.
"""
height: Int
}
Expand Down Expand Up @@ -862,19 +865,20 @@ input SellerUpsertProductCustomTextTokenInput {
"""
isRequired: Boolean = true
"""
베이스 이미지 위 가로 위치. 생략 시 배치를 클라이언트가 정한다.
문구 영역 왼쪽 위 모서리의 가로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000)이고 서버는 범위를 검사하지 않는다.
생략 시 배치를 클라이언트가 정한다.
"""
posX: Int
"""
베이스 이미지 위 세로 위치.
문구 영역 왼쪽 위 모서리의 세로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000).
"""
posY: Int
"""
문구 영역 너비.
문구 영역 너비. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000).
"""
width: Int
"""
문구 영역 높이.
문구 영역 높이. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000).
"""
height: Int
}
Expand Down
48 changes: 39 additions & 9 deletions src/features/product/repositories/product.repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';

import { DomainException } from '@/common/errors/error-catalog';
import { uniqueConstraintName } from '@/common/utils/prisma-error';
import {
AUDIT_LOG_REPOSITORY,
type AuditEntry,
Expand All @@ -17,6 +19,17 @@ import {
} from '@/generated/prisma/client';
import { activeWhere, PrismaService, visibleWhere } from '@/prisma';

/** 활성 슬롯과의 키 중복(경쟁 포함)·삭제 슬롯 키로의 변경은 unique 충돌로만 드러나 도메인 예외로 좁힌다. */
function rethrowTokenKeyTaken(error: unknown): never {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
uniqueConstraintName(error) === 'uk_product_custom_text_token'
) {
throw new DomainException('CUSTOM_TEXT_TOKEN_KEY_TAKEN');
}
throw error;
}

const tagSuggestionInclude = {
_count: {
select: {
Expand Down Expand Up @@ -1002,15 +1015,32 @@ export class ProductRepository {
height: args.height,
};
const tokenId = args.tokenId;
return this.writeWithAudit(
(tx) =>
tokenId
? tx.productCustomTextToken.update({ where: { id: tokenId }, data })
: tx.productCustomTextToken.create({
data: { template_id: args.templateId, ...data },
}),
audit,
);
return this.writeWithAudit(async (tx) => {
if (tokenId) {
return tx.productCustomTextToken
.update({ where: { id: tokenId }, data })
.catch(rethrowTokenKeyTaken);
}
// 같은 템플릿의 슬롯 생성을 직렬화한다 — 잠금 없이는 같은 삭제 슬롯을 동시에 복구한 둘이 모두 성공한다
await tx.$queryRaw`SELECT id FROM product_custom_template WHERE id = ${args.templateId} FOR UPDATE`;
// unique가 삭제 행도 세므로 같은 키의 삭제 슬롯은 새로 만들지 않고 복구한다
const deleted = await tx.productCustomTextToken.findFirst({
where: {
template_id: args.templateId,
token_key: args.tokenKey,
deleted_at: { not: null },
},
select: { id: true },
});
return deleted
? tx.productCustomTextToken.update({
where: { id: deleted.id },
data: { ...data, deleted_at: null },
})
: tx.productCustomTextToken
.create({ data: { template_id: args.templateId, ...data } })
.catch(rethrowTokenKeyTaken);
}, audit);
}

async findCustomTextTokenById(id: bigint) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log';
import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository';
import { ProductRepository } from '@/features/product/repositories/product.repository';
import { SellerCustomTemplateService } from '@/features/product/services/product-seller-custom-template.service';
import type { SellerCustomTextTokenOutput } from '@/features/product/types/product-seller-output.type';
import { StoreSellerRepository } from '@/features/store/repositories/store-seller.repository';
import type { PrismaClient, Product } from '@/generated/prisma/client';
import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client';
Expand Down Expand Up @@ -266,6 +267,224 @@ describe('SellerCustomTemplateService (real DB)', () => {
expect(result.id).toBe(token.id.toString());
expect(result.tokenKey).toBe('B');
});

describe('같은 tokenKey 재사용', () => {
it('삭제한 슬롯과 같은 키로 등록하면 그 슬롯을 복구해 입력값으로 갱신한다', async () => {
const { accountId, storeId, product } = await setupSellerWithProduct();
const tpl = await createTemplate(product.id);
const first = await service.sellerUpsertProductCustomTextToken(
accountId,
{
templateId: tpl.id.toString(),
tokenKey: 'NAME',
defaultText: '가',
},
);
await service.sellerDeleteProductCustomTextToken(
accountId,
BigInt(first.id),
);

const result = await service.sellerUpsertProductCustomTextToken(
accountId,
{
templateId: tpl.id.toString(),
tokenKey: 'NAME',
defaultText: '나',
maxLength: 12,
sortOrder: 3,
isRequired: false,
posX: 1000,
posY: 2000,
width: 8000,
height: 1500,
},
);

expect(result).toEqual({
id: first.id,
templateId: tpl.id.toString(),
tokenKey: 'NAME',
defaultText: '나',
maxLength: 12,
sortOrder: 3,
isRequired: false,
posX: 1000,
posY: 2000,
width: 8000,
height: 1500,
});
const rows = await prisma.productCustomTextToken.findMany({
where: { template_id: tpl.id, deleted_at: undefined },
});
expect(rows).toHaveLength(1);
expect(rows[0].deleted_at).toBeNull();
const audits = await prisma.auditLog.findMany({
where: { store_id: storeId, action: 'CREATE' },
orderBy: { id: 'asc' },
});
expect(audits.map((a) => a.after_json)).toEqual([
{ tokenId: first.id, tokenKey: 'NAME' },
{ tokenId: first.id, tokenKey: 'NAME' },
]);
});

it('활성 슬롯과 키가 겹치면 CUSTOM_TEXT_TOKEN_KEY_TAKEN이고 아무것도 바뀌지 않는다', async () => {
const { accountId, storeId, product } = await setupSellerWithProduct();
const tpl = await createTemplate(product.id);
const live = await prisma.productCustomTextToken.create({
data: { template_id: tpl.id, token_key: 'NAME', default_text: '가' },
});

await expect(
service.sellerUpsertProductCustomTextToken(accountId, {
templateId: tpl.id.toString(),
tokenKey: 'NAME',
defaultText: '나',
}),
).rejects.toThrowDomain('CUSTOM_TEXT_TOKEN_KEY_TAKEN');

const after = await prisma.productCustomTextToken.findUniqueOrThrow({
where: { id: live.id },
});
expect(after.default_text).toBe('가');
expect(
await prisma.auditLog.count({ where: { store_id: storeId } }),
).toBe(0);
});

it.each([
['활성 슬롯', null],
['삭제된 슬롯', new Date()],
])(
'수정으로 %s의 키로 바꾸면 CUSTOM_TEXT_TOKEN_KEY_TAKEN',
async (_label, deletedAt) => {
const { accountId, product } = await setupSellerWithProduct();
const tpl = await createTemplate(product.id);
await prisma.productCustomTextToken.create({
data: {
template_id: tpl.id,
token_key: 'TAKEN',
default_text: 'x',
deleted_at: deletedAt,
},
});
const mine = await prisma.productCustomTextToken.create({
data: { template_id: tpl.id, token_key: 'MINE', default_text: 'y' },
});

await expect(
service.sellerUpsertProductCustomTextToken(accountId, {
templateId: tpl.id.toString(),
tokenId: mine.id.toString(),
tokenKey: 'TAKEN',
defaultText: 'y',
}),
).rejects.toThrowDomain('CUSTOM_TEXT_TOKEN_KEY_TAKEN');
},
);

it('다른 템플릿의 같은 키는 활성이든 삭제든 무관하다', async () => {
const me = await setupSellerWithProduct();
const other = await setupSellerWithProduct();
const myTpl = await createTemplate(me.product.id);
const othersTpl = await createTemplate(other.product.id);
const othersLive = await prisma.productCustomTextToken.create({
data: {
template_id: othersTpl.id,
token_key: 'NAME',
default_text: 'a',
},
});
const othersDeleted = await prisma.productCustomTextToken.create({
data: {
template_id: othersTpl.id,
token_key: 'GONE',
default_text: 'b',
deleted_at: new Date(),
},
});

const created = await Promise.all(
['NAME', 'GONE'].map((tokenKey) =>
service.sellerUpsertProductCustomTextToken(me.accountId, {
templateId: myTpl.id.toString(),
tokenKey,
defaultText: '내것',
}),
),
);

expect(created.map((t) => t.templateId)).toEqual([
myTpl.id.toString(),
myTpl.id.toString(),
]);
const createdIds = created.map((t) => t.id);
expect(createdIds).not.toContain(othersLive.id.toString());
expect(createdIds).not.toContain(othersDeleted.id.toString());
const stillDeleted =
await prisma.productCustomTextToken.findUniqueOrThrow({
where: { id: othersDeleted.id },
});
expect(stillDeleted.deleted_at).not.toBeNull();
});

it.each([
['새 키', false],
['삭제된 슬롯의 키', true],
])(
'%s로 동시에 2건을 등록하면 1건만 성공하고 나머지는 CUSTOM_TEXT_TOKEN_KEY_TAKEN',
async (_label, withDeleted) => {
const { accountId, storeId, product } =
await setupSellerWithProduct();
const tpl = await createTemplate(product.id);
if (withDeleted) {
await prisma.productCustomTextToken.create({
data: {
template_id: tpl.id,
token_key: 'NAME',
default_text: 'old',
deleted_at: new Date(),
},
});
}

const results = await Promise.allSettled(
['A', 'B'].map((defaultText) =>
service.sellerUpsertProductCustomTextToken(accountId, {
templateId: tpl.id.toString(),
tokenKey: 'NAME',
defaultText,
}),
),
);

const fulfilled = results.filter(
(r): r is PromiseFulfilledResult<SellerCustomTextTokenOutput> =>
r.status === 'fulfilled',
);
const rejected = results.filter(
(r): r is PromiseRejectedResult => r.status === 'rejected',
);
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
expect(rejected[0].reason).toThrowDomain(
'CUSTOM_TEXT_TOKEN_KEY_TAKEN',
);
const rows = await prisma.productCustomTextToken.findMany({
where: { template_id: tpl.id, deleted_at: undefined },
});
expect(rows).toHaveLength(1);
expect(rows[0].deleted_at).toBeNull();
expect(rows[0].default_text).toBe(fulfilled[0].value.defaultText);
expect(
await prisma.auditLog.count({
where: { store_id: storeId, action: 'CREATE' },
}),
).toBe(1);
},
);
});
});

describe('sellerDeleteProductCustomTextToken', () => {
Expand Down
Loading