Skip to content
Merged

to main #1012

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web-jam-back",
"version": "2.10.1",
"version": "2.10.2",
"description": "web-jam.com",
"type": "module",
"main": "build/src/index.js",
Expand Down
62 changes: 46 additions & 16 deletions src/model/outreach/outreach-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ import { MAX_STEP, nextTouchDueAfter, touchAt } from './cadence.js';
// InquiryController CC precedent). Maria's address is the same one inquiries CC.
const PITCH_CC = ['joshua.v.sherman@gmail.com', 'chemmariasherman@gmail.com'];

// JaMmusic#1250 — a 29-venue batch silently skipped 6 venues with no
// venueType/templateOverride/templateType because resolvePitch hard-400'd
// instead of sending. Rather than drop a venue on the floor for a missing
// tag (the real gap is web-jam-back#843's venueType backfill), fall back to
// this safest-for-an-unknown-venue template type. Prod carries both a `cold`
// and a `returning` stage for it, so findTemplate always resolves. Kept as
// one exported constant so it's changeable in a single place.
export const DEFAULT_TEMPLATE_TYPE = 'MidRangeCafeBar';

// JaMmusic#1250 — placeholder venueName for a skipped-batch entry where no
// venue doc could even be loaded (invalid id, or venue not found). The
// frontend always renders venueName, so this field is never omitted.
export const UNKNOWN_VENUE_NAME = '(unknown venue)';

