feat: internationalize API error responses by locale
Add shared error codes with zh/en/ms messages, coded app exceptions, and locale-aware global filter. Frontends send X-Locale so error text matches the active UI language. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { appBadRequest, appNotFound } from '../../../shared/common/app-error';
|
||||
|
||||
export const CONTENT_TYPES = ['BANNER', 'NOTICE', 'TICKER'] as const;
|
||||
export type ContentType = (typeof CONTENT_TYPES)[number];
|
||||
@@ -50,21 +50,21 @@ export class ContentService {
|
||||
|
||||
private assertContentType(type: string): ContentType {
|
||||
if (!CONTENT_TYPES.includes(type as ContentType)) {
|
||||
throw new BadRequestException(`Invalid contentType: ${type}`);
|
||||
throw appBadRequest('CONTENT_TYPE_INVALID', { type });
|
||||
}
|
||||
return type as ContentType;
|
||||
}
|
||||
|
||||
private assertStatus(status: string): ContentStatus {
|
||||
if (!CONTENT_STATUSES.includes(status as ContentStatus)) {
|
||||
throw new BadRequestException(`Invalid status: ${status}`);
|
||||
throw appBadRequest('CONTENT_STATUS_INVALID', { status });
|
||||
}
|
||||
return status as ContentStatus;
|
||||
}
|
||||
|
||||
private validateSchedule(startTime?: Date | null, endTime?: Date | null) {
|
||||
if (startTime && endTime && endTime <= startTime) {
|
||||
throw new BadRequestException('endTime must be after startTime');
|
||||
throw appBadRequest('CONTENT_END_BEFORE_START');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,16 +74,16 @@ export class ContentService {
|
||||
status: ContentStatus,
|
||||
) {
|
||||
if (!translations.length) {
|
||||
throw new BadRequestException('At least one translation required');
|
||||
throw appBadRequest('CONTENT_TRANSLATION_REQUIRED');
|
||||
}
|
||||
|
||||
const locales = new Set<string>();
|
||||
for (const tr of translations) {
|
||||
if (!tr.locale?.trim()) {
|
||||
throw new BadRequestException('Translation locale required');
|
||||
throw appBadRequest('CONTENT_LOCALE_REQUIRED');
|
||||
}
|
||||
if (locales.has(tr.locale)) {
|
||||
throw new BadRequestException(`Duplicate locale: ${tr.locale}`);
|
||||
throw appBadRequest('CONTENT_LOCALE_DUPLICATE', { locale: tr.locale });
|
||||
}
|
||||
locales.add(tr.locale);
|
||||
}
|
||||
@@ -101,12 +101,12 @@ export class ContentService {
|
||||
});
|
||||
|
||||
if (!hasUsable) {
|
||||
throw new BadRequestException(
|
||||
throw appBadRequest(
|
||||
contentType === 'BANNER'
|
||||
? 'ACTIVE banner requires imageUrl in at least one locale'
|
||||
? 'CONTENT_ACTIVE_BANNER_INCOMPLETE'
|
||||
: contentType === 'NOTICE'
|
||||
? 'ACTIVE notice requires title or body in at least one locale'
|
||||
: 'ACTIVE ticker requires body in at least one locale',
|
||||
? 'CONTENT_ACTIVE_NOTICE_INCOMPLETE'
|
||||
: 'CONTENT_ACTIVE_TICKER_INCOMPLETE',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -114,10 +114,10 @@ export class ContentService {
|
||||
private validateLink(linkType?: string | null, linkTarget?: string | null) {
|
||||
if (!linkType) return;
|
||||
if (!CONTENT_LINK_TYPES.includes(linkType as ContentLinkType)) {
|
||||
throw new BadRequestException(`Invalid linkType: ${linkType}`);
|
||||
throw appBadRequest('CONTENT_LINK_TYPE_INVALID', { linkType });
|
||||
}
|
||||
if (!linkTarget?.trim()) {
|
||||
throw new BadRequestException('linkTarget required when linkType is set');
|
||||
throw appBadRequest('CONTENT_LINK_TARGET_REQUIRED');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ export class ContentService {
|
||||
where: { id },
|
||||
include: { translations: true },
|
||||
});
|
||||
if (!item) throw new NotFoundException('Content not found');
|
||||
if (!item) throw appNotFound('CONTENT_NOT_FOUND');
|
||||
return item;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user