Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/app/api/cron/wa-auto-health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {NextResponse} from 'next/server';
import {timingSafeEqual} from 'node:crypto';
import {getSupabaseAdmin} from '@/lib/server-db';
import {waAutoForOwner} from '@/lib/wa-auto-client';
import {resolveWaAutoPermissions} from '@/lib/wa-auto-permissions';

export const dynamic='force-dynamic';
export const maxDuration=30;

/** Internal readiness check. This uses precisely the same WA.Auto transport
* credentials as automated sends, without sending a WhatsApp message.
*/
export async function GET(request:Request){
const secret=String(process.env.WA_MOVEMENT_CRON_SECRET||'');
const supplied=String(request.headers.get('authorization')||'').replace(/^Bearer\s+/i,'').trim();
if(secret.length<20||secret.length!==supplied.length||
!timingSafeEqual(Buffer.from(secret),Buffer.from(supplied)))
return NextResponse.json({ok:false,error:'Unauthorized'},{status:401});
try{
const db=await getSupabaseAdmin();
const {data:settings,error}=await db.from('wa_daily_return_settings')
.select('empresa_id,owner_auth_id,consent_attested')
.eq('consent_attested',true).not('owner_auth_id','is',null).limit(3);
if(error)return NextResponse.json({ok:false,error:'Settings unavailable'},{status:503});
const checked=[];
for(const config of settings||[]){
const {data:owner}=await db.from('usuarios')
.select('empresa_id,cargo')
.eq('empresa_id',config.empresa_id)
.eq('auth_user_id',config.owner_auth_id).maybeSingle();
if(!owner||!resolveWaAutoPermissions({cargo:owner.cargo}).canManage){
checked.push({ok:false,reason:'unverified_owner'});
continue;
}
const result=await waAutoForOwner(config.owner_auth_id,config.empresa_id);
checked.push({ok:result.ok===true,
status:result.ok?'ready':'offline',
httpStatus:result.ok?200:(result.httpStatus||null),
reason:result.ok?'authenticated':'integration_or_session_unavailable'});
}
return NextResponse.json({ok:checked.length>0&&checked.every(x=>x.ok),checks:checked},
{headers:{'Cache-Control':'private, no-store'}});
}catch{
return NextResponse.json({ok:false,error:'Health check unavailable'},{status:503});
}
}
6 changes: 3 additions & 3 deletions src/app/whatsapp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1151,9 +1151,9 @@ function WhatsAppTerminalInner() {

return (
<>
<div className="flex h-[calc(100dvh-64px)] min-h-0 bg-background text-foreground overflow-hidden">
<div className="flex min-h-[calc(100dvh-64px)] w-full bg-background text-foreground">
<Sidebar />
<main className="lexis-main-pad flex-1 min-w-0 min-h-0 flex flex-col overflow-y-auto overscroll-contain pb-[82px] md:pb-[78px]">
<main className="lexis-main-pad flex-1 min-w-0 flex flex-col overflow-visible pb-[110px] md:pb-[96px]">
<header className="shrink-0 border-b border-border/60 bg-card/80 backdrop-blur px-3 sm:px-4 py-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
<div className="h-10 w-10 rounded-xl bg-emerald-600 text-white flex items-center justify-center shrink-0">
Expand Down Expand Up @@ -1220,7 +1220,7 @@ function WhatsAppTerminalInner() {

<WaAutoConnectionCard />

<div className="flex-1 min-h-[560px] shrink-0 grid grid-cols-1 grid-rows-[minmax(120px,25dvh)_minmax(0,1fr)] md:grid-cols-12 md:grid-rows-1 overflow-hidden">
<div className="flex-1 min-h-[560px] grid grid-cols-1 grid-rows-[minmax(120px,25dvh)_minmax(0,1fr)] md:grid-cols-12 md:grid-rows-1 overflow-hidden" style={{height:"min(760px, max(560px, calc(100dvh - 235px)))"}}>
{/* Lista */}
<aside className="md:col-span-4 xl:col-span-3 border-r border-border/50 border-b md:border-b-0 flex flex-col min-h-0 bg-card/40 overflow-hidden">
<div className="p-3 border-b border-border/40">
Expand Down
39 changes: 22 additions & 17 deletions src/components/whatsapp/movement-campaign-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { BellRing, FileClock, CheckCircle2, Clock3, Loader2, Pause, Play, RefreshCcw, Send, ShieldCheck, Square, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { PUBLICATION_TEMPLATE_PREVIEWS } from "@/lib/wa-publication-templates";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
Expand Down Expand Up @@ -41,7 +40,7 @@ export function MovementCampaignControl() {
const [open,setOpen]=useState(false);
const [loading,setLoading]=useState(false);
const [sending,setSending]=useState(false);
const [consent,setConsent]=useState(false);

const [kind,setKind]=useState<CampaignKind>("movement");
const [showTemplates,setShowTemplates]=useState(false);
const [preview,setPreview]=useState<Preview|null>(null);
Expand Down Expand Up @@ -74,6 +73,9 @@ export function MovementCampaignControl() {
if(!result.ok&&result.error) {
toast({title:"Fila de movimentações",description:result.error,variant:"destructive"});
}
if(result.warning) {
toast({title:"Envio aceito, mas revisão necessária",description:result.warning,variant:"destructive"});
}
await reload();
}catch{
if(alive)toast({title:"Falha de conexão com a fila",description:"Confira os envios antes de retomar.",variant:"destructive"});
Expand All @@ -91,23 +93,22 @@ export function MovementCampaignControl() {
if(!allowed)return null;

const inspect=async(selectedKind:CampaignKind)=>{
setKind(selectedKind);setOpen(true);setConsent(false);setLoading(true);setPreview(null);setShowTemplates(false);
setKind(selectedKind);setOpen(true);setLoading(true);setPreview(null);setShowTemplates(false);
try{
const result=await Promise.race([
previewWhatsAppMovementCampaignAction(selectedKind),
new Promise<never>((_,reject)=>window.setTimeout(()=>reject(new Error('A consulta demorou demais. Tente atualizar a prévia.')),25000)),
]);
if(result.ok)setConsent(result.consentAttested===true);
setPreview(result as Preview);
}catch(e:any){setPreview({ok:false,error:e?.message||"Não foi possível consultar a carteira."});}
finally{setLoading(false);}
};

const start=async()=>{
if((!consent)||!preview?.ok||!preview.counts?.eligible)return;
if(!preview?.ok||!preview.counts?.eligible)return;
setLoading(true);
try{
const result=await startWhatsAppMovementCampaignAction(consent,kind);
const result=await startWhatsAppMovementCampaignAction(true,kind);
if(!result.ok){
toast({title:"Campanha não iniciada",description:result.error,variant:"destructive"});
return;
Expand Down Expand Up @@ -206,6 +207,14 @@ export function MovementCampaignControl() {
</div>
)}
</div>
{preview.counts.eligible===0&&(
<div role="status" className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
Nenhum comunicado está pronto neste momento. Retornos vencidos não são envios aptos.
O scanner DataJud + DJEN pode conferir casos individuais; somente novidades oficiais
posteriores ao último retorno entram na fila. Casos encerrados e telefones ausentes
permanecem excluídos.
</div>
)}
<div className="rounded-xl border border-border overflow-hidden">
<div className="bg-muted/50 px-3 py-2 text-xs font-bold">Prévia individualizada — até 5 exemplos</div>
<div className="max-h-[220px] overflow-y-auto divide-y divide-border">
Expand Down Expand Up @@ -244,15 +253,11 @@ export function MovementCampaignControl() {
</p>
</div>
) : null}
{!preview.consentAttested ? (
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3">
<Checkbox checked={consent} onCheckedChange={v=>setConsent(v===true)} className="mt-0.5"/>
<span className="text-xs leading-relaxed">
<strong>Confirmo que os clientes desta carteira autorizaram contatos de acompanhamento processual por WhatsApp.</strong>
Mensagens serão efetivamente enviadas ao iniciar.
</span>
</label>
) : <p className="text-xs text-emerald-700">Consentimento e opt-in da carteira já confirmados.</p>}
<p className="rounded-xl border border-emerald-500/25 bg-emerald-500/5 p-3 text-xs text-foreground">
Envio sem confirmação repetitiva: ao iniciar, o gestor utiliza a autorização contratual
declarada para a carteira desta empresa. O sistema ainda exclui números inválidos,
processos encerrados, retornos sem novidade e contatos com recusa expressa ou SAIR.
</p>
<p className="text-[11px] text-muted-foreground">
{kind==='publication'
? 'Publicações: até 25 mensagens por dia, intervalo mínimo de 3 minutos, só em horário comercial de dias úteis, no máximo uma por número a cada 24h. Esses controles não garantem ausência de bloqueio; a política do WhatsApp e os modelos aprovados quando exigidos continuam obrigatórios.'
Expand All @@ -262,9 +267,9 @@ export function MovementCampaignControl() {
</p>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={()=>setOpen(false)}>Cancelar</Button>
<Button onClick={()=>void start()} disabled={!consent||loading||!preview.counts.eligible||campaign?.status==='running'}>
<Button onClick={()=>void start()} disabled={loading||!preview.counts.eligible||campaign?.status==='running'}>
{loading?<Loader2 size={14} className="mr-2 animate-spin"/>:<Send size={14} className="mr-2"/>}
{kind==='publication'?'Iniciar automaticamente ':'Iniciar '}{preview.counts.eligible} avisos
{'Iniciar envio sequencial de '}{preview.counts.eligible} avisos
</Button>
</div>
{campaign?.status==='running'&&<p className="text-xs text-amber-700 dark:text-amber-300">Pause ou conclua a fila em andamento antes de iniciar uma nova.</p>}
Expand Down
4 changes: 2 additions & 2 deletions src/components/whatsapp/wa-daily-return-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function WaDailyReturnPanel(){
{[1,3,7,14,30].map(n=><option key={n} value={n}>{n} dia{n>1?'s':''}</option>)}
</select></label>
<Button size="sm" variant="outline" onClick={()=>void save()} disabled={busy}>Salvar periodicidade</Button>
{dash?.ok?<span className="text-muted-foreground">Retornos vencidos: {dash.due||0} · Enviados hoje: {dash.sentToday||0}</span>:null}
{dash?.ok?<span className="text-muted-foreground">Retornos agendados vencidos (antes dos filtros): {dash.due||0} · Enviados hoje: {dash.sentToday||0}</span>:null}
</div>
<div className="rounded-xl border border-border p-3 space-y-2">
<p className="text-xs font-semibold">Scanner de retornos · um processo por vez</p>
Expand All @@ -112,7 +112,7 @@ export function WaDailyReturnPanel(){
<Button size="sm" disabled={busy||running} onClick={start}>Iniciar varredura</Button>
<Button size="sm" variant="destructive" disabled={!running} onClick={stop}>Parar scanner</Button>
</div>
<p className="text-xs text-muted-foreground">Verificados nesta sessão: {checked}. A varredura manual continua enquanto esta aba permanecer aberta. O scanner da carteira também prepara avisos quando a automação está ativa.</p>
<p className="text-xs text-muted-foreground">Verificados nesta sessão: {checked}. A verificação ocorre um processo por vez. O total vencido inclui casos encerrados, sem telefone e sem andamento novo; nenhum deles é disparado sem validação. O scanner da carteira também prepara avisos apenas quando a automação está ativa.</p>
</div>
<div className="rounded-xl border border-border p-3 space-y-2">
<p className="text-xs font-semibold">Verificar apenas um processo</p>
Expand Down
43 changes: 35 additions & 8 deletions src/lib/wa-auto-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,20 +341,47 @@ export async function logoutWaAuto() {
}

/** Already-authorized background identity; never accepts identity from a browser argument. */
/**
* WA.Auto Cloud expects the shared integration token plus x-lexis-user-id.
* The older lexishwa1 HMAC credential is not accepted by its integration
* middleware and caused every server-side queue send to return HTTP 401.
* Caller resolves company ownership and management permission before here.
*/
export async function waAutoForOwner(userId:string,empresaId:string,to?:string,message?:string) {
const cfg=getWaAutoConfig();
const validId=/^[0-9a-f-]{36}$/i;
if(!validId.test(userId)||!validId.test(empresaId))return {
ok:false as const,rejected:true,httpStatus:403,error:'Responsável pela sessão não identificado.'
};
if(!cfg.integrationToken)return {
ok:false as const,rejected:true,httpStatus:503,
error:'WA_INTEGRATION_TOKEN não configurado no LexisPredict para envios do servidor.'
};
try {
const {signWaWorkerIdentity}=await import('@/lib/wa-worker-auth');
const token=signWaWorkerIdentity(userId,empresaId,cfg.integrationToken);
const response=await fetch(cfg.baseUrl+'/api/integrations/lexispredict/'+(to?'send':'status'),{
method:to?'POST':'GET',headers:{Authorization:'Bearer '+token,'Content-Type':'application/json'},
...(to?{body:JSON.stringify({to,message})}:{}),cache:'no-store',signal:AbortSignal.timeout(to?40000:12000),
method:to?'POST':'GET',
headers:{
Authorization:'Bearer '+cfg.integrationToken,
'x-wa-integration-token':cfg.integrationToken,
'x-lexis-user-id':userId,
'Content-Type':'application/json',
},
...(to?{body:JSON.stringify({to,message})}:{}),
cache:'no-store',
signal:AbortSignal.timeout(to?40000:12000),
});
const body=await readJson(response);
if(!response.ok||body?.ok===false)return {ok:false as const,rejected:response.status>=400&&response.status<500,
httpStatus:response.status,error:String(body?.error||'WA.Auto HTTP '+response.status),raw:body};
if(!to && body?.connection?.status!=='ready')return {ok:false as const,rejected:true,
httpStatus:409,error:'WhatsApp do responsável desconectado',raw:body};
if(!response.ok||body?.ok===false)return {
ok:false as const,rejected:response.status>=400&&response.status<500,
httpStatus:response.status,
error:response.status===401
? 'WA.Auto rejeitou a credencial do servidor (HTTP 401). Confira WA_INTEGRATION_TOKEN em ambas as plataformas; a campanha foi bloqueada.'
: String(body?.error||'WA.Auto HTTP '+response.status),raw:body,
};
if(!to && body?.connection?.status!=='ready')return {
ok:false as const,rejected:true,httpStatus:409,
error:'WhatsApp do responsável desconectado no WA.Auto.',raw:body,
};
return {ok:true as const,raw:body};
}catch(e:any){return {ok:false as const,rejected:false,error:String(e?.message||'WA.Auto indisponível')};}
}
Loading
Loading