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

578 lines
19 KiB
Swift
Raw Normal View History

import Foundation
import Hummingbird
import HummingbirdElementary
import Infrastructure
import Persistence
/// Serves the path to a sale: the offer sheet, the messages, the sale records, and the seller dashboard.
///
/// Every route is for a signed-in account; signed out, a `GET` leads to the sign-in screen and back, and a `POST` to the sign-in screen alone.
/// The rules themselves — who may offer, answer, or take a step — live in `Persistence`'s `TradeRepository`.
///
/// - 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 TradeController<Context: RequestContext> {
// MARK: Properties
private let analytics: Analytics?
private let assetVersion: String?
/// 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
/// The repository conversations, offers, and sales are read and written through.
private let trade: TradeRepository
// MARK: Initializers
/// Creates a trade controller.
/// - Parameters:
/// - trade: the repository conversations, offers, and sales are read and written through.
/// - catalogue: the repository listings and shops 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.
/// - 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,
onCatalogueChange: @escaping @Sendable () async -> Void = {}
) {
self.analytics = analytics
self.onCatalogueChange = onCatalogueChange
self.assetVersion = assetVersion
self.catalogue = catalogue
self.sessions = sessions
self.trade = trade
}
}
// MARK: - RouterController
extension TradeController: RouterController {
// MARK: Properties
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
routes.get(.Trade.offer, use: showSheet(.offer))
routes.post(.Trade.offer, use: makeOffer)
routes.get(.Trade.buy, use: showSheet(.buy))
routes.post(.Trade.buy, use: buy)
routes.get(.Trade.messages, use: messages)
routes.get(.Trade.newConversation, use: startConversation)
routes.get(.Trade.conversation, use: conversation)
routes.post(.Trade.conversation, use: post)
routes.get(.Trade.sale, use: sale)
routes.post(.Trade.sale, use: takeStep)
routes.get(.Trade.dashboard, use: dashboard)
routes.post(.Trade.answer, use: answer)
return routes
}
}
// MARK: - Offers
private extension TradeController {
/// Handles buying a listing at its asking price: opens the sale record and leads to it.
@Sendable
func buy(
request: Request,
context: Context
) async throws -> Response {
let slug = try context.parameters.require(.slug)
guard let (user, viewer) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.listing(slug)), type: .normal)
}
do {
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)
}
}
/// Handles an offer: makes it, and leads to the conversation it was made in. One that breaks a rule gets the sheet back, saying why.
@Sendable
func makeOffer(
request: Request,
context: Context
) async throws -> Response {
let slug = try context.parameters.require(.slug)
guard let (user, viewer) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: .Route.listing(slug)), type: .normal)
}
let form = try await URLEncodedFormDecoder().decode(OfferForm.self, from: request, context: context)
let amountCents = Self.cents(form.amount)
do {
let id = try await trade.makeOffer(
onListing: slug,
buyer: user,
amountCents: amountCents ?? 0,
note: form.note ?? ""
)
return .redirect(to: .Route.conversation(id), type: .normal)
} catch let error as TradeError {
return try await sheet(
.offer,
slug: slug,
amountCents: amountCents,
note: form.note ?? "",
error: error,
viewer: viewer,
request: request,
context: context
)
}
}
/// Builds the handler showing the sheet in one of its modes. A listing that cannot be offered on, or bought, leads back to itself.
func showSheet(
_ mode: OfferPage.Mode
) -> @Sendable (Request, Context) async throws -> Response {
{ request, context in
let slug = try context.parameters.require(.slug)
guard let (_, viewer) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
return try await sheet(
mode,
slug: slug,
amountCents: request.uri.queryParameters.get("amount").flatMap(Self.cents),
viewer: viewer,
request: request,
context: context
)
}
}
/// Renders the sheet — with a `422` when it reports an error — or leads back to a listing the sheet is not for.
func sheet(
_ mode: OfferPage.Mode,
slug: String,
amountCents: Int? = nil,
note: String = "",
error: TradeError? = nil,
viewer: Viewer,
request: Request,
context: Context
) async throws -> Response {
guard let listing = try await catalogue.listing(slug: slug) else {
throw HTTPError(.notFound)
}
guard
listing.isAvailable,
viewer.shopSlug != listing.shop.slug,
mode == .buy || listing.acceptsOffers
else {
return .redirect(to: listing.path, type: .found)
}
return HTMLResponse(
status: error == nil ? .ok : .unprocessableContent,
additionalHeaders: .perViewer
) { [analytics, assetVersion] in
OfferPage(
listing: listing,
mode: mode,
amountCents: amountCents,
note: note,
error: error?.explanation,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
}
// MARK: - Messages
private extension TradeController {
/// Handles a request for a conversation, marking it read.
@Sendable
func conversation(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
guard
let id = context.parameters.get(.id, as: UUID.self),
let open = try await trade.conversation(id: id, for: user)
else {
throw HTTPError(.notFound)
}
return try await messagesPage(open: open, user: user, request: request, context: context)
}
/// Handles a request for the messages, with no conversation open.
@Sendable
func messages(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
return try await messagesPage(open: nil, user: user, request: request, context: context)
}
/// Renders the messages. The viewer is read after the open conversation was marked read, so the header's count is current.
func messagesPage(
open: Conversation?,
user: User,
request: Request,
context: Context
) async throws -> Response {
let conversations = try await trade.conversations(for: user)
let viewer = await sessions.viewer(for: request)
let box = request.uri.queryParameters.get("box").flatMap(Conversation.Side.init)
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
MessagesPage(
conversations: conversations,
open: open,
box: box,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
/// Handles a message posted to a conversation.
@Sendable
func post(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn, type: .normal)
}
guard let id = context.parameters.get(.id, as: UUID.self) else {
throw HTTPError(.notFound)
}
let form = try await URLEncodedFormDecoder().decode(MessageForm.self, from: request, context: context)
do {
try await trade.post(form.body, to: id, from: user)
} catch is TradeError {
throw HTTPError(.notFound)
}
return .redirect(to: .Route.conversation(id), type: .normal)
}
/// Handles the start of a conversation about a listing, or with a shop at large: finds or starts it, and leads to it.
@Sendable
func startConversation(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
let listing = request.uri.queryParameters.get("listing")
let shop = request.uri.queryParameters.get("shop")
do {
if let listing {
return .redirect(to: .Route.conversation(try await trade.conversation(aboutListing: listing, buyer: user)), type: .found)
}
if let shop {
return .redirect(to: .Route.conversation(try await trade.conversation(withShop: shop, buyer: user)), type: .found)
}
} catch TradeError.ownListing {
return .redirect(to: .Route.sellerDashboard, type: .found)
} catch is TradeError {
throw HTTPError(.notFound)
}
return .redirect(to: .Route.messages, type: .found)
}
}
// MARK: - Sales
private extension TradeController {
/// Handles a request for a sale record; one the account is no party to is not found.
@Sendable
func sale(
request: Request,
context: Context
) async throws -> Response {
guard let (user, viewer) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
guard
let number = context.parameters.get(.number, as: Int.self),
let sale = try await trade.sale(number: number, for: user),
let role = sale.role(of: user.id)
else {
throw HTTPError(.notFound)
}
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
SalePage(
sale: sale,
role: role,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
/// Handles a step of a sale's handshake. One that is not the account's to take now changes nothing, and leads back to the record.
@Sendable
func takeStep(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn, type: .normal)
}
guard let number = context.parameters.get(.number, as: Int.self) else {
throw HTTPError(.notFound)
}
let form = try await URLEncodedFormDecoder().decode(StepForm.self, from: request, context: context)
let action: Sale.Action? = switch form.step {
case "paid": .markPaid(note: form.note ?? "")
case "sent": .markSent(note: form.note ?? "")
case "received": .markReceived
case "rated": .rate(stars: form.stars ?? 0)
default: nil
}
if let action {
_ = try? await trade.perform(action, onSale: number, by: user)
}
return .redirect(to: .Route.sale(number), type: .normal)
}
}
// MARK: - Selling
private extension TradeController {
/// Handles a seller's answer to an offer: accepting leads to the sale record opened, declining back to where the answer was given.
@Sendable
func answer(
request: Request,
context: Context
) async throws -> Response {
guard let (user, _) = await account(of: request) else {
return .redirect(to: .Route.signIn, type: .normal)
}
guard let id = context.parameters.get(.id, as: UUID.self) else {
throw HTTPError(.notFound)
}
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
)
}
/// Handles a request for the seller dashboard; an account without a shop is led to where one is opened.
@Sendable
func dashboard(
request: Request,
context: Context
) async throws -> Response {
guard let (user, viewer) = await account(of: request) else {
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
}
guard let shop = try await trade.shop(ownedBy: user) else {
return .redirect(to: .Route.openShop, type: .found)
}
let offers = try await trade.openOffers(for: user)
let sales = try await trade.sales(for: user).filter { $0.seller.id == user.id }
let listings = try await catalogue.listings(matching: .init(shop: shop.slug))
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
SellerDashboardPage(
shop: shop,
offers: offers,
sales: sales,
listings: listings,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
}
// MARK: - Helpers
private extension TradeController {
/// The signed-in account a request comes from, and the viewer the pages show of it; or `nil` signed out.
func account(
of request: Request
) async -> (User, Viewer)? {
guard
let user = await sessions.user(for: request),
let viewer = await sessions.viewer(for: request)
else {
return nil
}
return (user, viewer)
}
/// An amount typed in euros — `215`, `€215`, `215,50` — in euro cents, or `nil` when it is not one.
static func cents(
_ amount: String
) -> Int? {
let cleaned = amount
.replacingOccurrences(of: "€", with: "")
.replacingOccurrences(of: ",", with: ".")
.trimmingCharacters(in: .whitespaces)
guard
let euros = Double(cleaned),
euros.isFinite,
euros >= 0,
euros < 1_000_000
else {
return nil
}
return Int((euros * 100).rounded())
}
}
// MARK: - Structs
/// The fields the offer sheet posts.
private struct OfferForm: Decodable {
let amount: String
let note: String?
}
/// The field the composer posts.
private struct MessageForm: Decodable {
let body: String
}
/// The fields a step of the handshake posts.
private struct StepForm: Decodable {
let step: String
let note: String?
let stars: Int?
}
/// The fields an answer to an offer posts.
private struct AnswerForm: Decodable {
let answer: String
let `return`: String?
}
// MARK: - Extensions
extension TradeError {
// MARK: Computed
/// Why the step was turned down, as the person taking it reads it.
var explanation: String {
switch self {
case .invalidAmount: "Enter an amount above zero, up to the asking price. To pay the asking price, use Buy now."
case .listingUnavailable: "This copy is no longer for sale."
case .notAllowed: "That step isn't yours to take right now."
case .offerAlreadyOpen: "You already have an open offer on this listing. Wait for the seller's answer, or for it to expire."
case .offerTooLow(let floorCents): "The seller auto-declines offers under \(floorCents.euros)."
case .offersNotAccepted: "This seller doesn't take offers on this copy."
case .ownListing: "This is your own listing."
case .shopUnattended: "This shop can't take orders right now."
}
}
}
// MARK: - Constants
private extension RouterPath {
/// A namespace for the ``TradeController`` route paths.
enum Trade {
static let answer: RouterPath = .init("\(String.Route.sellerDashboard)/offers/:\(String.id)")
static let buy: RouterPath = .init(.Route.buy(":\(String.slug)"))
static let conversation: RouterPath = .init("\(String.Route.messages)/:\(String.id)")
static let dashboard: RouterPath = .init(.Route.sellerDashboard)
static let messages: RouterPath = .init(.Route.messages)
static let newConversation: RouterPath = .init("\(String.Route.messages)/new")
static let offer: RouterPath = .init(.Route.offer(":\(String.slug)"))
static let sale: RouterPath = .init(.Route.sale(0).replacingOccurrences(of: "0", with: ":\(String.number)"))
}
}
private extension String {
/// The name of the route parameter carrying a conversation's or an offer's identifier.
static let id = "id"
/// The name of the route parameter carrying a sale's number.
static let number = "number"
/// The name of the route parameter carrying a listing's slug.
static let slug = "slug"
}