feat(theme-4): sync inbox/announcements/presence/deposit from main
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlayerMessagesService } from './player-messages.service';
|
||||
|
||||
@Module({
|
||||
providers: [PlayerMessagesService],
|
||||
exports: [PlayerMessagesService],
|
||||
})
|
||||
export class PlayerMessagesModule {}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { PlayerMessagesService } from './player-messages.service';
|
||||
|
||||
describe('PlayerMessagesService', () => {
|
||||
const prisma = {
|
||||
playerMessage: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
let service: PlayerMessagesService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new PlayerMessagesService(prisma as never);
|
||||
});
|
||||
|
||||
it('creates localized deposit approved messages', async () => {
|
||||
prisma.playerMessage.create.mockResolvedValue({
|
||||
id: 1n,
|
||||
type: 'DEPOSIT_APPROVED',
|
||||
title: '充值已到账',
|
||||
body: 'body',
|
||||
payload: { orderNo: 'DEP-1' },
|
||||
readAt: null,
|
||||
createdAt: new Date('2026-06-17T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
const result = await service.createDepositApprovedMessage(7n, {
|
||||
depositOrderId: 10n,
|
||||
orderNo: 'DEP-1',
|
||||
amount: '100',
|
||||
approvedAmount: '100',
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
expect(prisma.playerMessage.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 7n,
|
||||
type: 'DEPOSIT_APPROVED',
|
||||
title: '充值已到账',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.type).toBe('DEPOSIT_APPROVED');
|
||||
expect(result.isRead).toBe(false);
|
||||
});
|
||||
|
||||
it('returns paginated inbox with unread count', async () => {
|
||||
prisma.playerMessage.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 2n,
|
||||
type: 'DEPOSIT_REJECTED',
|
||||
title: 'Deposit rejected',
|
||||
body: 'Rejected',
|
||||
payload: null,
|
||||
readAt: null,
|
||||
createdAt: new Date('2026-06-17T11:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
prisma.playerMessage.count.mockResolvedValueOnce(1).mockResolvedValueOnce(1);
|
||||
|
||||
const result = await service.listForPlayer(7n, 1, 20);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.unreadCount).toBe(1);
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('deletes a single message for the player', async () => {
|
||||
prisma.playerMessage.findFirst.mockResolvedValue({
|
||||
id: 3n,
|
||||
type: 'DEPOSIT_APPROVED',
|
||||
title: 'Deposit approved',
|
||||
body: 'body',
|
||||
payload: null,
|
||||
readAt: null,
|
||||
createdAt: new Date('2026-06-17T12:00:00.000Z'),
|
||||
});
|
||||
prisma.playerMessage.delete.mockResolvedValue({ id: 3n });
|
||||
|
||||
const result = await service.deleteForPlayer(7n, 3n);
|
||||
|
||||
expect(prisma.playerMessage.delete).toHaveBeenCalledWith({ where: { id: 3n } });
|
||||
expect(result).toEqual({ deleted: true, wasUnread: true });
|
||||
});
|
||||
|
||||
it('deletes all messages for the player', async () => {
|
||||
prisma.playerMessage.deleteMany.mockResolvedValue({ count: 4 });
|
||||
|
||||
const result = await service.deleteAllForPlayer(7n);
|
||||
|
||||
expect(prisma.playerMessage.deleteMany).toHaveBeenCalledWith({ where: { userId: 7n } });
|
||||
expect(result).toEqual({ deleted: 4 });
|
||||
});
|
||||
|
||||
it('broadcasts banner promotion inbox messages to active players', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([
|
||||
{ id: 10n, locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
|
||||
{ id: 11n, locale: 'en-US', preferences: null },
|
||||
]);
|
||||
prisma.playerMessage.createMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
const count = await service.broadcastBannerPromotion({
|
||||
contentId: 99n,
|
||||
translations: [
|
||||
{ locale: 'zh-CN', title: '夏季活动', body: '<p>限时优惠</p>' },
|
||||
{ locale: 'en-US', title: 'Summer promo', body: '' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
userId: 10n,
|
||||
type: 'BANNER_PROMO',
|
||||
title: '夏季活动',
|
||||
body: '限时优惠',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
userId: 11n,
|
||||
type: 'BANNER_PROMO',
|
||||
title: 'Summer promo',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts announcement promotion inbox messages to active players', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([
|
||||
{ id: 10n, locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
|
||||
]);
|
||||
prisma.playerMessage.createMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
const count = await service.broadcastAnnouncementPromotion({
|
||||
contentId: 100n,
|
||||
translations: [{ locale: 'zh-CN', title: '维护通知', body: '系统维护中' }],
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
expect.objectContaining({
|
||||
userId: 10n,
|
||||
type: 'ANNOUNCEMENT_PROMO',
|
||||
title: '维护通知',
|
||||
body: '系统维护中',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
437
apps/api/src/domains/player-messages/player-messages.service.ts
Normal file
437
apps/api/src/domains/player-messages/player-messages.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user