feat(notifications): Add notifications module (MCH-017)

- Add entities: Notification, NotificationTemplate, NotificationPreference, DeviceToken
- Add NotificationsService with send, templated, preferences, device tokens
- Add NotificationsController with REST endpoints
- Add default notification templates for orders, payments, alerts
- Integrate axios for HTTP calls to WhatsApp service

Sprint 3: MCH-017 Notificaciones

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 03:32:03 -06:00
parent 4fcdd30812
commit 75e881e1cc
12 changed files with 929 additions and 7 deletions

47
package-lock.json generated
View File

@ -18,6 +18,7 @@
"@nestjs/swagger": "^11.2.5",
"@nestjs/typeorm": "^11.0.0",
"@react-native-community/netinfo": "^11.4.1",
"axios": "^1.13.2",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
@ -205,6 +206,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@ -3779,7 +3781,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/available-typed-arrays": {
@ -3797,6 +3798,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@ -4526,7 +4538,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@ -4915,7 +4926,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@ -5192,7 +5202,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -5843,6 +5852,26 @@
"integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -5930,7 +5959,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@ -5947,7 +5975,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -5957,7 +5984,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@ -9551,6 +9577,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -9688,6 +9720,7 @@
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.83.1.tgz",
"integrity": "sha512-mL1q5HPq5cWseVhWRLl+Fwvi5z1UO+3vGOpjr+sHFwcUletPRZ5Kv+d0tUfqHmvi73/53NjlQqX1Pyn4GguUfA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.83.1",

View File

@ -33,6 +33,7 @@
"@nestjs/swagger": "^11.2.5",
"@nestjs/typeorm": "^11.0.0",
"@react-native-community/netinfo": "^11.4.1",
"axios": "^1.13.2",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",

View File