// An active campaign blocks a new pitch for the same venue + window (no
// double-pitching). sent / replied are active; every outcome status
// (no-response / interested / not-interested / booked / target-filled) is
Expand All @@ -42,7 +56,11 @@ const OUTREACH_SEND_CAPS = ['outreach:create', 'outreach:approve'];
interface AuthedUser { userType?: string; privileges?: string[] }
type AuthRequest = Request & { user?: string };
type AuthIdRequest = Request<{ id: string }> & { user?: string };
type AuthzError = { status: number; message: string; outreach?: unknown };
// venueName (JaMmusic#1250) — carried alongside every resolvePitch error so
// sendBatch's skipped report can name the venue instead of just its
// ObjectId. Always set by resolvePitch (UNKNOWN_VENUE_NAME when no venue doc
// could be loaded at all), never left undefined for a batch-originated error.
type AuthzError = { status: number; message: string; outreach?: unknown; venueName?: string };
type AuthzResult = AuthzError | null;
interface PitchContext { error?: AuthzError; venue?: VenueDoc; template?: TemplateDoc; type?: string }
interface ResolveOpts { skipDedup?: boolean; requireEligible?: boolean }
Expand Down Expand Up @@ -724,38 +742,43 @@ class OutreachController extends Controller {
const { skipDedup = false, requireEligible = true } = opts;
let venue: VenueDoc | null;
try { venue = await venueModel.findById(body.venueId || '') as unknown as VenueDoc | null; } catch (e) {
return { error: { status: 500, message: (e as Error).message } };
return { error: { status: 500, message: (e as Error).message, venueName: UNKNOWN_VENUE_NAME } };
}
if (!venue) return { error: { status: 400, message: 'venue not found' } };
if (venue.status === 'archived') return { error: { status: 400, message: 'venue is archived' } };
if (!venue) return { error: { status: 400, message: 'venue not found', venueName: UNKNOWN_VENUE_NAME } };
const venueName = venue.name || UNKNOWN_VENUE_NAME;
if (venue.status === 'archived') return { error: { status: 400, message: 'venue is archived', venueName } };
if (requireEligible && !venue.outreachEligible) {
return { error: { status: 400, message: 'venue is not outreach-eligible (not vetted)' } };
return { error: { status: 400, message: 'venue is not outreach-eligible (not vetted)', venueName } };
}
// #974 — sendability precondition, ADDITIONAL to (never a replacement for)
// the outreachEligible vetting gate just above: a venue with no VALID
// primary email is never sendable. Format-checked, not just presence —
// `contactVerified` is gone; a real, present primary email IS the
// verification now.
if (!isValidEmail(venue.email)) return { error: { status: 400, message: 'venue has no valid primary email to pitch' } };
if (!isValidEmail(venue.email)) return { error: { status: 400, message: 'venue has no valid primary email to pitch', venueName } };

// Dedup guard first — refuse a duplicate before doing template work.
if (!skipDedup) {
const dupeErr = await this.dedupGuard(body.venueId, parseTargetWeekend(body.targetWeekend));
if (dupeErr) return { error: dupeErr };
if (dupeErr) return { error: { ...dupeErr, venueName } };
}

// Template type: explicit caller value > per-venue override > venue's type (#848).
const type = (body.templateType || venue.templateOverride || venue.venueType || '').trim();
if (!type) return { error: { status: 400, message: 'no templateType and venue has no venueType' } };
// Template type: explicit caller value > per-venue override > venue's type
// (#848). JaMmusic#1250 — when NONE of those resolve, fall back to
// DEFAULT_TEMPLATE_TYPE rather than hard-400ing and silently skipping the
// venue (the #843 venueType-backfill gap is real, but a missing tag must
// never cost a send). findTemplate below still fails hard if even the
// default template is missing/inactive.
const type = (body.templateType || venue.templateOverride || venue.venueType || '').trim() || DEFAULT_TEMPLATE_TYPE;

let template: TemplateDoc | null;
try {
const stage = await this.resolveStage(venue);
template = await this.findTemplate(type, stage);
} catch (e) {
return { error: { status: 500, message: (e as Error).message } };
return { error: { status: 500, message: (e as Error).message, venueName } };
}
if (!template) return { error: { status: 400, message: `no active template for type ${type}` } };
if (!template) return { error: { status: 400, message: `no active template for type ${type}`, venueName } };

return { venue, template, type };
}
Expand Down Expand Up @@ -848,22 +871,29 @@ class OutreachController extends Controller {
if (sendErr) return res.status(sendErr.status).json({ message: sendErr.message });

const actor = resolveActor(req, body);
const result: { requested: number; sent: number; skipped: { venueId: string; reason: string }[]; records: unknown[] } = {
// venueName (JaMmusic#1250) — carried on every skipped entry, never
// omitted, so the report is readable without cross-referencing Mongo
// ObjectIds by hand.
const result: { requested: number; sent: number; skipped: { venueId: string; venueName: string; reason: string }[]; records: unknown[] } = {
requested: body.venueIds.length, sent: 0, skipped: [], records: [],
};
for (const venueId of body.venueIds) {
if (!mongoose.Types.ObjectId.isValid(venueId)) { result.skipped.push({ venueId, reason: 'invalid id' }); continue; }
if (!mongoose.Types.ObjectId.isValid(venueId)) {
result.skipped.push({ venueId, venueName: UNKNOWN_VENUE_NAME, reason: 'invalid id' }); continue;
}
const sendBody = {
targetDates: body.targetDates, targetWeekend: body.targetWeekend, bookingPeriod: body.bookingPeriod, cc: body.cc,
customIntro: body.customIntro, customBody: body.customBody,
};
// eslint-disable-next-line no-await-in-loop
const ctx = await this.resolvePitch({ venueId, templateType: body.templateType, ...sendBody });
if (ctx.error) { result.skipped.push({ venueId, reason: ctx.error.message }); continue; }
if (ctx.error) {
result.skipped.push({ venueId, venueName: ctx.error.venueName || UNKNOWN_VENUE_NAME, reason: ctx.error.message }); continue;
}
const { venue, template, type } = ctx as Required<PitchContext>;
// eslint-disable-next-line no-await-in-loop
const r = await this.performSend(venue, template, type, sendBody, actor);
if (!r.ok) { result.skipped.push({ venueId, reason: r.message }); continue; }
if (!r.ok) { result.skipped.push({ venueId, venueName: venue.name || UNKNOWN_VENUE_NAME, reason: r.message }); continue; }
result.sent += 1; result.records.push(r.record);
}
return res.status(200).json(result);
Expand Down
58 changes: 52 additions & 6 deletions test/unit/outreach/outreach-controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ vi.mock('#src/lib/classify-reply.js', () => ({
default: { classifyReply },
}));

const { default: controller } = await import('#src/model/outreach/outreach-controller.js');
const {
default: controller, DEFAULT_TEMPLATE_TYPE, UNKNOWN_VENUE_NAME,
} = await import('#src/model/outreach/outreach-controller.js');
const { default: userModel } = await import('#src/model/user/user-facade.js');
const { default: venueModel } = await import('#src/model/venue/venue-facade.js');
const { default: templateModel } = await import('#src/model/template/template-facade.js');
Expand Down Expand Up @@ -195,12 +197,18 @@ describe('Outreach Controller (#844 batch model)', () => {
expect(sendMail).not.toHaveBeenCalled();
});

it('400s when no template type can be resolved', async () => {
// JaMmusic#1250 — a venue with no templateType/templateOverride/venueType
// no longer hard-400s and gets silently skipped; it falls back to
// DEFAULT_TEMPLATE_TYPE (MidRangeCafeBar) and the pitch still sends.
it('falls back to DEFAULT_TEMPLATE_TYPE and sends when no template type can be resolved (#1250)', async () => {
asApprover();
(venueModel as any).findById = vi.fn(() => Promise.resolve(validVenue({ venueType: '' })));
const findOne = vi.fn(() => Promise.resolve(validTemplate({ type: DEFAULT_TEMPLATE_TYPE })));
(templateModel as any).findOne = findOne;
await c.sendPitch({ user: 'a', body: { venueId: oid(), targetDates: 'Aug 14-16', targetWeekend: VALID_WEEKEND } }, resStub);
expect(status).toBe(400);
expect(payload.message).toContain('venueType');
expect(status).toBe(201);
expect(findOne).toHaveBeenCalledWith(expect.objectContaining({ type: DEFAULT_TEMPLATE_TYPE, active: true }));
expect((c.model.create as any).mock.calls[0][0].templateUsed).toBe(DEFAULT_TEMPLATE_TYPE);
});

it('400s when no active template exists for the type', async () => {
Expand Down Expand Up @@ -486,6 +494,30 @@ describe('Outreach Controller (#844 batch model)', () => {
expect(status).toBe(201);
expect((c.model.create as any).mock.calls[0][0].templateUsed).toBe('MidRangeCafeBar');
});

// JaMmusic#1250 — precedence guardrails around the new default fallback:
// an explicit templateType, or a bare venue.venueType, must still win
// over DEFAULT_TEMPLATE_TYPE; the fallback is the LAST resort only.
it('an explicit templateType still wins over the default (#1250)', async () => {
asApprover();
(venueModel as any).findById = vi.fn(() => Promise.resolve(validVenue({ venueType: '', templateOverride: '' })));
await c.sendPitch(
{ user: 'josh', body: { venueId: oid(), targetDates: 'Aug 14-16', targetWeekend: VALID_WEEKEND, templateType: 'PubFestivalBrewery' } },
resStub,
);
expect(status).toBe(201);
expect((c.model.create as any).mock.calls[0][0].templateUsed).toBe('PubFestivalBrewery');
expect((c.model.create as any).mock.calls[0][0].templateUsed).not.toBe(DEFAULT_TEMPLATE_TYPE);
});

it('venue.venueType still wins over the default (#1250)', async () => {
asApprover();
(venueModel as any).findById = vi.fn(() => Promise.resolve(validVenue({ venueType: 'PubFestivalBrewery', templateOverride: '' })));
await c.sendPitch({ user: 'josh', body: { venueId: oid(), targetDates: 'Aug 14-16', targetWeekend: VALID_WEEKEND } }, resStub);
expect(status).toBe(201);
expect((c.model.create as any).mock.calls[0][0].templateUsed).toBe('PubFestivalBrewery');
expect((c.model.create as any).mock.calls[0][0].templateUsed).not.toBe(DEFAULT_TEMPLATE_TYPE);
});
});

describe('sendBatch (#844)', () => {
Expand Down Expand Up @@ -539,18 +571,19 @@ describe('Outreach Controller (#844 batch model)', () => {
asApprover();
await c.sendBatch({ user: 'josh', body: { venueIds: ['bad', oid()], targetDates: 'Aug 14-16', targetWeekend: VALID_WEEKEND } }, resStub);
expect(payload.sent).toBe(1);
expect(payload.skipped).toEqual([{ venueId: 'bad', reason: 'invalid id' }]);
expect(payload.skipped).toEqual([{ venueId: 'bad', venueName: UNKNOWN_VENUE_NAME, reason: 'invalid id' }]);
});

it('skips an ineligible venue (collected, batch continues)', async () => {
asApprover();
(venueModel as any).findById = vi.fn()
.mockResolvedValueOnce(validVenue())
.mockResolvedValueOnce(validVenue({ outreachEligible: false }));
.mockResolvedValueOnce(validVenue({ name: 'The Ineligible Room', outreachEligible: false }));
await c.sendBatch({ user: 'josh', body: { venueIds: [oid(), oid()], targetDates: 'Aug 14-16', targetWeekend: VALID_WEEKEND } }, resStub);
expect(payload.sent).toBe(1);
expect(payload.skipped).toHaveLength(1);
expect(payload.skipped[0].reason).toContain('not outreach-eligible');
expect(payload.skipped[0].venueName).toBe('The Ineligible Room');
});

it('skips a venue whose send fails', async () => {
Expand All @@ -560,6 +593,19 @@ describe('Outreach Controller (#844 batch model)', () => {
expect(payload.sent).toBe(0);
expect(payload.skipped).toHaveLength(1);
expect(payload.skipped[0].reason).toContain('email send failed');
expect(payload.skipped[0].venueName).toBe('The Spot on Kirk');
});

// JaMmusic#1250 — every skipped entry carries venueName; a venue that
// never even loaded (bad id, or not found) gets the UNKNOWN_VENUE_NAME
// placeholder instead of the field being omitted.
it('carries venueName on every skipped entry, including the unknown-venue placeholder (#1250)', async () => {
asApprover();
(venueModel as any).findById = vi.fn(() => Promise.resolve(null));
await c.sendBatch({ user: 'josh', body: { venueIds: ['bad', oid()], targetDates: 'Aug 14-16', targetWeekend: VALID_WEEKEND } }, resStub);
expect(payload.skipped).toHaveLength(2);
expect(payload.skipped[0]).toEqual({ venueId: 'bad', venueName: UNKNOWN_VENUE_NAME, reason: 'invalid id' });
expect(payload.skipped[1]).toMatchObject({ venueName: UNKNOWN_VENUE_NAME, reason: 'venue not found' });
});

it('lets an agent batch-send when auto-approve is ON', async () => {
Expand Down
Loading