Skip to content

feat: inbox window + master data settings + contacts backend (camadas 1-3)#493

Open
x1strategyltda-art wants to merge 30 commits into
ChatbotXIO:mainfrom
x1strategyltda-art:feat/inbox-master-data-contacts
Open

feat: inbox window + master data settings + contacts backend (camadas 1-3)#493
x1strategyltda-art wants to merge 30 commits into
ChatbotXIO:mainfrom
x1strategyltda-art:feat/inbox-master-data-contacts

Conversation

@x1strategyltda-art
Copy link
Copy Markdown

Summary

Entrega cumulativa de 3 camadas da pirâmide ChatbotX (clone open-source Respond.io):

  • Camada 1 (Fundamentos): audit log infra + WorkspaceMember 3 roles + permissions estruturadas
  • Camada 5 (Inbox): sidebar 215 px, conversation list cards 98 px, conversation window pixel-perfect, composer (emoji picker / @ mention / comment box amber), contact drawer com inline edit
  • Camada 2 (Dados Mestres): páginas Settings pixel-perfect Respond.io pra Tags (/settings/tags), Custom Fields (/settings/contact-fields), Snippets (/settings/snippets), Bot Fields (/settings/bot-fields). Cores tag via tripleta HSL (bg L=18% / text L=93% / outline L=24%). Redirects 301 das URLs antigas.
  • Camada 3 (Contatos backend): audit log + recordContactEvent em 9 actions. ContactEvent expandido com 9 tipos. Merge contacts FROM SCRATCH (transaction Drizzle). Import CSV inline (até 500 rows) com validação E.164 + dedup.

12 audit log keys novas: TAG/CUSTOM_FIELD/BOT_FIELD/SNIPPET + CONTACT_TAG_ADDED/REMOVED/FIELD_UPDATED/MERGED/IMPORTED.

i18n em 3 locales (pt-BR, en, vi) em todas as features novas.

Worker fix: cast em flow.ts pra acomodar TriggerNode no runStepsAndQuickReplies.

Test plan

  • Testar criar/editar/deletar Tag em /settings/tags — confirmar audit log + ContactEvent
  • Testar criar/editar/deletar Custom Field em /settings/contact-fields — confirmar 8 colunas (Nome | ID | Descrição | Tipo | Visibilidade | Data | Ações)
  • Testar criar/editar/deletar Snippet em /settings/snippets
  • Testar criar/editar/deletar Bot Field em /settings/bot-fields
  • Confirmar redirects 301: /tags/settings/tags, /custom-fields/settings/contact-fields, /bot-fields/settings/bot-fields
  • Verificar audit log no DB após mutations: SELECT action FROM \"AuditLog\" WHERE \"workspaceId\" = ... ORDER BY \"createdAt\" DESC
  • Testar merge contacts via API: tags/customFields migram pro primary, conversations reassignadas, duplicates deletados
  • Testar import contatos com CSV (E.164 validado, dedup por phone, custom field mapping funciona)
  • Confirmar UI pixel-perfect tags chip: outline 1px sutil + radius 4px + trio HSL
  • Confirmar drawer ContactActivityLog renderiza todos os 9 event types com ícone + label

🤖 Generated with Claude Code

PEDRO and others added 30 commits May 26, 2026 11:19
- WhatsApp delivery tracking + reactions + bug 24h fixado
- Inbox card 84px + player audio bubble + leading-none avatares
- Workspace contact field visibility (parcial recovery)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ssignedId nos schemas

Recovery Fase 1 da Camada 2 Dados Mestres (sessão 2fb51603 perdida por
lefthook auto-stash em 2026-05-26).

action.ts: createContactRequest extende com assigneeUserId (responsável),
tagIds (etiquetas iniciais) e customFields (record opcional). gender
agora opcional para casar com tela "Novo Contato" pixel-perfect.

query.ts: listContactsRequest extende com lifecycleStageIds, blocked
(sidebar Bloqueados) e assignedId (filter Caixas/Times igual Inbox).
listContactsItem ganha lifecycleStage via createSelectSchema.
Recovery Fase 2 da Camada 2 Dados Mestres.

