Skip to main content
Ticket Hub provides a comprehensive event management system for event organizers to create, update, and cancel events with full control over ticketing.

Event Schema

Events are stored in the Convex database with the following schema:
events: defineTable({
  name: v.string(),
  description: v.string(),
  location: v.string(),
  eventDate: v.number(),
  price: v.number(),
  totalTickets: v.number(),
  userId: v.string(),
  imageStorageId: v.optional(v.id("_storage")),
  is_cancelled: v.optional(v.boolean()),
})

Creating Events

Event creation follows a structured workflow that validates all required fields and handles image uploads.
1

Fill Event Details

Users provide essential event information including name, description, location, date, price, and total tickets available.The form uses Zod validation to ensure data integrity:
const formSchema = z.object({
  name: z.string().min(1, "Event name is required"),
  description: z.string().min(1, "Description is required"),
  location: z.string().min(1, "Location is required"),
  eventDate: z.date().min(
    new Date(new Date().setHours(0, 0, 0, 0)),
    "Event date must be in the future"
  ),
  price: z.number().min(0, "Price must be 0 or greater"),
  totalTickets: z.number().min(1, "Must have at least 1 ticket"),
});
2

Upload Event Image (Optional)

Organizers can upload an event image that will be displayed on the event page and ticket.The image upload process:
  1. Generate an upload URL from Convex storage
  2. Upload the file to Convex
  3. Store the storage ID reference in the event record
3

Submit and Create

The event is created in the database and the organizer is redirected to the event page.

Updating Events

Organizers can only update events that they created. The system validates ownership before allowing modifications.

Update Constraints

When updating an event, the following rules apply:
  • Total Tickets: Cannot be reduced below the number of tickets already sold
  • Price: Can be changed but doesn’t affect existing purchases
  • Date: Can be rescheduled to any future date
  • Image: Can be added, replaced, or removed
export const updateEvent = mutation({
  args: {
    eventId: v.id("events"),
    name: v.string(),
    description: v.string(),
    location: v.string(),
    eventDate: v.number(),
    price: v.number(),
    totalTickets: v.number(),
  },
  handler: async (ctx, args) => {
    const { eventId, ...updates } = args;

    // Get current event to check tickets sold
    const event = await ctx.db.get(eventId);
    if (!event) throw new Error("Event not found");

    const soldTickets = await ctx.db
      .query("tickets")
      .withIndex("by_event", (q) => q.eq("eventId", eventId))
      .filter((q) =>
        q.or(q.eq(q.field("status"), "valid"), q.eq(q.field("status"), "used"))
      )
      .collect();

    // Ensure new total tickets is not less than sold tickets
    if (updates.totalTickets < soldTickets.length) {
      throw new Error(
        `Cannot reduce total tickets below ${soldTickets.length} (number of tickets already sold)`
      );
    }

    await ctx.db.patch(eventId, updates);
    return eventId;
  },
});

Image Upload Functionality

Event images are stored in Convex’s built-in file storage system.
1

Generate Upload URL

Request a temporary upload URL from Convex:
const postUrl = await generateUploadUrl();
2

Upload File

POST the image file to the generated URL:
const result = await fetch(postUrl, {
  method: "POST",
  headers: { "Content-Type": file.type },
  body: file,
});
const { storageId } = await result.json();
3

Link to Event

Store the storage ID in the event record:
await updateEventImage({
  eventId,
  storageId: imageStorageId as Id<"_storage">,
});

Image Preview Component

From src/components/event-form.tsx:324-346:
{imagePreview || (!removedCurrentImage && currentImageUrl) ? (
  <div className="relative w-32 aspect-square bg-gray-100 rounded-lg">
    <Image
      src={imagePreview || currentImageUrl!}
      alt="Preview"
      fill
      className="object-contain rounded-lg"
    />
    <button
      type="button"
      onClick={() => {
        setSelectedImage(null);
        setImagePreview(null);
        setRemovedCurrentImage(true);
        if (imageInput.current) {
          imageInput.current.value = "";
        }
      }}
      className="absolute cursor-pointer -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6"
    >
      ×
    </button>
  </div>
) : (
  <input
    type="file"
    accept="image/*"
    onChange={handleImageChange}
    ref={imageInput}
  />
)}

Cancelling Events

Event cancellation is permanent and requires that all tickets be refunded first.

Cancellation Requirements

All valid and used tickets must be refunded before an event can be cancelled. This protects both buyers and sellers.
const tickets = await ctx.db
  .query("tickets")
  .withIndex("by_event", (q) => q.eq("eventId", eventId))
  .filter((q) =>
    q.or(q.eq(q.field("status"), "valid"), q.eq(q.field("status"), "used"))
  )
  .collect();

if (tickets.length > 0) {
  throw new Error(
    "Cannot cancel event with active tickets. Please refund all tickets first."
  );
}
When an event is cancelled, all waiting list entries are automatically deleted:
const waitingListEntries = await ctx.db
  .query("waitingList")
  .withIndex("by_event_status", (q) => q.eq("eventId", eventId))
  .collect();

for (const entry of waitingListEntries) {
  await ctx.db.delete(entry._id);
}

Cancel Event Mutation

From convex/events.ts:468-506:
export const cancelEvent = mutation({
  args: { eventId: v.id("events") },
  handler: async (ctx, { eventId }) => {
    const event = await ctx.db.get(eventId);
    if (!event) throw new Error("Event not found");

    // Get all valid tickets for this event
    const tickets = await ctx.db
      .query("tickets")
      .withIndex("by_event", (q) => q.eq("eventId", eventId))
      .filter((q) =>
        q.or(q.eq(q.field("status"), "valid"), q.eq(q.field("status"), "used"))
      )
      .collect();

    if (tickets.length > 0) {
      throw new Error(
        "Cannot cancel event with active tickets. Please refund all tickets first."
      );
    }

    // Mark event as cancelled
    await ctx.db.patch(eventId, {
      is_cancelled: true,
    });

    // Delete any waiting list entries
    const waitingListEntries = await ctx.db
      .query("waitingList")
      .withIndex("by_event_status", (q) => q.eq("eventId", eventId))
      .collect();

    for (const entry of waitingListEntries) {
      await ctx.db.delete(entry._id);
    }

    return { success: true };
  },
});

Event Queries

Get All Events

Retrieve all non-cancelled events:
export const get = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db
      .query("events")
      .filter((q) => q.eq(q.field("is_cancelled"), undefined))
      .collect();
  },
});

Search Events

Search by name, description, or location (convex/events.ts:370-387):
export const search = query({
  args: { searchTerm: v.string() },
  handler: async (ctx, { searchTerm }) => {
    const events = await ctx.db
      .query("events")
      .filter((q) => q.eq(q.field("is_cancelled"), undefined))
      .collect();

    return events.filter((event) => {
      const searchTermLower = searchTerm.toLowerCase();
      return (
        event.name.toLowerCase().includes(searchTermLower) ||
        event.description.toLowerCase().includes(searchTermLower) ||
        event.location.toLowerCase().includes(searchTermLower)
      );
    });
  },
});

Next Steps

Ticket Purchasing

Learn how users purchase tickets for events

Seller Dashboard

Track event performance and revenue

Build docs developers (and LLMs) love