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 = { 'zh-CN': '新推广活动', 'en-US': 'New promotion', 'ms-MY': 'Promosi baharu', }; const ANNOUNCEMENT_PROMO_DEFAULT_TITLE: Record = { 'zh-CN': '新公告', 'en-US': 'New announcement', 'ms-MY': 'Pengumuman baharu', }; type MessageTemplate = { title: string; body: (payload: DepositMessagePayload) => string; }; const MESSAGE_TEMPLATES: Record< Exclude, Record > = { 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( 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; 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(); 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)) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue; const row = raw as Record; 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, 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 }; } }