diff --git a/Sources/App/Features/Auth/Models/User.swift b/Sources/App/Features/Auth/Models/User.swift index b4b14a3e..986dcc8f 100644 --- a/Sources/App/Features/Auth/Models/User.swift +++ b/Sources/App/Features/Auth/Models/User.swift @@ -24,7 +24,7 @@ final class User: Authenticatable, ModelAuthenticatable, Content, ModelSessionAu } /// Unique identifier for this user. - @ID() + @ID var id: UUID? /// The user's name. diff --git a/Sources/App/Features/Auth/Models/UserToken.swift b/Sources/App/Features/Auth/Models/UserToken.swift index af211572..cf491da1 100644 --- a/Sources/App/Features/Auth/Models/UserToken.swift +++ b/Sources/App/Features/Auth/Models/UserToken.swift @@ -19,7 +19,7 @@ final class UserToken: Model, Content, ModelTokenAuthenticatable, Codable, @unch return timestamp > Date() } - @ID() + @ID var id: UUID? @Field(key: "value") diff --git a/Sources/App/Features/Sponsors/Controllers/SponsorScanController.swift b/Sources/App/Features/Sponsors/Controllers/SponsorScanController.swift new file mode 100644 index 00000000..6416029d --- /dev/null +++ b/Sources/App/Features/Sponsors/Controllers/SponsorScanController.swift @@ -0,0 +1,27 @@ +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..9b9283d9 --- /dev/null +++ b/Sources/App/Features/Sponsors/Models/SponsorScanResponse.swift @@ -0,0 +1,23 @@ +import Vapor + +struct SponsorScanResponse: Content { + let name: String + let firstName: String + let lastName: String + let company: String? + let email: String + let responses: [String: String] + + init(ticket: TitoTicket) { + name = ticket.fullName + firstName = ticket.first_name + lastName = ticket.last_name + company = ticket.company_name + email = ticket.email + 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())