从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
680 lines
20 KiB
TypeScript
680 lines
20 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
||
import { Prisma } from '@prisma/client';
|
||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||
|
||
export type PlayerMessageType =
|
||
| 'DEPOSIT_APPROVED'
|
||
| 'DEPOSIT_REJECTED'
|
||
| 'BANNER_PROMO'
|
||
| 'ANNOUNCEMENT_PROMO'
|
||
| 'ADMIN_CUSTOM';
|
||
|
||
export type DepositMessagePayload = {
|
||
depositOrderId: string;
|
||
orderNo: string;
|
||
amount: string;
|
||
approvedAmount?: string | null;
|
||
rejectReason?: string | null;
|
||
};
|
||
|
||
export type BannerPromoPayload = {
|
||
contentId: string;
|
||
};
|
||
|
||
type ContentTranslationLike = {
|
||
locale: string;
|
||
title?: string | null;
|
||
body?: string | null;
|
||
};
|
||
|
||
const SUPPORTED_LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
|
||
|
||
const BANNER_PROMO_DEFAULT_TITLE: Record<string, string> = {
|
||
'zh-CN': '新推广活动',
|
||
'en-US': 'New promotion',
|
||
'ms-MY': 'Promosi baharu',
|
||
};
|
||
|
||
const ANNOUNCEMENT_PROMO_DEFAULT_TITLE: Record<string, string> = {
|
||
'zh-CN': '新公告',
|
||
'en-US': 'New announcement',
|
||
'ms-MY': 'Pengumuman baharu',
|
||
};
|
||
|
||
type MessageTemplate = {
|
||
title: string;
|
||
body: (payload: DepositMessagePayload) => string;
|
||
};
|
||
|
||
const MESSAGE_TEMPLATES: Record<
|
||
Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO' | 'ADMIN_CUSTOM'>,
|
||
Record<string, MessageTemplate>
|
||
> = {
|
||
DEPOSIT_APPROVED: {
|
||
'zh-CN': {
|
||
title: '充值已到账',
|
||
body: (p) =>
|
||
`您的充值订单 ${p.orderNo} 已审核通过,申请金额 ${p.amount},到账金额 ${p.approvedAmount ?? p.amount}。`,
|
||
},
|
||
'en-US': {
|
||
title: 'Deposit approved',
|
||
body: (p) =>
|
||
`Your deposit order ${p.orderNo} has been approved. Requested ${p.amount}, credited ${p.approvedAmount ?? p.amount}.`,
|
||
},
|
||
'ms-MY': {
|
||
title: 'Deposit diluluskan',
|
||
body: (p) =>
|
||
`Pesanan deposit ${p.orderNo} telah diluluskan. Diminta ${p.amount}, dikreditkan ${p.approvedAmount ?? p.amount}.`,
|
||
},
|
||
},
|
||
DEPOSIT_REJECTED: {
|
||
'zh-CN': {
|
||
title: '充值未通过',
|
||
body: (p) => {
|
||
const reason = p.rejectReason?.trim();
|
||
return reason
|
||
? `您的充值订单 ${p.orderNo}(${p.amount})未通过审核。原因:${reason}`
|
||
: `您的充值订单 ${p.orderNo}(${p.amount})未通过审核。`;
|
||
},
|
||
},
|
||
'en-US': {
|
||
title: 'Deposit rejected',
|
||
body: (p) => {
|
||
const reason = p.rejectReason?.trim();
|
||
return reason
|
||
? `Your deposit order ${p.orderNo} (${p.amount}) was rejected. Reason: ${reason}`
|
||
: `Your deposit order ${p.orderNo} (${p.amount}) was rejected.`;
|
||
},
|
||
},
|
||
'ms-MY': {
|
||
title: 'Deposit ditolak',
|
||
body: (p) => {
|
||
const reason = p.rejectReason?.trim();
|
||
return reason
|
||
? `Pesanan deposit ${p.orderNo} (${p.amount}) ditolak. Sebab: ${reason}`
|
||
: `Pesanan deposit ${p.orderNo} (${p.amount}) ditolak.`;
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
function resolveLocale(locale?: string | null): string {
|
||
const value = locale?.trim();
|
||
if (value && SUPPORTED_LOCALES.includes(value as (typeof SUPPORTED_LOCALES)[number])) {
|
||
return value;
|
||
}
|
||
return 'en-US';
|
||
}
|
||
|
||
function pickContentTranslation<T extends { locale: string }>(
|
||
translations: T[],
|
||
locale: string,
|
||
): T | undefined {
|
||
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;
|
||
}
|
||
return translations[0];
|
||
}
|
||
|
||
function stripHtml(value: string): string {
|
||
return value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function buildBannerPromoFallbackBody(locale: string, title: string): string {
|
||
if (locale === 'zh-CN') return `「${title}」已上线,请到首页查看详情。`;
|
||
if (locale === 'ms-MY') return `"${title}" kini tersedia. Lihat butiran di halaman utama.`;
|
||
return `"${title}" is now live. View details on the home page.`;
|
||
}
|
||
|
||
function buildAnnouncementPromoFallbackBody(locale: string, title: string): string {
|
||
if (locale === 'zh-CN') return `公告「${title}」已发布,请及时查看。`;
|
||
if (locale === 'ms-MY') return `Pengumuman "${title}" telah diterbitkan. Sila semak.`;
|
||
return `Announcement "${title}" is published. Please check it out.`;
|
||
}
|
||
|
||
function buildBannerPromoMessage(
|
||
locale: string | null | undefined,
|
||
contentId: bigint,
|
||
translations: ContentTranslationLike[],
|
||
) {
|
||
const resolvedLocale = resolveLocale(locale);
|
||
const tr = pickContentTranslation(translations, resolvedLocale);
|
||
const title =
|
||
tr?.title?.trim() ||
|
||
BANNER_PROMO_DEFAULT_TITLE[resolvedLocale] ||
|
||
BANNER_PROMO_DEFAULT_TITLE['en-US'];
|
||
const rawBody = tr?.body?.trim() ? stripHtml(tr.body) : '';
|
||
const body = rawBody || buildBannerPromoFallbackBody(resolvedLocale, title);
|
||
const payload: BannerPromoPayload = { contentId: contentId.toString() };
|
||
return {
|
||
type: 'BANNER_PROMO' as const,
|
||
title,
|
||
body,
|
||
payload,
|
||
};
|
||
}
|
||
|
||
function buildAnnouncementPromoMessage(
|
||
locale: string | null | undefined,
|
||
contentId: bigint,
|
||
translations: ContentTranslationLike[],
|
||
) {
|
||
const resolvedLocale = resolveLocale(locale);
|
||
const tr = pickContentTranslation(translations, resolvedLocale);
|
||
const title =
|
||
tr?.title?.trim() ||
|
||
(tr?.body?.trim() ? tr.body.trim().slice(0, 40) : '') ||
|
||
ANNOUNCEMENT_PROMO_DEFAULT_TITLE[resolvedLocale] ||
|
||
ANNOUNCEMENT_PROMO_DEFAULT_TITLE['en-US'];
|
||
const rawBody = tr?.body?.trim() ?? '';
|
||
const body = rawBody || buildAnnouncementPromoFallbackBody(resolvedLocale, title);
|
||
const payload: BannerPromoPayload = { contentId: contentId.toString() };
|
||
return {
|
||
type: 'ANNOUNCEMENT_PROMO' as const,
|
||
title,
|
||
body,
|
||
payload,
|
||
};
|
||
}
|
||
|
||
function mapMessageRow(row: {
|
||
id: bigint;
|
||
type: string;
|
||
title: string;
|
||
body: string;
|
||
payload: Prisma.JsonValue;
|
||
readAt: Date | null;
|
||
createdAt: Date;
|
||
}) {
|
||
return {
|
||
id: row.id.toString(),
|
||
type: row.type,
|
||
title: row.title,
|
||
body: row.body,
|
||
payload: row.payload ?? null,
|
||
readAt: row.readAt?.toISOString() ?? null,
|
||
createdAt: row.createdAt.toISOString(),
|
||
isRead: row.readAt != null,
|
||
};
|
||
}
|
||
|
||
export type BroadcastTranslation = {
|
||
title: string;
|
||
body: string;
|
||
};
|
||
|
||
export type BroadcastTranslations = Record<string, BroadcastTranslation>;
|
||
|
||
export type BroadcastTranslationInput = {
|
||
locale: string;
|
||
title?: string | null;
|
||
body?: string | null;
|
||
};
|
||
|
||
function normalizeBroadcastTranslations(
|
||
inputs: BroadcastTranslationInput[],
|
||
): BroadcastTranslations {
|
||
const map: BroadcastTranslations = {};
|
||
for (const tr of inputs) {
|
||
const locale = resolveLocale(tr.locale);
|
||
map[locale] = {
|
||
title: tr.title?.trim() ?? '',
|
||
body: tr.body?.trim() ?? '',
|
||
};
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function resolveBroadcastContent(
|
||
translations: BroadcastTranslations,
|
||
locale: string | null | undefined,
|
||
): BroadcastTranslation | null {
|
||
const chain = [resolveLocale(locale), 'en-US', 'zh-CN', 'ms-MY'];
|
||
const seen = new Set<string>();
|
||
for (const loc of chain) {
|
||
if (seen.has(loc)) continue;
|
||
seen.add(loc);
|
||
const tr = translations[loc];
|
||
if (!tr) continue;
|
||
const title = tr.title?.trim();
|
||
const body = tr.body?.trim();
|
||
if (title || body) {
|
||
return {
|
||
title: title || stripHtml(body).slice(0, 256) || 'Notification',
|
||
body: body || '',
|
||
};
|
||
}
|
||
}
|
||
for (const tr of Object.values(translations)) {
|
||
const title = tr.title?.trim();
|
||
const body = tr.body?.trim();
|
||
if (title || body) {
|
||
return {
|
||
title: title || stripHtml(body).slice(0, 256) || 'Notification',
|
||
body: body || '',
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function parseBroadcastTranslations(value: Prisma.JsonValue | null | undefined): BroadcastTranslations {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||
const map: BroadcastTranslations = {};
|
||
for (const [locale, raw] of Object.entries(value as Record<string, unknown>)) {
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
||
const row = raw as Record<string, unknown>;
|
||
map[locale] = {
|
||
title: typeof row.title === 'string' ? row.title : '',
|
||
body: typeof row.body === 'string' ? row.body : '',
|
||
};
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function mapBroadcastRow(row: {
|
||
id: bigint;
|
||
title: string;
|
||
body: string;
|
||
translations: Prisma.JsonValue | null;
|
||
targetType: string;
|
||
targetUserId: bigint | null;
|
||
targetUsername: string | null;
|
||
recipientCount: number;
|
||
createdById: bigint | null;
|
||
createdByUsername: string | null;
|
||
createdAt: Date;
|
||
}) {
|
||
const translations = parseBroadcastTranslations(row.translations);
|
||
const hasTranslations = Object.keys(translations).length > 0;
|
||
const preview =
|
||
resolveBroadcastContent(hasTranslations ? translations : { 'en-US': { title: row.title, body: row.body } }, 'en-US') ??
|
||
{ title: row.title, body: row.body };
|
||
|
||
return {
|
||
id: row.id.toString(),
|
||
title: preview.title,
|
||
body: preview.body,
|
||
translations: hasTranslations
|
||
? translations
|
||
: { 'en-US': { title: row.title, body: row.body } },
|
||
targetType: row.targetType,
|
||
targetUserId: row.targetUserId?.toString() ?? null,
|
||
targetUsername: row.targetUsername,
|
||
recipientCount: row.recipientCount,
|
||
createdById: row.createdById?.toString() ?? null,
|
||
createdByUsername: row.createdByUsername,
|
||
createdAt: row.createdAt.toISOString(),
|
||
};
|
||
}
|
||
|
||
@Injectable()
|
||
export class PlayerMessagesService {
|
||
constructor(private prisma: PrismaService) {}
|
||
|
||
private buildDepositMessage(
|
||
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO' | 'ADMIN_CUSTOM'>,
|
||
locale: string | null | undefined,
|
||
payload: DepositMessagePayload,
|
||
) {
|
||
const resolvedLocale = resolveLocale(locale);
|
||
const templates = MESSAGE_TEMPLATES[type];
|
||
const template = templates[resolvedLocale] ?? templates['en-US'];
|
||
return {
|
||
type,
|
||
title: template.title,
|
||
body: template.body(payload),
|
||
payload,
|
||
};
|
||
}
|
||
|
||
async createDepositApprovedMessage(
|
||
userId: bigint,
|
||
data: {
|
||
depositOrderId: bigint;
|
||
orderNo: string;
|
||
amount: string;
|
||
approvedAmount: string;
|
||
locale?: string | null;
|
||
},
|
||
client: Prisma.TransactionClient | PrismaService = this.prisma,
|
||
) {
|
||
const payload: DepositMessagePayload = {
|
||
depositOrderId: data.depositOrderId.toString(),
|
||
orderNo: data.orderNo,
|
||
amount: data.amount,
|
||
approvedAmount: data.approvedAmount,
|
||
};
|
||
const content = this.buildDepositMessage('DEPOSIT_APPROVED', data.locale, payload);
|
||
const row = await client.playerMessage.create({
|
||
data: {
|
||
userId,
|
||
type: content.type,
|
||
title: content.title,
|
||
body: content.body,
|
||
payload: content.payload as Prisma.InputJsonValue,
|
||
},
|
||
});
|
||
return mapMessageRow(row);
|
||
}
|
||
|
||
async createDepositRejectedMessage(
|
||
userId: bigint,
|
||
data: {
|
||
depositOrderId: bigint;
|
||
orderNo: string;
|
||
amount: string;
|
||
rejectReason?: string | null;
|
||
locale?: string | null;
|
||
},
|
||
client: Prisma.TransactionClient | PrismaService = this.prisma,
|
||
) {
|
||
const payload: DepositMessagePayload = {
|
||
depositOrderId: data.depositOrderId.toString(),
|
||
orderNo: data.orderNo,
|
||
amount: data.amount,
|
||
rejectReason: data.rejectReason ?? null,
|
||
};
|
||
const content = this.buildDepositMessage('DEPOSIT_REJECTED', data.locale, payload);
|
||
const row = await client.playerMessage.create({
|
||
data: {
|
||
userId,
|
||
type: content.type,
|
||
title: content.title,
|
||
body: content.body,
|
||
payload: content.payload as Prisma.InputJsonValue,
|
||
},
|
||
});
|
||
return mapMessageRow(row);
|
||
}
|
||
|
||
async broadcastBannerPromotion(data: {
|
||
contentId: bigint;
|
||
translations: ContentTranslationLike[];
|
||
}) {
|
||
const players = await this.prisma.user.findMany({
|
||
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
|
||
select: {
|
||
id: true,
|
||
locale: true,
|
||
preferences: { select: { locale: true } },
|
||
},
|
||
});
|
||
|
||
if (!players.length) return 0;
|
||
|
||
const rows = players.map((player) => {
|
||
const playerLocale = player.preferences?.locale ?? player.locale;
|
||
const content = buildBannerPromoMessage(
|
||
playerLocale,
|
||
data.contentId,
|
||
data.translations,
|
||
);
|
||
return {
|
||
userId: player.id,
|
||
type: content.type,
|
||
title: content.title,
|
||
body: content.body,
|
||
payload: content.payload as Prisma.InputJsonValue,
|
||
};
|
||
});
|
||
|
||
const batchSize = 200;
|
||
for (let i = 0; i < rows.length; i += batchSize) {
|
||
await this.prisma.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
|
||
}
|
||
|
||
return rows.length;
|
||
}
|
||
|
||
async broadcastAnnouncementPromotion(data: {
|
||
contentId: bigint;
|
||
translations: ContentTranslationLike[];
|
||
}) {
|
||
const players = await this.prisma.user.findMany({
|
||
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
|
||
select: {
|
||
id: true,
|
||
locale: true,
|
||
preferences: { select: { locale: true } },
|
||
},
|
||
});
|
||
|
||
if (!players.length) return 0;
|
||
|
||
const rows = players.map((player) => {
|
||
const playerLocale = player.preferences?.locale ?? player.locale;
|
||
const content = buildAnnouncementPromoMessage(
|
||
playerLocale,
|
||
data.contentId,
|
||
data.translations,
|
||
);
|
||
return {
|
||
userId: player.id,
|
||
type: content.type,
|
||
title: content.title,
|
||
body: content.body,
|
||
payload: content.payload as Prisma.InputJsonValue,
|
||
};
|
||
});
|
||
|
||
const batchSize = 200;
|
||
for (let i = 0; i < rows.length; i += batchSize) {
|
||
await this.prisma.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
|
||
}
|
||
|
||
return rows.length;
|
||
}
|
||
|
||
async listForPlayer(userId: bigint, page = 1, pageSize = 20) {
|
||
const safePage = Math.max(1, page);
|
||
const safePageSize = Math.min(50, Math.max(1, pageSize));
|
||
const skip = (safePage - 1) * safePageSize;
|
||
const where = { userId };
|
||
|
||
const [rows, total, unreadCount] = await Promise.all([
|
||
this.prisma.playerMessage.findMany({
|
||
where,
|
||
orderBy: { createdAt: 'desc' },
|
||
skip,
|
||
take: safePageSize,
|
||
}),
|
||
this.prisma.playerMessage.count({ where }),
|
||
this.prisma.playerMessage.count({ where: { ...where, readAt: null } }),
|
||
]);
|
||
|
||
return {
|
||
items: rows.map(mapMessageRow),
|
||
total,
|
||
unreadCount,
|
||
page: safePage,
|
||
pageSize: safePageSize,
|
||
};
|
||
}
|
||
|
||
async getForPlayer(userId: bigint, messageId: bigint) {
|
||
const row = await this.prisma.playerMessage.findFirst({
|
||
where: { id: messageId, userId },
|
||
});
|
||
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
|
||
return mapMessageRow(row);
|
||
}
|
||
|
||
async markRead(userId: bigint, messageId: bigint) {
|
||
const row = await this.prisma.playerMessage.findFirst({
|
||
where: { id: messageId, userId },
|
||
});
|
||
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
|
||
if (row.readAt) return mapMessageRow(row);
|
||
|
||
const updated = await this.prisma.playerMessage.update({
|
||
where: { id: messageId },
|
||
data: { readAt: new Date() },
|
||
});
|
||
return mapMessageRow(updated);
|
||
}
|
||
|
||
async markAllRead(userId: bigint) {
|
||
const result = await this.prisma.playerMessage.updateMany({
|
||
where: { userId, readAt: null },
|
||
data: { readAt: new Date() },
|
||
});
|
||
return { updated: result.count };
|
||
}
|
||
|
||
async getUnreadCount(userId: bigint) {
|
||
const unreadCount = await this.prisma.playerMessage.count({
|
||
where: { userId, readAt: null },
|
||
});
|
||
return { unreadCount };
|
||
}
|
||
|
||
async deleteForPlayer(userId: bigint, messageId: bigint) {
|
||
const row = await this.prisma.playerMessage.findFirst({
|
||
where: { id: messageId, userId },
|
||
});
|
||
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
|
||
await this.prisma.playerMessage.delete({ where: { id: messageId } });
|
||
return { deleted: true, wasUnread: row.readAt == null };
|
||
}
|
||
|
||
async deleteAllForPlayer(userId: bigint) {
|
||
const result = await this.prisma.playerMessage.deleteMany({ where: { userId } });
|
||
return { deleted: result.count };
|
||
}
|
||
|
||
async listBroadcasts(page = 1, pageSize = 20) {
|
||
const safePage = Math.max(1, page);
|
||
const safePageSize = Math.min(50, Math.max(1, pageSize));
|
||
const skip = (safePage - 1) * safePageSize;
|
||
|
||
const [rows, total] = await Promise.all([
|
||
this.prisma.playerMessageBroadcast.findMany({
|
||
orderBy: { createdAt: 'desc' },
|
||
skip,
|
||
take: safePageSize,
|
||
}),
|
||
this.prisma.playerMessageBroadcast.count(),
|
||
]);
|
||
|
||
return {
|
||
items: rows.map(mapBroadcastRow),
|
||
total,
|
||
page: safePage,
|
||
pageSize: safePageSize,
|
||
};
|
||
}
|
||
|
||
async createCustomBroadcast(data: {
|
||
translations: BroadcastTranslationInput[];
|
||
targetType: 'ALL' | 'USER';
|
||
targetUsername?: string | null;
|
||
createdById?: bigint | null;
|
||
createdByUsername?: string | null;
|
||
}) {
|
||
const translations = normalizeBroadcastTranslations(data.translations);
|
||
const preview = resolveBroadcastContent(translations, 'en-US');
|
||
if (!preview?.title && !preview?.body) {
|
||
throw appBadRequest('BROADCAST_CONTENT_REQUIRED');
|
||
}
|
||
const title = preview.title.slice(0, 256);
|
||
const body = preview.body;
|
||
if (!title && !body) throw appBadRequest('BROADCAST_CONTENT_REQUIRED');
|
||
|
||
let targetUsers: Array<{
|
||
id: bigint;
|
||
username: string;
|
||
locale: string | null;
|
||
preferences: { locale: string | null } | null;
|
||
}> = [];
|
||
let targetUserId: bigint | null = null;
|
||
let targetUsername: string | null = null;
|
||
|
||
if (data.targetType === 'ALL') {
|
||
targetUsers = await this.prisma.user.findMany({
|
||
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
locale: true,
|
||
preferences: { select: { locale: true } },
|
||
},
|
||
});
|
||
} else {
|
||
const username = data.targetUsername?.trim();
|
||
if (!username) throw appBadRequest('BROADCAST_TARGET_USER_REQUIRED');
|
||
const player = await this.prisma.user.findFirst({
|
||
where: {
|
||
username,
|
||
userType: 'PLAYER',
|
||
deletedAt: null,
|
||
status: 'ACTIVE',
|
||
},
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
locale: true,
|
||
preferences: { select: { locale: true } },
|
||
},
|
||
});
|
||
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
|
||
targetUsers = [player];
|
||
targetUserId = player.id;
|
||
targetUsername = player.username;
|
||
}
|
||
|
||
if (!targetUsers.length) throw appBadRequest('BROADCAST_NO_RECIPIENTS');
|
||
|
||
const broadcast = await this.prisma.$transaction(async (tx) => {
|
||
const created = await tx.playerMessageBroadcast.create({
|
||
data: {
|
||
title,
|
||
body,
|
||
translations: translations as Prisma.InputJsonValue,
|
||
targetType: data.targetType,
|
||
targetUserId,
|
||
targetUsername,
|
||
recipientCount: targetUsers.length,
|
||
createdById: data.createdById ?? null,
|
||
createdByUsername: data.createdByUsername ?? null,
|
||
},
|
||
});
|
||
|
||
const rows = targetUsers.map((player) => {
|
||
const playerLocale = player.preferences?.locale ?? player.locale;
|
||
const content =
|
||
resolveBroadcastContent(translations, playerLocale) ?? preview;
|
||
return {
|
||
userId: player.id,
|
||
type: 'ADMIN_CUSTOM',
|
||
title: content.title.slice(0, 256),
|
||
body: content.body,
|
||
broadcastId: created.id,
|
||
payload: { broadcastId: created.id.toString() } as Prisma.InputJsonValue,
|
||
};
|
||
});
|
||
|
||
const batchSize = 200;
|
||
for (let i = 0; i < rows.length; i += batchSize) {
|
||
await tx.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
|
||
}
|
||
|
||
return created;
|
||
});
|
||
|
||
return mapBroadcastRow(broadcast);
|
||
}
|
||
|
||
async deleteBroadcast(broadcastId: bigint) {
|
||
const row = await this.prisma.playerMessageBroadcast.findUnique({
|
||
where: { id: broadcastId },
|
||
});
|
||
if (!row) throw appNotFound('BROADCAST_NOT_FOUND');
|
||
await this.prisma.playerMessageBroadcast.delete({ where: { id: broadcastId } });
|
||
return { deleted: true, recipientCount: row.recipientCount };
|
||
}
|
||
}
|