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
12 changes: 6 additions & 6 deletions src/components/whatsapp/movement-campaign-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type CampaignKind = "movement" | "publication";
type Preview = {
ok:boolean;error?:string;consentAttested?:boolean;kind?:CampaignKind;counts?:{
scanned:number;withoutPhone:number;withoutEvent:number;blocked:number;samePhone:number;
alreadyQueued:number;eligible:number;alreadyClosed?:number;consentMissing?:number;
alreadyQueued:number;eligible:number;alreadyClosed?:number;closedEligible?:number;consentMissing?:number;
needsReview?:number;alreadyNotified?:number;missingReturn?:number;noNewMovement?:number;
}; samples?:Sample[];
};
Expand Down Expand Up @@ -172,8 +172,8 @@ export function MovementCampaignControl() {
<DialogTitle className="flex items-center gap-2 text-base"><BellRing size={18}/> {kind==='publication'?'Avisar novidades pendentes da carteira':'Avisar clientes — última movimentação'}</DialogTitle>
<DialogDescription>
{kind==='publication'
? 'Considera toda a carteira da empresa, de todos os responsáveis. Envia apenas para processos abertos com movimentação DataJud/DJEN posterior ao último retorno marcado. Eventos antigos e processos encerrados são excluídos.'
: 'Consulta toda a carteira da empresa. Prepara um aviso somente quando há movimentação posterior ao último retorno, excluindo encerrados, bloqueados e avisos já registrados.'}
? 'Considera toda a carteira da empresa, de todos os responsáveis. Considera também processos marcados como encerrados. Envia somente movimentação DataJud/DJEN posterior ao último retorno, com teor oficial verificável, consentimento e sem bloqueio.'
: 'Consulta toda a carteira da empresa. Prepara um aviso por novidade posterior ao último retorno, inclusive de processos marcados como encerrados. Bloqueios, avisos repetidos e eventos sem prova verificável são excluídos.'}
Comment on lines 174 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the contradictory closed-case exclusion notice

The updated description tells managers that closed processes are included, but the same confirmation dialog still states at lines 256-259 that the system excludes closed processes. Since the backend now includes them, an operator can start a customer-facing bulk campaign based on a false exclusion summary; the final notice should accurately describe the recipients before enabling the send.

Useful? React with 👍 / 👎.

