29 KiB
29 KiB
ET-BACKEND-MGN-014-002: Notificaciones In-App y Email
RF Asociado: RF-MGN-014-002 Módulo: MGN-014 Complejidad: Media Story Points: 4 SP (Backend) Estado: Diseñado Fecha: 2025-11-24
Resumen Técnico
Backend API para notificaciones in-app y email. Implementa endpoints RESTful con NestJS, validaciones de negocio, persistencia con Prisma ORM y soporte multi-tenancy con Row Level Security (RLS).
Stack Tecnológico
- Runtime: Node.js 20 LTS
- Framework: NestJS 10.x
- ORM: Prisma 5.x (con multi-schema support)
- Validación: class-validator + class-transformer
- Autenticación: JWT (Passport.js)
- Base de Datos: PostgreSQL 16 (multi-schema + RLS)
- Testing: Jest + Supertest
- Documentación: Swagger/OpenAPI 3.0
Arquitectura del Endpoint
API Endpoints
POST /api/v1/notifications
// Request
POST /api/v1/notifications
Authorization: Bearer <JWT>
Content-Type: application/json
{
// Payload según RF
}
// Response 201 Created
{
"data": { ... },
"message": "NotificacionesIn-appEmail creado exitosamente",
"timestamp": "2025-11-24T..."
}
// Response 400 Bad Request
{
"error": "ValidationError",
"message": "Datos inválidos",
"details": [...]
}
// Response 403 Forbidden
{
"error": "ForbiddenError",
"message": "Sin permisos para crear notificacionesin-appemail"
}
GET /api/v1/notifications
// Request
GET /api/v1/notifications?page=1&limit=20&sortBy=createdAt&sortOrder=desc
Authorization: Bearer <JWT>
// Response 200 OK
{
"data": [...],
"meta": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5
}
}
GET /api/v1/notifications/:id
// Request
GET /api/v1/notifications/{id}
Authorization: Bearer <JWT>
// Response 200 OK
{
"data": { ... }
}
// Response 404 Not Found
{
"error": "NotFoundError",
"message": "NotificacionesIn-appEmail no encontrado"
}
PUT /api/v1/notifications/:id
// Request
PUT /api/v1/notifications/{id}
Authorization: Bearer <JWT>
Content-Type: application/json
{
// Payload de actualización
}
// Response 200 OK
{
"data": { ... },
"message": "NotificacionesIn-appEmail actualizado exitosamente"
}
DELETE /api/v1/notifications/:id
// Request
DELETE /api/v1/notifications/{id}
Authorization: Bearer <JWT>
// Response 200 OK (soft delete)
{
"message": "NotificacionesIn-appEmail eliminado exitosamente"
}
// Response 409 Conflict
{
"error": "ConflictError",
"message": "No se puede eliminar: tiene registros relacionados"
}
Modelo de Datos (Prisma Schema)
// Basado en el DDL SQL: system-schema-ddl.sql
model NotificacionesIn-appEmail {
id String @id @default(uuid())
tenantId String @map("tenant_id")
// Campos específicos del negocio
name String @db.VarChar(255)
code String? @db.VarChar(50)
// Auditoría
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by")
updatedAt DateTime? @updatedAt @map("updated_at")
updatedBy String? @map("updated_by")
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by")
// Relations
tenant Tenant @relation(fields: [tenantId], references: [id])
@@map("system.notification_preferences")
@@index([tenantId])
@@unique([tenantId, code])
}
DTOs (Data Transfer Objects)
Create DTO
// src/modules/mgn-014/dto/create-notificacionesin-appemail.dto.ts
import { IsString, IsEmail, IsOptional, MinLength, MaxLength, IsUUID } from 'class-validator';
export class CreateNotificacionesIn-appEmailDto {
@IsString()
@MinLength(3)
@MaxLength(255)
name: string;
@IsString()
@IsOptional()
@MaxLength(50)
code?: string;
// Campos adicionales según RF
}
Update DTO
// src/modules/mgn-014/dto/update-notificacionesin-appemail.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateNotificacionesIn-appEmailDto } from './create-notificacionesin-appemail.dto';
export class UpdateNotificacionesIn-appEmailDto extends PartialType(CreateNotificacionesIn-appEmailDto) {}
Response DTO
// src/modules/mgn-014/dto/notificacionesin-appemail-response.dto.ts
export class NotificacionesIn-appEmailResponseDto {
id: string;
tenantId: string;
name: string;
code?: string;
createdAt: Date;
updatedAt?: Date;
}
Filter DTO
// src/modules/mgn-014/dto/filter-notificacionesin-appemail.dto.ts
import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class FilterNotificacionesIn-appEmailDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@IsOptional()
@IsString()
sortBy?: string = 'createdAt';
@IsOptional()
@IsString()
sortOrder?: 'asc' | 'desc' = 'desc';
}
Servicios de Negocio
Service Principal
// src/modules/mgn-014/services/notificacionesin-appemail.service.ts
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import { PrismaService } from '@shared/prisma/prisma.service';
import { CreateNotificacionesIn-appEmailDto, UpdateNotificacionesIn-appEmailDto, FilterNotificacionesIn-appEmailDto } from '../dto';
@Injectable()
export class NotificacionesIn-appEmailService {
constructor(private readonly prisma: PrismaService) {}
async create(tenantId: string, userId: string, dto: CreateNotificacionesIn-appEmailDto) {
// 1. Validaciones de negocio
await this.validateBusinessRules(tenantId, dto);
// 2. Verificar duplicados
if (dto.code) {
const existing = await this.prisma.notificacionesin-appemail.findFirst({
where: {
tenantId,
code: dto.code,
deletedAt: null,
},
});
if (existing) {
throw new ConflictException(`NotificacionesIn-appEmail con código ${dto.code} ya existe`);
}
}
// 3. Crear en BD (RLS automático por tenantId)
const entity = await this.prisma.notificacionesin-appemail.create({
data: {
tenantId,
createdBy: userId,
...dto,
},
});
// 4. Eventos/side effects (si aplica)
// await this.eventEmitter.emit('notificacionesin-appemail.created', entity);
return entity;
}
async findAll(tenantId: string, filters: FilterNotificacionesIn-appEmailDto) {
const { page, limit, sortBy, sortOrder, search } = filters;
const skip = (page - 1) * limit;
const where = {
tenantId,
deletedAt: null,
...(search && {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ code: { contains: search, mode: 'insensitive' } },
],
}),
};
const [data, total] = await Promise.all([
this.prisma.notificacionesin-appemail.findMany({
where,
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
}),
this.prisma.notificacionesin-appemail.count({ where }),
]);
return {
data,
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
async findOne(tenantId: string, id: string) {
const entity = await this.prisma.notificacionesin-appemail.findFirst({
where: {
id,
tenantId,
deletedAt: null,
},
});
if (!entity) {
throw new NotFoundException(`NotificacionesIn-appEmail con ID ${id} no encontrado`);
}
return entity;
}
async update(tenantId: string, userId: string, id: string, dto: UpdateNotificacionesIn-appEmailDto) {
// 1. Verificar existencia
await this.findOne(tenantId, id);
// 2. Validar cambios
await this.validateBusinessRules(tenantId, dto, id);
// 3. Actualizar
const entity = await this.prisma.notificacionesin-appemail.update({
where: { id },
data: {
...dto,
updatedBy: userId,
updatedAt: new Date(),
},
});
// 4. Eventos
// await this.eventEmitter.emit('notificacionesin-appemail.updated', entity);
return entity;
}
async remove(tenantId: string, userId: string, id: string) {
// 1. Verificar existencia
await this.findOne(tenantId, id);
// 2. Verificar dependencias (opcional)
// await this.checkDependencies(id);
// 3. Soft delete
await this.prisma.notificacionesin-appemail.update({
where: { id },
data: {
deletedAt: new Date(),
deletedBy: userId,
},
});
// 4. Eventos
// await this.eventEmitter.emit('notificacionesin-appemail.deleted', { id });
}
private async validateBusinessRules(
tenantId: string,
dto: Partial<CreateNotificacionesIn-appEmailDto>,
excludeId?: string,
) {
// Implementar reglas de negocio del RF
// Ejemplo:
// if (dto.field && !await this.isValidField(dto.field)) {
// throw new BadRequestException('Field inválido');
// }
}
}
Controller
// src/modules/mgn-014/controllers/notificacionesin-appemail.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { PermissionsGuard } from '@modules/auth/guards/permissions.guard';
import { RequirePermissions } from '@shared/decorators/require-permissions.decorator';
import { CurrentTenant } from '@shared/decorators/current-tenant.decorator';
import { CurrentUser } from '@shared/decorators/current-user.decorator';
import { NotificacionesIn-appEmailService } from '../services/notificacionesin-appemail.service';
import { CreateNotificacionesIn-appEmailDto, UpdateNotificacionesIn-appEmailDto, FilterNotificacionesIn-appEmailDto, NotificacionesIn-appEmailResponseDto } from '../dto';
@ApiTags('NotificacionesIn-appEmail')
@ApiBearerAuth()
@Controller('api/v1/notifications')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class NotificacionesIn-appEmailController {
constructor(private readonly service: NotificacionesIn-appEmailService) {}
@Post()
@RequirePermissions('mgn-014.notificacionesin-appemail.create')
@ApiOperation({ summary: 'Crear notificacionesin-appemail' })
@ApiResponse({ status: 201, description: 'NotificacionesIn-appEmail creado exitosamente', type: NotificacionesIn-appEmailResponseDto })
@ApiResponse({ status: 400, description: 'Datos inválidos' })
@ApiResponse({ status: 403, description: 'Sin permisos' })
async create(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Body() dto: CreateNotificacionesIn-appEmailDto,
) {
const entity = await this.service.create(tenantId, userId, dto);
return {
data: entity,
message: 'NotificacionesIn-appEmail creado exitosamente',
};
}
@Get()
@RequirePermissions('mgn-014.notificacionesin-appemail.read')
@ApiOperation({ summary: 'Listar notificacionesin-appemails' })
@ApiResponse({ status: 200, description: 'Lista de notificacionesin-appemails', type: [NotificacionesIn-appEmailResponseDto] })
async findAll(
@CurrentTenant() tenantId: string,
@Query() filters: FilterNotificacionesIn-appEmailDto,
) {
return this.service.findAll(tenantId, filters);
}
@Get(':id')
@RequirePermissions('mgn-014.notificacionesin-appemail.read')
@ApiOperation({ summary: 'Obtener notificacionesin-appemail por ID' })
@ApiResponse({ status: 200, description: 'NotificacionesIn-appEmail encontrado', type: NotificacionesIn-appEmailResponseDto })
@ApiResponse({ status: 404, description: 'NotificacionesIn-appEmail no encontrado' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id') id: string,
) {
const entity = await this.service.findOne(tenantId, id);
return { data: entity };
}
@Put(':id')
@RequirePermissions('mgn-014.notificacionesin-appemail.update')
@ApiOperation({ summary: 'Actualizar notificacionesin-appemail' })
@ApiResponse({ status: 200, description: 'NotificacionesIn-appEmail actualizado exitosamente' })
@ApiResponse({ status: 404, description: 'NotificacionesIn-appEmail no encontrado' })
async update(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Param('id') id: string,
@Body() dto: UpdateNotificacionesIn-appEmailDto,
) {
const entity = await this.service.update(tenantId, userId, id, dto);
return {
data: entity,
message: 'NotificacionesIn-appEmail actualizado exitosamente',
};
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@RequirePermissions('mgn-014.notificacionesin-appemail.delete')
@ApiOperation({ summary: 'Eliminar notificacionesin-appemail' })
@ApiResponse({ status: 204, description: 'NotificacionesIn-appEmail eliminado exitosamente' })
@ApiResponse({ status: 404, description: 'NotificacionesIn-appEmail no encontrado' })
async remove(
@CurrentTenant() tenantId: string,
@CurrentUser('id') userId: string,
@Param('id') id: string,
) {
await this.service.remove(tenantId, userId, id);
}
}
Reglas de Negocio (Implementación)
RN-1: Validaciones de integridad según modelo de datos
private async validateRN1(dto: Partial<CreateNotificacionesIn-appEmailDto>): Promise<void> {
// Implementación de la regla: Validaciones de integridad según modelo de datos
// TODO: Implementar validación específica
}
RN-2: RLS filtra registros por tenant y empresa
private async validateRN2(dto: Partial<CreateNotificacionesIn-appEmailDto>): Promise<void> {
// Implementación de la regla: RLS filtra registros por tenant y empresa
// TODO: Implementar validación específica
}
RN-3: Cambios quedan registrados en audit log
private async validateRN3(dto: Partial<CreateNotificacionesIn-appEmailDto>): Promise<void> {
// Implementación de la regla: Cambios quedan registrados en audit log
// TODO: Implementar validación específica
}
RN-4: Notificaciones según configuración de usuario
private async validateRN4(dto: Partial<CreateNotificacionesIn-appEmailDto>): Promise<void> {
// Implementación de la regla: Notificaciones según configuración de usuario
// TODO: Implementar validación específica
}
Seguridad
Multi-Tenancy (RLS)
- Prisma middleware automático que agrega
tenantIda todas las queries - Row Level Security en PostgreSQL valida acceso
- Soft delete: usa
deletedAten lugar de borrado físico
Permisos (RBAC)
@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequirePermissions('mgn-014.notificacionesin-appemail.create')
@Post()
async create(...) { ... }
Permisos requeridos:
mgn-014.notificacionesin-appemail.create- Crear registrosmgn-014.notificacionesin-appemail.read- Leer registrosmgn-014.notificacionesin-appemail.update- Actualizar registrosmgn-014.notificacionesin-appemail.delete- Eliminar registros
Validación de Input
- DTOs con class-validator para validación automática
- Sanitización de strings (XSS prevention)
- Rate limiting: 100 requests/min por usuario
- SQL Injection prevention: Prisma ORM con prepared statements
Auditoría
Todos los cambios registran:
createdBy/updatedBy/deletedBy- Usuario que ejecuta accióncreatedAt/updatedAt/deletedAt- Timestamp de acción- Integración con MGN-014 (Tracking Automático de Cambios)
Testing
Unit Tests
// notificacionesin-appemail.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { NotificacionesIn-appEmailService } from './notificacionesin-appemail.service';
import { PrismaService } from '@shared/prisma/prisma.service';
import { NotFoundException, ConflictException } from '@nestjs/common';
describe('NotificacionesIn-appEmailService', () => {
let service: NotificacionesIn-appEmailService;
let prisma: PrismaService;
const mockPrisma = {
notificacionesin-appemail: {
create: jest.fn(),
findMany: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
NotificacionesIn-appEmailService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<NotificacionesIn-appEmailService>(NotificacionesIn-appEmailService);
prisma = module.get<PrismaService>(PrismaService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
it('should create notificacionesin-appemail with valid data', async () => {
const tenantId = 'tenant-123';
const userId = 'user-456';
const dto = { name: 'Test NotificacionesIn-appEmail', code: 'TEST' };
const expected = { id: 'id-789', tenantId, ...dto };
mockPrisma.notificacionesin-appemail.findFirst.mockResolvedValue(null);
mockPrisma.notificacionesin-appemail.create.mockResolvedValue(expected);
const result = await service.create(tenantId, userId, dto);
expect(result).toEqual(expected);
expect(mockPrisma.notificacionesin-appemail.create).toHaveBeenCalledWith({
data: { tenantId, createdBy: userId, ...dto },
});
});
it('should throw ConflictException when code already exists', async () => {
const tenantId = 'tenant-123';
const userId = 'user-456';
const dto = { name: 'Test NotificacionesIn-appEmail', code: 'DUPLICATE' };
mockPrisma.notificacionesin-appemail.findFirst.mockResolvedValue({ id: 'existing' });
await expect(service.create(tenantId, userId, dto)).rejects.toThrow(ConflictException);
});
});
describe('findOne', () => {
it('should return notificacionesin-appemail when found', async () => {
const tenantId = 'tenant-123';
const id = 'id-789';
const expected = { id, tenantId, name: 'Test' };
mockPrisma.notificacionesin-appemail.findFirst.mockResolvedValue(expected);
const result = await service.findOne(tenantId, id);
expect(result).toEqual(expected);
});
it('should throw NotFoundException when not found', async () => {
const tenantId = 'tenant-123';
const id = 'non-existent';
mockPrisma.notificacionesin-appemail.findFirst.mockResolvedValue(null);
await expect(service.findOne(tenantId, id)).rejects.toThrow(NotFoundException);
});
});
describe('update', () => {
it('should update notificacionesin-appemail successfully', async () => {
const tenantId = 'tenant-123';
const userId = 'user-456';
const id = 'id-789';
const dto = { name: 'Updated Name' };
const existing = { id, tenantId, name: 'Old Name' };
const expected = { ...existing, ...dto };
mockPrisma.notificacionesin-appemail.findFirst.mockResolvedValue(existing);
mockPrisma.notificacionesin-appemail.update.mockResolvedValue(expected);
const result = await service.update(tenantId, userId, id, dto);
expect(result).toEqual(expected);
});
});
describe('remove', () => {
it('should soft delete notificacionesin-appemail', async () => {
const tenantId = 'tenant-123';
const userId = 'user-456';
const id = 'id-789';
const existing = { id, tenantId, name: 'To Delete' };
mockPrisma.notificacionesin-appemail.findFirst.mockResolvedValue(existing);
mockPrisma.notificacionesin-appemail.update.mockResolvedValue({ ...existing, deletedAt: new Date() });
await service.remove(tenantId, userId, id);
expect(mockPrisma.notificacionesin-appemail.update).toHaveBeenCalledWith({
where: { id },
data: {
deletedAt: expect.any(Date),
deletedBy: userId,
},
});
});
});
});
Integration Tests (e2e)
// notificacionesin-appemail.controller.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '@/app.module';
describe('NotificacionesIn-appEmailController (e2e)', () => {
let app: INestApplication;
let authToken: string;
let tenantId: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
// Login para obtener token
const loginResponse = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: 'test@example.com', password: 'Test1234!' });
authToken = loginResponse.body.data.accessToken;
tenantId = loginResponse.body.data.user.tenantId;
});
afterAll(async () => {
await app.close();
});
describe('POST /api/v1/notifications', () => {
it('should create notificacionesin-appemail successfully', () => {
return request(app.getHttpServer())
.post('/api/v1/notifications')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Test NotificacionesIn-appEmail',
code: 'TEST001',
})
.expect(201)
.expect((res) => {
expect(res.body.data).toHaveProperty('id');
expect(res.body.data.name).toBe('Test NotificacionesIn-appEmail');
expect(res.body.message).toContain('creado exitosamente');
});
});
it('should return 400 for invalid data', () => {
return request(app.getHttpServer())
.post('/api/v1/notifications')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: '' }) // Invalid: name too short
.expect(400);
});
it('should return 401 without token', () => {
return request(app.getHttpServer())
.post('/api/v1/notifications')
.send({ name: 'Test' })
.expect(401);
});
});
describe('GET /api/v1/notifications', () => {
it('should return list of notificacionesin-appemails', () => {
return request(app.getHttpServer())
.get('/api/v1/notifications')
.set('Authorization', `Bearer ${authToken}`)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeInstanceOf(Array);
expect(res.body.meta).toHaveProperty('page');
expect(res.body.meta).toHaveProperty('total');
});
});
it('should filter by search term', () => {
return request(app.getHttpServer())
.get('/api/v1/notifications?search=test')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
});
});
describe('GET /api/v1/notifications/:id', () => {
it('should return notificacionesin-appemail by ID', async () => {
// Crear primero
const createRes = await request(app.getHttpServer())
.post('/api/v1/notifications')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Find By ID Test' });
const id = createRes.body.data.id;
return request(app.getHttpServer())
.get(`/api/v1/notifications/${id}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200)
.expect((res) => {
expect(res.body.data.id).toBe(id);
});
});
it('should return 404 for non-existent ID', () => {
return request(app.getHttpServer())
.get('/api/v1/notifications/non-existent-id')
.set('Authorization', `Bearer ${authToken}`)
.expect(404);
});
});
describe('PUT /api/v1/notifications/:id', () => {
it('should update notificacionesin-appemail successfully', async () => {
// Crear primero
const createRes = await request(app.getHttpServer())
.post('/api/v1/notifications')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Original Name' });
const id = createRes.body.data.id;
return request(app.getHttpServer())
.put(`/api/v1/notifications/${id}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Updated Name' })
.expect(200)
.expect((res) => {
expect(res.body.data.name).toBe('Updated Name');
});
});
});
describe('DELETE /api/v1/notifications/:id', () => {
it('should soft delete notificacionesin-appemail', async () => {
// Crear primero
const createRes = await request(app.getHttpServer())
.post('/api/v1/notifications')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'To Delete' });
const id = createRes.body.data.id;
await request(app.getHttpServer())
.delete(`/api/v1/notifications/${id}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(204);
// Verificar que no aparece en listados
const listRes = await request(app.getHttpServer())
.get('/api/v1/notifications')
.set('Authorization', `Bearer ${authToken}`);
const deleted = listRes.body.data.find(item => item.id === id);
expect(deleted).toBeUndefined();
});
});
});
Performance
Índices Necesarios
-- Índices definidos en system-schema-ddl.sql
CREATE INDEX idx_notificacionesin-appemail_tenant_code ON system.notificacionesin-appemail(tenant_id, code);
CREATE INDEX idx_notificacionesin-appemail_tenant_name ON system.notificacionesin-appemail(tenant_id, name);
CREATE INDEX idx_notificacionesin-appemail_created_at ON system.notificacionesin-appemail(created_at DESC);
-- Para soft delete
CREATE INDEX idx_notificacionesin-appemail_deleted_at ON system.notificacionesin-appemail(deleted_at) WHERE deleted_at IS NULL;
Caching (Redis)
// Para listas maestras estáticas
@Cacheable({ ttl: 3600 }) // 1 hora
async findAll(tenantId: string, filters: FilterNotificacionesIn-appEmailDto) {
// ...
}
// Invalidación en cambios
@CacheEvict({ pattern: 'notificacionesin-appemail:*' })
async update(...) { ... }
@CacheEvict({ pattern: 'notificacionesin-appemail:*' })
async remove(...) { ... }
Paginación
- Default: 20 items por página
- Máximo: 100 items por página
- Cursor-based: Para listados grandes (opcional)
Query Optimization
// Usar select para reducir payload
async findAll(tenantId: string, filters: FilterNotificacionesIn-appEmailDto) {
return this.prisma.notificacionesin-appemail.findMany({
where: { ... },
select: {
id: true,
name: true,
code: true,
createdAt: true,
// Omitir campos pesados
},
});
}
Logging y Monitoreo
import { Logger } from '@nestjs/common';
export class NotificacionesIn-appEmailService {
private readonly logger = new Logger(NotificacionesIn-appEmailService.name);
async create(tenantId: string, userId: string, dto: CreateNotificacionesIn-appEmailDto) {
this.logger.log({
action: 'notificacionesin-appemail.create',
tenantId,
userId,
data: dto,
});
try {
const entity = await this.prisma.notificacionesin-appemail.create({ ... });
this.logger.log({
action: 'notificacionesin-appemail.created',
tenantId,
userId,
entityId: entity.id,
});
return entity;
} catch (error) {
this.logger.error({
action: 'notificacionesin-appemail.create.error',
tenantId,
userId,
error: error.message,
stack: error.stack,
});
throw error;
}
}
}
Métricas (Prometheus)
http_requests_total{method="POST",endpoint="/api/v1/notifications",status="201"}http_request_duration_seconds{endpoint="/api/v1/notifications"}notificacionesin-appemail_created_total{tenant_id="xxx"}
Referencias
Dependencias
Módulos NestJS
AuthModule- Autenticación y autorizaciónPrismaModule- ORM y acceso a BDLoggerModule- Logging centralizadoEventsModule- Event emitter (opcional)
RF Bloqueantes
- RF-MGN-001-001 (Autenticación de Usuarios)
- RF-MGN-001-004 (Multi-Tenancy)
RF Dependientes
- [Listar RF que dependen de este]
Notas de Implementación
- Crear módulo NestJS:
nest g module mgn-014/notificacionesin-appemail - Crear service:
nest g service mgn-014/notificacionesin-appemail - Crear controller:
nest g controller mgn-014/notificacionesin-appemail - Crear DTOs en
mgn-014/dto/folder - Definir Prisma model en
schema.prisma - Implementar unit tests (>80% coverage)
- Implementar e2e tests
- Documentar endpoints en Swagger
- Validar con criterios de aceptación del RF
- Code review por Tech Lead
- QA testing
Estimación
- Backend Development: 4 SP
- Testing (Unit + e2e): 2 SP
- Code Review + QA: 1 SP
- Total: 8 SP
Documento generado: 2025-11-24 Versión: 1.0 Estado: Diseñado Próximo paso: Implementación