|
|
@@ -3,6 +3,7 @@ import {
|
|
|
Injectable,
|
|
|
BadRequestException,
|
|
|
NotFoundException,
|
|
|
+ Logger,
|
|
|
} from '@nestjs/common';
|
|
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
|
|
import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
|
|
|
@@ -19,9 +20,18 @@ import {
|
|
|
} from './ads.dto';
|
|
|
import { CommonStatus } from '../common/status.enum';
|
|
|
import { ImageUrlBuilderService } from './image/image-url-builder.service';
|
|
|
-
|
|
|
+import type { AdType as PrismaAdType } from '@prisma/mongo/client';
|
|
|
+
|
|
|
+/**
|
|
|
+ * MIGRATION NOTES:
|
|
|
+ * - Ads previously referenced `adsModuleId` with a relation; the new schema stores `adType` as an enum and no longer joins `AdsModule`.
|
|
|
+ * - Create/update expect `adType` today and the service temporarily looks up legacy `adsModuleId` callers (logs a warning) so existing mgnt clients keep working until they switch.
|
|
|
+ * - To finish the migration, backfill every Ads document with its `adType` (use each AdsModule.adType) and remove all remaining `adsModuleId` usage in downstream code paths.
|
|
|
+ */
|
|
|
@Injectable()
|
|
|
export class AdsService {
|
|
|
+ private readonly logger = new Logger(AdsService.name);
|
|
|
+
|
|
|
constructor(
|
|
|
private readonly mongoPrismaService: MongoPrismaService,
|
|
|
private readonly cacheSyncService: CacheSyncService,
|
|
|
@@ -29,15 +39,17 @@ export class AdsService {
|
|
|
private readonly imageUrlBuilderService: ImageUrlBuilderService,
|
|
|
) {}
|
|
|
|
|
|
- /**
|
|
|
- * Current epoch time in milliseconds.
|
|
|
- *
|
|
|
- * NOTE:
|
|
|
- * - Kept as `number` for backward compatibility.
|
|
|
- * - If you migrate to BigInt timestamps, update this and the Prisma schema.
|
|
|
- */
|
|
|
- private now(): number {
|
|
|
- return Date.now();
|
|
|
+ private nowSeconds(): bigint {
|
|
|
+ return BigInt(Math.floor(Date.now() / 1000));
|
|
|
+ }
|
|
|
+
|
|
|
+ private toBigIntSeconds(value?: number | bigint | null): bigint | undefined {
|
|
|
+ if (value === undefined || value === null) return undefined;
|
|
|
+ if (typeof value === 'bigint') return value;
|
|
|
+ if (typeof value === 'number' && Number.isFinite(value)) {
|
|
|
+ return BigInt(Math.floor(value));
|
|
|
+ }
|
|
|
+ return BigInt(value);
|
|
|
}
|
|
|
|
|
|
private trimOptional(value?: string | null) {
|
|
|
@@ -59,6 +71,37 @@ export class AdsService {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
+ * Backfill adType using adsModuleId when legacy callers still include it.
|
|
|
+ * Logs a warning and requires the adsModule to exist.
|
|
|
+ */
|
|
|
+ private async resolveLegacyAdType(
|
|
|
+ dto: { adType?: PrismaAdType },
|
|
|
+ context: 'create' | 'update' | 'list',
|
|
|
+ ): Promise<PrismaAdType | undefined> {
|
|
|
+ if (dto.adType) return dto.adType;
|
|
|
+
|
|
|
+ const legacy = (dto as Record<string, unknown>)['adsModuleId'];
|
|
|
+ if (!legacy || typeof legacy !== 'string') {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ const adsModule = await this.mongoPrismaService.adsModule.findUnique({
|
|
|
+ where: { id: legacy },
|
|
|
+ select: { adType: true },
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!adsModule) {
|
|
|
+ throw new NotFoundException('Ads module not found');
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.warn(
|
|
|
+ `[AdsService] Legacy adsModuleId=${legacy} resolved to adType=${adsModule.adType} during ${context}`,
|
|
|
+ );
|
|
|
+
|
|
|
+ return adsModule.adType;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
* Ensure the channel exists.
|
|
|
*/
|
|
|
private async assertChannelExists(channelId: string): Promise<void> {
|
|
|
@@ -75,7 +118,7 @@ export class AdsService {
|
|
|
private mapToDto(ad: any): AdsInterfaceDto {
|
|
|
return {
|
|
|
id: ad.id,
|
|
|
- adsModuleId: ad.adsModuleId,
|
|
|
+ adType: ad.adType,
|
|
|
advertiser: ad.advertiser,
|
|
|
title: ad.title,
|
|
|
adsContent: ad.adsContent,
|
|
|
@@ -96,54 +139,101 @@ export class AdsService {
|
|
|
async create(dto: CreateAdsDto) {
|
|
|
this.ensureTimeRange(dto.startDt, dto.expiryDt);
|
|
|
|
|
|
- const now = this.now();
|
|
|
+ const adType = await this.resolveLegacyAdType(dto, 'create');
|
|
|
+ if (!adType) {
|
|
|
+ throw new BadRequestException('adType is required');
|
|
|
+ }
|
|
|
+
|
|
|
+ const startDt = this.toBigIntSeconds(dto.startDt);
|
|
|
+ const expiryDt = this.toBigIntSeconds(dto.expiryDt);
|
|
|
+ if (!startDt || !expiryDt) {
|
|
|
+ throw new BadRequestException(
|
|
|
+ 'startDt and expiryDt must be valid epoch seconds',
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const now = this.nowSeconds();
|
|
|
+
|
|
|
+ const adData: any = {
|
|
|
+ adType,
|
|
|
+ advertiser: this.trimOptional(dto.advertiser),
|
|
|
+ title: this.trimOptional(dto.title),
|
|
|
+ adsContent: this.trimOptional(dto.adsContent) ?? null,
|
|
|
+ adsCoverImg: this.trimOptional(dto.adsCoverImg) ?? null,
|
|
|
+ adsUrl: this.trimOptional(dto.adsUrl) ?? null,
|
|
|
+ startDt,
|
|
|
+ expiryDt,
|
|
|
+ seq: dto.seq ?? 0,
|
|
|
+ status: dto.status ?? CommonStatus.enabled,
|
|
|
+ createAt: now,
|
|
|
+ updateAt: now,
|
|
|
+ };
|
|
|
+
|
|
|
+ if (dto.imgSource !== undefined) {
|
|
|
+ adData.imgSource = dto.imgSource;
|
|
|
+ }
|
|
|
|
|
|
const ad = await this.mongoPrismaService.ads.create({
|
|
|
- data: {
|
|
|
- adsModuleId: dto.adsModuleId,
|
|
|
- advertiser: dto.advertiser,
|
|
|
- title: dto.title,
|
|
|
- adsContent: this.trimOptional(dto.adsContent) ?? null,
|
|
|
- adsCoverImg: this.trimOptional(dto.adsCoverImg) ?? null,
|
|
|
- adsUrl: this.trimOptional(dto.adsUrl) ?? null,
|
|
|
- startDt: dto.startDt,
|
|
|
- expiryDt: dto.expiryDt,
|
|
|
- seq: dto.seq ?? 0,
|
|
|
- status: dto.status ?? CommonStatus.enabled,
|
|
|
- createAt: now,
|
|
|
- updateAt: now,
|
|
|
- },
|
|
|
- include: { adsModule: true },
|
|
|
+ data: adData,
|
|
|
});
|
|
|
|
|
|
// Auto-schedule cache refresh (per-ad + pool)
|
|
|
- await this.cacheSyncService.scheduleAdRefresh(ad.id, ad.adsModule.adType);
|
|
|
+ await this.cacheSyncService.scheduleAdRefresh(ad.id, ad.adType);
|
|
|
|
|
|
// Return created ad mapped to AdsInterfaceDto
|
|
|
return this.mapToDto(ad);
|
|
|
}
|
|
|
|
|
|
async update(dto: UpdateAdsDto) {
|
|
|
- this.ensureTimeRange(dto.startDt, dto.expiryDt);
|
|
|
+ if (dto.startDt !== undefined && dto.expiryDt !== undefined) {
|
|
|
+ this.ensureTimeRange(dto.startDt, dto.expiryDt);
|
|
|
+ }
|
|
|
|
|
|
- const now = this.now();
|
|
|
+ const now = this.nowSeconds();
|
|
|
|
|
|
- // Build data object carefully to avoid unintended field changes
|
|
|
const data: any = {
|
|
|
- adsModuleId: dto.adsModuleId,
|
|
|
- advertiser: dto.advertiser,
|
|
|
- title: dto.title,
|
|
|
- adsContent: this.trimOptional(dto.adsContent) ?? null,
|
|
|
- adsCoverImg: this.trimOptional(dto.adsCoverImg) ?? null,
|
|
|
- adsUrl: this.trimOptional(dto.adsUrl) ?? null,
|
|
|
- startDt: dto.startDt,
|
|
|
- expiryDt: dto.expiryDt,
|
|
|
- seq: dto.seq ?? 0,
|
|
|
updateAt: now,
|
|
|
};
|
|
|
|
|
|
- // Only update status if explicitly provided,
|
|
|
- // to avoid silently re-enabling disabled ads.
|
|
|
+ const adTypeValue = await this.resolveLegacyAdType(dto, 'update');
|
|
|
+ if (adTypeValue) {
|
|
|
+ data.adType = adTypeValue;
|
|
|
+ }
|
|
|
+ if (dto.advertiser !== undefined) {
|
|
|
+ data.advertiser = this.trimOptional(dto.advertiser);
|
|
|
+ }
|
|
|
+ if (dto.title !== undefined) {
|
|
|
+ data.title = this.trimOptional(dto.title);
|
|
|
+ }
|
|
|
+ if (dto.adsContent !== undefined) {
|
|
|
+ data.adsContent = this.trimOptional(dto.adsContent) ?? null;
|
|
|
+ }
|
|
|
+ if (dto.adsCoverImg !== undefined) {
|
|
|
+ data.adsCoverImg = this.trimOptional(dto.adsCoverImg) ?? null;
|
|
|
+ }
|
|
|
+ if (dto.adsUrl !== undefined) {
|
|
|
+ data.adsUrl = this.trimOptional(dto.adsUrl) ?? null;
|
|
|
+ }
|
|
|
+ if (dto.imgSource !== undefined) {
|
|
|
+ data.imgSource = dto.imgSource;
|
|
|
+ }
|
|
|
+ if (dto.startDt !== undefined) {
|
|
|
+ const start = this.toBigIntSeconds(dto.startDt);
|
|
|
+ if (start === undefined) {
|
|
|
+ throw new BadRequestException('startDt must be a valid epoch second');
|
|
|
+ }
|
|
|
+ data.startDt = start;
|
|
|
+ }
|
|
|
+ if (dto.expiryDt !== undefined) {
|
|
|
+ const expiry = this.toBigIntSeconds(dto.expiryDt);
|
|
|
+ if (expiry === undefined) {
|
|
|
+ throw new BadRequestException('expiryDt must be a valid epoch second');
|
|
|
+ }
|
|
|
+ data.expiryDt = expiry;
|
|
|
+ }
|
|
|
+ if (dto.seq !== undefined) {
|
|
|
+ data.seq = dto.seq;
|
|
|
+ }
|
|
|
if (dto.status !== undefined) {
|
|
|
data.status = dto.status;
|
|
|
}
|
|
|
@@ -152,11 +242,9 @@ export class AdsService {
|
|
|
const ad = await this.mongoPrismaService.ads.update({
|
|
|
where: { id: dto.id },
|
|
|
data,
|
|
|
- include: { adsModule: true },
|
|
|
});
|
|
|
|
|
|
- // Auto-schedule cache refresh (per-ad + pool)
|
|
|
- await this.cacheSyncService.scheduleAdRefresh(ad.id, ad.adsModule.adType);
|
|
|
+ await this.cacheSyncService.scheduleAdRefresh(ad.id, ad.adType);
|
|
|
|
|
|
return this.mapToDto(ad);
|
|
|
} catch (e) {
|
|
|
@@ -170,7 +258,6 @@ export class AdsService {
|
|
|
async findOne(id: string) {
|
|
|
const row = await this.mongoPrismaService.ads.findUnique({
|
|
|
where: { id },
|
|
|
- include: { adsModule: true },
|
|
|
});
|
|
|
|
|
|
if (!row) {
|
|
|
@@ -189,8 +276,9 @@ export class AdsService {
|
|
|
if (dto.advertiser) {
|
|
|
where.advertiser = { contains: dto.advertiser, mode: 'insensitive' };
|
|
|
}
|
|
|
- if (dto.adsModuleId) {
|
|
|
- where.adsModuleId = dto.adsModuleId;
|
|
|
+ const adTypeFilter = await this.resolveLegacyAdType(dto, 'list');
|
|
|
+ if (adTypeFilter) {
|
|
|
+ where.adType = adTypeFilter;
|
|
|
}
|
|
|
if (dto.status !== undefined) {
|
|
|
where.status = dto.status;
|
|
|
@@ -204,12 +292,9 @@ export class AdsService {
|
|
|
this.mongoPrismaService.ads.count({ where }),
|
|
|
this.mongoPrismaService.ads.findMany({
|
|
|
where,
|
|
|
- orderBy: { updateAt: 'desc' },
|
|
|
+ orderBy: [{ seq: 'asc' }, { updateAt: 'desc' }, { createAt: 'desc' }],
|
|
|
skip: (page - 1) * size,
|
|
|
take: size,
|
|
|
- include: {
|
|
|
- adsModule: true,
|
|
|
- },
|
|
|
}),
|
|
|
]);
|
|
|
|
|
|
@@ -229,14 +314,13 @@ export class AdsService {
|
|
|
// Fetch ad first to get adType for cache invalidation
|
|
|
const ad = await this.mongoPrismaService.ads.findUnique({
|
|
|
where: { id },
|
|
|
- include: { adsModule: true },
|
|
|
});
|
|
|
|
|
|
await this.mongoPrismaService.ads.delete({ where: { id } });
|
|
|
|
|
|
// Auto-schedule cache refresh if ad existed
|
|
|
- if (ad?.adsModule?.adType) {
|
|
|
- await this.cacheSyncService.scheduleAdRefresh(id, ad.adsModule.adType);
|
|
|
+ if (ad?.adType) {
|
|
|
+ await this.cacheSyncService.scheduleAdRefresh(id, ad.adType);
|
|
|
}
|
|
|
|
|
|
return { message: 'Deleted' };
|
|
|
@@ -264,7 +348,6 @@ export class AdsService {
|
|
|
// Ensure ad exists
|
|
|
const ad = await this.mongoPrismaService.ads.findUnique({
|
|
|
where: { id },
|
|
|
- include: { adsModule: true },
|
|
|
});
|
|
|
if (!ad) {
|
|
|
throw new NotFoundException('Ads not found');
|
|
|
@@ -299,13 +382,12 @@ export class AdsService {
|
|
|
data: {
|
|
|
adsCoverImg: key,
|
|
|
imgSource,
|
|
|
- updateAt: this.now(),
|
|
|
+ updateAt: this.nowSeconds(),
|
|
|
},
|
|
|
- include: { adsModule: true },
|
|
|
});
|
|
|
|
|
|
// Schedule cache refresh
|
|
|
- await this.cacheSyncService.scheduleAdRefresh(id, ad.adsModule.adType);
|
|
|
+ await this.cacheSyncService.scheduleAdRefresh(id, ad.adType);
|
|
|
|
|
|
return updated;
|
|
|
}
|