Files
ccn/Services/Website/Sources/Library/Public/Controllers/CollectingController.swift
T

295 lines
9.6 KiB
Swift
Raw Normal View History

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<Context: RequestContext> {
// 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<Context> {
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
}
}