ET-NOTIF-BACKEND: Servicios y API REST
Identificacion
| Campo |
Valor |
| ID |
ET-NOTIF-BACKEND |
| Modulo |
MGN-008 Notifications |
| Version |
1.0 |
| Estado |
En Diseno |
| Framework |
NestJS |
| Autor |
Requirements-Analyst |
| Fecha |
2025-12-05 |
Estructura de Archivos
apps/backend/src/modules/notifications/
├── notifications.module.ts
├── controllers/
│ ├── notifications.controller.ts
│ ├── email-templates.controller.ts
│ ├── push.controller.ts
│ └── preferences.controller.ts
├── services/
│ ├── notifications.service.ts
│ ├── email.service.ts
│ ├── email-templates.service.ts
│ ├── push.service.ts
│ └── preferences.service.ts
├── gateways/
│ └── notifications.gateway.ts
├── entities/
│ ├── notification.entity.ts
│ ├── email-template.entity.ts
│ ├── email-job.entity.ts
│ ├── push-subscription.entity.ts
│ ├── push-job.entity.ts
│ └── notification-preference.entity.ts
├── dto/
│ ├── send-notification.dto.ts
│ ├── send-email.dto.ts
│ ├── create-template.dto.ts
│ ├── subscribe-push.dto.ts
│ └── update-preferences.dto.ts
├── processors/
│ ├── email.processor.ts
│ └── push.processor.ts
└── templates/
└── base.hbs
Entidades
Notification Entity
@Entity('notifications', { schema: 'core_notifications' })
export class Notification {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ length: 50 })
type: NotificationType;
@Column({ length: 255 })
title: string;
@Column({ type: 'text' })
body: string;
@Column({ type: 'jsonb', default: {} })
data: Record<string, any>;
@Column({ name: 'action_url', length: 500, nullable: true })
actionUrl: string;
@Column({ name: 'action_label', length: 100, nullable: true })
actionLabel: string;
@Column({ length: 50, nullable: true })
icon: string;
@Column({ type: 'varchar', array: true, default: ['in_app'] })
channels: NotificationChannel[];
@Column({ name: 'is_read', default: false })
isRead: boolean;
@Column({ name: 'read_at', type: 'timestamptz', nullable: true })
readAt: Date;
@Column({ name: 'is_archived', default: false })
isArchived: boolean;
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
}
export type NotificationChannel = 'in_app' | 'email' | 'push';
export type NotificationType =
| 'security.login_new_device'
| 'security.password_changed'
| 'user.mention'
| 'user.assignment'
| 'task.assigned'
| 'task.completed'
| 'document.shared'
| 'payment.received'
| 'report.ready';
EmailTemplate Entity
@Entity('email_templates', { schema: 'core_notifications' })
export class EmailTemplate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
@Column({ length: 100, unique: true })
key: string;
@Column({ length: 255 })
name: string;
@Column({ length: 255 })
subject: string;
@Column({ name: 'body_html', type: 'text' })
bodyHtml: string;
@Column({ name: 'body_text', type: 'text', nullable: true })
bodyText: string;
@Column({ type: 'jsonb', default: [] })
variables: TemplateVariable[];
@Column({ length: 50, default: 'transactional' })
category: EmailCategory;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
PushSubscription Entity
@Entity('push_subscriptions', { schema: 'core_notifications' })
export class PushSubscription {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ length: 500, unique: true })
endpoint: string;
@Column({ length: 255 })
p256dh: string;
@Column({ length: 255 })
auth: string;
@Column({ name: 'device_type', length: 20 })
deviceType: 'web' | 'android' | 'ios';
@Column({ name: 'device_name', length: 100, nullable: true })
deviceName: string;
@Column({ length: 50, nullable: true })
browser: string;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
lastUsedAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
Servicios
NotificationsService
@Injectable()
export class NotificationsService {
constructor(
@InjectRepository(Notification)
private readonly repo: Repository<Notification>,
private readonly gateway: NotificationsGateway,
private readonly emailService: EmailService,
private readonly pushService: PushService,
private readonly preferencesService: PreferencesService,
) {}
async send(dto: SendNotificationDto): Promise<Notification> {
// Get user preferences
const preferences = await this.preferencesService.get(dto.userId, dto.type);
// Filter channels based on preferences
const effectiveChannels = dto.channels.filter(channel => {
switch (channel) {
case 'in_app': return preferences.inApp;
case 'email': return preferences.email;
case 'push': return preferences.push;
default: return false;
}
});
if (effectiveChannels.length === 0) {
return null; // User disabled all channels for this type
}
// Create in-app notification
let notification: Notification = null;
if (effectiveChannels.includes('in_app')) {
notification = this.repo.create({
...dto,
channels: effectiveChannels,
});
await this.repo.save(notification);
// Send via WebSocket
await this.gateway.sendToUser(dto.userId, {
type: 'notification',
payload: notification,
});
}
// Queue email
if (effectiveChannels.includes('email')) {
await this.emailService.queue({
tenantId: dto.tenantId,
userId: dto.userId,
template: this.getEmailTemplate(dto.type),
variables: { title: dto.title, body: dto.body, ...dto.data },
frequency: preferences.emailFrequency,
});
}
// Queue push
if (effectiveChannels.includes('push')) {
await this.pushService.queue({
userId: dto.userId,
title: dto.title,
body: dto.body,
data: dto.data,
actionUrl: dto.actionUrl,
});
}
return notification;
}
async sendBulk(
userIds: string[],
dto: Omit<SendNotificationDto, 'userId'>
): Promise<number> {
let count = 0;
for (const userId of userIds) {
const notification = await this.send({ ...dto, userId });
if (notification) count++;
}
return count;
}
async findByUser(
userId: string,
query: QueryNotificationsDto
): Promise<PaginatedResult<Notification>> {
const qb = this.repo.createQueryBuilder('n')
.where('n.user_id = :userId', { userId });
if (query.isRead !== undefined) {
qb.andWhere('n.is_read = :isRead', { isRead: query.isRead });
}
if (query.type) {
qb.andWhere('n.type = :type', { type: query.type });
}
if (!query.includeArchived) {
qb.andWhere('n.is_archived = false');
}
// Exclude expired
qb.andWhere('(n.expires_at IS NULL OR n.expires_at > NOW())');
qb.orderBy('n.created_at', 'DESC');
return paginate(qb, query);
}
async markAsRead(
userId: string,
ids?: string[]
): Promise<{ affected: number }> {
const qb = this.repo.createQueryBuilder()
.update()
.set({ isRead: true, readAt: new Date() })
.where('user_id = :userId', { userId })
.andWhere('is_read = false');
if (ids?.length) {
qb.andWhere('id IN (:...ids)', { ids });
}
const result = await qb.execute();
// Notify via WebSocket
await this.gateway.sendToUser(userId, {
type: 'notifications_read',
payload: { ids, all: !ids },
});
return { affected: result.affected };
}
async getUnreadCount(userId: string): Promise<UnreadCounts> {
const result = await this.repo
.createQueryBuilder('n')
.select('n.type', 'type')
.addSelect('COUNT(*)', 'count')
.where('n.user_id = :userId', { userId })
.andWhere('n.is_read = false')
.andWhere('n.is_archived = false')
.andWhere('(n.expires_at IS NULL OR n.expires_at > NOW())')
.groupBy('n.type')
.getRawMany();
const total = result.reduce((sum, r) => sum + parseInt(r.count), 0);
const byType = result.reduce((acc, r) => {
acc[r.type] = parseInt(r.count);
return acc;
}, {});
return { total, byType };
}
async archive(userId: string, ids: string[]): Promise<void> {
await this.repo.update(
{ userId, id: In(ids) },
{ isArchived: true }
);
}
}
EmailService
@Injectable()
export class EmailService {
constructor(
@InjectRepository(EmailJob)
private readonly jobRepo: Repository<EmailJob>,
@InjectRepository(EmailTemplate)
private readonly templateRepo: Repository<EmailTemplate>,
@InjectQueue('email')
private readonly queue: Queue,
private readonly configService: ConfigService,
) {}
async queue(dto: QueueEmailDto): Promise<EmailJob> {
// Get template
const template = await this.templateRepo.findOne({
where: [
{ key: dto.template, tenantId: dto.tenantId },
{ key: dto.template, tenantId: IsNull() },
],
order: { tenantId: 'DESC' }, // Prefer tenant-specific
});
if (!template) {
throw new NotFoundException(`Email template ${dto.template} not found`);
}
// Get user email
const user = await this.usersService.findById(dto.userId);
// Render template
const subject = this.render(template.subject, dto.variables);
const bodyHtml = this.render(template.bodyHtml, dto.variables);
const bodyText = template.bodyText
? this.render(template.bodyText, dto.variables)
: null;
// Calculate scheduled time based on frequency
const scheduledAt = this.calculateScheduledTime(dto.frequency);
// Create job
const job = this.jobRepo.create({
tenantId: dto.tenantId,
templateId: template.id,
toEmail: user.email,
toName: user.fullName,
subject,
bodyHtml,
bodyText,
variables: dto.variables,
scheduledAt,
});
await this.jobRepo.save(job);
// Add to Bull queue
await this.queue.add('send', { jobId: job.id }, {
delay: scheduledAt.getTime() - Date.now(),
attempts: 3,
backoff: { type: 'exponential', delay: 60000 },
});
return job;
}
async sendDirect(dto: SendEmailDirectDto): Promise<void> {
const transporter = nodemailer.createTransport({
host: this.configService.get('SMTP_HOST'),
port: this.configService.get('SMTP_PORT'),
secure: this.configService.get('SMTP_SECURE', false),
auth: {
user: this.configService.get('SMTP_USER'),
pass: this.configService.get('SMTP_PASS'),
},
});
await transporter.sendMail({
from: this.configService.get('EMAIL_FROM'),
to: dto.to,
subject: dto.subject,
html: dto.bodyHtml,
text: dto.bodyText,
attachments: dto.attachments,
});
}
private render(template: string, variables: Record<string, any>): string {
return Handlebars.compile(template)(variables);
}
private calculateScheduledTime(frequency: EmailFrequency): Date {
const now = new Date();
switch (frequency) {
case 'instant':
return now;
case 'hourly':
return startOfHour(addHours(now, 1));
case 'daily':
return startOfDay(addDays(now, 1));
case 'weekly':
return startOfWeek(addWeeks(now, 1));
default:
return now;
}
}
}
PushService
@Injectable()
export class PushService {
private readonly webpush: typeof webpush;
constructor(
@InjectRepository(PushSubscription)
private readonly subscriptionRepo: Repository<PushSubscription>,
@InjectRepository(PushJob)
private readonly jobRepo: Repository<PushJob>,
@InjectQueue('push')
private readonly queue: Queue,
private readonly configService: ConfigService,
) {
webpush.setVapidDetails(
this.configService.get('PUSH_SUBJECT'),
this.configService.get('VAPID_PUBLIC_KEY'),
this.configService.get('VAPID_PRIVATE_KEY'),
);
this.webpush = webpush;
}
async subscribe(
userId: string,
dto: SubscribePushDto
): Promise<PushSubscription> {
// Check if already exists
const existing = await this.subscriptionRepo.findOne({
where: { endpoint: dto.endpoint },
});
if (existing) {
// Update and return
existing.isActive = true;
existing.lastUsedAt = new Date();
return this.subscriptionRepo.save(existing);
}
const subscription = this.subscriptionRepo.create({
userId,
endpoint: dto.endpoint,
p256dh: dto.keys.p256dh,
auth: dto.keys.auth,
deviceType: dto.deviceType,
deviceName: dto.deviceName,
browser: dto.browser,
});
return this.subscriptionRepo.save(subscription);
}
async unsubscribe(userId: string, endpoint: string): Promise<void> {
await this.subscriptionRepo.update(
{ userId, endpoint },
{ isActive: false }
);
}
async queue(dto: QueuePushDto): Promise<void> {
// Get all active subscriptions for user
const subscriptions = await this.subscriptionRepo.find({
where: { userId: dto.userId, isActive: true },
});
for (const subscription of subscriptions) {
const job = this.jobRepo.create({
subscriptionId: subscription.id,
title: dto.title,
body: dto.body,
icon: dto.icon,
data: dto.data,
actionUrl: dto.actionUrl,
});
await this.jobRepo.save(job);
await this.queue.add('send', { jobId: job.id }, {
attempts: 3,
backoff: { type: 'exponential', delay: 30000 },
});
}
}
async send(job: PushJob): Promise<void> {
const subscription = await this.subscriptionRepo.findOne({
where: { id: job.subscriptionId },
});
if (!subscription || !subscription.isActive) {
return;
}
const payload = JSON.stringify({
title: job.title,
body: job.body,
icon: job.icon,
data: job.data,
actionUrl: job.actionUrl,
});
try {
await this.webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
payload
);
subscription.lastUsedAt = new Date();
await this.subscriptionRepo.save(subscription);
} catch (error) {
if (error.statusCode === 410) {
// Subscription expired
subscription.isActive = false;
await this.subscriptionRepo.save(subscription);
}
throw error;
}
}
}
PreferencesService
@Injectable()
export class PreferencesService {
constructor(
@InjectRepository(NotificationPreference)
private readonly repo: Repository<NotificationPreference>,
) {}
async get(
userId: string,
type?: string
): Promise<NotificationPreference | NotificationPreferences> {
if (type) {
const pref = await this.repo.findOne({
where: { userId, notificationType: type },
});
return pref || this.getDefaults(type);
}
const prefs = await this.repo.find({ where: { userId } });
return this.mergeWithDefaults(prefs);
}
async update(
userId: string,
updates: UpdatePreferencesDto
): Promise<NotificationPreference[]> {
const results: NotificationPreference[] = [];
for (const [type, settings] of Object.entries(updates)) {
await this.repo.upsert(
{
userId,
notificationType: type,
...settings,
updatedAt: new Date(),
},
['userId', 'notificationType']
);
const updated = await this.repo.findOne({
where: { userId, notificationType: type },
});
results.push(updated);
}
return results;
}
async reset(userId: string, types?: string[]): Promise<void> {
const qb = this.repo.createQueryBuilder()
.delete()
.where('user_id = :userId', { userId });
if (types?.length) {
qb.andWhere('notification_type IN (:...types)', { types });
}
await qb.execute();
}
private getDefaults(type: string): NotificationPreference {
return {
inApp: true,
email: true,
push: false,
emailFrequency: 'instant',
} as NotificationPreference;
}
}
WebSocket Gateway
@WebSocketGateway({
namespace: '/notifications',
cors: { origin: '*' },
})
export class NotificationsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
private server: Server;
private userSockets: Map<string, Set<string>> = new Map();
constructor(
private readonly jwtService: JwtService,
private readonly notificationsService: NotificationsService,
) {}
async handleConnection(client: Socket): Promise<void> {
try {
const token = client.handshake.auth.token;
const payload = this.jwtService.verify(token);
const userId = payload.sub;
client.data.userId = userId;
if (!this.userSockets.has(userId)) {
this.userSockets.set(userId, new Set());
}
this.userSockets.get(userId).add(client.id);
client.join(`user:${userId}`);
// Send initial unread count
const count = await this.notificationsService.getUnreadCount(userId);
client.emit('unread_count', count);
} catch (error) {
client.disconnect();
}
}
handleDisconnect(client: Socket): void {
const userId = client.data.userId;
if (userId && this.userSockets.has(userId)) {
this.userSockets.get(userId).delete(client.id);
if (this.userSockets.get(userId).size === 0) {
this.userSockets.delete(userId);
}
}
}
async sendToUser(userId: string, message: WsMessage): Promise<void> {
this.server.to(`user:${userId}`).emit(message.type, message.payload);
}
@SubscribeMessage('mark_read')
async handleMarkRead(
client: Socket,
payload: { ids?: string[] }
): Promise<void> {
const userId = client.data.userId;
await this.notificationsService.markAsRead(userId, payload.ids);
}
@SubscribeMessage('ping')
handlePing(): string {
return 'pong';
}
}
Job Processors
EmailProcessor
@Processor('email')
export class EmailProcessor {
constructor(
@InjectRepository(EmailJob)
private readonly jobRepo: Repository<EmailJob>,
private readonly emailService: EmailService,
) {}
@Process('send')
async handleSend(job: Job<{ jobId: string }>): Promise<void> {
const emailJob = await this.jobRepo.findOne({
where: { id: job.data.jobId },
});
if (!emailJob || emailJob.status === 'sent') {
return;
}
emailJob.status = 'processing';
emailJob.attempts += 1;
await this.jobRepo.save(emailJob);
try {
await this.emailService.sendDirect({
to: emailJob.toEmail,
toName: emailJob.toName,
subject: emailJob.subject,
bodyHtml: emailJob.bodyHtml,
bodyText: emailJob.bodyText,
});
emailJob.status = 'sent';
emailJob.sentAt = new Date();
emailJob.error = null;
} catch (error) {
emailJob.error = error.message;
if (emailJob.attempts >= emailJob.maxAttempts) {
emailJob.status = 'failed';
} else {
emailJob.status = 'pending';
}
throw error; // Retry
} finally {
await this.jobRepo.save(emailJob);
}
}
}
PushProcessor
@Processor('push')
export class PushProcessor {
constructor(
@InjectRepository(PushJob)
private readonly jobRepo: Repository<PushJob>,
private readonly pushService: PushService,
) {}
@Process('send')
async handleSend(job: Job<{ jobId: string }>): Promise<void> {
const pushJob = await this.jobRepo.findOne({
where: { id: job.data.jobId },
});
if (!pushJob || pushJob.status === 'sent') {
return;
}
pushJob.status = 'processing';
pushJob.attempts += 1;
await this.jobRepo.save(pushJob);
try {
await this.pushService.send(pushJob);
pushJob.status = 'sent';
pushJob.sentAt = new Date();
pushJob.error = null;
} catch (error) {
pushJob.error = error.message;
if (pushJob.attempts >= 3) {
pushJob.status = 'failed';
} else {
pushJob.status = 'pending';
throw error; // Retry
}
} finally {
await this.jobRepo.save(pushJob);
}
}
}
Controladores
NotificationsController
@ApiTags('Notifications')
@Controller('notifications')
@UseGuards(JwtAuthGuard)
export class NotificationsController {
constructor(private readonly service: NotificationsService) {}
@Get()
async findAll(
@CurrentUser() user: User,
@Query() query: QueryNotificationsDto
) {
return this.service.findByUser(user.id, query);
}
@Get('count')
async getUnreadCount(@CurrentUser() user: User) {
return this.service.getUnreadCount(user.id);
}
@Put('read')
async markAllAsRead(@CurrentUser() user: User) {
return this.service.markAsRead(user.id);
}
@Put(':id/read')
async markAsRead(
@CurrentUser() user: User,
@Param('id') id: string
) {
return this.service.markAsRead(user.id, [id]);
}
@Delete(':id')
async archive(
@CurrentUser() user: User,
@Param('id') id: string
) {
return this.service.archive(user.id, [id]);
}
}
PushController
@ApiTags('Push Notifications')
@Controller('notifications/push')
@UseGuards(JwtAuthGuard)
export class PushController {
constructor(private readonly service: PushService) {}
@Get('vapid-key')
getVapidKey() {
return {
publicKey: this.configService.get('VAPID_PUBLIC_KEY'),
};
}
@Post('subscribe')
async subscribe(
@CurrentUser() user: User,
@Body() dto: SubscribePushDto
) {
return this.service.subscribe(user.id, dto);
}
@Delete('unsubscribe')
async unsubscribe(
@CurrentUser() user: User,
@Body('endpoint') endpoint: string
) {
return this.service.unsubscribe(user.id, endpoint);
}
@Get('subscriptions')
async getSubscriptions(@CurrentUser() user: User) {
return this.service.getSubscriptions(user.id);
}
}
PreferencesController
@ApiTags('Notification Preferences')
@Controller('notifications/preferences')
@UseGuards(JwtAuthGuard)
export class PreferencesController {
constructor(private readonly service: PreferencesService) {}
@Get()
async getAll(@CurrentUser() user: User) {
return this.service.get(user.id);
}
@Get(':type')
async getOne(
@CurrentUser() user: User,
@Param('type') type: string
) {
return this.service.get(user.id, type);
}
@Patch()
async update(
@CurrentUser() user: User,
@Body() dto: UpdatePreferencesDto
) {
return this.service.update(user.id, dto);
}
@Delete()
async reset(
@CurrentUser() user: User,
@Body('types') types?: string[]
) {
return this.service.reset(user.id, types);
}
}
API Endpoints Summary
| Method |
Path |
Auth |
Description |
| GET |
/notifications |
User |
List notifications |
| GET |
/notifications/count |
User |
Get unread count |
| PUT |
/notifications/read |
User |
Mark all as read |
| PUT |
/notifications/:id/read |
User |
Mark one as read |
| DELETE |
/notifications/:id |
User |
Archive notification |
| GET |
/notifications/push/vapid-key |
- |
Get VAPID public key |
| POST |
/notifications/push/subscribe |
User |
Subscribe to push |
| DELETE |
/notifications/push/unsubscribe |
User |
Unsubscribe |
| GET |
/notifications/push/subscriptions |
User |
List subscriptions |
| GET |
/notifications/preferences |
User |
Get preferences |
| GET |
/notifications/preferences/:type |
User |
Get by type |
| PATCH |
/notifications/preferences |
User |
Update preferences |
| DELETE |
/notifications/preferences |
User |
Reset to defaults |
| GET |
/admin/notifications/templates |
Admin |
List templates |
| POST |
/admin/notifications/templates |
Admin |
Create template |
| PATCH |
/admin/notifications/templates/:id |
Admin |
Update template |
| DELETE |
/admin/notifications/templates/:id |
Admin |
Delete template |
| POST |
/admin/notifications/send |
Admin |
Send notification |
WebSocket Events
| Event |
Direction |
Description |
notification |
Server -> Client |
New notification received |
unread_count |
Server -> Client |
Updated unread count |
notifications_read |
Server -> Client |
Notifications marked as read |
mark_read |
Client -> Server |
Request to mark as read |
ping |
Client -> Server |
Heartbeat |
Historial
| Version |
Fecha |
Autor |
Cambios |
| 1.0 |
2025-12-05 |
Requirements-Analyst |
Creacion inicial |