// box-mgnt-api/src/mgnt-backend/feature/channel/channel.service.ts import { Injectable, BadRequestException, NotFoundException, } from '@nestjs/common'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service'; import { CacheSyncService } from '../../../cache-sync/cache-sync.service'; import { CacheEntityType, CacheOperation, } from '../../../cache-sync/cache-sync.types'; import { CreateChannelDto, ListChannelDto, UpdateChannelDto, } from './channel.dto'; import { nowSecBigInt } from '@box/common/time/time.util'; @Injectable() export class ChannelService { constructor( private readonly mongoPrismaService: MongoPrismaService, private readonly cacheSyncService: CacheSyncService, ) {} /** * Current epoch seconds (BigInt) for persisted timestamps. * * NOTE: * - We now keep `createAt`/`updateAt` as BigInt seconds. */ private now(): bigint { return nowSecBigInt(); } private trimOptional(value?: string | null) { if (value === undefined) return undefined; if (value === null) return null; return typeof value === 'string' ? value.trim() : value; } async create(dto: CreateChannelDto) { // Check for duplicate channel name const existingChannel = await this.mongoPrismaService.channel.findFirst({ where: { name: dto.name }, }); if (existingChannel) { throw new BadRequestException( `Channel with name "${dto.name}" already exists`, ); } // Check for duplicate channelId const existingChannelId = await this.mongoPrismaService.channel.findFirst({ where: { channelId: dto.channelId }, }); if (existingChannelId) { throw new BadRequestException( `Channel with channelId "${dto.channelId}" already exists`, ); } const isDefault = (await this.mongoPrismaService.channel.count()) === 0; const now = this.now(); const channel = await this.mongoPrismaService.channel.create({ data: { channelId: dto.channelId, name: dto.name, landingUrl: dto.landingUrl, videoCdn: this.trimOptional(dto.videoCdn) ?? null, coverCdn: this.trimOptional(dto.coverCdn) ?? null, clientName: this.trimOptional(dto.clientName) ?? null, clientNotice: this.trimOptional(dto.clientNotice) ?? null, remark: this.trimOptional(dto.remark) ?? null, isDefault, categories: (dto.categories as any) || null, tags: (dto.tags as any) || null, tagNames: dto.tagNames || [], createAt: now, updateAt: now, }, }); // Auto-schedule cache refresh await this.cacheSyncService.scheduleChannelRefreshAll(); // Schedule channel-with-categories rebuild for this channel await this.cacheSyncService.scheduleAction({ entityType: CacheEntityType.CHANNEL, operation: CacheOperation.REFRESH, payload: { channelId: channel.id }, }); return channel; } async update(dto: UpdateChannelDto) { // Check for duplicate channel name (excluding current channel) const duplicateChannel = await this.mongoPrismaService.channel.findFirst({ where: { name: dto.name, id: { not: dto.id }, }, }); if (duplicateChannel) { throw new BadRequestException( `Channel with name "${dto.name}" already exists`, ); } // check for duplicate channelId (excluding current channel) const duplicateChannelId = await this.mongoPrismaService.channel.findFirst({ where: { channelId: dto.channelId, id: { not: dto.id }, }, }); if (duplicateChannelId) { throw new BadRequestException( `Channel with channelId "${dto.channelId}" already exists`, ); } const now = this.now(); try { const channel = await this.mongoPrismaService.channel.update({ where: { id: dto.id }, data: { channelId: dto.channelId, name: dto.name, landingUrl: dto.landingUrl, videoCdn: this.trimOptional(dto.videoCdn) ?? null, coverCdn: this.trimOptional(dto.coverCdn) ?? null, clientName: this.trimOptional(dto.clientName) ?? null, clientNotice: this.trimOptional(dto.clientNotice) ?? null, remark: this.trimOptional(dto.remark) ?? null, categories: (dto.categories as any) || null, tags: (dto.tags as any) || null, tagNames: dto.tagNames || [], updateAt: now, }, }); // Auto-schedule cache refresh await this.cacheSyncService.scheduleChannelRefreshAll(); // Schedule channel-with-categories rebuild for this channel await this.cacheSyncService.scheduleAction({ entityType: CacheEntityType.CHANNEL, operation: CacheOperation.REFRESH, payload: { channelId: channel.id }, }); return channel; } catch (e) { if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') { throw new NotFoundException('Channel not found'); } throw e; } } async findOne(id: string) { const row = await this.mongoPrismaService.channel.findUnique({ where: { id }, }); if (!row) { throw new NotFoundException('Channel not found'); } return row; } async list(dto: ListChannelDto) { const where: any = {}; if (dto.name) { where.name = { contains: dto.name, mode: 'insensitive' }; } // Defensive guards for pagination const page = dto.page && dto.page > 0 ? dto.page : 1; const size = dto.size && dto.size > 0 ? dto.size : 10; const [total, data] = await this.mongoPrismaService.$transaction([ this.mongoPrismaService.channel.count({ where }), this.mongoPrismaService.channel.findMany({ where, orderBy: { updateAt: 'desc' }, skip: (page - 1) * size, take: size, }), ]); return { total, data, totalPages: Math.ceil(total / size), page, size, }; } async remove(id: string) { try { await this.mongoPrismaService.channel.delete({ where: { id } }); // Auto-schedule cache refresh await this.cacheSyncService.scheduleChannelRefreshAll(); return { message: 'Deleted' }; } catch (e) { if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') { throw new NotFoundException('Channel not found'); } throw e; } } }