</DialogDescription>
</DialogHeader>
{loading&&!preview?<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground"><Loader2 className="animate-spin" size={18}/> Conferindo todos os processos...</div>:null}
Expand All @@ -197,7 +197,7 @@ export function MovementCampaignControl() {
{preview.counts.blocked} bloqueados/não contatar; {preview.counts.alreadyQueued} já preparados ou enviados (não duplicar).
{(
<div className="mt-2 space-y-1 rounded-xl border bg-muted/40 p-3 text-xs">
<p><strong>{preview.counts.alreadyClosed||0}</strong> já encerrados na carteira (excluídos)</p>
<p><strong>{preview.counts.closedEligible||0}</strong> marcados como encerrados com aviso verificável elegível (incluídos na seleção)</p>
<p><strong>{preview.counts.consentMissing||0}</strong> sem autorização expressa de WhatsApp (excluídos)</p>
<p><strong>{preview.counts.noNewMovement||0}</strong> sem novidade após o último retorno (excluídos)</p>
<p><strong>{preview.counts.missingReturn||0}</strong> sem data de último retorno para comparar</p>
Expand All @@ -211,8 +211,8 @@ export function MovementCampaignControl() {
<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.
posteriores ao último retorno entram na fila. Casos sem novidade verificável e telefones ausentes
permanecem excluídos, independentemente do status interno.
</div>
)}
<div className="rounded-xl border border-border overflow-hidden">
Expand Down
11 changes: 11 additions & 0 deletions src/lib/wa-daily-return-policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {describe,expect,it} from 'vitest';
import {isStatusRequest,normalizePhone,prepareDailyReturn,type ReturnCase} from './wa-daily-return-policy';
import {prepareMovementAlert} from './wa-movement-builder';
const base:ReturnCase={
id:1,empresa_id:'11111111-1111-1111-1111-111111111111',
cliente:'Maria de Souza',telefone:'(11) 99999-4321',
Expand Down Expand Up @@ -43,6 +44,16 @@ describe('retorno inteligente DataJud/DJEN',()=>{
expect(prepareDailyReturn({...base,ultimo_retorno:null},{mode:'due',today:'2026-10-09'}).reason).toBe('missing_return');
expect(prepareDailyReturn({...base,status:'ENCERRADO'},{mode:'due',today:'2026-10-09'}).reason).toBe('closed');
});
it('includes closed cases only in verified movement campaigns, not routine due returns',()=>{
const closed={...base,status:'ENCERRADO'};
const allowed=prepareMovementAlert(closed);
expect(allowed.reason).toBe('ok');
Comment on lines +49 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the conflicting closed-case regression test

This new ok expectation directly conflicts with src/lib/wa-movement-builder.test.ts:36-38, which still expects the same prepareMovementAlert({...base, status:'ENCERRADO'}) call to return closed. Because prepareMovementAlert now always passes includeClosed:true, the pre-existing test will receive ok, causing the full Vitest suite to fail until that assertion is updated.

Useful? React with 👍 / 👎.

expect(allowed.alert?.message).toContain('Setor Processual');
expect(allowed.alert?.message).not.toMatch(/seu processo foi encerrado|seu processo foi extinto/i);
expect(prepareDailyReturn(closed,{mode:'due',today:'2026-10-09'}).reason).toBe('closed');
expect(prepareMovementAlert({...closed,ultimo_retorno:'2026-10-08'}).reason).toBe('no_new_movement');
expect(prepareMovementAlert({...closed,dados:{whatsapp_opt_in:true,whatsapp_opt_out:true}}).reason).toBe('blocked');
});
it('requires explicit opt-in and honors opt-out',()=>{
expect(prepareDailyReturn({...base,dados:{}},{mode:'due',today:'2026-10-09'}).reason).toBe('no_consent');
expect(prepareDailyReturn({...base,dados:{whatsapp_opt_in:true,nao_contatar:true}},{mode:'due',today:'2026-10-09'}).reason).toBe('blocked');
Expand Down
7 changes: 5 additions & 2 deletions src/lib/wa-daily-return-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,13 @@ export function isStatusRequest(input:string):boolean {
* A date-only last-return record cannot distinguish two events on the same day.
*/
export function prepareDailyReturn(row:ReturnCase,opts:{
mode:ReturnMode;today?:string;intervalDays?:number;consentAttested?:boolean;
mode:ReturnMode;today?:string;intervalDays?:number;consentAttested?:boolean;includeClosed?:boolean;
}):ReturnDecision {
const meta=row.dados||{};
if(isCasoEncerrado(row))return {ready:null,reason:'closed'};
// Movimento posterior confirmado pode ocorrer em uma carteira marcada como encerrada.
// Apenas os avisos de movimentação da carteira optam por incluir esses casos;
// um status interno nunca é prova de encerramento definitivo no tribunal.
if(isCasoEncerrado(row) && !opts.includeClosed)return {ready:null,reason:'closed'};
Comment on lines +70 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow verified terminal events for already-closed cases

When an already-closed case has a fresh official terminal event, this new bypass lets it reach the source checks, but the unchanged guards at lines 103-104 and 115-117 still return needs_source_review for terminal DataJud descriptions and verified DJEN evidence. Those guards previously only encountered open cases—the DJEN comment explicitly says they prevent announcing closure while the portfolio remains open—so the newly supported closed case is never queued for precisely the extinction, final dismissal, or archival movement that commonly accompanies closure. Apply the terminal safeguard conditionally when the portfolio is still open, or otherwise handle verified terminal evidence for closed rows.

Useful? React with 👍 / 👎.

const hasOptIn=['whatsapp_opt_in','consentimento_whatsapp','whatsapp_autorizado'].some(k=>affirmative(meta[k]));
if(['nao_contatar','não_contatar','whatsapp_opt_out','optOut','optout','bloquear_whatsapp','naoEnviarWhatsapp'].some(k=>blockedValue(meta[k])) || ['whatsapp_opt_in','consentimento_whatsapp','whatsapp_autorizado'].some(k=>denied(meta[k])))
return {ready:null,reason:'blocked'};
Expand Down
4 changes: 2 additions & 2 deletions src/lib/wa-movement-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ describe('Avisos processuais WA.Auto',()=>{
expect(prepareMovementAlert({...base,dados:{whatsapp_opt_in:false}}).reason).toBe('blocked');
expect(prepareMovementAlert({...base,dados:{consentimento_whatsapp:'não'}}).reason).toBe('blocked');
});
it('excludes closed and already-reported events',()=>{
expect(prepareMovementAlert({...base,status:'ENCERRADO'}).reason).toBe('closed');
it('includes closed cases with verified updates, but excludes already-reported events',()=>{
expect(prepareMovementAlert({...base,status:'ENCERRADO'}).reason).toBe('ok');
expect(prepareMovementAlert({...base,ultimo_retorno:'2026-10-05'}).reason).toBe('no_new_movement');
expect(prepareMovementAlert({...base,ultimo_retorno:null}).reason).toBe('missing_return');
});
Expand Down
2 changes: 1 addition & 1 deletion src/lib/wa-movement-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type Alert = {

/** Both scanner and portfolio queues use the same last-return cutoff. */
export function prepareMovementAlert(row: SourceRow, consentAttested = false) {
const result = prepareDailyReturn(row, {mode:'single', consentAttested});
const result = prepareDailyReturn(row, {mode:'single', consentAttested, includeClosed:true});
const notice = result.ready;
if (!notice) return {alert:null, reason:result.reason};
return {reason:'ok' as const, alert:{
Expand Down
10 changes: 7 additions & 3 deletions src/lib/wa-movement-campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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';
import {brazilToday} from '@/lib/wa-daily-return-policy';
import {isCasoEncerrado} from '@/lib/status-encerrado';


async function requireManager() {
Expand All @@ -28,7 +29,7 @@ async function collectPortfolio(empresaId: string, kind:CampaignKind='movement',
const db = await getSupabaseAdmin();
const entries: Alert[] = [];
const counts = { scanned:0, withoutPhone:0, withoutEvent:0, blocked:0, samePhone:0,
alreadyClosed:0, consentMissing:0, needsReview:0, alreadyNotified:0, missingReturn:0, noNewMovement:0 };
alreadyClosed:0, closedEligible: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:3000});
Expand All @@ -46,7 +47,10 @@ async function collectPortfolio(empresaId: string, kind:CampaignKind='movement',
else if(reason==='needs_source_review')counts.needsReview++;
else if(reason==='no_new_movement')counts.noNewMovement++;
else counts.withoutEvent++;
} else entries.push(alert);
} else {
entries.push(alert);
if(isCasoEncerrado(row))counts.closedEligible++;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count closed eligibility after removing prior notices

Incrementing closedEligible here counts every otherwise valid closed alert before wa_notice_prior removes already queued or sent events. For example, a portfolio containing one previously sent closed-case event produces eligible: 0 but closedEligible: 1, while the UI describes that case as eligible and included in the selection. Derive this count from pending, or preserve closed status on alerts so the preview reports only notices that can actually be queued.

Useful? React with 👍 / 👎.

}
}
if(rows.length<3000)break;
after=Number(rows[rows.length-1].id);
Expand Down Expand Up @@ -99,7 +103,7 @@ export async function createMovementCampaign(confirmed: boolean,kind:CampaignKin
const {entries}=await collectPortfolio(ctx.empresa_id,kind,confirmed||settings.consentAttested);
if (!entries.length) throw new Error(
kind === 'publication'
? 'Nenhum aviso elegível: confira telefone, data do último retorno e novidade em processos abertos.'
? 'Nenhum aviso elegível: confira telefone, último retorno e nova publicação verificada na carteira.'
: 'Nenhum processo com telefone válido e movimentação identificada.'
);
const {data: campaign,error: createErr} = await db.from('wa_movement_campaigns')
Expand Down
Loading