Nova ordem definida pelo Pedro 2026-05-26:
Análises → Inbox → Contatos → Agentes de IA → Transmissão → Fluxos →
Configurações.

Antes Fluxos estava em terceiro lugar (logo após Inbox). Move pra
posição 6, e Contatos sobe pra 3 — assim a sequência reflete a
priorização operacional (atender → cadastrar → automatizar).
…cycle + bloqueados)

Recovery Fase 3 da Camada 2 Dados Mestres.

ContactsAreaSidebar reescrita pra ficar pixel-perfect ao InboxAreaSidebar
(Pedro: "literalmente o mesmo"): filtros top Todos/Minhas/Não atribuídas,
seção Times via inboxTeams, Ciclo de vida com toggle global compartilhado
(chave chatbotx.lifecycle.visible), rodapé Contatos bloqueados.

Comportamento: navega via URL search params (assignedId, lifecycleStageId,
blocked) em vez de mutar chatStore. Active state vem de useSearchParams().
Botão collapse compartilha use-inbox-sidebar-collapsed com Inbox.

Layout absorve as novas props necessárias: currentUserId via getCurrentUserId,
inboxTeams via listInboxTeams, lifecycleCounts + assignmentCounts +
unreadByStage + unreadByAssignment via queries lifecycle-stages. Envolve
tudo em ChatStore/InboxStore/User/CustomField/Tag providers (mesma stack
do /inbox/layout) pra que o drawer "Detalhes do contato" reuse o
ContactDetail FULL.
…ields + responsável

Recovery Fase 4 da Camada 2 Dados Mestres.

Form reescrito pixel-perfect Respond.io: First Name → Last Name → Phone* →
Email → Responsável (Combobox de workspace members) → Tags (popover com
checkboxes e chips) → Custom Fields (accordion). Footer sticky Cancel +
Create no rodapé do Sheet.

Action createContact ganha suporte a 3 campos opcionais novos vindos do
schema: assigneeUserId (vira conversation.assignedUserId), tagIds (INSERT
em contactsToTagsModel) e customFields (record que vira rows em
contactCustomFieldModel) — tudo na mesma transaction Drizzle.

Renomeia onSubmmited → onSubmitted no form e no dialog (typo).

i18n: adiciona 8 chaves nos 3 locales (pt-BR/en/vi):
contacts.placeholders.{addFirstName,addLastName,addEmail,selectAssignee},
tags.{addTag,searchPlaceholder,emptyState}, customFields.addValue (com
interpolação {field}) e actions.remove.
… na conversation

Recovery Fase 5 da Camada 2 Dados Mestres.

