From f6b4ecdf94edb758566660c8f47f8dae38548d82 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Fri, 19 Jun 2026 10:53:03 -0400 Subject: [PATCH 1/3] feat(venue): venue collection + CRUD REST API (#819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mongo becomes the master store for booking venues. Adds the Venue collection in web-jam-back's own DB, with gated CRUD at /venue: - privilege-first authorization (venue:create/edit/delete on the shared web-jam-llm AI-agent identity; admin-role fallback). No venue:read cap per the repo convention — reads require any venue write cap or admin. - create-time upsert/dedupe on a natural key (email, else name+city) so agents that re-add a known venue update it instead of duplicating. - DELETE is a soft-delete (status: archived), never a hard remove. - GET /venue?eligibleFor= applies the ±2-month clear-window rule against the gig calendar (cross-DB read via gigModel). - each write stamps the acting agent in lastModifiedBy (actor field). 21 unit tests; typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 4 +- package.json | 2 +- src/auth/capabilities.ts | 8 + src/model/venue/venue-controller.ts | 239 +++++++++++++++++++++++ src/model/venue/venue-facade.ts | 6 + src/model/venue/venue-router.ts | 36 ++++ src/model/venue/venue-schema.ts | 44 +++++ src/routes.ts | 2 + test/unit/venue/venue-controller.spec.ts | 194 ++++++++++++++++++ 9 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 src/model/venue/venue-controller.ts create mode 100644 src/model/venue/venue-facade.ts create mode 100644 src/model/venue/venue-router.ts create mode 100644 src/model/venue/venue-schema.ts create mode 100644 test/unit/venue/venue-controller.spec.ts diff --git a/package-lock.json b/package-lock.json index bfa2d7bd..7d08c730 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "web-jam-back", - "version": "2.0.18", + "version": "2.0.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web-jam-back", - "version": "2.0.18", + "version": "2.0.22", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 359482ab..5165a87f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web-jam-back", - "version": "2.0.21", + "version": "2.0.22", "description": "web-jam.com", "type": "module", "main": "build/src/index.js", diff --git a/src/auth/capabilities.ts b/src/auth/capabilities.ts index 6ca4002a..47ce4281 100644 --- a/src/auth/capabilities.ts +++ b/src/auth/capabilities.ts @@ -16,6 +16,14 @@ export const CAPABILITIES = [ // Gig-promotion channels (Task 5). Assignable to the web-jam-llm bot so // Claude/gemma can trigger sends; humans pass via admin role fallback. 'promo:email', + // Booking-outreach venue management (web-jam-back#819). Granted to the shared + // web-jam-llm AI-agent identity so agents (and the JaMmusic admin UI) can CRUD + // the venue collection; humans pass via the admin role fallback. No `venue:read` + // — per this repo's convention there are no `:read` capabilities; venue reads + // are gated by holding any venue write capability (or the admin role). + 'venue:create', + 'venue:edit', + 'venue:delete', ] as const; export type Capability = (typeof CAPABILITIES)[number]; diff --git a/src/model/venue/venue-controller.ts b/src/model/venue/venue-controller.ts new file mode 100644 index 00000000..2ce42156 --- /dev/null +++ b/src/model/venue/venue-controller.ts @@ -0,0 +1,239 @@ +import { Request, Response } from 'express'; +import mongoose from 'mongoose'; +import Controller from '#src/lib/controller.js'; +import { Icontroller } from '#src/lib/routeUtils.js'; +import venueModel from './venue-facade.js'; +import userModel from '../user/user-facade.js'; +import gigModel from '../gig/gig-facade.js'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s.@]+$/; +const VENUE_TYPES = ['Originals', 'PubFestivalBrewery', 'MidRangeCafeBar']; +const STATUS_OPTIONS = ['active', 'archived']; + +// Role fallback for human admins who authorize by role (no privileges array). +// AI agents pass via the venue:* capabilities on the shared web-jam-llm identity. +const ALLOWED_ROLES = ['JaM-admin', 'Developer']; + +// Write capabilities. Reads are gated by holding ANY of these (or the admin +// role) — this repo has no `:read` capabilities by convention. +const VENUE_WRITE_CAPS = ['venue:create', 'venue:edit', 'venue:delete']; + +// ±2-month clear-window for the eligibility filter (web-jam-back#819). +const ELIGIBILITY_WINDOW_MONTHS = 2; + +interface AuthedUser { userType?: string; privileges?: string[] } +type AuthRequest = Request & { user?: string }; +type AuthIdRequest = Request<{ id: string }> & { user?: string }; +type AuthzError = { status: number; message: string }; +type AuthzResult = AuthzError | null; + +interface VenueBody { + _id?: string; + name?: string; + city?: string; + usState?: string; + venueType?: string; + contactName?: string; + email?: string; + phone?: string; + website?: string; + status?: string; + notes?: string; + lastContacted?: string; + actor?: string; +} + +interface GigDoc { venue?: string; datetime?: string | Date } + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function stripHtml(value: string): string { + // `<[^>]*>` is linear (negated class, no nested quantifier) — safe from + // catastrophic backtracking despite the generic slow-regex warning. + // eslint-disable-next-line sonarjs/slow-regex + return value.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase(); +} + +// The actor that performed a write: an explicit `actor` (stamped by the MCP +// server / agent) wins; otherwise fall back to the authenticated token subject. +function resolveActor(req: AuthRequest, body: VenueBody): string { + return (body.actor || '').trim() || req.user || ''; +} + +// Reject a write body up front. Returns an error message, or '' when valid. +// `partial` (PUT) only validates the fields that are present. +function validateBody(body: VenueBody, partial: boolean): string { + if (!partial || body.name !== undefined) { + if (!body.name || !body.name.trim()) return 'Name is required'; + } + if (body.venueType !== undefined && VENUE_TYPES.indexOf(body.venueType) === -1) return 'venueType not valid'; + if (body.status !== undefined && STATUS_OPTIONS.indexOf(body.status) === -1) return 'status not valid'; + if (body.email !== undefined && body.email !== '' && !EMAIL_RE.test(String(body.email).trim().toLowerCase())) { + return 'A valid email is required'; + } + return ''; +} + +// Privilege-first, role-fallback gate (mirrors PromoController). Reused for both +// writes (a specific capability) and reads (any venue write capability). +function checkAccess(user: AuthedUser, required: string[]): AuthzResult { + const privileges = user.privileges || []; + if (privileges.length) { + if (!privileges.some((p) => required.indexOf(p) !== -1)) { + return { status: 403, message: `missing ${required.join('/')} capability` }; + } + return null; + } + if (ALLOWED_ROLES.indexOf(user.userType || '') === -1) { + return { status: 403, message: 'not authorized for venue management' }; + } + return null; +} + +class VenueController extends Controller { + // Load the token's user, then apply the access gate. Every venue route runs + // ensureAuthenticated first (valid token → req.user); this adds authorization. + async authorize(req: AuthRequest, required: string[]): Promise { // eslint-disable-line class-methods-use-this + let user: AuthedUser | null; + try { user = await userModel.findById(req.user || '') as unknown as AuthedUser | null; } catch (e) { + return { status: 500, message: (e as Error).message }; + } + if (!user) return { status: 401, message: 'user not found' }; + return checkAccess(user, required); + } + + // Build the Mongo filter for GET /venue from whitelisted query params. By + // default archived venues are hidden unless an explicit `status` is requested. + static buildListFilter(query: Record): Record { + const filter: Record = {}; + if (typeof query.status === 'string') filter.status = query.status; + else filter.status = { $ne: 'archived' }; + if (typeof query.venueType === 'string') filter.venueType = query.venueType; + return filter; + } + + // Drop venues that have a gig within ±2 months of the target date. Gigs live + // in a different DB (read via gigModel); matching is by venue name against the + // gig's HTML `venue` text (best-effort, name-based). + static async filterEligible(venues: Record[], target: Date): Promise[]> { + const start = new Date(target); start.setMonth(start.getMonth() - ELIGIBILITY_WINDOW_MONTHS); + const end = new Date(target); end.setMonth(end.getMonth() + ELIGIBILITY_WINDOW_MONTHS); + let gigs: GigDoc[]; + try { gigs = await gigModel.find({}) as unknown as GigDoc[]; } catch (e) { return Promise.reject(e); } + const booked = gigs + .filter((g) => g.datetime && new Date(g.datetime) >= start && new Date(g.datetime) <= end) + .map((g) => stripHtml(String(g.venue || ''))); + return venues.filter((v) => { + const name = String(v.name || '').trim().toLowerCase(); + if (!name) return true; + return !booked.some((bv) => bv.includes(name)); + }); + } + + // GET /venue — list venues (filters: status, venueType, eligibleFor=). + async listVenues(req: AuthRequest, res: Response): Promise { + const guardErr = await this.authorize(req, VENUE_WRITE_CAPS); + if (guardErr) return res.status(guardErr.status).json({ message: guardErr.message }); + const query = (req.query || {}) as Record; + let venues: Record[]; + try { venues = await this.model.find(VenueController.buildListFilter(query)); } catch (e) { + return res.status(500).json({ message: (e as Error).message }); + } + if (typeof query.eligibleFor === 'string') { + const target = new Date(query.eligibleFor); + if (Number.isNaN(target.getTime())) return res.status(400).json({ message: 'eligibleFor must be a valid date' }); + try { venues = await VenueController.filterEligible(venues, target); } catch (e) { + return res.status(500).json({ message: (e as Error).message }); + } + } + return res.status(200).json(venues); + } + + // GET /venue/:id + async getVenue(req: AuthIdRequest, res: Response): Promise { + const guardErr = await this.authorize(req, VENUE_WRITE_CAPS); + if (guardErr) return res.status(guardErr.status).json({ message: guardErr.message }); + if (!mongoose.Types.ObjectId.isValid(req.params.id)) return res.status(400).json({ message: 'Find id is invalid' }); + let doc; + try { doc = await this.model.findById(req.params.id); } catch (e) { return res.status(500).json({ message: (e as Error).message }); } + if (!doc) return res.status(400).json({ message: 'nothing found with id provided' }); + return res.status(200).json(doc); + } + + // Find an existing venue for dedupe: by email when given (strongest key), + // otherwise by case-insensitive name (+ city when present). + async findDuplicate(body: VenueBody): Promise | null> { + const email = (body.email || '').trim().toLowerCase(); + if (email) return this.model.findOne({ email }); + const query: Record = { name: new RegExp(`^${escapeRegExp((body.name || '').trim())}$`, 'i') }; + const city = (body.city || '').trim(); + if (city) query.city = new RegExp(`^${escapeRegExp(city)}$`, 'i'); + return this.model.findOne(query); + } + + // POST /venue — create a venue, or upsert onto an existing match (dedupe), so + // an agent that re-adds a known venue updates it instead of duplicating. A + // matched venue is also un-archived. + async createVenue(req: AuthRequest, res: Response): Promise { + const guardErr = await this.authorize(req, ['venue:create']); + if (guardErr) return res.status(guardErr.status).json({ message: guardErr.message }); + const body = (req.body || {}) as VenueBody; + delete body._id; + const invalid = validateBody(body, false); + if (invalid) return res.status(400).json({ message: invalid }); + + const actor = resolveActor(req, body); + let existing: Record | null; + try { existing = await this.findDuplicate(body); } catch (e) { return res.status(500).json({ message: (e as Error).message }); } + if (existing) { + let updated; + try { + updated = await this.model.findByIdAndUpdate(String(existing._id), { + ...body, status: body.status || 'active', lastModifiedBy: actor, + }); + } catch (e) { return res.status(500).json({ message: (e as Error).message }); } + return res.status(200).json(updated); + } + let doc; + try { + doc = await this.model.create({ ...body, status: body.status || 'active', lastModifiedBy: actor }); + } catch (e) { return res.status(500).json({ message: (e as Error).message }); } + return res.status(201).json(doc); + } + + // PUT /venue/:id — partial update. + async updateVenue(req: AuthIdRequest, res: Response): Promise { + const guardErr = await this.authorize(req, ['venue:edit']); + if (guardErr) return res.status(guardErr.status).json({ message: guardErr.message }); + if (!req.params.id || !mongoose.Types.ObjectId.isValid(req.params.id)) return res.status(400).json({ message: 'Update id is invalid' }); + const body = (req.body || {}) as VenueBody; + delete body._id; + const invalid = validateBody(body, true); + if (invalid) return res.status(400).json({ message: invalid }); + let doc; + try { + doc = await this.model.findByIdAndUpdate(req.params.id, { ...body, lastModifiedBy: resolveActor(req, body) }); + } catch (e) { return res.status(500).json({ message: (e as Error).message }); } + if (!doc) return res.status(400).json({ message: 'Id Not Found' }); + return res.status(200).json(doc); + } + + // DELETE /venue/:id — soft-delete (archive), never a hard remove, so history + // survives. Hard purge is an admin-only action outside this API (#819). + async deleteVenue(req: AuthIdRequest, res: Response): Promise { + const guardErr = await this.authorize(req, ['venue:delete']); + if (guardErr) return res.status(guardErr.status).json({ message: guardErr.message }); + if (!req.params.id || !mongoose.Types.ObjectId.isValid(req.params.id)) return res.status(400).json({ message: 'Delete id is invalid' }); + const actor = resolveActor(req, (req.body || {}) as VenueBody); + let doc; + try { + doc = await this.model.findByIdAndUpdate(req.params.id, { status: 'archived', lastModifiedBy: actor }); + } catch (e) { return res.status(500).json({ message: (e as Error).message }); } + if (!doc) return res.status(400).json({ message: 'Delete id is invalid' }); + return res.status(200).json({ message: 'Venue was archived successfully', venue: doc }); + } +} + +export default new VenueController(venueModel) as unknown as Icontroller; diff --git a/src/model/venue/venue-facade.ts b/src/model/venue/venue-facade.ts new file mode 100644 index 00000000..b1994109 --- /dev/null +++ b/src/model/venue/venue-facade.ts @@ -0,0 +1,6 @@ +import Model from '../../lib/facade.js'; +import venueSchema from './venue-schema.js'; + +class VenueModel extends Model {} + +export default new VenueModel(venueSchema); diff --git a/src/model/venue/venue-router.ts b/src/model/venue/venue-router.ts new file mode 100644 index 00000000..e64b23b9 --- /dev/null +++ b/src/model/venue/venue-router.ts @@ -0,0 +1,36 @@ +import express from 'express'; +import controller from './venue-controller.js'; +import authUtils from '../../auth/authUtils.js'; +import routeUtils from '../../lib/routeUtils.js'; + +// Venue management — booking-outreach data, NOT public (unlike /gig). Every +// route runs makeAction → ensureAuthenticated (populates req.user from the +// token); the controller then does the per-capability venue:* check +// (privilege-first, admin-role fallback). web-jam-back#819. +const router = express.Router(); + +router.route('/') + .get((req, res) => { + const action = routeUtils.makeAction(req, res, 'listVenues', controller, authUtils); + void action(); + }) + .post((req, res) => { + const action = routeUtils.makeAction(req, res, 'createVenue', controller, authUtils); + void action(); + }); + +router.route('/:id') + .get((req, res) => { + const action = routeUtils.makeAction(req, res, 'getVenue', controller, authUtils); + void action(); + }) + .put((req, res) => { + const action = routeUtils.makeAction(req, res, 'updateVenue', controller, authUtils); + void action(); + }) + .delete((req, res) => { + const action = routeUtils.makeAction(req, res, 'deleteVenue', controller, authUtils); + void action(); + }); + +export default router; diff --git a/src/model/venue/venue-schema.ts b/src/model/venue/venue-schema.ts new file mode 100644 index 00000000..7afe398c --- /dev/null +++ b/src/model/venue/venue-schema.ts @@ -0,0 +1,44 @@ +import mongoose from 'mongoose'; + +const { Schema } = mongoose; + +const options = { + timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }, +}; + +// Booking-outreach venues. As of web-jam-back#819 Mongo is the single master +// for venues (the Gig Booking Worksheet xlsx is retired). Unlike gigs — which +// live in WebJamSocketCluster's DB and are read via a dedicated connection — +// venues live in web-jam-back's OWN default database, so this model binds to the +// default mongoose connection like every other web-jam-back collection. +// +// `status` is lifecycle only: `archived` is the soft-delete state (DELETE never +// hard-removes a venue, so its history/outreach links survive a fat-fingered +// phone delete). Per-campaign outreach status lives in the separate `outreach` +// collection (#823), not here. +const venueSchema = new Schema({ + name: { type: String, required: true, trim: true }, + city: { type: String, required: false, trim: true }, + usState: { type: String, required: false, trim: true }, + venueType: { + type: String, + required: false, + enum: ['Originals', 'PubFestivalBrewery', 'MidRangeCafeBar'], + }, + contactName: { type: String, required: false, trim: true }, + email: { + type: String, required: false, lowercase: true, trim: true, + }, + phone: { type: String, required: false, trim: true }, + website: { type: String, required: false, trim: true }, + status: { + type: String, required: false, enum: ['active', 'archived'], default: 'active', + }, + notes: { type: String, required: false }, + lastContacted: { type: Date, required: false }, + // The AI agent or human that last wrote this record (#818 `actor` field — one + // shared agent identity authenticates, but each write records who acted). + lastModifiedBy: { type: String, required: false }, +}, options); + +export default mongoose.models.Venue || mongoose.model('Venue', venueSchema); diff --git a/src/routes.ts b/src/routes.ts index 15498d98..c662cc42 100755 --- a/src/routes.ts +++ b/src/routes.ts @@ -9,6 +9,7 @@ import gig from './model/gig/gig-router.js'; import subscriber from './model/subscriber/subscriber-router.js'; import adminSubscriber from './model/subscriber/admin-subscriber-router.js'; import promo from './model/promo/promo-router.js'; +import venue from './model/venue/venue-router.js'; import facebook from './model/facebook/index.js'; const router = express.Router(); @@ -25,5 +26,6 @@ export default function route(app: Express): void { router.use('/subscriber', subscriber); router.use('/admin/subscriber', adminSubscriber); router.use('/promo', promo); + router.use('/venue', venue); router.use('/facebook', facebook); } diff --git a/test/unit/venue/venue-controller.spec.ts b/test/unit/venue/venue-controller.spec.ts new file mode 100644 index 00000000..dbbe7deb --- /dev/null +++ b/test/unit/venue/venue-controller.spec.ts @@ -0,0 +1,194 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import mongoose from 'mongoose'; +import controller from '../../../src/model/venue/venue-controller.js'; +import userModel from '../../../src/model/user/user-facade.js'; +import gigModel from '../../../src/model/gig/gig-facade.js'; + +const c = controller as any; + +describe('Venue Controller', () => { + let status = 0; + let payload: any; + const resStub: any = { + status: (s: number) => { + status = s; + return { json: (obj: any) => { payload = obj; return obj; } }; + }, + }; + + // Default: an AI-agent identity holding every venue capability. + const asAgent = (privileges = ['venue:create', 'venue:edit', 'venue:delete']) => { + (userModel as any).findById = vi.fn(() => Promise.resolve({ privileges })); + }; + + beforeEach(() => { + status = 0; + payload = undefined; + asAgent(); + }); + + describe('authorize', () => { + it('403s when the capability is missing', async () => { + asAgent(['venue:edit']); + await c.createVenue({ user: 'a', body: { name: 'X' } }, resStub); + expect(status).toBe(403); + expect(payload.message).toContain('venue:create'); + }); + + it('401s when the user is not found', async () => { + (userModel as any).findById = vi.fn(() => Promise.resolve(null)); + await c.listVenues({ user: 'a', query: {} }, resStub); + expect(status).toBe(401); + }); + + it('allows a privilege-less admin via role fallback', async () => { + (userModel as any).findById = vi.fn(() => Promise.resolve({ userType: 'JaM-admin', privileges: [] })); + c.model.find = vi.fn(() => Promise.resolve([])); + await c.listVenues({ user: 'a', query: {} }, resStub); + expect(status).toBe(200); + }); + }); + + describe('createVenue', () => { + it('rejects a missing name', async () => { + await c.createVenue({ user: 'a', body: {} }, resStub); + expect(status).toBe(400); + expect(payload.message).toContain('Name'); + }); + + it('rejects an invalid venueType', async () => { + await c.createVenue({ user: 'a', body: { name: 'X', venueType: 'Bogus' } }, resStub); + expect(status).toBe(400); + expect(payload.message).toContain('venueType'); + }); + + it('rejects an invalid email', async () => { + await c.createVenue({ user: 'a', body: { name: 'X', email: 'nope' } }, resStub); + expect(status).toBe(400); + expect(payload.message).toContain('valid email'); + }); + + it('creates a new venue when there is no duplicate', async () => { + c.model.findOne = vi.fn(() => Promise.resolve(null)); + const create = vi.fn(() => Promise.resolve({ _id: 'new' })); + c.model.create = create; + await c.createVenue({ user: 'agent', body: { name: 'The Spot', city: 'Salem', actor: 'sonnet' } }, resStub); + expect(status).toBe(201); + const arg = (create.mock.calls[0] as unknown[])[0] as any; + expect(arg.status).toBe('active'); + expect(arg.lastModifiedBy).toBe('sonnet'); + }); + + it('upserts onto an existing duplicate instead of inserting', async () => { + c.model.findOne = vi.fn(() => Promise.resolve({ _id: 'dup1' })); + const upd = vi.fn(() => Promise.resolve({ _id: 'dup1' })); + c.model.findByIdAndUpdate = upd; + const create = vi.fn(); + c.model.create = create; + await c.createVenue({ user: 'agent', body: { name: 'The Spot', email: 'b@k.com' } }, resStub); + expect(status).toBe(200); + expect(create).not.toHaveBeenCalled(); + expect(upd).toHaveBeenCalledWith('dup1', expect.objectContaining({ status: 'active' })); + }); + + it('dedupes by email when an email is provided', async () => { + const findOne = vi.fn(() => Promise.resolve(null)); + c.model.findOne = findOne; + c.model.create = vi.fn(() => Promise.resolve({ _id: 'n' })); + await c.createVenue({ user: 'a', body: { name: 'X', email: 'A@B.com' } }, resStub); + expect(findOne).toHaveBeenCalledWith({ email: 'a@b.com' }); + }); + }); + + describe('updateVenue', () => { + it('rejects an invalid id', async () => { + await c.updateVenue({ user: 'a', params: { id: 'nope' }, body: {} }, resStub); + expect(status).toBe(400); + expect(payload.message).toContain('Update id'); + }); + + it('rejects an invalid status', async () => { + const id = new mongoose.Types.ObjectId().toString(); + await c.updateVenue({ user: 'a', params: { id }, body: { status: 'bogus' } }, resStub); + expect(status).toBe(400); + expect(payload.message).toContain('status'); + }); + + it('updates a valid record', async () => { + const id = new mongoose.Types.ObjectId().toString(); + const upd = vi.fn(() => Promise.resolve({ _id: id })); + c.model.findByIdAndUpdate = upd; + await c.updateVenue({ user: 'agent', params: { id }, body: { notes: 'called them' } }, resStub); + expect(status).toBe(200); + expect(upd).toHaveBeenCalledWith(id, expect.objectContaining({ notes: 'called them', lastModifiedBy: 'agent' })); + }); + }); + + describe('deleteVenue (soft-delete)', () => { + it('archives rather than hard-deleting', async () => { + const id = new mongoose.Types.ObjectId().toString(); + const upd = vi.fn(() => Promise.resolve({ _id: id, status: 'archived' })); + c.model.findByIdAndUpdate = upd; + await c.deleteVenue({ user: 'a', params: { id }, body: {} }, resStub); + expect(status).toBe(200); + expect(upd).toHaveBeenCalledWith(id, expect.objectContaining({ status: 'archived' })); + expect(payload.message).toContain('archived'); + }); + + it('rejects an invalid id', async () => { + await c.deleteVenue({ user: 'a', params: { id: 'bad' }, body: {} }, resStub); + expect(status).toBe(400); + }); + }); + + describe('getVenue', () => { + it('rejects an invalid id', async () => { + await c.getVenue({ user: 'a', params: { id: 'bad' } }, resStub); + expect(status).toBe(400); + }); + + it('returns a found venue', async () => { + const id = new mongoose.Types.ObjectId().toString(); + c.model.findById = vi.fn(() => Promise.resolve({ _id: id, name: 'The Spot' })); + await c.getVenue({ user: 'a', params: { id } }, resStub); + expect(status).toBe(200); + expect(payload.name).toBe('The Spot'); + }); + }); + + describe('listVenues', () => { + it('returns the collection', async () => { + c.model.find = vi.fn(() => Promise.resolve([{ name: 'A' }, { name: 'B' }])); + await c.listVenues({ user: 'a', query: {} }, resStub); + expect(status).toBe(200); + expect(payload).toHaveLength(2); + }); + + it('applies the ±2-month eligibility filter against gigs', async () => { + c.model.find = vi.fn(() => Promise.resolve([{ name: 'Open Cafe' }, { name: 'Booked Bar' }])); + (gigModel as any).find = vi.fn(() => Promise.resolve([ + { venue: 'Booked Bar', datetime: '2026-07-15T00:00:00.000Z' }, + ])); + await c.listVenues({ user: 'a', query: { eligibleFor: '2026-07-01' } }, resStub); + expect(status).toBe(200); + expect(payload.map((v: any) => v.name)).toEqual(['Open Cafe']); + }); + + it('rejects an invalid eligibleFor date', async () => { + c.model.find = vi.fn(() => Promise.resolve([])); + await c.listVenues({ user: 'a', query: { eligibleFor: 'not-a-date' } }, resStub); + expect(status).toBe(400); + }); + }); + + describe('buildListFilter', () => { + it('hides archived by default', () => { + expect((controller as any).constructor.buildListFilter({})).toEqual({ status: { $ne: 'archived' } }); + }); + + it('honors an explicit status and venueType', () => { + const f = (controller as any).constructor.buildListFilter({ status: 'archived', venueType: 'Originals' }); + expect(f).toEqual({ status: 'archived', venueType: 'Originals' }); + }); + }); +}); From 68a41d787dac9641e0686ae11b99a0fff4a04655 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Fri, 19 Jun 2026 10:57:16 -0400 Subject: [PATCH 2/3] chore(node): bump Node to 24.17.0 (engines, .nvmrc, CircleCI image) Co-Authored-By: Claude Opus 4.8 --- .circleci/config.yml | 2 +- .nvmrc | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1f861fa4..b8954c1f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,7 +2,7 @@ version: 2.1 jobs: build: docker: - - image: cimg/node:24.16-browsers + - image: cimg/node:24.17-browsers working_directory: ~/repo steps: - checkout diff --git a/.nvmrc b/.nvmrc index b832e400..1dd37d53 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.16.0 +24.17.0 diff --git a/package.json b/package.json index 5165a87f..0f9039ef 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "build/src/index.js", "engines": { - "node": "24.16.0" + "node": "24.17.0" }, "files": [ "src/" From 87035f7d4c9a1fc06153e14638582b763cb4bd94 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Fri, 19 Jun 2026 12:43:44 -0400 Subject: [PATCH 3/3] ci: pin CircleCI to cimg/node:24.16-browsers until 24.17 image ships App stays on Node 24.17.0 (engines/.nvmrc); only the CI image is held back because cimg/node:24.17-browsers is not yet published. Co-Authored-By: Claude Opus 4.8 --- .circleci/config.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b8954c1f..3bf51f71 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,7 +2,9 @@ version: 2.1 jobs: build: docker: - - image: cimg/node:24.17-browsers + # App targets Node 24.17 (engines/.nvmrc), but cimg/node:24.17-browsers + # isn't published yet — pin to the latest available; bump when it ships. + - image: cimg/node:24.16-browsers working_directory: ~/repo steps: - checkout