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

This commit is contained in:
2026-06-18 10:08:03 +08:00
parent d3ca8498fb
commit 5c5aa7e55a
87 changed files with 6911 additions and 1026 deletions

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { PresenceService } from '../../domains/presence/presence.service';
import { Decimal } from '@prisma/client/runtime/library';
function dec(v: Decimal | null | undefined) {
@@ -12,7 +13,10 @@ function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
@Injectable()
export class AdminDashboardService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private presence: PresenceService,
) {}
async getOverview() {
const today = new Date();
@@ -60,6 +64,7 @@ export class AdminDashboardService {
walletAgg,
recentBets,
recentPlayers,
playersOnlineNow,
] = await Promise.all([
this.prisma.bet.aggregate({
where: { placedAt: { gte: today } },
@@ -125,6 +130,7 @@ export class AdminDashboardService {
parent: { select: { username: true } },
},
}),
this.presence.getOnlineCount(),
]);
const matchByStatus: Record<string, number> = {};
@@ -164,6 +170,7 @@ export class AdminDashboardService {
playersActive: playerActive,
playersSuspended: playerSuspended,
playersDirect: playerDirect,
playersOnlineNow,
agentsTotal: agentProfiles._count._all,
agentsActive,
},

View File

@@ -50,6 +50,8 @@ import { P } from './admin-permissions';
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
import { DepositService } from '../../domains/deposit/deposit.service';
import { PlayerMessagesService } from '../../domains/player-messages/player-messages.service';
import { PresenceService } from '../../domains/presence/presence.service';
import {
IsString,
IsNumber,
@@ -272,6 +274,10 @@ class CreateStaffDto {
@IsString()
roleCode!: string;
@IsOptional()
@IsString()
visibleMenus?: string;
}
class UpdateStaffDto {
@@ -287,6 +293,10 @@ class UpdateStaffDto {
@IsString()
@MinLength(8)
password?: string;
@IsOptional()
@IsString()
visibleMenus?: string;
}
class ResetPlayerPasswordDto {
@@ -1046,6 +1056,10 @@ class CreateContentDto {
@IsString()
endTime?: string | null;
@IsOptional()
@IsBoolean()
notifyInbox?: boolean;
@IsArray()
translations!: ContentTranslationDto[];
}
@@ -1085,6 +1099,16 @@ class ContentStatusDto {
status!: string;
}
class InboxNotifySettingsDto {
@IsOptional()
@IsBoolean()
inboxEnabled?: boolean;
@IsOptional()
@IsBoolean()
deposit?: boolean;
}
class CashbackPreviewDto {
@IsString()
periodStart!: string;
@@ -1209,9 +1233,18 @@ export class AdminController {
private databaseReset: DatabaseResetService,
private smokeTests: SmokeTestService,
private depositService: DepositService,
private playerMessages: PlayerMessagesService,
private staff: AdminStaffService,
private presence: PresenceService,
) {}
@Get('presence/online-count')
@RequirePermissions(P.usersView)
async getOnlinePlayerCount() {
const count = await this.presence.getOnlineCount();
return jsonResponse({ count, asOf: new Date().toISOString() });
}
@Get('dashboard')
@RequirePermissions(P.reports)
async getDashboard() {
@@ -1500,6 +1533,23 @@ export class AdminController {
return jsonResponse(updated);
}
@Delete('staff/:id')
@RequirePermissions(P.settings)
async deleteStaff(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
await this.staff.deleteStaff(BigInt(id), operatorId);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'DELETE_STAFF',
module: 'STAFF',
targetId: id,
});
return jsonResponse({ deleted: true });
}
@Delete('users/:id')
@RequirePermissions(P.usersCreate)
async deletePlayer(
@@ -3104,6 +3154,20 @@ export class AdminController {
return urls;
}
@Get('contents/inbox-notify-settings')
@RequirePermissions(P.content, P.reports)
async getInboxNotifySettings() {
const settings = await this.systemConfig.getInboxNotifySettings();
return jsonResponse(settings);
}
@Put('contents/inbox-notify-settings')
@RequirePermissions(P.content)
async updateInboxNotifySettings(@Body() dto: InboxNotifySettingsDto) {
const settings = await this.systemConfig.updateInboxNotifySettings(dto);
return jsonResponse(settings);
}
@Get('contents')
@RequirePermissions(P.content, P.reports)
async listContents(
@@ -3128,8 +3192,34 @@ export class AdminController {
@Post('contents')
@RequirePermissions(P.content)
async createContent(@Body() dto: CreateContentDto) {
const item = await this.content.create(dto);
return jsonResponse(item);
const { notifyInbox, ...createDto } = dto;
const item = await this.content.create(createDto);
let notifiedCount: number | undefined;
if (notifyInbox && (createDto.status ?? 'DRAFT') === 'ACTIVE') {
const inboxEnabled = await this.systemConfig.getInboxFeatureEnabled();
if (inboxEnabled) {
const translations = createDto.translations.map((tr) => ({
locale: tr.locale,
title: tr.title,
body: tr.body,
}));
if (createDto.contentType === 'BANNER') {
notifiedCount = await this.playerMessages.broadcastBannerPromotion({
contentId: BigInt(item.id),
translations,
});
} else if (
createDto.contentType === 'NOTICE' ||
createDto.contentType === 'TICKER'
) {
notifiedCount = await this.playerMessages.broadcastAnnouncementPromotion({
contentId: BigInt(item.id),
translations,
});
}
}
}
return jsonResponse({ ...item, notifiedCount });
}
@Put('contents/:id')
@@ -3349,6 +3439,13 @@ export class AdminController {
return jsonResponse(result);
}
@Get('deposit-orders/pending-count')
@RequirePermissions(P.depositReview)
async depositPendingCount() {
const count = await this.depositService.countPendingDepositOrders();
return jsonResponse({ count });
}
@Get('deposit-orders/:id/audit-logs')
@RequirePermissions(P.depositReview)
async depositOrderAuditLogs(@Param('id') id: string) {

View File

@@ -15,6 +15,8 @@ import { BetsModule } from '../../domains/betting/bets.module';
import { DatabaseModule } from '../../infrastructure/database/database.module';
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
import { DepositModule } from '../../domains/deposit/deposit.module';
import { PlayerMessagesModule } from '../../domains/player-messages/player-messages.module';
import { PresenceModule } from '../../domains/presence/presence.module';
@Module({
imports: [
@@ -31,6 +33,8 @@ import { DepositModule } from '../../domains/deposit/deposit.module';
DatabaseModule,
SmokeTestModule,
DepositModule,
PlayerMessagesModule,
PresenceModule,
],
controllers: [AdminController],
providers: [AdminDashboardService, PermissionsGuard],

View File

@@ -3,6 +3,7 @@ import {
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
@@ -30,6 +31,8 @@ import { BetsService } from '../../domains/betting/bets.service';
import { ContentService } from '../../domains/operations/content/content.service';
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
import { DepositService } from '../../domains/deposit/deposit.service';
import { PlayerMessagesService } from '../../domains/player-messages/player-messages.service';
import { PresenceService } from '../../domains/presence/presence.service';
import { isInLocalTodayMatchWindow } from '@thebet365/shared';
import { IsString, IsNumber, IsArray, ValidateNested, Min, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
@@ -120,8 +123,16 @@ export class PlayerController {
private cashback: CashbackService,
private systemConfig: SystemConfigService,
private deposit: DepositService,
private playerMessages: PlayerMessagesService,
private presence: PresenceService,
) {}
@Post('presence/ping')
async presencePing(@CurrentUser('id') userId: bigint) {
await this.presence.touch(userId);
return jsonResponse({ ok: true });
}
private async formatPlayerProfile(user: NonNullable<Awaited<ReturnType<UsersService['findById']>>>) {
const accountSettings = await this.systemConfig.getPlayerAccountSettings();
const prefs = user.preferences;
@@ -175,10 +186,12 @@ export class PlayerController {
@Headers('x-time-zone') headerTimeZone?: string,
) {
const locale = userLocale || headerLocale || 'zh-CN';
const [banners, announcements, allMatches] = await Promise.all([
const [banners, announcements, allMatches, upcomingMatches, inboxEnabled] = await Promise.all([
this.content.listActive('BANNER', locale),
this.content.listActiveAnnouncements(locale),
this.matches.listPublished(locale, undefined, { includeMarkets: false }),
this.matches.listUpcomingPublished(locale),
this.systemConfig.getInboxFeatureEnabled(),
]);
const timeZone = safeTimeZone(headerTimeZone);
const now = new Date();
@@ -198,6 +211,8 @@ export class PlayerController {
notices: announcements,
hotMatches,
todayMatches,
upcomingMatches,
inboxEnabled,
});
}
@@ -245,6 +260,20 @@ export class PlayerController {
return jsonResponse(match);
}
@Public()
@Get('selections/odds')
async selectionOdds(@Query('ids') ids?: string) {
if (!ids?.trim()) throw appBadRequest('SELECTION_NOT_FOUND');
const parsed = ids
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => BigInt(s));
if (!parsed.length || parsed.length > 20) throw appBadRequest('PARLAY_LEG_COUNT_INVALID');
const items = await this.matches.getSelectionsOdds(parsed);
return jsonResponse({ items });
}
@Post('bets/single')
async singleBet(@CurrentUser('id') userId: bigint, @CurrentUser('parentId') parentId: bigint, @Body() dto: SingleBetDto) {
const bet = await this.bets.placeSingleBet(
@@ -449,4 +478,56 @@ export class PlayerController {
createdAt: order!.createdAt,
});
}
// ============ Messages / Inbox ============
@Get('messages')
async listMessages(
@CurrentUser('id') userId: bigint,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const result = await this.playerMessages.listForPlayer(
userId,
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 20,
);
return jsonResponse(result);
}
@Get('messages/unread-count')
async messageUnreadCount(@CurrentUser('id') userId: bigint) {
const result = await this.playerMessages.getUnreadCount(userId);
return jsonResponse(result);
}
@Get('messages/:id')
async messageDetail(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
const message = await this.playerMessages.getForPlayer(userId, BigInt(id));
return jsonResponse(message);
}
@Patch('messages/read-all')
async markAllMessagesRead(@CurrentUser('id') userId: bigint) {
const result = await this.playerMessages.markAllRead(userId);
return jsonResponse(result);
}
@Patch('messages/:id/read')
async markMessageRead(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
const message = await this.playerMessages.markRead(userId, BigInt(id));
return jsonResponse(message);
}
@Delete('messages')
async deleteAllMessages(@CurrentUser('id') userId: bigint) {
const result = await this.playerMessages.deleteAllForPlayer(userId);
return jsonResponse(result);
}
@Delete('messages/:id')
async deleteMessage(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
const result = await this.playerMessages.deleteForPlayer(userId, BigInt(id));
return jsonResponse(result);
}
}

View File

@@ -7,9 +7,11 @@ import { BetsModule } from '../../domains/betting/bets.module';
import { ContentModule } from '../../domains/operations/content/content.module';
import { CashbackModule } from '../../domains/operations/cashback/cashback.module';
import { DepositModule } from '../../domains/deposit/deposit.module';
import { PlayerMessagesModule } from '../../domains/player-messages/player-messages.module';
import { PresenceModule } from '../../domains/presence/presence.module';
@Module({
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule, DepositModule],
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule, DepositModule, PlayerMessagesModule, PresenceModule],
controllers: [PlayerController],
})
export class PlayerModule {}