From a1a852c44d6fb5f19fee79eaf4f36e436ba80e92 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 28 May 2026 12:31:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(c6):=20notifica=C3=A7=C3=B5es=20e=20push?= =?UTF-8?q?=20=E2=80=94=20Web=20Push=20VAPID,=20badge=20din=C3=A2mico,=20S?= =?UTF-8?q?hare=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 8 ++ apps/api/package.json | 6 ++ .../migration.sql | 22 +++++ apps/api/prisma/schema.prisma | 19 ++++ apps/api/src/app/app.module.ts | 2 + apps/api/src/app/config/env.schema.ts | 24 ++++- .../notifications/notifications.controller.ts | 29 ++++++ .../app/notifications/notifications.module.ts | 11 +++ .../notifications/notifications.service.ts | 73 ++++++++++++++ .../api/src/app/notifications/push.service.ts | 51 ++++++++++ apps/api/src/app/orders/orders.module.ts | 2 + apps/api/src/app/orders/orders.service.ts | 30 +++++- apps/web/public/sw.js | 22 +++++ .../src/cockpits/rafael/OrderDetailPage.tsx | 41 ++++++++ apps/web/src/components/layout/Topbar.tsx | 17 ++-- apps/web/src/lib/hooks/usePushRegistration.ts | 42 ++++++++ apps/web/src/lib/queries/notifications.ts | 15 +++ apps/web/src/main.tsx | 6 ++ libs/shared/api-interface/src/index.ts | 1 + .../src/lib/notifications.contract.ts | 17 ++++ nx.json | 3 +- pnpm-lock.yaml | 99 ++++++++++++++++++- 22 files changed, 522 insertions(+), 18 deletions(-) create mode 100644 apps/api/prisma/migrations/20260528121304_c6_push_subscriptions/migration.sql create mode 100644 apps/api/src/app/notifications/notifications.controller.ts create mode 100644 apps/api/src/app/notifications/notifications.module.ts create mode 100644 apps/api/src/app/notifications/notifications.service.ts create mode 100644 apps/api/src/app/notifications/push.service.ts create mode 100644 apps/web/public/sw.js create mode 100644 apps/web/src/lib/hooks/usePushRegistration.ts create mode 100644 apps/web/src/lib/queries/notifications.ts create mode 100644 libs/shared/api-interface/src/lib/notifications.contract.ts diff --git a/.env.example b/.env.example index cbc8e90..3b0f474 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,14 @@ OTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=1.0 SENTRY_DSN= +# Web Push VAPID (C6) — gerar com: node -e "const wp=require('web-push'); const k=wp.generateVAPIDKeys(); console.log(k)" +# Em prod: Vault injeta. Em dev: opcional — push fica desabilitado se vazio. +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_CONTACT=mailto:noreply@sar.dev +# Chave pública VAPID para o front-end (mesmo valor de VAPID_PUBLIC_KEY) +VITE_VAPID_PUBLIC_KEY= + # Feature flags (DEV: bypass. Prod: GrowthBook self-host) GROWTHBOOK_API_HOST=http://localhost:3100 GROWTHBOOK_CLIENT_KEY= diff --git a/apps/api/package.json b/apps/api/package.json index 38f2a3a..e8c7287 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" } } diff --git a/apps/api/prisma/migrations/20260528121304_c6_push_subscriptions/migration.sql b/apps/api/prisma/migrations/20260528121304_c6_push_subscriptions/migration.sql new file mode 100644 index 0000000..9155277 --- /dev/null +++ b/apps/api/prisma/migrations/20260528121304_c6_push_subscriptions/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f4b6900..f792ff3 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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. diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 8420d02..13dcb88 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -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 }, diff --git a/apps/api/src/app/config/env.schema.ts b/apps/api/src/app/config/env.schema.ts index 2a5d521..271a034 100644 --- a/apps/api/src/app/config/env.schema.ts +++ b/apps/api/src/app/config/env.schema.ts @@ -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', + }); } } }); diff --git a/apps/api/src/app/notifications/notifications.controller.ts b/apps/api/src/app/notifications/notifications.controller.ts new file mode 100644 index 0000000..fd421cf --- /dev/null +++ b/apps/api/src/app/notifications/notifications.controller.ts @@ -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 { + 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 { + await this.svc.unsubscribe(body.endpoint); + } + + @Get('pending-count') + async pendingCount(@Req() req: AuthenticatedRequest) { + return this.svc.pendingCount(req.user.sub, req.user.role); + } +} diff --git a/apps/api/src/app/notifications/notifications.module.ts b/apps/api/src/app/notifications/notifications.module.ts new file mode 100644 index 0000000..f8526b2 --- /dev/null +++ b/apps/api/src/app/notifications/notifications.module.ts @@ -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 {} diff --git a/apps/api/src/app/notifications/notifications.service.ts b/apps/api/src/app/notifications/notifications.service.ts new file mode 100644 index 0000000..87ee122 --- /dev/null +++ b/apps/api/src/app/notifications/notifications.service.ts @@ -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, + private readonly push: PushService, + ) {} + + async subscribe(userId: string, role: string, dto: SubscribePayload): Promise { + 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 { + 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 { + 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 { + 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 { + 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))); + } +} diff --git a/apps/api/src/app/notifications/push.service.ts b/apps/api/src/app/notifications/push.service.ts new file mode 100644 index 0000000..23bc98a --- /dev/null +++ b/apps/api/src/app/notifications/push.service.ts @@ -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) { + 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 { + 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)}`); + } + } +} diff --git a/apps/api/src/app/orders/orders.module.ts b/apps/api/src/app/orders/orders.module.ts index 6b429b9..e35ae0c 100644 --- a/apps/api/src/app/orders/orders.module.ts +++ b/apps/api/src/app/orders/orders.module.ts @@ -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], diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index 56d398a..984f9bb 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -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) {} + constructor( + private readonly cls: ClsService, + private readonly notifications: NotificationsService, + ) {} async list(query: OrderListQuery, userId: string, role: string): Promise { 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); } diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..d801859 --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,22 @@ +// Service Worker SAR — C6 Web Push +// Recebe push events e exibe notificação nativa. Clique abre a URL do payload. + +self.addEventListener('push', (event) => { + const data = event.data?.json() ?? {}; + event.waitUntil( + self.registration.showNotification(data.title ?? 'SAR', { + body: data.body ?? '', + icon: '/sar-icon.png', + badge: '/sar-icon.png', + data: data.url ? { url: data.url } : undefined, + }), + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const url = event.notification.data?.url; + if (url) { + event.waitUntil(clients.openWindow(url)); + } +}); diff --git a/apps/web/src/cockpits/rafael/OrderDetailPage.tsx b/apps/web/src/cockpits/rafael/OrderDetailPage.tsx index e24634e..1cf1b18 100644 --- a/apps/web/src/cockpits/rafael/OrderDetailPage.tsx +++ b/apps/web/src/cockpits/rafael/OrderDetailPage.tsx @@ -16,7 +16,10 @@ import { Timeline, Typography, Input, + message, } from 'antd'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faShareNodes } from '@fortawesome/free-solid-svg-icons'; import type { TableColumnsType } from 'antd'; import { Link, useParams } from '@tanstack/react-router'; import type { OrderItem, OrderStatus, OrderStatusHistory } from '@sar/api-interface'; @@ -49,6 +52,25 @@ function fmt(v: string | number): string { return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); } +function buildShareText(order: { + number: string; + clientName: string; + total: string; + items: Array<{ productName: string; quantity: string; unitPrice: string }>; +}): string { + const lines = [ + `*Pedido ${order.number} — ${order.clientName}*`, + '', + ...order.items.map( + (it) => + `• ${it.productName} × ${Number(it.quantity).toLocaleString('pt-BR')} — ${fmt(it.unitPrice)} un.`, + ), + '', + `*Total: ${fmt(order.total)}*`, + ]; + return lines.join('\n'); +} + function getRoleFromToken(): string { const token = authStore.get(); if (!token) return 'rep'; @@ -230,6 +252,11 @@ export function OrderDetailPage() { const role = getRoleFromToken(); const canAct = role !== 'rep' && order?.status === 'pending_approval'; + const canShare = + role === 'rep' && + (order?.status === 'approved' || order?.status === 'invoiced') && + typeof navigator !== 'undefined' && + !!navigator.share; const [approveOpen, setApproveOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false); @@ -298,6 +325,20 @@ export function OrderDetailPage() { )} + {canShare && ( + + )} {actionError && ( diff --git a/apps/web/src/components/layout/Topbar.tsx b/apps/web/src/components/layout/Topbar.tsx index f0048a0..2df30b0 100644 --- a/apps/web/src/components/layout/Topbar.tsx +++ b/apps/web/src/components/layout/Topbar.tsx @@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faBell, faMagnifyingGlass, faBars } from '@fortawesome/free-solid-svg-icons'; import { brandTokens } from '../../lib/theme'; import { FoundationStatus } from './FoundationStatus'; +import { usePendingCount } from '../../lib/queries/notifications'; interface TopbarProps { onToggleSidebar?: () => void; @@ -14,6 +15,9 @@ interface TopbarProps { * Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav). */ export function Topbar({ onToggleSidebar }: TopbarProps) { + const { data: pendingData } = usePendingCount(); + const pendingCount = pendingData?.count ?? 0; + return ( - SAR + SAR + } style={{ borderRadius: 12 }} aria-label="Buscar" @@ -88,7 +85,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) { {/* Lado direito: status fundação + notificações + perfil */} - +