Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Edfermachado/proyectoSistemas/llms.txt

Use this file to discover all available pages before exploring further.

When a user registers for an event in UniEvents, the system creates an attendee record that serves triple duty: it is the registration entry, the payment ledger item, and the ticket all at once. Each record carries a unique ticketToken (a UUID) that is encoded into a QR code displayed in the user’s profile. At the event door, an access_control or tenant_admin user scans that QR code through the built-in scanner, which marks the ticket as used and writes an immutable audit entry to the scan_logs table.

Attendee Registration

The attendees table is the heart of the ticketing system. Every registration — whether self-service by a student or manually entered by an admin — produces one row here.
FieldTypeNotes
iduuidPrimary key, auto-generated
eventIduuidFK → events.id (non-nullable)
namevarchar(255)Attendee’s full name
emailvarchar(255)Attendee’s email
phonevarchar(50)Attendee’s phone number
statusenumregistrado, confirmado, or pago_pendiente
attendeeTypeenumestudiante (default) or foraneo
userIduuidFK → users.id (nullable for admin-entered records)
ticketTokenuuidUnique token for the QR code, auto-generated
scannedAttimestampSet when the QR is scanned; null = not yet used
paymentReferencevarchar(50)Bank transfer or mobile payment reference number
paymentScreenshotUrlvarchar(500)URL of the uploaded payment proof image
paymentVerifiedByuuidFK → users.id — the admin who verified payment
paymentVerifiedAttimestampWhen the payment was verified
createdAttimestampAuto-set on creation
// src/db/schema.ts
export const attendees = pgTable('attendees', {
  id: uuid('id').primaryKey().defaultRandom(),
  eventId: uuid('event_id').references(() => events.id).notNull(),
  name: varchar('name', { length: 255 }).notNull(),
  email: varchar('email', { length: 255 }).notNull(),
  phone: varchar('phone', { length: 50 }).notNull(),
  status: registrationStatusEnum('status').default('registrado'),
  attendeeType: attendeeTypeEnum('attendee_type').default('estudiante'),
  userId: uuid('user_id').references(() => users.id),
  ticketToken: uuid('ticket_token').defaultRandom().unique(),
  scannedAt: timestamp('scanned_at'),
  paymentReference: varchar('payment_reference', { length: 50 }),
  paymentScreenshotUrl: varchar('payment_screenshot_url', { length: 500 }),
  paymentVerifiedBy: uuid('payment_verified_by').references(() => users.id),
  paymentVerifiedAt: timestamp('payment_verified_at'),
  createdAt: timestamp('created_at').defaultNow(),
});

export const registrationStatusEnum = pgEnum('registration_status', [
  'registrado',
  'confirmado',
  'pago_pendiente',
]);

export const attendeeTypeEnum = pgEnum('attendee_type', [
  'estudiante',
  'foraneo',
]);

Registration Status Flow

The status field drives the entire registration lifecycle. The path a registration takes depends on whether the event is free or paid. Free events — status is set to 'confirmado' automatically at the moment of registration:
User registers


 status: 'confirmado'   ← immediate, no admin action needed


  QR ticket ready
Paid events — status starts at 'pago_pendiente' and waits for admin review:
User registers + uploads payment proof


 status: 'pago_pendiente'

  tenant_admin reviews

  ┌───┴────┐
  ▼        ▼
'confirmado'  'registrado'   ← rejected; user must retry
The status decision logic lives in AttendeesService.registerAttendee:
// src/services/attendees.service.ts
let status = data.status;
if (!status) {
  const isFree =
    event.price === 'FREE' ||
    event.price === 'GRATIS' ||
    event.price === '0' ||
    !event.price;
  status = isFree ? 'confirmado' : 'pago_pendiente';
}
Admins can also register attendees manually via manualRegisterByAdmin. In that flow, the admin explicitly chooses the initial status — typically setting it to 'confirmado' immediately to skip the payment step for comped or VIP entries.

QR Ticket

Every attendee record is assigned a ticketToken — a randomly generated UUID that is unique across the entire attendees table. This token is the sole credential needed to gain entry to an event. The token is displayed as a QR code in the authenticated user’s profile page. At the event door, staff use the scanner interface at /faculty-admin/scanner to scan it. The scan endpoint performs three checks before granting access:
1

Token lookup

The system looks up the attendees record by ticketToken. A 404 response means the token does not exist.
2

Already-used check

If scannedAt is already set, the ticket has been used. The endpoint returns a 409 Conflict with the original scan timestamp — preventing re-entry with the same QR code.
3

Mark as used + log

