Etapa 5 — Painel Tenant: clientes, contatos e produtos/serviços

Adiciona os módulos customers (com contatos aninhados e regra de contato
principal único) e products, ambos com TenantGuard/RLS, soft delete,
limite de clientes do plano e SKU único por tenant (409 amigável).
Atualiza o controle de progresso e cobre os critérios de aceite com
test/tenant-customers-produtos.e2e-spec.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 21:44:01 -03:00
co-authored by Claude Sonnet 5
parent 04472fcd2b
commit 24b3bb9ae4
20 changed files with 1523 additions and 8 deletions
+111 -8
View File
@@ -34,7 +34,7 @@ qualquer nova etapa começar.
| 2 | Autenticação e RLS | Concluída | 2026-08-13 |
| 3 | Painel Master (tenants/planos/faturas) | Concluída | 2026-08-13 |
| 4 | Integrações Evolution API + E-mail | Concluída | 2026-08-14 |
| 5 | Painel Tenant (clientes/produtos) | [ ] Não iniciada | |
| 5 | Painel Tenant (clientes/produtos) | Concluída | 2026-08-14 |
| 6 | Vendas, Parcelas e Dashboards | [ ] Não iniciada | |
| 7 | Motor de Alertas (BullMQ) | [ ] Não iniciada | |
| 8 | Personalização Whitelabel e Ajustes Finais | [ ] Não iniciada | |
@@ -439,16 +439,108 @@ propositalmente para depois — detalhar em Observações).
## Etapa 5 — Painel Tenant: Clientes, Produtos e Serviços
- [ ] CRUD de customers funcional, com soft delete e busca
- [ ] Criar customer além do limite do plano retorna erro claro
- [ ] CRUD de contatos aninhado funciona; contato principal único respeitado
- [ ] CRUD de produtos/serviços funcional, com soft delete e busca
- [ ] SKU duplicado no mesmo tenant retorna erro 409; permitido entre tenants
- [ ] Teste confirma que acessar recurso de outro tenant retorna 404
- [ ] Regra de acesso master a dados de tenant definida e documentada
- [x] CRUD de customers funcional, com soft delete e busca
- [x] Criar customer além do limite do plano retorna erro claro
- [x] CRUD de contatos aninhado funciona; contato principal único respeitado
- [x] CRUD de produtos/serviços funcional, com soft delete e busca
- [x] SKU duplicado no mesmo tenant retorna erro 409; permitido entre tenants
- [x] Teste confirma que acessar recurso de outro tenant retorna 404
- [x] Regra de acesso master a dados de tenant definida e documentada
**Observações:**
- Módulos novos: `src/modules/customers/` (`CustomersController`/`Service` em
`/tenant/customers`, `CustomerContactsController`/`Service` aninhado em
`/tenant/customers/:customerId/contatos`) e `src/modules/products/`
(`ProductsController`/`Service` em `/tenant/produtos`). Todos os
controllers usam só `@UseGuards(JwtAuthGuard, TenantGuard)` — sem
`RolesGuard`/`@Roles`, como o prompt desta etapa pedia ("todos os
endpoints usam TenantGuard") — qualquer papel de `tenant_user`
(`tenant_admin`/`vendedor`/`financeiro`/`visualizador`) acessa; papéis
mais granulares por endpoint ficam para uma etapa futura, se necessário.
Todos usam `TenantContextService.getTx()` (conexão `app_tenant`, RLS
ativo) — nenhuma query manual filtrando por `tenant_id`.
- **Decisão que o prompt pedia confirmar antes de implementar** (critério
de aceite "master deve conseguir acessar em modo de suporte — decisão a
confirmar"): **não** — o master "puro" (sem estar logado como um tenant
específico) não tem nenhum acesso aos endpoints desta etapa — o mesmo
critério que já valia nas etapas anteriores (`TenantGuard` rejeita
qualquer token que não seja `tenant_user`, 403). Confirmado com o
usuário nesta sessão. Se um modo de suporte for necessário no futuro, deve virar uma
etapa própria com endpoints paralelos (ex: `GET
/master/tenants/:tenantId/customers`), guardados por `MasterGuard` e com
log de auditoria de cada acesso — não reaproveitar as rotas `/tenant/*`.
- Limite de `limite_customers` do plano: contado só entre customers
`ativo=true` (`tx.customers.count({ where: { ativo: true } })`) — um
cliente desativado (soft delete) libera espaço pra cadastrar outro,
mesmo espírito de "soft delete não deve punir o tenant". `plano` é
resolvido via `PrismaMasterService` (`tenants.findUnique({include:
{plano:true}})`), mesmo padrão já usado em `WhatsappService` pro limite
de instâncias (Etapa 4) — `limite_customers` `NULL` = ilimitado, não
bloqueia.
- Regra de contato principal único (`customer_contacts.principal`):
aplicada com um `updateMany` desmarcando o(s) principal(is) anterior(es)
do mesmo `customer_id`, seguido do `create`/`update` do novo — como o
interceptor de RLS (`TenantRlsInterceptor`, Etapa 2) já envolve a
request inteira numa única transação Prisma, essas duas operações já são
atômicas por construção, sem precisar abrir uma `$transaction` aninhada
(que nem seria possível: `tx` aqui já é um `Prisma.TransactionClient`,
que não expõe `$transaction`).
Comportamento explícito verificado em teste: criar dois contatos
seguidos com `principal:true` deixa só o segundo como principal.
- SKU duplicado no mesmo tenant → 409: `products_services` tem
`@@unique([tenant_id, sku])` no schema; o service tenta o `create`/
`update` dentro de um `try/catch` e traduz
`Prisma.PrismaClientKnownRequestError` com `code === 'P2002'` para
`ConflictException` com mensagem citando o SKU. Não filtra por
`error.meta.target` incluir `'sku'` — testado nesta sessão que o driver
Postgres do Prisma nem sempre popula esse campo de forma previsível (às
vezes só o nome da constraint, às vezes indisponível); como `sku` é a
única constraint `UNIQUE` da tabela além da chave primária, checar só
`code === 'P2002'` é suficiente e mais robusto.
- Validação de `documento` (customers): formato de CPF (11 dígitos) ou
CNPJ (14 dígitos), puro ou com a pontuação usual — **não** confere
dígito verificador, como o prompt desta etapa já autorizava
("não precisa validar dígito verificador na primeira versão"). Comentário
no código (`src/modules/customers/documento.util.ts`) registrando que
pode evoluir para validação completa (módulo 11) numa etapa futura.
- Isolamento por RLS desta etapa não precisou de nenhuma policy nova — já
criadas na migration da Etapa 1/2
(`tenant_isolation_customers`/`tenant_isolation_products`/
`tenant_isolation_customer_contacts`, essa última via subquery em
`customer_id` já que `customer_contacts` não tem `tenant_id` direto).
Testado nesta etapa (não só confiado na policy): tenant B tentando
ler/editar/excluir customer, produto e lista de contatos do tenant A
pelo UUID exato — sempre 404 (nunca 403), já que a linha simplesmente
não existe do ponto de vista da conexão `app_tenant` com
`app.current_tenant_id` do tenant B.
- Teste e2e novo, `test/tenant-customers-produtos.e2e-spec.ts` (20 testes),
cobrindo todos os critérios de aceite: CRUD de customers (campo mínimo
obrigatório, validação de formato de documento, busca por nome/
documento, paginação, update, soft delete confirmando a linha
permanecer no banco com `ativo=false` via query direta ao Prisma),
bloqueio ao estourar `limite_customers` do plano com mensagem clara,
CRUD de contatos aninhado com a regra de principal único, contato de
customer inexistente retornando 404, CRUD de produtos/serviços
(validação de `tipo`/`valor_venda > 0`, SKU duplicado 409 vs. SKU igual
permitido entre tenants diferentes, filtro por `tipo`/`ativo`/busca,
soft delete), isolamento RLS entre dois tenants reais (customer, produto
e lista de contatos de outro tenant sempre 404) e bloqueio de todos os
endpoints desta etapa com token master "puro" (403). Rodado com `docker
compose up -d postgres redis` + `prisma migrate deploy` (nenhuma
migration nova — `customers`/`customer_contacts`/`products_services` já
existiam desde a Etapa 1) + `jest --config ./test/jest-e2e.json
--runInBand` apontando pro Postgres via `localhost` — **74/74 testes e2e
passando** (os 54 das Etapas 1-4 + os 20 novos desta etapa). `npm run
lint`, `npm run test` (unit) e `npm run build` também passam sem erros.
Ambiente deste host não tem Node/npm nativo (só Docker) — dependências
instaladas via `docker run node:20-alpine` com `python3 make g++`
adicionados (necessários pro binding nativo do `argon2`, mesmo motivo já
documentado na Etapa 1/4); rede bridge padrão do Docker funcionou nesta
sessão (sem precisar `--network host` pra `npm install`/pull de imagens,
só pro container de teste alcançar o Postgres publicado pelo
`docker-compose` via `localhost`).
---
## Etapa 6 — Vendas, Parcelas e Dashboards
@@ -599,3 +691,14 @@ ponta a ponta no teste e2e contra um servidor HTTP local que simula esse
contrato (`FakeEvolutionServer`, em `test/integracoes.e2e-spec.ts`), não
contra o serviço real. Confirmar contra uma instância real antes de operar
em produção.
[2026-08-14] Etapa 5 — Decisão explicitamente pedida pelo critério de
aceite ("master deve conseguir acessar em modo de suporte — decisão a
confirmar antes de implementar"), confirmada com o usuário: **o master
"puro" não acessa `/tenant/customers`/`/tenant/produtos`/contatos de
nenhum tenant.** Todos os controllers desta etapa usam só `TenantGuard`
(sem endpoint paralelo pro master). Se um modo de suporte para
diagnóstico/atendimento vier a ser necessário, deve ser uma etapa própria
(endpoints `/master/tenants/:tenantId/...` read-only, `MasterGuard` +
auditoria de cada acesso), não uma extensão das rotas `/tenant/*`
existentes.
+4
View File
@@ -7,10 +7,12 @@ import { validate } from './config/env.validation';
import { PrismaModule } from './config/prisma.module';
import { AlertDispatchModule } from './modules/alert-dispatch/alert-dispatch.module';
import { AuthModule } from './modules/auth/auth.module';
import { CustomersModule } from './modules/customers/customers.module';
import { EmailConfigModule } from './modules/email-config/email-config.module';
import { EvolutionModule } from './modules/evolution/evolution.module';
import { HealthModule } from './modules/health/health.module';
import { PlanosModule } from './modules/planos/planos.module';
import { ProductsModule } from './modules/products/products.module';
import { TenantsModule } from './modules/tenants/tenants.module';
import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
@@ -31,6 +33,8 @@ import { WhatsappModule } from './modules/whatsapp/whatsapp.module';
AlertDispatchModule,
EmailConfigModule,
WhatsappModule,
CustomersModule,
ProductsModule,
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
@@ -0,0 +1,70 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CustomerContactsService } from './customer-contacts.service';
import { CreateContactDto } from './dto/create-contact.dto';
import { UpdateContactDto } from './dto/update-contact.dto';
@UseGuards(JwtAuthGuard, TenantGuard)
@Controller('tenant/customers/:customerId/contatos')
export class CustomerContactsController {
constructor(private readonly customerContactsService: CustomerContactsService) {}
@Post()
create(
@Param('customerId', ParseUUIDPipe) customerId: string,
@Body() dto: CreateContactDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.customerContactsService.create(customerId, dto, user, req.ip);
}
@Get()
findAll(@Param('customerId', ParseUUIDPipe) customerId: string) {
return this.customerContactsService.findAll(customerId);
}
@Get(':contactId')
findOne(
@Param('customerId', ParseUUIDPipe) customerId: string,
@Param('contactId', ParseUUIDPipe) contactId: string,
) {
return this.customerContactsService.findOne(customerId, contactId);
}
@Put(':contactId')
update(
@Param('customerId', ParseUUIDPipe) customerId: string,
@Param('contactId', ParseUUIDPipe) contactId: string,
@Body() dto: UpdateContactDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.customerContactsService.update(customerId, contactId, dto, user, req.ip);
}
@Delete(':contactId')
remove(
@Param('customerId', ParseUUIDPipe) customerId: string,
@Param('contactId', ParseUUIDPipe) contactId: string,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.customerContactsService.remove(customerId, contactId, user, req.ip);
}
}
@@ -0,0 +1,124 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { writeAuditLog } from '../../common/audit-log.util';
import { TenantContextService } from '../../config/tenant-context.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateContactDto } from './dto/create-contact.dto';
import { UpdateContactDto } from './dto/update-contact.dto';
@Injectable()
export class CustomerContactsService {
constructor(private readonly tenantContext: TenantContextService) {}
private async assertCustomerExists(customerId: string) {
const tx = this.tenantContext.getTx();
const customer = await tx.customers.findUnique({ where: { id: customerId } });
if (!customer) {
throw new NotFoundException('Cliente não encontrado');
}
}
async findAll(customerId: string) {
await this.assertCustomerExists(customerId);
const tx = this.tenantContext.getTx();
return tx.customer_contacts.findMany({
where: { customer_id: customerId },
orderBy: [{ principal: 'desc' }, { criado_em: 'asc' }],
});
}
async findOne(customerId: string, contactId: string) {
await this.assertCustomerExists(customerId);
const tx = this.tenantContext.getTx();
const contato = await tx.customer_contacts.findFirst({
where: { id: contactId, customer_id: customerId },
});
if (!contato) {
throw new NotFoundException('Contato não encontrado');
}
return contato;
}
/** Só um contato pode ser `principal=true` por customer — ao marcar um novo, desmarca o anterior na mesma transação de request. */
async create(customerId: string, dto: CreateContactDto, actor: JwtPayload, ip?: string) {
await this.assertCustomerExists(customerId);
const tx = this.tenantContext.getTx();
if (dto.principal) {
await tx.customer_contacts.updateMany({
where: { customer_id: customerId, principal: true },
data: { principal: false },
});
}
const contato = await tx.customer_contacts.create({
data: { ...dto, customer_id: customerId },
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: this.tenantContext.getTenantId(),
acao: 'criar_contato_customer',
entidade: 'customer_contacts',
entidade_id: contato.id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return contato;
}
async update(
customerId: string,
contactId: string,
dto: UpdateContactDto,
actor: JwtPayload,
ip?: string,
) {
await this.findOne(customerId, contactId);
const tx = this.tenantContext.getTx();
if (dto.principal) {
await tx.customer_contacts.updateMany({
where: { customer_id: customerId, principal: true, id: { not: contactId } },
data: { principal: false },
});
}
const atualizado = await tx.customer_contacts.update({
where: { id: contactId },
data: dto,
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: this.tenantContext.getTenantId(),
acao: 'atualizar_contato_customer',
entidade: 'customer_contacts',
entidade_id: contactId,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return atualizado;
}
async remove(customerId: string, contactId: string, actor: JwtPayload, ip?: string) {
await this.findOne(customerId, contactId);
const tx = this.tenantContext.getTx();
await tx.customer_contacts.delete({ where: { id: contactId } });
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: this.tenantContext.getTenantId(),
acao: 'remover_contato_customer',
entidade: 'customer_contacts',
entidade_id: contactId,
ip_origem: ip,
});
}
}
@@ -0,0 +1,62 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CustomersService } from './customers.service';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { ListCustomersQueryDto } from './dto/list-customers.query.dto';
import { UpdateCustomerDto } from './dto/update-customer.dto';
@UseGuards(JwtAuthGuard, TenantGuard)
@Controller('tenant/customers')
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Post()
create(@Body() dto: CreateCustomerDto, @CurrentUser() user: JwtPayload, @Req() req: Request) {
return this.customersService.create(dto, user, req.ip);
}
@Get()
findAll(@Query() query: ListCustomersQueryDto) {
return this.customersService.findAll(query);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.customersService.findOne(id);
}
@Put(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCustomerDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.customersService.update(id, dto, user, req.ip);
}
@Delete(':id')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.customersService.remove(id, user, req.ip);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { CustomerContactsController } from './customer-contacts.controller';
import { CustomerContactsService } from './customer-contacts.service';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
@Module({
controllers: [CustomersController, CustomerContactsController],
providers: [CustomersService, CustomerContactsService],
})
export class CustomersModule {}
+141
View File
@@ -0,0 +1,141 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { writeAuditLog } from '../../common/audit-log.util';
import { PrismaMasterService } from '../../config/prisma-master.service';
import { TenantContextService } from '../../config/tenant-context.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { ListCustomersQueryDto } from './dto/list-customers.query.dto';
import { UpdateCustomerDto } from './dto/update-customer.dto';
@Injectable()
export class CustomersService {
constructor(
private readonly prismaMaster: PrismaMasterService,
private readonly tenantContext: TenantContextService,
) {}
async create(dto: CreateCustomerDto, actor: JwtPayload, ip?: string) {
const tenantId = this.tenantContext.getTenantId();
const tx = this.tenantContext.getTx();
const tenant = await this.prismaMaster.tenants.findUnique({
where: { id: tenantId },
include: { plano: true },
});
const limite = tenant?.plano?.limite_customers;
if (limite !== null && limite !== undefined) {
const existentes = await tx.customers.count({ where: { ativo: true } });
if (existentes >= limite) {
throw new BadRequestException(
`Limite de ${limite} cliente(s) do plano atingido — desative um cliente existente ou faça upgrade de plano`,
);
}
}
const customer = await tx.customers.create({ data: { ...dto, tenant_id: tenantId } });
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: tenantId,
acao: 'criar_customer',
entidade: 'customers',
entidade_id: customer.id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return customer;
}
async findAll(query: ListCustomersQueryDto) {
const tx = this.tenantContext.getTx();
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Prisma.customersWhereInput = {
...(query.ativo === undefined ? {} : { ativo: query.ativo }),
...(query.busca
? {
OR: [
{ razao_social: { contains: query.busca, mode: 'insensitive' } },
{ nome_fantasia: { contains: query.busca, mode: 'insensitive' } },
{ documento: { contains: query.busca, mode: 'insensitive' } },
],
}
: {}),
};
const data = await tx.customers.findMany({
where,
orderBy: { razao_social: 'asc' },
skip: (page - 1) * limit,
take: limit,
});
const total = await tx.customers.count({ where });
return { data, meta: { total, page, limit } };
}
async findOne(id: string) {
const tx = this.tenantContext.getTx();
const customer = await tx.customers.findUnique({
where: { id },
include: { contatos: true },
});
if (!customer) {
throw new NotFoundException('Cliente não encontrado');
}
return customer;
}
async update(id: string, dto: UpdateCustomerDto, actor: JwtPayload, ip?: string) {
await this.findOne(id);
const tx = this.tenantContext.getTx();
const atualizado = await tx.customers.update({
where: { id },
data: { ...dto, atualizado_em: new Date() },
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: this.tenantContext.getTenantId(),
acao: 'atualizar_customer',
entidade: 'customers',
entidade_id: id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return atualizado;
}
/** Soft delete — histórico de vendas depende dos registros de customer, então a linha nunca é removida de fato. */
async remove(id: string, actor: JwtPayload, ip?: string) {
await this.findOne(id);
const tx = this.tenantContext.getTx();
const desativado = await tx.customers.update({
where: { id },
data: { ativo: false, atualizado_em: new Date() },
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: this.tenantContext.getTenantId(),
acao: 'desativar_customer',
entidade: 'customers',
entidade_id: id,
ip_origem: ip,
});
return desativado;
}
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Valida apenas o *formato* de CPF (11 dígitos) ou CNPJ (14 dígitos), puro
* ou com a pontuação usual — não confere dígito verificador. Pode evoluir
* para validação completa (módulo 11) numa etapa futura, se necessário.
*/
export const DOCUMENTO_REGEX =
/^(\d{11}|\d{14}|\d{3}\.\d{3}\.\d{3}-\d{2}|\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2})$/;
export const DOCUMENTO_MENSAGEM = 'documento deve ser um CPF ou CNPJ válido (formato)';
@@ -0,0 +1,23 @@
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
export class CreateContactDto {
@IsString()
@MinLength(2)
nome: string;
@IsOptional()
@IsString()
cargo?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
telefone_whatsapp?: string;
@IsOptional()
@IsBoolean()
principal?: boolean;
}
@@ -0,0 +1,42 @@
import { IsEmail, IsOptional, IsString, Length, Matches, MinLength } from 'class-validator';
import { DOCUMENTO_MENSAGEM, DOCUMENTO_REGEX } from '../documento.util';
export class CreateCustomerDto {
@IsString()
@MinLength(2)
razao_social: string;
@IsOptional()
@IsString()
nome_fantasia?: string;
@IsOptional()
@IsString()
@Matches(DOCUMENTO_REGEX, { message: DOCUMENTO_MENSAGEM })
documento?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
telefone_whatsapp?: string;
@IsOptional()
@IsString()
endereco?: string;
@IsOptional()
@IsString()
cidade?: string;
@IsOptional()
@IsString()
@Length(2, 2)
uf?: string;
@IsOptional()
@IsString()
observacoes?: string;
}
@@ -0,0 +1,26 @@
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListCustomersQueryDto {
@IsOptional()
@IsString()
busca?: string;
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : value === 'true' || value === true))
@IsBoolean()
ativo?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
@@ -0,0 +1,24 @@
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
export class UpdateContactDto {
@IsOptional()
@IsString()
@MinLength(2)
nome?: string;
@IsOptional()
@IsString()
cargo?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
telefone_whatsapp?: string;
@IsOptional()
@IsBoolean()
principal?: boolean;
}
@@ -0,0 +1,47 @@
import { IsEmail, IsOptional, IsString, Length, Matches, MinLength } from 'class-validator';
import { DOCUMENTO_MENSAGEM, DOCUMENTO_REGEX } from '../documento.util';
/**
* `ativo` fica de fora de propósito — a (des)ativação passa pelo `DELETE`
* (soft delete), não por aqui, para manter um único caminho auditável.
*/
export class UpdateCustomerDto {
@IsOptional()
@IsString()
@MinLength(2)
razao_social?: string;
@IsOptional()
@IsString()
nome_fantasia?: string;
@IsOptional()
@IsString()
@Matches(DOCUMENTO_REGEX, { message: DOCUMENTO_MENSAGEM })
documento?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
telefone_whatsapp?: string;
@IsOptional()
@IsString()
endereco?: string;
@IsOptional()
@IsString()
cidade?: string;
@IsOptional()
@IsString()
@Length(2, 2)
uf?: string;
@IsOptional()
@IsString()
observacoes?: string;
}
@@ -0,0 +1,46 @@
import { item_tipo } from '@prisma/client';
import {
IsBoolean,
IsIn,
IsNumber,
IsOptional,
IsPositive,
IsString,
MinLength,
} from 'class-validator';
const TIPOS: item_tipo[] = ['produto', 'servico'];
export class CreateProductDto {
@IsIn(TIPOS)
tipo: item_tipo;
@IsString()
@MinLength(2)
nome: string;
@IsOptional()
@IsString()
descricao?: string;
@IsOptional()
@IsString()
sku?: string;
@IsNumber()
@IsPositive()
valor_venda: number;
@IsOptional()
@IsNumber()
@IsPositive()
valor_custo?: number;
@IsOptional()
@IsString()
unidade?: string;
@IsOptional()
@IsBoolean()
recorrente?: boolean;
}
@@ -0,0 +1,33 @@
import { item_tipo } from '@prisma/client';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
const TIPOS: item_tipo[] = ['produto', 'servico'];
export class ListProductsQueryDto {
@IsOptional()
@IsIn(TIPOS)
tipo?: item_tipo;
@IsOptional()
@IsString()
busca?: string;
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : value === 'true' || value === true))
@IsBoolean()
ativo?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
@@ -0,0 +1,50 @@
import { item_tipo } from '@prisma/client';
import {
IsBoolean,
IsIn,
IsNumber,
IsOptional,
IsPositive,
IsString,
MinLength,
} from 'class-validator';
const TIPOS: item_tipo[] = ['produto', 'servico'];
/** `ativo` fica de fora de propósito — a (des)ativação passa pelo `DELETE` (soft delete), mesmo padrão de `UpdateCustomerDto`. */
export class UpdateProductDto {
@IsOptional()
@IsIn(TIPOS)
tipo?: item_tipo;
@IsOptional()
@IsString()
@MinLength(2)
nome?: string;
@IsOptional()
@IsString()
descricao?: string;
@IsOptional()
@IsString()
sku?: string;
@IsOptional()
@IsNumber()
@IsPositive()
valor_venda?: number;
@IsOptional()
@IsNumber()
@IsPositive()
valor_custo?: number;
@IsOptional()
@IsString()
unidade?: string;
@IsOptional()
@IsBoolean()
recorrente?: boolean;
}
@@ -0,0 +1,62 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateProductDto } from './dto/create-product.dto';
import { ListProductsQueryDto } from './dto/list-products.query.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { ProductsService } from './products.service';
@UseGuards(JwtAuthGuard, TenantGuard)
@Controller('tenant/produtos')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Post()
create(@Body() dto: CreateProductDto, @CurrentUser() user: JwtPayload, @Req() req: Request) {
return this.productsService.create(dto, user, req.ip);
}
@Get()
findAll(@Query() query: ListProductsQueryDto) {
return this.productsService.findAll(query);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.productsService.findOne(id);
}
@Put(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateProductDto,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.productsService.update(id, dto, user, req.ip);
}
@Delete(':id')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: JwtPayload,
@Req() req: Request,
) {
return this.productsService.remove(id, user, req.ip);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
})
export class ProductsModule {}
+145
View File
@@ -0,0 +1,145 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { writeAuditLog } from '../../common/audit-log.util';
import { TenantContextService } from '../../config/tenant-context.service';
import { JwtPayload } from '../auth/types/jwt-payload.type';
import { CreateProductDto } from './dto/create-product.dto';
import { ListProductsQueryDto } from './dto/list-products.query.dto';
import { UpdateProductDto } from './dto/update-product.dto';
/**
* `sku` é a única constraint UNIQUE de `products_services` além da chave
* primária, então basta checar o código do erro — `meta.target` do driver
* Postgres do Prisma nem sempre vem populado (às vezes só o nome da
* constraint, às vezes indisponível), não dá pra confiar nele aqui.
*/
function isUniqueSkuViolation(error: unknown): boolean {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
@Injectable()
export class ProductsService {
constructor(private readonly tenantContext: TenantContextService) {}
async create(dto: CreateProductDto, actor: JwtPayload, ip?: string) {
const tenantId = this.tenantContext.getTenantId();
const tx = this.tenantContext.getTx();
let produto;
try {
produto = await tx.products_services.create({ data: { ...dto, tenant_id: tenantId } });
} catch (error) {
if (isUniqueSkuViolation(error)) {
throw new ConflictException(`SKU "${dto.sku}" já cadastrado para este tenant`);
}
throw error;
}
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: tenantId,
acao: 'criar_produto',
entidade: 'products_services',
entidade_id: produto.id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return produto;
}
async findAll(query: ListProductsQueryDto) {
const tx = this.tenantContext.getTx();
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Prisma.products_servicesWhereInput = {
...(query.tipo ? { tipo: query.tipo } : {}),
...(query.ativo === undefined ? {} : { ativo: query.ativo }),
...(query.busca
? {
OR: [
{ nome: { contains: query.busca, mode: 'insensitive' } },
{ sku: { contains: query.busca, mode: 'insensitive' } },
],
}
: {}),
};
const data = await tx.products_services.findMany({
where,
orderBy: { nome: 'asc' },
skip: (page - 1) * limit,
take: limit,
});
const total = await tx.products_services.count({ where });
return { data, meta: { total, page, limit } };
}
async findOne(id: string) {
const tx = this.tenantContext.getTx();
const produto = await tx.products_services.findUnique({ where: { id } });
if (!produto) {
throw new NotFoundException('Produto/serviço não encontrado');
}
return produto;
}
async update(id: string, dto: UpdateProductDto, actor: JwtPayload, ip?: string) {
await this.findOne(id);
const tx = this.tenantContext.getTx();
let atualizado;
try {
atualizado = await tx.products_services.update({
where: { id },
data: { ...dto, atualizado_em: new Date() },
});
} catch (error) {
if (isUniqueSkuViolation(error)) {
throw new ConflictException(`SKU "${dto.sku}" já cadastrado para este tenant`);
}
throw error;
}
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: this.tenantContext.getTenantId(),
acao: 'atualizar_produto',
entidade: 'products_services',
entidade_id: id,
detalhes: dto as unknown as Prisma.InputJsonValue,
ip_origem: ip,
});
return atualizado;
}
/** Soft delete — produto usado em venda antiga não pode sumir do histórico. */
async remove(id: string, actor: JwtPayload, ip?: string) {
await this.findOne(id);
const tx = this.tenantContext.getTx();
const desativado = await tx.products_services.update({
where: { id },
data: { ativo: false, atualizado_em: new Date() },
});
await writeAuditLog(tx, {
usuario_tipo: 'tenant_user',
usuario_id: actor.sub,
tenant_id: this.tenantContext.getTenantId(),
acao: 'desativar_produto',
entidade: 'products_services',
entidade_id: id,
ip_origem: ip,
});
return desativado;
}
}
+484
View File
@@ -0,0 +1,484 @@
import { randomUUID } from 'node:crypto';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/config/prisma.service';
import { hashPassword } from '../src/modules/auth/password.util';
interface TokenPair {
access_token: string;
refresh_token: string;
}
describe('Painel Tenant — Customers, Contatos e Produtos/Serviços (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
let httpServer: Parameters<typeof request>[0];
const senha = 'SenhaForteDemo#26';
const suffix = randomUUID().slice(0, 8);
const masterEmail = `master-etapa5-${suffix}@teste.com`;
let masterId: string;
let masterToken: string;
const tenantIds: string[] = [];
const planoIds: string[] = [];
const tenantUserIds: string[] = [];
async function authMaster(): Promise<string> {
const res = await request(httpServer)
.post('/auth/master/login')
.send({ email: masterEmail, senha })
.expect(200);
return (res.body as TokenPair).access_token;
}
function asMaster() {
return {
get: (url: string) =>
request(httpServer).get(url).set('Authorization', `Bearer ${masterToken}`),
post: (url: string) =>
request(httpServer).post(url).set('Authorization', `Bearer ${masterToken}`),
};
}
function asTenant(token: string) {
return {
get: (url: string) => request(httpServer).get(url).set('Authorization', `Bearer ${token}`),
post: (url: string) => request(httpServer).post(url).set('Authorization', `Bearer ${token}`),
put: (url: string) => request(httpServer).put(url).set('Authorization', `Bearer ${token}`),
delete: (url: string) =>
request(httpServer).delete(url).set('Authorization', `Bearer ${token}`),
};
}
async function criarPlano(overrides: Partial<Record<string, unknown>> = {}) {
const res = await asMaster()
.post('/master/planos')
.send({
nome: `Plano ${suffix} ${randomUUID().slice(0, 4)}`,
valor_mensal: 199.9,
limite_instancias_whatsapp: 1,
...overrides,
})
.expect(201);
planoIds.push((res.body as { id: string }).id);
return res.body as { id: string };
}
async function criarTenantComAdmin(planoId: string) {
const emailAdmin = `admin-${randomUUID().slice(0, 8)}-${suffix}@teste.com`;
const res = await asMaster()
.post('/master/tenants')
.send({
razao_social: `Tenant Etapa5 ${suffix} ${randomUUID().slice(0, 6)}`,
email_contato: `contato-${randomUUID().slice(0, 6)}@teste.com`,
plano_id: planoId,
admin: { nome: 'Admin de Teste', email: emailAdmin },
})
.expect(201);
const body = res.body as {
tenant: { id: string };
admin: { id: string; email: string; senha_provisoria: string };
};
tenantIds.push(body.tenant.id);
tenantUserIds.push(body.admin.id);
await prisma.tenants.update({ where: { id: body.tenant.id }, data: { status: 'ativo' } });
const login = await request(httpServer)
.post('/auth/tenant/login')
.send({ email: body.admin.email, senha: body.admin.senha_provisoria })
.expect(200);
return {
tenant: body.tenant,
admin: body.admin,
token: (login.body as TokenPair).access_token,
};
}
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
httpServer = app.getHttpServer() as Parameters<typeof request>[0];
prisma = app.get(PrismaService);
const senha_hash = await hashPassword(senha);
const master = await prisma.master_admins.create({
data: { nome: 'Master Etapa 5', email: masterEmail, senha_hash },
});
masterId = master.id;
masterToken = await authMaster();
});
afterAll(async () => {
await prisma.tenants.deleteMany({ where: { id: { in: tenantIds } } });
await prisma.planos.deleteMany({ where: { id: { in: planoIds } } });
await prisma.auth_refresh_tokens.deleteMany({
where: { usuario_id: { in: [masterId, ...tenantUserIds] } },
});
await prisma.login_attempts.deleteMany({
where: { usuario_tipo: 'master', email: masterEmail },
});
await prisma.audit_logs.deleteMany({ where: { usuario_id: masterId } });
await prisma.master_admins.deleteMany({ where: { id: masterId } });
await app.close();
});
describe('customers — CRUD, busca e soft delete', () => {
let token: string;
beforeAll(async () => {
const plano = await criarPlano();
const tenant = await criarTenantComAdmin(plano.id);
token = tenant.token;
});
it('cria cliente só com o campo obrigatório (razao_social)', async () => {
const res = await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Mínimo Ltda' })
.expect(201);
expect(res.body).toMatchObject({ razao_social: 'Cliente Mínimo Ltda', ativo: true });
});
it('razao_social ausente retorna 400', async () => {
await asTenant(token).post('/tenant/customers').send({}).expect(400);
});
it('aceita documento em formato CPF ou CNPJ válido e rejeita formato inválido', async () => {
await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente CPF', documento: '123.456.789-01' })
.expect(201);
await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente CNPJ', documento: '12.345.678/0001-99' })
.expect(201);
await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Documento Inválido', documento: '123' })
.expect(400);
});
it('lista com busca por nome/documento, filtro por ativo e paginação', async () => {
await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Empresa Buscável Xyz' })
.expect(201);
const porNome = await asTenant(token).get('/tenant/customers?busca=Buscável').expect(200);
const body = porNome.body as { data: Array<{ razao_social: string }>; meta: unknown };
expect(body.data.length).toBeGreaterThanOrEqual(1);
expect(body.data.every((c) => c.razao_social.includes('Buscável'))).toBe(true);
const paginada = await asTenant(token).get('/tenant/customers?page=1&limit=1').expect(200);
const paginadaBody = paginada.body as { data: unknown[]; meta: { limit: number } };
expect(paginadaBody.data.length).toBe(1);
expect(paginadaBody.meta.limit).toBe(1);
});
it('atualiza cliente', async () => {
const criado = await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Para Editar' })
.expect(201);
const id = (criado.body as { id: string }).id;
const atualizado = await asTenant(token)
.put(`/tenant/customers/${id}`)
.send({ razao_social: 'Cliente Editado', cidade: 'São Paulo', uf: 'SP' })
.expect(200);
expect(atualizado.body).toMatchObject({
razao_social: 'Cliente Editado',
cidade: 'São Paulo',
uf: 'SP',
});
});
it('DELETE faz soft delete — linha continua existindo com ativo=false', async () => {
const criado = await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Para Desativar' })
.expect(201);
const id = (criado.body as { id: string }).id;
await asTenant(token).delete(`/tenant/customers/${id}`).expect(200);
const naBase = await prisma.customers.findUnique({ where: { id } });
expect(naBase).not.toBeNull();
expect(naBase?.ativo).toBe(false);
const viaApi = await asTenant(token).get(`/tenant/customers/${id}`).expect(200);
expect((viaApi.body as { ativo: boolean }).ativo).toBe(false);
const listaAtivos = await asTenant(token).get('/tenant/customers?ativo=true').expect(200);
const idsAtivos = (listaAtivos.body as { data: Array<{ id: string }> }).data.map((c) => c.id);
expect(idsAtivos).not.toContain(id);
});
});
describe('customers — limite do plano', () => {
it('criar cliente além do limite_customers do plano retorna 400 com mensagem clara', async () => {
const plano = await criarPlano({ limite_customers: 1 });
const tenant = await criarTenantComAdmin(plano.id);
await asTenant(tenant.token)
.post('/tenant/customers')
.send({ razao_social: 'Primeiro Cliente (dentro do limite)' })
.expect(201);
const bloqueado = await asTenant(tenant.token)
.post('/tenant/customers')
.send({ razao_social: 'Segundo Cliente (estoura o limite)' })
.expect(400);
expect((bloqueado.body as { message: string }).message).toMatch(/limite/i);
});
});
describe('customer_contacts — CRUD aninhado e contato principal único', () => {
let token: string;
let customerId: string;
beforeAll(async () => {
const plano = await criarPlano();
const tenant = await criarTenantComAdmin(plano.id);
token = tenant.token;
const customer = await asTenant(token)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Com Contatos' })
.expect(201);
customerId = (customer.body as { id: string }).id;
});
it('cria contato principal e, ao marcar outro como principal, desmarca o anterior', async () => {
const primeiro = await asTenant(token)
.post(`/tenant/customers/${customerId}/contatos`)
.send({ nome: 'Contato Um', principal: true })
.expect(201);
const primeiroId = (primeiro.body as { id: string; principal: boolean }).id;
expect((primeiro.body as { principal: boolean }).principal).toBe(true);
const segundo = await asTenant(token)
.post(`/tenant/customers/${customerId}/contatos`)
.send({ nome: 'Contato Dois', principal: true })
.expect(201);
expect((segundo.body as { principal: boolean }).principal).toBe(true);
const listagem = await asTenant(token)
.get(`/tenant/customers/${customerId}/contatos`)
.expect(200);
const contatos = listagem.body as Array<{ id: string; principal: boolean }>;
const contatoPrimeiroAtualizado = contatos.find((c) => c.id === primeiroId);
expect(contatoPrimeiroAtualizado?.principal).toBe(false);
expect(contatos.filter((c) => c.principal).length).toBe(1);
});
it('atualiza e remove contato', async () => {
const criado = await asTenant(token)
.post(`/tenant/customers/${customerId}/contatos`)
.send({ nome: 'Contato Temporário' })
.expect(201);
const contactId = (criado.body as { id: string }).id;
await asTenant(token)
.put(`/tenant/customers/${customerId}/contatos/${contactId}`)
.send({ cargo: 'Financeiro' })
.expect(200);
await asTenant(token)
.delete(`/tenant/customers/${customerId}/contatos/${contactId}`)
.expect(200);
await asTenant(token)
.get(`/tenant/customers/${customerId}/contatos/${contactId}`)
.expect(404);
});
it('contato de customer inexistente retorna 404', async () => {
await asTenant(token)
.post(`/tenant/customers/${randomUUID()}/contatos`)
.send({ nome: 'Não Deveria Criar' })
.expect(404);
});
});
describe('produtos/serviços — CRUD, busca, soft delete e SKU único por tenant', () => {
let token: string;
beforeAll(async () => {
const plano = await criarPlano();
const tenant = await criarTenantComAdmin(plano.id);
token = tenant.token;
});
it('cria produto e serviço válidos', async () => {
const produto = await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Cabo HDMI', valor_venda: 39.9 })
.expect(201);
expect(produto.body).toMatchObject({ tipo: 'produto', nome: 'Cabo HDMI', ativo: true });
const servico = await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'servico', nome: 'Instalação', valor_venda: 150, recorrente: false })
.expect(201);
expect(servico.body).toMatchObject({ tipo: 'servico', nome: 'Instalação' });
});
it('tipo ausente ou valor_venda <= 0 retornam 400', async () => {
await asTenant(token)
.post('/tenant/produtos')
.send({ nome: 'Sem Tipo', valor_venda: 10 })
.expect(400);
await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Valor Zero', valor_venda: 0 })
.expect(400);
});
it('SKU duplicado no mesmo tenant retorna 409 amigável', async () => {
const sku = `SKU-${randomUUID().slice(0, 8)}`;
await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto A', valor_venda: 10, sku })
.expect(201);
const conflito = await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto B', valor_venda: 20, sku })
.expect(409);
expect((conflito.body as { message: string }).message).toMatch(/sku/i);
});
it('SKU igual é permitido em tenants diferentes', async () => {
const plano = await criarPlano();
const outroTenant = await criarTenantComAdmin(plano.id);
const sku = `SKU-COMPARTILHADO-${randomUUID().slice(0, 8)}`;
await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto Tenant A', valor_venda: 10, sku })
.expect(201);
await asTenant(outroTenant.token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto Tenant B', valor_venda: 10, sku })
.expect(201);
});
it('lista com filtro por tipo/ativo e busca por nome/sku', async () => {
const sku = `SKU-BUSCA-${randomUUID().slice(0, 8)}`;
await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'servico', nome: 'Serviço Filtrável', valor_venda: 50, sku })
.expect(201);
const porTipo = await asTenant(token).get('/tenant/produtos?tipo=servico').expect(200);
const bodyTipo = porTipo.body as { data: Array<{ tipo: string }> };
expect(bodyTipo.data.every((p) => p.tipo === 'servico')).toBe(true);
const porSku = await asTenant(token).get(`/tenant/produtos?busca=${sku}`).expect(200);
const bodySku = porSku.body as { data: Array<{ sku: string | null }> };
expect(bodySku.data.some((p) => p.sku === sku)).toBe(true);
});
it('DELETE faz soft delete — linha continua existindo com ativo=false', async () => {
const criado = await asTenant(token)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto Para Desativar', valor_venda: 10 })
.expect(201);
const id = (criado.body as { id: string }).id;
await asTenant(token).delete(`/tenant/produtos/${id}`).expect(200);
const naBase = await prisma.products_services.findUnique({ where: { id } });
expect(naBase).not.toBeNull();
expect(naBase?.ativo).toBe(false);
});
});
describe('isolamento entre tenants (RLS)', () => {
let tokenA: string;
let tokenB: string;
let customerIdA: string;
let productIdA: string;
beforeAll(async () => {
const planoA = await criarPlano();
const tenantA = await criarTenantComAdmin(planoA.id);
tokenA = tenantA.token;
const planoB = await criarPlano();
const tenantB = await criarTenantComAdmin(planoB.id);
tokenB = tenantB.token;
const customer = await asTenant(tokenA)
.post('/tenant/customers')
.send({ razao_social: 'Cliente Só do Tenant A' })
.expect(201);
customerIdA = (customer.body as { id: string }).id;
const produto = await asTenant(tokenA)
.post('/tenant/produtos')
.send({ tipo: 'produto', nome: 'Produto Só do Tenant A', valor_venda: 10 })
.expect(201);
productIdA = (produto.body as { id: string }).id;
});
it('tenant B não enxerga customer do tenant A mesmo sabendo o UUID exato (404, não 403)', async () => {
await asTenant(tokenB).get(`/tenant/customers/${customerIdA}`).expect(404);
await asTenant(tokenB)
.put(`/tenant/customers/${customerIdA}`)
.send({ razao_social: 'Tentativa de Editar' })
.expect(404);
await asTenant(tokenB).delete(`/tenant/customers/${customerIdA}`).expect(404);
const lista = await asTenant(tokenB).get('/tenant/customers').expect(200);
const ids = (lista.body as { data: Array<{ id: string }> }).data.map((c) => c.id);
expect(ids).not.toContain(customerIdA);
});
it('tenant B não enxerga produto do tenant A mesmo sabendo o UUID exato (404, não 403)', async () => {
await asTenant(tokenB).get(`/tenant/produtos/${productIdA}`).expect(404);
await asTenant(tokenB)
.put(`/tenant/produtos/${productIdA}`)
.send({ nome: 'Tentativa de Editar' })
.expect(404);
await asTenant(tokenB).delete(`/tenant/produtos/${productIdA}`).expect(404);
});
it('tenant B não enxerga contatos do customer do tenant A', async () => {
await asTenant(tokenB).get(`/tenant/customers/${customerIdA}/contatos`).expect(404);
});
});
describe('acesso master sem contexto de tenant', () => {
it('master "puro" recebe 403 em todos os endpoints desta etapa', async () => {
await asMaster().get('/tenant/customers').expect(403);
await asMaster().get('/tenant/produtos').expect(403);
});
});
});