Tema
Módulo Configurações
Localização: backend/src/modules/config/
Responsável por toda a configuração operacional do sistema: lojas, usuários, papéis (roles), integrações de comunicação (e-mail, WhatsApp, SMTP) e parâmetros de campanha. O módulo é composto pelos sub-módulos settings, roles, users e store-aliases, cada um com seu próprio controller e service.
Arquitetura geral
config/
├── settings/
│ ├── settings.controller.ts — HTTP layer
│ └── settings.service.ts — Lógica de negócio
├── roles/
│ ├── roles.controller.ts
│ └── roles.service.ts
├── users/
│ ├── users.controller.ts
│ ├── users.service.ts
│ ├── webhooks.controller.ts — Webhooks do Clerk
│ └── clerk-bootstrap.service.ts
└── store-aliases/
├── store-aliases.controller.ts
└── store-aliases.service.tsDependências transversais:
EncryptionService— criptografia AES-256-GCM de credenciais sensíveisPrismaService— acesso ao banco de dadosClerkAuthGuard— autenticação JWT via ClerkPermissionsGuard— autorização por permissão declarada no decorator@Permissions(...)TenantInterceptor— isola o contexto de organização por request- BullMQ queue
whatsapp-send— acesso ao cliente Redis viaqueue.client ChatwootDbService— operações diretas no banco do Chatwoot (desbloqueio de agentes)
Sub-módulo: Settings
Classe: SettingsService
Arquivo: backend/src/modules/config/settings/settings.service.ts
Controller: SettingsController em backend/src/modules/config/settings/settings.controller.ts
Permissão base: app:settings (rotas de loja) ou app:integrations (rotas de integração)
Método getEmailSettings(): Promise<EmailSettings | null>
Retorna as configurações de e-mail da primeira loja cadastrada. A senha é sempre mascarada como '********' antes de retornar ao frontend — o valor real nunca trafega pela API.
Fluxo:
- Busca a primeira loja via
Store.findFirst() - Se não há loja, retorna
null - Busca
EmailSettingscomwhere: { storeId: store.id } - Se
settings.passexiste, substitui pelo placeholder'********' - Retorna o objeto
Tabelas afetadas: Store (leitura), EmailSettings (leitura)
Método upsertEmailSettings(data: UpdateEmailSettingsDto): Promise<EmailSettings>
Cria ou atualiza a configuração de e-mail da loja. A senha é criptografada com AES-256-GCM antes de persistir.
Parâmetros:
data.pass— senha SMTP em texto puro (opcional na atualização)- Demais campos do
UpdateEmailSettingsDto
Fluxo:
- Busca a primeira loja; lança
NotFoundExceptionse não existe - Copia
dataparaencryptedData - Se
data.passfoi fornecida, chamaencryptionService.encrypt(data.pass)e substitui emencryptedData - Executa
emailSettings.upsert({ where: { storeId } })— cria se não existe, atualiza se existe
Segurança: A senha nunca é salva em plaintext. O formato armazenado é iv:encrypted:tag (veja EncryptionService).
Tabelas afetadas: Store (leitura), EmailSettings (escrita/upsert)
Método listWhatsappInstances(organizationId?: string): Promise<MaskedInstance[]>
Lista todas as instâncias WhatsApp da organização. Tokens, access tokens e app secrets são sempre mascarados como '********'.
Parâmetros:
organizationId— opcional; sem filtro lista todas as instâncias do sistema
Fluxo:
- Busca
StoreWhatsappNumber.findMany()com join emStore(id, name, tradeName) - Ordena por
isDefault DESC(padrão aparece primeiro) - Mapeia cada instância substituindo
token,accessTokeneappSecretpor'********'(ounullse não configurado)
Tabelas afetadas: StoreWhatsappNumber (leitura), Store (leitura via include)
Método addWhatsappInstance(data: CreateWhatsappInstanceDto): Promise<CreatedInstance>
Adiciona uma nova instância WhatsApp. É o único momento em que o webhookVerifyToken em texto puro é retornado — o usuário deve copiá-lo imediatamente pois não é recuperável.
Parâmetros do DTO:
name— nome da instâncianumber— número de telefoneprovider—'meta_cloud'ou outro providertoken— token de acesso da instância (opcional)wabaId— ID da WABA no Meta (opcional)phoneNumberId— ID do número no Meta (opcional)accessToken— access token de longa duração do Meta (opcional)appSecret— app secret do aplicativo Meta (opcional)webhookVerifyToken— token para validação de webhook Meta (opcional; se omitido, gera UUID)isDefault— se deve ser a instância padrão da loja
Fluxo:
- Busca a primeira loja; lança
NotFoundExceptionse não existe - Se
isDefault = true, rebaixa todas as demais instâncias da loja paraisDefault = false - Criptografa
token,accessTokeneappSecret(se fornecidos) viaEncryptionService.encrypt() - Gera o
webhookVerifyToken:- Usa o valor fornecido (após
.trim()) ou gera um UUID viarandomUUID() - Armazena o hash SHA-256 do token (não o plaintext)
- Usa o valor fornecido (após
- Cria o registro em
StoreWhatsappNumbercomstatus: 'CONNECTED' - Retorna o objeto com
webhookVerifyTokenem plaintext (única vez), masaccessTokeneappSecretmascarados
Segurança:
token,accessToken,appSecret→ criptografados com AES-256-GCMwebhookVerifyToken→ armazenado como SHA-256 (suficiente para comparação, impossível de reverter)- Token retornado em plaintext apenas nesta resposta de criação
Tabelas afetadas: Store (leitura), StoreWhatsappNumber (escrita)
Método updateWhatsappInstance(id: string, data: UpdateWhatsappInstanceDto): Promise<MaskedInstance>
Atualiza campos de uma instância WhatsApp existente. Re-criptografa credenciais somente quando um novo valor real é enviado — enviar '********' sinaliza "manter como está".
Parâmetros:
id— UUID da instânciadata— campos editáveis:name,number,wabaId,phoneNumberId,isDefault,chatwootInboxId,accessToken,appSecret
Fluxo:
- Valida que a instância existe; lança
NotFoundExceptionse não - Se
isDefault = true, rebaixa as demais instâncias da mesma loja - Monta
updateDatacom apenas os campos presentes no DTO (camposundefinedsão ignorados) - Para
accessTokeneappSecret: só re-criptografa se o valor é diferente de'********' - Executa
StoreWhatsappNumber.update() - Retorna o objeto com
accessTokeneappSecretmascarados
Tabelas afetadas: StoreWhatsappNumber (escrita)
Método deleteWhatsappInstance(id: string): Promise<StoreWhatsappNumber>
Remove uma instância WhatsApp e seus dados dependentes em cascata manual (Prisma não faz cascade automático nessas relações).
Fluxo:
- Remove todos os
WhatsappTemplatevinculados à instância - Remove todas as
WhatsappMessagevinculadas à instância - Remove o registro
StoreWhatsappNumber
Tabelas afetadas: WhatsappTemplate (deleção), WhatsappMessage (deleção), StoreWhatsappNumber (deleção)
Método exchangeMetaCode(code: string, redirectUri: string): Promise<CreatedInstance>
Implementa o fluxo OAuth do Meta para onboarding de WhatsApp Business. Troca o authorization code por um access token e descobre automaticamente a WABA associada.
Parâmetros:
code— authorization code retornado pelo Meta OAuth dialogredirectUri— URI de redirecionamento usada no OAuth (deve ser idêntica à usada na abertura do dialog)
Fluxo completo:
Passo 1 — Troca do code por access_token:
- GET
https://graph.facebook.com/v19.0/oauth/access_tokencomclient_id,client_secret,code,redirect_uri - Valida que a resposta contém
access_token; caso contrário lança erro descritivo
Passo 2 — Descoberta do WABA ID (4 estratégias em cascata):
| Estratégia | Endpoint | Condição de uso |
|---|---|---|
| A | debug_token → granular_scopes | Funciona quando o app é Tech Provider |
| B | /me/whatsapp_business_accounts | Fallback genérico |
| C | /me/businesses → owned_whatsapp_business_accounts | Para contas com Business Manager |
| D | /{userId}/whatsapp_business_accounts (app token) | Último recurso via user_id do debug_token |
Se nenhuma estratégia encontrar um WABA ID, lança erro explicativo orientando o usuário a configurar o Meta Business Manager.
Passo 3 — Busca dados da WABA e números:
- GET
/{wabaId}?fields=id,name,phone_numbers{id,display_phone_number,verified_name} - Valida que há ao menos um número de telefone; caso contrário lança erro
- Usa o primeiro número encontrado
Passo 4 — Criação da instância:
- Delega para
addWhatsappInstance()comprovider: 'meta_cloud'e dados da WABA
Segurança: O appSecret nunca é logado em plaintext. Logs usam maskValue(appSecret) que exibe {primeiros4}***{últimos4}.
Tabelas afetadas: Idem a addWhatsappInstance()
Método getStore(): Promise<Store | null>
Retorna os dados da loja padrão (primeira loja encontrada). Não aplica filtro por organização — assume single-tenant por loja.
Tabelas afetadas: Store (leitura)
Método updateStore(data: { name?, cnpj?, cityNormalized? }): Promise<Store>
Atualiza nome, CNPJ e cidade normalizada da loja padrão.
Fluxo:
- Busca a primeira loja; lança
NotFoundExceptionse não existe - Atualiza apenas os campos
name,cnpjecityNormalized
Tabelas afetadas: Store (escrita)
Método getStores(organizationId: string): Promise<Store[]>
Lista todas as lojas da organização com campos resumidos: id, name, tradeName, isEcommerce, isActive. Ordenadas alfabeticamente por nome.
Tabelas afetadas: Store (leitura)
Método getBusinessHours(storeId: string, organizationId: string): Promise<{ businessHours: Json | null }>
Retorna a configuração de horários de funcionamento de uma loja específica. Valida pertencimento à organização antes de retornar.
Tabelas afetadas: Store (leitura)
Método setBusinessHours(storeId, businessHours, organizationId): Promise<{ updated: true }>
Persiste a configuração de horários e dispara a limpeza de flags Redis para forçar reavaliação imediata.
Parâmetros:
storeId— UUID da lojabusinessHours— objeto JSON com o schedule por dia da semanaorganizationId— UUID da organização (validação de tenant)
Fluxo:
- Valida que a loja existe e pertence à organização
- Salva
businessHourscomoPrisma.InputJsonValueno campoStore.businessHours - Chama
clearBusinessHoursFlags()para limpar o estado Redis
Tabelas afetadas: Store (escrita)
Método privado clearBusinessHoursFlags(storeId, organizationId, businessHours)
Limpa as flags Redis de business hours após uma atualização de horários, garantindo que o sistema reavalie o status de abertura imediatamente.
Fluxo:
- Busca todas as instâncias WhatsApp da loja que têm
chatwootInboxIdconfigurado - Se não há instâncias com inbox, retorna sem fazer nada
- Calcula a data atual em horário de Brasília (UTC-3)
- Chama
isTodayClosePast()para saber se o horário de fechamento de hoje já passou - Para cada inbox, marca para deleção as chaves Redis:
bh:warned:{orgId}:{inboxId}:{date}— sempre deletadabh:blocked:{orgId}:{inboxId}:{date}— deletada apenas se o fechamento não passoubh:rotated:{orgId}:{inboxId}:{date}— deletada apenas se o fechamento não passou
- Executa
redis.del(...keys)em batch - Se o fechamento não passou (
closeAlreadyPast = false), chamaChatwootDbService.unblockInboxAgents()para cada inbox — permite que agentes façam login imediatamente sem esperar o cron
Redis keys afetadas:
bh:warned:{organizationId}:{chatwootInboxId}:{YYYY-MM-DD}bh:blocked:{organizationId}:{chatwootInboxId}:{YYYY-MM-DD}bh:rotated:{organizationId}:{chatwootInboxId}:{YYYY-MM-DD}
Erros: Falhas são logadas como warn mas não propagadas — o update do banco já foi confirmado.
Método privado isTodayClosePast(businessHours): boolean
Determina se o horário de fechamento do dia atual (em BRT) já passou, considerando uma tolerância de 60 minutos após o horário configurado.
Lógica:
- Converte
UTCparaBRT(UTC-3) manualmente (sem biblioteca de timezone) - Determina o dia da semana local
- Lê
schedule[localDay].closedo objeto de horários - Compara o horário atual (em minutos desde meia-noite) com
closeHour * 60 + closeMinute + 60 - Retorna
falseem caso de qualquer erro (comportamento seguro: assume que ainda está aberto)
Método setEcommerceStore(storeId, isEcommerce, organizationId): Promise<Store>
Define qual loja é a loja e-commerce da organização. Apenas uma loja pode ser e-commerce por vez.
Fluxo:
- Valida que a loja existe e pertence à organização
- Se
isEcommerce = true, rebaixa todas as outras lojas da organização paraisEcommerce = false - Atualiza a loja alvo
Tabelas afetadas: Store (escrita múltipla via updateMany + update)
Método getOrg(organizationId: string): Promise<{ id, name, logoUrl } | null>
Retorna o nome e URL do logo da organização.
Tabelas afetadas: Organization (leitura)
Método updateOrg(organizationId, data: { logoUrl? }): Promise<{ id, name, logoUrl }>
Atualiza a URL do logo da organização.
Tabelas afetadas: Organization (escrita)
Método getCampaignSettings(organizationId: string): Promise<CampaignSettings>
Retorna as configurações de campanha da organização. Todos os campos têm valores padrão garantidos quando não configurados.
Valores padrão:
| Campo | Padrão |
|---|---|
attributionWindowDays | 7 |
replyWindowHours | 48 |
optOutMessage | Mensagem padrão de descadastro |
redirectGreeting | Saudação padrão |
redirectConfirmation | Confirmação padrão com e |
messageLimitDays | null (desabilitado) |
messageLimitCount | null (desabilitado) |
blockMultipleCampaigns | false |
Tabelas afetadas: Organization (leitura do campo JSON campaignSettings)
Método updateCampaignSettings(organizationId, data): Promise<CampaignSettings>
Atualiza as configurações de campanha com validação e sanitização de valores. Aplica merge com os valores existentes — campos não enviados são preservados.
Validações e limites:
| Campo | Mínimo | Máximo | Tratamento |
|---|---|---|---|
attributionWindowDays | 1 | 90 | Math.min(Math.max(1, val), 90) |
replyWindowHours | 1 | 720 | Math.min(Math.max(1, val), 720) |
optOutMessage | — | 1000 chars | .trim().slice(0, 1000) |
redirectGreeting | — | 1000 chars | .trim().slice(0, 1000) |
redirectConfirmation | — | 2000 chars | .trim().slice(0, 2000) |
messageLimitDays | 1 ou null | 365 | null = desabilita |
messageLimitCount | 1 ou null | 100 | null = desabilita |
blockMultipleCampaigns | — | — | boolean direto |
Tabelas afetadas: Organization (escrita do campo JSON campaignSettings)
Método getOrgSmtp(organizationId: string): Promise<SmtpConfig | null>
Retorna a configuração SMTP da organização com a senha parcialmente mascarada usando maskValue() (exibe {primeiros4}***{últimos4}).
Tabelas afetadas: OrganizationSmtpSettings (leitura)
Método upsertOrgSmtp(organizationId, data): Promise<{ saved: true }>
Cria ou atualiza a configuração SMTP da organização. A senha é criptografada com AES-256-GCM.
Parâmetros:
senderName,senderEmail,host,port,user,secure— obrigatóriospass— opcional; se omitida, mantém a senha atual
Fluxo:
- Busca a configuração existente
- Se
data.passfoi fornecida:encryptedPass = encryptionService.encrypt(data.pass) - Caso contrário: reutiliza
existing.pass(já criptografada) - Executa
OrganizationSmtpSettings.upsert()
Tabelas afetadas: OrganizationSmtpSettings (escrita/upsert)
Método privado maskValue(value?, visibleStart = 4, visibleEnd = 4): string | null
Utilitário de mascaramento para exibição de credenciais na UI.
Comportamento:
- Se
valueé nulo/vazio: retornanull - Se
value.length <= visibleStart + visibleEnd: retorna'*'.repeat(value.length)(mascara tudo) - Caso contrário: retorna
{primeiros4}***{últimos4}
Exemplo: 'sk_live_abcdef1234' → 'sk_l***1234'
Sub-módulo: EncryptionService
Arquivo: backend/src/common/services/encryption.service.ts
Serviço singleton responsável por criptografia simétrica de credenciais. Inicializado no onModuleInit do NestJS.
Algoritmo
AES-256-GCM — cifra autenticada que garante confidencialidade e integridade dos dados.
Inicialização (onModuleInit)
- Busca a
MASTER_ENCRYPTION_KEYviaSecretProvider(Azure Key Vault em produção) - Deriva uma chave de 32 bytes via SHA-256 da string da key — garante que qualquer string se torna uma chave válida para AES-256
Método encrypt(text: string): string
Criptografa um texto puro e retorna no formato {iv_hex}:{encrypted_hex}:{auth_tag_hex}.
Fluxo:
- Gera IV aleatório de 16 bytes via
crypto.randomBytes(16) - Cria cipher AES-256-GCM com a masterKey e o IV
- Cifra o texto (UTF-8 → HEX)
- Obtém o auth tag GCM (16 bytes)
- Concatena:
iv.hex + ':' + encrypted.hex + ':' + tag.hex
Método decrypt(hash: string): string
Descriptografa um hash no formato iv:encrypted:tag.
Fluxo:
- Split por
:extrai os 3 componentes - Valida que os 3 existem; lança erro em formato inválido
- Cria decipher, define auth tag, descriptografa
- Em caso de falha (key incorreta, dados corrompidos): lança
InternalServerErrorException
Sub-módulo: Roles
Classe: RolesService
Arquivo: backend/src/modules/config/roles/roles.service.ts
Permissão: app:roles
O sistema de papéis usa níveis hierárquicos numéricos onde menor número = maior autoridade. Level 0 é o Super Admin e tem poderes irrestri tos.
Método findAll(organizationId: string): Promise<Role[]>
Lista todos os papéis da organização ordenados por level ASC (do mais poderoso ao menos poderoso).
Tabelas afetadas: Role (leitura)
Método findOne(organizationId: string, roleId: string): Promise<Role>
Busca um papel pelo ID e valida pertencimento à organização. Lança NotFoundException se não existe ou pertence a outra organização.
Tabelas afetadas: Role (leitura)
Método create(organizationId, userLevel, data): Promise<Role>
Cria um novo papel. Aplica proteção de escalonamento: usuários não podem criar papéis com nível igual ou superior ao seu.
Parâmetros do data:
name— nome do papeldescription— descrição opcionallevel— nível hierárquico (inteiro positivo)permissions— array de strings de permissãoreadOnly— boolean (padrãofalse)
Validação de escalonamento:
- Se
userLevel !== 0(não é Super Admin) edata.level <= userLevel: lançaForbiddenException - Super Admin (
userLevel === 0): sem restrições
Tabelas afetadas: Role (criação)
Método update(organizationId, roleId, userLevel, data): Promise<Role>
Atualiza um papel existente. Aplica três camadas de proteção:
Camada 1 — Proteção do Super Admin:
- Se o papel a editar tem
level === 0euserLevel !== 0:ForbiddenException("Você não pode editar o cargo de Administrador")
Camada 2 — Proteção de hierarquia (papel alvo):
- Se
userLevel !== 0erole.level <= userLevel:ForbiddenException(papel alvo tem nível igual ou superior ao do editor)
Camada 3 — Proteção de escalonamento (nível destino):
- Se
userLevel !== 0edata.level !== undefinededata.level <= userLevel:ForbiddenException(tentativa de elevar o papel acima do próprio nível)
Tabelas afetadas: Role (escrita)
Método remove(organizationId, roleId, userLevel): Promise<Role>
Remove um papel. Aplica verificações de proteção e valida que o papel não está em uso.
Validações:
- Papel existe e pertence à organização (
findOne) - Proteção do Super Admin:
level === 0só pode ser deletado por outro Super Admin - Proteção de hierarquia: não pode deletar papel com nível ≤ ao do executante
- Verifica se há usuários vinculados (
User.count({ where: { roleId } })); se> 0: lançaForbiddenException
Tabelas afetadas: Role (deleção), User (contagem)
Sub-módulo: Users
Classe: UsersService
Arquivo: backend/src/modules/config/users/users.service.ts
Permissão: app:team
Gerencia o ciclo de vida dos usuários: convite, alteração de papel, remoção e reset de senha. Integra com a API do Clerk para todas as operações de identidade.
Método getOrganizationDetails(organizationId: string): Promise<{ name, syncStatus } | null>
Retorna nome e status de sincronização da organização. Usado pelo endpoint GET /users/me.
Tabelas afetadas: Organization (leitura)
Método findAll(currentUser): Promise<User[]>
Lista usuários. O comportamento varia conforme o nível do usuário:
Super Admin (role.level === 0):
- Retorna todos os usuários do sistema (sem filtro de organização)
- Inclui campos extras:
organizationId,clerkId
Outros usuários:
- Retorna apenas usuários da mesma organização
- Campos retornados:
id,name,email,role,roleId,createdAt,status
Tabelas afetadas: User (leitura), Role (via include implícito em role)
Método inviteUser(currentUser, email, roleId, name?, storeIds?, targetOrganizationId?): Promise<User>
Convida um novo usuário para a organização. Cria o convite no Clerk e um registro local com status INVITED.
Parâmetros:
currentUser— usuário que está convidandoemail— e-mail do convidadoroleId— UUID do papel a ser atribuídoname— nome opcionalstoreIds— lojas às quais o usuário terá acesso (opcional)targetOrganizationId— organização alvo (apenas Super Admin pode especificar outra organização)
Validações:
- Papel (
roleId) existe no banco;NotFoundExceptionse não currentUserLevel > 10: apenas gerentes (level ≤ 10) e Super Admin podem convidarcurrentUserLevel !== 0 && roleData.level <= currentUserLevel: não pode convidar para papel igual ou superior ao seu- Determina
finalOrgId: Super Admin pode usartargetOrganizationId; outros usamcurrentUser.organizationId - E-mail já existe na organização:
ConflictException
Fluxo de integração com Clerk:
- Busca a
Organizationlocal e seuclerkOrgId - Se a organização ainda não tem
clerkOrgId: cria a organização no Clerk viaclerkClient.organizations.createOrganization()e persiste oclerkOrgId - Cria o convite via
clerkClient.organizations.createOrganizationInvitation()comrole: 'org:member'eredirectUrl: {FRONTEND_URL}/sign-up - Cria o registro local
Usercom statusINVITED,clerkInvitationIde vínculos de loja (se informados)
Tratamento de erros do Clerk:
- HTTP 403: erro descritivo sobre permissão de admin da organização no Clerk
- HTTP 404 / 409: repropaga a mensagem do Clerk
- Outros: mensagem genérica de falha
Tabelas afetadas: Role (leitura), User (leitura + criação), Organization (leitura + possível escrita de clerkOrgId)
Rate limit: 30 requests por 60 segundos
Método updateRole(currentUser, targetUserId, newRoleId, storeIds?): Promise<User>
Altera o papel de um usuário. Aplica múltiplas camadas de proteção contra escalação de privilégios.
Validações:
- Auto-edição bloqueada (exceto Super Admin)
currentUserLevel > 10: apenas gerentes e Super Admin podem alterar perfis- Novo papel existe:
NotFoundExceptionse não - Usuário alvo existe:
NotFoundExceptionse não - Isolamento de tenant: usuário de outra organização →
ConflictException - Proteção de escalonamento (apenas para não-Super Admin):
- Novo papel tem
level <= currentUserLevel: não pode promover acima de si - Usuário alvo tem
role.level <= currentUserLevel: não pode alterar superior ou par
- Novo papel tem
Tabelas afetadas: Role (leitura), User (leitura + escrita)
Rate limit: 20 requests por 60 segundos
Método remove(currentUser, id): Promise<User>
Remove um usuário do sistema. Executa deleção em cascata manual e revoga o acesso no Clerk.
Validações:
- Auto-deleção bloqueada
- Usuário existe
- Isolamento de tenant (exceto Super Admin)
- Proteção de escalonamento: não pode remover superior ou par hierárquico
Fluxo de remoção no Clerk:
- Se
user.clerkIdexiste:clerkClient.users.deleteUser(user.clerkId) - Se status
INVITEDe temclerkInvitationId: buscaclerkOrgIdda organização e chamaclerkClient.organizations.revokeOrganizationInvitation() - Erros no Clerk são logados mas não propagados (o banco local é a fonte de verdade)
Limpeza de dados associados (antes de deletar o usuário):
AccessLog.deleteMany({ where: { userId: id } })— evita erro de FKSegment.updateMany({ where: { updatedById: id }, data: { updatedById: null } })— preserva segmentosUser.delete({ where: { id } })
Tabelas afetadas: AccessLog (deleção), Segment (atualização), User (deleção)
Método adminResetPassword(currentUser, targetUserId): Promise<{ success, message }>
Revoga todas as sessões ativas do usuário alvo no Clerk, forçando que ele use "Esqueci minha senha" no próximo acesso.
Validações:
- Usuário alvo existe
- Isolamento de tenant (exceto Super Admin)
- Usuário alvo deve ter
clerkId(não pode ser statusINVITED)
Fluxo:
- Busca todas as sessões ativas:
clerkClient.sessions.getSessionList({ userId: targetUser.clerkId }) - Revoga cada sessão:
clerkClient.sessions.revokeSession(session.id) - Registra o evento diretamente em
AccessLogcomactionType: 'ADMIN_PASSWORD_RESET'
Tabelas afetadas: User (leitura), AccessLog (criação direta)
Sub-módulo: TenantInterceptor
Arquivo: backend/src/common/interceptors/tenant.interceptor.ts
Interceptor global que estabelece o contexto de tenant (organização) para cada request autenticado. Usa AsyncLocalStorage via tenantContext.run() para isolar o contexto entre requests concorrentes.
Comportamento:
- Extrai
userdorequest(populado peloClerkAuthGuard) - Se
usertemrole: determinaactiveOrgId = user.organizationId - Override de Super Admin: Se
user.role.level === 0e o headerx-org-idcontém um UUID válido: substituiactiveOrgIdpelo valor do header (permite Super Admin operar em qualquer organização) - Chama
tenantContext.run({ organizationId, role }, callback)envolvendo o handler
Casos de warning (sem contexto):
user.organizationIdé nulo: log de aviso, request continua sem contexto de tenantuser.roleé nulo: log de aviso, request continua sem contexto de tenant
Header especial: x-org-id — exclusivo para Super Admin; UUIDs inválidos são ignorados
Horários de funcionamento — Estrutura JSON
O campo Store.businessHours armazena um objeto JSON com a seguinte estrutura:
json
{
"schedule": {
"monday": { "active": true, "open": "09:00", "close": "18:00" },
"tuesday": { "active": true, "open": "09:00", "close": "18:00" },
"wednesday": { "active": true, "open": "09:00", "close": "18:00" },
"thursday": { "active": true, "open": "09:00", "close": "18:00" },
"friday": { "active": true, "open": "09:00", "close": "17:00" },
"saturday": { "active": true, "open": "09:00", "close": "13:00" },
"sunday": { "active": false, "open": "00:00", "close": "00:00" }
}
}Regras de negócio:
- Ausência de configuração = sempre aberto (sem restrição)
- Turnos overnight suportados (ex:
open: "23:30",close: "02:00") - Timezone: BRT (UTC-3) — calculado manualmente no backend
- Ao salvar: flags Redis de business hours são limpas imediatamente para reavaliação instantânea
- Tolerância de fechamento: o sistema considera a loja fechada 60 minutos após o
closeconfigurado
Tabelas do banco afetadas pelo módulo Config
| Tabela | Operações |
|---|---|
Store | CRUD completo (nome, CNPJ, businessHours, isEcommerce) |
EmailSettings | Upsert (configurações de e-mail SMTP por loja) |
StoreWhatsappNumber | CRUD completo (instâncias WhatsApp) |
WhatsappTemplate | Deleção (cascata ao remover instância) |
WhatsappMessage | Deleção (cascata ao remover instância) |
Organization | Atualização (logoUrl, campaignSettings, clerkOrgId) |
OrganizationSmtpSettings | Upsert (SMTP global da organização) |
Role | CRUD completo (papéis da organização) |
User | CRUD completo (usuários, convites, papéis) |
AccessLog | Criação (reset de senha) e deleção (ao remover usuário) |
Segment | Atualização (desvincular updatedById ao remover usuário) |
Padrões de segurança resumidos
| Dado | Técnica | Onde |
|---|---|---|
| Senha SMTP (e-mail por loja) | AES-256-GCM | EmailSettings.pass |
| Senha SMTP (organização) | AES-256-GCM | OrganizationSmtpSettings.pass |
WhatsApp token | AES-256-GCM | StoreWhatsappNumber.token |
WhatsApp accessToken | AES-256-GCM | StoreWhatsappNumber.accessToken |
WhatsApp appSecret | AES-256-GCM | StoreWhatsappNumber.appSecret |
WhatsApp webhookVerifyToken | SHA-256 (hash unidirecional) | StoreWhatsappNumber.webhookVerifyToken |
| Exibição de credenciais na API | Sempre '********' ou maskValue() | Todos os endpoints GET |
webhookVerifyToken plaintext | Retornado apenas uma vez na criação | addWhatsappInstance() |
| Token Meta em logs | maskValue() — parcialmente mascarado | Logs do exchangeMetaCode() |