Add series management and ticketing features: Introduce series event functionality in event creation, enhance event display with series information, and implement ticketing options for public events. Update layouts and improve form handling for better user experience.

This commit is contained in:
Jennie Robinson Faber 2025-08-27 20:40:54 +01:00
parent c3a29fa47c
commit a88aa62198
24 changed files with 2897 additions and 44 deletions

View file

@ -29,13 +29,32 @@ export default defineEventHandler(async (event) => {
await connectDB()
const newEvent = new Event({
const eventData = {
...body,
createdBy: 'admin@ghostguild.org', // TODO: Use actual authenticated user
startDate: new Date(body.startDate),
endDate: new Date(body.endDate),
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null
})
}
// Handle ticket data
if (body.tickets) {
eventData.tickets = {
enabled: body.tickets.enabled || false,
public: {
available: body.tickets.public?.available || false,
name: body.tickets.public?.name || 'Public Ticket',
description: body.tickets.public?.description || '',
price: body.tickets.public?.price || 0,
quantity: body.tickets.public?.quantity || null,
sold: 0, // Initialize sold count
earlyBirdPrice: body.tickets.public?.earlyBirdPrice || null,
earlyBirdDeadline: body.tickets.public?.earlyBirdDeadline ? new Date(body.tickets.public.earlyBirdDeadline) : null
}
}
}
const newEvent = new Event(eventData)
const savedEvent = await newEvent.save()

View file

@ -37,6 +37,23 @@ export default defineEventHandler(async (event) => {
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null,
updatedAt: new Date()
}
// Handle ticket data
if (body.tickets) {
updateData.tickets = {
enabled: body.tickets.enabled || false,
public: {
available: body.tickets.public?.available || false,
name: body.tickets.public?.name || 'Public Ticket',
description: body.tickets.public?.description || '',
price: body.tickets.public?.price || 0,
quantity: body.tickets.public?.quantity || null,
sold: body.tickets.public?.sold || 0,
earlyBirdPrice: body.tickets.public?.earlyBirdPrice || null,
earlyBirdDeadline: body.tickets.public?.earlyBirdDeadline ? new Date(body.tickets.public.earlyBirdDeadline) : null
}
}
}
const updatedEvent = await Event.findByIdAndUpdate(
eventId,

View file

@ -0,0 +1,60 @@
import Series from '../../models/series.js'
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
// Fetch all series
const series = await Series.find({ isActive: true })
.sort({ createdAt: -1 })
.lean()
// For each series, get event count and statistics
const seriesWithStats = await Promise.all(
series.map(async (s) => {
const events = await Event.find({
'series.id': s.id,
'series.isSeriesEvent': true
}).select('_id startDate endDate registrations').lean()
const now = new Date()
const eventCount = events.length
const completedEvents = events.filter(e => e.endDate < now).length
const upcomingEvents = events.filter(e => e.startDate > now).length
const firstEventDate = events.length > 0 ?
Math.min(...events.map(e => new Date(e.startDate))) : null
const lastEventDate = events.length > 0 ?
Math.max(...events.map(e => new Date(e.endDate))) : null
let status = 'upcoming'
if (lastEventDate && lastEventDate < now) {
status = 'completed'
} else if (firstEventDate && firstEventDate <= now && lastEventDate && lastEventDate >= now) {
status = 'active'
}
return {
...s,
eventCount,
completedEvents,
upcomingEvents,
startDate: firstEventDate,
endDate: lastEventDate,
status,
totalRegistrations: events.reduce((sum, e) => sum + (e.registrations?.length || 0), 0)
}
})
)
return seriesWithStats
} catch (error) {
console.error('Error fetching series:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch series'
})
}
})

View file

@ -0,0 +1,49 @@
import Series from '../../models/series.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const body = await readBody(event)
// Validate required fields
if (!body.id || !body.title || !body.description) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID, title, and description are required'
})
}
// Create new series
const newSeries = new Series({
id: body.id,
title: body.title,
description: body.description,
type: body.type || 'workshop_series',
totalEvents: body.totalEvents || null,
createdBy: 'admin' // TODO: Get from authentication
})
await newSeries.save()
return {
success: true,
data: newSeries
}
} catch (error) {
console.error('Error creating series:', error)
if (error.code === 11000) {
throw createError({
statusCode: 400,
statusMessage: 'A series with this ID already exists'
})
}
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to create series'
})
}
})

View file

