chore: merge executor worktree (worktree-agent-a153b3afb38c39100)
This commit is contained in:
commit
c3ad292139
3 changed files with 408 additions and 14 deletions
|
|
@ -0,0 +1,120 @@
|
|||
---
|
||||
phase: 18-campaign-grouping-phishing-analysis-api
|
||||
plan: 05
|
||||
subsystem: api
|
||||
tags: [postgres, vitest, campaign-grouping, phishing, idempotency, gap-closure]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 18-01
|
||||
provides: groupReportIntoCampaign tiered matching (CAMP-01/CAMP-02) and campaigns schema (migration 097)
|
||||
- phase: 18-04
|
||||
provides: reports.campaign_id on OwnReportRow and the same-campaign no-op guard this plan extends
|
||||
provides:
|
||||
- "CR-03 fix: own-campaign revalidation guard in groupReportIntoCampaign — re-analyzing a single-report campaign reuses the report's own existing campaign instead of creating a duplicate campaigns row with the same campaign_key"
|
||||
- "CR-02 fix: decrementOriginCampaign() helper, called on cross-campaign migration AND on the signal-diverged create-new fall-through, so an abandoned origin campaign's report_count is never left stale"
|
||||
- "Mocked regression tests (Tests A-H) reproducing both CR-03 and CR-02 exactly, with RED confirmed for Tests A, B, E, H against pre-fix code"
|
||||
affects: [19-classification, 20-remediation-approval-audit]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Own-signal tier keys (tier1Key/tier2Key/tier3Key/currentKeys) computed once, shared between the own-campaign revalidation guard and the create-new fall-through"
|
||||
- "Shared decrementOriginCampaign(client, campaignId) helper called from both abandonment sites (sibling-migration branch and signal-diverged create-new) to avoid duplicating the UPDATE statement"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- lib/services/campaign-grouping-service.ts
|
||||
- lib/services/campaign-grouping-service.test.ts
|
||||
|
||||
key-decisions:
|
||||
- "Own-campaign guard runs ONLY when matchCampaignId is null (no sibling matched) AND ownReport.campaign_id is non-null — placed after the sibling-match branch, before create-new, so it never suppresses a legitimate D-08 sibling upgrade"
|
||||
- "CR-02 decrement is scope-limited to decrement-only — never deletes an origin campaign even if report_count reaches 0 (FK-cascade/detail-route risk deferred to a later phase per plan's explicit scope note)"
|
||||
- "CR-02 decrement applied in BOTH abandonment sites (existing sibling-migration branch AND Task 1's signal-diverged create-new fall-through) per plan-checker guidance — not just the migration branch"
|
||||
|
||||
requirements-completed: [CAMP-01, CAMP-02]
|
||||
|
||||
# Metrics
|
||||
duration: n/a (partial — Task 3 checkpoint pending)
|
||||
completed: null
|
||||
---
|
||||
|
||||
# Phase 18 Plan 05: Campaign-grouping gap closure (CR-03 + CR-02) Summary — PARTIAL (Tasks 1-2 complete, Task 3 checkpoint pending)
|
||||
|
||||
**Own-campaign revalidation guard closes CR-03 (duplicate campaigns on single-report re-analyze); shared decrementOriginCampaign() helper closes CR-02 (stale report_count on migration and signal-diverged create-new) — both proven by 8 new mocked vitest regression tests with confirmed RED on Tests A, B, E, H.**
|
||||
|
||||
**STATUS: Tasks 1 and 2 are complete and committed. Task 3 (BLOCKING human-verify checkpoint — live database verification) has NOT yet run. This summary will be superseded/finalized once Task 3 resolves.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Tasks completed:** 2 of 3 (Task 3 is the blocking checkpoint, pending)
|
||||
- **Files modified:** 2 (`lib/services/campaign-grouping-service.ts`, `lib/services/campaign-grouping-service.test.ts`)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **CR-03 closed (code + mocked tests):** re-analyzing a single-report campaign (no sibling report exists yet) now reuses the report's own existing `campaign_id` with `created:false` — zero `INSERT`/`UPDATE` queries issued. Root cause was every tier query's required self-exclusion (`r.id != $x`) meaning a lone report could never match itself, so `matchCampaignId` stayed null and control fell straight into "create new campaign" on every repeat `/analyze` call.
|
||||
- **CR-02 closed (code + mocked tests):** cross-campaign migration (report moves from campaign A to different existing campaign B) now decrements A's `report_count` in addition to incrementing B's. The identical decrement is also applied when the signal-diverged create-new fall-through abandons an origin campaign in favor of a brand-new one (the plan-checker-flagged second abandonment site).
|
||||
- **D-08 preserved:** a report with a genuinely better sibling match still upgrades to that sibling's campaign; the own-campaign guard only runs when no sibling matched, and the CR-02 decrement correctly does not fire when there is no origin campaign (never-grouped report).
|
||||
- **No regression:** first-time grouping of a genuinely new report (`campaign_id = null`) still creates exactly one campaign; the existing 18-04 same-campaign no-op test (renamed here to "Test G") still fires zero mutations.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Own-campaign revalidation guard before create-new (CR-03)** — `633b48c` (fix)
|
||||
2. **Task 2: Decrement origin campaign report_count on migration AND signal-diverged create-new (CR-02)** — `6945591` (fix)
|
||||
3. **Task 3: Live database verification of no-duplicate-on-reanalyze** — NOT YET RUN (blocking human-verify checkpoint)
|
||||
|
||||
_Both Task 1 and Task 2 followed TDD RED-before-GREEN discipline: RED confirmed via `npx vitest run` for Tests A/B (Task 1) and Tests E/H (Task 2) against the pre-fix code, then GREEN confirmed after implementing the fix._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `lib/services/campaign-grouping-service.ts` — added `decrementOriginCampaign()` helper; hoisted `tier1Key`/`tier2Key`/`tier3Key`/`currentKeys` computation above the find-or-create block; added the CR-03 own-campaign revalidation guard between the sibling-match branch and the create-new fall-through; added the CR-02 decrement call in both the sibling-migration branch and the signal-diverged create-new fall-through
|
||||
- `lib/services/campaign-grouping-service.test.ts` — added `ownCampaign` to `MockRows` + a new SQL-substring router branch (`SELECT campaign_key`); added Tests A-D (CR-03) and Tests E-H (CR-02, with the pre-existing 18-04 same-campaign no-op test relabeled as "Test G")
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Own-campaign guard placed **outside** (after) the sibling-match branch, not merged into it — the plan required this to guarantee it never suppresses a legitimate D-08 sibling upgrade, since the guard must only run when `matchCampaignId` is null.
|
||||
- Factored the CR-02 decrement into a single shared `decrementOriginCampaign(client, campaignId)` helper rather than duplicating the raw `UPDATE campaigns SET report_count = report_count - 1 ...` statement at both call sites — reduces duplication while keeping each call site's guard condition (`if (ownReport.campaign_id)`) explicit and readable at the call site.
|
||||
- Test G (the pre-existing 18-04 regression test) was renamed/annotated in place rather than duplicated, since its assertions already fully satisfy the plan's Test G requirement (same-campaign re-match remains a zero-mutation no-op).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 1 - Bug, self-caught during GREEN] Missing null-guard on the CR-02 decrement in the migration branch**
|
||||
|
||||
- **Found during:** Task 2, first GREEN attempt
|
||||
- **Issue:** The initial implementation called `decrementOriginCampaign(client, ownReport.campaign_id)` unconditionally after the increment/re-link in the sibling-migration branch, without checking `ownReport.campaign_id` was non-null first. This broke the existing "Tier 3 match increments..." regression test and the new Test F (D-08 upgrade with no prior campaign) — both scenarios have `ownReport.campaign_id` as `undefined`/`null` (a never-grouped report), and the unconditional call issued a spurious second `UPDATE campaigns` with `campaignId = undefined` as the bound parameter.
|
||||
- **Fix:** Wrapped the call in `if (ownReport.campaign_id) { await decrementOriginCampaign(...); }`, matching the plan's explicit requirement that the decrement only fires "when there is no origin (campaign_id=null) or when the match resolves to the report's own campaign" → no decrement.
|
||||
- **Files modified:** `lib/services/campaign-grouping-service.ts`
|
||||
- **Verification:** Re-ran `npx vitest run lib/services/campaign-grouping-service.test.ts` — all 24 tests passed (previously 2 failing: the pre-existing Tier-3-match test and new Test F).
|
||||
- **Committed in:** `6945591` (part of Task 2 commit — caught and fixed before the commit was made, not a separate follow-up commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (Rule 1 — bug caught during the plan's own TDD GREEN verification step, not a deviation from the plan's design)
|
||||
**Impact on plan:** No scope creep — this was a bug in my own first-draft implementation of the plan's specified behavior, caught by the plan's own Test F before committing. Zero impact on the plan's intended design.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None beyond the self-caught deviation above. `npx tsc --noEmit --pretty` exits 0 after both tasks; `npx vitest run lib/services/campaign-grouping-service.test.ts` is green with all 24 tests (16 pre-existing + Tests A-H new).
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — no external service configuration required. Task 3 requires a human to run a live verification against a real Postgres database (dev instance or a cleaned-up production throwaway ticket); this is not "setup" but the plan's mandatory human-verification checkpoint, detailed in `18-05-PLAN.md` Task 3's `<how-to-verify>` block.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**NOT READY — Task 3 (blocking human-verify checkpoint) has not run.** Per the plan's `coverage_caveat`: mocked-only unit coverage was proven insufficient last time (the 18-04 mocked regression test passed while CR-03 shipped and reproduced live in production), so this gap-closure plan is explicitly not considered closed until a human confirms, against a real database:
|
||||
|
||||
1. A second consecutive `/analyze` on the same single-report ticket returns the same `campaignId` with `created:false` (not a new campaign, not `created:true`).
|
||||
2. A direct DB query shows exactly one `campaigns` row for that `campaign_key` with an unchanged `report_count`.
|
||||
3. Any test data created during verification is cleaned up.
|
||||
|
||||
This worktree's code (commits `633b48c` and `6945591`) is ready for that live verification. No further code changes are anticipated unless the live verification surfaces a divergence from the mocked tests' assumptions.
|
||||
|
||||
---
|
||||
*Phase: 18-campaign-grouping-phishing-analysis-api*
|
||||
*Plan: 05*
|
||||
*Status: PARTIAL — Tasks 1-2 complete, Task 3 checkpoint pending human live-verification*
|
||||
|
|
@ -70,6 +70,8 @@ interface MockRows {
|
|||
tier2Candidates?: unknown[];
|
||||
candidateIndicators?: unknown[];
|
||||
tier3?: unknown[];
|
||||
/** CR-03: rows for the own-campaign revalidation SELECT (campaigns WHERE id = ownReport.campaign_id). */
|
||||
ownCampaign?: unknown[];
|
||||
insertCampaign?: unknown[];
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +104,9 @@ function makeClient(rows: MockRows) {
|
|||
if (sql.includes('BETWEEN $4::timestamptz')) {
|
||||
return { rows: rows.tier3 ?? [], rowCount: rows.tier3?.length ?? 0 };
|
||||
}
|
||||
if (sql.includes('SELECT campaign_key')) {
|
||||
return { rows: rows.ownCampaign ?? [], rowCount: rows.ownCampaign?.length ?? 0 };
|
||||
}
|
||||
if (sql.includes('UPDATE campaigns')) {
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
|
|
@ -334,7 +339,7 @@ describe('groupReportIntoCampaign', () => {
|
|||
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('re-analyzing a report already linked to a campaign that a sibling report re-matches does not increment report_count a second time', async () => {
|
||||
it('Test G (CR-02 guard against over-firing): same-campaign re-match remains a pure no-op — the origin decrement must NOT fire when the match resolves back to the report\'s own campaign', async () => {
|
||||
// Own report is already linked to campaign-1. A SIBLING report (not this
|
||||
// report's own excluded row) also belongs to campaign-1 and matches via
|
||||
// Tier 3 (sender + subject + client). This is the CAMP-02/CR-01 gap:
|
||||
|
|
@ -360,4 +365,194 @@ describe('groupReportIntoCampaign', () => {
|
|||
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0);
|
||||
expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// CR-03 (gap closure, 18-05 Task 1): own-campaign revalidation guard.
|
||||
//
|
||||
// Root cause: every tier query above self-excludes the report's OWN row
|
||||
// (`r.id != $x`). For a single-report campaign (no sibling report exists
|
||||
// yet), all three tiers return zero rows on re-analyze, matchCampaignId
|
||||
// stays null, and — pre-fix — control fell straight into "create new
|
||||
// campaign", producing a SECOND campaigns row with the identical
|
||||
// campaign_key. Tests A/B below reproduce that exact scenario and FAILED
|
||||
// against the pre-fix code (confirmed during RED).
|
||||
// ===========================================================================
|
||||
|
||||
it('Test A (CR-03 core): single-report re-analyze via Tier-1 stored key reuses the report\'s own existing campaign with zero mutating queries', async () => {
|
||||
stage({
|
||||
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }],
|
||||
ownMessage: [{ id: 'own-msg-id', message_id: 'lone-message-id' }],
|
||||
tier1: [], // no sibling shares this message_id — single-report campaign
|
||||
ownIndicators: [],
|
||||
tier2Candidates: [],
|
||||
tier3: [],
|
||||
ownCampaign: [{ campaign_key: 'message_id:lone-message-id', group_method: 'message_id' }],
|
||||
});
|
||||
|
||||
const result = await groupReportIntoCampaign('report-own-a');
|
||||
|
||||
expect(result).toEqual({
|
||||
campaignId: 'campaign-own',
|
||||
groupMethod: 'message_id',
|
||||
created: false,
|
||||
});
|
||||
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0);
|
||||
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
|
||||
expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Test B (CR-03 Tier-3-only variant): single-report re-analyze via Tier-3 stored key reuses the report\'s own existing campaign with zero mutating queries', async () => {
|
||||
stage({
|
||||
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }],
|
||||
ownMessage: [],
|
||||
tier3: [],
|
||||
ownCampaign: [
|
||||
{ campaign_key: 'sender_subject_client:5:invoice alert:10', group_method: 'sender_subject_client' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await groupReportIntoCampaign('report-own-b');
|
||||
|
||||
expect(result).toEqual({
|
||||
campaignId: 'campaign-own',
|
||||
groupMethod: 'sender_subject_client',
|
||||
created: false,
|
||||
});
|
||||
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0);
|
||||
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
|
||||
expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Test C (no regression): campaign_id=null still creates exactly one campaign and never runs the own-campaign guard', async () => {
|
||||
stage({
|
||||
ownReport: [REPORT_ROW], // no campaign_id field => null/undefined, never grouped
|
||||
ownMessage: [],
|
||||
tier3: [],
|
||||
insertCampaign: [{ id: 'brand-new-campaign' }],
|
||||
});
|
||||
|
||||
const result = await groupReportIntoCampaign('report-own-c');
|
||||
|
||||
expect(result).toEqual({
|
||||
campaignId: 'brand-new-campaign',
|
||||
groupMethod: 'sender_subject_client',
|
||||
created: true,
|
||||
});
|
||||
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1);
|
||||
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
|
||||
// The own-campaign guard must not fire when there is no own campaign_id.
|
||||
expect(callsContaining('SELECT campaign_key')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Test D (signal genuinely diverged): stored own-campaign key no longer matches current signal, falls through to create-new', async () => {
|
||||
stage({
|
||||
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }],
|
||||
ownMessage: [],
|
||||
tier3: [],
|
||||
ownCampaign: [
|
||||
{ campaign_key: 'sender_subject_client:5:old subject:10', group_method: 'sender_subject_client' },
|
||||
],
|
||||
insertCampaign: [{ id: 'diverged-new-campaign' }],
|
||||
});
|
||||
|
||||
const result = await groupReportIntoCampaign('report-own-d');
|
||||
|
||||
expect(result).toEqual({
|
||||
campaignId: 'diverged-new-campaign',
|
||||
groupMethod: 'sender_subject_client',
|
||||
created: true,
|
||||
});
|
||||
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// CR-02 (gap closure, 18-05 Task 2): decrement the origin campaign's
|
||||
// report_count on cross-campaign migration, AND on the signal-diverged
|
||||
// create-new fall-through added by Task 1 (CR-03) above — both are the
|
||||
// same "abandoning an origin campaign" situation, just landing on an
|
||||
// existing sibling's campaign vs. a brand-new one. Tests E and H below
|
||||
// FAILED against the pre-Task-2 code (confirmed during RED).
|
||||
// ===========================================================================
|
||||
|
||||
it('Test E (CR-02 migration decrements origin): cross-campaign migration decrements the origin and increments the destination exactly once each', async () => {
|
||||
stage({
|
||||
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-A' }],
|
||||
ownMessage: [],
|
||||
tier3: [{ campaign_id: 'campaign-B', title: 'Invoice Alert' }],
|
||||
});
|
||||
|
||||
const result = await groupReportIntoCampaign('report-e');
|
||||
|
||||
expect(result).toEqual({
|
||||
campaignId: 'campaign-B',
|
||||
groupMethod: 'sender_subject_client',
|
||||
created: false,
|
||||
});
|
||||
|
||||
const updateCampaignCalls = callsContaining('UPDATE campaigns');
|
||||
expect(updateCampaignCalls).toHaveLength(2);
|
||||
const incrementCall = updateCampaignCalls.find((c) => c.sql.includes('report_count + 1'));
|
||||
const decrementCall = updateCampaignCalls.find((c) => c.sql.includes('report_count - 1'));
|
||||
expect(incrementCall?.params).toEqual(['campaign-B']);
|
||||
expect(decrementCall?.params).toEqual(['campaign-A']);
|
||||
|
||||
const updateReportCalls = callsContaining('UPDATE reports SET campaign_id');
|
||||
expect(updateReportCalls).toHaveLength(1);
|
||||
expect(updateReportCalls[0].params).toEqual(['campaign-B', 'report-e']);
|
||||
});
|
||||
|
||||
it('Test F (D-08 upgrade preserved, no origin to decrement): a never-grouped report upgrading to a sibling campaign increments the destination only', async () => {
|
||||
stage({
|
||||
ownReport: [REPORT_ROW], // campaign_id null/undefined — never grouped before
|
||||
ownMessage: [{ id: 'own-msg-id', message_id: 'shared-message-id-upgrade' }],
|
||||
tier1: [{ campaign_id: 'campaign-B' }],
|
||||
});
|
||||
|
||||
const result = await groupReportIntoCampaign('report-f');
|
||||
|
||||
expect(result).toEqual({
|
||||
campaignId: 'campaign-B',
|
||||
groupMethod: 'message_id',
|
||||
created: false,
|
||||
});
|
||||
|
||||
const updateCampaignCalls = callsContaining('UPDATE campaigns');
|
||||
expect(updateCampaignCalls).toHaveLength(1);
|
||||
expect(updateCampaignCalls[0].sql).toContain('report_count + 1');
|
||||
expect(updateCampaignCalls[0].params).toEqual(['campaign-B']);
|
||||
|
||||
const updateReportCalls = callsContaining('UPDATE reports SET campaign_id');
|
||||
expect(updateReportCalls).toHaveLength(1);
|
||||
expect(updateReportCalls[0].params).toEqual(['campaign-B', 'report-f']);
|
||||
});
|
||||
|
||||
it('Test H (plan-checker-flagged divergence case): signal-diverged create-new also decrements the abandoned origin campaign exactly once', async () => {
|
||||
stage({
|
||||
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-A' }],
|
||||
ownMessage: [],
|
||||
tier3: [],
|
||||
ownCampaign: [
|
||||
{ campaign_key: 'sender_subject_client:5:old subject:10', group_method: 'sender_subject_client' },
|
||||
],
|
||||
insertCampaign: [{ id: 'diverged-new-campaign-h' }],
|
||||
});
|
||||
|
||||
const result = await groupReportIntoCampaign('report-h');
|
||||
|
||||
expect(result).toEqual({
|
||||
campaignId: 'diverged-new-campaign-h',
|
||||
groupMethod: 'sender_subject_client',
|
||||
created: true,
|
||||
});
|
||||
|
||||
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1);
|
||||
const updateCampaignCalls = callsContaining('UPDATE campaigns');
|
||||
expect(updateCampaignCalls).toHaveLength(1);
|
||||
expect(updateCampaignCalls[0].sql).toContain('report_count - 1');
|
||||
expect(updateCampaignCalls[0].params).toEqual(['campaign-A']);
|
||||
|
||||
const updateReportCalls = callsContaining('UPDATE reports SET campaign_id');
|
||||
expect(updateReportCalls).toHaveLength(1);
|
||||
expect(updateReportCalls[0].params).toEqual(['diverged-new-campaign-h', 'report-h']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
* Tier 3 (sender + normalized subject + client + 24h window).
|
||||
*/
|
||||
|
||||
import type { PoolClient } from 'pg';
|
||||
import { postgresClient } from './postgres-client';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -118,6 +119,24 @@ function computeTier3Key(
|
|||
return `sender_subject_client:${requesterContactId}:${normalizedSubject}:${companyId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CR-02: decrements a now-abandoned origin campaign's report_count. Shared
|
||||
* by both places a report can be abandoned from its origin campaign — the
|
||||
* sibling-migration branch (moving to a DIFFERENT existing campaign) and the
|
||||
* signal-diverged create-new fall-through (moving to a brand-new campaign) —
|
||||
* so neither is left with a stale, inflated count. Decrement only; never
|
||||
* deletes the origin row even if its count reaches 0 (out of scope — see
|
||||
* CR-02 scope note at the call sites).
|
||||
*/
|
||||
async function decrementOriginCampaign(client: PoolClient, originCampaignId: string): Promise<void> {
|
||||
await client.query(
|
||||
`UPDATE campaigns
|
||||
SET report_count = report_count - 1, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[originCampaignId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared entry point called by the webhook path, the cron sweep, and the
|
||||
* on-demand `/analyze` route. Looks up the report's own signal (message,
|
||||
|
|
@ -318,6 +337,27 @@ export async function groupReportIntoCampaign(
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Own-signal tier keys, computed once here and reused by both the
|
||||
// own-campaign revalidation guard (CR-03, below) and the create-new
|
||||
// fall-through further down — avoids computing them twice.
|
||||
// ---------------------------------------------------------------------
|
||||
const tier1Key = computeTier1Key(ownMessage?.message_id ?? null);
|
||||
const tier2Key = computeTier2Key(
|
||||
ownAttachmentHashes,
|
||||
ownUrlDomains,
|
||||
normalizedSubject,
|
||||
ownSenderValue
|
||||
);
|
||||
const tier3Key = computeTier3Key(
|
||||
ownReport.requester_contact_id,
|
||||
normalizedSubject,
|
||||
ownReport.company_id
|
||||
);
|
||||
const currentKeys = [tier1Key, tier2Key, tier3Key, `report:${reportId}`].filter(
|
||||
(k): k is string => k !== null
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Find-or-create against `campaigns` (CAMP-02).
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
@ -342,26 +382,65 @@ export async function groupReportIntoCampaign(
|
|||
`UPDATE reports SET campaign_id = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[matchCampaignId, reportId]
|
||||
);
|
||||
// CR-02: if the report had a DIFFERENT prior campaign (the
|
||||
// same-campaign case returned above; a never-grouped report has no
|
||||
// origin — D-08), decrement it so it doesn't keep a permanently
|
||||
// stale report_count for a report it no longer holds. Scope:
|
||||
// decrement only, never delete the origin row even if its count
|
||||
// reaches 0 (deleting introduces FK-cascade/detail-route risk out of
|
||||
// scope for this gap fix; an empty campaign is a strictly lesser
|
||||
// problem than a stale count).
|
||||
if (ownReport.campaign_id) {
|
||||
await decrementOriginCampaign(client, ownReport.campaign_id);
|
||||
}
|
||||
return { campaignId: matchCampaignId, groupMethod: matchGroupMethod, created: false };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// CR-03: no sibling matched on any tier (matchCampaignId is null).
|
||||
// Every tier query above self-excludes the report's own row
|
||||
// (`r.id != $x`), so a single-report campaign (no sibling report
|
||||
// exists yet — the common case for any brand-new phishing lure before
|
||||
// a second person reports it) always reaches here with matchCampaignId
|
||||
// still null. Pre-fix, control fell straight into "create new
|
||||
// campaign" below, abandoning the report's still-valid campaign and
|
||||
// producing a SECOND campaigns row with the identical campaign_key on
|
||||
// every repeat /analyze call. This guard sits OUTSIDE the sibling-
|
||||
// match branch above precisely because it must only run when NO
|
||||
// sibling matched — it never suppresses a legitimate D-08 sibling
|
||||
// upgrade, which returns from the branch above instead.
|
||||
// ---------------------------------------------------------------------
|
||||
if (!matchCampaignId && ownReport.campaign_id) {
|
||||
const ownCampaignRes = await client.query<{ campaign_key: string; group_method: string }>(
|
||||
`SELECT campaign_key, group_method
|
||||
FROM campaigns
|
||||
WHERE id = $1`,
|
||||
[ownReport.campaign_id]
|
||||
);
|
||||
const ownCampaign = ownCampaignRes.rows[0];
|
||||
if (ownCampaign && currentKeys.includes(ownCampaign.campaign_key)) {
|
||||
// The report's own campaign still satisfies its freshly-recomputed
|
||||
// current tier key — it's still validly linked, reuse it as-is.
|
||||
return {
|
||||
campaignId: ownReport.campaign_id,
|
||||
groupMethod: ownCampaign.group_method as GroupReportResult['groupMethod'],
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
// CR-02: own campaign row is gone, or the report's signal has
|
||||
// genuinely diverged from its stored key — this report is about to
|
||||
// be abandoned from its origin campaign in favor of a brand-new one
|
||||
// below (the same class of abandonment as the sibling-migration
|
||||
// branch above, just landing on a new row instead of an existing
|
||||
// sibling's). Decrement now so the origin doesn't show a stale
|
||||
// count. Falls through to create-new unchanged.
|
||||
await decrementOriginCampaign(client, ownReport.campaign_id);
|
||||
}
|
||||
|
||||
// No match on any tier — create a new campaign. Prefer the strongest
|
||||
// available key (Tier 1 > Tier 2 > Tier 3) so future duplicates of THIS
|
||||
// report can match it; do NOT set/transition campaigns.status (leave
|
||||
// migration default 'open' — Phase 19/20's concern).
|
||||
const tier1Key = computeTier1Key(ownMessage?.message_id ?? null);
|
||||
const tier2Key = computeTier2Key(
|
||||
ownAttachmentHashes,
|
||||
ownUrlDomains,
|
||||
normalizedSubject,
|
||||
ownSenderValue
|
||||
);
|
||||
const tier3Key = computeTier3Key(
|
||||
ownReport.requester_contact_id,
|
||||
normalizedSubject,
|
||||
ownReport.company_id
|
||||
);
|
||||
|
||||
let newCampaignKey: string;
|
||||
let newGroupMethod: GroupReportResult['groupMethod'];
|
||||
if (tier1Key) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue