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()