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:
29
apps/api/src/app/notifications/notifications.controller.ts
Normal file
29
apps/api/src/app/notifications/notifications.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/app/notifications/notifications.module.ts
Normal file
11
apps/api/src/app/notifications/notifications.module.ts
Normal 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 {}
|
||||
73
apps/api/src/app/notifications/notifications.service.ts
Normal file
73
apps/api/src/app/notifications/notifications.service.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
51
apps/api/src/app/notifications/push.service.ts
Normal file
51
apps/api/src/app/notifications/push.service.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user