fix(api,web): corrige autorização da área do rep e falhas silenciosas no front
Auditoria de segurança + robustez da área do representante antes de produção. API (autorização e injeção): - IDOR em clients: endpoints de detalhe (findOne, notas, ctr, top-produtos, histórico, contatos) validam carteira do rep via assertClienteDaCarteira; cliente alheio retorna 404. orders.create idem antes de aceitar idCliente. - Role guard em dashboards supervisor/manager e ger/chamados (rep -> 403). - Datas em $queryRawUnsafe agora exigem YYYY-MM-DD (DateOnlySchema + revalidação); dtAniversario de contato validado e escapado. - id_empresa de CTR/NF usa matrizEmpresa() em vez de 1/9001 hardcoded. - Alçada de desconto: transmit valida desconto efetivo por item (preço vs tabela pauta>promo>base + promoção ativa), não só o desconto global. - Idempotency-Key escopado a empresa+vendedor; unsubscribe com DTO e dono. Web (erros nunca silenciosos): - Remove dupla serialização (apiFetch já faz JSON.stringify) em funil, sac e politicas — corrige criar oportunidade, abrir chamado e editar políticas. - Handler global de erro no QueryClient (query e mutation viram toast). - Error boundary por rota (payload divergente não derruba o app em branco). - Alert usa message= em vez de title= (AntD 6); sw.js usa globalThis. Qualidade: - Corrige ping.contract.spec (idEmpresa) e zera erros de lint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,12 @@ function escSql(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
// Financeiro (CTR) e NF vivem na empresa MATRIZ. O ERP usa códigos > 9000 para
|
||||
// origem de pedido (ex.: 9001 → matriz 1), espelhando catalog/dashboard/equipe.
|
||||
function matrizEmpresa(idEmpresa: number): number {
|
||||
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
}
|
||||
|
||||
// Row bruta do $queryRawUnsafe
|
||||
interface ClientRow {
|
||||
id_cliente: number;
|
||||
@@ -108,6 +114,25 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
|
||||
export class ClientsService {
|
||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||
|
||||
// PGD-AUTHZ: rep só acessa clientes da própria carteira; gestores passam direto.
|
||||
// NotFound (não Forbidden) para não revelar a existência de clientes de terceiros.
|
||||
private async assertClienteDaCarteira(idCliente: number): Promise<void> {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role !== 'rep') return;
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||
idCliente,
|
||||
);
|
||||
if (!rows[0] || Number(rows[0].cod_vendedor) !== codVendedor) {
|
||||
throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
|
||||
}
|
||||
}
|
||||
|
||||
async list(query: ClientListQuery): Promise<ClientListResponse> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
@@ -257,6 +282,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async listContacts(idCorrent: number): Promise<Contato[]> {
|
||||
await this.assertClienteDaCarteira(idCorrent);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
@@ -316,6 +342,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
|
||||
await this.assertClienteDaCarteira(idCorrent);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
@@ -337,7 +364,7 @@ export class ClientsService {
|
||||
${idEmpresa}, ${codVendedor}, ${idCorrent},
|
||||
'${esc(dto.nome)}',
|
||||
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
|
||||
${dto.dtAniversario ? `'${dto.dtAniversario}'` : 'NULL'},
|
||||
${dto.dtAniversario ? `'${esc(dto.dtAniversario)}'` : 'NULL'},
|
||||
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
|
||||
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
|
||||
)
|
||||
@@ -370,6 +397,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
@@ -430,12 +458,13 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
|
||||
const idEmpresaMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||
|
||||
interface Row {
|
||||
id_produto: number;
|
||||
@@ -493,8 +522,10 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
|
||||
interface Row {
|
||||
qtd_aberto: string;
|
||||
@@ -513,6 +544,7 @@ export class ClientsService {
|
||||
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
|
||||
FROM sar.vw_ctr
|
||||
WHERE id_cliente = ${idCliente}
|
||||
AND id_empresa = ${idEmpresaMatriz}
|
||||
AND situacao = 'A'
|
||||
AND saldo > 0
|
||||
`);
|
||||
@@ -528,6 +560,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async findOne(idCliente: number): Promise<ClientDetail> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
@@ -579,8 +612,10 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
|
||||
type CtrRow = {
|
||||
id_ctr: number;
|
||||
@@ -602,7 +637,7 @@ export class ClientsService {
|
||||
saldo::text AS saldo
|
||||
FROM sar.vw_ctr
|
||||
WHERE id_cliente = $1
|
||||
AND id_empresa = 1
|
||||
AND id_empresa = $3
|
||||
AND situacao = 'A'
|
||||
AND saldo > 0
|
||||
ORDER BY dt_vencimento ASC
|
||||
@@ -610,6 +645,7 @@ export class ClientsService {
|
||||
`,
|
||||
idCliente,
|
||||
limit,
|
||||
idEmpresaMatriz,
|
||||
);
|
||||
|
||||
const toDate = (d: Date | string): string =>
|
||||
@@ -626,8 +662,12 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
// NF fica na matriz; pedidos podem ter origem na matriz ou na empresa fiscal (matriz+9000)
|
||||
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
const idEmpresaFiscal = idEmpresaMatriz + 9000;
|
||||
|
||||
type NfRow = {
|
||||
id_nf: number;
|
||||
@@ -653,9 +693,9 @@ export class ClientsService {
|
||||
JOIN sar.vw_pedidos_erp p
|
||||
ON p.numero = nf.num_entrega
|
||||
AND p.tipo = 'E'
|
||||
AND p.id_empresa IN (1, 9001)
|
||||
AND p.id_empresa IN ($3, $4)
|
||||
WHERE p.id_cliente = $1
|
||||
AND nf.id_empresa = 1
|
||||
AND nf.id_empresa = $3
|
||||
AND nf.status = 'E'
|
||||
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
|
||||
ORDER BY nf.id_nf
|
||||
@@ -665,6 +705,8 @@ export class ClientsService {
|
||||
`,
|
||||
idCliente,
|
||||
limit,
|
||||
idEmpresaMatriz,
|
||||
idEmpresaFiscal,
|
||||
);
|
||||
|
||||
return rows.map((r: NfRow) => ({
|
||||
|
||||
Reference in New Issue
Block a user