feat(admin,player,api): 公共管理与优胜冠军国旗、玩家端内容对接
新增公共内容 CRUD 与批量操作;公告滚动合并管理;优胜冠军内置国家选择与单行保存;玩家端统一 usePlayerHome 对接轮播与跑马灯。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
@@ -304,6 +305,113 @@ class AddOutrightSelectionDto {
|
||||
@IsNumber()
|
||||
@Min(1.01)
|
||||
odds!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
class UpdateOutrightSelectionTeamDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teamCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teamZh?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teamEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoUrl?: string | null;
|
||||
}
|
||||
|
||||
class ContentTranslationDto {
|
||||
@IsString()
|
||||
locale!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
body?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
class CreateContentDto {
|
||||
@IsString()
|
||||
@IsIn(['BANNER', 'NOTICE', 'TICKER'])
|
||||
contentType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
linkType?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
linkTarget?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
startTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endTime?: string | null;
|
||||
|
||||
@IsArray()
|
||||
translations!: ContentTranslationDto[];
|
||||
}
|
||||
|
||||
class UpdateContentDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
linkType?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
linkTarget?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
startTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
translations?: ContentTranslationDto[];
|
||||
}
|
||||
|
||||
class ContentStatusDto {
|
||||
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
class CashbackPreviewDto {
|
||||
@@ -790,6 +898,20 @@ export class AdminController {
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Patch('outrights/:matchId/selections/:selectionId')
|
||||
async updateOutrightSelectionTeam(
|
||||
@Param('matchId') matchId: string,
|
||||
@Param('selectionId') selectionId: string,
|
||||
@Body() dto: UpdateOutrightSelectionTeamDto,
|
||||
) {
|
||||
const data = await this.outright.updateSelectionTeam(
|
||||
BigInt(matchId),
|
||||
BigInt(selectionId),
|
||||
dto,
|
||||
);
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Delete('outrights/:matchId/selections/:selectionId')
|
||||
async removeOutrightSelection(
|
||||
@Param('matchId') matchId: string,
|
||||
@@ -875,17 +997,47 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('contents')
|
||||
async listContents(@Query('type') type?: string) {
|
||||
const items = await this.content.listAll(type);
|
||||
async listContents(
|
||||
@Query('type') type?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
const items = await this.content.listForAdmin(type, status);
|
||||
return jsonResponse(items);
|
||||
}
|
||||
|
||||
@Get('contents/:id')
|
||||
async getContent(@Param('id') id: string) {
|
||||
const item = await this.content.getForAdmin(BigInt(id));
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Post('contents')
|
||||
async createContent(@Body() dto: Parameters<ContentService['create']>[0]) {
|
||||
async createContent(@Body() dto: CreateContentDto) {
|
||||
const item = await this.content.create(dto);
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Put('contents/:id')
|
||||
async updateContent(@Param('id') id: string, @Body() dto: UpdateContentDto) {
|
||||
const item = await this.content.update(BigInt(id), dto);
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Patch('contents/:id/status')
|
||||
async updateContentStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ContentStatusDto,
|
||||
) {
|
||||
const item = await this.content.updateStatus(BigInt(id), dto.status);
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Delete('contents/:id')
|
||||
async deleteContent(@Param('id') id: string) {
|
||||
const result = await this.content.remove(BigInt(id));
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('i18n/messages')
|
||||
async getMessages(@Query('locale') locale = 'en-US') {
|
||||
const messages = await this.i18n.getMessages(locale);
|
||||
|
||||
@@ -109,17 +109,19 @@ export class PlayerController {
|
||||
|
||||
@Get('home')
|
||||
async home(@CurrentUser('locale') locale: string) {
|
||||
const [banners, notices, ticker, hotMatches, todayMatches] = await Promise.all([
|
||||
const [banners, announcements, hotMatches, todayMatches] = await Promise.all([
|
||||
this.content.listActive('BANNER', locale),
|
||||
this.content.listActive('NOTICE', locale),
|
||||
this.content.listActive('TICKER', locale),
|
||||
this.content.listActiveAnnouncements(locale),
|
||||
this.matches.listPublished(locale),
|
||||
this.matches.listPublished(locale),
|
||||
]);
|
||||
return jsonResponse({
|
||||
banners,
|
||||
notices,
|
||||
ticker,
|
||||
announcements,
|
||||
/** @deprecated 使用 announcements */
|
||||
ticker: announcements,
|
||||
/** @deprecated 使用 announcements */
|
||||
notices: announcements,
|
||||
hotMatches: (hotMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot),
|
||||
todayMatches,
|
||||
});
|
||||
|
||||
@@ -138,6 +138,7 @@ export class OutrightService {
|
||||
rank: sel.sortOrder + 1 || index + 1,
|
||||
teamZh: teamZh || sel.selectionName,
|
||||
teamEn: teamEn || sel.selectionName,
|
||||
logoUrl: team?.logoUrl ?? null,
|
||||
odds: sel.odds.toString(),
|
||||
oddsVersion: sel.oddsVersion.toString(),
|
||||
status: sel.status,
|
||||
@@ -243,6 +244,7 @@ export class OutrightService {
|
||||
teamZh: string;
|
||||
teamEn: string;
|
||||
odds: number;
|
||||
logoUrl?: string;
|
||||
},
|
||||
) {
|
||||
if (!data.teamCode?.trim()) {
|
||||
@@ -256,10 +258,19 @@ export class OutrightService {
|
||||
const market = await this.ensureOutrightMarket(match.id);
|
||||
const code = data.teamCode.trim().toUpperCase();
|
||||
|
||||
const logoUrl =
|
||||
data.logoUrl === undefined
|
||||
? undefined
|
||||
: data.logoUrl.trim()
|
||||
? data.logoUrl.trim()
|
||||
: null;
|
||||
const team = await this.prisma.team.upsert({
|
||||
where: { code },
|
||||
create: { code },
|
||||
update: {},
|
||||
create: {
|
||||
code,
|
||||
...(logoUrl !== undefined ? { logoUrl } : {}),
|
||||
},
|
||||
update: logoUrl !== undefined ? { logoUrl } : {},
|
||||
});
|
||||
await this.upsertTeamTranslations(team.id, {
|
||||
'zh-CN': data.teamZh.trim() || data.teamEn,
|
||||
@@ -292,6 +303,77 @@ export class OutrightService {
|
||||
return this.getForAdmin(matchId);
|
||||
}
|
||||
|
||||
async updateSelectionTeam(
|
||||
matchId: bigint,
|
||||
selectionId: bigint,
|
||||
data: {
|
||||
teamCode?: string;
|
||||
teamZh?: string;
|
||||
teamEn?: string;
|
||||
logoUrl?: string | null;
|
||||
},
|
||||
) {
|
||||
const match = await this.getOutrightMatchOrThrow(matchId);
|
||||
const market = await this.ensureOutrightMarket(match.id);
|
||||
const sel = await this.prisma.marketSelection.findFirst({
|
||||
where: { id: selectionId, marketId: market.id },
|
||||
});
|
||||
if (!sel) throw new NotFoundException('Selection not found');
|
||||
|
||||
const nextCode = data.teamCode?.trim().toUpperCase() || sel.selectionCode;
|
||||
if (nextCode !== sel.selectionCode) {
|
||||
const dup = await this.prisma.marketSelection.findFirst({
|
||||
where: {
|
||||
marketId: market.id,
|
||||
selectionCode: nextCode,
|
||||
id: { not: selectionId },
|
||||
},
|
||||
});
|
||||
if (dup) {
|
||||
throw new BadRequestException('Selection already exists for this team code');
|
||||
}
|
||||
await this.prisma.marketSelection.update({
|
||||
where: { id: selectionId },
|
||||
data: {
|
||||
selectionCode: nextCode,
|
||||
selectionName:
|
||||
data.teamZh?.trim() || data.teamEn?.trim() || sel.selectionName,
|
||||
},
|
||||
});
|
||||
} else if (data.teamZh?.trim() || data.teamEn?.trim()) {
|
||||
await this.prisma.marketSelection.update({
|
||||
where: { id: selectionId },
|
||||
data: {
|
||||
selectionName:
|
||||
data.teamZh?.trim() || data.teamEn?.trim() || sel.selectionName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const team = await this.prisma.team.upsert({
|
||||
where: { code: nextCode },
|
||||
create: { code: nextCode },
|
||||
update: {},
|
||||
});
|
||||
|
||||
if (data.teamZh !== undefined || data.teamEn !== undefined) {
|
||||
await this.upsertTeamTranslations(team.id, {
|
||||
'zh-CN': data.teamZh?.trim() || data.teamEn?.trim() || nextCode,
|
||||
'en-US': data.teamEn?.trim() || data.teamZh?.trim() || nextCode,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.logoUrl !== undefined) {
|
||||
const logoUrl = data.logoUrl?.trim() ? data.logoUrl.trim() : null;
|
||||
await this.prisma.team.update({
|
||||
where: { id: team.id },
|
||||
data: { logoUrl },
|
||||
});
|
||||
}
|
||||
|
||||
return this.getForAdmin(matchId);
|
||||
}
|
||||
|
||||
async closeSelection(matchId: bigint, selectionId: bigint) {
|
||||
const match = await this.getOutrightMatchOrThrow(matchId);
|
||||
const market = await this.ensureOutrightMarket(match.id);
|
||||
@@ -399,6 +481,7 @@ export class OutrightService {
|
||||
id: sel.id.toString(),
|
||||
teamCode: sel.selectionCode,
|
||||
teamName,
|
||||
logoUrl: team?.logoUrl ?? null,
|
||||
odds: sel.odds.toString(),
|
||||
oddsVersion: sel.oddsVersion.toString(),
|
||||
};
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
|
||||
export const CONTENT_TYPES = ['BANNER', 'NOTICE', 'TICKER'] as const;
|
||||
export type ContentType = (typeof CONTENT_TYPES)[number];
|
||||
|
||||
/** 管理端合并 Tab:公告与滚动条在玩家端均为顶部跑马灯 */
|
||||
export const ANNOUNCEMENT_ADMIN_TYPE = 'ANNOUNCEMENT';
|
||||
export const ANNOUNCEMENT_TYPES: ContentType[] = ['NOTICE', 'TICKER'];
|
||||
|
||||
export const CONTENT_STATUSES = ['DRAFT', 'ACTIVE', 'INACTIVE'] as const;
|
||||
export type ContentStatus = (typeof CONTENT_STATUSES)[number];
|
||||
|
||||
export const CONTENT_LINK_TYPES = ['ROUTE', 'URL'] as const;
|
||||
export type ContentLinkType = (typeof CONTENT_LINK_TYPES)[number];
|
||||
|
||||
export const CONTENT_LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
|
||||
|
||||
export type ContentTranslationInput = {
|
||||
locale: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
function pickContentTranslation<T extends { locale: string }>(
|
||||
translations: T[],
|
||||
locale: string,
|
||||
): T | undefined {
|
||||
const chain = [locale, 'en-US', 'zh-CN'];
|
||||
const chain = [locale, 'en-US', 'zh-CN', 'ms-MY'];
|
||||
for (const loc of chain) {
|
||||
const hit = translations.find((tr) => tr.locale === loc);
|
||||
if (hit) return hit;
|
||||
@@ -13,10 +39,194 @@ function pickContentTranslation<T extends { locale: string }>(
|
||||
return translations[0];
|
||||
}
|
||||
|
||||
function normalizeOptionalUrl(value?: string | null) {
|
||||
const v = value?.trim();
|
||||
return v || null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private assertContentType(type: string): ContentType {
|
||||
if (!CONTENT_TYPES.includes(type as ContentType)) {
|
||||
throw new BadRequestException(`Invalid contentType: ${type}`);
|
||||
}
|
||||
return type as ContentType;
|
||||
}
|
||||
|
||||
private assertStatus(status: string): ContentStatus {
|
||||
if (!CONTENT_STATUSES.includes(status as ContentStatus)) {
|
||||
throw new BadRequestException(`Invalid status: ${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');
|
||||
}
|
||||
}
|
||||
|
||||
private validateTranslations(
|
||||
contentType: ContentType,
|
||||
translations: ContentTranslationInput[],
|
||||
status: ContentStatus,
|
||||
) {
|
||||
if (!translations.length) {
|
||||
throw new BadRequestException('At least one translation required');
|
||||
}
|
||||
|
||||
const locales = new Set<string>();
|
||||
for (const tr of translations) {
|
||||
if (!tr.locale?.trim()) {
|
||||
throw new BadRequestException('Translation locale required');
|
||||
}
|
||||
if (locales.has(tr.locale)) {
|
||||
throw new BadRequestException(`Duplicate locale: ${tr.locale}`);
|
||||
}
|
||||
locales.add(tr.locale);
|
||||
}
|
||||
|
||||
if (status !== 'ACTIVE') return;
|
||||
|
||||
const hasUsable = translations.some((tr) => {
|
||||
if (contentType === 'BANNER') {
|
||||
return !!normalizeOptionalUrl(tr.imageUrl);
|
||||
}
|
||||
if (contentType === 'NOTICE') {
|
||||
return !!(tr.title?.trim() || tr.body?.trim());
|
||||
}
|
||||
return !!tr.body?.trim();
|
||||
});
|
||||
|
||||
if (!hasUsable) {
|
||||
throw new BadRequestException(
|
||||
contentType === 'BANNER'
|
||||
? 'ACTIVE banner requires imageUrl in at least one locale'
|
||||
: contentType === 'NOTICE'
|
||||
? 'ACTIVE notice requires title or body in at least one locale'
|
||||
: 'ACTIVE ticker requires body in at least one locale',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
if (!linkTarget?.trim()) {
|
||||
throw new BadRequestException('linkTarget required when linkType is set');
|
||||
}
|
||||
}
|
||||
|
||||
playerVisibility(
|
||||
item: {
|
||||
status: string;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
contentType: string;
|
||||
translations: Array<{
|
||||
locale: string;
|
||||
title?: string | null;
|
||||
body?: string | null;
|
||||
imageUrl?: string | null;
|
||||
}>;
|
||||
},
|
||||
now = new Date(),
|
||||
): { playerVisible: boolean; playerHiddenReason: string | null } {
|
||||
if (item.status !== 'ACTIVE') {
|
||||
return { playerVisible: false, playerHiddenReason: 'NOT_ACTIVE' };
|
||||
}
|
||||
if (item.startTime && item.startTime > now) {
|
||||
return { playerVisible: false, playerHiddenReason: 'NOT_STARTED' };
|
||||
}
|
||||
if (item.endTime && item.endTime < now) {
|
||||
return { playerVisible: false, playerHiddenReason: 'EXPIRED' };
|
||||
}
|
||||
|
||||
const type = item.contentType as ContentType;
|
||||
const ok = item.translations.some((tr) => {
|
||||
if (type === 'BANNER') return !!normalizeOptionalUrl(tr.imageUrl);
|
||||
if (type === 'NOTICE') return !!(tr.title?.trim() || tr.body?.trim());
|
||||
return !!tr.body?.trim();
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
return { playerVisible: false, playerHiddenReason: 'INCOMPLETE' };
|
||||
}
|
||||
|
||||
return { playerVisible: true, playerHiddenReason: null };
|
||||
}
|
||||
|
||||
private mapAdminItem(
|
||||
item: Awaited<ReturnType<typeof this.getRawById>>,
|
||||
now = new Date(),
|
||||
) {
|
||||
const visibility = this.playerVisibility(item, now);
|
||||
const preview =
|
||||
pickContentTranslation(item.translations, 'zh-CN') ??
|
||||
pickContentTranslation(item.translations, 'en-US');
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
contentType: item.contentType,
|
||||
sortOrder: item.sortOrder,
|
||||
status: item.status,
|
||||
linkType: item.linkType,
|
||||
linkTarget: item.linkTarget,
|
||||
startTime: item.startTime?.toISOString() ?? null,
|
||||
endTime: item.endTime?.toISOString() ?? null,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
previewTitle: preview?.title ?? preview?.body?.slice(0, 40) ?? '',
|
||||
previewImageUrl: preview?.imageUrl ?? null,
|
||||
playerVisible: visibility.playerVisible,
|
||||
playerHiddenReason: visibility.playerHiddenReason,
|
||||
translations: item.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title ?? '',
|
||||
body: tr.body ?? '',
|
||||
imageUrl: tr.imageUrl ?? '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getRawById(id: bigint) {
|
||||
const item = await this.prisma.content.findUnique({
|
||||
where: { id },
|
||||
include: { translations: true },
|
||||
});
|
||||
if (!item) throw new NotFoundException('Content not found');
|
||||
return item;
|
||||
}
|
||||
|
||||
async listActiveAnnouncements(locale: string) {
|
||||
const now = new Date();
|
||||
const items = await this.prisma.content.findMany({
|
||||
where: {
|
||||
contentType: { in: [...ANNOUNCEMENT_TYPES] },
|
||||
status: 'ACTIVE',
|
||||
OR: [{ startTime: null }, { startTime: { lte: now } }],
|
||||
AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }],
|
||||
},
|
||||
include: { translations: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return items
|
||||
.filter((item) => this.playerVisibility(item, now).playerVisible)
|
||||
.map((item) => {
|
||||
const tr = pickContentTranslation(item.translations, locale);
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
contentType: item.contentType,
|
||||
sortOrder: item.sortOrder,
|
||||
translation: tr,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listActive(contentType: string, locale: string) {
|
||||
const now = new Date();
|
||||
const items = await this.prisma.content.findMany({
|
||||
@@ -30,39 +240,208 @@ export class ContentService {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return items.map((item) => {
|
||||
const t = pickContentTranslation(item.translations, locale);
|
||||
return { ...item, translation: t };
|
||||
return items
|
||||
.filter((item) => this.playerVisibility(item, now).playerVisible)
|
||||
.map((item) => {
|
||||
const t = pickContentTranslation(item.translations, locale);
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
contentType: item.contentType,
|
||||
sortOrder: item.sortOrder,
|
||||
linkType: item.linkType,
|
||||
linkTarget: item.linkTarget,
|
||||
translation: t,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listForAdmin(contentType?: string, status?: string) {
|
||||
const typeWhere =
|
||||
contentType === ANNOUNCEMENT_ADMIN_TYPE
|
||||
? { contentType: { in: [...ANNOUNCEMENT_TYPES] } }
|
||||
: contentType
|
||||
? { contentType }
|
||||
: {};
|
||||
|
||||
const items = await this.prisma.content.findMany({
|
||||
where: {
|
||||
...typeWhere,
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
include: { translations: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return items.map((item) => this.mapAdminItem(item));
|
||||
}
|
||||
|
||||
async getForAdmin(id: bigint) {
|
||||
return this.mapAdminItem(await this.getRawById(id));
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
contentType: string;
|
||||
sortOrder?: number;
|
||||
linkType?: string;
|
||||
linkTarget?: string;
|
||||
translations: Array<{ locale: string; title?: string; body?: string; imageUrl?: string }>;
|
||||
status?: string;
|
||||
linkType?: string | null;
|
||||
linkTarget?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
translations: ContentTranslationInput[];
|
||||
}) {
|
||||
return this.prisma.content.create({
|
||||
const contentType = this.assertContentType(data.contentType);
|
||||
const status = this.assertStatus(data.status ?? 'DRAFT');
|
||||
this.validateLink(data.linkType, data.linkTarget);
|
||||
this.validateTranslations(contentType, data.translations, status);
|
||||
|
||||
const startTime = data.startTime ? new Date(data.startTime) : null;
|
||||
const endTime = data.endTime ? new Date(data.endTime) : null;
|
||||
this.validateSchedule(startTime, endTime);
|
||||
|
||||
const item = await this.prisma.content.create({
|
||||
data: {
|
||||
contentType: data.contentType,
|
||||
contentType,
|
||||
sortOrder: data.sortOrder ?? 0,
|
||||
linkType: data.linkType,
|
||||
linkTarget: data.linkTarget,
|
||||
status: 'ACTIVE',
|
||||
status,
|
||||
linkType: data.linkType?.trim() || null,
|
||||
linkTarget: data.linkTarget?.trim() || null,
|
||||
startTime,
|
||||
endTime,
|
||||
translations: {
|
||||
create: data.translations,
|
||||
create: data.translations.map((tr) => ({
|
||||
locale: tr.locale.trim(),
|
||||
title: tr.title?.trim() || null,
|
||||
body: tr.body?.trim() || null,
|
||||
imageUrl: normalizeOptionalUrl(tr.imageUrl),
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { translations: true },
|
||||
});
|
||||
|
||||
return this.mapAdminItem(item);
|
||||
}
|
||||
|
||||
async listAll(contentType?: string) {
|
||||
return this.prisma.content.findMany({
|
||||
where: contentType ? { contentType } : {},
|
||||
include: { translations: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
async update(
|
||||
id: bigint,
|
||||
data: {
|
||||
sortOrder?: number;
|
||||
status?: string;
|
||||
linkType?: string | null;
|
||||
linkTarget?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
translations?: ContentTranslationInput[];
|
||||
},
|
||||
) {
|
||||
const existing = await this.getRawById(id);
|
||||
const contentType = existing.contentType as ContentType;
|
||||
const status = data.status
|
||||
? this.assertStatus(data.status)
|
||||
: (existing.status as ContentStatus);
|
||||
|
||||
const linkType =
|
||||
data.linkType !== undefined
|
||||
? data.linkType?.trim() || null
|
||||
: existing.linkType;
|
||||
const linkTarget =
|
||||
data.linkTarget !== undefined
|
||||
? data.linkTarget?.trim() || null
|
||||
: existing.linkTarget;
|
||||
this.validateLink(linkType, linkTarget);
|
||||
|
||||
const startTime =
|
||||
data.startTime !== undefined
|
||||
? data.startTime
|
||||
? new Date(data.startTime)
|
||||
: null
|
||||
: existing.startTime;
|
||||
const endTime =
|
||||
data.endTime !== undefined
|
||||
? data.endTime
|
||||
? new Date(data.endTime)
|
||||
: null
|
||||
: existing.endTime;
|
||||
this.validateSchedule(startTime, endTime);
|
||||
|
||||
const translations =
|
||||
data.translations ??
|
||||
existing.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title ?? undefined,
|
||||
body: tr.body ?? undefined,
|
||||
imageUrl: tr.imageUrl ?? undefined,
|
||||
}));
|
||||
this.validateTranslations(contentType, translations, status);
|
||||
|
||||
await this.prisma.content.update({
|
||||
where: { id },
|
||||
data: {
|
||||
sortOrder: data.sortOrder ?? existing.sortOrder,
|
||||
status,
|
||||
linkType,
|
||||
linkTarget,
|
||||
startTime,
|
||||
endTime,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.translations) {
|
||||
for (const tr of data.translations) {
|
||||
await this.prisma.contentTranslation.upsert({
|
||||
where: {
|
||||
contentId_locale: {
|
||||
contentId: id,
|
||||
locale: tr.locale.trim(),
|
||||
},
|
||||
},
|
||||
create: {
|
||||
contentId: id,
|
||||
locale: tr.locale.trim(),
|
||||
title: tr.title?.trim() || null,
|
||||
body: tr.body?.trim() || null,
|
||||
imageUrl: normalizeOptionalUrl(tr.imageUrl),
|
||||
},
|
||||
update: {
|
||||
title: tr.title?.trim() || null,
|
||||
body: tr.body?.trim() || null,
|
||||
imageUrl: normalizeOptionalUrl(tr.imageUrl),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.getForAdmin(id);
|
||||
}
|
||||
|
||||
async updateStatus(id: bigint, status: string) {
|
||||
const existing = await this.getRawById(id);
|
||||
const next = this.assertStatus(status);
|
||||
this.validateTranslations(
|
||||
existing.contentType as ContentType,
|
||||
existing.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title ?? undefined,
|
||||
body: tr.body ?? undefined,
|
||||
imageUrl: tr.imageUrl ?? undefined,
|
||||
})),
|
||||
next,
|
||||
);
|
||||
await this.prisma.content.update({
|
||||
where: { id },
|
||||
data: { status: next },
|
||||
});
|
||||
return this.getForAdmin(id);
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.getRawById(id);
|
||||
await this.prisma.contentTranslation.deleteMany({ where: { contentId: id } });
|
||||
await this.prisma.content.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** @deprecated use listForAdmin */
|
||||
async listAll(contentType?: string) {
|
||||
return this.listForAdmin(contentType);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user