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
This commit is contained in:
lorentz 2026-07-15 22:25:04 -04:00
parent afd7af70c8
commit 9ca2ccf1c7

View file

@ -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<OwnReportRow>(
`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()