Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 158 additions & 16 deletions src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { Injectable, Optional } from '@nestjs/common';
import { Injectable, Optional, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan } from 'typeorm';
import { Repository, MoreThan, In } from 'typeorm';
import { Notification, NotificationType, NotificationStatus } from './entities/notification.entity';
import { PaginationService } from '../common/services/pagination.service';
import { PaginationQueryDto } from '../common/dto/pagination.dto';
import { CreateNotificationDto } from './dto/notification.dto';
import { SendTemplatedNotificationDto } from './dto/preferences.dto';
import { PreferencesService } from './preferences/preferences.service';
import { NotificationTemplateService } from './templates/notification-template.service';

@Injectable()
export class NotificationsService {
constructor(
@InjectRepository(Notification)
private notificationRepository: Repository<Notification>,
private readonly preferencesService: PreferencesService,
private readonly templateService: NotificationTemplateService,
@Optional()
private paginationService: PaginationService = new PaginationService(),
) {}
Expand Down Expand Up @@ -46,33 +52,169 @@
return this.notificationRepository.find({ where: { userId } });
}

// Stubs for other methods (to satisfy typecheck)
async send(_dto: any) {
return null;
async create(dto: CreateNotificationDto) {
const notification = this.notificationRepository.create({
userId: dto.userId,
title: dto.title,
content: dto.content,
type: dto.type ?? NotificationType.IN_APP,
priority: dto.priority,
metadata: dto.metadata,
status: NotificationStatus.PENDING,
});

return this.notificationRepository.save(notification);
}
async sendTemplated(_dto: any) {
return [];

async send(dto: CreateNotificationDto) {
const notification = await this.create(dto);

const prefs = await this.preferencesService.getPreferences(dto.userId);

if (prefs.globalUnsubscribe) {
return notification;
}

const channels: { enabled: boolean; type: NotificationType }[] = [
{ enabled: prefs.inAppEnabled, type: NotificationType.IN_APP },
{ enabled: prefs.emailEnabled, type: NotificationType.EMAIL },
{ enabled: prefs.pushEnabled, type: NotificationType.PUSH },
{ enabled: prefs.smsEnabled, type: NotificationType.SMS },
];

const dispatches: Promise<Notification>[] = [];

for (const channel of channels) {
if (channel.enabled) {
const channelNotification = this.notificationRepository.create({
userId: dto.userId,
title: dto.title,
content: dto.content,
type: channel.type,
priority: dto.priority,
metadata: dto.metadata,
status: NotificationStatus.SENT,
});
dispatches.push(this.notificationRepository.save(channelNotification));
}
}

await Promise.all(dispatches);

return notification;
}
async unsubscribe(_userId: string, _eventType: string) {
return;

async sendTemplated(dto: SendTemplatedNotificationDto) {
const prefs = await this.preferencesService.getPreferences(dto.userId);

if (prefs.globalUnsubscribe) {
throw new BadRequestException('User has globally unsubscribed from notifications');
}

if (

Check failure on line 114 in src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎······prefs.eventFrequency?.[dto.eventType]·===·'never'⏎····` with `prefs.eventFrequency?.[dto.eventType]·===·'never'`
prefs.eventFrequency?.[dto.eventType] === 'never'
) {
throw new BadRequestException(

Check failure on line 117 in src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎········`User·has·unsubscribed·from·event·type·"${dto.eventType}"`,⏎······` with ``User·has·unsubscribed·from·event·type·"${dto.eventType}"``
`User has unsubscribed from event type "${dto.eventType}"`,
);
}

const rendered = await this.templateService.renderByName(
dto.templateName,
dto.context,
dto.templateVersion,
);

const channels: { enabled: boolean; type: NotificationType }[] = [
{ enabled: prefs.inAppEnabled, type: NotificationType.IN_APP },
{ enabled: prefs.emailEnabled, type: NotificationType.EMAIL },
{ enabled: prefs.pushEnabled, type: NotificationType.PUSH },
{ enabled: prefs.smsEnabled, type: NotificationType.SMS },
];

const saved: Notification[] = [];

for (const channel of channels) {
if (channel.enabled) {
const notification = this.notificationRepository.create({
userId: dto.userId,
title: rendered.subject ?? dto.templateName,
content: rendered.body,
type: channel.type,
status: NotificationStatus.SENT,
metadata: {
templateName: dto.templateName,
templateVersion: rendered.templateVersion,
eventType: dto.eventType,
},
});
saved.push(await this.notificationRepository.save(notification));
}
}

return saved;
}

async findForUser(userId: string, query?: PaginationQueryDto) {
const limit = query?.limit ?? 20;
const offset = query?.offset ?? (query?.cursor ? undefined : ((query?.page ?? 1) - 1) * limit);
const offset =

Check failure on line 160 in src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `⏎·····`
query?.offset ?? (query?.cursor ? undefined : ((query?.page ?? 1) - 1) * limit);

const qb = this.notificationRepository
.createQueryBuilder('notification')
.where('notification.userId = :userId', { userId });

return this.paginationService.paginate(qb, query?.cursor, limit, offset, 'createdAt');
}
async create(_dto: any) {
return null;

async markRead(id: string, userId: string) {
const notification = await this.notificationRepository.findOne({
where: { id },
});

if (!notification) {
throw new NotFoundException(`Notification ${id} not found`);
}

if (notification.userId !== userId) {
throw new BadRequestException('You do not own this notification');
}

if (notification.isRead) {
return notification;
}

notification.isRead = true;
notification.readAt = new Date();

return this.notificationRepository.save(notification);
}
async markRead(_id: string, _userId: string) {
return null;

async markManyRead(ids: string[], userId: string) {
if (!ids.length) {
return;
}

const notifications = await this.notificationRepository.find({
where: { id: In(ids) },
});

if (notifications.length !== ids.length) {
throw new NotFoundException('One or more notifications not found');
}

const owned = notifications.filter((n) => n.userId === userId);
if (owned.length !== ids.length) {
throw new BadRequestException('You do not own one or more of these notifications');
}

await this.notificationRepository.update(
{ id: In(ids), userId },
{ isRead: true, readAt: new Date() },
);
}
async markManyRead(_ids: string[], _userId: string) {
return;

async unsubscribe(userId: string, eventType: string) {
return this.preferencesService.unsubscribe(userId, eventType);
}
}
23 changes: 23 additions & 0 deletions src/notifications/preferences/preferences.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,27 @@
preferences[channel] = !preferences[channel];
return this.preferencesRepository.save(preferences);
}

/**
* Unsubscribe user from a specific event type or all notifications.
* If eventType is 'all', sets globalUnsubscribe to true.
* Otherwise, sets the event frequency to 'never' for that event type.
*/
async unsubscribe(

Check failure on line 66 in src/notifications/preferences/preferences.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎····userId:·string,⏎····eventType:·string,⏎··` with `userId:·string,·eventType:·string`
userId: string,
eventType: string,
): Promise<NotificationPreferences> {
const preferences = await this.getPreferences(userId);

if (eventType === 'all') {
preferences.globalUnsubscribe = true;
} else {
if (!preferences.eventFrequency) {
preferences.eventFrequency = {};
}
preferences.eventFrequency[eventType] = 'never';
}

return this.preferencesRepository.save(preferences);
}
}
Loading