feat(c6): notificações e push — Web Push VAPID, badge dinâmico, Share API

FR-6.1/6.2: Sandra recebe push quando pedido entra em pending_approval;
Rafael recebe quando pedido é aprovado ou recusado. Service worker registrado
em background (PWA-ready via public/sw.js).

FR-6.3: Badge na Topbar busca GET /notifications/pending-count (supervisores
veem count de pending_approval; reps veem 0). Intervalo de 30s.

FR-6.4: Botão Compartilhar no OrderDetailPage para pedidos approved/invoiced
(apenas reps). Usa navigator.share() com texto formatado para WhatsApp.

Infra: modelo PushSubscription (Prisma), NotificationsModule (subscribe/
unsubscribe/pending-count + PushService VAPID), VAPID keys em .env,
integração no OrdersService (create → supervisores, approve/reject → repId).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 12:31:13 +00:00
parent e3587e680a
commit a1a852c44d
22 changed files with 522 additions and 18 deletions

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],

View File

@@ -11,6 +11,7 @@ import type {
RejectOrder,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { NotificationsService } from '../notifications/notifications.service';
function decimalToString(v: Prisma.Decimal | null | undefined): string {
return v ? v.toString() : '0';
@@ -18,7 +19,10 @@ function decimalToString(v: Prisma.Decimal | null | undefined): string {
@Injectable()
export class OrdersService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
constructor(
private readonly cls: ClsService<WorkspaceClsStore>,
private readonly notifications: NotificationsService,
) {}
async list(query: OrderListQuery, userId: string, role: string): Promise<OrderListResponse> {
const prisma = this.cls.get('prisma');
@@ -252,7 +256,13 @@ export class OrdersService {
});
if (status === OrderStatus.pending_approval) {
// Buscar order com history atualizado
// FR-6.1: notifica supervisores que há pedido aguardando aprovação
void this.notifications.notifySupervisors({
title: 'Pedido aguardando aprovação',
body: `${order.client.name}${order.number} — R$ ${order.total.toFixed(2).replace('.', ',')}`,
url: `/pedidos/${order.id}`,
});
const updated = await prisma.order.findUniqueOrThrow({
where: { id: order.id },
include: {
@@ -322,6 +332,14 @@ export class OrdersService {
history: { orderBy: { changedAt: 'asc' } },
},
});
// FR-6.1: notifica o rep que o pedido foi aprovado
void this.notifications.notifyUser(order.repId, {
title: 'Pedido aprovado',
body: `${final.number}${final.client.name} aprovado${dto.discountPct !== undefined ? ` com ${newDiscountPct}% de desconto` : ''}`,
url: `/pedidos/${id}`,
});
return this.mapDetail(final);
}
@@ -370,6 +388,14 @@ export class OrdersService {
history: { orderBy: { changedAt: 'asc' } },
},
});
// FR-6.1: notifica o rep que o pedido foi recusado
void this.notifications.notifyUser(order.repId, {
title: 'Pedido recusado',
body: `${final.number}${final.client.name}: ${dto.reason}`,
url: `/pedidos/${id}`,
});
return this.mapDetail(final);
}