@ -0,0 +1,58 @@
import Series from '../../../models/series.js'
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID is required'
})
}
// Find the series
const series = await Series.findOne({ id: id })
if (!series) {
throw createError({
statusCode: 404,
statusMessage: 'Series not found'
})
}
// Remove series relationship from all related events
await Event.updateMany(
{ 'series.id': id, 'series.isSeriesEvent': true },
{
$set: {
'series.isSeriesEvent': false,
'series.id': '',
'series.title': '',
'series.description': '',
'series.type': 'workshop_series',
'series.position': 1,
'series.totalEvents': null
}
}
)
// Delete the series
await Series.deleteOne({ id: id })
return {
success: true,
message: 'Series deleted and events converted to standalone events'
}
} catch (error) {
console.error('Error deleting series:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to delete series'
})
}
})

View file

@ -0,0 +1,62 @@
import Series from '../../../models/series.js'
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const id = getRouterParam(event, 'id')
const body = await readBody(event)
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID is required'
})
}
// Find and update the series
const series = await Series.findOne({ id: id })
if (!series) {
throw createError({
statusCode: 404,
statusMessage: 'Series not found'
})
}
// Update series fields
if (body.title !== undefined) series.title = body.title
if (body.description !== undefined) series.description = body.description
if (body.type !== undefined) series.type = body.type
if (body.totalEvents !== undefined) series.totalEvents = body.totalEvents
if (body.isActive !== undefined) series.isActive = body.isActive
await series.save()
// Also update all related events with the new series information
await Event.updateMany(
{ 'series.id': id, 'series.isSeriesEvent': true },
{
$set: {
'series.title': series.title,
'series.description': series.description,
'series.type': series.type,
'series.totalEvents': series.totalEvents
}
}
)
return {
success: true,
data: series
}
} catch (error) {
console.error('Error updating series:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to update series'
})
}
})

View file

@ -0,0 +1,112 @@
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
import mongoose from 'mongoose'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const identifier = getRouterParam(event, 'id')
const body = await readBody(event)
if (!identifier) {
throw createError({
statusCode: 400,
statusMessage: 'Event identifier is required'
})
}
// Validate required fields for guest registration
if (!body.name || !body.email) {
throw createError({
statusCode: 400,
statusMessage: 'Name and email are required'
})
}
// Fetch the event
let eventData
if (mongoose.Types.ObjectId.isValid(identifier)) {
eventData = await Event.findById(identifier)
}
if (!eventData) {
eventData = await Event.findOne({ slug: identifier })
}
if (!eventData) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
})
}
// Check if event allows public registration (not members-only)
if (eventData.membersOnly) {
throw createError({
statusCode: 403,
statusMessage: 'This event is for members only. Please become a member to register.'
})
}
// If event requires payment, reject guest registration
if (eventData.pricing.paymentRequired && !eventData.pricing.isFree) {
throw createError({
statusCode: 402,
statusMessage: 'This event requires payment. Please use the payment registration endpoint.'
})
}
// Check if event is full
if (eventData.maxAttendees && eventData.registrations.length >= eventData.maxAttendees) {
throw createError({
statusCode: 400,
statusMessage: 'Event is full'
})
}
// Check if already registered
const alreadyRegistered = eventData.registrations.some(
reg => reg.email.toLowerCase() === body.email.toLowerCase()
)
if (alreadyRegistered) {
throw createError({
statusCode: 400,
statusMessage: 'You are already registered for this event'
})
}
// Add guest registration
eventData.registrations.push({
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: 'guest',
isMember: false,
paymentStatus: 'not_required',
amountPaid: 0,
registeredAt: new Date()
})
await eventData.save()
// TODO: Send confirmation email for guest registration
return {
success: true,
message: 'Successfully registered as guest',
registrationId: eventData.registrations[eventData.registrations.length - 1]._id,
note: 'As a guest, you have access to this free public event. Consider becoming a member for access to all events!'
}
} catch (error) {
console.error('Error with guest registration:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to register as guest'
})
}
})

View file

