diff --git a/AGENTS.md b/AGENTS.md index bb0f3e93..a0f5b439 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ rules and do not reconstruct them from memory or from this file. - **Build:** Check package.json for build scripts. - **Node Engine Version Bumps:** When bumping Node.js in `package.json` `engines.node`, always run `npm install --package-lock-only --ignore-scripts` (or `npm install --ignore-scripts`) to update `package-lock.json` root engine definition without triggering `postinstall` build scripts, so both files are committed together. - **Standards:** Follow existing ESM patterns. +- **Vitest Config & Environment Variables**: Never hardcode test environment variables (such as `test.env.AllowUrl`) in shared, CI-executed `vitest.config.ts` to satisfy local test runs. Vitest's `test.env` overrides project-level CI environment variables across all test workers and silently overwrites production/CI test environments. If local test execution requires environment variables, configure them in uncommitted local `.env` files rather than modifying `vitest.config.ts`. - **Merging:** Gemini is **NOT** allowed to merge PR changes to the `dev` or `main` branches. The user is the reviewer. ## Quota & Token Hygiene @@ -32,3 +33,4 @@ rules and do not reconstruct them from memory or from this file. ## Routes & Verbs - **Venue Updates (`/venue/:id`)**: `PATCH /venue/:id` is the partial-merge update verb (routing to `controller.updateVenue`). Address updates enforce immutability once set (`400: address cannot be removed`). - **Setlist API Sorting (`GET /setlist` and `GET /setlist/:id`)**: Accepts `?sort=title` (or `sort=artist` / `sort=order`) to return items in alphabetical or specified order. Sorting is read-time view only and strips `sort` from Mongoose query params so stored MongoDB item order is never mutated. +- **Outreach Report Serving & Takedown (`/outreach/report`)**: `POST /outreach/report` (authenticated) stores or updates rendered HTML artifacts in MongoDB (`OutreachReport` collection). `GET /outreach/report/:weekend` (public) serves raw HTML directly with `Content-Type: text/html; charset=utf-8` and `Cache-Control: public, max-age=300`. `DELETE /outreach/report/:weekend` (authenticated) deletes the stored report document from MongoDB upon gig booking or decommission, returning HTTP 404 on subsequent requests. diff --git a/package.json b/package.json index 93ce086e..fc5b5ca1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web-jam-back", - "version": "2.11.14", + "version": "2.12.0", "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 b787c905..2cd491a6 100644 --- a/src/model/outreach/outreach-controller.ts +++ b/src/model/outreach/outreach-controller.ts @@ -12,6 +12,7 @@ import { findReplies } from '#src/lib/imap-replies.js'; import { classifyReply } from '#src/lib/classify-reply.js'; import outreachModel from './outreach-facade.js'; import outreachConfigModel from './outreach-config-facade.js'; +import outreachReportModel from './outreach-report-facade.js'; import venueModel from '../venue/venue-facade.js'; import templateModel from '../template/template-facade.js'; import { formatTemplate, sanitizeTemplateText } from '../template/template-controller.js'; @@ -1615,6 +1616,92 @@ class OutreachController extends Controller { } catch (e) { return res.status(500).json({ message: (e as Error).message }); } return res.status(200).json(updated); } + + // POST /outreach/report — save or update an outreach HTML run report (web-jam-back#1052). + async saveReport(req: AuthRequest, res: Response): Promise { + const guardErr = await this.authorize(req, OUTREACH_SEND_CAPS); + if (guardErr) return res.status(guardErr.status).json({ message: guardErr.message }); + const { + weekend, title, htmlContent, candidatesCount, dispatchedCount, metadata, + } = (req.body || {}) as { + weekend?: string; + title?: string; + htmlContent?: string; + candidatesCount?: number; + dispatchedCount?: number; + metadata?: Record; + }; + if (!weekend || typeof weekend !== 'string' || !weekend.trim()) { + return res.status(400).json({ message: 'weekend is required' }); + } + if (!htmlContent || typeof htmlContent !== 'string' || !htmlContent.trim()) { + return res.status(400).json({ message: 'htmlContent is required' }); + } + const trimmedWeekend = weekend.trim(); + const reportTitle = (title && typeof title === 'string' && title.trim()) + ? title.trim() + : `Outreach Report - ${trimmedWeekend}`; + const docData = { + weekend: trimmedWeekend, + title: reportTitle, + htmlContent, + candidatesCount: typeof candidatesCount === 'number' ? candidatesCount : 0, + dispatchedCount: typeof dispatchedCount === 'number' ? dispatchedCount : 0, + metadata: metadata && typeof metadata === 'object' ? metadata : {}, + }; + try { + const existing = await outreachReportModel.findOne({ weekend: trimmedWeekend }) as { _id?: unknown } | null; + let saved; + if (existing && existing._id) { + saved = await outreachReportModel.findByIdAndUpdate(String(existing._id), docData); + } else { + saved = await outreachReportModel.create(docData); + } + return res.status(existing ? 200 : 201).json(saved); + } catch (e) { + return res.status(500).json({ message: (e as Error).message }); + } + } + + // GET /outreach/report/:weekend — public HTML report serving endpoint (web-jam-back#1052). + async getReport(req: Request, res: Response): Promise { + const { weekend } = req.params; + if (!weekend || typeof weekend !== 'string' || !weekend.trim()) { + return res.status(400).json({ message: 'weekend parameter is required' }); + } + const trimmedWeekend = weekend.trim(); + try { + const doc = await outreachReportModel.findOne({ weekend: trimmedWeekend }) as { htmlContent?: string } | null; + if (!doc || !doc.htmlContent) { + return res.status(404).json({ message: `Outreach report for weekend '${trimmedWeekend}' not found` }); + } + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=300'); + return res.status(200).send(doc.htmlContent); + } catch (e) { + return res.status(500).json({ message: (e as Error).message }); + } + } + + // DELETE /outreach/report/:weekend — hard delete HTML report on gig booking or takedown (web-jam-back#1052). + async deleteReport(req: AuthRequest, res: Response): Promise { + const guardErr = await this.authorize(req, OUTREACH_ANY_CAPS); + if (guardErr) return res.status(guardErr.status).json({ message: guardErr.message }); + const { weekend } = req.params; + if (!weekend || typeof weekend !== 'string' || !weekend.trim()) { + return res.status(400).json({ message: 'weekend parameter is required' }); + } + const trimmedWeekend = weekend.trim(); + try { + const deleted = await outreachReportModel.findOneAndDelete({ weekend: trimmedWeekend }); + if (!deleted) { + return res.status(404).json({ message: `Outreach report for weekend '${trimmedWeekend}' not found` }); + } + return res.status(200).json({ message: `Outreach report for weekend '${trimmedWeekend}' deleted successfully` }); + } catch (e) { + return res.status(500).json({ message: (e as Error).message }); + } + } } export default new OutreachController(outreachModel) as unknown as Icontroller; diff --git a/src/model/outreach/outreach-report-facade.ts b/src/model/outreach/outreach-report-facade.ts new file mode 100644 index 00000000..e7902a69 --- /dev/null +++ b/src/model/outreach/outreach-report-facade.ts @@ -0,0 +1,10 @@ +import Model from '../../lib/facade.js'; +import outreachReportSchema from './outreach-report-schema.js'; + +class OutreachReportModel extends Model { + findOneAndDelete(query: Record): Promise | null> { + return this.Schema.findOneAndDelete(query).lean().exec() as unknown as Promise | null>; + } +} + +export default new OutreachReportModel(outreachReportSchema); diff --git a/src/model/outreach/outreach-report-schema.ts b/src/model/outreach/outreach-report-schema.ts new file mode 100644 index 00000000..1f73f2a0 --- /dev/null +++ b/src/model/outreach/outreach-report-schema.ts @@ -0,0 +1,47 @@ +import mongoose from 'mongoose'; + +const { Schema } = mongoose; + +const options = { + timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }, +}; + +// Outreach run reports (web-jam-back#1052). +// Stores rendered HTML review artifacts and metadata for target weekend campaigns +// (e.g. `2026-10-16-to-2026-10-18`) so they can be served dynamically via +// GET /outreach/report/:weekend without server redeploys, and deleted upon booking. +const outreachReportSchema = new Schema({ + weekend: { + type: String, + required: true, + unique: true, + index: true, + trim: true, + }, + title: { + type: String, + required: true, + trim: true, + }, + htmlContent: { + type: String, + required: true, + }, + candidatesCount: { + type: Number, + required: false, + default: 0, + }, + dispatchedCount: { + type: Number, + required: false, + default: 0, + }, + metadata: { + type: Schema.Types.Mixed, + required: false, + default: () => ({}), + }, +}, options); + +export default mongoose.models.OutreachReport || mongoose.model('OutreachReport', outreachReportSchema); diff --git a/src/model/outreach/outreach-router.ts b/src/model/outreach/outreach-router.ts index be0a5f72..27ff8fea 100644 --- a/src/model/outreach/outreach-router.ts +++ b/src/model/outreach/outreach-router.ts @@ -107,6 +107,26 @@ router.route('/:id/outcome') void action(); }); +// POST /outreach/report — save or update an outreach HTML run report (web-jam-back#1052). +router.route('/report') + .post((req, res) => { + const action = routeUtils.makeAction(req, res, 'saveReport', controller, authUtils); + void action(); + }); + +// GET /outreach/report/:weekend — public HTML report serving endpoint (web-jam-back#1052). +// DELETE /outreach/report/:weekend — authenticated HTML report takedown endpoint (web-jam-back#1052). +router.route('/report/:weekend') + .get((req, res) => { + (async () => { + await controller.getReport(req, res); + })(); + }) + .delete((req, res) => { + const action = routeUtils.makeAction(req, res, 'deleteReport', controller, authUtils); + void action(); + }); + router.route('/:id') .get((req, res) => { const action = routeUtils.makeAction(req, res, 'getOutreach', controller, authUtils); diff --git a/test/unit/outreach/outreach-report.spec.ts b/test/unit/outreach/outreach-report.spec.ts new file mode 100644 index 00000000..ad465755 --- /dev/null +++ b/test/unit/outreach/outreach-report.spec.ts @@ -0,0 +1,299 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import mongoose from 'mongoose'; + +const { default: controller } = await import('#src/model/outreach/outreach-controller.js'); +const { default: userModel } = await import('#src/model/user/user-facade.js'); +const { default: reportModel } = await import('#src/model/outreach/outreach-report-facade.js'); + +const c = controller as any; +const oid = () => new mongoose.Types.ObjectId().toString(); + +describe('Outreach Report Endpoints (web-jam-back#1052)', () => { + let status = 0; + let payload: any; + let headers: Record = {}; + let rawBody: any; + + const resStub: any = { + status: (s: number) => { + status = s; + return { + json: (obj: any) => { payload = obj; return obj; }, + send: (body: any) => { rawBody = body; return body; }, + }; + }, + setHeader: (name: string, value: string) => { + headers[name] = value; + }, + }; + + const origFindOneAndDelete = reportModel.findOneAndDelete; + + beforeEach(() => { + status = 0; + payload = undefined; + headers = {}; + rawBody = undefined; + reportModel.findOneAndDelete = origFindOneAndDelete; + vi.restoreAllMocks(); + }); + + const asAgent = (privileges = ['outreach:create', 'outreach:edit', 'outreach:delete']) => { + (userModel as any).findById = vi.fn(() => Promise.resolve({ privileges })); + }; + + const asApprover = () => asAgent(['outreach:approve']); + + const asNonOutreach = () => { + (userModel as any).findById = vi.fn(() => Promise.resolve({ privileges: ['venue:view'] })); + }; + + describe('POST /outreach/report (saveReport)', () => { + it('rejects unauthenticated requests (401)', async () => { + (userModel as any).findById = vi.fn(() => Promise.resolve(null)); + const req: any = { user: oid(), body: { weekend: '2026-10-16-to-2026-10-18', htmlContent: '

