import FluentKit import Foundation import HummingbirdFluent /// Reads and writes what leads to a sale: conversations, offers, and the sale records with their handshake. /// /// Every write checks that the account may make it, and throws a ``TradeError`` when not. A write touching several rows runs in a transaction. public struct TradeRepository: Sendable { // MARK: Constants /// The number before the first sale record's: the prototype's records count up from `CCN-48213`. private static let firstNumber = 48_212 // MARK: Properties private let fluent: Fluent /// The clock entries are stamped and expiries judged by. private let now: @Sendable () -> Date // MARK: Initializers /// Creates a repository backed by the given `Fluent` service. /// - Parameters: /// - fluent: the service whose default database the repository operates on. /// - now: the clock entries are stamped and expiries judged by; the system's by default. public init( fluent: Fluent, now: @escaping @Sendable () -> Date = { .init() } ) { self.fluent = fluent self.now = now } } // MARK: - Conversations public extension TradeRepository { /// Fetches a conversation as one of its parties sees it, marking it read by them. /// - Returns: the conversation, or `nil` when there is none the account is a party to. func conversation( id: UUID, for user: User ) async throws -> Conversation? { guard let record = try await conversations(on: fluent.db()) .filter(\.$id == id) .first(), let side = side(of: user.id, in: record) else { return nil } let conversation = try snapshot(of: record, as: side) if side == .buying { record.buyerReadAt = now() } else { record.sellerReadAt = now() } try await record.save(on: fluent.db()) return conversation } /// Fetches every conversation an account is a party to, the latest spoken in first. func conversations( for user: User ) async throws -> [Conversation] { try await conversations(on: fluent.db()) .all() .compactMap { record in try side(of: user.id, in: record).map { try snapshot(of: record, as: $0) } } .sorted { ($0.latest?.createdAt ?? .distantPast) > ($1.latest?.createdAt ?? .distantPast) } } /// Finds the buyer's conversation about a listing, starting it when there is none. /// - Returns: the conversation's identifier. func conversation( aboutListing slug: String, buyer: User ) async throws -> UUID { try await fluent.db().transaction { database in let listing = try await listing(slug: slug, on: database) return try await conversation( between: buyer, and: listing.shop, about: listing, on: database ).requireID() } } /// Finds the buyer's conversation with a shop at large, starting it when there is none. /// - Returns: the conversation's identifier. func conversation( withShop slug: String, buyer: User ) async throws -> UUID { try await fluent.db().transaction { database in guard let shop = try await ShopRecord.query(on: database) .filter(\.$slug == slug) .with(\.$owner) .first() else { throw TradeError.listingUnavailable } return try await conversation( between: buyer, and: shop, about: nil, on: database ).requireID() } } /// Adds a message to a conversation; one that is blank is dropped. func post( _ body: String, to id: UUID, from user: User ) async throws { let body = body.trimmingCharacters(in: .whitespacesAndNewlines) guard let record = try await conversations(on: fluent.db()) .filter(\.$id == id) .first(), side(of: user.id, in: record) != nil else { throw TradeError.notAllowed } guard !body.isEmpty else { return } try await MessageRecord( conversationID: id, senderID: user.id, kind: .text, body: String(body.prefix(2_000)), at: now() ).save(on: fluent.db()) } /// The number of conversations holding something an account has not seen yet. func unreadCount( for user: User ) async throws -> Int { try await conversations(for: user) .count { $0.unreadCount > 0 } } } // MARK: - Offers public extension TradeRepository { /// Makes a buyer's offer on a listing, in their conversation about it. /// /// The offer binds the buyer for ``Offer/lifetime``. One under the listing's floor is turned down outright, and a buyer holds one open /// offer per listing. /// - Returns: the identifier of the conversation the offer was made in. func makeOffer( onListing slug: String, buyer: User, amountCents: Int, note: String ) async throws -> UUID { try await fluent.db().transaction { database in let listing = try await listing(slug: slug, on: database) guard listing.acceptsOffers else { throw TradeError.offersNotAccepted } guard amountCents > 0, amountCents <= listing.priceCents else { throw TradeError.invalidAmount } let floor = Offer.floor(forAsking: listing.priceCents) guard amountCents >= floor else { throw TradeError.offerTooLow(floorCents: floor) } let listingID = try listing.requireID() let isOpen = try await OfferRecord.query(on: database) .filter(\.$listing.$id == listingID) .filter(\.$buyer.$id == buyer.id) .filter(\.$status == Offer.Status.open.rawValue) .filter(\.$expiresAt > now()) .first() != nil guard !isOpen else { throw TradeError.offerAlreadyOpen } let conversationID = try await conversation( between: buyer, and: listing.shop, about: listing, on: database ).requireID() let offer = OfferRecord( conversationID: conversationID, listingID: listingID, buyerID: buyer.id, amountCents: amountCents, at: now() ) try await offer.save(on: database) try await MessageRecord( conversationID: conversationID, senderID: buyer.id, kind: .offer, body: String(note.trimmingCharacters(in: .whitespacesAndNewlines).prefix(500)), offerID: try offer.requireID(), at: now() ).save(on: database) return conversationID } } /// Fetches the offers waiting for a seller's answer, the soonest to expire first. func openOffers( for seller: User ) async throws -> [Offer] { try await OfferRecord.query(on: fluent.db()) .filter(\.$status == Offer.Status.open.rawValue) .filter(\.$expiresAt > now()) .sort(\.$expiresAt) .with(\.$buyer) .with(\.$listing) { $0.with(\.$shop) { $0.with(\.$owner) } } .all() .filter { $0.listing.shop.$owner.id == seller.id } .map { try $0.snapshot(at: now()) } } /// Answers an offer on the seller's behalf. Accepting it opens a sale record. /// - Returns: the number of the sale opened, or `nil` when the offer was declined. @discardableResult func respond( toOffer id: UUID, accepting: Bool, by seller: User ) async throws -> Int? { try await fluent.db().transaction { database in guard let offer = try await OfferRecord.query(on: database) .filter(\.$id == id) .with(\.$listing, { $0.with(\.$shop) }) .first(), offer.listing.shop.$owner.id == seller.id, offer.status == Offer.Status.open.rawValue, offer.expiresAt > now() else { throw TradeError.notAllowed } guard accepting else { offer.status = Offer.Status.declined.rawValue try await offer.save(on: database) try await MessageRecord( conversationID: offer.$conversation.id, senderID: seller.id, kind: .event, body: "declined the offer", at: now() ).save(on: database) return nil } guard offer.listing.soldAt == nil else { throw TradeError.listingUnavailable } offer.status = Offer.Status.accepted.rawValue try await offer.save(on: database) return try await agree( on: offer.listing, conversationID: offer.$conversation.id, buyerID: offer.$buyer.id, sellerID: seller.id, priceCents: offer.amountCents, event: (seller.id, "accepted the offer"), on: database ) } } } // MARK: - Sales public extension TradeRepository { /// Buys a listing outright, at its asking price, opening a sale record. /// - Returns: the number of the sale opened. func buy( listing slug: String, buyer: User ) async throws -> Int { try await fluent.db().transaction { database in let listing = try await listing(slug: slug, on: database) guard let sellerID = listing.shop.$owner.id else { throw TradeError.shopUnattended } let conversation = try await conversation( between: buyer, and: listing.shop, about: listing, on: database ) return try await agree( on: listing, conversationID: try conversation.requireID(), buyerID: buyer.id, sellerID: sellerID, priceCents: listing.priceCents, event: (buyer.id, "bought the copy at the asking price"), on: database ) } } /// Takes a step of a sale's handshake, stamping it into the sale's conversation. /// - Returns: the sale after the step. @discardableResult func perform( _ action: Sale.Action, onSale number: Int, by user: User ) async throws -> Sale { try await fluent.db().transaction { database in guard let record = try await sales(on: database) .filter(\.$number == number) .first(), let role = try record.snapshot.role(of: user.id), try record.snapshot.allows(action, by: role) else { throw TradeError.notAllowed } let event: String switch action { case .markPaid(let note): record.paidAt = now() record.paymentNote = Self.note(note) event = "marked the sale paid" case .markSent(let note): record.sentAt = now() record.shippingNote = Self.note(note) event = "marked the copy sent" case .markReceived: record.receivedAt = now() event = "marked the copy received" case .rate(let stars): if role == .buyer { record.buyerRating = stars } else { record.sellerRating = stars } event = "left a rating" } try await record.save(on: database) try await MessageRecord( conversationID: record.$conversation.id, senderID: user.id, kind: .event, body: event, saleNumber: number, at: now() ).save(on: database) return try record.snapshot } } /// Fetches a sale record as one of its parties. /// - Returns: the sale, or `nil` when there is none the account is a party to. func sale( number: Int, for user: User ) async throws -> Sale? { try await sales(on: fluent.db()) .filter(\.$number == number) .first() .map { try $0.snapshot } .flatMap { $0.role(of: user.id) == nil ? nil : $0 } } /// Fetches every sale an account is a party to, the latest agreed first. func sales( for user: User ) async throws -> [Sale] { try await sales(on: fluent.db()) .group(.or) { $0.filter(\.$buyer.$id == user.id) $0.filter(\.$seller.$id == user.id) } .sort(\.$agreedAt, .descending) .all() .map { try $0.snapshot } } /// Fetches the shop an account owns. /// - Returns: the shop, or `nil` when the account owns none. func shop( ownedBy user: User ) async throws -> Shop? { try await ShopRecord.query(on: fluent.db()) .filter(\.$owner.$id == user.id) .first()? .snapshot } } // MARK: - Helpers private extension TradeRepository { // MARK: Methods /// Opens the sale record of an agreement, takes the listing off sale, lapses the other offers on it, and stamps the event. /// - Returns: the number of the sale opened. func agree( on listing: ListingRecord, conversationID: UUID, buyerID: UUID, sellerID: UUID, priceCents: Int, event: (actor: UUID, body: String), on database: any Database ) async throws -> Int { let listingID = try listing.requireID() let number = (try await SaleRecord.query(on: database).max(\.$number) ?? Self.firstNumber) + 1 let declared = listing.snapshot let sale = SaleRecord() sale.number = number sale.$conversation.id = conversationID sale.$listing.id = listingID sale.$buyer.id = buyerID sale.$seller.id = sellerID sale.priceCents = priceCents sale.askedCents = listing.priceCents sale.shippingCents = listing.shippingCents sale.declaredGrade = declared.gradeLabel + (declared.certNumber.map { " · cert \($0)" } ?? "") sale.declaredPageQuality = declared.pageQuality sale.declaredPrinting = declared.printingDetail sale.declaredNotes = declared.sellerNotes sale.agreedAt = now() listing.soldAt = now() try await sale.save(on: database) try await listing.save(on: database) try await OfferRecord.query(on: database) .filter(\.$listing.$id == listingID) .filter(\.$status == Offer.Status.open.rawValue) .set(\.$status, to: Offer.Status.expired.rawValue) .update() try await MessageRecord( conversationID: conversationID, senderID: event.actor, kind: .event, body: event.body, saleNumber: number, at: now() ).save(on: database) return number } /// Finds the conversation between a buyer and a shop about a listing — or about the shop at large — starting it when there is none. func conversation( between buyer: User, and shop: ShopRecord, about listing: ListingRecord?, on database: any Database ) async throws -> ConversationRecord { guard let ownerID = shop.$owner.id else { throw TradeError.shopUnattended } guard ownerID != buyer.id else { throw TradeError.ownListing } let shopID = try shop.requireID() let listingID = try listing?.requireID() let existing = try await ConversationRecord.query(on: database) .filter(\.$buyer.$id == buyer.id) .filter(\.$shop.$id == shopID) .all() .first { $0.$listing.id == listingID } if let existing { return existing } let conversation = ConversationRecord( buyerID: buyer.id, shopID: shopID, listingID: listingID, at: now() ) try await conversation.save(on: database) return conversation } /// The conversations query, with everything a snapshot reads eager-loaded. func conversations( on database: any Database ) -> QueryBuilder { ConversationRecord.query(on: database) .with(\.$buyer) .with(\.$shop) .with(\.$listing) { $0.with(\.$shop) } .with(\.$sales) .with(\.$messages) { message in message .with(\.$sender) .with(\.$offer) { offer in offer .with(\.$buyer) .with(\.$listing) { $0.with(\.$shop) } } } } /// Fetches a listing still for sale, with its shop. func listing( slug: String, on database: any Database ) async throws -> ListingRecord { guard let listing = try await ListingRecord.query(on: database) .filter(\.$slug == slug) .with(\.$shop) .first(), listing.soldAt == nil else { throw TradeError.listingUnavailable } return listing } /// A party's note, trimmed and capped, or `nil` when blank. static func note( _ note: String ) -> String? { let note = note.trimmingCharacters(in: .whitespacesAndNewlines) return note.isEmpty ? nil : String(note.prefix(200)) } /// The sales query, with everything a snapshot reads eager-loaded. func sales( on database: any Database ) -> QueryBuilder { SaleRecord.query(on: database) .with(\.$buyer) .with(\.$seller) .with(\.$listing) { $0.with(\.$shop) } } /// The side of a conversation an account is on, or `nil` when it is a party to neither. func side( of userID: UUID, in record: ConversationRecord ) -> Conversation.Side? { if record.$buyer.id == userID { return .buying } return record.shop.$owner.id == userID ? .selling : nil } /// The snapshot of a conversation, as the given side sees it. func snapshot( of record: ConversationRecord, as side: Conversation.Side ) throws -> Conversation { let viewerID = side == .buying ? record.$buyer.id : record.shop.$owner.id let readAt = side == .buying ? record.buyerReadAt : record.sellerReadAt let messages = record.messages.sorted { $0.createdAt < $1.createdAt } return .init( id: try record.requireID(), buyer: try record.buyer.snapshot, shop: record.shop.snapshot, listing: record.listing?.snapshot, side: side, entries: try messages.map { message in .init( kind: .init(rawValue: message.kind) ?? .text, sender: try message.sender.snapshot, body: message.body, offer: try message.offer?.snapshot(at: now()), saleNumber: message.saleNumber, createdAt: message.createdAt ) }, unreadCount: messages.count { $0.createdAt > readAt && $0.$sender.id != viewerID }, saleNumber: record.sales.first?.number ) } }