Skip to content
Merged

to main #1054

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: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
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.11.14",
"version": "2.12.0",
"description": "web-jam.com",
"type": "module",
"main": "build/src/index.js",
Expand Down
87 changes: 87 additions & 0 deletions src/model/outreach/outreach-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<unknown> {
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<string, unknown>;
};
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<unknown> {
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<unknown> {
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;
10 changes: 10 additions & 0 deletions src/model/outreach/outreach-report-facade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Model from '../../lib/facade.js';
import outreachReportSchema from './outreach-report-schema.js';

class OutreachReportModel extends Model {
findOneAndDelete(query: Record<string, unknown>): Promise<Record<string, unknown> | null> {
return this.Schema.findOneAndDelete(query).lean().exec() as unknown as Promise<Record<string, unknown> | null>;
}
}

export default new OutreachReportModel(outreachReportSchema);
47 changes: 47 additions & 0 deletions src/model/outreach/outreach-report-schema.ts
Original file line number Diff line number Diff line change
@@ -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);
20 changes: 20 additions & 0 deletions src/model/outreach/outreach-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading