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:
parent
4fcdd30812
commit
75e881e1cc
47
package-lock.json
generated
47
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {}
|
||||
|
||||
132
src/modules/notifications/dto/notification.dto.ts
Normal file
132
src/modules/notifications/dto/notification.dto.ts
Normal 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;
|
||||
}
|
||||
46
src/modules/notifications/entities/device-token.entity.ts
Normal file
46
src/modules/notifications/entities/device-token.entity.ts
Normal 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;
|
||||
}
|
||||
4
src/modules/notifications/entities/index.ts
Normal file
4
src/modules/notifications/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './notification.entity';
|
||||
export * from './notification-template.entity';
|
||||
export * from './notification-preference.entity';
|
||||
export * from './device-token.entity';
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
101
src/modules/notifications/entities/notification.entity.ts
Normal file
101
src/modules/notifications/entities/notification.entity.ts
Normal 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;
|
||||
}
|
||||
95
src/modules/notifications/notifications.controller.ts
Normal file
95
src/modules/notifications/notifications.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
26
src/modules/notifications/notifications.module.ts
Normal file
26
src/modules/notifications/notifications.module.ts
Normal 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 {}
|
||||
399
src/modules/notifications/notifications.service.ts
Normal file
399
src/modules/notifications/notifications.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user