Implemented phase 5 for the POC prototype in the Website service.
This commit is contained in:
1 parent
89a7a7b8e5
commit
31d2d98d49
46 files changed
+4039
-162
No files matched your search
@@ -0,0 +1,295 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user