From 9ca2ccf1c7e681cd5e07c5b9fea30b0c65d88f23 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 22:25:04 -0400 Subject: [PATCH] fix(18-04): short-circuit own-campaign re-match to stop report_count double-increment - OwnReportRow now selects campaign_id::text; match branch no-ops (created: false, no UPDATE) when the tiered match resolves to the report's own current campaign_id via a sibling row - Closes CAMP-02 gap / CR-01: /analyze can be re-run indefinitely without inflating campaigns.report_count --- lib/services/campaign-grouping-service.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/services/campaign-grouping-service.ts b/lib/services/campaign-grouping-service.ts index 39ba1df..ee3a2d1 100644 --- a/lib/services/campaign-grouping-service.ts +++ b/lib/services/campaign-grouping-service.ts @@ -66,6 +66,7 @@ interface OwnReportRow { requester_contact_id: number | null; company_id: number | null; created_at: string; + campaign_id: string | null; } interface OwnMessageRow { @@ -154,7 +155,7 @@ export async function groupReportIntoCampaign( return await postgresClient.transaction(async (client) => { const ownReportRes = await client.query( - `SELECT title, requester_contact_id, company_id, created_at + `SELECT title, requester_contact_id, company_id, created_at, campaign_id::text AS campaign_id FROM reports WHERE id = $1`, [reportId] @@ -321,6 +322,16 @@ export async function groupReportIntoCampaign( // Find-or-create against `campaigns` (CAMP-02). // --------------------------------------------------------------------- if (matchCampaignId && matchGroupMethod) { + // CR-01/CAMP-02 no-op guard: tier queries only exclude the report's + // OWN row (`r.id != $x`), not its siblings already in the same + // campaign. Since `/analyze` re-runs grouping unconditionally + // (D-08), a report already linked to campaign X can re-match X via + // a sibling row on every repeat call, inflating `report_count` + // without bound. If the match resolves to the report's own current + // campaign, it's already correctly linked — skip both UPDATEs. + if (matchCampaignId === ownReport.campaign_id) { + return { campaignId: matchCampaignId, groupMethod: matchGroupMethod, created: false }; + } await client.query( `UPDATE campaigns SET report_count = report_count + 1, last_seen_at = NOW(), updated_at = NOW()