gcx/backend/services/notification-service/src/application/services/announcement.service.ts

301 lines
9.9 KiB
TypeScript

import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
import {
Announcement,
AnnouncementType,
TargetType,
AnnouncementTargetConfig,
} from '../../domain/entities/announcement.entity';
import {
ANNOUNCEMENT_REPOSITORY,
IAnnouncementRepository,
AnnouncementWithReadStatus,
} from '../../domain/repositories/announcement.repository.interface';
import {
USER_TAG_REPOSITORY,
IUserTagRepository,
} from '../../domain/repositories/user-tag.repository.interface';
import { PushDispatcherService } from './push-dispatcher.service';
export interface CreateAnnouncementParams {
title: string;
content: string;
type: AnnouncementType;
priority?: string;
targetType?: TargetType;
targetConfig?: { tags?: string[]; userIds?: string[] };
imageUrl?: string;
linkUrl?: string;
publishedAt?: Date;
expiresAt?: Date;
createdBy?: string;
}
export interface UpdateAnnouncementParams {
title?: string;
content?: string;
type?: AnnouncementType;
priority?: string;
targetType?: TargetType;
targetConfig?: { tags?: string[]; userIds?: string[] };
imageUrl?: string | null;
linkUrl?: string | null;
isEnabled?: boolean;
publishedAt?: Date | null;
expiresAt?: Date | null;
}
@Injectable()
export class AnnouncementService {
private readonly logger = new Logger(AnnouncementService.name);
constructor(
@Inject(ANNOUNCEMENT_REPOSITORY)
private readonly announcementRepo: IAnnouncementRepository,
@Inject(USER_TAG_REPOSITORY)
private readonly userTagRepo: IUserTagRepository,
private readonly pushDispatcher: PushDispatcherService,
) {}
async create(params: CreateAnnouncementParams): Promise<Announcement> {
const targetType = params.targetType ?? TargetType.ALL;
let targetConfig: AnnouncementTargetConfig | null = null;
if (params.targetConfig || targetType !== TargetType.ALL) {
targetConfig = {
type: targetType,
tags: params.targetConfig?.tags,
userIds: params.targetConfig?.userIds,
};
}
const announcement = this.announcementRepo.save(
Object.assign(new Announcement(), {
title: params.title,
content: params.content,
type: params.type,
priority: params.priority ?? 'NORMAL',
targetType,
targetConfig,
imageUrl: params.imageUrl ?? null,
linkUrl: params.linkUrl ?? null,
isEnabled: true,
publishedAt: params.publishedAt ?? null,
expiresAt: params.expiresAt ?? null,
createdBy: params.createdBy ?? null,
}),
);
const saved = await announcement;
// Save targeting data to junction tables
await this.announcementRepo.clearTargets(saved.id);
if (targetType === TargetType.BY_TAG && params.targetConfig?.tags?.length) {
await this.announcementRepo.saveTagTargets(saved.id, params.targetConfig.tags);
}
if (targetType === TargetType.SPECIFIC && params.targetConfig?.userIds?.length) {
await this.announcementRepo.saveUserTargets(saved.id, params.targetConfig.userIds);
}
this.logger.log(`Announcement created: ${saved.id} (targetType=${targetType})`);
// If published immediately (publishedAt is null or ≤ now), trigger push
const shouldPush = !saved.publishedAt || saved.publishedAt <= new Date();
if (shouldPush) {
this.triggerPushForAnnouncement(saved).catch((err) =>
this.logger.error(`Push trigger failed for announcement ${saved.id}: ${err.message}`),
);
}
return saved;
}
async update(id: string, params: UpdateAnnouncementParams): Promise<Announcement> {
const existing = await this.announcementRepo.findById(id);
if (!existing) {
throw new NotFoundException('Announcement not found');
}
// Apply partial update
if (params.title !== undefined) existing.title = params.title;
if (params.content !== undefined) existing.content = params.content;
if (params.type !== undefined) existing.type = params.type;
if (params.priority !== undefined) existing.priority = params.priority as any;
if (params.isEnabled !== undefined) existing.isEnabled = params.isEnabled;
if (params.imageUrl !== undefined) existing.imageUrl = params.imageUrl;
if (params.linkUrl !== undefined) existing.linkUrl = params.linkUrl;
if (params.publishedAt !== undefined) existing.publishedAt = params.publishedAt;
if (params.expiresAt !== undefined) existing.expiresAt = params.expiresAt;
// Update targeting
if (params.targetType !== undefined || params.targetConfig !== undefined) {
const targetType = params.targetType ?? existing.targetType;
existing.targetType = targetType;
if (params.targetConfig || targetType !== TargetType.ALL) {
existing.targetConfig = {
type: targetType,
tags: params.targetConfig?.tags ?? existing.targetConfig?.tags,
userIds: params.targetConfig?.userIds ?? existing.targetConfig?.userIds,
};
} else {
existing.targetConfig = null;
}
// Refresh junction tables
await this.announcementRepo.clearTargets(id);
if (targetType === TargetType.BY_TAG && existing.targetConfig?.tags?.length) {
await this.announcementRepo.saveTagTargets(id, existing.targetConfig.tags);
}
if (targetType === TargetType.SPECIFIC && existing.targetConfig?.userIds?.length) {
await this.announcementRepo.saveUserTargets(id, existing.targetConfig.userIds);
}
}
const saved = await this.announcementRepo.save(existing);
this.logger.log(`Announcement updated: ${id}`);
return saved;
}
async delete(id: string): Promise<void> {
await this.announcementRepo.delete(id);
this.logger.log(`Announcement deleted: ${id}`);
}
async findAll(params?: {
type?: AnnouncementType;
isEnabled?: boolean;
page?: number;
limit?: number;
}): Promise<{ items: Announcement[]; total: number; page: number; limit: number }> {
const page = params?.page ?? 1;
const limit = params?.limit ?? 20;
const offset = (page - 1) * limit;
const [items, total] = await this.announcementRepo.findAll({
type: params?.type,
isEnabled: params?.isEnabled,
limit,
offset,
});
return { items, total, page, limit };
}
async findById(id: string): Promise<Announcement> {
const announcement = await this.announcementRepo.findById(id);
if (!announcement) {
throw new NotFoundException('Announcement not found');
}
return announcement;
}
async getForUser(
userId: string,
params?: { type?: AnnouncementType; page?: number; limit?: number },
): Promise<{
items: AnnouncementWithReadStatus[];
unreadCount: number;
page: number;
limit: number;
}> {
const page = params?.page ?? 1;
const limit = params?.limit ?? 20;
const offset = (page - 1) * limit;
const userTags = await this.userTagRepo.findTagsByUserId(userId);
const [items, unreadCount] = await Promise.all([
this.announcementRepo.findForUser({
userId,
userTags,
type: params?.type,
limit,
offset,
}),
this.announcementRepo.countUnreadForUser(userId, userTags),
]);
return { items, unreadCount, page, limit };
}
async countUnreadForUser(userId: string): Promise<number> {
const userTags = await this.userTagRepo.findTagsByUserId(userId);
return this.announcementRepo.countUnreadForUser(userId, userTags);
}
async markAsRead(announcementId: string, userId: string): Promise<void> {
await this.announcementRepo.markAsRead(announcementId, userId);
}
async markAllAsRead(userId: string): Promise<void> {
const userTags = await this.userTagRepo.findTagsByUserId(userId);
await this.announcementRepo.markAllAsRead(userId, userTags);
}
// ── Push Integration ──
/**
* Trigger device push for an announcement based on its targeting:
* - ALL → sendToUsers with empty filter (all registered tokens)
* - BY_TAG → resolve userIds from tags, then push
* - SPECIFIC → push to specific userIds
*/
private async triggerPushForAnnouncement(announcement: Announcement): Promise<void> {
const data: Record<string, string> = {
announcementId: announcement.id,
type: announcement.type,
};
if (announcement.linkUrl) {
data.linkUrl = announcement.linkUrl;
}
if (announcement.targetType === TargetType.ALL) {
// For ALL targeting, pass empty array — PushDispatcher handles gracefully
// In production, this would page through all users
this.logger.log(`Push for ALL-targeted announcement ${announcement.id} (broadcast)`);
// Broadcast push requires fetching all user IDs or using topic-based push
// For now we log; real implementation would use FCM topics or iterate users
return;
}
if (announcement.targetType === TargetType.BY_TAG && announcement.targetConfig?.tags?.length) {
const tags = announcement.targetConfig.tags;
const userIds: string[] = [];
for (const tag of tags) {
const ids = await this.userTagRepo.findUserIdsByTag(tag);
userIds.push(...ids);
}
// Deduplicate
const uniqueUserIds = [...new Set(userIds)];
if (uniqueUserIds.length > 0) {
this.logger.log(
`Push for BY_TAG announcement ${announcement.id}: ${uniqueUserIds.length} users`,
);
await this.pushDispatcher.sendToUsers(
uniqueUserIds,
announcement.title,
announcement.content.slice(0, 200),
data,
);
}
return;
}
if (announcement.targetType === TargetType.SPECIFIC && announcement.targetConfig?.userIds?.length) {
const userIds = announcement.targetConfig.userIds;
this.logger.log(
`Push for SPECIFIC announcement ${announcement.id}: ${userIds.length} users`,
);
await this.pushDispatcher.sendToUsers(
userIds,
announcement.title,
announcement.content.slice(0, 200),
data,
);
}
}
}