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

@@ -5,5 +5,11 @@
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)",
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"web-push": "^3.6.7"
},
"devDependencies": {
"@types/web-push": "^3.6.4"
}
}

View File

@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" UUID NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
-- CreateIndex
CREATE INDEX "PushSubscription_role_idx" ON "PushSubscription"("role");

View File

@@ -208,6 +208,25 @@ model RepDiscountLimit {
@@index([repId])
}
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
//
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
// role desnormalizado do JWT para filtrar destinatários (notifySupervisors, notifyUser).
model PushSubscription {
id String @id @default(uuid()) @db.Uuid
userId String
role String // 'rep' | 'supervisor' | 'manager' | 'admin'
endpoint String @unique
p256dh String
auth String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([role])
}
// ─── OrderStatusHistory (C3) ─────────────────────────────────────────────────
//
// Registro imutável de cada transição de status. changedById = userId do ator.

View File

@@ -12,6 +12,7 @@ import { ClientsModule } from './clients/clients.module';
import { OrdersModule } from './orders/orders.module';
import { CatalogModule } from './catalog/catalog.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { NotificationsModule } from './notifications/notifications.module';
import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
@@ -27,6 +28,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
OrdersModule,
CatalogModule,
DashboardModule,
NotificationsModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -13,7 +13,10 @@ export const EnvSchema = z
API_PORT: z.coerce.number().int().positive().default(3000),
API_HOST: z.string().min(1).default('0.0.0.0'),
API_GLOBAL_PREFIX: z.string().min(1).default('api'),
API_VERSION: z.string().regex(/^v\d+$/).default('v1'),
API_VERSION: z
.string()
.regex(/^v\d+$/)
.default('v1'),
// CORS — origens permitidas (Web em dev: http://localhost:4200)
CORS_ORIGINS: z
@@ -28,7 +31,10 @@ export const EnvSchema = z
// Master-login (DEV stub — IdP real virá na próxima sessão)
MASTER_LOGIN_URL: z.url().default('http://localhost:3000/auth/dev'),
MASTER_LOGIN_JWT_SECRET: z.string().min(32).default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'),
MASTER_LOGIN_JWT_SECRET: z
.string()
.min(32)
.default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'),
JWT_ACCESS_EXPIRATION: z.coerce.number().int().positive().default(900),
JWT_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000),
@@ -61,6 +67,11 @@ export const EnvSchema = z
OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0),
SENTRY_DSN: z.string().optional(),
// Web Push VAPID (C6) — gerado via web-push generateVAPIDKeys()
VAPID_PUBLIC_KEY: z.string().optional(),
VAPID_PRIVATE_KEY: z.string().optional(),
VAPID_CONTACT: z.string().default('mailto:noreply@sar.dev'),
// Feature flags
GROWTHBOOK_API_HOST: z.string().optional(),
GROWTHBOOK_CLIENT_KEY: z.string().optional(),
@@ -74,11 +85,16 @@ export const EnvSchema = z
ctx.addIssue({
code: 'custom',
path: ['MASTER_LOGIN_JWT_SECRET'],
message: 'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).',
message:
'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).',
});
}
if (!env.DATABASE_URL) {
ctx.addIssue({ code: 'custom', path: ['DATABASE_URL'], message: 'obrigatório em produção' });
ctx.addIssue({
code: 'custom',
path: ['DATABASE_URL'],
message: 'obrigatório em produção',
});
}
}
});

View File

@@ -0,0 +1,29 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import { SubscribePayloadSchema, type SubscribePayload } from '@sar/api-interface';
import type { AuthenticatedRequest } from '../auth/jwt.types';
import { NotificationsService } from './notifications.service';
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
@Controller('notifications')
export class NotificationsController {
constructor(private readonly svc: NotificationsService) {}
@Post('subscribe')
@HttpCode(HttpStatus.NO_CONTENT)
async subscribe(@Req() req: AuthenticatedRequest, @Body() body: SubscribeDto): Promise<void> {
await this.svc.subscribe(req.user.sub, req.user.role, body as unknown as SubscribePayload);
}
@Delete('unsubscribe')
@HttpCode(HttpStatus.NO_CONTENT)
async unsubscribe(@Body() body: { endpoint: string }): Promise<void> {
await this.svc.unsubscribe(body.endpoint);
}
@Get('pending-count')
async pendingCount(@Req() req: AuthenticatedRequest) {
return this.svc.pendingCount(req.user.sub, req.user.role);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { PushService } from './push.service';
@Module({
controllers: [NotificationsController],
providers: [NotificationsService, PushService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { OrderStatus } from '@prisma/client';
import type { SubscribePayload, PendingCountResponse } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { PushService, type PushPayload } from './push.service';
@Injectable()
export class NotificationsService {
constructor(
private readonly cls: ClsService<WorkspaceClsStore>,
private readonly push: PushService,
) {}
async subscribe(userId: string, role: string, dto: SubscribePayload): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
await prisma.pushSubscription.upsert({
where: { endpoint: dto.endpoint },
update: { userId, role, p256dh: dto.keys.p256dh, auth: dto.keys.auth },
create: {
userId,
role,
endpoint: dto.endpoint,
p256dh: dto.keys.p256dh,
auth: dto.keys.auth,
},
});
}
async unsubscribe(endpoint: string): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
await prisma.pushSubscription.deleteMany({ where: { endpoint } });
}
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
if (role === 'supervisor' || role === 'manager' || role === 'admin') {
const count = await prisma.order.count({
where: { status: OrderStatus.pending_approval, deletedAt: null },
});
return { count };
}
return { count: 0 };
}
// Envia push para todos os supervisores/managers/admin do workspace.
async notifySupervisors(payload: PushPayload): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) return;
const subs = await prisma.pushSubscription.findMany({
where: { role: { in: ['supervisor', 'manager', 'admin'] } },
});
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
}
// Envia push para um userId específico (todos os dispositivos registrados).
async notifyUser(userId: string, payload: PushPayload): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) return;
const subs = await prisma.pushSubscription.findMany({ where: { userId } });
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
}
}

View File

@@ -0,0 +1,51 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as webpush from 'web-push';
import type { Env } from '../config/env.schema';
export interface PushPayload {
title: string;
body: string;
url?: string;
}
interface PushTarget {
endpoint: string;
p256dh: string;
auth: string;
}
@Injectable()
export class PushService {
private readonly logger = new Logger(PushService.name);
private readonly enabled: boolean;
constructor(config: ConfigService<Env, true>) {
const publicKey = config.get('VAPID_PUBLIC_KEY', { infer: true });
const privateKey = config.get('VAPID_PRIVATE_KEY', { infer: true });
const contact = config.get('VAPID_CONTACT', { infer: true });
if (publicKey && privateKey) {
webpush.setVapidDetails(contact, publicKey, privateKey);
this.enabled = true;
} else {
this.enabled = false;
this.logger.warn(
'VAPID não configurado — push desativado (defina VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY)',
);
}
}
async send(target: PushTarget, payload: PushPayload): Promise<void> {
if (!this.enabled) return;
try {
await webpush.sendNotification(
{ endpoint: target.endpoint, keys: { p256dh: target.p256dh, auth: target.auth } },
JSON.stringify(payload),
);
} catch (err: unknown) {
// 410 Gone = subscription expirada; logar sem throw para não quebrar o fluxo principal
this.logger.warn({ err }, `Push falhou para ${target.endpoint.slice(0, 60)}`);
}
}
}

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);
}