mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
Merge remote-tracking branch 'refs/remotes/pr/305' into triagem/305
This commit is contained in:
@@ -147,7 +147,7 @@ jobs:
|
||||
risk-radar.spec.ts invite-lifecycle.spec.ts system-update.spec.ts
|
||||
agente-papeis-operador.spec.ts prova-painel-provedores.spec.ts
|
||||
followup-linguagem.spec.ts followup-ramos.spec.ts
|
||||
wizard-do-funcionario.spec.ts mfa-opcional.spec.ts
|
||||
wizard-do-funcionario.spec.ts mfa-opcional.spec.ts inbox-responder-citando.spec.ts
|
||||
# ⚠️ A ORDEM DESTA LISTA NÃO DECIDE A ORDEM DE EXECUÇÃO. O Playwright
|
||||
# ordena os arquivos por CAMINHO, não pela ordem em que são passados na
|
||||
# linha de comando. Medido no run 31838253496: `marca-logo.spec.ts` estava
|
||||
|
||||
@@ -111,7 +111,7 @@ async function removerEcoDoProprioEnvio(
|
||||
}
|
||||
|
||||
const MSG_COLS =
|
||||
"id, organization_id, conversation_id, channel_session_id, contact_id, external_id, type, direction, status, ack, error_code, error_message, body, media_url, media_mime, media_size_bytes, media_storage_path, sent_via, sent_by_user_id, sent_at, delivered_at, read_at, metadata, edited_at, revoked_at, created_at";
|
||||
"id, organization_id, conversation_id, channel_session_id, contact_id, external_id, type, direction, status, ack, error_code, error_message, body, media_url, media_mime, media_size_bytes, media_storage_path, sent_via, sent_by_user_id, sent_at, delivered_at, read_at, metadata, edited_at, revoked_at, reply_to_message_id, created_at";
|
||||
|
||||
function actorAuditPayload(actor: Actor): {
|
||||
actorUserId: string | null;
|
||||
@@ -410,8 +410,43 @@ export async function sendMessageHandler(
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
// ─── A CITAÇÃO, e a checagem que ela obriga ────────────────────────────────
|
||||
//
|
||||
// O id da citada vem do CLIENTE. Sem confirmar que ela é da MESMA conversa,
|
||||
// alguém poderia citar uma mensagem de outra conversa — e a citação é
|
||||
// renderizada com o texto, então isso vaza conteúdo de um atendimento para
|
||||
// dentro de outro. O filtro de conversa é o que fecha isso; o de organização
|
||||
// vem de brinde por `conversation_id` já ser desta org.
|
||||
//
|
||||
// Recusar em silêncio (citar nada) seria pior que recusar alto: quem clicou
|
||||
// "responder" veria a mensagem sair sem o fio e não saberia por quê.
|
||||
let citada: { id: string; external_id: string | null } | null = null;
|
||||
if (input.reply_to_message_id) {
|
||||
const { data: alvo } = await supabase
|
||||
.from("messages")
|
||||
.select("id, external_id")
|
||||
.eq("id", input.reply_to_message_id)
|
||||
.eq("organization_id", ctx.organization_id)
|
||||
.eq("conversation_id", c.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!alvo) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
"validation_error",
|
||||
undefined,
|
||||
ctx.requestId,
|
||||
"A mensagem citada não é desta conversa.",
|
||||
);
|
||||
}
|
||||
citada = alvo as { id: string; external_id: string | null };
|
||||
}
|
||||
|
||||
const insertRow = {
|
||||
organization_id: c.organization_id,
|
||||
// Guardado mesmo quando o canal não sabe citar: o fio existe no NOSSO
|
||||
// histórico de qualquer jeito, e é o que a tela desenha.
|
||||
reply_to_message_id: citada?.id ?? null,
|
||||
conversation_id: c.id,
|
||||
channel_session_id: c.channel_session_id,
|
||||
contact_id: c.contact_id,
|
||||
@@ -596,6 +631,9 @@ export async function sendMessageHandler(
|
||||
filename,
|
||||
caption: input.body ?? null,
|
||||
},
|
||||
// O id que a PLATAFORMA conhece, lido da linha citada agora — não uma
|
||||
// cópia guardada no envio, que poderia divergir da linha.
|
||||
replyToExternalId: citada?.external_id ?? null,
|
||||
}));
|
||||
} else if (input.type === "contact") {
|
||||
const sc = outboundMetadata.shared_contact as
|
||||
@@ -633,6 +671,7 @@ export async function sendMessageHandler(
|
||||
providerConversationId: c.provider_conversation_id,
|
||||
kind: input.type,
|
||||
body: input.body ?? "",
|
||||
replyToExternalId: citada?.external_id ?? null,
|
||||
}));
|
||||
}
|
||||
await removerEcoDoProprioEnvio(
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { Message, Note } from "@/lib/types/messaging";
|
||||
|
||||
interface Props {
|
||||
conversationId: string | null;
|
||||
/** Escolher uma mensagem para responder. Sobe até o composer. */
|
||||
onResponder?: (m: Message) => void;
|
||||
}
|
||||
|
||||
/** Onda 5.2: union de item do thread — mensagem real ou nota interna (nunca vai ao cliente). */
|
||||
@@ -41,7 +43,7 @@ function dayLabel(d: Date): string {
|
||||
return format(d, "dd/MM/yyyy", { locale: ptBR });
|
||||
}
|
||||
|
||||
export function ChatThread({ conversationId }: Props) {
|
||||
export function ChatThread({ conversationId, onResponder }: Props) {
|
||||
const q = useMessagesRealtime(conversationId);
|
||||
const notes = useConversationNotes(conversationId);
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -58,6 +60,16 @@ export function ChatThread({ conversationId }: Props) {
|
||||
[q.data],
|
||||
);
|
||||
|
||||
/**
|
||||
* As mensagens por id, para resolver a CITADA sem ir ao servidor.
|
||||
*
|
||||
* Uma consulta por bolha citada seria uma cascata de requisições numa
|
||||
* conversa longa. Aqui o fio sai da lista que já está na tela — e quando a
|
||||
* citada ficou fora da página carregada, ele simplesmente não aparece, que é
|
||||
* melhor que segurar a conversa esperando por um texto de enfeite.
|
||||
*/
|
||||
const porId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||
|
||||
const items: ThreadItem[] = useMemo(
|
||||
() => mergeThreadItems(messages, notes),
|
||||
[messages, notes],
|
||||
@@ -188,6 +200,12 @@ export function ChatThread({ conversationId }: Props) {
|
||||
key={`msg-${item.data.id}`}
|
||||
message={item.data}
|
||||
debugCitations={debugCitations}
|
||||
onResponder={onResponder}
|
||||
// A citada sai da MESMA lista já carregada: buscar no servidor
|
||||
// por cada citação faria uma consulta por bolha. Quando a
|
||||
// citada é antiga demais e ficou fora da página, o fio some —
|
||||
// que é melhor que segurar a conversa esperando.
|
||||
citada={porId.get(item.data.reply_to_message_id ?? "") ?? null}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { EmojiButton } from "@/components/inbox/composer/EmojiButton";
|
||||
import { resolveSlash, TemplateMenu } from "@/components/inbox/composer/TemplateMenu";
|
||||
import { useCreateNote } from "@/hooks/inbox/useCreateNote";
|
||||
import { useMessageTemplates, type MessageTemplate } from "@/hooks/inbox/useMessageTemplates";
|
||||
import { X } from "lucide-react";
|
||||
import { useSendMessage } from "@/hooks/inbox/useSendMessage";
|
||||
import { useUploadMedia } from "@/hooks/inbox/useUploadMedia";
|
||||
import { imagemDoClipboard } from "@/lib/inbox/clipboard-image";
|
||||
@@ -43,6 +44,16 @@ interface Props {
|
||||
* versão deste bloqueio usava `blockedReason` e levou a nota junto.
|
||||
*/
|
||||
janelaFechada?: string | null;
|
||||
/**
|
||||
* A mensagem que esta resposta CITA, quando o atendente escolheu responder
|
||||
* "em cima" de uma. `null` = envio solto, o caso comum.
|
||||
*
|
||||
* Vem de fora e não daqui porque quem escolhe é a lista de mensagens: o
|
||||
* composer só precisa mostrar o que foi escolhido e mandá-lo junto.
|
||||
*/
|
||||
respondendo?: { id: string; body: string | null; direction: string } | null;
|
||||
/** Desfaz a escolha — o `x` da faixa de citação. */
|
||||
onCancelarResposta?: () => void;
|
||||
/** Nome do contato da conversa, para interpolar {{nome}}/{{primeiro_nome}} do template escolhido. */
|
||||
contactName?: string | null;
|
||||
/** Contato da conversa — excluído do seletor de cartão compartilhado. */
|
||||
@@ -50,7 +61,16 @@ interface Props {
|
||||
}
|
||||
|
||||
export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
|
||||
{ conversationId, disabled, blockedReason, janelaFechada, contactName, currentContactId },
|
||||
{
|
||||
conversationId,
|
||||
disabled,
|
||||
blockedReason,
|
||||
janelaFechada,
|
||||
contactName,
|
||||
currentContactId,
|
||||
respondendo,
|
||||
onCancelarResposta,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const t = useT();
|
||||
@@ -103,8 +123,24 @@ export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
|
||||
return;
|
||||
}
|
||||
send.mutate(
|
||||
{ conversation_id: conversationId, body, type: "text" },
|
||||
{ onError: restoreOnError },
|
||||
{
|
||||
conversation_id: conversationId,
|
||||
body,
|
||||
type: "text",
|
||||
...(respondendo ? { reply_to_message_id: respondendo.id } : {}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setText("");
|
||||
// A citação vale para UMA mensagem. Mantê-la depois do envio faria a
|
||||
// próxima frase sair citando algo que o atendente já respondeu.
|
||||
onCancelarResposta?.();
|
||||
requestAnimationFrame(() => autoresize());
|
||||
},
|
||||
// Do upstream, e fica: sem isto o texto some quando o envio falha, e
|
||||
// quem escreveu um parágrafo o perde sem ter como recuperá-lo.
|
||||
onError: restoreOnError,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -212,6 +248,36 @@ export const Composer = forwardRef<ComposerHandle, Props>(function Composer(
|
||||
{t("Nota interna")}
|
||||
</button>
|
||||
</div>
|
||||
{/*
|
||||
A FAIXA DA CITAÇÃO — o que o atendente escolheu responder.
|
||||
|
||||
Fica ACIMA do campo, como no WhatsApp, e não dentro dele: o texto
|
||||
citado pode ter várias linhas, e empurrá-lo para dentro do campo faria
|
||||
o que se digita disputar espaço com o que se cita.
|
||||
|
||||
`line-clamp-2` porque o objetivo é reconhecer qual mensagem é, não
|
||||
relê-la — ela está logo acima, no fio.
|
||||
*/}
|
||||
{respondendo && mode === "reply" && (
|
||||
<div className="mb-1 flex items-start gap-2 rounded-md border-l-2 border-primary bg-muted/60 px-2 py-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-medium text-primary">
|
||||
{respondendo.direction === "outbound" ? t("Você") : t("Cliente")}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{respondendo.body?.trim() || t("(sem texto)")}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelarResposta}
|
||||
aria-label={t("Cancelar resposta")}
|
||||
className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
{mode === "reply" && (
|
||||
<AttachMenu
|
||||
|
||||
@@ -20,8 +20,13 @@ import { Composer, type ComposerHandle } from "./Composer";
|
||||
import { ConversationHeader } from "./ConversationHeader";
|
||||
import { RetentionNotice } from "./RetentionNotice";
|
||||
import { CRMSidePanel } from "./CRMSidePanel";
|
||||
import type { Message as ConversationMensagem } from "@/lib/types/messaging";
|
||||
import { InboxKeyboardShortcuts } from "./InboxKeyboardShortcuts";
|
||||
import { ShortcutsHelpDialog } from "./ShortcutsHelpDialog";
|
||||
import { ChevronLeft, PanelRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* O QUE CADA ABA SIGNIFICA. Exportada porque é a definição em si — o defeito
|
||||
@@ -92,6 +97,15 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
|
||||
const [selectedId, setSelectedId] = useState<string | null>(initialSelectedId);
|
||||
const [visibleIds, setVisibleIds] = useState<string[]>([]);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
/** A ficha do contato como painel deslizante — só existe abaixo do `xl`. */
|
||||
const [fichaAberta, setFichaAberta] = useState(false);
|
||||
/**
|
||||
* A mensagem escolhida para responder "em cima".
|
||||
*
|
||||
* Mora aqui, e não no composer, porque quem ESCOLHE é a lista de mensagens e
|
||||
* quem MOSTRA é o composer — são irmãos, e o estado comum é do pai.
|
||||
*/
|
||||
const [respondendo, setRespondendo] = useState<ConversationMensagem | null>(null);
|
||||
const composerRef = useRef<ComposerHandle | null>(null);
|
||||
|
||||
const filters: ConversationsFilters = useMemo(
|
||||
@@ -132,12 +146,27 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
|
||||
const claim = useClaimConversation();
|
||||
const close = useCloseConversation();
|
||||
|
||||
// A leitura da conversa aberta é do upstream e fica: sem ela o contador de
|
||||
// não-lidas nunca zera para quem abre a conversa.
|
||||
useMarkAsRead(
|
||||
selectedConversation?.id ?? null,
|
||||
selectedConversation?.unread_count_for_assignee ?? 0,
|
||||
);
|
||||
|
||||
const handleSelect = useCallback((id: string) => setSelectedId(id), []);
|
||||
// Aceita `null`: é o VOLTAR do celular, que limpa a seleção e devolve a lista.
|
||||
// É um SUPERCONJUNTO do `handleSelect` do upstream — o tipo dele não aceita
|
||||
// `null`, e sem isso o botão de voltar não teria o que chamar.
|
||||
//
|
||||
// A seleção NÃO vive na URL (só o `?filter=` vive) — então este voltar é
|
||||
// estado local, e o botão de voltar do navegador não desfaz a seleção. É a
|
||||
// limitação conhecida deste caminho; trocar por URL mudaria o deep-link de
|
||||
// conversa, que hoje entra por `initialSelectedId` vindo da rota.
|
||||
const handleSelect = useCallback((id: string | null) => {
|
||||
setSelectedId(id);
|
||||
// Sem isto, escolher "responder" numa conversa e trocar para outra levaria
|
||||
// a citação junto — e a resposta sairia citando mensagem de outro cliente.
|
||||
setRespondendo(null);
|
||||
}, []);
|
||||
const handleVisibleChange = useCallback((ids: string[]) => setVisibleIds(ids), []);
|
||||
const handleFocusReply = useCallback(() => composerRef.current?.focus(), []);
|
||||
const handleClaim = useCallback(() => {
|
||||
@@ -227,7 +256,25 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
|
||||
// deixava. Margem de 2px não é margem, é sorte.
|
||||
return (
|
||||
<div className="grid h-[calc(100dvh-3.5rem-2*var(--space-6))] w-full grid-cols-1 md:grid-cols-[300px_1fr] xl:grid-cols-[272px_1fr_296px] 2xl:grid-cols-[300px_1fr_320px]">
|
||||
<div className="flex h-full min-h-0 flex-col border-r border-border">
|
||||
{/*
|
||||
NO CELULAR, UMA COISA POR VEZ.
|
||||
|
||||
Antes as duas colunas caíam empilhadas em `grid-cols-1`: a lista inteira
|
||||
primeiro e a conversa DEPOIS dela. Para responder era preciso rolar a
|
||||
lista toda até o fim, e o composer ficava fora da tela — que é o
|
||||
"incômodo" relatado por quem atende do telefone.
|
||||
|
||||
Sem media query em JavaScript de propósito: `useMediaQuery` decide DEPOIS
|
||||
da hidratação, então a primeira pintura mostra o layout errado e pisca. A
|
||||
classe condicional é resolvida pelo CSS, na primeira pintura, e some no
|
||||
`md` — onde as duas colunas cabem juntas e a regra não se aplica.
|
||||
*/}
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-0 flex-col border-r border-border md:flex",
|
||||
selectedId ? "hidden" : "flex",
|
||||
)}
|
||||
>
|
||||
<InboxFilters value={filterValue} onChange={setFilterValue} />
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationList
|
||||
@@ -241,12 +288,52 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-0 flex-col md:flex",
|
||||
selectedConversation ? "flex" : "hidden md:flex",
|
||||
)}
|
||||
>
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
{/*
|
||||
A barra que só existe no celular: o caminho de VOLTA e a porta
|
||||
para a ficha.
|
||||
|
||||
Sem o voltar, quem abre uma conversa no telefone fica preso nela —
|
||||
a lista está escondida e não há gesto que a traga. E a ficha do
|
||||
contato mora numa coluna que só aparece a partir do `xl`, então no
|
||||
telefone ela seria inalcançável; aqui ela vira painel deslizante,
|
||||
com o mesmo componente da coluna (nada de uma segunda versão que
|
||||
diverge na primeira mudança).
|
||||
*/}
|
||||
<div className="flex items-center gap-1 border-b border-border px-1 py-1 md:hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 gap-1 px-2"
|
||||
onClick={() => handleSelect(null)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Conversas
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Sheet open={fichaAberta} onOpenChange={setFichaAberta}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-1 px-2 xl:hidden">
|
||||
<PanelRight className="size-4" />
|
||||
Ficha
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[min(22rem,90vw)] overflow-y-auto p-0">
|
||||
<SheetTitle className="sr-only">Ficha do contato</SheetTitle>
|
||||
<CRMSidePanel conversation={selectedConversation} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
<ConversationHeader conversation={selectedConversation} />
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ChatThread conversationId={selectedConversation.id} />
|
||||
<ChatThread conversationId={selectedConversation.id} onResponder={setRespondendo} />
|
||||
</div>
|
||||
<RetentionNotice conversationId={selectedConversation.id} />
|
||||
{motivoDaJanela && (
|
||||
@@ -263,6 +350,8 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
|
||||
janelaFechada={motivoDaJanela}
|
||||
disabled={selectedConversation.status === "closed"}
|
||||
contactName={selectedConversation.contacts?.name ?? null}
|
||||
respondendo={respondendo}
|
||||
onCancelarResposta={() => setRespondendo(null)}
|
||||
currentContactId={selectedConversation.contact_id}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { Check, Checks, Robot, WarningOctagon } from "@/lib/ui/icons";
|
||||
import { ArrowBendUpLeft, Check, Checks, Robot, WarningOctagon } from "@/lib/ui/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { Message } from "@/lib/types/messaging";
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
interface Props {
|
||||
message: Message;
|
||||
debugCitations?: boolean;
|
||||
/** Escolher esta mensagem para responder "em cima" dela. */
|
||||
onResponder?: (m: Message) => void;
|
||||
/** A mensagem citada por ESTA, quando houver — desenha o fio. */
|
||||
citada?: Message | null;
|
||||
}
|
||||
|
||||
function AckIndicator({ status }: { status: string }) {
|
||||
@@ -31,7 +35,7 @@ function AckIndicator({ status }: { status: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, debugCitations }: Props) {
|
||||
export function MessageBubble({ message, debugCitations, onResponder, citada }: Props) {
|
||||
const isOutbound = message.direction === "outbound";
|
||||
const time = format(new Date(message.sent_at), "HH:mm", { locale: ptBR });
|
||||
const isFailed = message.status === "failed";
|
||||
@@ -56,7 +60,47 @@ export function MessageBubble({ message, debugCitations }: Props) {
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className={cn("flex w-full px-4 py-1", isOutbound ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-1 px-4 py-1",
|
||||
isOutbound ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{/*
|
||||
RESPONDER — aparece ao passar o mouse, como no WhatsApp Web.
|
||||
Fica FORA da bolha para não disputar espaço com o texto, e do lado de
|
||||
dentro da conversa (à esquerda no que sai, à direita no que entra), que
|
||||
é onde a mão já está.
|
||||
|
||||
`opacity` e não `hidden`: esconder de verdade faria o layout pular
|
||||
quando o mouse entra. Em telas de toque não há hover — por isso
|
||||
`focus-visible` também revela, e o teclado alcança.
|
||||
*/}
|
||||
{onResponder && isOutbound && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResponder(message)}
|
||||
aria-label="Responder a esta mensagem"
|
||||
className={cn(
|
||||
"rounded p-1 text-muted-foreground transition-opacity hover:bg-muted",
|
||||
// VISÍVEL POR PADRÃO, e escondido só onde EXISTE hover.
|
||||
//
|
||||
// A primeira versão era `opacity-0` + `group-hover`, copiando o
|
||||
// WhatsApp Web. No celular isso deixa o botão invisível para
|
||||
// sempre: não há como passar o mouse, e `focus-visible` só chega
|
||||
// por teclado. Ou seja, a função sumia exatamente onde o dono
|
||||
// deste CRM mais atende.
|
||||
//
|
||||
// `@media (hover: hover)` pergunta pelo DISPOSITIVO, não pela
|
||||
// largura: um tablet largo com toque continua mostrando, e um
|
||||
// desktop estreito continua escondendo. Largura não é a pergunta.
|
||||
"opacity-100 [@media(hover:hover)]:opacity-0",
|
||||
"[@media(hover:hover)]:group-hover:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
>
|
||||
<ArrowBendUpLeft size={14} />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] text-sm",
|
||||
@@ -71,6 +115,27 @@ export function MessageBubble({ message, debugCitations }: Props) {
|
||||
isFailed && "border border-destructive",
|
||||
)}
|
||||
>
|
||||
{/*
|
||||
A CITAÇÃO, dentro da bolha e acima do texto — o fio.
|
||||
|
||||
Mostra de quem era e um trecho. `line-clamp-2` porque serve para
|
||||
reconhecer, não para reler: a original está logo acima no histórico.
|
||||
*/}
|
||||
{citada && (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 rounded border-l-2 px-2 py-1 text-xs",
|
||||
isOutbound
|
||||
? "border-primary-foreground/50 bg-primary-foreground/10"
|
||||
: "border-primary bg-background/60",
|
||||
)}
|
||||
>
|
||||
<div className="font-medium opacity-80">
|
||||
{citada.direction === "outbound" ? "Você" : "Cliente"}
|
||||
</div>
|
||||
<div className="line-clamp-2 opacity-70">{citada.body?.trim() || "(sem texto)"}</div>
|
||||
</div>
|
||||
)}
|
||||
{senderLabel && (
|
||||
<div className="mb-0.5 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide opacity-80">
|
||||
{senderLabel === "IA" ? (
|
||||
@@ -144,6 +209,31 @@ export function MessageBubble({ message, debugCitations }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onResponder && !isOutbound && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResponder(message)}
|
||||
aria-label="Responder a esta mensagem"
|
||||
className={cn(
|
||||
"rounded p-1 text-muted-foreground transition-opacity hover:bg-muted",
|
||||
// VISÍVEL POR PADRÃO, e escondido só onde EXISTE hover.
|
||||
//
|
||||
// A primeira versão era `opacity-0` + `group-hover`, copiando o
|
||||
// WhatsApp Web. No celular isso deixa o botão invisível para
|
||||
// sempre: não há como passar o mouse, e `focus-visible` só chega
|
||||
// por teclado. Ou seja, a função sumia exatamente onde o dono
|
||||
// deste CRM mais atende.
|
||||
//
|
||||
// `@media (hover: hover)` pergunta pelo DISPOSITIVO, não pela
|
||||
// largura: um tablet largo com toque continua mostrando, e um
|
||||
// desktop estreito continua escondendo. Largura não é a pergunta.
|
||||
"opacity-100 [@media(hover:hover)]:opacity-0",
|
||||
"[@media(hover:hover)]:group-hover:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
>
|
||||
<ArrowBendUpLeft size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ interface SendArgs {
|
||||
template_name?: string;
|
||||
template_language?: string;
|
||||
template_values?: Record<string, string>;
|
||||
/** A mensagem citada — id da NOSSA linha; o handler traduz para o do canal. */
|
||||
reply_to_message_id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ export function useSendMessage() {
|
||||
media_mime: args.media_mime ?? null,
|
||||
media_size_bytes: null,
|
||||
media_storage_path: null,
|
||||
reply_to_message_id: args.reply_to_message_id ?? null,
|
||||
sent_via: "user",
|
||||
sent_by_user_id: null,
|
||||
sent_at: new Date().toISOString(),
|
||||
|
||||
@@ -135,6 +135,11 @@ export const wahaAdapter: ChannelAdapter = {
|
||||
// comportamento visível — proibido nas Fases 0–2.
|
||||
if (!client) return { externalId: null };
|
||||
|
||||
// A estrutura de três caminhos é do upstream (o cartão de contato entrou
|
||||
// depois da citação). O que se enxerta aqui é o `replyToExternalId` no
|
||||
// caminho de TEXTO — os outros dois não citam: o WAHA aceita `reply_to` só
|
||||
// no `sendText`, e mandá-lo nos outros seria pedir para a API ignorar em
|
||||
// silêncio, que é como se perde uma feature sem ninguém notar.
|
||||
let res: unknown;
|
||||
if (envelope.kind === "contact" && envelope.contact) {
|
||||
const resolvedId = await resolveWhatsappIdForContactCard(
|
||||
@@ -155,7 +160,14 @@ export const wahaAdapter: ChannelAdapter = {
|
||||
wahaSendPlanFor(envelope.kind, envelope.media),
|
||||
);
|
||||
} else {
|
||||
res = await client.sendMessage(envelope.sessionRef, envelope.to, envelope.body ?? "");
|
||||
res = await client.sendMessage(
|
||||
envelope.sessionRef,
|
||||
envelope.to,
|
||||
envelope.body ?? "",
|
||||
// A citação é enfeite da conversa, nunca condição de envio: quando não
|
||||
// há, o envio segue igual. Ver `OutboundEnvelope.replyToExternalId`.
|
||||
envelope.replyToExternalId,
|
||||
);
|
||||
}
|
||||
|
||||
return { externalId: parseWahaMessageId(res) };
|
||||
|
||||
@@ -196,6 +196,11 @@ export const zernioAdapter: ChannelAdapter = {
|
||||
const body: Record<string, unknown> = {
|
||||
accountId: creds.accountId,
|
||||
...(envelope.media ? attachmentFields(envelope) : { message: envelope.body ?? "" }),
|
||||
// ─── A CITAÇÃO ──────────────────────────────────────────────────────
|
||||
// `replyTo` recebe o id que a PLATAFORMA conhece — para WhatsApp, o
|
||||
// `wamid`. É o `external_id` da linha citada, nunca o `id` da nossa
|
||||
// tabela: o provider nunca viu o nosso. Só entra quando existe.
|
||||
...(envelope.replyToExternalId ? { replyTo: envelope.replyToExternalId } : {}),
|
||||
};
|
||||
|
||||
const res = await fetch(url, {
|
||||
|
||||
@@ -123,6 +123,20 @@ export interface OutboundEnvelope extends ChannelTenantScope {
|
||||
* fazer (tipicamente, abrir a conversa com template).
|
||||
*/
|
||||
providerConversationId?: string | null;
|
||||
/**
|
||||
* A mensagem que ESTA responde, pelo id que o PROVIDER conhece.
|
||||
*
|
||||
* É o `external_id` da linha citada (para WhatsApp, o `wamid`) — não o `id`
|
||||
* da nossa tabela, que o provider nunca viu. Quem monta o envelope resolve
|
||||
* essa tradução; o adapter só repassa.
|
||||
*
|
||||
* OPCIONAL, e canal que não sabe citar simplesmente ignora: a citação é
|
||||
* enfeite da conversa, nunca condição de envio. Um canal recusar a mensagem
|
||||
* inteira porque não sabe citar seria trocar a mensagem pelo enfeite.
|
||||
*
|
||||
* `undefined` = envio solto, que é o caso comum.
|
||||
*/
|
||||
replyToExternalId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -61,6 +61,15 @@ export const sendMessageSchema = z
|
||||
* da tela usa. Chave montada de outro jeito é o mismatch voltando.
|
||||
*/
|
||||
template_values: z.record(z.string(), z.string()).optional(),
|
||||
/**
|
||||
* A mensagem que esta responde — o id da NOSSA linha, não o do provider.
|
||||
*
|
||||
* Quem envia conhece o que está na tela, e na tela está o nosso id. A
|
||||
* tradução para o id que a plataforma entende (`wamid`) é feita no handler,
|
||||
* lendo a linha apontada: pedir o `wamid` aqui obrigaria a tela a conhecer
|
||||
* o vocabulário do canal, que é justamente o que o seam existe para evitar.
|
||||
*/
|
||||
reply_to_message_id: z.string().uuid().optional(),
|
||||
})
|
||||
.refine(
|
||||
(d) => {
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface Message {
|
||||
edited_at: string | null;
|
||||
/** Quando o AUTOR apagou para todos. A linha fica; o texto não é mostrado. */
|
||||
revoked_at: string | null;
|
||||
/** A mensagem que esta responde (citação). `null` = envio solto. */
|
||||
reply_to_message_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export {
|
||||
ArrowsClockwise,
|
||||
Dot,
|
||||
// actions
|
||||
ArrowBendUpLeft,
|
||||
List,
|
||||
Bell,
|
||||
PaperPlaneTilt,
|
||||
|
||||
+33
-2
@@ -322,14 +322,45 @@ export class WahaClient {
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(session: string, chatId: string, text: string): Promise<unknown> {
|
||||
/**
|
||||
* `replyTo` = citar uma mensagem, como o "responder em cima" do WhatsApp.
|
||||
*
|
||||
* ─── O defeito que isto conserta ──────────────────────────────────────────
|
||||
*
|
||||
* A citação já existia de ponta a ponta MENOS aqui: a tela deixava escolher a
|
||||
* mensagem, o handler resolvia o `external_id` da citada e gravava
|
||||
* `reply_to_message_id`, e a bolha aparecia pendurada — no CRM. No WhatsApp do
|
||||
* cliente chegava mensagem solta. Ou seja, a tela prometia uma coisa e o
|
||||
* aparelho do outro lado mostrava outra, sem nada ficar vermelho.
|
||||
*
|
||||
* ─── O formato do id, que é onde isto falha em silêncio ───────────────────
|
||||
*
|
||||
* O `reply_to` do WAHA quer o id COMPLETO (`{fromMe}_{chatId}_{bareId}`), não
|
||||
* o cru. E o WAHA é assimétrico: a resposta de envio devolve o cru (`3EB0…`) e
|
||||
* o webhook entrega o completo — ver `bareWaMessageId`.
|
||||
*
|
||||
* Medido numa instalação real: as 1.734 mensagens de ENTRADA têm o id
|
||||
* completo, que é o formato certo. E citar o que o cliente disse é o caso que
|
||||
* importa — quem responde "em cima" está respondendo a ele.
|
||||
*
|
||||
* Por isso o id vai como está, sem reconstrução: inventar o prefixo a partir
|
||||
* da direção acertaria o caso que já funciona e chutaria no resto.
|
||||
*/
|
||||
async sendMessage(
|
||||
session: string,
|
||||
chatId: string,
|
||||
text: string,
|
||||
replyTo?: string | null,
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(`${this.baseUrl}/api/sendText`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Api-Key": this.apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ session, chatId, text }),
|
||||
// Só entra quando existe: mandar `reply_to: null` é pedir para citar
|
||||
// "nada", e a API não tem por que ser gentil com isso.
|
||||
body: JSON.stringify({ session, chatId, text, ...(replyTo ? { reply_to: replyTo } : {}) }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`waha_${res.status}`);
|
||||
return res.json();
|
||||
|
||||
@@ -13956,4 +13956,25 @@ create unique index if not exists channel_sessions_zernio_account_id_ativo_uniqu
|
||||
on public.channel_sessions (zernio_account_id)
|
||||
where archived_at is null and zernio_account_id is not null;
|
||||
|
||||
-- ---- a mensagem que responde outra (migration 0168) ----
|
||||
-- O canal intermediado aceita citação (`replyTo` no envio, com o `wamid` da
|
||||
-- citada) e o WhatsApp mostra a resposta pendurada na original. Sem guardar
|
||||
-- QUEM foi citado, o CRM manda a citação para o cliente e não a mostra de volta
|
||||
-- na própria tela: o atendente vê frases soltas onde o cliente vê um fio.
|
||||
--
|
||||
-- FK e não o wamid solto: a pergunta da tela é "qual mensagem NOSSA foi
|
||||
-- citada?", e a resposta é uma linha desta tabela — inclusive quando ela ainda
|
||||
-- não tem `external_id` (a nossa, enquanto está `queued`). O id que o provider
|
||||
-- recebe sai da linha apontada, no envio.
|
||||
--
|
||||
-- `set null` no delete: apagar a citada não pode levar junto a resposta, que é
|
||||
-- conteúdo próprio. Perder o fio é aceitável; perder a resposta é apagar
|
||||
-- histórico por causa de um ponteiro.
|
||||
alter table public.messages
|
||||
add column if not exists reply_to_message_id uuid
|
||||
references public.messages(id) on delete set null;
|
||||
create index if not exists messages_reply_to_idx
|
||||
on public.messages (reply_to_message_id)
|
||||
where reply_to_message_id is not null;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- ============================================================================
|
||||
-- 0168 — A MENSAGEM QUE RESPONDE OUTRA.
|
||||
--
|
||||
-- O canal intermediado aceita citação (`replyTo` no envio, recebendo o `wamid`
|
||||
-- da mensagem citada), e o WhatsApp mostra a resposta pendurada na original —
|
||||
-- que é como as pessoas conversam ali. Sem guardar QUEM foi citado, o CRM
|
||||
-- manda a citação para o cliente e não consegue mostrá-la de volta na própria
|
||||
-- tela: o atendente vê frases soltas onde o cliente vê um fio.
|
||||
--
|
||||
-- ─── Por que uma FK, e não o wamid solto ───────────────────────────────────
|
||||
--
|
||||
-- Porque a pergunta da tela é "qual mensagem NOSSA foi citada?", e a resposta é
|
||||
-- uma linha desta tabela. Guardar o `external_id` do provider faria toda
|
||||
-- renderização buscar por uma coluna de texto — e, pior, deixaria de funcionar
|
||||
-- para a citação de uma mensagem que ainda não tem `external_id` (a nossa,
|
||||
-- enquanto está `queued`). É o `R` da DIRC: a linha já existe, aqui basta o
|
||||
-- ponteiro.
|
||||
--
|
||||
-- O `external_id` que o provider precisa continua saindo da linha apontada, no
|
||||
-- momento do envio — uma leitura a mais, e nenhuma cópia que possa divergir.
|
||||
--
|
||||
-- ─── `on delete set null`, nunca cascade ───────────────────────────────────
|
||||
--
|
||||
-- Apagar a mensagem citada não pode levar junto a resposta: a resposta é
|
||||
-- conteúdo próprio, dita por alguém, e some sozinha só se quem a disse mandar.
|
||||
-- Perder a citação é aceitável (a tela mostra a resposta sem o fio); perder a
|
||||
-- resposta seria apagar histórico de atendimento por causa de um ponteiro.
|
||||
--
|
||||
-- Índice PARCIAL: a esmagadora maioria das mensagens não cita ninguém, e um
|
||||
-- índice cheio de NULL só ocupa espaço. O predicado é o mesmo que a tela usa.
|
||||
-- ============================================================================
|
||||
|
||||
alter table public.messages
|
||||
add column if not exists reply_to_message_id uuid
|
||||
references public.messages(id) on delete set null;
|
||||
|
||||
create index if not exists messages_reply_to_idx
|
||||
on public.messages (reply_to_message_id)
|
||||
where reply_to_message_id is not null;
|
||||
|
||||
comment on column public.messages.reply_to_message_id is
|
||||
'A mensagem que esta responde (citação). NULL = envio solto, o caso comum. '
|
||||
'O id que o provider recebe em `replyTo` sai do `external_id` da linha apontada.';
|
||||
@@ -203,6 +203,7 @@ aplica.
|
||||
| `20260820120000` | `0166_indice_do_claim_da_fila` | **O cap global do claim varria `job_queue` inteira a cada rodada.** `claimJobs` (`lib/agent-engine/queue/queue.ts`) abre toda rodada — dentro do advisory lock que serializa os claimers — com `select count(*) from job_queue where status = 'running'`, e nenhum dos quatro índices da tabela servia esse predicado. O parcial das lanes (`uniq_job_queue_one_running_per_contact`) chega perto e **não vale**: o predicado dele é mais ESTREITO (exclui `contact_id is null`, que é todo `watchdog`/`flywheel`), então o planejador não pode responder por ele. Medido em `pgvector/pgvector:pg17` com este baseline, 50.000 linhas `done` + 4 `running`: **Seq Scan / 715 buffers → Index Only Scan / 3 buffers**, índice de **16 kB**. **O custo não depende de linha viva, depende de bloat:** apagando as 50.004 dentro de uma transação e perguntando de novo, o Seq Scan ainda lê os **mesmos 715 buffers** — ele visita PÁGINA, não tupla —, e nada no produto poda `job_queue`. **O `/healthz` do worker NÃO é consertado por isto, e foi medido, não suposto:** `workers/agent-worker/main.ts` e `lib/agent-engine/obs/metrics.ts` fazem `select status, count(*) from job_queue group by status`, que precisa de TODOS os status; com o índice novo o plano continua HashAggregate sobre Seq Scan, 715 buffers, idêntico ao de antes — e um índice CHEIO em `(status)` também não move (medido: o planejador segue escolhendo Seq Scan, e o índice custaria 360 kB). Fica fora do escopo: aquilo roda em probe do Docker a cada 30s, não no caminho quente do claim. **O bloco `do $$` não é cerimônia:** `create index if not exists` casa por NOME e não por definição, e as duas variantes foram medidas em pg17 — homônimo na própria `job_queue` com outra definição vira `NOTICE: ... already exists, skipping` (NOTICE nem chega ao filtro `ERROR\|FATAL` do `update.sh`: no-op perfeitamente silencioso), e homônimo em OUTRA tabela (nome de índice é único por SCHEMA) dá o mesmo no-op **e** faz o `comment on index` acertar o índice errado, cegando o delator. O bloco derruba e recria o homônimo NOSSO em `job_queue`; o de outro objeto ele **não apaga** — `raise exception` com a razão escrita, que é o comportamento certo num script que roda sem `ON_ERROR_STOP` (o erro aparece ao operador e o resto do baseline segue). **O `comment on index` é DELATOR:** índice ausente levanta `relation "idx_job_queue_running" does not exist`, texto conferido contra a lista benigna real do `update.sh` (`already exists\|multiple primary keys\|multiple default values\|is already a member\|already a partition`) — não casa com nenhum termo, logo chega ao cliente; um `already exists` seria engolido. Guardado por `tests/invariants/queue-cap-global-do-claim.test.ts`, que **extrai o `select` do FONTE de produção** em vez de copiá-lo: índice presente e predicado mudado (`::text` a mais, `lower(status)`, `status in (...)`) devolveria Seq Scan com o símbolo intacto. Aditiva e idempotente: sem constraint, sem backfill, sem dado tocado. |
|
||||
| `20260820160000` | `0165_identificador_de_canal_unico_entre_ativos` | **Os dois identificadores de canal que chegaram depois do snapshot não tinham trava nenhuma, e é por eles que o código resolve credencial de envio e o DONO de uma mensagem que acabou de entrar.** `waha_session_name` e `webhook_path_token` são UNIQUE desde o snapshot; `meta_phone_number_id` (0087) e `zernio_account_id` (0131) nasceram sem. Traz **dois índices únicos PARCIAIS** (`where archived_at is null`), pelo precedente da 0107 — canal arquivado é canal excluído e a linha só sobrevive como âncora das FKs RESTRICT, então trava total impediria reconectar o mesmo número depois de excluí-lo. **O que a ausência produzia (issue #236):** três consultas de `lib/channels/` resolviam a sessão só pelo identificador, em client de service role (bypassa RLS). Com duas linhas casando, `maybeSingle()` **não** devolve "a primeira" — medido contra `@supabase/postgrest-js` 2.112.1, devolve `data: null` + `error PGRST116` (HTTP 406). Os três **descartavam o `error`**, então: os dois resolvedores de credencial caíam no fallback do `.env` (a mensagem saía pela conta de OUTRA instalação) e `meta/ingest.ts` devolvia `no_session` com a rota respondendo 200 — a mensagem recebida era **descartada para as duas organizações**. **Classificação: alta, não crítica** — a colisão é atingível por configuração LEGÍTIMA (agência, migração de conta entre organizações), não por reivindicação hostil: `validatePartnerCredentials` (`lib/channels/connect.ts`) exige que o identificador esteja na lista de contas que a chave informada alcança. **A deduplicação vem ANTES da trava e não é destrutiva:** o `update.sh` do clone roda sem `ON_ERROR_STOP` e engoliria o 23505, deixando o clone sem trava e sem aviso. Apagar está fora (sessão de cliente, histórico por FK RESTRICT) e arquivar faria o canal sumir da tela sem ninguém pedir — a perdedora é **RENOMEADA** para `<original>-conflito-<id da sessão>`: continua visível, e como o identificador não existe no provider a varredura de saúde (`app/api/v1/cron/channel-health`) grava `FAILED`/`STOPPED` na passada seguinte e **abre aviso na Central** (os três estados estão em `STATUS_QUE_AVISAM`). Fica com o identificador a sessão ativa **mais recente** (criá-la exigiu provar posse da conta na tela de conexão ⇒ é a intenção mais recente); errar o palpite não destrói nada e a linha perdedora ela mesma avisa. **Idempotente por construção:** o sufixo carrega o `id` (único), então a segunda passada casa zero linhas e não há como sufixar duas vezes. Nomes de índice novos (conferidos contra `baseline.sql` e `migrations/`), então o `if not exists` — que casa por NOME — não vira no-op em cima de um homônimo com outra definição. O código foi corrigido nas três camadas junto: filtro de `organization_id` nos três sítios (com o `organizationId` atravessando o seam de canal como campo **obrigatório**, para o typecheck cobrar), `error` que deixa de ser descartado, e o invariante `tests/unit/canal-consulta-por-organizacao.test.ts`, que varre `lib/channels/` derivando as colunas de `CHANNEL_SESSION_REF_COLUMNS` — o quarto canal entra na varredura sozinho. |
|
||||
| `20260820170000` | `0167_poda_da_fila_e_expurgo_do_audit` | **Nada no produto apagava job terminal, e a retenção de 5 anos do audit existia só no COMMENT.** `grep -rn "from job_queue" lib workers app supabase scripts | grep -i delete` devolvia **zero linhas**: `job_queue` crescia desde a instalação e nunca encolhia; `api_audit_log` prometia 5 anos + "hot 90 dias / cold S3" em seis documentos, sem uma linha de código que executasse qualquer das duas metades. São as candidatas naturais a estourar os **500 MB** do plano free antes de qualquer tabela de negócio — e o bloat também custa CPU (715 buffers varridos no `count(*)` do claim com ZERO linhas vivas, medido na #260). Traz **duas `security definer`** (`fn_podar_fila_de_jobs`, `fn_expurgar_auditoria_vencida`), **três índices** e o cron `data-retention` (diário). **DELETE por idade e não particionamento**: particionar `job_queue` exigiria mexer no claim `FOR UPDATE SKIP LOCKED` e nos dois índices ÚNICOS parciais que garantem um turno por lead — trocar essa garantia por disco é péssimo negócio. **O QUE TEM DONO NÃO SAI, e são três cortes:** `pending`/`running` nunca saem (o primeiro ainda vai sair, o segundo está com um worker e o reaper o devolve); terminais são só `done`/`failed`/`dead` (conferidos em `lib/agent-engine/queue/queue.ts`); e **`dead` com aviso ABERTO na Central tem dono** — um humano que não olhou — com o `not exists` **antes** do `limit`, porque filtrar depois faria um lote de protegidos devolver 0, o laço do cron pararia achando que acabou e a poda morreria de fome com backlog na frente. **Cascata declarada:** o DELETE leva junto `send_ledger` e `before_send_traces` (FK `on delete cascade`, as duas também sem poda) e apenas anula o ponteiro em `llm_calls`/`lead_checkpoints`/`lead_state_transitions`; os dois consumidores de `send_ledger` sem janela (`countPriorAcceptedSends` → disclosure de IA, e o gate LGPD de 1º toque de prospecção, `before-send.ts:261`) falham **fechado** — disclosure a mais e veto a mais, nunca a menos —, daí piso de 7 dias e default de 90. **A definer do audit não é porta de adulteração, e cada razão é conferível:** (a) não tem seletor de linha — nenhum parâmetro de org, ator, ação ou id, e o único predicado é `created_at < now() - N dias`, então ela só sabe apagar pela ponta mais velha; (b) o **piso de 90 dias mora no corpo**, não em quem chama, então nem com a service key se remove rastro recente; (c) revogada das duas origens de EXECUTE e concedida só a `service_role`; (d) não amplia o raio de quem já tem a chave (`service_role` já tem `TRUNCATE` na mesma tabela); (e) **registra a própria erosão** — o cron grava `retention.sweep_run` com a contagem, e essa linha é nova demais para a chamada seguinte alcançar. `api_audit_log` **não tinha** GRANT de DELETE para ninguém (nem para `service_role`), e é por isso que o expurgo não podia sair pelo admin client. **Hot/cold em S3 não foi entregue e a doutrina do `CLAUDE.md` foi corrigida** em vez de fingir: o self-host não tem para onde arquivar (o Storage do cliente é a MESMA cota de 1 GB, já dividida com `whatsapp-media`). Índices com **nome próprio da poda** (`idx_audit_expurgo_created_at`) porque `create index if not exists` casa por NOME e um nome genérico viraria no-op silencioso num clone. Aditiva, sem constraint nova (nada a deduplicar antes), idempotente por `create or replace` + `if not exists` + `revoke`. **NÃO MEDIDO:** o comportamento sob milhões de linhas reais — os lotes foram exercitados no Postgres efêmero do `test:db`, não numa VPS com histórico de anos. |
|
||||
| `20260821090000` | `0168_a_mensagem_que_responde_outra` | **A citação: qual mensagem esta responde.** O canal intermediado aceita `replyTo` no envio (recebendo o `wamid` da citada) e o WhatsApp mostra a resposta pendurada na original — que é como as pessoas conversam ali. Sem guardar QUEM foi citado, o CRM manda a citação para o cliente e não consegue mostrá-la de volta na própria tela: o atendente vê frases soltas onde o cliente vê um fio. **FK e não o wamid solto:** a pergunta da tela é "qual mensagem NOSSA foi citada?", e a resposta é uma linha desta tabela; guardar o texto do provider faria toda renderização buscar por coluna de texto e deixaria de funcionar para a citação de uma mensagem que ainda não tem `external_id` (a nossa, enquanto está `queued`). É o `R` da DIRC — a linha existe, aqui basta o ponteiro, e o id que o provider precisa sai dela no momento do envio. **`on delete set null`, nunca cascade:** apagar a citada não pode levar junto a resposta, que é conteúdo próprio de alguém; perder o fio é aceitável, perder a resposta seria apagar histórico por causa de um ponteiro. Índice PARCIAL porque a esmagadora maioria não cita ninguém. Aditiva e idempotente. |
|
||||
|
||||
## Reproducibility
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* RESPONDER "EM CIMA" DE UMA MENSAGEM — pela tela, como o atendente faz.
|
||||
*
|
||||
* O canal intermediado aceita citação (`replyTo`, recebendo o `wamid`), e o
|
||||
* WhatsApp mostra a resposta pendurada na original. Todo o caminho novo é
|
||||
* VISUAL — escolher a mensagem, ver a faixa, cancelar, enviar — e nada disso é
|
||||
* alcançável por teste de unidade: eles provam que a função existe, não que dá
|
||||
* para clicá-la.
|
||||
*
|
||||
* ─── O que este arquivo cobre, e por que cada caso ──────────────────────────
|
||||
*
|
||||
* 1. o botão APARECE (é `opacity-0` até o hover; um `hidden` teria feito o
|
||||
* layout pular, e um seletor que só olha o DOM passaria mesmo invisível);
|
||||
* 2. escolher mostra a faixa com o trecho citado;
|
||||
* 3. o `×` desfaz — sem saída, quem clica por engano fica preso citando;
|
||||
* 4. trocar de conversa LIMPA a citação. Este é o caso que mais importa: sem
|
||||
* ele, a resposta sairia citando a mensagem de OUTRO cliente.
|
||||
*
|
||||
* Não cobre o que sai na rede: se o `replyTo` chegou ao provider é assunto do
|
||||
* adapter, e o teste de tela não deve fingir que mede isso.
|
||||
*/
|
||||
|
||||
interface E2ECreds {
|
||||
password: string;
|
||||
users: Record<string, { id: string; email: string; role: string }>;
|
||||
}
|
||||
|
||||
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
|
||||
const creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as E2ECreds;
|
||||
const EVIDENCE = path.join(process.cwd(), ".superpowers/evidence");
|
||||
|
||||
/**
|
||||
* Login simples — e por isso o usuário é o `agent`, nunca o `admin`.
|
||||
*
|
||||
* `admin` tem MFA obrigatório (doutrina de Auth), então o login dele para em
|
||||
* `/login/mfa` e este `waitForURL` nunca resolve. O repo tem um helper próprio
|
||||
* para esse caso (`helpers/login-admin.ts`), e ele existe justamente porque a
|
||||
* armadilha já pegou gente antes. As demais specs de inbox usam `agent`, que
|
||||
* tem o acesso que estes casos precisam.
|
||||
*/
|
||||
async function login(page: Page, email: string): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.locator("#email").fill(email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.waitForURL(/\/app\//);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre o inbox e entra numa conversa que TENHA mensagens.
|
||||
*
|
||||
* Devolve `false` quando o ambiente não tem nenhuma — e o caso é pulado em vez
|
||||
* de falhar. Um teste que exige dado semeado por outra spec quebra por ordem de
|
||||
* execução, não por defeito, e ensina a ignorar o vermelho.
|
||||
*/
|
||||
async function abrirConversaComMensagens(page: Page): Promise<boolean> {
|
||||
await page.goto("/app/inbox?filter=all");
|
||||
const bolhas = page.locator("[class*='rounded-2xl']");
|
||||
const primeira = page.locator("li, [role='listitem']").first();
|
||||
if (await primeira.count()) await primeira.click();
|
||||
await expect(bolhas.first())
|
||||
.toBeVisible({ timeout: 8000 })
|
||||
.catch(() => undefined);
|
||||
return (await bolhas.count()) > 0;
|
||||
}
|
||||
|
||||
test.describe("responder citando", () => {
|
||||
test("o botão de responder revela a faixa, e o × a desfaz", async ({ page }) => {
|
||||
await login(page, creds.users.agent!.email);
|
||||
const temMensagens = await abrirConversaComMensagens(page);
|
||||
test.skip(!temMensagens, "ambiente sem conversa com mensagens — nada a citar");
|
||||
|
||||
const responder = page.getByRole("button", { name: /Responder a esta mensagem/i }).first();
|
||||
|
||||
// O botão vive em `opacity-0` até o hover. `toBeVisible` do Playwright
|
||||
// considera opacidade 0 como visível, então o hover é o que prova de
|
||||
// verdade que ele é alcançável — e o clique, que é clicável.
|
||||
await page.locator("[class*='rounded-2xl']").first().hover();
|
||||
await expect(responder).toBeVisible();
|
||||
await responder.click();
|
||||
|
||||
// A faixa aparece acima do campo, com o botão de cancelar.
|
||||
const cancelar = page.getByRole("button", { name: /Cancelar resposta/i });
|
||||
await expect(cancelar).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: path.join(EVIDENCE, "responder-citando-faixa.png"),
|
||||
fullPage: false,
|
||||
});
|
||||
|
||||
await cancelar.click();
|
||||
await expect(cancelar).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("trocar de conversa LIMPA a citação", async ({ page }) => {
|
||||
// Sem isto a resposta sairia citando a mensagem de outro cliente — o pior
|
||||
// desfecho possível desta feature, e invisível até acontecer com alguém.
|
||||
await login(page, creds.users.agent!.email);
|
||||
const temMensagens = await abrirConversaComMensagens(page);
|
||||
test.skip(!temMensagens, "ambiente sem conversa com mensagens");
|
||||
|
||||
await page.locator("[class*='rounded-2xl']").first().hover();
|
||||
const responder = page.getByRole("button", { name: /Responder a esta mensagem/i }).first();
|
||||
await responder.click();
|
||||
await expect(page.getByRole("button", { name: /Cancelar resposta/i })).toBeVisible();
|
||||
|
||||
// Volta para a lista e entra em OUTRA conversa.
|
||||
await page.goto("/app/inbox?filter=all");
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Cancelar resposta/i }),
|
||||
"a citação sobreviveu à troca de conversa",
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* A CITAÇÃO EXISTIA EM TODA PARTE, MENOS ONDE ELA SAI.
|
||||
*
|
||||
* ─── O defeito, visto por quem usa ──────────────────────────────────────────
|
||||
*
|
||||
* O caminho estava inteiro: a tela deixa escolher a mensagem, o handler resolve
|
||||
* o `external_id` da citada, o banco guarda `reply_to_message_id`, e a bolha
|
||||
* aparece pendurada na original. No CRM.
|
||||
*
|
||||
* No WhatsApp do cliente chegava mensagem SOLTA. O adapter do canal por QR
|
||||
* simplesmente não lia `envelope.replyToExternalId` — o campo existia no
|
||||
* envelope, o canal intermediado já o usava, e este ignorava.
|
||||
*
|
||||
* É o pior formato de defeito deste produto: a tela promete, nada fica
|
||||
* vermelho, e quem descobre é o atendente quando o cliente responde sem
|
||||
* entender do que se fala.
|
||||
*
|
||||
* ─── O formato do id é onde isto falharia em silêncio ──────────────────────
|
||||
*
|
||||
* `reply_to` quer o id COMPLETO (`{fromMe}_{chatId}_{bareId}`). O WAHA é
|
||||
* assimétrico (ver `bareWaMessageId`): a resposta de envio devolve o cru, o
|
||||
* webhook devolve o completo. Medido numa instalação real: as 1.734 mensagens
|
||||
* de ENTRADA têm o completo — e citar o que o CLIENTE disse é o caso que
|
||||
* importa.
|
||||
*/
|
||||
|
||||
const fetchOriginal = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = fetchOriginal;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
/** Captura o corpo que sai para o WAHA. */
|
||||
function espiao() {
|
||||
const corpos: Record<string, unknown>[] = [];
|
||||
globalThis.fetch = vi.fn(async (_u: unknown, init?: unknown) => {
|
||||
const b = ((init ?? {}) as { body?: string }).body;
|
||||
if (b) corpos.push(JSON.parse(b) as Record<string, unknown>);
|
||||
return { ok: true, status: 200, json: async () => ({ id: "3EB0ABC" }), text: async () => "" };
|
||||
}) as unknown as typeof fetch;
|
||||
return corpos;
|
||||
}
|
||||
|
||||
async function cliente() {
|
||||
const { WahaClient } = await import("@/lib/waha/client");
|
||||
return new WahaClient("http://waha", "chave");
|
||||
}
|
||||
|
||||
const CITADA = "false_127904277102624@lid_3EB060A3E6B358FFFF84DC";
|
||||
|
||||
describe("o canal por QR manda a citação", () => {
|
||||
it("o `reply_to` sai com o id COMPLETO, como a API pede", async () => {
|
||||
const corpos = espiao();
|
||||
await (await cliente()).sendMessage("s1", "595@c.us", "hijale no recuerdo bro", CITADA);
|
||||
expect(corpos[0]?.reply_to, "a citação não saiu — chega mensagem solta").toBe(CITADA);
|
||||
expect(corpos[0]?.text).toBe("hijale no recuerdo bro");
|
||||
});
|
||||
|
||||
it("sem citação, o campo NÃO vai no corpo", async () => {
|
||||
// `reply_to: null` é pedir para citar "nada". O envio comum não pode passar
|
||||
// a carregar um campo vazio só porque o outro caso existe.
|
||||
const corpos = espiao();
|
||||
await (await cliente()).sendMessage("s1", "595@c.us", "oi");
|
||||
expect(Object.keys(corpos[0] ?? {})).not.toContain("reply_to");
|
||||
});
|
||||
|
||||
it("string vazia também não vira citação", async () => {
|
||||
const corpos = espiao();
|
||||
await (await cliente()).sendMessage("s1", "595@c.us", "oi", "");
|
||||
expect(Object.keys(corpos[0] ?? {})).not.toContain("reply_to");
|
||||
});
|
||||
});
|
||||
|
||||
describe("o adapter repassa o que o envelope traz", () => {
|
||||
/**
|
||||
* ⚠️ O CLIENTE É DUBLADO, e não montado a partir de env.
|
||||
*
|
||||
* A primeira versão usava `vi.stubEnv` para dar `WAHA_BASE_URL` e
|
||||
* `WAHA_API_KEY` ao `getWahaClient()`. Passava na minha máquina e FALHOU no
|
||||
* CI — porque `lib/env.ts` parseia `process.env` uma vez, no carregamento do
|
||||
* módulo, e o stub chega depois. Sem env, `getWahaClient()` devolve `null`, o
|
||||
* adapter sai por cima e nenhum corpo é capturado.
|
||||
*
|
||||
* Ou seja: o teste dizia "a citação chega" porque a MINHA máquina tinha as
|
||||
* credenciais. Teste que depende do ambiente de quem roda não prova nada — e
|
||||
* este mentia para o lado pior, o verde.
|
||||
*/
|
||||
it("`replyToExternalId` chega ao corpo do envio", async () => {
|
||||
const chamadas: unknown[][] = [];
|
||||
vi.doMock("@/lib/waha/client", async (orig) => ({
|
||||
...(await orig<Record<string, unknown>>()),
|
||||
getWahaClient: () => ({
|
||||
sendMessage: (...a: unknown[]) => {
|
||||
chamadas.push(a);
|
||||
return Promise.resolve({ id: "3EB0ABC" });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.resetModules();
|
||||
const { wahaAdapter } = await import("@/lib/channels/adapters/waha");
|
||||
await wahaAdapter.send({
|
||||
// `organizationId` entrou no envelope na sincronia de 21/08 — o adapter
|
||||
// não o usa aqui, mas o tipo o exige.
|
||||
organizationId: "org-1",
|
||||
sessionRef: "s1",
|
||||
to: "595@c.us",
|
||||
kind: "text",
|
||||
body: "hijale no recuerdo bro",
|
||||
replyToExternalId: CITADA,
|
||||
});
|
||||
// O 4º argumento de `sendMessage` é a citação — o elo que faltava.
|
||||
expect(chamadas[0]?.[3], "o adapter voltou a ignorar a citação").toBe(CITADA);
|
||||
vi.doUnmock("@/lib/waha/client");
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* O BOTÃO DE RESPONDER PRECISA EXISTIR NO CELULAR.
|
||||
*
|
||||
* ─── O defeito que este arquivo existe para impedir ─────────────────────────
|
||||
*
|
||||
* A primeira versão copiou o WhatsApp Web: `opacity-0` + `group-hover`. Em
|
||||
* desktop fica elegante; no celular o botão fica INVISÍVEL PARA SEMPRE — não há
|
||||
* como passar o mouse, e `focus-visible` só chega por teclado.
|
||||
*
|
||||
* Ou seja, a função sumia exatamente onde o dono deste CRM mais atende. Foi ele
|
||||
* quem apontou, olhando a tela do telefone, e não nenhum gate.
|
||||
*
|
||||
* ─── Por que a pergunta é `hover`, e não largura ────────────────────────────
|
||||
*
|
||||
* `@media (hover: hover)` pergunta pelo DISPOSITIVO. Um tablet largo com toque
|
||||
* continua mostrando o botão; um desktop com janela estreita continua
|
||||
* escondendo. Um breakpoint de largura (`md:`) erraria os dois casos, e erraria
|
||||
* em silêncio.
|
||||
*
|
||||
* ─── O que este teste NÃO prova, e por que ele é assim mesmo ───────────────
|
||||
*
|
||||
* Ele lê CLASSES, não comportamento — e classe presente não é botão alcançável.
|
||||
* A prova de verdade seria abrir um browser SEM hover, e o Playwright não sabe
|
||||
* emular isso: `emulateMedia` cobre `colorScheme`, `reducedMotion` e
|
||||
* `forcedColors`, não `hover`. `hasTouch` muda os eventos, não a media query.
|
||||
*
|
||||
* Então esta é a rede possível, e ela é honesta sobre o que segura: impede a
|
||||
* REGRESSÃO exata que aconteceu (alguém voltar a esconder sem condicionar ao
|
||||
* dispositivo). Quem quiser a prova real precisa de um aparelho na mão.
|
||||
*/
|
||||
|
||||
const BOLHA = readFileSync("components/inbox/MessageBubble.tsx", "utf8");
|
||||
|
||||
describe("no celular o botão de responder aparece", () => {
|
||||
it("o padrão é VISÍVEL — esconder é a exceção", () => {
|
||||
// A ordem importa: `opacity-100` como base e o `0` atrás da media query.
|
||||
// Invertido, o celular volta a não ver nada.
|
||||
expect(BOLHA).toMatch(/"opacity-100 \[@media\(hover:hover\)\]:opacity-0"/);
|
||||
});
|
||||
|
||||
it("só esconde onde EXISTE hover", () => {
|
||||
expect(BOLHA, "voltou a esconder sem perguntar pelo dispositivo").toMatch(
|
||||
/\[@media\(hover:hover\)\]:group-hover:opacity-100/,
|
||||
);
|
||||
});
|
||||
|
||||
it("não usa breakpoint de LARGURA para decidir isso", () => {
|
||||
// `md:opacity-0` erraria o tablet com toque e o desktop estreito — e erraria
|
||||
// calado, que é o que torna esse tipo de bug caro.
|
||||
const trechos = [...BOLHA.matchAll(/(?:sm|md|lg|xl):opacity-0/g)];
|
||||
expect(trechos.map((m) => m[0]), "largura não responde 'tem hover?'").toEqual([]);
|
||||
});
|
||||
|
||||
it("os DOIS botões (entrada e saída) seguem a mesma regra", () => {
|
||||
// São dois elementos espelhados. Consertar um e esquecer o outro deixaria a
|
||||
// metade da conversa sem resposta possível no celular.
|
||||
const comRegra = [...BOLHA.matchAll(/\[@media\(hover:hover\)\]:opacity-0/g)];
|
||||
expect(comRegra.length, "um dos dois botões ficou de fora").toBe(2);
|
||||
});
|
||||
|
||||
it("o teclado também alcança", () => {
|
||||
// Quem navega por Tab não tem hover nem toque. Sem isto o botão existe e é
|
||||
// inalcançável — que é a mesma falha, com outro público.
|
||||
expect(BOLHA).toMatch(/focus-visible:opacity-100/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user