Drizzle relational query API não aceita filter aninhado cross-table no
where (conversation: { assignedUserId: X } retorna "Unknown relational
filter field"). Solução: helper resolveContactIdsByAssignment pré-busca
os contactIds via SELECT direto em conversationModel pelo tipo de
assignedId vindo do sidebar:
  • "unassigned" → assignedUserId IS NULL AND assignedInboxTeamId IS NULL
  • "u_<userId>" → assignedUserId = <userId>
  • "t_<teamId>" → assignedInboxTeamId = <teamId>

listContacts curto-circuita com data vazia quando o lookup retorna zero
IDs (evita query desnecessária e mantém render consistente). generateWhere
recebe os IDs como segundo parâmetro e aplica id IN (...) no findMany.
…l pixel-perfect

Recovery Fase 6 (final) da Camada 2 Dados Mestres.

handleRowClick: alimenta chatStore com a conversation do contato
(prependConversation + setActiveConversationId) antes de abrir o drawer.
Assim o <ContactDetail> FULL — mesmo componente do Inbox — reusa a mesma
fonte de verdade (useChatStore.conversations) pra renderizar assignedUser,
tags reais, etc. Fecha o drawer limpa o activeConversationId no store.

Cell da coluna Nome refatorado pra <ContactNameCell> (componente novo no
fim do arquivo): seed do avatar = contact.id (snowflake imutável) +
useAvatarUrl pra imagem própria quando existe — mesmo padrão do
<ConversationItem> do Inbox (reference_avatares_consistentes), garantindo
visual idêntico em qualquer lugar que renderiza o contato.

Tabela:
  • Tags em UMA linha sem wrap — 3 chips + "+N" pro restante, evita row
    de Francisca alargar quando tem muitas tags.
  • Wrapper relative + drawer overlay (sem encolher tabela).
  • tableClassName: separator white/[0.06] sutil + tipografia
    text-text-secondary (#CFD3D8) Inter 14px weight 400 — pixel-perfect
    Respond.io (reference_fontes_respond_io).
Em algum commit anterior o item foi removido do navMain mas a rota
/manage/integrations/page.tsx continua existindo e renderizando
ManageOrganizationSettings (credenciais OAuth da org).

Volta no topo do navMain com Grid2x2PlusIcon (visual original).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(auth) trustedOrigins localhost

Paridade Respond.io Camada 1 (Fundamentos) — gap ChatbotXIO#7 catalogado na
auditoria 2026-05-27.

Schema: User.activityStatus (text default 'available' notNull) +
User.lastActiveAt (timestamptz nullable). Migration
20260527124958_add_user_activity_status.

Better-Auth: additionalFields registra ambos campos pra que
authClient.updateUser({ activityStatus }) funcione sem rota custom.
trustedOrigins agora aceita http://localhost:3123 além do
BETTER_AUTH_URL (que aponta pra ngrok quando rodando WhatsApp E2E) —
sem isso sign-in do localhost falhava com "Invalid origin".

utils.ts e middlewares/auth.ts: coerce default `?? "available"` no
spread de sessionData.user porque Better-Auth tipa additionalFields
como `string | null | undefined` mas UserModel exige `string` notNull.

UI: novo features/account/activity-status.tsx exporta
<ActivityStatusSelector> (dropdown 3 opções, chama authClient.updateUser)
e <ActivityStatusDot> (dot colorido reutilizável). profile-form troca
pill hardcoded "Available" por selector. nav-user (sidebar bottom)
ganha dot colorido metade-fora no avatar (estilo Slack) + seção
"Meu status" no dropdown pra trocar de qualquer lugar.

SidebarMenuButton tem overflow-hidden hardcoded — override com
overflow-visible! no nosso uso pra dot não ser cortado.

i18n 3 locales: namespace personalSettings.activityStatus com
sectionLabel/available/busy/offline/updateSuccess/updateError. Remove
key obsoleta statusAvailable.

MVP: sem auto-offline por inatividade (precisa heartbeat client +
worker cron) e sem broadcast realtime via PartySocket (status muda
local mas outros usuários só veem após refresh). Anotado como
follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Paridade Respond.io Camada 1 (gap ChatbotXIO#9 catalogado na auditoria
2026-05-27). O grupo agrupa Admins (Members) + Inbox Teams — ambos
configs do workspace. "Workspace" é mais conciso e alinha com o termo
"Workspace Settings" usado no menu de configurações Respond.io.

Label igual nos 3 locales (pt-BR/en/vi) — termo internacional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…XIO#13+ChatbotXIO#14)

Camada 2 Dados Mestres — 2 gaps cosméticos catalogados na auditoria
2026-05-27:

ChatbotXIO#13 Tags delete confirmação numérica: delete-tag-dialog ganha Input
field quando totalContacts > 0 (soma contactsCount das tags
selecionadas). Botão Delete habilita só quando user digita o número
exato. Sem uso = dialog simples (skip confirm). Bate Respond.io
("introduza o número de contactos atribuídos a esta etiqueta").

ChatbotXIO#14 Lifecycle limite 20 stages: constante MAX_LIFECYCLE_STAGES = 20
em schema/index.ts + refine() bloqueia save acima do limite.
lifecycle-stages-editor: addStage check + toast erro + botão
"Adicionar etapa" desabilitado com tooltip quando atinge limite
(active+lost combinados conforme workspace-settings.md doc).

i18n 3 locales: tags.delete.{usageDescription,confirmInputLabel}
(plural ICU) + lifecycle.maxStagesReached.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hatbotXIO#10 Fase A+B)

Paridade Respond.io Camada 2 (gap ChatbotXIO#10 catalogado 2026-05-27). Backend
completo; UI Add/Edit dialog (Fase C) e drawer Inbox render (Fase D)
seguem em commits separados.

Schema: customFieldTypes enum ganha "time", "url", "list" (Respond.io
tem todos). customFieldVisibilities enum novo
(alwaysShow/alwaysHide/hideWhenEmpty). CustomField.showInInbox boolean
substituído por CustomField.visibility text (default "alwaysShow").
Coluna CustomField.values (jsonb) pra opções pré-definidas do tipo
list.

Migration 20260527133314_custom_field_visibility_types_values:
manual (Drizzle não consegue ADD VALUE em pgEnum sem prompt). ALTER
TYPE ADD VALUE x3 + ADD COLUMN visibility + UPDATE backfill
showInInbox→visibility + DROP COLUMN showInInbox + ADD COLUMN values.

Cascade:
- custom-field-hook.ts customFieldIconsMap ganha 3 ícones lucide
  (Clock/Link/List)
- create-custom-field.action remove showInInbox: true (default schema
  cobre)
- custom-field-table cell visibility renderiza 3 estados via Record
  top-level VISIBILITY_LABEL_KEY (evita nested ternary)

Próximo: Fase C — Add/Edit dialog renderiza chips de values pra type
list + Customize Visibility 3-state. Fase D — drawer Inbox renderiza
input específico por tipo + respeita hideWhenEmpty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…botXIO#10 Fase C)

Paridade Respond.io Camada 2 (gap ChatbotXIO#10, parte C). Fases A+B (backend +
provider) entregues em 5a3acda.

Schemas action: createCustomFieldRequest e updateCustomFieldRequest
ganham visibility (enum) e values (string[] max 100) opcionais.
customFieldTypes.options já tinha os 3 novos tipos (commit anterior).

UI:
- custom-field-label.tsx: switch case → Record TYPE_LABEL_KEY com 11
  entradas (paridade Respond.io: shortText/longText/list/boolean/email/
  phoneNumber/number/url/date/datetime/time).
- create-custom-field.tsx (Add dialog): 11 opções no select Type, novo
  campo Visibilidade (alwaysShow/alwaysHide/hideWhenEmpty), conditional
  ValuesEditor quando type="list".
- update-custom-field-dialog.tsx: idem para visibility/values. Edit dialog
  só mostra ValuesEditor pra custom fields existentes do tipo list.
- NOVO components/values-editor.tsx: Input + chips Badge removíveis,
  Enter adiciona, dedup automático. Reutilizável.

i18n 3 locales (pt-BR/en/vi): fields.time.label, fields.list.label,
customFields.valuesLabel, customFields.valuesPlaceholder. Visibility
labels já existiam.

Falta Fase D — Inbox drawer renderiza inputs específicos por tipo
(list multi-select, url link, time picker) + respeita hideWhenEmpty
(esconde row quando contact não tem value preenchido).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(gap ChatbotXIO#10 Fase D)

Paridade Respond.io Camada 2 (gap ChatbotXIO#10 — final). Fases A+B (backend) +
C (UI settings) entregues em 5a3acda e fa1c5e4.

Schema: ContactEditableField ganha visibility?: CustomFieldVisibility +
options?: string[] (popula values do customField pra type="list").

contact-detail.tsx (drawer Inbox):
- Loop customFields populate visibility + options.
- visibleFields filter: exclui alwaysHide (vai pro accordion oculto) e
  hideWhenEmpty quando value vazio (paridade Respond.io — campo só
  aparece se preenchido).
- hiddenFields agrupa alwaysHide com o sistema legado de
  workspace-contact-field-visibility (override por usuário).

InlineContactField router:
- type="list" → delega pra ListContactField (popover com checkboxes).
- type="url" preenchido e não editando → link clicável azul +
  ExternalLinkIcon + botão "Editar" pra voltar pro Input.
- resolveInputType olha field.type primeiro (url/time/date/datetime/
  email/phoneNumber/number), fallback pra key dos defaults.

NOVO list-contact-field.tsx:
- Popover com Checkboxes das `options` (definidas pelo admin em
  /settings/contact-fields).
- Display: chips Badge dos selecionados (CSV no value armazenado).
- Save imediato a cada toggle via updateContactFieldAction (mesma
  action dos defaults).
- empty state quando admin não configurou opções.

i18n 3 locales: customFields.emptyOptions.

ChatbotXIO#10 FECHADO. Fases A+B+C+D entregues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…atbotXIO#11)

Paridade Respond.io Camada 2 (gap ChatbotXIO#11 catalogado 2026-05-27). Fecha
Custom Fields 100% junto com ChatbotXIO#10.

Parte 1 — fieldId slug:
- Schema CustomField ganha coluna fieldId (text NOT NULL) + unique
  index (workspaceId, fieldId). Migration manual com backfill via
  regexp_replace(name, '[^a-zA-Z0-9]+', '_'). 2 fields existentes no
  DB de dev convertidos pra link_do_pedido + teste.
- Helper @chatbotx.io/utils/slug exporta slugify() e slugifyUnique()
  (sufixa _2, _3 quando há colisão). NFD + diacríticos removidos pra
  ASCII-only.
- create-custom-field action consulta slugs existentes do workspace e
  gera fieldId único antes do INSERT. Field renomeado mantém o
  fieldId original (imutável — APIs/templates externos dependem dele).
- Tabela /settings/contact-fields coluna "ID do campo" mostra
  fieldId em vez do Snowflake id. Acessor mudou de "id" pra "fieldId".

Parte 2 — Default Contact Fields read-only:
- NOVO components/default-fields-list.tsx renderiza 7 rows fixas no
  topo da tabela /settings/contact-fields: firstName, lastName,
  phoneNumber, email, country, locale, avatar. Bate Respond.io
  ("Standard Contact Fields" — não editáveis nem deletáveis).
- Cada row: ícone tipo + label i18n + fieldId code + tipo label +
  lock icon com tooltip "Campo padrão, não editável".
- Visual border-dashed pra diferenciar de custom fields da grid
  abaixo (que são editáveis).

i18n 3 locales: customFields.defaultsTitle/defaultsTooltip/
defaultsLockTooltip. Labels dos 7 defaults reaproveitam keys
existentes (fields.firstName/lastName/email/phoneNumber/country/
locale/avatar.label).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…otXIO#12)

Paridade Respond.io Camada 2 (gap ChatbotXIO#12 catalogado 2026-05-27). Snippets
saem do mínimo (shortcut + text) pra estrutura completa.

Schema SavedReply ganha:
- name (text nullable) — campo descritivo separado do shortcut. Visível
  na tabela de gestão pra usuário identificar snippet sem decifrar o
  comando curto.
- topics (jsonb string[]) — tags pra organizar (máx 10/snippet). Filtro
  futuro no popover do composer.
- files (jsonb SavedReplyFile[]) — anexos (máx 5/snippet). Cada arquivo
  expande junto da mensagem ao usar o snippet.
Migration manual com ADD COLUMN IF NOT EXISTS pros 3 campos.

Action createSavedReply enforça limite 5000 snippets/workspace via
SELECT COUNT antes do INSERT — paridade Respond.io documentada em
workspace-settings.md ("cada espaço de trabalho só pode ter até 5.000
snippets"). editSavedReply propaga os 3 novos campos no UPDATE.

Schema action createSavedReplyRequest estende com name/topics/files
opcionais + constantes MAX_SNIPPETS_PER_WORKSPACE (5000),
MAX_TOPICS_PER_SNIPPET (10), MAX_FILES_PER_SNIPPET (5).
snippetFileSchema valida formato dos anexos.

UI snippet-form-dialog refatorado com 5 campos:
- Nome (input) — opcional
- Atalho (input) — obrigatório
- Tópicos — novo componente TopicsInput inline (input + chips removíveis,
  Enter adiciona, dedup, max enforcement)
- Mensagem (textarea) — obrigatório
- Anexos — novo componente FilesList inline (file input + lista com
  remove + tamanho formatado). MVP com blob URL local; upload S3
  presigned via DirectUploadButton fica pra commit separado.

Snippets-table-columns reordenado:
- Coluna Nome (com fallback pro shortcut quando vazio)
- Nova coluna Atalho (código /shortcut em font mono)
- Nova coluna Tópicos (3 chips visíveis + "+N")
- Nova coluna Anexos (paperclip icon + count)
- Mensagem/Adicionado/Editado mantidos

i18n 3 locales: snippets.shortcutLabel/shortcutPlaceholder/topicsLabel/
topicsPlaceholder/topicsLimitReached/filesLabel/addFile/filesLimitReached.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ChatbotXIO#15 Fase A)

Paridade Respond.io Camada 2 (gap ChatbotXIO#15 catalogado 2026-05-27). Backend
completo; Fases B/C/D seguem em commits separados.

Schema novo `ConversationCategory` — admin CRUD em /settings/closing-notes:
- name + description + position + workspaceId
- unique (workspaceId, name)
- cascade ON DELETE workspace

Schema novo `ConversationClosingNote` — 1-1 com Conversation:
- conversationId (unique → 1-1)
- categoryId nullable + categoria SET NULL ao deletar
- summary text nullable
- closedByUserId nullable + user SET NULL ao deletar
- cascade ON DELETE conversation/workspace

Workspace ganha coluna `closingNotesMode` text default "disabled":
- "disabled" (atual — fecha conversa direto, sem dialog)
- "optional" (abre dialog, pode pular)
- "mandatoryDialog" (categoria obrigatória, summary opcional)
- "mandatoryBoth" (categoria + summary obrigatórios)

Partial `closingNotesModes` Zod enum em
packages/database/src/partials/conversation.ts (exporta ClosingNotesMode
type).

Relations seguindo pattern defineRelationsPart (Drizzle v2, não
relations() legado): conversationCategory.workspace+closingNotes /
conversationClosingNote.workspace+conversation+category+closedByUser.
Registrado em relations/index.ts (TWO edits — import + spread no
relations object — invariant AGENTS.md).

Audit log keys novas em packages/business/src/audit-log/types.ts (5):
- CONVERSATION_CATEGORY_CREATED/UPDATED/DELETED
- CLOSING_NOTES_MODE_UPDATED
- CONVERSATION_CLOSED_WITH_NOTE

Migration `20260527143406_closing_notes_categories_and_mode` manual
via drizzle-kit generate --custom. ALTER TABLE Workspace + 2x CREATE
TABLE + 2x CREATE UNIQUE INDEX. Aplicada via docker exec psql + journal
sync via db:migrate.

Próximo: Fase B — /settings/closing-notes UI (CRUD categorias + radio
4 modos). Fase C — modal close conversation no Inbox. Fase D — render
closing note no contact activity timeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Paridade Respond.io Camada 2 (gap ChatbotXIO#15 Fase B). Fase A backend
entregue em bcb27d6.

Nova rota /settings/closing-notes (page.tsx busca workspace.closingNotesMode + lista categorias) renderiza ClosingNotesSettings:

- Bloco "Modo de operação" com RadioGroup 4 opções
  (disabled/optional/mandatoryDialog/mandatoryBoth). Cada opção tem
  label + descrição explicando comportamento. Save imediato via
  updateClosingNotesModeAction ao trocar radio.

- Bloco "Categorias de conversa" com lista de cards e botão
  "Adicionar categoria". Cada card: nome + descrição + botões
  editar/deletar. Empty state quando 0 categorias.

- CategoryFormDialog reaproveita pra create E edit. Form: Nome
  (required 1-100) + Descrição (opcional max 500). Validação Zod,
  toast no sucesso/erro, revalidatePath após mudança.

- AlertDialog de confirmação ao deletar (substitui window.confirm
  proibido pelo Biome). Pattern useState deletingCategory + dialog
  controlled.

Item novo no sidebar /settings (grupo "Caixa de entrada", após
Snippets) com ícone Note iconsax.

Actions:
- create-category — auditLog CONVERSATION_CATEGORY_CREATED
- update-category — auditLog CONVERSATION_CATEGORY_UPDATED
- delete-category — auditLog CONVERSATION_CATEGORY_DELETED (sem
  inputSchema, só bindArgs workspaceId+id; chamada direta async
  fora do useAction hook porque hook não tipa multi-bindArgs)
- update-mode — auditLog CLOSING_NOTES_MODE_UPDATED

Query listConversationCategories (RSC) orderBy(position, name).
Await explícito no return (Biome require-await rule).

i18n 3 locales: 20+ keys closingNotes.* (title/subtitle/modeTitle/
modeSubtitle/mode.{disabled,optional,mandatoryDialog,mandatoryBoth}
com label+desc/categoriesTitle/addCategory/editCategoryTitle/
namePlaceholder/descriptionPlaceholder/emptyCategories/
deleteCategoryTitle/confirmDelete + toasts).

Próximo: Fase C — modal close conversation no Inbox (respeita
modo + valida campos). Fase D — render closing note na timeline
do contato.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rkspace (Fase C)

ChatbotXIO#15 Fase C — conecta backend + settings UI das Fases A/B com o fluxo
do atendente. Botão "Fechar" no header da conversa agora:

- mode=disabled        → arquiva direto (comportamento atual)
- mode=optional        → abre modal, pode "Pular e fechar"
- mode=mandatoryDialog → exige categoria
- mode=mandatoryBoth   → exige categoria + resumo

Server (closeConversationWithNoteAction) re-valida a obrigatoriedade
contextual pra evitar bypass via DevTools. Cria ConversationClosingNote
(1-1 via unique conversationId) + arquiva + emite os mesmos eventos
do archiveConversationAction pra triggers/analytics não regredirem.

Prop drilling: inbox/page.tsx → ChatLayout → MessageHead →
CloseConversationButton. Config fetchada server-side em
getClosingNotesConfig (mode + categorias ordenadas por position+name).

i18n: closingNotes.dialog.* em en, pt-BR e vi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ato (Fase D)

ChatbotXIO#15 Fase D — fecha o loop. Quando atendente fecha conversa com
categoria ou resumo via CloseConversationDialog, o evento agora aparece
no histórico de atividades do contato (drawer do Inbox).

Mudanças:
- contactEventTypes: adiciona CONVERSATION_CLOSED_WITH_NOTE (string
  "contact.conversation.closedWithNote", padrão dot.notation existente).
- closeConversationWithNoteAction: chama recordContactEvent passando
  meta = { conversationId, categoryName, summary, closedByUserName }.
  Fire-and-forget (não bloqueia o archive — recordContactEvent já tem
  try/catch interno).
- contact-activity-log.tsx: novo renderer com CheckCircle2Icon verde +
  badge da categoria + box com summary em itálico. Mantém o estilo
  hardcoded pt-br dos outros renderers do arquivo.

Nenhuma migração de schema necessária — ContactEvent já é flexível
(eventType: text, meta: jsonb).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ntre arquivos

ChatbotXIO#15 hotfix. Build standalone quebrou com:
  Error: relations -> conversationCategoryModel:
    { closingNotes: r.many.conversationClosingNoteModel(...) }:
    not enough data provided to build the relation - "from"/"to" are
    not defined, and no reverse relations of table
    "conversationClosingNoteModel" were found

defineRelationsPart só infere reverse relations DENTRO da mesma chamada
(mesmo arquivo). Como conversationCategoryModel mora em
relations/conversation-category.ts e o inverso (category) mora em
relations/conversation-closing-note.ts, o auto-link falha.

Fix: from/to explícitos no .many do lado da categoria. Padrão consistente
com workspace.savedReplies que faz a mesma coisa.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hatbotXIO#20.3)

Quick wins da auditoria ChatbotXIO#20 da Camada 3 — 2 features marginais do
Respond.io que faltavam:

ChatbotXIO#20.2 — DeleteContactDialog ganha confirmação numérica
Quando o(s) contato(s) selecionado(s) têm tags ou conversa vinculadas,
exige digitar o número total (tags + conversas) pra liberar Excluir —
mesmo padrão do DeleteTagsDialog (ChatbotXIO#13). Contatos vazios mantém o flow
rápido "Tem certeza?" anterior.

- Nova prop opcional usageCounts: { tags, conversations }
- Caller calcula no client (já tem dados no schema)
- contacts-list-action.tsx (bulk delete na /contacts) soma de cada row
- conversation-action.tsx (delete do Inbox) passa { tags: 0, conversations: 1 }
  porque contactResource não carrega tags lá — comentário explica
- i18n: contacts.delete.* em en, pt-BR e vi

ChatbotXIO#20.3 — Columns picker /contacts persistido em localStorage
Botão "Colunas" no toolbar abre Popover com checkbox por coluna.
TanStack column.toggleVisibility() nativo + meta.label em todas as
hideable. 3 colunas fixas (select, nome, criado em) com enableHiding:
false não aparecem no picker.

- ContactsColumnsPicker reutilizável (recebe table)
- localStorage key chatbotx.contacts.columnVisibility (SSR-safe via
  lazy useState + effect on change)
- Atalho "Mostrar/Ocultar todas" no header
- meta.label em 8 colunas que faltavam (channels, lifecycleStage,
  email, phoneNumber, tags, country, locale, conversationStatus,
  lastMessage)
- i18n: contacts.columnsPicker.* em en, pt-BR e vi

Camada 3: 1/5 (ChatbotXIO#20 fechado). Falta ChatbotXIO#16, ChatbotXIO#17, ChatbotXIO#18, ChatbotXIO#19.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oft-delete)

Estratégia A escolhida pelo Pedro: soft-delete via mergedIntoId em vez
de DELETE físico. Reverter é trivial (UPDATE 3 campos pra NULL), histórico
preservado, auditável.

Schema (Contact)
  - mergedIntoId bigint → Contact.id (self-ref via AnyPgColumn pra
    quebrar circularidade de tipos; ON DELETE SET NULL)
  - mergedAt timestamp with timezone
  - mergedByUserId bigint → User.id (ON DELETE SET NULL)
  - INDEX Contact_mergedIntoId_idx pra acelerar lookups "fundidos em X"
  - Migration custom: 20260527155004_unmerge_contact_fields

mergeContacts() refator
  - Etapa 5 (era DELETE FROM Contact) virou UPDATE SET mergedIntoId,
    mergedAt, mergedByUserId. Conversation/tag/customField/etc continuam
    sendo MOVIDOS no momento do merge (não snapshot) — unmerge restaura
    o contato MAS NÃO reverte movimentos. UI avisa explicitamente.
  - Docstring atualizada explicando soft-delete

Filtros mergedIntoId IS NULL
  - list-contacts.queries.ts → generateWhere adiciona mergedIntoId:isNull
  - count-blocked-contacts.ts → isNull no AND do count
  - listConversations NÃO precisa filtrar (conversation foi movida no
    merge; duplicate fica sem conversation, naturalmente não aparece)
  - Bulk actions (add tag, custom field, etc) ficam como debt list —
    o pior cenário hoje é "bulk action atinge contato fundido", não
    visível na UI mas ainda no banco

unmergeContactsAction
  - bindArgs workspaceId + inputSchema { primaryId, duplicateIds[] }
  - Valida: todos os duplicates têm mergedIntoId = primaryId (evita
    unmerge cruzado entre primaries diferentes)
  - UPDATE SET 3 campos pra NULL
  - logAudit CONTACT_UNMERGED + recordContactEvent UNMERGED no PRIMARY

UI
  - MergedContactsPanel — lazy fetch via listContactsMergedInto;
    banner amarelo no drawer do contato primary; lista com botão
    "Desfazer fusão" por linha; warning sobre conversas/tags
  - Plugado em ContactDetailDrawer antes do "Ver no Inbox"
  - i18n contacts.unmerge.* em en, pt-BR, vi

Camada 3: 2/5 (ChatbotXIO#17 + ChatbotXIO#20 fechados). Falta ChatbotXIO#16, ChatbotXIO#18, ChatbotXIO#19.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant