refactor(series): extract loadPublicSeries helper
This commit is contained in:
parent
bd4561fea7
commit
5f93d4c2e3
6 changed files with 68 additions and 56 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import Series from '../../models/series.js'
|
|
||||||
import { loadPublicEvent } from '../../utils/loadEvent.js'
|
import { loadPublicEvent } from '../../utils/loadEvent.js'
|
||||||
|
import { loadPublicSeries } from '../../utils/loadSeries.js'
|
||||||
import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js'
|
import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js'
|
||||||
import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js'
|
import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js'
|
||||||
import { initializeHelcimPaySession } from '../../utils/helcim.js'
|
import { initializeHelcimPaySession } from '../../utils/helcim.js'
|
||||||
|
|
@ -55,14 +55,7 @@ export default defineEventHandler(async (event) => {
|
||||||
if (!seriesId) {
|
if (!seriesId) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'metadata.seriesId is required for series_ticket' })
|
throw createError({ statusCode: 400, statusMessage: 'metadata.seriesId is required for series_ticket' })
|
||||||
}
|
}
|
||||||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId)
|
const series = await loadPublicSeries(event, 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 ticketInfo = calculateSeriesTicketPrice(series, accessMember)
|
const ticketInfo = calculateSeriesTicketPrice(series, accessMember)
|
||||||
if (!ticketInfo) {
|
if (!ticketInfo) {
|
||||||
throw createError({ statusCode: 403, statusMessage: 'No series passes available for your membership status' })
|
throw createError({ statusCode: 403, statusMessage: 'No series passes available for your membership status' })
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Event from "../../models/event.js";
|
import Event from "../../models/event.js";
|
||||||
import Series from "../../models/series.js";
|
import { loadPublicSeries } from "../../utils/loadSeries.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
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
|
// Try to fetch the Series model first for full ticketing info.
|
||||||
// Build query conditions based on whether id looks like ObjectId or string
|
// Legacy series may exist only as event metadata (no Series doc), so we
|
||||||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(id);
|
// fall through to the events-based path below when no Series doc matches.
|
||||||
const seriesQuery = isObjectId
|
const seriesModel = await loadPublicSeries(event, id, {
|
||||||
? { $or: [{ _id: id }, { id: id }, { slug: id }] }
|
select: "-registrations", // Don't expose registration details
|
||||||
: { $or: [{ id: id }, { slug: id }] };
|
lean: true,
|
||||||
|
allowMissing: true,
|
||||||
const seriesModel = await Series.findOne(seriesQuery)
|
});
|
||||||
.select("-registrations") // Don't expose registration details
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
// Fetch all events in this series
|
// Fetch all events in this series
|
||||||
const events = await Event.find({
|
const events = await Event.find({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Series from "../../../../models/series.js";
|
|
||||||
import Member from "../../../../models/member.js";
|
import Member from "../../../../models/member.js";
|
||||||
|
import { loadPublicSeries } from "../../../../utils/loadSeries.js";
|
||||||
import {
|
import {
|
||||||
calculateSeriesTicketPrice,
|
calculateSeriesTicketPrice,
|
||||||
checkSeriesTicketAvailability,
|
checkSeriesTicketAvailability,
|
||||||
|
|
@ -13,20 +13,7 @@ export default defineEventHandler(async (event) => {
|
||||||
const email = query.email;
|
const email = query.email;
|
||||||
|
|
||||||
// Fetch series
|
// Fetch series
|
||||||
// Build query conditions based on whether seriesId looks like ObjectId or string
|
const series = await loadPublicSeries(event, seriesId);
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if tickets are enabled
|
// Check if tickets are enabled
|
||||||
if (!series.tickets?.enabled) {
|
if (!series.tickets?.enabled) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Series from "../../../../models/series.js";
|
|
||||||
import Event from "../../../../models/event.js";
|
import Event from "../../../../models/event.js";
|
||||||
import Member from "../../../../models/member.js";
|
import Member from "../../../../models/member.js";
|
||||||
|
import { loadPublicSeries } from "../../../../utils/loadSeries.js";
|
||||||
import {
|
import {
|
||||||
validateSeriesTicketPurchase,
|
validateSeriesTicketPurchase,
|
||||||
calculateSeriesTicketPrice,
|
calculateSeriesTicketPrice,
|
||||||
|
|
@ -19,20 +19,7 @@ export default defineEventHandler(async (event) => {
|
||||||
const { name, email, paymentId } = body;
|
const { name, email, paymentId } = body;
|
||||||
|
|
||||||
// Fetch series
|
// Fetch series
|
||||||
// Build query conditions based on whether seriesId looks like ObjectId or string
|
const series = await loadPublicSeries(event, seriesId);
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check membership — prefer JWT auth for accurate member pricing.
|
// Check membership — prefer JWT auth for accurate member pricing.
|
||||||
// Only members with access (active or pending_payment) get member-tier
|
// Only members with access (active or pending_payment) get member-tier
|
||||||
|
|
|
||||||
47
server/utils/loadSeries.js
Normal file
47
server/utils/loadSeries.js
Normal file
|
|
@ -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<Object|null>} 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
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js'
|
import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js'
|
||||||
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
|
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
|
||||||
import { loadPublicEvent } from '../../../server/utils/loadEvent.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 { PAYMENT_METADATA_TYPES } from '../../../server/utils/paymentTypes.js'
|
||||||
import Member from '../../../server/models/member.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 initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js'
|
||||||
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
|
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.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/validateBody.js', () => ({ validateBody: vi.fn() }))
|
||||||
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
|
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
|
||||||
vi.mock('../../../server/utils/loadEvent.js', () => ({ loadPublicEvent: vi.fn() }))
|
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/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
|
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
|
||||||
vi.stubGlobal('helcimInitializePaymentSchema', {})
|
vi.stubGlobal('helcimInitializePaymentSchema', {})
|
||||||
|
|
@ -34,7 +34,7 @@ describe('initialize-payment endpoint', () => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
getOptionalMember.mockResolvedValue(null)
|
getOptionalMember.mockResolvedValue(null)
|
||||||
Member.findOne.mockResolvedValue(null)
|
Member.findOne.mockResolvedValue(null)
|
||||||
Series.findOne.mockResolvedValue(null)
|
loadPublicSeries.mockResolvedValue(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
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 = {
|
const body = {
|
||||||
amount: 100, // tampered
|
amount: 100, // tampered
|
||||||
metadata: { type: 'series_ticket', seriesId: 'ser-x' }
|
metadata: { type: 'series_ticket', seriesId: 'ser-x' }
|
||||||
}
|
}
|
||||||
globalThis.validateBody.mockResolvedValue(body)
|
globalThis.validateBody.mockResolvedValue(body)
|
||||||
Series.findOne.mockResolvedValue({
|
loadPublicSeries.mockResolvedValue({
|
||||||
_id: 'ser-x',
|
_id: 'ser-x',
|
||||||
title: 'Coop Foundations',
|
title: 'Coop Foundations',
|
||||||
tickets: { enabled: true, public: { available: true, price: 7500 } }
|
tickets: { enabled: true, public: { available: true, price: 7500 } }
|
||||||
|
|
@ -219,7 +219,7 @@ describe('initialize-payment endpoint', () => {
|
||||||
expect(sentBody.amount).toBe(7500)
|
expect(sentBody.amount).toBe(7500)
|
||||||
expect(sentBody.paymentType).toBe('purchase')
|
expect(sentBody.paymentType).toBe('purchase')
|
||||||
expect(result.amount).toBe(7500)
|
expect(result.amount).toBe(7500)
|
||||||
expect(Series.findOne).toHaveBeenCalled()
|
expect(loadPublicSeries).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses member pricing when metadata.email matches an active member', async () => {
|
it('uses member pricing when metadata.email matches an active member', async () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue