feat(theme-3): sync inbox/announcements/presence/deposit/search from main

This commit is contained in:
2026-06-18 10:02:32 +08:00
parent 0d430857c7
commit 6881d325d5
87 changed files with 6944 additions and 964 deletions

View File

@@ -0,0 +1,437 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appNotFound } from '../../shared/common/app-error';
export type PlayerMessageType =
| 'DEPOSIT_APPROVED'
| 'DEPOSIT_REJECTED'
| 'BANNER_PROMO'
| 'ANNOUNCEMENT_PROMO';
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'>,
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,
};
}
@Injectable()
export class PlayerMessagesService {
constructor(private prisma: PrismaService) {}
private buildDepositMessage(
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
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 };
}
}