diff --git a/package-lock.json b/package-lock.json index d1eaead..e803c69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 43ab54f..9b79f69 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.module.ts b/src/app.module.ts index 33fb69a..f42c8ac 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/modules/notifications/dto/notification.dto.ts b/src/modules/notifications/dto/notification.dto.ts new file mode 100644 index 0000000..bd7b3db --- /dev/null +++ b/src/modules/notifications/dto/notification.dto.ts @@ -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; + + @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; + + @IsOptional() + @IsObject() + data?: Record; +} + +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; +} diff --git a/src/modules/notifications/entities/device-token.entity.ts b/src/modules/notifications/entities/device-token.entity.ts new file mode 100644 index 0000000..29872b2 --- /dev/null +++ b/src/modules/notifications/entities/device-token.entity.ts @@ -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; +} diff --git a/src/modules/notifications/entities/index.ts b/src/modules/notifications/entities/index.ts new file mode 100644 index 0000000..3164718 --- /dev/null +++ b/src/modules/notifications/entities/index.ts @@ -0,0 +1,4 @@ +export * from './notification.entity'; +export * from './notification-template.entity'; +export * from './notification-preference.entity'; +export * from './device-token.entity'; diff --git a/src/modules/notifications/entities/notification-preference.entity.ts b/src/modules/notifications/entities/notification-preference.entity.ts new file mode 100644 index 0000000..e63c402 --- /dev/null +++ b/src/modules/notifications/entities/notification-preference.entity.ts @@ -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; +} diff --git a/src/modules/notifications/entities/notification-template.entity.ts b/src/modules/notifications/entities/notification-template.entity.ts new file mode 100644 index 0000000..f4df0cc --- /dev/null +++ b/src/modules/notifications/entities/notification-template.entity.ts @@ -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; +} diff --git a/src/modules/notifications/entities/notification.entity.ts b/src/modules/notifications/entities/notification.entity.ts new file mode 100644 index 0000000..61894c8 --- /dev/null +++ b/src/modules/notifications/entities/notification.entity.ts @@ -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; + + @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; +} diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..20b7071 --- /dev/null +++ b/src/modules/notifications/notifications.controller.ts @@ -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 }; + } +} diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..582c502 --- /dev/null +++ b/src/modules/notifications/notifications.module.ts @@ -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 {} diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..fdf2256 --- /dev/null +++ b/src/modules/notifications/notifications.service.ts @@ -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, + @InjectRepository(NotificationTemplate) + private readonly templateRepo: Repository, + @InjectRepository(NotificationPreference) + private readonly preferenceRepo: Repository, + @InjectRepository(DeviceToken) + private readonly deviceTokenRepo: Repository, + private readonly configService: ConfigService, + ) { + this.whatsappServiceUrl = this.configService.get('WHATSAPP_SERVICE_URL', 'http://localhost:3143'); + } + + // ========== SEND NOTIFICATIONS ========== + + async send(tenantId: string, dto: SendNotificationDto): Promise { + // 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + return template.replace(/\{\{(\w+)\}\}/g, (match, key) => { + return variables[key]?.toString() || match; + }); + } + + // ========== PREFERENCES ========== + + async getPreferences(userId: string): Promise { + return this.preferenceRepo.find({ where: { userId } }); + } + + async updatePreferences(userId: string, dto: UpdatePreferencesDto): Promise { + 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 { + 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 { + // 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 { + 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 { + await this.notificationRepo.update( + { id: In(notificationIds), userId }, + { status: NotificationStatus.READ, readAt: new Date() }, + ); + } + + async getUnreadCount(userId: string): Promise { + return this.notificationRepo.count({ + where: { userId, status: In([NotificationStatus.SENT, NotificationStatus.DELIVERED]) }, + }); + } + + // ========== ORDER NOTIFICATIONS ========== + + async notifyNewOrder(tenantId: string, order: any): Promise { + // 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 { + const typeMap: Record = { + 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 { + 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; + } +}