637 lines
20 KiB
Swift
637 lines
20 KiB
Swift
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> {
|
||
|
|
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> {
|
||
|
|
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
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|