@ -0,0 +1,136 @@
import Event from '../../../models/event.js'
import Member from '../../../models/member.js'
import { connectDB } from '../../../utils/mongoose.js'
import { processHelcimPayment } from '../../../utils/helcim.js'
import mongoose from 'mongoose'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const identifier = getRouterParam(event, 'id')
const body = await readBody(event)
if (!identifier) {
throw createError({
statusCode: 400,
statusMessage: 'Event identifier is required'
})
}
// Validate required payment fields
if (!body.name || !body.email || !body.paymentToken) {
throw createError({
statusCode: 400,
statusMessage: 'Name, email, and payment token are required'
})
}
// Fetch the event
let eventData
if (mongoose.Types.ObjectId.isValid(identifier)) {
eventData = await Event.findById(identifier)
}
if (!eventData) {
eventData = await Event.findOne({ slug: identifier })
}
if (!eventData) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
})
}
// Check if event requires payment
if (eventData.pricing.isFree || !eventData.pricing.paymentRequired) {
throw createError({
statusCode: 400,
statusMessage: 'This event does not require payment'
})
}
// Check if user is already registered
const existingRegistration = eventData.registrations.find(
reg => reg.email.toLowerCase() === body.email.toLowerCase()
)
if (existingRegistration) {
throw createError({
statusCode: 400,
statusMessage: 'You are already registered for this event'
})
}
// Check if user is a member (members get free access)
const member = await Member.findOne({ email: body.email.toLowerCase() })
if (member) {
// Members get free access - register directly without payment
eventData.registrations.push({
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: `${member.circle}-${member.contributionTier}`,
isMember: true,
paymentStatus: 'not_required',
amountPaid: 0
})
await eventData.save()
return {
success: true,
message: 'Successfully registered as a member (no payment required)',
registration: eventData.registrations[eventData.registrations.length - 1]
}
}
// Process payment for non-members
const paymentResult = await processHelcimPayment({
amount: eventData.pricing.publicPrice,
paymentToken: body.paymentToken,
customerData: {
name: body.name,
email: body.email
}
})
if (!paymentResult.success) {
throw createError({
statusCode: 400,
statusMessage: paymentResult.message || 'Payment failed'
})
}
// Add registration with successful payment
eventData.registrations.push({
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: 'non-member',
isMember: false,
paymentStatus: 'completed',
paymentId: paymentResult.transactionId,
amountPaid: eventData.pricing.publicPrice
})
await eventData.save()
return {
success: true,
message: 'Payment successful and registered for event',
paymentId: paymentResult.transactionId,
registration: eventData.registrations[eventData.registrations.length - 1]
}
} catch (error) {
console.error('Error processing event payment:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to process payment and registration'
})
}
})

View file

@ -65,27 +65,41 @@ export default defineEventHandler(async (event) => {
})
}
// Check member status if event is members-only
if (eventData.membersOnly && body.membershipLevel === 'non-member') {
// Check if email belongs to a member
const member = await Member.findOne({ email: body.email.toLowerCase() })
if (!member) {
throw createError({
statusCode: 403,
statusMessage: 'This event is for members only. Please become a member to register.'
})
}
// Update membership level from database
body.membershipLevel = `${member.circle}-${member.contributionTier}`
// Check member status and handle different registration scenarios
const member = await Member.findOne({ email: body.email.toLowerCase() })
if (eventData.membersOnly && !member) {
throw createError({
statusCode: 403,
statusMessage: 'This event is for members only. Please become a member to register.'
})
}
// If event requires payment and user is not a member, redirect to payment flow
if (eventData.pricing.paymentRequired && !eventData.pricing.isFree && !member) {
throw createError({
statusCode: 402, // Payment Required
statusMessage: 'This event requires payment. Please use the payment registration endpoint.'
})
}
// Set member status and membership level
let isMember = false
let membershipLevel = 'non-member'
if (member) {
isMember = true
membershipLevel = `${member.circle}-${member.contributionTier}`
}
// Add registration
eventData.registrations.push({
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: body.membershipLevel || 'non-member',
membershipLevel,
isMember,
paymentStatus: 'not_required', // Free events or member registrations
amountPaid: 0,
dietary: body.dietary || false,
registeredAt: new Date()
})

View file

@ -0,0 +1,78 @@
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID is required'
})
}
// Fetch all events in this series
const events = await Event.find({
'series.id': id,
'series.isSeriesEvent': true
})
.sort({ 'series.position': 1, startDate: 1 })
.select('-registrations')
.lean()
if (events.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'Event series not found'
})
}
// Get series metadata from the first event
const seriesInfo = events[0].series
// Calculate series statistics
const now = new Date()
const completedEvents = events.filter(e => e.endDate < now).length
const upcomingEvents = events.filter(e => e.startDate > now).length
const ongoingEvents = events.filter(e => e.startDate <= now && e.endDate >= now).length
const firstEventDate = events[0].startDate
const lastEventDate = events[events.length - 1].endDate
// Return series with additional metadata
return {
id: id,
title: seriesInfo.title,
description: seriesInfo.description,
type: seriesInfo.type,
totalEvents: seriesInfo.totalEvents,
startDate: firstEventDate,
endDate: lastEventDate,
events: events.map(e => ({
...e,
id: e._id.toString()
})),
statistics: {
totalEvents: events.length,
completedEvents,
upcomingEvents,
ongoingEvents,
isOngoing: firstEventDate <= now && lastEventDate >= now,
isUpcoming: firstEventDate > now,
isCompleted: lastEventDate < now,
totalRegistrations: events.reduce((sum, e) => sum + (e.registrations?.length || 0), 0)
}
}
} catch (error) {
console.error('Error fetching event series:', error)
if (error.statusCode) throw error
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch event series'
})
}
})

View file