Report

' } }; + await c.saveReport(req, resStub); + expect(status).toBe(401); + expect(payload.message).toContain('user not found'); + }); + + it('rejects unauthorized users without outreach privileges (403)', async () => { + asNonOutreach(); + const req: any = { user: oid(), body: { weekend: '2026-10-16-to-2026-10-18', htmlContent: '

Report

' } }; + await c.saveReport(req, resStub); + expect(status).toBe(403); + }); + + it('rejects missing or empty weekend (400)', async () => { + asAgent(); + const req: any = { user: oid(), body: { htmlContent: '

Report

' } }; + await c.saveReport(req, resStub); + expect(status).toBe(400); + expect(payload.message).toContain('weekend is required'); + + const reqEmpty: any = { user: oid(), body: { weekend: ' ', htmlContent: '

Report

' } }; + await c.saveReport(reqEmpty, resStub); + expect(status).toBe(400); + expect(payload.message).toContain('weekend is required'); + }); + + it('rejects missing or empty htmlContent (400)', async () => { + asAgent(); + const req: any = { user: oid(), body: { weekend: '2026-10-16-to-2026-10-18' } }; + await c.saveReport(req, resStub); + expect(status).toBe(400); + expect(payload.message).toContain('htmlContent is required'); + + const reqEmpty: any = { user: oid(), body: { weekend: '2026-10-16-to-2026-10-18', htmlContent: ' ' } }; + await c.saveReport(reqEmpty, resStub); + expect(status).toBe(400); + expect(payload.message).toContain('htmlContent is required'); + }); + + it('creates a new report with 201 status when not previously existing', async () => { + asAgent(); + const reportId = oid(); + (reportModel as any).findOne = vi.fn(() => Promise.resolve(null)); + (reportModel as any).create = vi.fn((data: any) => Promise.resolve({ _id: reportId, ...data })); + + const req: any = { + user: oid(), + body: { + weekend: '2026-10-16-to-2026-10-18', + title: 'October 16-18 Weekend Run', + htmlContent: 'Report Content', + candidatesCount: 15, + dispatchedCount: 10, + metadata: { metro: 'salem-roanoke' }, + }, + }; + + await c.saveReport(req, resStub); + expect(status).toBe(201); + expect(payload._id).toBe(reportId); + expect(payload.weekend).toBe('2026-10-16-to-2026-10-18'); + expect(payload.title).toBe('October 16-18 Weekend Run'); + expect(payload.htmlContent).toBe('Report Content'); + expect(payload.candidatesCount).toBe(15); + expect(payload.dispatchedCount).toBe(10); + expect((reportModel as any).create).toHaveBeenCalledWith(expect.objectContaining({ + weekend: '2026-10-16-to-2026-10-18', + title: 'October 16-18 Weekend Run', + })); + }); + + it('uses fallback default title when title is omitted', async () => { + asAgent(); + const reportId = oid(); + (reportModel as any).findOne = vi.fn(() => Promise.resolve(null)); + (reportModel as any).create = vi.fn((data: any) => Promise.resolve({ _id: reportId, ...data })); + + const req: any = { + user: oid(), + body: { + weekend: '2026-10-16-to-2026-10-18', + htmlContent: 'Report Content', + }, + }; + + await c.saveReport(req, resStub); + expect(status).toBe(201); + expect((reportModel as any).create).toHaveBeenCalledWith(expect.objectContaining({ + title: 'Outreach Report - 2026-10-16-to-2026-10-18', + })); + }); + + it('updates an existing report with 200 status when already existing', async () => { + asApprover(); + const existingId = oid(); + (reportModel as any).findOne = vi.fn(() => Promise.resolve({ _id: existingId, weekend: '2026-10-16-to-2026-10-18' })); + (reportModel as any).findByIdAndUpdate = vi.fn((id: string, data: any) => Promise.resolve({ _id: id, ...data })); + + const req: any = { + user: oid(), + body: { + weekend: '2026-10-16-to-2026-10-18', + title: 'Updated Title', + htmlContent: 'Updated Body', + }, + }; + + await c.saveReport(req, resStub); + expect(status).toBe(200); + expect(payload._id).toBe(existingId); + expect(payload.title).toBe('Updated Title'); + expect((reportModel as any).findByIdAndUpdate).toHaveBeenCalledWith(existingId, expect.objectContaining({ + weekend: '2026-10-16-to-2026-10-18', + title: 'Updated Title', + })); + }); + + it('returns 500 when database throws', async () => { + asAgent(); + (reportModel as any).findOne = vi.fn(() => Promise.reject(new Error('Mongo connection drop'))); + + const req: any = { + user: oid(), + body: { + weekend: '2026-10-16-to-2026-10-18', + htmlContent: '