The scannedAt timestamp is written to the attendee record and a scanLogs entry is inserted with eventId, attendeeId, and scannedBy (the scanner’s userId).
// src/app/api/tickets/validate/route.ts
export async function POST(req: Request) {
  const session = await getSession();
  const allowedRoles = ['tenant_admin', 'event_manager', 'superadmin', 'access_control'];
  if (!session || !allowedRoles.includes(session.role as string)) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { token } = await req.json();

  const registration = await db.query.attendees.findFirst({
    where: eq(attendees.ticketToken, token),
    with: { event: true },
  });

  if (!registration) {
    return NextResponse.json({ error: 'Entrada no encontrada (404)' }, { status: 404 });
  }

  if (registration.scannedAt) {
    return NextResponse.json(
      { error: 'Entrada ya utilizada (409)', scannedAt: registration.scannedAt },
      { status: 409 }
    );
  }

  // Mark ticket as used
  await db.update(attendees)
    .set({ scannedAt: new Date() })
    .where(eq(attendees.id, registration.id));

  // Write the audit log
  await db.insert(scanLogs).values({
    eventId: registration.eventId,
    attendeeId: registration.id,
    scannedBy: session.id as string,
  });

  return NextResponse.json({
    success: true,
    message: 'Acceso Permitido (200)',
    attendee: {
      name: registration.name,
      type: registration.attendeeType,
      event: registration.event?.title,
    },
  });
}
ticketToken is single-use. Once scannedAt is set, re-scanning the same QR will always return 409. There is no built-in way to “un-scan” a ticket — if you need to reverse an accidental scan, update the record directly in the database.

Payment Workflow

For paid events, users submit their payment evidence through the registration form or from their profile page.
1

User submits payment proof

The user provides a paymentReference (bank or mobile transfer reference number) and optionally uploads a screenshot of the transaction. The POST /api/payments endpoint saves both to the attendee record. Status remains 'pago_pendiente'.
// POST /api/payments — stores reference and screenshot
await db.update(attendees)
  .set({
    paymentReference,
    paymentScreenshotUrl: screenshotUrl,
    // status stays 'pago_pendiente' until admin verifies
  })
  .where(eq(attendees.id, attendeeId));
2

Screenshot upload

The screenshot is first uploaded to Supabase Storage via uploadPaymentScreenshot. If Supabase is unavailable, the file falls back to local disk storage under public/uploads/payments/, resized and converted to WebP via sharp for storage efficiency.
3

tenant_admin reviews and verifies

The faculty admin sees the pending payment in the /faculty-admin attendee list. They call POST /api/payments/verify with the attendee’s id and an action of either 'approve' or 'reject'.
// POST /api/payments/verify
if (action === 'approve') {
  await db.update(attendees)
    .set({
      status: 'confirmado',
      paymentVerifiedBy: session.userId as string,
      paymentVerifiedAt: new Date(),
    })
    .where(eq(attendees.id, attendeeId));
} else if (action === 'reject') {
  await db.update(attendees)
    .set({
      status: 'registrado',
      paymentReference: null,
      paymentScreenshotUrl: null,
    })
    .where(eq(attendees.id, attendeeId));
}
4

Result

  • Approvedstatus becomes 'confirmado', paymentVerifiedBy and paymentVerifiedAt are recorded. The attendee’s QR ticket becomes valid for scanning.
  • Rejectedstatus resets to 'registrado' and payment evidence is cleared. The attendee must submit a new payment proof.
Only tenant_admin users can call POST /api/payments/verify. The endpoint also verifies that the attendee’s event belongs to the caller’s tenantId, preventing cross-tenant payment approvals.

Scan Logs

Every successful QR scan is recorded in the scan_logs table. This creates a permanent, tamper-evident audit trail of who was admitted to an event, when, and by which staff member.
// src/db/schema.ts
export const scanLogs = pgTable('scan_logs', {
  id: uuid('id').primaryKey().defaultRandom(),
  eventId: uuid('event_id').references(() => events.id).notNull(),
  attendeeId: uuid('attendee_id').references(() => attendees.id).notNull(),
  scannedBy: uuid('scanned_by').references(() => users.id).notNull(),
  scannedAt: timestamp('scanned_at').defaultNow(),
});
FieldDescription
eventIdWhich event the scan happened at
attendeeIdWhich attendee was admitted
scannedByThe staff member (userId) who performed the scan
scannedAtThe exact timestamp of admission
Scan logs can be used to produce real-time attendance counts and post-event attendance reports. Each log entry is immutable — deleting or modifying them requires direct database access.

Build docs developers (and LLMs) love