@ -18,6 +18,7 @@ import { CodiSpeiModule } from './modules/codi-spei/codi-spei.module';
import { WidgetsModule } from './modules/widgets/widgets.module';
import { InvoicesModule } from './modules/invoices/invoices.module';
import { MarketplaceModule } from './modules/marketplace/marketplace.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
@Module({
imports: [
@ -64,6 +65,7 @@ import { MarketplaceModule } from './modules/marketplace/marketplace.module';
WidgetsModule,
InvoicesModule,
MarketplaceModule,
NotificationsModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,132 @@
import { IsString, IsOptional, IsEnum, IsObject, IsBoolean, IsDateString, IsArray } from 'class-validator';
import { NotificationChannel, NotificationType, NotificationStatus } from '../entities/notification.entity';
import { DevicePlatform } from '../entities/device-token.entity';
export class SendNotificationDto {
@IsEnum(NotificationType)
type: NotificationType;
@IsEnum(NotificationChannel)
channel: NotificationChannel;
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
customerId?: string;
@IsOptional()
@IsString()
phoneNumber?: string;
@IsString()
title: string;
@IsString()
body: string;
@IsOptional()
@IsObject()
data?: Record<string, any>;
@IsOptional()
@IsDateString()
scheduledAt?: string;
}
export class SendTemplatedNotificationDto {
@IsEnum(NotificationType)
type: NotificationType;
@IsOptional()
@IsEnum(NotificationChannel)
channel?: NotificationChannel;
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
customerId?: string;
@IsOptional()
@IsString()
phoneNumber?: string;
@IsObject()
variables: Record<string, string | number>;
@IsOptional()
@IsObject()
data?: Record<string, any>;
}
export class RegisterDeviceDto {
@IsEnum(DevicePlatform)
platform: DevicePlatform;
@IsString()
token: string;
@IsOptional()
@IsString()
deviceName?: string;
}
export class UpdatePreferencesDto {
@IsEnum(NotificationChannel)
channel: NotificationChannel;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsBoolean()
quietHoursEnabled?: boolean;
@IsOptional()
@IsString()
quietHoursStart?: string;
@IsOptional()
@IsString()
quietHoursEnd?: string;
}
export class MarkAsReadDto {
@IsArray()
@IsString({ each: true })
notificationIds: string[];
}
export class NotificationFiltersDto {
@IsOptional()
@IsEnum(NotificationStatus)
status?: NotificationStatus;
@IsOptional()
@IsEnum(NotificationType)
type?: NotificationType;
@IsOptional()
@IsEnum(NotificationChannel)
channel?: NotificationChannel;
@IsOptional()
@IsDateString()
fromDate?: string;
@IsOptional()
@IsDateString()
toDate?: string;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}

View File

@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum DevicePlatform {
IOS = 'ios',
ANDROID = 'android',
WEB = 'web',
}
@Entity({ schema: 'notifications', name: 'device_tokens' })
@Index(['userId'])
@Index(['token'], { unique: true })
export class DeviceToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@Column({ type: 'varchar', length: 20 })
platform: DevicePlatform;
@Column({ type: 'text' })
token: string;
@Column({ name: 'device_name', length: 100, nullable: true })
deviceName: string;
@Column({ default: true })
active: boolean;
@Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
lastUsedAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,4 @@
export * from './notification.entity';
export * from './notification-template.entity';
export * from './notification-preference.entity';
export * from './device-token.entity';

View File

@ -0,0 +1,40 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { NotificationChannel } from './notification.entity';
@Entity({ schema: 'notifications', name: 'notification_preferences' })
@Index(['userId', 'channel'], { unique: true })
export class NotificationPreference {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@Column({ type: 'varchar', length: 20 })
channel: NotificationChannel;
@Column({ default: true })
enabled: boolean;
@Column({ name: 'quiet_hours_enabled', default: false })
quietHoursEnabled: boolean;
@Column({ name: 'quiet_hours_start', type: 'time', nullable: true })
quietHoursStart: string; // e.g., '22:00'
@Column({ name: 'quiet_hours_end', type: 'time', nullable: true })
quietHoursEnd: string; // e.g., '08:00'
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,43 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { NotificationChannel, NotificationType } from './notification.entity';
@Entity({ schema: 'notifications', name: 'notification_templates' })
@Index(['tenantId', 'type', 'channel'], { unique: true })
export class NotificationTemplate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', nullable: true })
tenantId: string; // null = default template
@Column({ type: 'varchar', length: 50 })
type: NotificationType;
@Column({ type: 'varchar', length: 20 })
channel: NotificationChannel;
@Column({ length: 255 })
title: string;
@Column({ type: 'text' })
body: string;
@Column({ type: 'jsonb', nullable: true })
variables: string[]; // Required variables like {{order_id}}, {{customer_name}}
@Column({ default: true })
active: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,101 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum NotificationChannel {
PUSH = 'push',
WHATSAPP = 'whatsapp',
SMS = 'sms',
}
export enum NotificationStatus {
PENDING = 'pending',
SENT = 'sent',
DELIVERED = 'delivered',
READ = 'read',
FAILED = 'failed',
}
export enum NotificationType {
// Transactional
NEW_ORDER = 'new_order',
ORDER_CONFIRMED = 'order_confirmed',
ORDER_READY = 'order_ready',
ORDER_DELIVERED = 'order_delivered',
ORDER_CANCELLED = 'order_cancelled',
PAYMENT_RECEIVED = 'payment_received',
// Alerts
LOW_STOCK = 'low_stock',
FIADO_REMINDER = 'fiado_reminder',
LARGE_SALE = 'large_sale',
// Reports
DAILY_SUMMARY = 'daily_summary',
WEEKLY_REPORT = 'weekly_report',
// System
WELCOME = 'welcome',
CUSTOM = 'custom',
}
@Entity({ schema: 'notifications', name: 'notifications' })
@Index(['tenantId', 'userId', 'status'])
@Index(['tenantId', 'createdAt'])
export class Notification {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id' })
tenantId: string;
@Column({ name: 'user_id', nullable: true })
userId: string;
@Column({ name: 'customer_id', nullable: true })
customerId: string;
@Column({ type: 'varchar', length: 50 })
type: NotificationType;
@Column({ type: 'varchar', length: 20 })
channel: NotificationChannel;
@Column({ length: 255 })
title: string;
@Column({ type: 'text' })
body: string;
@Column({ type: 'jsonb', nullable: true })
data: Record<string, any>;
@Column({ type: 'varchar', length: 20, default: NotificationStatus.PENDING })
status: NotificationStatus;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string;
@Column({ name: 'retry_count', type: 'int', default: 0 })
retryCount: number;
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
scheduledAt: Date;
@Column({ name: 'sent_at', type: 'timestamptz', nullable: true })
sentAt: Date;
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt: Date;
@Column({ name: 'read_at', type: 'timestamptz', nullable: true })
readAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,95 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { NotificationsService } from './notifications.service';
import {
SendNotificationDto,
RegisterDeviceDto,
UpdatePreferencesDto,
MarkAsReadDto,
NotificationFiltersDto,
} from './dto/notification.dto';
@ApiTags('Notifications')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Post('send')
@ApiOperation({ summary: 'Send a notification' })
async send(@Request() req, @Body() dto: SendNotificationDto) {
return this.notificationsService.send(req.user.tenantId, dto);
}
@Get()
@ApiOperation({ summary: 'Get notification history' })
async getNotifications(@Request() req, @Query() filters: NotificationFiltersDto) {
return this.notificationsService.getNotifications(
req.user.tenantId,
req.user.userId,
filters,
);
}
@Get('unread-count')
@ApiOperation({ summary: 'Get unread notification count' })
async getUnreadCount(@Request() req) {
const count = await this.notificationsService.getUnreadCount(req.user.userId);
return { count };
}
@Put('read')
@ApiOperation({ summary: 'Mark notifications as read' })
async markAsRead(@Request() req, @Body() dto: MarkAsReadDto) {
await this.notificationsService.markAsRead(req.user.userId, dto.notificationIds);
return { success: true };
}
@Put(':id/read')
@ApiOperation({ summary: 'Mark single notification as read' })
async markOneAsRead(@Request() req, @Param('id') id: string) {
await this.notificationsService.markAsRead(req.user.userId, [id]);
return { success: true };
}
// ========== Preferences ==========
@Get('preferences')
@ApiOperation({ summary: 'Get notification preferences' })
async getPreferences(@Request() req) {
return this.notificationsService.getPreferences(req.user.userId);
}
@Put('preferences')
@ApiOperation({ summary: 'Update notification preferences' })
async updatePreferences(@Request() req, @Body() dto: UpdatePreferencesDto) {
return this.notificationsService.updatePreferences(req.user.userId, dto);
}
// ========== Device Tokens ==========
@Post('register-device')
@ApiOperation({ summary: 'Register device for push notifications' })
async registerDevice(@Request() req, @Body() dto: RegisterDeviceDto) {
return this.notificationsService.registerDevice(req.user.userId, dto);
}
@Post('unregister-device')
@ApiOperation({ summary: 'Unregister device from push notifications' })
async unregisterDevice(@Request() req, @Body() body: { token: string }) {
await this.notificationsService.deactivateDevice(req.user.userId, body.token);
return { success: true };
}
}

View File

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { Notification } from './entities/notification.entity';
import { NotificationTemplate } from './entities/notification-template.entity';
import { NotificationPreference } from './entities/notification-preference.entity';
import { DeviceToken } from './entities/device-token.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
Notification,
NotificationTemplate,
NotificationPreference,
DeviceToken,
]),
ConfigModule,
],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@ -0,0 +1,399 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, In, LessThan } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { Notification, NotificationChannel, NotificationStatus, NotificationType } from './entities/notification.entity';
import { NotificationTemplate } from './entities/notification-template.entity';
import { NotificationPreference } from './entities/notification-preference.entity';
import { DeviceToken } from './entities/device-token.entity';
import {
SendNotificationDto,
SendTemplatedNotificationDto,
RegisterDeviceDto,
UpdatePreferencesDto,
NotificationFiltersDto,
} from './dto/notification.dto';
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private readonly whatsappServiceUrl: string;
constructor(
@InjectRepository(Notification)
private readonly notificationRepo: Repository<Notification>,
@InjectRepository(NotificationTemplate)
private readonly templateRepo: Repository<NotificationTemplate>,
@InjectRepository(NotificationPreference)
private readonly preferenceRepo: Repository<NotificationPreference>,
@InjectRepository(DeviceToken)
private readonly deviceTokenRepo: Repository<DeviceToken>,
private readonly configService: ConfigService,
) {
this.whatsappServiceUrl = this.configService.get('WHATSAPP_SERVICE_URL', 'http://localhost:3143');
}
// ========== SEND NOTIFICATIONS ==========
async send(tenantId: string, dto: SendNotificationDto): Promise<Notification> {
// Check preferences
if (dto.userId) {
const canSend = await this.checkPreferences(dto.userId, dto.channel);
if (!canSend) {
this.logger.debug(`Notification blocked by user preferences: ${dto.userId}`);
return null;
}
}
// Create notification record
const notification = this.notificationRepo.create({
tenantId,
userId: dto.userId,
customerId: dto.customerId,
type: dto.type,
channel: dto.channel,
title: dto.title,
body: dto.body,
data: dto.data,
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
status: dto.scheduledAt ? NotificationStatus.PENDING : NotificationStatus.PENDING,
});
await this.notificationRepo.save(notification);
// Send immediately if not scheduled
if (!dto.scheduledAt) {
await this.dispatch(notification, dto.phoneNumber);
}
return notification;
}
async sendTemplated(tenantId: string, dto: SendTemplatedNotificationDto): Promise<Notification> {
// Get template
const template = await this.getTemplate(tenantId, dto.type, dto.channel);
if (!template) {
this.logger.warn(`No template found for type=${dto.type}, channel=${dto.channel}`);
return null;
}
// Interpolate variables
const title = this.interpolate(template.title, dto.variables);
const body = this.interpolate(template.body, dto.variables);
return this.send(tenantId, {
type: dto.type,
channel: template.channel,
userId: dto.userId,
customerId: dto.customerId,
phoneNumber: dto.phoneNumber,
title,
body,
data: dto.data,
});
}
private async dispatch(notification: Notification, phoneNumber?: string): Promise<void> {
try {
switch (notification.channel) {
case NotificationChannel.PUSH:
await this.sendPush(notification);
break;
case NotificationChannel.WHATSAPP:
await this.sendWhatsApp(notification, phoneNumber);
break;
case NotificationChannel.SMS:
await this.sendSms(notification, phoneNumber);
break;
}
notification.status = NotificationStatus.SENT;
notification.sentAt = new Date();
} catch (error) {
this.logger.error(`Failed to dispatch notification ${notification.id}: ${error.message}`);
notification.status = NotificationStatus.FAILED;
notification.errorMessage = error.message;
notification.retryCount++;
// Retry with fallback if WhatsApp fails
if (notification.channel === NotificationChannel.WHATSAPP && notification.retryCount >= 3) {
this.logger.debug('Falling back to SMS');
notification.channel = NotificationChannel.SMS;
notification.retryCount = 0;
await this.notificationRepo.save(notification);
await this.dispatch(notification, phoneNumber);
return;
}
}
await this.notificationRepo.save(notification);
}
private async sendPush(notification: Notification): Promise<void> {
if (!notification.userId) {
throw new Error('Push notification requires userId');
}
const tokens = await this.deviceTokenRepo.find({
where: { userId: notification.userId, active: true },
});
if (tokens.length === 0) {
throw new Error('No active device tokens');
}
// TODO: Integrate with Firebase Admin SDK
// For now, log the push
this.logger.log(`[PUSH] To: ${notification.userId}, Title: ${notification.title}`);
// Update last used
await this.deviceTokenRepo.update(
{ userId: notification.userId, active: true },
{ lastUsedAt: new Date() },
);
}
private async sendWhatsApp(notification: Notification, phoneNumber?: string): Promise<void> {
if (!phoneNumber) {
throw new Error('WhatsApp notification requires phoneNumber');
}
// Call WhatsApp service
await axios.post(`${this.whatsappServiceUrl}/api/send`, {
to: phoneNumber,
message: `*${notification.title}*\n\n${notification.body}`,
tenantId: notification.tenantId,
});
this.logger.log(`[WHATSAPP] To: ${phoneNumber}, Title: ${notification.title}`);
}
private async sendSms(notification: Notification, phoneNumber?: string): Promise<void> {
if (!phoneNumber) {
throw new Error('SMS notification requires phoneNumber');
}
// TODO: Integrate with Twilio
// For now, log the SMS
const message = `${notification.title}: ${notification.body}`.slice(0, 160);
this.logger.log(`[SMS] To: ${phoneNumber}, Message: ${message}`);
}
// ========== TEMPLATES ==========
private async getTemplate(
tenantId: string,
type: NotificationType,
channel?: NotificationChannel,
): Promise<NotificationTemplate | null> {
// First try tenant-specific template
let template = await this.templateRepo.findOne({
where: { tenantId, type, channel, active: true },
});
// Fall back to default template
if (!template) {
template = await this.templateRepo.findOne({
where: { tenantId: null, type, channel, active: true },
});
}
// If no channel specified, try push first, then whatsapp
if (!template && !channel) {
template = await this.getTemplate(tenantId, type, NotificationChannel.PUSH);
if (!template) {
template = await this.getTemplate(tenantId, type, NotificationChannel.WHATSAPP);
}
}
return template;
}
private interpolate(template: string, variables: Record<string, string | number>): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return variables[key]?.toString() || match;
});
}
// ========== PREFERENCES ==========
async getPreferences(userId: string): Promise<NotificationPreference[]> {
return this.preferenceRepo.find({ where: { userId } });
}
async updatePreferences(userId: string, dto: UpdatePreferencesDto): Promise<NotificationPreference> {
let pref = await this.preferenceRepo.findOne({
where: { userId, channel: dto.channel },
});
if (!pref) {
pref = this.preferenceRepo.create({ userId, channel: dto.channel });
}
if (dto.enabled !== undefined) pref.enabled = dto.enabled;
if (dto.quietHoursEnabled !== undefined) pref.quietHoursEnabled = dto.quietHoursEnabled;
if (dto.quietHoursStart) pref.quietHoursStart = dto.quietHoursStart;
if (dto.quietHoursEnd) pref.quietHoursEnd = dto.quietHoursEnd;
return this.preferenceRepo.save(pref);
}
private async checkPreferences(userId: string, channel: NotificationChannel): Promise<boolean> {
const pref = await this.preferenceRepo.findOne({
where: { userId, channel },
});
if (!pref) return true; // No preference = allow
if (!pref.enabled) return false;
// Check quiet hours
if (pref.quietHoursEnabled && pref.quietHoursStart && pref.quietHoursEnd) {
const now = new Date();
const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
// Simple check (doesn't handle midnight crossover perfectly)
if (currentTime >= pref.quietHoursStart || currentTime <= pref.quietHoursEnd) {
return false;
}
}
return true;
}
// ========== DEVICE TOKENS ==========
async registerDevice(userId: string, dto: RegisterDeviceDto): Promise<DeviceToken> {
// Deactivate existing token if it exists
await this.deviceTokenRepo.update(
{ token: dto.token },
{ active: false },
);
// Create or update device token
let device = await this.deviceTokenRepo.findOne({
where: { userId, token: dto.token },
});
if (device) {
device.active = true;
device.lastUsedAt = new Date();
} else {
device = this.deviceTokenRepo.create({
userId,
platform: dto.platform,
token: dto.token,
deviceName: dto.deviceName,
active: true,
});
}
return this.deviceTokenRepo.save(device);
}
async deactivateDevice(userId: string, token: string): Promise<void> {
await this.deviceTokenRepo.update(
{ userId, token },
{ active: false },
);
}
// ========== HISTORY ==========
async getNotifications(
tenantId: string,
userId: string,
filters: NotificationFiltersDto,
): Promise<{ data: Notification[]; total: number }> {
const where: any = { tenantId, userId };
if (filters.status) where.status = filters.status;
if (filters.type) where.type = filters.type;
if (filters.channel) where.channel = filters.channel;
if (filters.fromDate && filters.toDate) {
where.createdAt = Between(new Date(filters.fromDate), new Date(filters.toDate));
}
const page = filters.page || 1;
const limit = filters.limit || 20;
const [data, total] = await this.notificationRepo.findAndCount({
where,
order: { createdAt: 'DESC' },
take: limit,
skip: (page - 1) * limit,
});
return { data, total };
}
async markAsRead(userId: string, notificationIds: string[]): Promise<void> {
await this.notificationRepo.update(
{ id: In(notificationIds), userId },
{ status: NotificationStatus.READ, readAt: new Date() },
);
}
async getUnreadCount(userId: string): Promise<number> {
return this.notificationRepo.count({
where: { userId, status: In([NotificationStatus.SENT, NotificationStatus.DELIVERED]) },
});
}
// ========== ORDER NOTIFICATIONS ==========
async notifyNewOrder(tenantId: string, order: any): Promise<void> {
// Notify owner via push + whatsapp
await this.sendTemplated(tenantId, {
type: NotificationType.NEW_ORDER,
channel: NotificationChannel.PUSH,
variables: {
order_id: order.orderNumber,
customer_name: order.customer?.name || 'Cliente',
total: order.total,
items_count: order.items?.length || 0,
},
data: { orderId: order.id },
});
}
async notifyOrderStatusChange(tenantId: string, order: any, phoneNumber: string): Promise<void> {
const typeMap: Record<string, NotificationType> = {
confirmed: NotificationType.ORDER_CONFIRMED,
ready: NotificationType.ORDER_READY,
delivered: NotificationType.ORDER_DELIVERED,
cancelled: NotificationType.ORDER_CANCELLED,
};
const type = typeMap[order.status];
if (!type) return;
await this.sendTemplated(tenantId, {
type,
channel: NotificationChannel.WHATSAPP,
phoneNumber,
customerId: order.customerId,
variables: {
order_id: order.orderNumber,
total: order.total,
business_name: 'Tu tienda', // TODO: Get from tenant config
},
});
}
// ========== CLEANUP ==========
async archiveOldNotifications(daysOld: number = 30): Promise<number> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - daysOld);
const result = await this.notificationRepo.delete({
createdAt: LessThan(cutoff),
status: In([NotificationStatus.READ, NotificationStatus.FAILED]),
});
return result.affected || 0;
}
}