@ -0,0 +1,91 @@
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const query = getQuery(event)
// Build filter for series events only
const filter = {
'series.isSeriesEvent': true,
isVisible: query.includeHidden === 'true' ? { $exists: true } : true
}
// Filter by series type
if (query.seriesType) {
filter['series.type'] = query.seriesType
}
// Filter for upcoming series
if (query.upcoming === 'true') {
filter.startDate = { $gte: new Date() }
}
// Fetch all series events and group them by series.id
const events = await Event.find(filter)
.sort({ 'series.id': 1, 'series.position': 1, startDate: 1 })
.select('-registrations')
.lean()
// Group events by series ID
const seriesMap = new Map()
events.forEach(event => {
const seriesId = event.series?.id
if (!seriesId) return
if (!seriesMap.has(seriesId)) {
seriesMap.set(seriesId, {
id: seriesId,
title: event.series.title,
description: event.series.description,
type: event.series.type,
totalEvents: event.series.totalEvents,
events: [],
firstEventDate: event.startDate,
lastEventDate: event.endDate
})
}
const series = seriesMap.get(seriesId)
series.events.push({
...event,
id: event._id.toString()
})
// Update date range
if (event.startDate < series.firstEventDate) {
series.firstEventDate = event.startDate
}
if (event.endDate > series.lastEventDate) {
series.lastEventDate = event.endDate
}
})
// Convert to array and add computed fields
const seriesArray = Array.from(seriesMap.values()).map(series => {
const now = new Date()
return {
...series,
eventCount: series.events.length,
startDate: series.firstEventDate,
endDate: series.lastEventDate,
isOngoing: series.firstEventDate <= now && series.lastEventDate >= now,
isUpcoming: series.firstEventDate > now,
isCompleted: series.lastEventDate < now,
status: series.lastEventDate < now ? 'completed' :
series.firstEventDate <= now && series.lastEventDate >= now ? 'active' : 'upcoming'
}
})
return seriesArray
} catch (error) {
console.error('Error fetching event series:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch event series'
})
}
})

View file

@ -39,6 +39,41 @@ const eventSchema = new mongoose.Schema({
isCancelled: { type: Boolean, default: false },
cancellationMessage: String, // Custom message for cancelled events
membersOnly: { type: Boolean, default: false },
// Series information - embedded approach for better performance
series: {
id: String, // Simple string ID to group related events
title: String, // Series title (e.g., "Cooperative Game Development Workshop Series")
description: String, // Series description
type: {
type: String,
enum: ['workshop_series', 'recurring_meetup', 'multi_day', 'course', 'tournament'],
default: 'workshop_series'
},
position: Number, // Order within the series (e.g., 1 = first event, 2 = second, etc.)
totalEvents: Number, // Total planned events in the series
isSeriesEvent: { type: Boolean, default: false } // Flag to identify series events
},
// Event pricing for public attendees (deprecated - use tickets instead)
pricing: {
isFree: { type: Boolean, default: true },
publicPrice: { type: Number, default: 0 }, // Price for non-members
currency: { type: String, default: 'CAD' },
paymentRequired: { type: Boolean, default: false }
},
// Ticket configuration
tickets: {
enabled: { type: Boolean, default: false },
public: {
available: { type: Boolean, default: false },
name: { type: String, default: 'Public Ticket' },
description: String,
price: { type: Number, default: 0 },
quantity: Number, // null = unlimited
sold: { type: Number, default: 0 },
earlyBirdPrice: Number,
earlyBirdDeadline: Date
}
},
// Circle targeting
targetCircles: [{
type: String,
@ -58,6 +93,14 @@ const eventSchema = new mongoose.Schema({
name: String,
email: String,
membershipLevel: String,
isMember: { type: Boolean, default: false },
paymentStatus: {
type: String,
enum: ['pending', 'completed', 'failed', 'not_required'],
default: 'not_required'
},
paymentId: String, // Helcim transaction ID
amountPaid: { type: Number, default: 0 },
registeredAt: { type: Date, default: Date.now }
}],
createdBy: { type: String, required: true },

35
server/models/series.js Normal file
View file

@ -0,0 +1,35 @@
import mongoose from 'mongoose'
const seriesSchema = new mongoose.Schema({
id: {
type: String,
required: true,
unique: true,
validate: {
validator: function(v) {
return /^[a-z0-9-]+$/.test(v);
},
message: 'Series ID must contain only lowercase letters, numbers, and dashes'
}
},
title: { type: String, required: true },
description: { type: String, required: true },
type: {
type: String,
enum: ['workshop_series', 'recurring_meetup', 'multi_day', 'course', 'tournament'],
default: 'workshop_series'
},
totalEvents: Number,
isActive: { type: Boolean, default: true },
createdBy: { type: String, required: true },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
})
// Update the updatedAt field on save
seriesSchema.pre('save', function(next) {
this.updatedAt = new Date()
next()
})
export default mongoose.models.Series || mongoose.model('Series', seriesSchema)