Report

', + }, + }; + + await c.saveReport(req, resStub); + expect(status).toBe(500); + expect(payload.message).toBe('Mongo connection drop'); + }); + }); + + describe('GET /outreach/report/:weekend (getReport)', () => { + it('returns 400 when weekend param is missing/empty', async () => { + const req: any = { params: { weekend: ' ' } }; + await c.getReport(req, resStub); + expect(status).toBe(400); + expect(payload.message).toContain('weekend parameter is required'); + }); + + it('returns 404 when report document does not exist', async () => { + (reportModel as any).findOne = vi.fn(() => Promise.resolve(null)); + const req: any = { params: { weekend: '2026-10-16-to-2026-10-18' } }; + await c.getReport(req, resStub); + expect(status).toBe(404); + expect(payload.message).toContain("Outreach report for weekend '2026-10-16-to-2026-10-18' not found"); + }); + + it('returns 404 when report document has empty htmlContent', async () => { + (reportModel as any).findOne = vi.fn(() => Promise.resolve({ weekend: '2026-10-16-to-2026-10-18', htmlContent: '' })); + const req: any = { params: { weekend: '2026-10-16-to-2026-10-18' } }; + await c.getReport(req, resStub); + expect(status).toBe(404); + }); + + it('serves HTML report with Content-Type: text/html and 200 status', async () => { + const html = '

