diff --git a/Etapas/09-controle-de-progresso.md b/Etapas/09-controle-de-progresso.md index 8c91f32..543ef31 100644 --- a/Etapas/09-controle-de-progresso.md +++ b/Etapas/09-controle-de-progresso.md @@ -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. diff --git a/src/app.module.ts b/src/app.module.ts index bfc0ccd..41fc63d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 }], }) diff --git a/src/modules/customers/customer-contacts.controller.ts b/src/modules/customers/customer-contacts.controller.ts new file mode 100644 index 0000000..87d5f8e --- /dev/null +++ b/src/modules/customers/customer-contacts.controller.ts @@ -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); + } +} diff --git a/src/modules/customers/customer-contacts.service.ts b/src/modules/customers/customer-contacts.service.ts new file mode 100644 index 0000000..0e041e7 --- /dev/null +++ b/src/modules/customers/customer-contacts.service.ts @@ -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, + }); + } +} diff --git a/src/modules/customers/customers.controller.ts b/src/modules/customers/customers.controller.ts new file mode 100644 index 0000000..0807ff8 --- /dev/null +++ b/src/modules/customers/customers.controller.ts @@ -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); + } +} diff --git a/src/modules/customers/customers.module.ts b/src/modules/customers/customers.module.ts new file mode 100644 index 0000000..0539be6 --- /dev/null +++ b/src/modules/customers/customers.module.ts @@ -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 {} diff --git a/src/modules/customers/customers.service.ts b/src/modules/customers/customers.service.ts new file mode 100644 index 0000000..dcac530 --- /dev/null +++ b/src/modules/customers/customers.service.ts @@ -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; + } +} diff --git a/src/modules/customers/documento.util.ts b/src/modules/customers/documento.util.ts new file mode 100644 index 0000000..9f1f39e --- /dev/null +++ b/src/modules/customers/documento.util.ts @@ -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)'; diff --git a/src/modules/customers/dto/create-contact.dto.ts b/src/modules/customers/dto/create-contact.dto.ts new file mode 100644 index 0000000..7169b15 --- /dev/null +++ b/src/modules/customers/dto/create-contact.dto.ts @@ -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; +} diff --git a/src/modules/customers/dto/create-customer.dto.ts b/src/modules/customers/dto/create-customer.dto.ts new file mode 100644 index 0000000..63cf7a9 --- /dev/null +++ b/src/modules/customers/dto/create-customer.dto.ts @@ -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; +} diff --git a/src/modules/customers/dto/list-customers.query.dto.ts b/src/modules/customers/dto/list-customers.query.dto.ts new file mode 100644 index 0000000..fe8f4b1 --- /dev/null +++ b/src/modules/customers/dto/list-customers.query.dto.ts @@ -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; +} diff --git a/src/modules/customers/dto/update-contact.dto.ts b/src/modules/customers/dto/update-contact.dto.ts new file mode 100644 index 0000000..45366fb --- /dev/null +++ b/src/modules/customers/dto/update-contact.dto.ts @@ -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; +} diff --git a/src/modules/customers/dto/update-customer.dto.ts b/src/modules/customers/dto/update-customer.dto.ts new file mode 100644 index 0000000..8b49bee --- /dev/null +++ b/src/modules/customers/dto/update-customer.dto.ts @@ -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; +} diff --git a/src/modules/products/dto/create-product.dto.ts b/src/modules/products/dto/create-product.dto.ts new file mode 100644 index 0000000..adc56cd --- /dev/null +++ b/src/modules/products/dto/create-product.dto.ts @@ -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; +} diff --git a/src/modules/products/dto/list-products.query.dto.ts b/src/modules/products/dto/list-products.query.dto.ts new file mode 100644 index 0000000..ed52594 --- /dev/null +++ b/src/modules/products/dto/list-products.query.dto.ts @@ -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; +} diff --git a/src/modules/products/dto/update-product.dto.ts b/src/modules/products/dto/update-product.dto.ts new file mode 100644 index 0000000..29cb3b7 --- /dev/null +++ b/src/modules/products/dto/update-product.dto.ts @@ -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; +} diff --git a/src/modules/products/products.controller.ts b/src/modules/products/products.controller.ts new file mode 100644 index 0000000..bfe7486 --- /dev/null +++ b/src/modules/products/products.controller.ts @@ -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); + } +} diff --git a/src/modules/products/products.module.ts b/src/modules/products/products.module.ts new file mode 100644 index 0000000..9b00f47 --- /dev/null +++ b/src/modules/products/products.module.ts @@ -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 {} diff --git a/src/modules/products/products.service.ts b/src/modules/products/products.service.ts new file mode 100644 index 0000000..a913552 --- /dev/null +++ b/src/modules/products/products.service.ts @@ -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; + } +} diff --git a/test/tenant-customers-produtos.e2e-spec.ts b/test/tenant-customers-produtos.e2e-spec.ts new file mode 100644 index 0000000..716f4ca --- /dev/null +++ b/test/tenant-customers-produtos.e2e-spec.ts @@ -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[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 { + 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> = {}) { + 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[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); + }); + }); +});