Implemented phase 3 for the POC prototype in the Website service.
This commit is contained in:
1 parent
b1ceed7790
commit
0dc3fe6bea
29 files changed
+4089
-66
No files matched your search
@@ -0,0 +1,564 @@
|
||||
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
|
||||
|
||||
/// 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.
|
||||
public init(
|
||||
trade: TradeRepository,
|
||||
catalogue: CatalogueRepository,
|
||||
sessions: SessionStore,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
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 {
|
||||
return .redirect(to: .Route.sale(try await trade.buy(listing: slug, buyer: user)), 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)
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -10,13 +10,21 @@ public actor SessionStore {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The repository a viewer's unread count and shop are read through, or `nil` to leave both out.
|
||||
private let trade: TradeRepository?
|
||||
|
||||
/// The accounts signed in, by session token.
|
||||
private var users: [String: User] = [:]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an empty session store.
|
||||
public init() {}
|
||||
/// - Parameter trade: the repository a viewer's unread count and shop are read through, or `nil` (the default) to leave both out.
|
||||
public init(
|
||||
trade: TradeRepository? = nil
|
||||
) {
|
||||
self.trade = trade
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
@@ -88,13 +96,26 @@ extension SessionStore {
|
||||
request.cookies[cookieName]?.value
|
||||
}
|
||||
|
||||
/// The signed-in viewer the request comes from, or `nil` when it carries no live session.
|
||||
/// The signed-in account the request comes from, or `nil` when it carries no live session.
|
||||
func user(
|
||||
for request: Request
|
||||
) -> User? {
|
||||
Self.token(in: request).flatMap(user)
|
||||
}
|
||||
|
||||
/// The signed-in viewer the request comes from, with what the header shows of them, or `nil` when it carries no live session.
|
||||
func viewer(
|
||||
for request: Request
|
||||
) -> Viewer? {
|
||||
Self.token(in: request)
|
||||
.flatMap(user)
|
||||
.map(Viewer.init)
|
||||
) async -> Viewer? {
|
||||
guard let user = user(for: request) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return .init(
|
||||
user,
|
||||
shopSlug: (try? await trade?.shop(ownedBy: user))?.slug,
|
||||
unreadMessages: (try? await trade?.unreadCount(for: user)) ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user