From 27fff4826361f844f223486e37094fa027efbc7c Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Fri, 28 Aug 2026 16:49:25 -0400 Subject: [PATCH 1/2] fix(outreach): apply 7-day cooldown to dedupGuard in batch dispatch --- package.json | 2 +- src/model/outreach/outreach-controller.ts | 10 ++++++- .../unit/outreach/outreach-controller.spec.ts | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c3b51a06..93ce086e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web-jam-back", - "version": "2.11.13", + "version": "2.11.14", "description": "web-jam.com", "type": "module", "main": "build/src/index.js", diff --git a/src/model/outreach/outreach-controller.ts b/src/model/outreach/outreach-controller.ts index 9e98ee3e..da6b6631 100644 --- a/src/model/outreach/outreach-controller.ts +++ b/src/model/outreach/outreach-controller.ts @@ -794,7 +794,15 @@ class OutreachController extends Controller { // targetWeekend can never match (both clauses require the field to exist) — // legacy records don't block. Returns the error envelope to relay, or null. async dedupGuard(venueId: string | undefined, tw: TargetWeekend | null): Promise { - const query: Record = { venueId, status: { $in: ACTIVE_STATUSES } }; + const cooldownThreshold = new Date(Date.now() - OUTREACH_COOLDOWN_DAYS * 24 * 60 * 60 * 1000); + const query: Record = { + venueId, + status: { $in: ACTIVE_STATUSES }, + $or: [ + { sentAt: { $gte: cooldownThreshold } }, + { created_at: { $gte: cooldownThreshold } }, + ], + }; if (tw) Object.assign(query, targetWeekendOverlapClause(tw)); let dupe; try { diff --git a/test/unit/outreach/outreach-controller.spec.ts b/test/unit/outreach/outreach-controller.spec.ts index 06235855..45827aa6 100644 --- a/test/unit/outreach/outreach-controller.spec.ts +++ b/test/unit/outreach/outreach-controller.spec.ts @@ -2417,4 +2417,30 @@ describe('Outreach Controller (#844 batch model)', () => { expect(sendArgs.html).not.toContain('perfect fit'); }); }); + + describe('dedupGuard 7-day cooldown (#1046)', () => { + it('allows re-pitching when an active outreach record for the weekend was sent > 7 days ago', async () => { + // Mock findOne to return null (because the query with cooldown threshold filters out old records) + c.model.findOne = vi.fn((q: any) => { + if (q.$or) return Promise.resolve(null); + return Promise.resolve({ _id: oid(), status: 'sent', sentAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) }); + }); + + const dupeErr = await c.dedupGuard(oid(), VALID_WEEKEND); + expect(dupeErr).toBeNull(); + expect(c.model.findOne).toHaveBeenCalled(); + const query = (c.model.findOne as any).mock.calls[0][0]; + expect(query.$or).toBeDefined(); + }); + + it('blocks re-pitching when an active outreach record was sent <= 7 days ago', async () => { + const recentDoc = { _id: oid(), status: 'sent', sentAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000) }; + c.model.findOne = vi.fn(() => Promise.resolve(recentDoc)); + + const dupeErr = await c.dedupGuard(oid(), VALID_WEEKEND); + expect(dupeErr).not.toBeNull(); + expect(dupeErr?.status).toBe(409); + expect(dupeErr?.message).toContain('active outreach already exists'); + }); + }); }); From d5f672dc980d8fd0831ad4ca3747f9ae802ecda5 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Fri, 28 Aug 2026 17:00:13 -0400 Subject: [PATCH 2/2] docs(outreach): document 7-day cooldown behavior in dedupGuard doc comment --- src/model/outreach/outreach-controller.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/model/outreach/outreach-controller.ts b/src/model/outreach/outreach-controller.ts index da6b6631..b787c905 100644 --- a/src/model/outreach/outreach-controller.ts +++ b/src/model/outreach/outreach-controller.ts @@ -792,7 +792,11 @@ class OutreachController extends Controller { // require + validate targetWeekend before calling resolvePitch, so `tw` is // always present here when skipDedup is false. A legacy record with no // targetWeekend can never match (both clauses require the field to exist) — - // legacy records don't block. Returns the error envelope to relay, or null. + // legacy records don't block. + // + // #1050 / #1046: Only active records sent or created within OUTREACH_COOLDOWN_DAYS + // (7 days) count as blocking; older campaigns allow re-pitching for the weekend. + // Returns the error envelope to relay, or null. async dedupGuard(venueId: string | undefined, tw: TargetWeekend | null): Promise { const cooldownThreshold = new Date(Date.now() - OUTREACH_COOLDOWN_DAYS * 24 * 60 * 60 * 1000); const query: Record = {