301 lines
9.9 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|
|
}
|