412 lines
13 KiB
Swift
412 lines
13 KiB
Swift
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.post(.init("\(String.Route.series)/:slug/own"), use: toggleOwned)
|
|
routes.get(.init(.Route.compare), use: compare)
|
|
routes.get(.init(.Route.collection), use: collection)
|
|
routes.post(.init(.Route.collection), use: addBooks)
|
|
routes.get(.init(.Route.events), use: events)
|
|
|
|
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)
|
|
var owned: Set<Int>?
|
|
|
|
if let user = await sessions.user(for: request) {
|
|
owned = try await collecting.ownedIssues(of: user)[slug] ?? []
|
|
}
|
|
|
|
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
|
SeriesPage(
|
|
series: series,
|
|
listings: listings,
|
|
issue: issue,
|
|
owned: owned,
|
|
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: - Collection and events
|
|
|
|
private extension CollectingController {
|
|
|
|
/// Handles books added to the collection from its form.
|
|
@Sendable
|
|
func addBooks(
|
|
request: Request,
|
|
context: Context
|
|
) async throws -> Response {
|
|
guard let user = await sessions.user(for: request) else {
|
|
return .redirect(to: .Route.signIn(returningTo: .Route.collection), type: .normal)
|
|
}
|
|
|
|
let form = try await URLEncodedFormDecoder().decode(BooksForm.self, from: request, context: context)
|
|
|
|
try await collecting.own(issues: CollectionPage.issues(in: form.issues), ofSeries: form.series, by: user)
|
|
|
|
return .redirect(to: .Route.collection, type: .normal)
|
|
}
|
|
|
|
/// Handles a request for the collection tracker.
|
|
@Sendable
|
|
func collection(
|
|
request: Request,
|
|
context: Context
|
|
) async throws -> Response {
|
|
guard let user = await sessions.user(for: request) else {
|
|
return .redirect(to: .Route.signIn(returningTo: .Route.collection), type: .found)
|
|
}
|
|
|
|
let catalogue = try await collecting.allSeries()
|
|
let owned = try await collecting.ownedIssues(of: user)
|
|
let listings = try await self.catalogue.listings()
|
|
let viewer = await sessions.viewer(for: request)
|
|
|
|
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
|
CollectionPage(
|
|
catalogue: catalogue,
|
|
owned: owned,
|
|
listings: listings,
|
|
viewer: viewer,
|
|
assetVersion: assetVersion,
|
|
analytics: analytics
|
|
)
|
|
}
|
|
.response(from: request, context: context)
|
|
}
|
|
|
|
/// Handles a request for the events.
|
|
@Sendable
|
|
func events(
|
|
request: Request,
|
|
context: Context
|
|
) async throws -> HTMLResponse {
|
|
let events = try await collecting.upcomingEvents()
|
|
let listings = try await catalogue.listings()
|
|
let viewer = await sessions.viewer(for: request)
|
|
|
|
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
|
EventsPage(
|
|
events: events,
|
|
listings: listings,
|
|
viewer: viewer,
|
|
assetVersion: assetVersion,
|
|
analytics: analytics
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Handles the switch on an issue of a series: into the collection, or out of it.
|
|
@Sendable
|
|
func toggleOwned(
|
|
request: Request,
|
|
context: Context
|
|
) async throws -> Response {
|
|
let slug = try context.parameters.require("slug")
|
|
|
|
guard let user = await sessions.user(for: request) else {
|
|
return .redirect(to: .Route.signIn(returningTo: .Route.series(slug)), type: .normal)
|
|
}
|
|
|
|
let form = try await URLEncodedFormDecoder().decode(IssueForm.self, from: request, context: context)
|
|
|
|
if try await collecting.ownedIssues(of: user)[slug]?.contains(form.issue) == true {
|
|
try await collecting.disown(issue: form.issue, ofSeries: slug, by: user)
|
|
} else {
|
|
try await collecting.own(issues: [form.issue], ofSeries: slug, by: user)
|
|
}
|
|
|
|
return .redirect(to: .Route.series(slug), type: .normal)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Structs
|
|
|
|
/// The fields the add-books form posts.
|
|
private struct BooksForm: Decodable {
|
|
let series: String
|
|
let issues: String
|
|
}
|
|
|
|
/// The field an issue's switch posts.
|
|
private struct IssueForm: Decodable {
|
|
let issue: Int
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|