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:
@@ -45,6 +45,14 @@ OTEL_TRACES_SAMPLER=parentbased_traceidratio
|
|||||||
OTEL_TRACES_SAMPLER_ARG=1.0
|
OTEL_TRACES_SAMPLER_ARG=1.0
|
||||||
SENTRY_DSN=
|
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)
|
# Feature flags (DEV: bypass. Prod: GrowthBook self-host)
|
||||||
GROWTHBOOK_API_HOST=http://localhost:3100
|
GROWTHBOOK_API_HOST=http://localhost:3100
|
||||||
GROWTHBOOK_CLIENT_KEY=
|
GROWTHBOOK_CLIENT_KEY=
|
||||||
|
|||||||
@@ -5,5 +5,11 @@
|
|||||||
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)",
|
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)",
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"web-push": "^3.6.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/web-push": "^3.6.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -208,6 +208,25 @@ model RepDiscountLimit {
|
|||||||
@@index([repId])
|
@@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) ─────────────────────────────────────────────────
|
// ─── OrderStatusHistory (C3) ─────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Registro imutável de cada transição de status. changedById = userId do ator.
|
// Registro imutável de cada transição de status. changedById = userId do ator.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ClientsModule } from './clients/clients.module';
|
|||||||
import { OrdersModule } from './orders/orders.module';
|
import { OrdersModule } from './orders/orders.module';
|
||||||
import { CatalogModule } from './catalog/catalog.module';
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
import { DashboardModule } from './dashboard/dashboard.module';
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -27,6 +28,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
|||||||
OrdersModule,
|
OrdersModule,
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export const EnvSchema = z
|
|||||||
API_PORT: z.coerce.number().int().positive().default(3000),
|
API_PORT: z.coerce.number().int().positive().default(3000),
|
||||||
API_HOST: z.string().min(1).default('0.0.0.0'),
|
API_HOST: z.string().min(1).default('0.0.0.0'),
|
||||||
API_GLOBAL_PREFIX: z.string().min(1).default('api'),
|
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 — origens permitidas (Web em dev: http://localhost:4200)
|
||||||
CORS_ORIGINS: z
|
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 (DEV stub — IdP real virá na próxima sessão)
|
||||||
MASTER_LOGIN_URL: z.url().default('http://localhost:3000/auth/dev'),
|
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_ACCESS_EXPIRATION: z.coerce.number().int().positive().default(900),
|
||||||
JWT_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000),
|
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),
|
OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0),
|
||||||
SENTRY_DSN: z.string().optional(),
|
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
|
// Feature flags
|
||||||
GROWTHBOOK_API_HOST: z.string().optional(),
|
GROWTHBOOK_API_HOST: z.string().optional(),
|
||||||
GROWTHBOOK_CLIENT_KEY: z.string().optional(),
|
GROWTHBOOK_CLIENT_KEY: z.string().optional(),
|
||||||
@@ -74,11 +85,16 @@ export const EnvSchema = z
|
|||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
path: ['MASTER_LOGIN_JWT_SECRET'],
|
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) {
|
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',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { OrdersController } from './orders.controller';
|
import { OrdersController } from './orders.controller';
|
||||||
import { OrdersService } from './orders.service';
|
import { OrdersService } from './orders.service';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [NotificationsModule],
|
||||||
controllers: [OrdersController],
|
controllers: [OrdersController],
|
||||||
providers: [OrdersService],
|
providers: [OrdersService],
|
||||||
exports: [OrdersService],
|
exports: [OrdersService],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
RejectOrder,
|
RejectOrder,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
|
||||||
function decimalToString(v: Prisma.Decimal | null | undefined): string {
|
function decimalToString(v: Prisma.Decimal | null | undefined): string {
|
||||||
return v ? v.toString() : '0';
|
return v ? v.toString() : '0';
|
||||||
@@ -18,7 +19,10 @@ function decimalToString(v: Prisma.Decimal | null | undefined): string {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrdersService {
|
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> {
|
async list(query: OrderListQuery, userId: string, role: string): Promise<OrderListResponse> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
@@ -252,7 +256,13 @@ export class OrdersService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (status === OrderStatus.pending_approval) {
|
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({
|
const updated = await prisma.order.findUniqueOrThrow({
|
||||||
where: { id: order.id },
|
where: { id: order.id },
|
||||||
include: {
|
include: {
|
||||||
@@ -322,6 +332,14 @@ export class OrdersService {
|
|||||||
history: { orderBy: { changedAt: 'asc' } },
|
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);
|
return this.mapDetail(final);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +388,14 @@ export class OrdersService {
|
|||||||
history: { orderBy: { changedAt: 'asc' } },
|
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);
|
return this.mapDetail(final);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
apps/web/public/sw.js
Normal file
22
apps/web/public/sw.js
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
Timeline,
|
Timeline,
|
||||||
Typography,
|
Typography,
|
||||||
Input,
|
Input,
|
||||||
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { Link, useParams } from '@tanstack/react-router';
|
import { Link, useParams } from '@tanstack/react-router';
|
||||||
import type { OrderItem, OrderStatus, OrderStatusHistory } from '@sar/api-interface';
|
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' });
|
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 {
|
function getRoleFromToken(): string {
|
||||||
const token = authStore.get();
|
const token = authStore.get();
|
||||||
if (!token) return 'rep';
|
if (!token) return 'rep';
|
||||||
@@ -230,6 +252,11 @@ export function OrderDetailPage() {
|
|||||||
|
|
||||||
const role = getRoleFromToken();
|
const role = getRoleFromToken();
|
||||||
const canAct = role !== 'rep' && order?.status === 'pending_approval';
|
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 [approveOpen, setApproveOpen] = useState(false);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
@@ -298,6 +325,20 @@ export function OrderDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<Button
|
||||||
|
icon={<FontAwesomeIcon icon={faShareNodes} />}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.share({ text: buildShareText(order) });
|
||||||
|
} catch {
|
||||||
|
void message.info('Compartilhamento cancelado');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Compartilhar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
{actionError && (
|
{actionError && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import { faBell, faMagnifyingGlass, faBars } from '@fortawesome/free-solid-svg-icons';
|
import { faBell, faMagnifyingGlass, faBars } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { brandTokens } from '../../lib/theme';
|
import { brandTokens } from '../../lib/theme';
|
||||||
import { FoundationStatus } from './FoundationStatus';
|
import { FoundationStatus } from './FoundationStatus';
|
||||||
|
import { usePendingCount } from '../../lib/queries/notifications';
|
||||||
|
|
||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
@@ -14,6 +15,9 @@ interface TopbarProps {
|
|||||||
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
|
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
|
||||||
*/
|
*/
|
||||||
export function Topbar({ onToggleSidebar }: TopbarProps) {
|
export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||||
|
const { data: pendingData } = usePendingCount();
|
||||||
|
const pendingCount = pendingData?.count ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
align="center"
|
align="center"
|
||||||
@@ -38,11 +42,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
|||||||
style={{ display: 'inline-flex' }}
|
style={{ display: 'inline-flex' }}
|
||||||
/>
|
/>
|
||||||
<Flex align="center" gap={12}>
|
<Flex align="center" gap={12}>
|
||||||
<img
|
<img src="/sar-icon.png" alt="SAR" style={{ height: 40, width: 'auto' }} />
|
||||||
src="/sar-icon.png"
|
|
||||||
alt="SAR"
|
|
||||||
style={{ height: 40, width: 'auto' }}
|
|
||||||
/>
|
|
||||||
<Flex vertical gap={0}>
|
<Flex vertical gap={0}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -75,10 +75,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
|||||||
size="large"
|
size="large"
|
||||||
placeholder="Buscar cliente, pedido, produto..."
|
placeholder="Buscar cliente, pedido, produto..."
|
||||||
prefix={
|
prefix={
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon icon={faMagnifyingGlass} style={{ color: 'var(--text-muted)' }} />
|
||||||
icon={faMagnifyingGlass}
|
|
||||||
style={{ color: 'var(--text-muted)' }}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
style={{ borderRadius: 12 }}
|
style={{ borderRadius: 12 }}
|
||||||
aria-label="Buscar"
|
aria-label="Buscar"
|
||||||
@@ -88,7 +85,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
|||||||
{/* Lado direito: status fundação + notificações + perfil */}
|
{/* Lado direito: status fundação + notificações + perfil */}
|
||||||
<Flex align="center" gap={16}>
|
<Flex align="center" gap={16}>
|
||||||
<FoundationStatus />
|
<FoundationStatus />
|
||||||
<Badge count={3} color={brandTokens.red} offset={[-4, 4]}>
|
<Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="large"
|
size="large"
|
||||||
|
|||||||
42
apps/web/src/lib/hooks/usePushRegistration.ts
Normal file
42
apps/web/src/lib/hooks/usePushRegistration.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY = import.meta.env['VITE_VAPID_PUBLIC_KEY'] as string | undefined;
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||||
|
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
||||||
|
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const raw = atob(b64);
|
||||||
|
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePushRegistration() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!VAPID_PUBLIC_KEY || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||||
|
|
||||||
|
const register = async () => {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const existing = await reg.pushManager.getSubscription();
|
||||||
|
const sub =
|
||||||
|
existing ??
|
||||||
|
(await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
|
||||||
|
}));
|
||||||
|
const json = sub.toJSON();
|
||||||
|
await apiFetch('/notifications/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: { p256dh: json.keys?.['p256dh'] ?? '', auth: json.keys?.['auth'] ?? '' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Push é opt-in — permissão negada ou SW não disponível é normal
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void register();
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
15
apps/web/src/lib/queries/notifications.ts
Normal file
15
apps/web/src/lib/queries/notifications.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { PendingCountResponseSchema } from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
export function usePendingCount() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['notifications', 'pending-count'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch('/notifications/pending-count');
|
||||||
|
return PendingCountResponseSchema.parse(res);
|
||||||
|
},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
staleTime: 20_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ import { DevLogin } from './components/dev/DevLogin';
|
|||||||
|
|
||||||
dayjs.locale('pt-br');
|
dayjs.locale('pt-br');
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||||
|
// SW é opt-in — falha silenciosa não impede o app
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV;
|
const isDev = import.meta.env.DEV;
|
||||||
|
|
||||||
function Root() {
|
function Root() {
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from './lib/client.contract';
|
|||||||
export * from './lib/order.contract';
|
export * from './lib/order.contract';
|
||||||
export * from './lib/product.contract';
|
export * from './lib/product.contract';
|
||||||
export * from './lib/dashboard.contract';
|
export * from './lib/dashboard.contract';
|
||||||
|
export * from './lib/notifications.contract';
|
||||||
|
|||||||
17
libs/shared/api-interface/src/lib/notifications.contract.ts
Normal file
17
libs/shared/api-interface/src/lib/notifications.contract.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Contratos canônicos de C6 — Notificações e Push.
|
||||||
|
|
||||||
|
export const SubscribePayloadSchema = z.object({
|
||||||
|
endpoint: z.string().url(),
|
||||||
|
keys: z.object({
|
||||||
|
p256dh: z.string().min(1),
|
||||||
|
auth: z.string().min(1),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
|
||||||
|
|
||||||
|
export const PendingCountResponseSchema = z.object({
|
||||||
|
count: z.number().int().min(0),
|
||||||
|
});
|
||||||
|
export type PendingCountResponse = z.infer<typeof PendingCountResponseSchema>;
|
||||||
3
nx.json
3
nx.json
@@ -116,5 +116,6 @@
|
|||||||
"linter": "eslint"
|
"linter": "eslint"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"analytics": true
|
||||||
}
|
}
|
||||||
|
|||||||
99
pnpm-lock.yaml
generated
99
pnpm-lock.yaml
generated
@@ -286,7 +286,15 @@ importers:
|
|||||||
specifier: ^5.1.4
|
specifier: ^5.1.4
|
||||||
version: 5.1.4(webpack@5.107.2)
|
version: 5.1.4(webpack@5.107.2)
|
||||||
|
|
||||||
apps/api: {}
|
apps/api:
|
||||||
|
dependencies:
|
||||||
|
web-push:
|
||||||
|
specifier: ^3.6.7
|
||||||
|
version: 3.6.7
|
||||||
|
devDependencies:
|
||||||
|
'@types/web-push':
|
||||||
|
specifier: ^3.6.4
|
||||||
|
version: 3.6.4
|
||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3808,6 +3816,9 @@ packages:
|
|||||||
'@types/stack-utils@2.0.3':
|
'@types/stack-utils@2.0.3':
|
||||||
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
|
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
|
||||||
|
|
||||||
|
'@types/web-push@3.6.4':
|
||||||
|
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
|
||||||
|
|
||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||||
|
|
||||||
@@ -4222,6 +4233,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
|
agent-base@7.1.4:
|
||||||
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
ajv-formats@2.1.1:
|
ajv-formats@2.1.1:
|
||||||
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4367,6 +4382,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
|
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
asn1.js@5.4.1:
|
||||||
|
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||||
|
|
||||||
asn1js@3.0.10:
|
asn1js@3.0.10:
|
||||||
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
|
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -4566,6 +4584,9 @@ packages:
|
|||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||||
|
|
||||||
|
bn.js@4.12.3:
|
||||||
|
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
|
||||||
|
|
||||||
body-parser@1.20.5:
|
body-parser@1.20.5:
|
||||||
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
|
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
|
||||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||||
@@ -4613,6 +4634,9 @@ packages:
|
|||||||
buffer-crc32@0.2.13:
|
buffer-crc32@0.2.13:
|
||||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
@@ -5298,6 +5322,9 @@ packages:
|
|||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@@ -6123,10 +6150,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
|
resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
|
||||||
engines: {node: '>=10.19.0'}
|
engines: {node: '>=10.19.0'}
|
||||||
|
|
||||||
|
http_ece@1.2.0:
|
||||||
|
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
https-proxy-agent@5.0.1:
|
https-proxy-agent@5.0.1:
|
||||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
https-proxy-agent@7.0.6:
|
||||||
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
human-signals@2.1.0:
|
human-signals@2.1.0:
|
||||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||||
engines: {node: '>=10.17.0'}
|
engines: {node: '>=10.17.0'}
|
||||||
@@ -6836,6 +6871,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -9441,6 +9482,11 @@ packages:
|
|||||||
wcwidth@1.0.1:
|
wcwidth@1.0.1:
|
||||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||||
|
|
||||||
|
web-push@3.6.7:
|
||||||
|
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
web-worker@1.5.0:
|
web-worker@1.5.0:
|
||||||
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
|
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
|
||||||
|
|
||||||
@@ -13827,6 +13873,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/stack-utils@2.0.3': {}
|
'@types/stack-utils@2.0.3': {}
|
||||||
|
|
||||||
|
'@types/web-push@3.6.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.12.4
|
'@types/node': 24.12.4
|
||||||
@@ -14302,6 +14352,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
ajv-formats@2.1.1(ajv@8.20.0):
|
ajv-formats@2.1.1(ajv@8.20.0):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ajv: 8.20.0
|
ajv: 8.20.0
|
||||||
@@ -14518,6 +14570,13 @@ snapshots:
|
|||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
is-array-buffer: 3.0.5
|
is-array-buffer: 3.0.5
|
||||||
|
|
||||||
|
asn1.js@5.4.1:
|
||||||
|
dependencies:
|
||||||
|
bn.js: 4.12.3
|
||||||
|
inherits: 2.0.4
|
||||||
|
minimalistic-assert: 1.0.1
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
asn1js@3.0.10:
|
asn1js@3.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
pvtsutils: 1.3.6
|
pvtsutils: 1.3.6
|
||||||
@@ -14746,6 +14805,8 @@ snapshots:
|
|||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
|
bn.js@4.12.3: {}
|
||||||
|
|
||||||
body-parser@1.20.5:
|
body-parser@1.20.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
@@ -14830,6 +14891,8 @@ snapshots:
|
|||||||
|
|
||||||
buffer-crc32@0.2.13: {}
|
buffer-crc32@0.2.13: {}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
@@ -15473,6 +15536,10 @@ snapshots:
|
|||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
effect@3.20.0:
|
effect@3.20.0:
|
||||||
@@ -16559,6 +16626,8 @@ snapshots:
|
|||||||
quick-lru: 5.1.1
|
quick-lru: 5.1.1
|
||||||
resolve-alpn: 1.2.1
|
resolve-alpn: 1.2.1
|
||||||
|
|
||||||
|
http_ece@1.2.0: {}
|
||||||
|
|
||||||
https-proxy-agent@5.0.1:
|
https-proxy-agent@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 6.0.2
|
agent-base: 6.0.2
|
||||||
@@ -16566,6 +16635,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
https-proxy-agent@7.0.6:
|
||||||
|
dependencies:
|
||||||
|
agent-base: 7.1.4
|
||||||
|
debug: 4.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
human-signals@2.1.0: {}
|
human-signals@2.1.0: {}
|
||||||
|
|
||||||
human-signals@5.0.0: {}
|
human-signals@5.0.0: {}
|
||||||
@@ -17587,6 +17663,17 @@ snapshots:
|
|||||||
object.assign: 4.1.7
|
object.assign: 4.1.7
|
||||||
object.values: 1.2.1
|
object.values: 1.2.1
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time: 1.0.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
jwa: 2.0.1
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -20322,6 +20409,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
defaults: 1.0.4
|
defaults: 1.0.4
|
||||||
|
|
||||||
|
web-push@3.6.7:
|
||||||
|
dependencies:
|
||||||
|
asn1.js: 5.4.1
|
||||||
|
http_ece: 1.2.0
|
||||||
|
https-proxy-agent: 7.0.6
|
||||||
|
jws: 4.0.1
|
||||||
|
minimist: 1.2.8
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
web-worker@1.5.0: {}
|
web-worker@1.5.0: {}
|
||||||
|
|
||||||
webidl-conversions@3.0.1: {}
|
webidl-conversions@3.0.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user