import Foundation import Hummingbird import HummingbirdElementary import Infrastructure import Persistence /// Serves what a collector uses: the want list, for a signed-in account, and the public series screens and comparison. /// /// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to. public struct CollectingController { // MARK: Properties private let analytics: Analytics? private let assetVersion: String? /// The repository listings are read through. private let catalogue: CatalogueRepository /// The repository wants and series are read and written through. private let collecting: CollectingRepository /// The store the signed-in account is read from. private let sessions: SessionStore // MARK: Initializers /// Creates a collecting controller. /// - Parameters: /// - collecting: the repository wants and series are read and written through. /// - catalogue: the repository listings are read through. /// - sessions: the store the signed-in account is read from. /// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned. /// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it. public init( collecting: CollectingRepository, catalogue: CatalogueRepository, sessions: SessionStore, assetVersion: String? = nil, analytics: Analytics? = nil ) { self.analytics = analytics self.assetVersion = assetVersion self.catalogue = catalogue self.collecting = collecting self.sessions = sessions } } // MARK: - RouterController extension CollectingController: RouterController { // MARK: Properties public var routes: RouteCollection { let routes = RouteCollection(context: Context.self) routes.get(.init(.Route.wants), use: wants) routes.post(.init(.Route.wants), use: addWant) routes.get(.init("\(String.Route.wants)/new"), use: newWant) routes.post(.init("\(String.Route.wants)/:id/alerts"), use: toggleAlerts) routes.post(.init("\(String.Route.wants)/:id/remove"), use: removeWant) routes.get(.init(.Route.series), use: seriesIndex) routes.get(.init("\(String.Route.series)/:slug"), use: series) routes.get(.init(.Route.compare), use: compare) return routes } } // MARK: - Wants private extension CollectingController { /// Handles a want posted from the form. @Sendable func addWant( request: Request, context: Context ) async throws -> Response { guard let user = await sessions.user(for: request) else { return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal) } let form = try await URLEncodedFormDecoder().decode(WantForm.self, from: request, context: context) try await collecting.addWant( title: form.title, minGrade: form.minGrade.flatMap(Double.init), maxPriceCents: form.maxPrice.flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }.flatMap { $0 > 0 ? $0 * 100 : nil }, slabbedOnly: form.slabbed == "1", for: user ) return .redirect(to: .Route.wants, type: .normal) } /// Handles a request for the add-a-want form, filled from a listing being saved, or with a title — a gap in a series. @Sendable func newWant( request: Request, context: Context ) async throws -> Response { guard let user = await sessions.user(for: request) else { return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found) } var draft = WantsPage.Draft(title: request.uri.queryParameters.get("title") ?? "") if let slug = request.uri.queryParameters.get("listing"), let listing = try await catalogue.listing(slug: slug) { draft = .init(title: listing.title, minGrade: listing.grade, maxPriceCents: listing.priceCents) } return try await wantsPage(user, draft: draft, request: request, context: context) } /// Handles the removal of one of the account's wants. @Sendable func removeWant( request: Request, context: Context ) async throws -> Response { guard let user = await sessions.user(for: request) else { return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal) } if let id = context.parameters.get("id", as: UUID.self) { try await collecting.removeWant(id, of: user) } return .redirect(to: .Route.wants, type: .normal) } /// Handles the switching of a want's alerts. @Sendable func toggleAlerts( request: Request, context: Context ) async throws -> Response { guard let user = await sessions.user(for: request) else { return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal) } if let id = context.parameters.get("id", as: UUID.self) { try await collecting.toggleAlerts(ofWant: id, of: user) } return .redirect(to: .Route.wants, type: .normal) } /// Handles a request for the want list. Looking at it is what makes its matches no longer new. @Sendable func wants( request: Request, context: Context ) async throws -> Response { guard let user = await sessions.user(for: request) else { return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .found) } return try await wantsPage(user, draft: nil, request: request, context: context) } /// Renders the want list with its matches still marked new, then marks them seen — so the header's bell is quiet on the next page. func wantsPage( _ user: User, draft: WantsPage.Draft?, request: Request, context: Context ) async throws -> Response { let wants = try await collecting.wants(of: user) try await collecting.markWantsSeen(by: user) let viewer = await sessions.viewer(for: request) return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in WantsPage( wants: wants, draft: draft, viewer: viewer, assetVersion: assetVersion, analytics: analytics ) } .response(from: request, context: context) } } // MARK: - Series private extension CollectingController { /// Handles a request for the comparison of the copies named in the query, up to three. @Sendable func compare( request: Request, context: Context ) async throws -> HTMLResponse { var copies = [Listing]() for slug in request.uri.queryParameters.getAll("copy").prefix(SeriesPage.maxCompared) { if let copy = try await catalogue.listing(slug: slug), copy.isAvailable { copies.append(copy) } } let viewer = await sessions.viewer(for: request) return .init(additionalHeaders: .perViewer) { [analytics, assetVersion, copies] in ComparePage( copies: copies, viewer: viewer, assetVersion: assetVersion, analytics: analytics ) } } /// Handles a request for a series; an unknown slug is not found. @Sendable func series( request: Request, context: Context ) async throws -> HTMLResponse { let slug = try context.parameters.require("slug") guard let series = try await collecting.series(slug: slug) else { throw HTTPError(.notFound) } // Every copy of the series, wherever it ships from: a series screen is the whole market for it. let listings = try await catalogue.listings().filter { $0.seriesSlug == slug } let issue = request.uri.queryParameters.get("issue", as: Int.self) let viewer = await sessions.viewer(for: request) return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in SeriesPage( series: series, listings: listings, issue: issue, viewer: viewer, assetVersion: assetVersion, analytics: analytics ) } } /// Handles a request for the series index. @Sendable func seriesIndex( request: Request, context: Context ) async throws -> HTMLResponse { let listings = try await catalogue.listings() let entries = try await collecting.allSeries().map { series in (series, listings.filter { $0.seriesSlug == series.slug }) } let viewer = await sessions.viewer(for: request) return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in SeriesIndexPage( entries: entries, viewer: viewer, assetVersion: assetVersion, analytics: analytics ) } } } // MARK: - Structs /// The fields the add-a-want form posts. private struct WantForm: Decodable { let title: String let minGrade: String? let maxPrice: String? let slabbed: String? enum CodingKeys: String, CodingKey { case title case minGrade = "min-grade" case maxPrice = "max-price" case slabbed } }