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 { // 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 { 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" }