Gig Outreach Review

'; + (reportModel as any).findOne = vi.fn(() => Promise.resolve({ + weekend: '2026-10-16-to-2026-10-18', + htmlContent: html, + })); + + const req: any = { params: { weekend: '2026-10-16-to-2026-10-18' } }; + await c.getReport(req, resStub); + expect(status).toBe(200); + expect(headers['Content-Type']).toBe('text/html; charset=utf-8'); + expect(headers['Cache-Control']).toBe('public, max-age=300'); + expect(rawBody).toBe(html); + }); + + it('returns 500 when database throws on retrieval', async () => { + (reportModel as any).findOne = vi.fn(() => Promise.reject(new Error('Mongo read error'))); + const req: any = { params: { weekend: '2026-10-16-to-2026-10-18' } }; + await c.getReport(req, resStub); + expect(status).toBe(500); + expect(payload.message).toBe('Mongo read error'); + }); + }); + + describe('DELETE /outreach/report/:weekend (deleteReport)', () => { + it('rejects unauthenticated requests (401)', async () => { + (userModel as any).findById = vi.fn(() => Promise.resolve(null)); + const req: any = { user: oid(), params: { weekend: '2026-10-16-to-2026-10-18' } }; + await c.deleteReport(req, resStub); + expect(status).toBe(401); + }); + + it('rejects unauthorized users without outreach privileges (403)', async () => { + asNonOutreach(); + const req: any = { user: oid(), params: { weekend: '2026-10-16-to-2026-10-18' } }; + await c.deleteReport(req, resStub); + expect(status).toBe(403); + }); + + it('returns 400 when weekend param is missing/empty', async () => { + asAgent(); + const req: any = { user: oid(), params: { weekend: ' ' } }; + await c.deleteReport(req, resStub); + expect(status).toBe(400); + expect(payload.message).toContain('weekend parameter is required'); + }); + + it('returns 404 when report to delete is not found', async () => { + asAgent(); + (reportModel as any).findOneAndDelete = vi.fn(() => Promise.resolve(null)); + const req: any = { user: oid(), params: { weekend: '2026-10-16-to-2026-10-18' } }; + await c.deleteReport(req, resStub); + expect(status).toBe(404); + expect(payload.message).toContain("Outreach report for weekend '2026-10-16-to-2026-10-18' not found"); + }); + + it('deletes report and returns 200 success message', async () => { + asAgent(); + (reportModel as any).findOneAndDelete = vi.fn(() => Promise.resolve({ weekend: '2026-10-16-to-2026-10-18' })); + const req: any = { user: oid(), params: { weekend: '2026-10-16-to-2026-10-18' } }; + await c.deleteReport(req, resStub); + expect(status).toBe(200); + expect(payload.message).toContain("Outreach report for weekend '2026-10-16-to-2026-10-18' deleted successfully"); + expect((reportModel as any).findOneAndDelete).toHaveBeenCalledWith({ weekend: '2026-10-16-to-2026-10-18' }); + }); + + it('returns 500 when database throws on delete', async () => { + asAgent(); + (reportModel as any).findOneAndDelete = vi.fn(() => Promise.reject(new Error('Mongo delete error'))); + const req: any = { user: oid(), params: { weekend: '2026-10-16-to-2026-10-18' } }; + await c.deleteReport(req, resStub); + expect(status).toBe(500); + expect(payload.message).toBe('Mongo delete error'); + }); + }); + + describe('OutreachReport Facade and Schema', () => { + it('findOneAndDelete delegates to mongoose Schema.findOneAndDelete', async () => { + const mockExec = vi.fn(() => Promise.resolve({ weekend: '2026-10-16-to-2026-10-18' })); + const mockLean = vi.fn(() => ({ exec: mockExec })); + (reportModel.Schema as any).findOneAndDelete = vi.fn(() => ({ lean: mockLean })); + + const res = await reportModel.findOneAndDelete({ weekend: '2026-10-16-to-2026-10-18' }); + expect(res).toEqual({ weekend: '2026-10-16-to-2026-10-18' }); + expect((reportModel.Schema as any).findOneAndDelete).toHaveBeenCalledWith({ weekend: '2026-10-16-to-2026-10-18' }); + }); + }); +});