579 lines
19 KiB
Swift
579 lines
19 KiB
Swift
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"
|
|
}
|