diff --git a/src/app/api/cron/wa-auto-health/route.ts b/src/app/api/cron/wa-auto-health/route.ts new file mode 100644 index 00000000..b5a21c48 --- /dev/null +++ b/src/app/api/cron/wa-auto-health/route.ts @@ -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}); + } +} diff --git a/src/app/whatsapp/page.tsx b/src/app/whatsapp/page.tsx index d55e2256..057cfc9a 100644 --- a/src/app/whatsapp/page.tsx +++ b/src/app/whatsapp/page.tsx @@ -1151,9 +1151,9 @@ function WhatsAppTerminalInner() { return ( <> -
+
-
+
@@ -1220,7 +1220,7 @@ function WhatsAppTerminalInner() { -
+
{/* Lista */}
+ {preview.counts.eligible===0&&( +
+ 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. +
+ )}
Prévia individualizada — até 5 exemplos
@@ -244,15 +253,11 @@ export function MovementCampaignControl() {

) : null} - {!preview.consentAttested ? ( - - ) :

Consentimento e opt-in da carteira já confirmados.

} +

+ 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. +

{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.' @@ -262,9 +267,9 @@ export function MovementCampaignControl() {

-
{campaign?.status==='running'&&

Pause ou conclua a fila em andamento antes de iniciar uma nova.

} diff --git a/src/components/whatsapp/wa-daily-return-panel.tsx b/src/components/whatsapp/wa-daily-return-panel.tsx index fb956ad2..184a4c9d 100644 --- a/src/components/whatsapp/wa-daily-return-panel.tsx +++ b/src/components/whatsapp/wa-daily-return-panel.tsx @@ -103,7 +103,7 @@ export function WaDailyReturnPanel(){ {[1,3,7,14,30].map(n=>)} - {dash?.ok?Retornos vencidos: {dash.due||0} · Enviados hoje: {dash.sentToday||0}:null} + {dash?.ok?Retornos agendados vencidos (antes dos filtros): {dash.due||0} · Enviados hoje: {dash.sentToday||0}:null}

Scanner de retornos · um processo por vez

@@ -112,7 +112,7 @@ export function WaDailyReturnPanel(){
-

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.

+

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.

Verificar apenas um processo

diff --git a/src/lib/wa-auto-client.ts b/src/lib/wa-auto-client.ts index 64da57b7..97a4b451 100644 --- a/src/lib/wa-auto-client.ts +++ b/src/lib/wa-auto-client.ts @@ -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')};} } diff --git a/src/lib/wa-movement-campaign.ts b/src/lib/wa-movement-campaign.ts index b24e647f..6e1e68af 100644 --- a/src/lib/wa-movement-campaign.ts +++ b/src/lib/wa-movement-campaign.ts @@ -5,7 +5,7 @@ import 'server-only'; import { getSupabaseAdmin, getUserContext } from '@/lib/server-db'; import { resolveWaAutoPermissions } from '@/lib/wa-auto-permissions'; -import { waAutoHealth, waAutoForOwner, sendViaWaAuto } from '@/lib/wa-auto-client'; +import { waAutoForOwner, sendViaWaAuto } from '@/lib/wa-auto-client'; import { persistWhatsAppMessage } from '@/lib/whatsapp-persist'; import { prepareMovementAlert, type SourceRow, type Alert } from '@/lib/wa-movement-builder'; import {getDailyReturnSettings,upsertDailyReturnSettings} from '@/lib/wa-daily-return-service'; @@ -31,7 +31,7 @@ async function collectPortfolio(empresaId: string, kind:CampaignKind='movement', alreadyClosed:0, consentMissing:0, needsReview:0, alreadyNotified:0, missingReturn:0, noNewMovement:0 }; let after=0; for (;;) { - const {data,error}=await db.rpc('wa_notice_portfolio_rows',{p_empresa:empresaId,p_after:after,p_limit:600}); + const {data,error}=await db.rpc('wa_notice_portfolio_rows',{p_empresa:empresaId,p_after:after,p_limit:3000}); if(error)throw new Error('Carteira: '+error.message); const rows=(data||[]) as SourceRow[]; for(const row of rows) { @@ -48,7 +48,7 @@ async function collectPortfolio(empresaId: string, kind:CampaignKind='movement', else counts.withoutEvent++; } else entries.push(alert); } - if(rows.length<600)break; + if(rows.length<3000)break; after=Number(rows[rows.length-1].id); } return {entries,counts}; @@ -58,10 +58,14 @@ export async function previewMovementCampaign(kind:CampaignKind='movement') { try { const ctx = await requireManager(); const settings=await getDailyReturnSettings(ctx.empresa_id); - const { entries, counts } = await collectPortfolio(ctx.empresa_id,kind,settings.consentAttested); const db = await getSupabaseAdmin(); + const [portfolio, priorResponse] = await Promise.all([ + collectPortfolio(ctx.empresa_id,kind,true), // prévia sob autorização contratual a registrar pelo gestor ao iniciar + db.rpc('wa_notice_prior',{p_empresa:ctx.empresa_id}), + ]); + const {entries,counts}=portfolio; // Preview excludes already queued/sent versions (database uniqueness also guards races). - const {data,error} = await db.rpc('wa_notice_prior',{p_empresa:ctx.empresa_id}); + const {data,error}=priorResponse; if (error) throw new Error('Fila não instalada no Supabase. Aplique a migração: ' + error.message); const prior = new Set((data || []).map((x:{processo_id:number;event_hash:string}) => x.processo_id + ':' + x.event_hash)); const pending = entries.filter(x => !prior.has(x.processo_id + ':' + x.event_hash)); @@ -82,7 +86,9 @@ export async function createMovementCampaign(confirmed: boolean,kind:CampaignKin const settings=await getDailyReturnSettings(ctx.empresa_id); if (!settings.consentAttested && confirmed !== true) throw new Error('Confirme previamente a autorização dos contatos de acompanhamento.'); - const health = await waAutoHealth(); + // Health must use the SAME server credential as the background dispatch. + // A browser-connected session alone did not validate automated sends (401). + const health = await waAutoForOwner(ctx.auth_id,ctx.empresa_id); if (!health.ok) throw new Error('Conecte o WA.Auto antes de iniciar. ' + (health.error || 'Sessão indisponível')); const db=await getSupabaseAdmin(); const {data: existing} = await db.from('wa_movement_campaigns').select('id') @@ -102,13 +108,15 @@ export async function createMovementCampaign(confirmed: boolean,kind:CampaignKin if (createErr || !campaign) throw new Error(createErr?.message || 'Não foi possível criar a campanha.'); let inserted=0; try { - for(let offset=0;offset - db.rpc('wa_add_campaign_notice',{p_campaign:campaign.id,p_notice:notice}))); - for(const result of results) { - if(result.error)throw new Error(result.error.message); - if(result.data)inserted++; - } + // One atomic SQL batch per 300 records rather than 150+ sequential + // network round trips for a portfolio of ~2,600 cases. + for(let offset=0;offset'cliente','CLIENTE',p.dados->'CLIENTE','telefone',p.dados->'telefone','TELEFONE',p.dados->'TELEFONE', + 'protocolo',p.dados->'protocolo','situacao',p.dados->'situacao','SITUACAO',p.dados->'SITUACAO', + 'status',p.dados->'status','status_interno',p.dados->'status_interno','statusManual',p.dados->'statusManual','STATUS_MANUAL',p.dados->'STATUS_MANUAL', + 'whatsapp_opt_in',p.dados->'whatsapp_opt_in','consentimento_whatsapp',p.dados->'consentimento_whatsapp','whatsapp_autorizado',p.dados->'whatsapp_autorizado', + 'nao_contatar',p.dados->'nao_contatar','não_contatar',p.dados->'não_contatar','whatsapp_opt_out',p.dados->'whatsapp_opt_out', + 'optOut',p.dados->'optOut','optout',p.dados->'optout','bloquear_whatsapp',p.dados->'bloquear_whatsapp','naoEnviarWhatsapp',p.dados->'naoEnviarWhatsapp', + 'wa_djen_evidence',p.dados->'wa_djen_evidence','tribunal_conferencia',p.dados->'tribunal_conferencia', + 'datajud_consultado_em',p.dados->'datajud_consultado_em', + 'ultimoRetorno',p.dados->'ultimoRetorno','ultimo_retorno',p.dados->'ultimo_retorno', + 'proximoRetorno',p.dados->'proximoRetorno','proximo_retorno',p.dados->'proximo_retorno' + ))) + from public.processos p where p.empresa_id=p_empresa and p.id>p_after + order by p.id limit least(3000,greatest(1,p_limit)) +$$; + +revoke all on function public.wa_notice_portfolio_rows(uuid,bigint,integer) from public,anon,authenticated; +grant execute on function public.wa_notice_portfolio_rows(uuid,bigint,integer) to service_role; + +-- Bulk insert is atomic per request and restricted to existing per-tenant +-- campaign rows. It delegates actual constraints to wa_add_campaign_notice. +create or replace function public.wa_add_campaign_notices(p_campaign uuid,p_notices jsonb) +returns integer language plpgsql security invoker set search_path='' as $$ +declare + campaign public.wa_movement_campaigns%rowtype; + item jsonb; + accepted uuid; + total integer:=0; +begin + if jsonb_typeof(p_notices)<>'array' or jsonb_array_length(p_notices)>500 + then raise exception 'Invalid campaign batch'; end if; + select * into campaign from public.wa_movement_campaigns + where id=p_campaign and status='paused' and consent_attested=true + for update; + if not found then raise exception 'Campaign not ready for queueing'; end if; + for item in select value from jsonb_array_elements(p_notices) loop + if item->>'empresa_id' is distinct from campaign.empresa_id::text + then raise exception 'Tenant mismatch'; end if; + accepted:=public.wa_add_campaign_notice(p_campaign,item); + if accepted is not null then total:=total+1; end if; + end loop; + return total; +end $$; +revoke all on function public.wa_add_campaign_notices(uuid,jsonb) from public,anon,authenticated; +grant execute on function public.wa_add_campaign_notices(uuid,jsonb) to service_role;