Etapa 7 — Motor de alertas (BullMQ)

Adiciona um processo worker separado do processo web (src/worker.ts,
WorkerModule) com dois crons diários e processors do BullMQ para alertas
de mensalidade (master→tenant) e cobrança (tenant→customer): marca
faturas/parcelas atrasadas, resolve template com fallback tenant→master
por canal, renderiza placeholders, dispara WhatsApp/e-mail com retry e
backoff exponencial, e grava alert_logs com idempotência por dia.

Módulo alerts (web) traz o CRUD de alert_templates (master e tenant) e a
consulta de alert_logs com filtros. alert_templates/alert_logs não têm
RLS no schema, então o isolamento entre tenants é feito manualmente
(PrismaMasterService + filtro por tenant_id), coberto por teste e2e
dedicado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 22:50:59 -03:00
co-authored by Claude Sonnet 5
parent 7daaa16935
commit d6d7d5fa61
37 changed files with 2661 additions and 19 deletions
@@ -0,0 +1,93 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { writeAuditLog } from '../../common/audit-log.util';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateMasterAlertTemplateDto } from './dto/create-master-alert-template.dto';
import { ListAlertTemplatesQueryDto } from './dto/list-alert-templates.query.dto';
import { UpdateMasterAlertTemplateDto } from './dto/update-master-alert-template.dto';
@Injectable()
export class MasterAlertTemplatesService {
constructor(private readonly prismaMaster: PrismaMasterService) {}
async create(dto: CreateMasterAlertTemplateDto, actor: JwtPayload, ip?: string) {
return this.prismaMaster.$transaction(async (tx) => {
const template = await tx.alert_templates.create({ data: { ...dto, tenant_id: null } });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'criar_alert_template_master',
entidade: 'alert_templates',
entidade_id: template.id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return template;
});
}
findAll(query: ListAlertTemplatesQueryDto) {
return this.prismaMaster.alert_templates.findMany({
where: {
tenant_id: null,
...(query.tipo ? { tipo: query.tipo } : {}),
...(query.canal ? { canal: query.canal } : {}),
...(query.ativo === undefined ? {} : { ativo: query.ativo }),
},
orderBy: { criado_em: 'desc' },
});
}
async findOne(id: string) {
const template = await this.prismaMaster.alert_templates.findFirst({
where: { id, tenant_id: null },
});
if (!template) {
throw new NotFoundException('Template não encontrado');
}
return template;
}
async update(id: string, dto: UpdateMasterAlertTemplateDto, actor: JwtPayload, ip?: string) {
await this.findOne(id);
return this.prismaMaster.$transaction(async (tx) => {
const atualizado = await tx.alert_templates.update({ where: { id }, data: dto });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'atualizar_alert_template_master',
entidade: 'alert_templates',
entidade_id: id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return atualizado;
});
}
/** Soft delete — `alert_logs.template_id` referencia o template sem CASCADE; exclusão real quebraria o histórico. */
async remove(id: string, actor: JwtPayload, ip?: string) {
await this.findOne(id);
return this.prismaMaster.$transaction(async (tx) => {
const desativado = await tx.alert_templates.update({ where: { id }, data: { ativo: false } });
await writeAuditLog(tx, {
usuario_tipo: 'master',
usuario_id: actor.sub,
acao: 'desativar_alert_template_master',
entidade: 'alert_templates',
entidade_id: id,
ip_origem: ip,
});
return desativado;
});
}
}