diff --git a/Sources/App/Features/Sponsors/Controllers/SponsorScanController.swift b/Sources/App/Features/Sponsors/Controllers/SponsorScanController.swift new file mode 100644 index 00000000..e66fe57a --- /dev/null +++ b/Sources/App/Features/Sponsors/Controllers/SponsorScanController.swift @@ -0,0 +1,26 @@ +import Vapor + +struct SponsorScanController: RouteCollection { + func boot(routes: any RoutesBuilder) throws { + let protected = routes.grouped(AppBearerMiddleware(), SponsorScanMiddleware()) + protected.post("scan", use: onScan) + } + + /// Scan an attendee's QR code and return their details. + /// - Parameter request: The incoming request containing the ticket stub + /// - Returns: Attendee details (name, company, email, custom questions) + @Sendable private func onScan(request: Request) async throws -> SponsorScanResponse { + let payload = try request.content.decode(SponsorScanRequest.self) + + guard let currentEvent = request.storage.get(CurrentEventKey.self), + let titoEvent = currentEvent.titoEvent else { + throw Abort(.internalServerError, reason: "Unable to identify event") + } + + guard let attendeeTicket = try await TitoService(event: titoEvent).ticket(stub: payload.stub, req: request) else { + throw Abort(.notFound, reason: "Ticket not found") + } + + return SponsorScanResponse(ticket: attendeeTicket) + } +} diff --git a/Sources/App/Features/Sponsors/Middleware/SponsorScanMiddleware.swift b/Sources/App/Features/Sponsors/Middleware/SponsorScanMiddleware.swift new file mode 100644 index 00000000..54ab8a05 --- /dev/null +++ b/Sources/App/Features/Sponsors/Middleware/SponsorScanMiddleware.swift @@ -0,0 +1,21 @@ +import Vapor + +/// Middleware that validates the authenticated user has a sponsor ticket type. +/// This middleware must be used after `AppBearerMiddleware` which validates the JWT +/// and stores the ticket in request storage. +struct SponsorScanMiddleware: AsyncMiddleware { + func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { + guard let token = request.headers.bearerAuthorization?.token else { + throw Abort(.unauthorized, reason: "No authorization token provided") + } + + let payload = try await request.jwt.verify(token, as: AppTicketJWTPayload.self) + + // Check if the ticket type contains "sponsor" + guard payload.ticketType.lowercased().contains("sponsor") else { + throw Abort(.forbidden, reason: "Sponsor ticket required to access this endpoint") + } + + return try await next.respond(to: request) + } +} diff --git a/Sources/App/Features/Sponsors/Models/SponsorScanResponse.swift b/Sources/App/Features/Sponsors/Models/SponsorScanResponse.swift new file mode 100644 index 00000000..73e808e4 --- /dev/null +++ b/Sources/App/Features/Sponsors/Models/SponsorScanResponse.swift @@ -0,0 +1,19 @@ +import Vapor + +struct SponsorScanResponse: Content { + let name: String + let company: String? + let email: String + let responses: [String: String] + + init(ticket: TitoTicket) { + self.name = ticket.fullName + self.company = ticket.company_name + self.email = ticket.email + self.responses = ticket.responses + } +} + +struct SponsorScanRequest: Content { + let stub: String +} diff --git a/Sources/App/Features/Tickets/Models/TitoTicket.swift b/Sources/App/Features/Tickets/Models/TitoTicket.swift index 3212edb9..1c4fcb17 100644 --- a/Sources/App/Features/Tickets/Models/TitoTicket.swift +++ b/Sources/App/Features/Tickets/Models/TitoTicket.swift @@ -17,6 +17,7 @@ struct TitoTicket: Codable { let avatar_url: URL? let responses: [String: String] let release: Release? + let release_title: String? let email: String let reference: String let qr_url: String? diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 13f57714..ca00725d 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -14,6 +14,7 @@ func routes(_ app: Application) throws { let apiRoutes = app.grouped("api", "v1") try apiRoutes.grouped("sponsors").register(collection: SponsorAPIController()) + try apiRoutes.grouped("sponsors").register(collection: SponsorScanController()) try apiRoutes.grouped("schedule").register(collection: ScheduleAPIController()) try apiRoutes.grouped("local").register(collection: LocalAPIController()) try apiRoutes.grouped("tickets").register(collection: TicketsAPIController())