From 27e73e969a49d02ae6e14a6aa580a9792fa5b8b2 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 27 Apr 2026 11:39:57 +0100 Subject: [PATCH] refactor(series): extract loadPublicSeries helper --- server/api/helcim/initialize-payment.post.js | 11 +---- server/api/series/[id].get.js | 20 ++++---- .../api/series/[id]/tickets/available.get.js | 17 +------ .../api/series/[id]/tickets/purchase.post.js | 17 +------ server/utils/loadSeries.js | 47 +++++++++++++++++++ tests/server/api/helcim-payment.test.js | 12 ++--- 6 files changed, 68 insertions(+), 56 deletions(-) create mode 100644 server/utils/loadSeries.js diff --git a/server/api/helcim/initialize-payment.post.js b/server/api/helcim/initialize-payment.post.js index 053b63d..a01b8d0 100644 --- a/server/api/helcim/initialize-payment.post.js +++ b/server/api/helcim/initialize-payment.post.js @@ -1,6 +1,6 @@ import Member from '../../models/member.js' -import Series from '../../models/series.js' import { loadPublicEvent } from '../../utils/loadEvent.js' +import { loadPublicSeries } from '../../utils/loadSeries.js' import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js' import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js' import { initializeHelcimPaySession } from '../../utils/helcim.js' @@ -55,14 +55,7 @@ export default defineEventHandler(async (event) => { if (!seriesId) { throw createError({ statusCode: 400, statusMessage: 'metadata.seriesId is required for series_ticket' }) } - const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId) - const seriesQuery = isObjectId - ? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] } - : { $or: [{ id: seriesId }, { slug: seriesId }] } - const series = await Series.findOne(seriesQuery) - if (!series) { - throw createError({ statusCode: 404, statusMessage: 'Series not found' }) - } + const series = await loadPublicSeries(event, seriesId) const ticketInfo = calculateSeriesTicketPrice(series, accessMember) if (!ticketInfo) { throw createError({ statusCode: 403, statusMessage: 'No series passes available for your membership status' }) diff --git a/server/api/series/[id].get.js b/server/api/series/[id].get.js index be18bdc..000f9b1 100644 --- a/server/api/series/[id].get.js +++ b/server/api/series/[id].get.js @@ -1,5 +1,5 @@ import Event from "../../models/event.js"; -import Series from "../../models/series.js"; +import { loadPublicSeries } from "../../utils/loadSeries.js"; import { connectDB } from "../../utils/mongoose.js"; export default defineEventHandler(async (event) => { @@ -15,16 +15,14 @@ export default defineEventHandler(async (event) => { }); } - // Try to fetch the Series model first for full ticketing info - // Build query conditions based on whether id looks like ObjectId or string - const isObjectId = /^[0-9a-fA-F]{24}$/.test(id); - const seriesQuery = isObjectId - ? { $or: [{ _id: id }, { id: id }, { slug: id }] } - : { $or: [{ id: id }, { slug: id }] }; - - const seriesModel = await Series.findOne(seriesQuery) - .select("-registrations") // Don't expose registration details - .lean(); + // Try to fetch the Series model first for full ticketing info. + // Legacy series may exist only as event metadata (no Series doc), so we + // fall through to the events-based path below when no Series doc matches. + const seriesModel = await loadPublicSeries(event, id, { + select: "-registrations", // Don't expose registration details + lean: true, + allowMissing: true, + }); // Fetch all events in this series const events = await Event.find({ diff --git a/server/api/series/[id]/tickets/available.get.js b/server/api/series/[id]/tickets/available.get.js index cf438ad..f7f0b0b 100644 --- a/server/api/series/[id]/tickets/available.get.js +++ b/server/api/series/[id]/tickets/available.get.js @@ -1,5 +1,5 @@ -import Series from "../../../../models/series.js"; import Member from "../../../../models/member.js"; +import { loadPublicSeries } from "../../../../utils/loadSeries.js"; import { calculateSeriesTicketPrice, checkSeriesTicketAvailability, @@ -13,20 +13,7 @@ export default defineEventHandler(async (event) => { const email = query.email; // Fetch series - // Build query conditions based on whether seriesId looks like ObjectId or string - const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId); - const seriesQuery = isObjectId - ? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] } - : { $or: [{ id: seriesId }, { slug: seriesId }] }; - - const series = await Series.findOne(seriesQuery); - - if (!series) { - throw createError({ - statusCode: 404, - statusMessage: "Series not found", - }); - } + const series = await loadPublicSeries(event, seriesId); // Check if tickets are enabled if (!series.tickets?.enabled) { diff --git a/server/api/series/[id]/tickets/purchase.post.js b/server/api/series/[id]/tickets/purchase.post.js index 0f27b7b..c21d1a9 100644 --- a/server/api/series/[id]/tickets/purchase.post.js +++ b/server/api/series/[id]/tickets/purchase.post.js @@ -1,6 +1,6 @@ -import Series from "../../../../models/series.js"; import Event from "../../../../models/event.js"; import Member from "../../../../models/member.js"; +import { loadPublicSeries } from "../../../../utils/loadSeries.js"; import { validateSeriesTicketPurchase, calculateSeriesTicketPrice, @@ -19,20 +19,7 @@ export default defineEventHandler(async (event) => { const { name, email, paymentId } = body; // Fetch series - // Build query conditions based on whether seriesId looks like ObjectId or string - const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId); - const seriesQuery = isObjectId - ? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] } - : { $or: [{ id: seriesId }, { slug: seriesId }] }; - - const series = await Series.findOne(seriesQuery); - - if (!series) { - throw createError({ - statusCode: 404, - statusMessage: "Series not found", - }); - } + const series = await loadPublicSeries(event, seriesId); // Check membership — prefer JWT auth for accurate member pricing. // Only members with access (active or pending_payment) get member-tier diff --git a/server/utils/loadSeries.js b/server/utils/loadSeries.js new file mode 100644 index 0000000..33d9018 --- /dev/null +++ b/server/utils/loadSeries.js @@ -0,0 +1,47 @@ +import Series from '../models/series.js' +import { connectDB } from './mongoose.js' + +/** + * Load a series by ObjectId, string id, or slug for public endpoints. + * Series has three identifier fields (`_id`, `id`, `slug`); this helper + * builds the same conditional `$or` query the call sites would otherwise + * inline. No isVisible gate today (parity with existing call-site behavior). + * + * @param {Object} reqEvent - h3 event (reserved for future auth/cookie access) + * @param {String} identifier - ObjectId string, string id, or slug + * @param {Object} [options] + * @param {Boolean} [options.lean] - apply .lean() to the query + * @param {String} [options.select] - apply .select() to the query + * @param {Boolean} [options.allowMissing] - return null instead of throwing 404 on miss + * @returns {Promise} the series document, or null if allowMissing and not found + */ +export async function loadPublicSeries(reqEvent, identifier, options = {}) { + if (!identifier) { + throw createError({ + statusCode: 400, + statusMessage: 'Series identifier is required' + }) + } + + await connectDB() + + const { lean = false, select = null, allowMissing = false } = options + + const isObjectId = /^[0-9a-fA-F]{24}$/.test(identifier) + const seriesQuery = isObjectId + ? { $or: [{ _id: identifier }, { id: identifier }, { slug: identifier }] } + : { $or: [{ id: identifier }, { slug: identifier }] } + + let query = Series.findOne(seriesQuery) + if (select) query = query.select(select) + if (lean) query = query.lean() + + const series = await query + + if (!series) { + if (allowMissing) return null + throw createError({ statusCode: 404, statusMessage: 'Series not found' }) + } + + return series +} diff --git a/tests/server/api/helcim-payment.test.js b/tests/server/api/helcim-payment.test.js index ff21f93..d5df7f6 100644 --- a/tests/server/api/helcim-payment.test.js +++ b/tests/server/api/helcim-payment.test.js @@ -3,9 +3,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js' import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js' import { loadPublicEvent } from '../../../server/utils/loadEvent.js' +import { loadPublicSeries } from '../../../server/utils/loadSeries.js' import { PAYMENT_METADATA_TYPES } from '../../../server/utils/paymentTypes.js' import Member from '../../../server/models/member.js' -import Series from '../../../server/models/series.js' import initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js' import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' @@ -17,8 +17,8 @@ vi.mock('../../../server/utils/auth.js', () => ({ vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() })) vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} })) vi.mock('../../../server/utils/loadEvent.js', () => ({ loadPublicEvent: vi.fn() })) +vi.mock('../../../server/utils/loadSeries.js', () => ({ loadPublicSeries: vi.fn() })) vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn() } })) -vi.mock('../../../server/models/series.js', () => ({ default: { findOne: vi.fn() } })) // helcimInitializePaymentSchema is a Nitro auto-import used by validateBody vi.stubGlobal('helcimInitializePaymentSchema', {}) @@ -34,7 +34,7 @@ describe('initialize-payment endpoint', () => { vi.clearAllMocks() getOptionalMember.mockResolvedValue(null) Member.findOne.mockResolvedValue(null) - Series.findOne.mockResolvedValue(null) + loadPublicSeries.mockResolvedValue(null) }) afterEach(() => { @@ -188,13 +188,13 @@ describe('initialize-payment endpoint', () => { }) }) - it('re-derives series_ticket price via Series.findOne + calculateSeriesTicketPrice', async () => { + it('re-derives series_ticket price via loadPublicSeries + calculateSeriesTicketPrice', async () => { const body = { amount: 100, // tampered metadata: { type: 'series_ticket', seriesId: 'ser-x' } } globalThis.validateBody.mockResolvedValue(body) - Series.findOne.mockResolvedValue({ + loadPublicSeries.mockResolvedValue({ _id: 'ser-x', title: 'Coop Foundations', tickets: { enabled: true, public: { available: true, price: 7500 } } @@ -219,7 +219,7 @@ describe('initialize-payment endpoint', () => { expect(sentBody.amount).toBe(7500) expect(sentBody.paymentType).toBe('purchase') expect(result.amount).toBe(7500) - expect(Series.findOne).toHaveBeenCalled() + expect(loadPublicSeries).toHaveBeenCalled() }) it('uses member pricing when metadata.email matches an active member', async () => {