Implemented phase 5 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 13:32:05 +02:00
1 parent 89a7a7b8e5
commit 31d2d98d49
46 files changed
+4039 -162

No files matched your search

@@ -0,0 +1,332 @@
import Hummingbird
import HummingbirdElementary
import Infrastructure
import Persistence
/// Serves what an account does for itself: its first sign-in, its profile and passkeys, and opening its shop.
///
/// - 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 AccountController<Context: RequestContext> {
// MARK: Properties
/// The repository accounts and their passkeys are read and changed through.
private let accounts: AccountRepository
private let analytics: Analytics?
private let assetVersion: String?
/// The repository shops are opened through.
private let catalogue: CatalogueRepository
/// The store the signed-in account is read from.
private let sessions: SessionStore
// MARK: Initializers
/// Creates an account controller.
/// - Parameters:
/// - accounts: the repository accounts and their passkeys are read and changed through.
/// - catalogue: the repository shops are opened 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(
accounts: AccountRepository,
catalogue: CatalogueRepository,
sessions: SessionStore,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.accounts = accounts
self.analytics = analytics
self.assetVersion = assetVersion
self.catalogue = catalogue
self.sessions = sessions
}
}
// MARK: - RouterController
extension AccountController: RouterController {
// MARK: Properties
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
routes.get(.init(.Route.welcome), use: welcome)
routes.post(.init(.Route.welcome), use: introduce)
routes.get(.init(.Route.account), use: account)
routes.post(.init("\(String.Route.account)/name"), use: rename)
routes.post(.init("\(String.Route.account)/passkeys/revoke"), use: revoke)
routes.get(.init(.Route.openShop), use: shopForm)
routes.post(.init(.Route.openShop), use: openShop)
return routes
}
}
// MARK: - Account
private extension AccountController {
/// Handles a request for the account screen.
@Sendable
func account(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .found)
}
return try await accountPage(user, request: request, context: context)
}
/// Renders the account screen — with a `422` when it reports a problem.
func accountPage(
_ user: User,
problem: String? = nil,
request: Request,
context: Context
) async throws -> Response {
let passkeys = try await accounts.passkeys(of: user)
let viewer = await sessions.viewer(for: request)
return HTMLResponse(
status: problem == nil ? .ok : .unprocessableContent,
additionalHeaders: .perViewer
) { [analytics, assetVersion] in
AccountPage(
user: user,
passkeys: passkeys,
problem: problem,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
/// Handles a new display name from the account screen.
@Sendable
func rename(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal)
}
let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context)
guard let name = Self.displayName(form.name) else {
return try await accountPage(user, problem: Self.nameProblem, request: request, context: context)
}
await sessions.update(try await accounts.rename(user, to: name))
return .redirect(to: .Route.account, type: .normal)
}
/// Handles the revoking of one of the account's own passkeys.
@Sendable
func revoke(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal)
}
let form = try await URLEncodedFormDecoder().decode(RevokeForm.self, from: request, context: context)
try await accounts.revokePasskey(form.credential, of: user)
return .redirect(to: "\(String.Route.account)#passkeys", type: .normal)
}
}
// MARK: - First sign-in
private extension AccountController {
/// Handles the display name picked on first sign-in, and leads on to what the visitor came for.
@Sendable
func introduce(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn, type: .normal)
}
let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context)
let returnPath = String.Route.safeReturn(form.return)
guard let name = Self.displayName(form.name) else {
return welcomePage(name: form.name, returnPath: returnPath, problem: Self.nameProblem, request: request, context: context)
}
await sessions.update(try await accounts.rename(user, to: name))
return .redirect(to: returnPath, type: .normal)
}
/// Handles a request for the first-sign-in screen.
@Sendable
func welcome(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn, type: .found)
}
return welcomePage(
name: user.displayName,
returnPath: .Route.safeReturn(request.uri.queryParameters.get("return")),
request: request,
context: context
)
}
/// Renders the first-sign-in screen — with a `422` when it reports a problem.
func welcomePage(
name: String,
returnPath: String,
problem: String? = nil,
request: Request,
context: Context
) -> Response {
HTMLResponse(
status: problem == nil ? .ok : .unprocessableContent,
additionalHeaders: [.cacheControl: "no-store"]
) { [analytics, assetVersion] in
WelcomePage(
displayName: name,
returnPath: returnPath,
problem: problem,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
}
// MARK: - Opening a shop
private extension AccountController {
/// Handles the open-a-shop form posted: opens the shop, and leads to its first listing.
@Sendable
func openShop(
request: Request,
context: Context
) async throws -> Response {
guard let user = await sessions.user(for: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .normal)
}
let values = try await URLEncodedFormDecoder().decode([String: String].self, from: request, context: context)
let form = ShopForm(values: values)
switch form.validated() {
case .failure(let problems):
return await shopPage(form, problems: problems.fields, request: request, context: context)
case .success(let draft):
do {
_ = try await catalogue.openShop(draft, for: user)
} catch ShopError.handleTaken {
return await shopPage(form, problems: [.handle: "Another shop has that handle."], request: request, context: context)
} catch ShopError.alreadyOwner {
return .redirect(to: .Route.sellerDashboard, type: .normal)
}
return .redirect(to: NewListingPage.path, type: .normal)
}
}
/// Handles a request for the open-a-shop form; an account that owns a shop is led to its dashboard.
@Sendable
func shopForm(
request: Request,
context: Context
) async throws -> Response {
guard await sessions.user(for: request) != nil else {
return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .found)
}
guard await sessions.viewer(for: request)?.hasShop != true else {
return .redirect(to: .Route.sellerDashboard, type: .found)
}
return await shopPage(.init(), request: request, context: context)
}
/// Renders the open-a-shop form — with a `422` when it reports problems.
func shopPage(
_ form: ShopForm,
problems: [ShopForm.Field: String] = [:],
request: Request,
context: Context
) async -> Response {
let viewer = await sessions.viewer(for: request)
return HTMLResponse(
status: problems.isEmpty ? .ok : .unprocessableContent,
additionalHeaders: .perViewer
) { [analytics, assetVersion] in
OpenShopPage(
form: form,
problems: problems,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
}
// MARK: - Helpers
private extension AccountController {
// MARK: Constants
static var nameProblem: String { "Enter a display name of 2 to 40 characters." }
// MARK: Methods
/// A display name as posted, trimmed; or `nil` when it is too short or too long.
static func displayName(
_ name: String
) -> String? {
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
return (2...40).contains(name.count) ? name : nil
}
}
// MARK: - Structs
/// The fields a display name form posts.
private struct NameForm: Decodable {
let name: String
let `return`: String?
}
/// The field a passkey's revoke form posts.
private struct RevokeForm: Decodable {
let credential: String
}
@@ -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
}
}
@@ -21,6 +21,9 @@ public struct ListingFormController<Context: RequestContext> {
/// The repository listings are published through.
private let catalogue: CatalogueRepository
/// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up.
private let onCatalogueChange: @Sendable () async -> Void
/// The store the photos are kept in.
private let photos: PhotoStore
@@ -40,15 +43,18 @@ public struct ListingFormController<Context: RequestContext> {
/// - photos: the store the photos are kept in.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it.
/// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default.
public init(
catalogue: CatalogueRepository,
trade: TradeRepository,
sessions: SessionStore,
photos: PhotoStore,
assetVersion: String? = nil,
analytics: Analytics? = nil
analytics: Analytics? = nil,
onCatalogueChange: @escaping @Sendable () async -> Void = {}
) {
self.analytics = analytics
self.onCatalogueChange = onCatalogueChange
self.assetVersion = assetVersion
self.catalogue = catalogue
self.photos = photos
@@ -151,6 +157,8 @@ private extension ListingFormController {
throw HTTPError(.internalServerError)
}
await onCatalogueChange()
return .redirect(to: listing.path, type: .normal)
}
@@ -8,8 +8,8 @@ import WebAuthn
/// Serves the passkey ceremonies behind the sign-in screen, as JSON for `js/passkey.js`.
///
/// One step both signs in and joins: an unknown email registers a passkey and creates its account together; anything else authenticates with a
/// passkey registered earlier — a discoverable one when no email is given. A passkey is never added to an existing account: the prototype has
/// no other proof of who is asking, so that would hand the account over.
/// passkey registered earlier — a discoverable one when no email is given. A passkey is added to an existing account only from that account's
/// own session: signed out there is no proof of who is asking, so it would hand the account over.
///
/// - 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 PasskeyController<Context: RequestContext> {
@@ -134,7 +134,35 @@ private extension PasskeyController {
user = joined
return try await signedIn(user, returningTo: answer.return)
// A new account picks its display name before going on.
return try await signedIn(user, returningTo: .Route.welcome(returningTo: .Route.safeReturn(answer.return)))
case .addition(let userID):
guard let owner = await sessions.user(for: request), owner.id == userID else {
return Self.refusal(.unauthorized, "Sign in again to add a passkey.")
}
let answer = try JSONDecoder().decode(Answer<RegistrationCredential>.self, from: body)
let credential = try await manager.finishRegistration(
challenge: ceremony.challenge,
credentialCreationData: answer.credential,
confirmCredentialIDNotRegisteredYet: { [accounts] in
try await accounts.isUnregistered(credentialID: Self.base64URL($0))
}
)
try await accounts.addPasskey(
to: owner,
credentialID: Self.base64URL(credential.id),
publicKey: credential.publicKey,
signCount: credential.signCount
)
// The bare path: from the account screen, one ending in a fragment would not reload it.
var response = Self.json(.ok, #"{"location":"\#(String.Route.account)"}"#)
response.setCookie(PasskeyCeremonies.cookie("", isSecure: isSecure))
return response
case .authentication:
let answer = try JSONDecoder().decode(Answer<AuthenticationCredential>.self, from: body)
let credentialID = Self.base64URL(answer.credential.id.asString())
@@ -167,8 +195,23 @@ private extension PasskeyController {
request: Request,
context: Context
) async throws -> Response {
let typed = (try? await request.decode(as: Start.self, context: context))?.email?
.trimmingCharacters(in: .whitespaces) ?? ""
let start = try? await request.decode(as: Start.self, context: context)
let typed = start?.email?.trimmingCharacters(in: .whitespaces) ?? ""
// From the account screen: another passkey for the account signed in.
if start?.add == true {
guard let owner = await sessions.user(for: request) else {
return Self.refusal(.unauthorized, "Sign in again to add a passkey.")
}
let options = manager.beginRegistration(user: .init(
id: Array(owner.id.uuidString.utf8),
name: owner.email,
displayName: owner.displayName
))
return try await begin(.addition(userID: owner.id), options: options, challenge: options.challenge, mode: "create")
}
// No email: whichever passkey the browser holds for this site.
guard !typed.isEmpty else {
@@ -299,6 +342,7 @@ private extension PasskeyController {
/// What the script posts to start a ceremony.
private struct Start: Decodable {
let email: String?
let add: Bool?
}
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
@@ -19,17 +19,14 @@ public struct RootController<Context: RequestContext> {
// MARK: Properties
/// What a page rendered per request is built from, the same as the cached ones.
private let page: @Sendable (Locale, Viewer?) -> IndexPage
/// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page.
private let sessions: SessionStore?
/// The landing pages. Whatever changes the catalogue refreshes them through it.
public let home: HomePages
/// Negotiates the language the bare route answers in.
private let negotiate: Negotiate
/// The landing page, rendered once per supported language and reused for every request.
private let responses: LocalizedHTMLCollectionResponse
/// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page.
private let sessions: SessionStore?
// MARK: Initializers
@@ -47,28 +44,14 @@ public struct RootController<Context: RequestContext> {
listings: HomeListings = .init(),
sessions: SessionStore? = nil
) {
let page: @Sendable (Locale, Viewer?) -> IndexPage = {
.init(
locale: $0,
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics,
listings: listings,
viewer: $1
)
}
self.home = .init(
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics,
listings: listings
)
self.negotiate = .init(bundle: .module)
self.page = page
self.sessions = sessions
// The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache
// and carry it too.
self.responses = .init(
bundle: .module,
variesOnAcceptLanguage: true
) {
page($0, nil)
}
}
}
@@ -156,14 +139,19 @@ private extension RootController {
context: Context
) async -> Response {
guard let viewer = await sessions?.viewer(for: request) else {
return responses.response(
for: language,
return await home.response(
in: language,
request: request
)
}
return HTMLResponse(additionalHeaders: .perViewer) { [page] in
page(.init(identifier: language), viewer)
let page = await home.page(
in: language,
for: viewer
)
return HTMLResponse(additionalHeaders: .perViewer) {
page
}
.response(from: request, context: context)
}
@@ -194,8 +194,13 @@ private extension SessionController {
)
}
let token = await sessions.start(for: try await accounts.account(email: email))
var response = Response.redirect(to: returnPath, type: .normal)
let account = try await accounts.signIn(email: email)
let token = await sessions.start(for: account.user)
// A new account picks its display name before going on.
var response = Response.redirect(
to: account.isNew ? .Route.welcome(returningTo: returnPath) : returnPath,
type: .normal
)
response.setCookie(SessionStore.cookie(token, isSecure: isSecure))
@@ -20,6 +20,9 @@ public struct TradeController<Context: RequestContext> {
/// The repository listings and shops are read through.
private let catalogue: CatalogueRepository
/// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up.
private let onCatalogueChange: @Sendable () async -> Void
/// The store the signed-in account is read from.
private let sessions: SessionStore
@@ -35,14 +38,17 @@ public struct TradeController<Context: RequestContext> {
/// - 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.
/// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default.
public init(
trade: TradeRepository,
catalogue: CatalogueRepository,
sessions: SessionStore,
assetVersion: String? = nil,
analytics: Analytics? = nil
analytics: Analytics? = nil,
onCatalogueChange: @escaping @Sendable () async -> Void = {}
) {
self.analytics = analytics
self.onCatalogueChange = onCatalogueChange
self.assetVersion = assetVersion
self.catalogue = catalogue
self.sessions = sessions
@@ -95,7 +101,11 @@ private extension TradeController {
}
do {
return .redirect(to: .Route.sale(try await trade.buy(listing: slug, buyer: user)), type: .normal)
let number = try await trade.buy(listing: slug, buyer: user)
await onCatalogueChange()
return .redirect(to: .Route.sale(number), type: .normal)
} catch let error as TradeError {
return try await sheet(.buy, slug: slug, error: error, viewer: viewer, request: request, context: context)
}
@@ -409,6 +419,10 @@ private extension TradeController {
let form = try await URLEncodedFormDecoder().decode(AnswerForm.self, from: request, context: context)
let number = try? await trade.respond(toOffer: id, accepting: form.answer == "accept", by: user)
if number.flatMap(\.self) != nil {
await onCatalogueChange()
}
return .redirect(
to: number.flatMap(\.self).map(String.Route.sale) ?? .Route.safeReturn(form.return),
type: .normal