Added the catalogue models and seed to support phase 3 of the POC prototype in the Persistence package.
This commit is contained in:
1 parent
43dcc03da6
commit
b1ceed7790
20 files changed
+2035
-8
No files matched your search
@@ -9,6 +9,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da
|
|||||||
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
|
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
|
||||||
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline |
|
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline |
|
||||||
| Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot; `CreateUser` seeds the prototype's demo accounts |
|
| Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot; `CreateUser` seeds the prototype's demo accounts |
|
||||||
|
| Trade | `TradeRepository`, which reads and writes `Conversation`, `Offer`, and `Sale` snapshots and enforces who may do what, throwing a `TradeError` otherwise; `Sale` carries the five-step handshake's rules as pure functions |
|
||||||
| Catalogue | `CatalogueRepository`, returning `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data |
|
| Catalogue | `CatalogueRepository`, returning `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data |
|
||||||
|
|
||||||
## Design rules
|
## Design rules
|
||||||
@@ -16,6 +17,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da
|
|||||||
- **One default database.** `Service` registers the selected backend as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which driver is in use.
|
- **One default database.** `Service` registers the selected backend as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which driver is in use.
|
||||||
- **Migrations are declared once, and append-only.** `PrepareDB` registers every migration in the order it must run; alter the schema by adding a migration, never by editing one that has already run. Registering does not apply them: the in-memory backend is migrated at launch, while a shared PostgreSQL database is migrated out of band (the executable's migrate-and-exit mode), so booting instances never race.
|
- **Migrations are declared once, and append-only.** `PrepareDB` registers every migration in the order it must run; alter the schema by adding a migration, never by editing one that has already run. Registering does not apply them: the in-memory backend is migrated at launch, while a shared PostgreSQL database is migrated out of band (the executable's migrate-and-exit mode), so booting instances never race.
|
||||||
- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types, so they stay internal to the package and repositories return `Sendable` value-type snapshots (e.g. `Listing`) instead.
|
- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types, so they stay internal to the package and repositories return `Sendable` value-type snapshots (e.g. `Listing`) instead.
|
||||||
|
- **Registration order is not creation order.** `AddTradeColumns` is registered ahead of `SeedCatalogue`, whose models write its columns; Fluent tracks migrations by name, so a database already past the seed still gains them.
|
||||||
- **Prototype shortcuts.** `CatalogueRepository` filters in memory, the catalogue fields (series, issue, publisher) are folded into the listing, and the demo data ships as a migration. All three go once real data arrives.
|
- **Prototype shortcuts.** `CatalogueRepository` filters in memory, the catalogue fields (series, issue, publisher) are folded into the listing, and the demo data ships as a migration. All three go once real data arrives.
|
||||||
- **Readiness never throws, and never hangs.** `Probe` runs a schema-independent `SELECT 1`, maps every failure to `false`, and races the query against a deadline (2 seconds by default) — a hanging database yields a prompt "not ready" instead of a stalled endpoint.
|
- **Readiness never throws, and never hangs.** `Probe` runs a schema-independent `SELECT 1`, maps every failure to `false`, and races the query against a deadline (2 seconds by default) — a hanging database yields a prompt "not ready" instead of a stalled endpoint.
|
||||||
- **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop, so every query reaches the same in-memory database instead of each pooled connection getting a private one.
|
- **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop, so every query reaches the same in-memory database instead of each pooled connection getting a private one.
|
||||||
@@ -28,14 +30,14 @@ Sources are split by visibility, then by kind, one type per file:
|
|||||||
```
|
```
|
||||||
Sources/
|
Sources/
|
||||||
├── Public/
|
├── Public/
|
||||||
│ ├── Enumerations/ Driver, TLS
|
│ ├── Enumerations/ Driver, TLS, TradeError
|
||||||
│ ├── Methods/ Service, PrepareDB, Probe
|
│ ├── Methods/ Service, PrepareDB, Probe
|
||||||
│ ├── Models/ Shop, Listing, ListingFilter, User
|
│ ├── Models/ Shop, Listing, ListingFilter, User, Conversation, Offer, Sale
|
||||||
│ ├── Repositories/ CatalogueRepository, AccountRepository
|
│ ├── Repositories/ CatalogueRepository, AccountRepository, TradeRepository
|
||||||
│ └── Types/ Configuration
|
│ └── Types/ Configuration
|
||||||
└── Internal/
|
└── Internal/
|
||||||
├── Migrations/ CreateShop, CreateListing, SeedCatalogue, CreateUser
|
├── Migrations/ CreateShop, CreateListing, AddTradeColumns, SeedCatalogue, CreateUser, CreateTrade
|
||||||
├── Models/ ShopRecord, ListingRecord, UserRecord
|
├── Models/ ShopRecord, ListingRecord, UserRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord
|
||||||
└── Seeds/ Seed
|
└── Seeds/ Seed
|
||||||
Tests/
|
Tests/
|
||||||
├── Cases/ the test suites, mirroring the Sources/ layout
|
├── Cases/ the test suites, mirroring the Sources/ layout
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import FluentKit
|
||||||
|
|
||||||
|
/// Adds an owner to shops and a sold date to listings.
|
||||||
|
///
|
||||||
|
/// Registered ahead of ``SeedCatalogue``, whose models write both columns. The owner carries no foreign key: on a fresh database the
|
||||||
|
/// `users` table does not exist yet at this point.
|
||||||
|
struct AddTradeColumns: AsyncMigration {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
func prepare(on database: Database) async throws {
|
||||||
|
try await database.schema(ShopRecord.schema)
|
||||||
|
.field("owner_id", .uuid)
|
||||||
|
.update()
|
||||||
|
try await database.schema(ListingRecord.schema)
|
||||||
|
.field("sold_at", .datetime)
|
||||||
|
.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
func revert(on database: Database) async throws {
|
||||||
|
try await database.schema(ListingRecord.schema)
|
||||||
|
.deleteField("sold_at")
|
||||||
|
.update()
|
||||||
|
try await database.schema(ShopRecord.schema)
|
||||||
|
.deleteField("owner_id")
|
||||||
|
.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import FluentKit
|
||||||
|
|
||||||
|
/// Creates the conversations, offers, sales, and messages tables, and hands the demo shops to seller accounts, created here when missing.
|
||||||
|
struct CreateTrade: AsyncMigration {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
func prepare(on database: Database) async throws {
|
||||||
|
try await database.schema(ConversationRecord.schema)
|
||||||
|
.id()
|
||||||
|
.field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id"))
|
||||||
|
.field("shop_id", .uuid, .required, .references(ShopRecord.schema, "id"))
|
||||||
|
.field("listing_id", .uuid, .references(ListingRecord.schema, "id"))
|
||||||
|
.field("created_at", .datetime, .required)
|
||||||
|
.field("buyer_read_at", .datetime, .required)
|
||||||
|
.field("seller_read_at", .datetime, .required)
|
||||||
|
.create()
|
||||||
|
try await database.schema(OfferRecord.schema)
|
||||||
|
.id()
|
||||||
|
.field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id"))
|
||||||
|
.field("listing_id", .uuid, .required, .references(ListingRecord.schema, "id"))
|
||||||
|
.field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id"))
|
||||||
|
.field("amount_cents", .int, .required)
|
||||||
|
.field("status", .string, .required)
|
||||||
|
.field("created_at", .datetime, .required)
|
||||||
|
.field("expires_at", .datetime, .required)
|
||||||
|
.create()
|
||||||
|
try await database.schema(SaleRecord.schema)
|
||||||
|
.id()
|
||||||
|
.field("number", .int, .required)
|
||||||
|
.field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id"))
|
||||||
|
.field("listing_id", .uuid, .required, .references(ListingRecord.schema, "id"))
|
||||||
|
.field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id"))
|
||||||
|
.field("seller_id", .uuid, .required, .references(UserRecord.schema, "id"))
|
||||||
|
.field("price_cents", .int, .required)
|
||||||
|
.field("asked_cents", .int, .required)
|
||||||
|
.field("shipping_cents", .int, .required)
|
||||||
|
.field("declared_grade", .string, .required)
|
||||||
|
.field("declared_page_quality", .string)
|
||||||
|
.field("declared_printing", .string, .required)
|
||||||
|
.field("declared_notes", .string, .required)
|
||||||
|
.field("agreed_at", .datetime, .required)
|
||||||
|
.field("paid_at", .datetime)
|
||||||
|
.field("payment_note", .string)
|
||||||
|
.field("sent_at", .datetime)
|
||||||
|
.field("shipping_note", .string)
|
||||||
|
.field("received_at", .datetime)
|
||||||
|
.field("buyer_rating", .int)
|
||||||
|
.field("seller_rating", .int)
|
||||||
|
.unique(on: "number")
|
||||||
|
.create()
|
||||||
|
try await database.schema(MessageRecord.schema)
|
||||||
|
.id()
|
||||||
|
.field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id"))
|
||||||
|
.field("sender_id", .uuid, .required, .references(UserRecord.schema, "id"))
|
||||||
|
.field("kind", .string, .required)
|
||||||
|
.field("body", .string, .required)
|
||||||
|
.field("offer_id", .uuid, .references(OfferRecord.schema, "id"))
|
||||||
|
.field("sale_number", .int)
|
||||||
|
.field("created_at", .datetime, .required)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
for (slug, seller) in Seed.owners {
|
||||||
|
let owner = try await UserRecord.query(on: database)
|
||||||
|
.filter(\.$email == seller.email)
|
||||||
|
.first() ?? UserRecord(email: seller.email, displayName: seller.displayName)
|
||||||
|
|
||||||
|
try await owner.save(on: database)
|
||||||
|
try await ShopRecord.query(on: database)
|
||||||
|
.filter(\.$slug == slug)
|
||||||
|
.set(\.$owner.$id, to: try owner.requireID())
|
||||||
|
.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func revert(on database: Database) async throws {
|
||||||
|
for schema in [MessageRecord.schema, SaleRecord.schema, OfferRecord.schema, ConversationRecord.schema] {
|
||||||
|
try await database.schema(schema).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import FluentKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A FluentKit model of a single `conversations` row: a buyer and a shop, talking about a listing or the shop at large.
|
||||||
|
final class ConversationRecord: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
static let schema = "conversations"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Parent(key: "buyer_id")
|
||||||
|
var buyer: UserRecord
|
||||||
|
|
||||||
|
@Parent(key: "shop_id")
|
||||||
|
var shop: ShopRecord
|
||||||
|
|
||||||
|
@OptionalParent(key: "listing_id")
|
||||||
|
var listing: ListingRecord?
|
||||||
|
|
||||||
|
@Field(key: "created_at")
|
||||||
|
var createdAt: Date
|
||||||
|
|
||||||
|
/// When the buyer last opened the conversation; what came later is unread to them.
|
||||||
|
@Field(key: "buyer_read_at")
|
||||||
|
var buyerReadAt: Date
|
||||||
|
|
||||||
|
/// When the seller last opened the conversation; what came later is unread to them.
|
||||||
|
@Field(key: "seller_read_at")
|
||||||
|
var sellerReadAt: Date
|
||||||
|
|
||||||
|
@Children(for: \.$conversation)
|
||||||
|
var messages: [MessageRecord]
|
||||||
|
|
||||||
|
@Children(for: \.$conversation)
|
||||||
|
var offers: [OfferRecord]
|
||||||
|
|
||||||
|
@Children(for: \.$conversation)
|
||||||
|
var sales: [SaleRecord]
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
buyerID: UUID,
|
||||||
|
shopID: UUID,
|
||||||
|
listingID: UUID?,
|
||||||
|
at date: Date
|
||||||
|
) {
|
||||||
|
self.$buyer.id = buyerID
|
||||||
|
self.$shop.id = shopID
|
||||||
|
self.$listing.id = listingID
|
||||||
|
self.createdAt = date
|
||||||
|
self.buyerReadAt = date
|
||||||
|
self.sellerReadAt = date
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -91,6 +91,9 @@ final class ListingRecord: Model, @unchecked Sendable {
|
|||||||
@Field(key: "listed_at")
|
@Field(key: "listed_at")
|
||||||
var listedAt: Date
|
var listedAt: Date
|
||||||
|
|
||||||
|
@OptionalField(key: "sold_at")
|
||||||
|
var soldAt: Date?
|
||||||
|
|
||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
@@ -169,7 +172,8 @@ extension ListingRecord {
|
|||||||
imprint: coverImprint,
|
imprint: coverImprint,
|
||||||
number: coverNumber
|
number: coverNumber
|
||||||
),
|
),
|
||||||
listedAt: listedAt
|
listedAt: listedAt,
|
||||||
|
soldAt: soldAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import FluentKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A FluentKit model of a single `messages` row: something said, an offer made, or an event stamped into a conversation.
|
||||||
|
final class MessageRecord: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
static let schema = "messages"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Parent(key: "conversation_id")
|
||||||
|
var conversation: ConversationRecord
|
||||||
|
|
||||||
|
/// Who wrote the message, made the offer, or caused the event.
|
||||||
|
@Parent(key: "sender_id")
|
||||||
|
var sender: UserRecord
|
||||||
|
|
||||||
|
@Field(key: "kind")
|
||||||
|
var kind: String
|
||||||
|
|
||||||
|
@Field(key: "body")
|
||||||
|
var body: String
|
||||||
|
|
||||||
|
@OptionalParent(key: "offer_id")
|
||||||
|
var offer: OfferRecord?
|
||||||
|
|
||||||
|
@OptionalField(key: "sale_number")
|
||||||
|
var saleNumber: Int?
|
||||||
|
|
||||||
|
@Field(key: "created_at")
|
||||||
|
var createdAt: Date
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
conversationID: UUID,
|
||||||
|
senderID: UUID,
|
||||||
|
kind: Conversation.Entry.Kind,
|
||||||
|
body: String,
|
||||||
|
offerID: UUID? = nil,
|
||||||
|
saleNumber: Int? = nil,
|
||||||
|
at date: Date
|
||||||
|
) {
|
||||||
|
self.$conversation.id = conversationID
|
||||||
|
self.$sender.id = senderID
|
||||||
|
self.kind = kind.rawValue
|
||||||
|
self.body = body
|
||||||
|
self.$offer.id = offerID
|
||||||
|
self.saleNumber = saleNumber
|
||||||
|
self.createdAt = date
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import FluentKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A FluentKit model of a single `offers` row.
|
||||||
|
final class OfferRecord: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
static let schema = "offers"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Parent(key: "conversation_id")
|
||||||
|
var conversation: ConversationRecord
|
||||||
|
|
||||||
|
@Parent(key: "listing_id")
|
||||||
|
var listing: ListingRecord
|
||||||
|
|
||||||
|
@Parent(key: "buyer_id")
|
||||||
|
var buyer: UserRecord
|
||||||
|
|
||||||
|
@Field(key: "amount_cents")
|
||||||
|
var amountCents: Int
|
||||||
|
|
||||||
|
@Field(key: "status")
|
||||||
|
var status: String
|
||||||
|
|
||||||
|
@Field(key: "created_at")
|
||||||
|
var createdAt: Date
|
||||||
|
|
||||||
|
@Field(key: "expires_at")
|
||||||
|
var expiresAt: Date
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
conversationID: UUID,
|
||||||
|
listingID: UUID,
|
||||||
|
buyerID: UUID,
|
||||||
|
amountCents: Int,
|
||||||
|
at date: Date
|
||||||
|
) {
|
||||||
|
self.$conversation.id = conversationID
|
||||||
|
self.$listing.id = listingID
|
||||||
|
self.$buyer.id = buyerID
|
||||||
|
self.amountCents = amountCents
|
||||||
|
self.status = Offer.Status.open.rawValue
|
||||||
|
self.createdAt = date
|
||||||
|
self.expiresAt = date.addingTimeInterval(Offer.lifetime)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
extension OfferRecord {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// The `Sendable` snapshot of the saved record; its buyer, and its listing with its shop, must be eager-loaded.
|
||||||
|
/// - Parameter now: the moment an open offer's expiry is judged at.
|
||||||
|
func snapshot(
|
||||||
|
at now: Date
|
||||||
|
) throws -> Offer {
|
||||||
|
let stored = Offer.Status(rawValue: status) ?? .declined
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
id: try requireID(),
|
||||||
|
conversationID: $conversation.id,
|
||||||
|
listing: listing.snapshot,
|
||||||
|
buyer: try buyer.snapshot,
|
||||||
|
amountCents: amountCents,
|
||||||
|
status: stored == .open && expiresAt <= now ? .expired : stored,
|
||||||
|
createdAt: createdAt,
|
||||||
|
expiresAt: expiresAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import FluentKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A FluentKit model of a single `sales` row: the record of an agreement and of its handshake.
|
||||||
|
final class SaleRecord: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
static let schema = "sales"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Field(key: "number")
|
||||||
|
var number: Int
|
||||||
|
|
||||||
|
@Parent(key: "conversation_id")
|
||||||
|
var conversation: ConversationRecord
|
||||||
|
|
||||||
|
@Parent(key: "listing_id")
|
||||||
|
var listing: ListingRecord
|
||||||
|
|
||||||
|
@Parent(key: "buyer_id")
|
||||||
|
var buyer: UserRecord
|
||||||
|
|
||||||
|
@Parent(key: "seller_id")
|
||||||
|
var seller: UserRecord
|
||||||
|
|
||||||
|
@Field(key: "price_cents")
|
||||||
|
var priceCents: Int
|
||||||
|
|
||||||
|
@Field(key: "asked_cents")
|
||||||
|
var askedCents: Int
|
||||||
|
|
||||||
|
@Field(key: "shipping_cents")
|
||||||
|
var shippingCents: Int
|
||||||
|
|
||||||
|
@Field(key: "declared_grade")
|
||||||
|
var declaredGrade: String
|
||||||
|
|
||||||
|
@OptionalField(key: "declared_page_quality")
|
||||||
|
var declaredPageQuality: String?
|
||||||
|
|
||||||
|
@Field(key: "declared_printing")
|
||||||
|
var declaredPrinting: String
|
||||||
|
|
||||||
|
@Field(key: "declared_notes")
|
||||||
|
var declaredNotes: String
|
||||||
|
|
||||||
|
@Field(key: "agreed_at")
|
||||||
|
var agreedAt: Date
|
||||||
|
|
||||||
|
@OptionalField(key: "paid_at")
|
||||||
|
var paidAt: Date?
|
||||||
|
|
||||||
|
@OptionalField(key: "payment_note")
|
||||||
|
var paymentNote: String?
|
||||||
|
|
||||||
|
@OptionalField(key: "sent_at")
|
||||||
|
var sentAt: Date?
|
||||||
|
|
||||||
|
@OptionalField(key: "shipping_note")
|
||||||
|
var shippingNote: String?
|
||||||
|
|
||||||
|
@OptionalField(key: "received_at")
|
||||||
|
var receivedAt: Date?
|
||||||
|
|
||||||
|
@OptionalField(key: "buyer_rating")
|
||||||
|
var buyerRating: Int?
|
||||||
|
|
||||||
|
@OptionalField(key: "seller_rating")
|
||||||
|
var sellerRating: Int?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
extension SaleRecord {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The `Sendable` snapshot of the saved record; its buyer, its seller, and its listing with its shop must be eager-loaded.
|
||||||
|
var snapshot: Sale {
|
||||||
|
get throws {
|
||||||
|
.init(
|
||||||
|
number: number,
|
||||||
|
conversationID: $conversation.id,
|
||||||
|
listing: listing.snapshot,
|
||||||
|
buyer: try buyer.snapshot,
|
||||||
|
seller: try seller.snapshot,
|
||||||
|
priceCents: priceCents,
|
||||||
|
askedCents: askedCents,
|
||||||
|
shippingCents: shippingCents,
|
||||||
|
declared: .init(
|
||||||
|
grade: declaredGrade,
|
||||||
|
pageQuality: declaredPageQuality,
|
||||||
|
printing: declaredPrinting,
|
||||||
|
notes: declaredNotes
|
||||||
|
),
|
||||||
|
agreedAt: agreedAt,
|
||||||
|
paidAt: paidAt,
|
||||||
|
paymentNote: paymentNote,
|
||||||
|
sentAt: sentAt,
|
||||||
|
shippingNote: shippingNote,
|
||||||
|
receivedAt: receivedAt,
|
||||||
|
buyerRating: buyerRating,
|
||||||
|
sellerRating: sellerRating
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -50,6 +50,9 @@ final class ShopRecord: Model, @unchecked Sendable {
|
|||||||
@Field(key: "about")
|
@Field(key: "about")
|
||||||
var about: String
|
var about: String
|
||||||
|
|
||||||
|
@OptionalParent(key: "owner_id")
|
||||||
|
var owner: UserRecord?
|
||||||
|
|
||||||
@Children(for: \.$shop)
|
@Children(for: \.$shop)
|
||||||
var listings: [ListingRecord]
|
var listings: [ListingRecord]
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ enum Seed {
|
|||||||
("joost@kestrelrow.example", "Joost de Wit"),
|
("joost@kestrelrow.example", "Joost de Wit"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// The seller account owning each demo shop, by the shop's slug.
|
||||||
|
static let owners: [(shop: String, seller: (email: String, displayName: String))] = [
|
||||||
|
("kestrel-row-comics", accounts[1]),
|
||||||
|
("third-panel-supply", ("hello@thirdpanel.example", "Noor Janssens")),
|
||||||
|
("longbox-atlantic", ("sales@longboxatlantic.example", "Owen Pryce")),
|
||||||
|
("silverfish-comics", ("shop@silverfish.example", "Fenna Bos")),
|
||||||
|
("inkwell-and-staple", ("post@inkwellstaple.example", "Lukas Brandt")),
|
||||||
|
]
|
||||||
|
|
||||||
/// The demo listings, newest first.
|
/// The demo listings, newest first.
|
||||||
static let listings: [Listing] = [
|
static let listings: [Listing] = [
|
||||||
single(
|
single(
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/// Why a step towards a sale was turned down.
|
||||||
|
public enum TradeError: Error, Equatable, Sendable {
|
||||||
|
/// The amount is not a positive number of cents at or under the asking price.
|
||||||
|
case invalidAmount
|
||||||
|
/// The listing does not exist, or is sold.
|
||||||
|
case listingUnavailable
|
||||||
|
/// The account is not a party to the conversation, offer, or sale — or the step is not theirs to take now.
|
||||||
|
case notAllowed
|
||||||
|
/// The buyer already has an open offer on the listing.
|
||||||
|
case offerAlreadyOpen
|
||||||
|
/// The offer is under the floor the seller hears offers from, in euro cents.
|
||||||
|
case offerTooLow(floorCents: Int)
|
||||||
|
/// The listing's seller takes no offers.
|
||||||
|
case offersNotAccepted
|
||||||
|
/// The listing, shop, conversation, or sale belongs to the account itself.
|
||||||
|
case ownListing
|
||||||
|
/// The shop has no owner to sell on its behalf.
|
||||||
|
case shopUnattended
|
||||||
|
}
|
||||||
@@ -24,8 +24,10 @@ public struct PrepareDB: Sendable {
|
|||||||
await fluent.migrations.add([
|
await fluent.migrations.add([
|
||||||
CreateShop(),
|
CreateShop(),
|
||||||
CreateListing(),
|
CreateListing(),
|
||||||
|
AddTradeColumns(),
|
||||||
SeedCatalogue(),
|
SeedCatalogue(),
|
||||||
CreateUser(),
|
CreateUser(),
|
||||||
|
CreateTrade(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A `Sendable` snapshot of a conversation between a buyer and a shop, as one of them sees it.
|
||||||
|
public struct Conversation: Sendable, Equatable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The conversation's identifier.
|
||||||
|
public let id: UUID
|
||||||
|
/// The account buying.
|
||||||
|
public let buyer: User
|
||||||
|
/// The shop selling.
|
||||||
|
public let shop: Shop
|
||||||
|
/// The listing the conversation is about, or `nil` when it is about the shop at large.
|
||||||
|
public let listing: Listing?
|
||||||
|
/// Which side of the conversation the viewer is on.
|
||||||
|
public let side: Side
|
||||||
|
/// What was said, offered, and stamped, oldest first.
|
||||||
|
public let entries: [Entry]
|
||||||
|
/// The number of entries from the other side the viewer has not seen yet.
|
||||||
|
public let unreadCount: Int
|
||||||
|
/// The number of the sale agreed in the conversation, or `nil` when none was.
|
||||||
|
public let saleNumber: Int?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID,
|
||||||
|
buyer: User,
|
||||||
|
shop: Shop,
|
||||||
|
listing: Listing?,
|
||||||
|
side: Side,
|
||||||
|
entries: [Entry],
|
||||||
|
unreadCount: Int,
|
||||||
|
saleNumber: Int?
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.buyer = buyer
|
||||||
|
self.shop = shop
|
||||||
|
self.listing = listing
|
||||||
|
self.side = side
|
||||||
|
self.entries = entries
|
||||||
|
self.unreadCount = unreadCount
|
||||||
|
self.saleNumber = saleNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Structs
|
||||||
|
|
||||||
|
public extension Conversation {
|
||||||
|
/// One thing in a conversation: something said, an offer made, or an event stamped.
|
||||||
|
struct Entry: Sendable, Equatable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// What the entry is.
|
||||||
|
public let kind: Kind
|
||||||
|
/// Who wrote the message, made the offer, or caused the event.
|
||||||
|
public let sender: User
|
||||||
|
/// The message, the offer's note, or the event in a sentence.
|
||||||
|
public let body: String
|
||||||
|
/// The offer made, for an offer entry.
|
||||||
|
public let offer: Offer?
|
||||||
|
/// The number of the sale an event opened, or `nil`.
|
||||||
|
public let saleNumber: Int?
|
||||||
|
/// When the entry was made.
|
||||||
|
public let createdAt: Date
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
public init(
|
||||||
|
kind: Kind,
|
||||||
|
sender: User,
|
||||||
|
body: String,
|
||||||
|
offer: Offer? = nil,
|
||||||
|
saleNumber: Int? = nil,
|
||||||
|
createdAt: Date
|
||||||
|
) {
|
||||||
|
self.kind = kind
|
||||||
|
self.sender = sender
|
||||||
|
self.body = body
|
||||||
|
self.offer = offer
|
||||||
|
self.saleNumber = saleNumber
|
||||||
|
self.createdAt = createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Enumerations
|
||||||
|
|
||||||
|
/// What a conversation entry is.
|
||||||
|
public enum Kind: String, Sendable {
|
||||||
|
/// Something a party wrote.
|
||||||
|
case text
|
||||||
|
/// An offer, with its optional note.
|
||||||
|
case offer
|
||||||
|
/// Something that happened: an offer answered, a sale agreed, a step of the handshake taken.
|
||||||
|
case event
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enumerations
|
||||||
|
|
||||||
|
public extension Conversation {
|
||||||
|
/// A side of a conversation.
|
||||||
|
enum Side: String, Sendable {
|
||||||
|
case buying
|
||||||
|
case selling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
public extension Conversation {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The latest entry, or `nil` in a conversation nothing was said in yet.
|
||||||
|
var latest: Entry? {
|
||||||
|
entries.last
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The offer still waiting for an answer, or `nil` when there is none.
|
||||||
|
var openOffer: Offer? {
|
||||||
|
entries
|
||||||
|
.compactMap(\.offer)
|
||||||
|
.last { $0.status == .open }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@ public struct Listing: Sendable, Equatable {
|
|||||||
public let cover: Cover
|
public let cover: Cover
|
||||||
/// When the copy was listed.
|
/// When the copy was listed.
|
||||||
public let listedAt: Date
|
public let listedAt: Date
|
||||||
|
/// When a sale of the copy was agreed, or `nil` while it is for sale.
|
||||||
|
public let soldAt: Date?
|
||||||
|
|
||||||
// MARK: Initializers
|
// MARK: Initializers
|
||||||
|
|
||||||
@@ -77,7 +79,8 @@ public struct Listing: Sendable, Equatable {
|
|||||||
shippingCents: Int,
|
shippingCents: Int,
|
||||||
acceptsOffers: Bool,
|
acceptsOffers: Bool,
|
||||||
cover: Cover,
|
cover: Cover,
|
||||||
listedAt: Date
|
listedAt: Date,
|
||||||
|
soldAt: Date? = nil
|
||||||
) {
|
) {
|
||||||
self.slug = slug
|
self.slug = slug
|
||||||
self.shop = shop
|
self.shop = shop
|
||||||
@@ -102,6 +105,7 @@ public struct Listing: Sendable, Equatable {
|
|||||||
self.acceptsOffers = acceptsOffers
|
self.acceptsOffers = acceptsOffers
|
||||||
self.cover = cover
|
self.cover = cover
|
||||||
self.listedAt = listedAt
|
self.listedAt = listedAt
|
||||||
|
self.soldAt = soldAt
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -112,6 +116,20 @@ public extension Listing {
|
|||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The grade or condition in a few words: `CGC 9.8` for a slab, `Raw · NM` for a raw issue, the condition alone for a collected edition.
|
||||||
|
var gradeLabel: String {
|
||||||
|
if let grader, let grade {
|
||||||
|
return "\(grader) \(String(format: "%.1f", grade))"
|
||||||
|
}
|
||||||
|
|
||||||
|
return format == "single-issues" ? "Raw · \(condition)" : condition
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the copy is still for sale.
|
||||||
|
var isAvailable: Bool {
|
||||||
|
soldAt == nil
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the issue is a key.
|
/// Whether the issue is a key.
|
||||||
var isKey: Bool {
|
var isKey: Bool {
|
||||||
keyNotes != nil
|
keyNotes != nil
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/// The criteria a listing search is narrowed and ordered by; the default matches every listing, newest first.
|
/// The criteria a listing search is narrowed and ordered by; the default matches every listing for sale, newest first.
|
||||||
public struct ListingFilter: Sendable, Equatable {
|
public struct ListingFilter: Sendable, Equatable {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@@ -109,6 +109,10 @@ extension ListingFilter {
|
|||||||
func matches(
|
func matches(
|
||||||
_ listing: Listing
|
_ listing: Listing
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
guard listing.isAvailable else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if let shop, listing.shop.slug != shop {
|
if let shop, listing.shop.slug != shop {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A `Sendable` snapshot of a buyer's offer on a listing.
|
||||||
|
public struct Offer: Sendable, Equatable {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
/// The share of the asking price under which an offer is declined outright.
|
||||||
|
public static let floorShare = 0.8
|
||||||
|
|
||||||
|
/// How long an offer binds its buyer: 48 hours.
|
||||||
|
public static let lifetime: TimeInterval = 172_800
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The offer's identifier.
|
||||||
|
public let id: UUID
|
||||||
|
/// The identifier of the conversation the offer was made in.
|
||||||
|
public let conversationID: UUID
|
||||||
|
/// The listing offered on.
|
||||||
|
public let listing: Listing
|
||||||
|
/// The account offering.
|
||||||
|
public let buyer: User
|
||||||
|
/// The amount offered, in euro cents.
|
||||||
|
public let amountCents: Int
|
||||||
|
/// Where the offer stands; an open offer past its expiry reads as expired.
|
||||||
|
public let status: Status
|
||||||
|
/// When the offer was made.
|
||||||
|
public let createdAt: Date
|
||||||
|
/// When the offer stops binding its buyer.
|
||||||
|
public let expiresAt: Date
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID,
|
||||||
|
conversationID: UUID,
|
||||||
|
listing: Listing,
|
||||||
|
buyer: User,
|
||||||
|
amountCents: Int,
|
||||||
|
status: Status,
|
||||||
|
createdAt: Date,
|
||||||
|
expiresAt: Date
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.conversationID = conversationID
|
||||||
|
self.listing = listing
|
||||||
|
self.buyer = buyer
|
||||||
|
self.amountCents = amountCents
|
||||||
|
self.status = status
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enumerations
|
||||||
|
|
||||||
|
public extension Offer {
|
||||||
|
/// Where an offer stands.
|
||||||
|
enum Status: String, Sendable {
|
||||||
|
/// Waiting for the seller's answer.
|
||||||
|
case open
|
||||||
|
/// Accepted: a sale record was opened.
|
||||||
|
case accepted
|
||||||
|
/// Declined by the seller.
|
||||||
|
case declined
|
||||||
|
/// Left unanswered past its expiry, or overtaken by a sale of the listing.
|
||||||
|
case expired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
public extension Offer {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// The lowest offer a listing's seller hears, in euro cents: ``floorShare`` of the asking price, rounded up to a whole euro.
|
||||||
|
/// - Parameter askingCents: the listing's asking price, in euro cents.
|
||||||
|
static func floor(
|
||||||
|
forAsking askingCents: Int
|
||||||
|
) -> Int {
|
||||||
|
Int((Double(askingCents) * floorShare / 100).rounded(.up)) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A `Sendable` snapshot of a sale record: what was agreed, and how far its handshake has come.
|
||||||
|
///
|
||||||
|
/// The handshake has five steps — agreed, paid, sent, received, rated — each stamped by the person who took it. Payment is settled between
|
||||||
|
/// buyer and seller off-platform; the record keeps the amounts and the stamps.
|
||||||
|
public struct Sale: Sendable, Equatable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The record's number, shown as `CCN-48213`.
|
||||||
|
public let number: Int
|
||||||
|
/// The identifier of the conversation the sale was agreed in.
|
||||||
|
public let conversationID: UUID
|
||||||
|
/// The listing sold.
|
||||||
|
public let listing: Listing
|
||||||
|
/// The account buying.
|
||||||
|
public let buyer: User
|
||||||
|
/// The account selling: the owner of the listing's shop.
|
||||||
|
public let seller: User
|
||||||
|
/// The agreed price, in euro cents.
|
||||||
|
public let priceCents: Int
|
||||||
|
/// The asking price at the moment of agreement, in euro cents.
|
||||||
|
public let askedCents: Int
|
||||||
|
/// The agreed shipping cost, in euro cents.
|
||||||
|
public let shippingCents: Int
|
||||||
|
/// The condition the seller declared, frozen at the moment of agreement.
|
||||||
|
public let declared: Declaration
|
||||||
|
/// When the sale was agreed.
|
||||||
|
public let agreedAt: Date
|
||||||
|
/// When the buyer marked the sale paid.
|
||||||
|
public let paidAt: Date?
|
||||||
|
/// How the buyer says they paid.
|
||||||
|
public let paymentNote: String?
|
||||||
|
/// When the seller marked the copy sent.
|
||||||
|
public let sentAt: Date?
|
||||||
|
/// The carrier and tracking the seller gave.
|
||||||
|
public let shippingNote: String?
|
||||||
|
/// When the buyer marked the copy received.
|
||||||
|
public let receivedAt: Date?
|
||||||
|
/// The stars the buyer gave the seller, out of five.
|
||||||
|
public let buyerRating: Int?
|
||||||
|
/// The stars the seller gave the buyer, out of five.
|
||||||
|
public let sellerRating: Int?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
public init(
|
||||||
|
number: Int,
|
||||||
|
conversationID: UUID,
|
||||||
|
listing: Listing,
|
||||||
|
buyer: User,
|
||||||
|
seller: User,
|
||||||
|
priceCents: Int,
|
||||||
|
askedCents: Int,
|
||||||
|
shippingCents: Int,
|
||||||
|
declared: Declaration,
|
||||||
|
agreedAt: Date,
|
||||||
|
paidAt: Date? = nil,
|
||||||
|
paymentNote: String? = nil,
|
||||||
|
sentAt: Date? = nil,
|
||||||
|
shippingNote: String? = nil,
|
||||||
|
receivedAt: Date? = nil,
|
||||||
|
buyerRating: Int? = nil,
|
||||||
|
sellerRating: Int? = nil
|
||||||
|
) {
|
||||||
|
self.number = number
|
||||||
|
self.conversationID = conversationID
|
||||||
|
self.listing = listing
|
||||||
|
self.buyer = buyer
|
||||||
|
self.seller = seller
|
||||||
|
self.priceCents = priceCents
|
||||||
|
self.askedCents = askedCents
|
||||||
|
self.shippingCents = shippingCents
|
||||||
|
self.declared = declared
|
||||||
|
self.agreedAt = agreedAt
|
||||||
|
self.paidAt = paidAt
|
||||||
|
self.paymentNote = paymentNote
|
||||||
|
self.sentAt = sentAt
|
||||||
|
self.shippingNote = shippingNote
|
||||||
|
self.receivedAt = receivedAt
|
||||||
|
self.buyerRating = buyerRating
|
||||||
|
self.sellerRating = sellerRating
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Structs
|
||||||
|
|
||||||
|
public extension Sale {
|
||||||
|
/// The condition a seller declared, frozen when the sale was agreed: a problem report is only ever for what is not in it.
|
||||||
|
struct Declaration: Sendable, Equatable {
|
||||||
|
/// The grade or condition, e.g. `CGC 9.8`.
|
||||||
|
public let grade: String
|
||||||
|
/// The page quality, or `nil` when not stated.
|
||||||
|
public let pageQuality: String?
|
||||||
|
/// The printing in full.
|
||||||
|
public let printing: String
|
||||||
|
/// The seller's notes on the copy.
|
||||||
|
public let notes: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
grade: String,
|
||||||
|
pageQuality: String?,
|
||||||
|
printing: String,
|
||||||
|
notes: String
|
||||||
|
) {
|
||||||
|
self.grade = grade
|
||||||
|
self.pageQuality = pageQuality
|
||||||
|
self.printing = printing
|
||||||
|
self.notes = notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enumerations
|
||||||
|
|
||||||
|
public extension Sale {
|
||||||
|
/// Something a party does to move the handshake on.
|
||||||
|
enum Action: Sendable, Equatable {
|
||||||
|
/// The buyer says they paid, and how.
|
||||||
|
case markPaid(note: String)
|
||||||
|
/// The seller says the copy is on its way, and how.
|
||||||
|
case markSent(note: String)
|
||||||
|
/// The buyer says the copy arrived.
|
||||||
|
case markReceived
|
||||||
|
/// Either party rates the other, out of five.
|
||||||
|
case rate(stars: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A party to a sale.
|
||||||
|
enum Role: Sendable {
|
||||||
|
case buyer
|
||||||
|
case seller
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A step of the handshake, in order.
|
||||||
|
enum Step: Int, CaseIterable, Comparable, Sendable {
|
||||||
|
case agreed = 1
|
||||||
|
case paid
|
||||||
|
case sent
|
||||||
|
case received
|
||||||
|
case rated
|
||||||
|
|
||||||
|
public static func < (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
lhs.rawValue < rhs.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
public extension Sale {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The last step taken. Rated counts once both parties have rated.
|
||||||
|
var completed: Step {
|
||||||
|
if buyerRating != nil, sellerRating != nil {
|
||||||
|
return .rated
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedAt != nil {
|
||||||
|
return .received
|
||||||
|
}
|
||||||
|
|
||||||
|
if sentAt != nil {
|
||||||
|
return .sent
|
||||||
|
}
|
||||||
|
|
||||||
|
return paidAt != nil ? .paid : .agreed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The step waited on, or `nil` once the handshake is done.
|
||||||
|
var next: Step? {
|
||||||
|
.init(rawValue: completed.rawValue + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The agreed price and shipping together, in euro cents.
|
||||||
|
var totalCents: Int {
|
||||||
|
priceCents + shippingCents
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Whether a party may take an action now.
|
||||||
|
///
|
||||||
|
/// Each step waits for the one before it and is taken once, by the party it belongs to: the buyer pays and receives, the seller sends.
|
||||||
|
/// Ratings unlock only after the copy is received, so every rating maps to a finished sale.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - action: the action to take.
|
||||||
|
/// - role: the party taking it.
|
||||||
|
func allows(
|
||||||
|
_ action: Action,
|
||||||
|
by role: Role
|
||||||
|
) -> Bool {
|
||||||
|
switch action {
|
||||||
|
case .markPaid: role == .buyer && completed == .agreed
|
||||||
|
case .markSent: role == .seller && completed == .paid
|
||||||
|
case .markReceived: role == .buyer && completed == .sent
|
||||||
|
case .rate(let stars): receivedAt != nil && (1...5).contains(stars) && rating(by: role) == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stars a party gave, or `nil` when they have not rated yet.
|
||||||
|
func rating(
|
||||||
|
by role: Role
|
||||||
|
) -> Int? {
|
||||||
|
role == .buyer ? buyerRating : sellerRating
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The party an account is to the sale, or `nil` when it is neither.
|
||||||
|
/// - Parameter userID: the account's identifier.
|
||||||
|
func role(
|
||||||
|
of userID: UUID
|
||||||
|
) -> Role? {
|
||||||
|
if userID == buyer.id {
|
||||||
|
return .buyer
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID == seller.id ? .seller : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Persistence
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"Sale model",
|
||||||
|
.tags(.model)
|
||||||
|
)
|
||||||
|
struct SaleTests {
|
||||||
|
|
||||||
|
// MARK: Computed tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `walks the handshake one step at a time`() {
|
||||||
|
#expect(sale().completed == .agreed)
|
||||||
|
#expect(sale().next == .paid)
|
||||||
|
#expect(sale(paid: true).completed == .paid)
|
||||||
|
#expect(sale(paid: true, sent: true).completed == .sent)
|
||||||
|
#expect(sale(paid: true, sent: true, received: true).completed == .received)
|
||||||
|
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5).completed == .received)
|
||||||
|
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4).completed == .rated)
|
||||||
|
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4).next == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `adds the shipping to the price`() {
|
||||||
|
#expect(sale().totalCents == 22_100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Methods tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `gives each step to its party, once, in order`() {
|
||||||
|
#expect(sale().allows(.markPaid(note: ""), by: .buyer))
|
||||||
|
#expect(!sale().allows(.markPaid(note: ""), by: .seller))
|
||||||
|
#expect(!sale(paid: true).allows(.markPaid(note: ""), by: .buyer))
|
||||||
|
|
||||||
|
#expect(!sale().allows(.markSent(note: ""), by: .seller))
|
||||||
|
#expect(sale(paid: true).allows(.markSent(note: ""), by: .seller))
|
||||||
|
#expect(!sale(paid: true).allows(.markSent(note: ""), by: .buyer))
|
||||||
|
|
||||||
|
#expect(!sale(paid: true).allows(.markReceived, by: .buyer))
|
||||||
|
#expect(sale(paid: true, sent: true).allows(.markReceived, by: .buyer))
|
||||||
|
#expect(!sale(paid: true, sent: true).allows(.markReceived, by: .seller))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `unlocks ratings only once the copy is received, one per party, out of five`() {
|
||||||
|
let received = sale(paid: true, sent: true, received: true)
|
||||||
|
|
||||||
|
#expect(!sale(paid: true, sent: true).allows(.rate(stars: 5), by: .buyer))
|
||||||
|
#expect(received.allows(.rate(stars: 5), by: .buyer))
|
||||||
|
#expect(received.allows(.rate(stars: 1), by: .seller))
|
||||||
|
#expect(!received.allows(.rate(stars: 0), by: .buyer))
|
||||||
|
#expect(!received.allows(.rate(stars: 6), by: .buyer))
|
||||||
|
#expect(!sale(paid: true, sent: true, received: true, buyerRating: 5).allows(.rate(stars: 4), by: .buyer))
|
||||||
|
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5).allows(.rate(stars: 4), by: .seller))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `knows its parties`() {
|
||||||
|
let sale = sale()
|
||||||
|
|
||||||
|
#expect(sale.role(of: sale.buyer.id) == .buyer)
|
||||||
|
#expect(sale.role(of: sale.seller.id) == .seller)
|
||||||
|
#expect(sale.role(of: .init()) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Offer tests
|
||||||
|
|
||||||
|
@Test(arguments: zip(
|
||||||
|
[24_000, 1_400, 4_500, 100],
|
||||||
|
[19_200, 1_200, 3_600, 100]
|
||||||
|
))
|
||||||
|
func `floors offers at four fifths of the asking price, in whole euros`(
|
||||||
|
asking: Int,
|
||||||
|
expects floor: Int
|
||||||
|
) {
|
||||||
|
#expect(Offer.floor(forAsking: asking) == floor)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension SaleTests {
|
||||||
|
|
||||||
|
/// A sale of the first demo listing at €215 + €6, as far along as told.
|
||||||
|
func sale(
|
||||||
|
paid: Bool = false,
|
||||||
|
sent: Bool = false,
|
||||||
|
received: Bool = false,
|
||||||
|
buyerRating: Int? = nil,
|
||||||
|
sellerRating: Int? = nil
|
||||||
|
) -> Sale {
|
||||||
|
let stamp = Date(timeIntervalSince1970: 0)
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
number: 48_213,
|
||||||
|
conversationID: .init(),
|
||||||
|
listing: Seed.listings[0],
|
||||||
|
buyer: .init(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser"),
|
||||||
|
seller: .init(id: .init(), email: "joost@kestrelrow.example", displayName: "Joost de Wit"),
|
||||||
|
priceCents: 21_500,
|
||||||
|
askedCents: 24_000,
|
||||||
|
shippingCents: 600,
|
||||||
|
declared: .init(grade: "CGC 9.8", pageQuality: "White", printing: "First printing", notes: ""),
|
||||||
|
agreedAt: stamp,
|
||||||
|
paidAt: paid ? stamp : nil,
|
||||||
|
sentAt: sent ? stamp : nil,
|
||||||
|
receivedAt: received ? stamp : nil,
|
||||||
|
buyerRating: buyerRating,
|
||||||
|
sellerRating: sellerRating
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import Foundation
|
||||||
|
import HummingbirdFluent
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Persistence
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"TradeRepository repository",
|
||||||
|
.tags(.repository)
|
||||||
|
)
|
||||||
|
struct TradeRepositoryTests {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
/// The first demo listing: Kestrel Row's Nightward #1, asking €240, open to offers.
|
||||||
|
private let slug = "nightward-1-cgc-9-8"
|
||||||
|
|
||||||
|
// MARK: Offers tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `makes an offer in the buyer's conversation about the listing`() async throws {
|
||||||
|
try await withTrade { trade, mara, joost, _ in
|
||||||
|
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: " Would you take €215? ")
|
||||||
|
let buying = try #require(try await trade.conversation(id: id, for: mara))
|
||||||
|
let inbox = try await trade.openOffers(for: joost)
|
||||||
|
|
||||||
|
#expect(buying.side == .buying)
|
||||||
|
#expect(buying.listing?.slug == slug)
|
||||||
|
#expect(buying.entries.map(\.kind) == [.offer])
|
||||||
|
#expect(buying.entries[0].body == "Would you take €215?")
|
||||||
|
#expect(buying.openOffer?.amountCents == 21_500)
|
||||||
|
#expect(inbox.map(\.amountCents) == [21_500])
|
||||||
|
#expect(inbox[0].buyer == mara)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `turns down offers that break the rules`() async throws {
|
||||||
|
try await withTrade { trade, mara, joost, _ in
|
||||||
|
await #expect(throws: TradeError.offerTooLow(floorCents: 19_200)) {
|
||||||
|
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 19_100, note: "")
|
||||||
|
}
|
||||||
|
await #expect(throws: TradeError.invalidAmount) {
|
||||||
|
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 24_100, note: "")
|
||||||
|
}
|
||||||
|
await #expect(throws: TradeError.invalidAmount) {
|
||||||
|
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 0, note: "")
|
||||||
|
}
|
||||||
|
await #expect(throws: TradeError.offersNotAccepted) {
|
||||||
|
try await trade.makeOffer(onListing: "echo-country-3", buyer: mara, amountCents: 1_300, note: "")
|
||||||
|
}
|
||||||
|
await #expect(throws: TradeError.ownListing) {
|
||||||
|
try await trade.makeOffer(onListing: slug, buyer: joost, amountCents: 21_500, note: "")
|
||||||
|
}
|
||||||
|
await #expect(throws: TradeError.listingUnavailable) {
|
||||||
|
try await trade.makeOffer(onListing: "missing", buyer: mara, amountCents: 21_500, note: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
|
||||||
|
|
||||||
|
await #expect(throws: TradeError.offerAlreadyOpen) {
|
||||||
|
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 22_000, note: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `lapses an offer left unanswered for 48 hours`() async throws {
|
||||||
|
try await withTrade { trade, mara, joost, clock in
|
||||||
|
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
|
||||||
|
let offer = try #require(try await trade.openOffers(for: joost).first)
|
||||||
|
|
||||||
|
clock.advance(by: Offer.lifetime)
|
||||||
|
|
||||||
|
let conversation = try #require(try await trade.conversation(id: id, for: mara))
|
||||||
|
let inbox = try await trade.openOffers(for: joost)
|
||||||
|
|
||||||
|
#expect(conversation.entries[0].offer?.status == .expired)
|
||||||
|
#expect(conversation.openOffer == nil)
|
||||||
|
#expect(inbox.isEmpty)
|
||||||
|
|
||||||
|
await #expect(throws: TradeError.notAllowed) {
|
||||||
|
try await trade.respond(toOffer: offer.id, accepting: true, by: joost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The listing is free for a new offer.
|
||||||
|
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 22_000, note: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `declines an offer, leaving the listing for sale`() async throws {
|
||||||
|
try await withTrade { trade, mara, joost, _ in
|
||||||
|
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
|
||||||
|
let offer = try #require(try await trade.openOffers(for: joost).first)
|
||||||
|
let number = try await trade.respond(toOffer: offer.id, accepting: false, by: joost)
|
||||||
|
let conversation = try #require(try await trade.conversation(id: id, for: mara))
|
||||||
|
|
||||||
|
#expect(number == nil)
|
||||||
|
#expect(conversation.entries.map(\.kind) == [.offer, .event])
|
||||||
|
#expect(conversation.entries[0].offer?.status == .declined)
|
||||||
|
#expect(conversation.entries[1].body == "declined the offer")
|
||||||
|
#expect(conversation.saleNumber == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `lets only the shop's owner answer an offer`() async throws {
|
||||||
|
try await withTrade { trade, mara, joost, _ in
|
||||||
|
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
|
||||||
|
|
||||||
|
let offer = try #require(try await trade.openOffers(for: joost).first)
|
||||||
|
|
||||||
|
await #expect(throws: TradeError.notAllowed) {
|
||||||
|
try await trade.respond(toOffer: offer.id, accepting: true, by: mara)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Sales tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `accepting an offer opens a sale, takes the listing off sale, and lapses the other offers`() async throws {
|
||||||
|
try await withDatabase { fluent in
|
||||||
|
let clock = Clock()
|
||||||
|
let trade = TradeRepository(fluent: fluent, now: clock.now)
|
||||||
|
let accounts = AccountRepository(fluent: fluent)
|
||||||
|
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||||
|
let sam = try await accounts.account(email: "sam@example.com")
|
||||||
|
let joost = try await accounts.account(email: "joost@kestrelrow.example")
|
||||||
|
|
||||||
|
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
|
||||||
|
|
||||||
|
let other = try await trade.makeOffer(onListing: slug, buyer: sam, amountCents: 20_000, note: "")
|
||||||
|
let offer = try #require(try await trade.openOffers(for: joost).first { $0.buyer == mara })
|
||||||
|
let number = try #require(try await trade.respond(toOffer: offer.id, accepting: true, by: joost))
|
||||||
|
let sale = try #require(try await trade.sale(number: number, for: mara))
|
||||||
|
let listing = try await CatalogueRepository(fluent: fluent).listing(slug: slug)
|
||||||
|
let forSale = try await CatalogueRepository(fluent: fluent).listings()
|
||||||
|
let lapsed = try #require(try await trade.conversation(id: other, for: sam))
|
||||||
|
let inbox = try await trade.openOffers(for: joost)
|
||||||
|
|
||||||
|
#expect(number == 48_213)
|
||||||
|
#expect(sale.priceCents == 21_500)
|
||||||
|
#expect(sale.askedCents == 24_000)
|
||||||
|
#expect(sale.shippingCents == 600)
|
||||||
|
#expect(sale.declared.grade == "CGC 9.8 · cert 4312087-002")
|
||||||
|
#expect(sale.buyer == mara)
|
||||||
|
#expect(sale.seller == joost)
|
||||||
|
#expect(sale.completed == .agreed)
|
||||||
|
#expect(listing?.isAvailable == false)
|
||||||
|
#expect(!forSale.contains { $0.slug == slug })
|
||||||
|
#expect(lapsed.entries[0].offer?.status == .expired)
|
||||||
|
#expect(inbox.isEmpty)
|
||||||
|
|
||||||
|
await #expect(throws: TradeError.listingUnavailable) {
|
||||||
|
try await trade.buy(listing: slug, buyer: sam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `buys at the asking price, numbering the sales in order`() async throws {
|
||||||
|
try await withTrade { trade, mara, _, _ in
|
||||||
|
let first = try await trade.buy(listing: slug, buyer: mara)
|
||||||
|
let second = try await trade.buy(listing: "echo-country-3", buyer: mara)
|
||||||
|
let sale = try #require(try await trade.sale(number: second, for: mara))
|
||||||
|
let sales = try await trade.sales(for: mara)
|
||||||
|
|
||||||
|
#expect(first == 48_213)
|
||||||
|
#expect(second == 48_214)
|
||||||
|
#expect(sale.priceCents == sale.askedCents)
|
||||||
|
#expect(sales.count == 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `walks the handshake, stamping each step into the conversation`() async throws {
|
||||||
|
try await withTrade { trade, mara, joost, _ in
|
||||||
|
let number = try await trade.buy(listing: slug, buyer: mara)
|
||||||
|
|
||||||
|
await #expect(throws: TradeError.notAllowed) {
|
||||||
|
try await trade.perform(.markSent(note: ""), onSale: number, by: joost)
|
||||||
|
}
|
||||||
|
await #expect(throws: TradeError.notAllowed) {
|
||||||
|
try await trade.perform(.markPaid(note: ""), onSale: number, by: joost)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await trade.perform(.markPaid(note: " iDEAL "), onSale: number, by: mara)
|
||||||
|
try await trade.perform(.markSent(note: "DHL 3S123"), onSale: number, by: joost)
|
||||||
|
try await trade.perform(.markReceived, onSale: number, by: mara)
|
||||||
|
try await trade.perform(.rate(stars: 5), onSale: number, by: mara)
|
||||||
|
|
||||||
|
let sale = try await trade.perform(.rate(stars: 4), onSale: number, by: joost)
|
||||||
|
let conversation = try #require(try await trade.conversation(id: sale.conversationID, for: joost))
|
||||||
|
|
||||||
|
#expect(sale.completed == .rated)
|
||||||
|
#expect(sale.paymentNote == "iDEAL")
|
||||||
|
#expect(sale.shippingNote == "DHL 3S123")
|
||||||
|
#expect(sale.buyerRating == 5)
|
||||||
|
#expect(sale.sellerRating == 4)
|
||||||
|
#expect(conversation.saleNumber == number)
|
||||||
|
#expect(conversation.entries.map(\.body) == [
|
||||||
|
"bought the copy at the asking price", "marked the sale paid", "marked the copy sent",
|
||||||
|
"marked the copy received", "left a rating", "left a rating",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `shows a sale to its parties alone`() async throws {
|
||||||
|
try await withDatabase { fluent in
|
||||||
|
let trade = TradeRepository(fluent: fluent)
|
||||||
|
let accounts = AccountRepository(fluent: fluent)
|
||||||
|
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||||
|
let sam = try await accounts.account(email: "sam@example.com")
|
||||||
|
let number = try await trade.buy(listing: slug, buyer: mara)
|
||||||
|
let strangers = try await trade.sale(number: number, for: sam)
|
||||||
|
let none = try await trade.sales(for: sam)
|
||||||
|
|
||||||
|
#expect(strangers == nil)
|
||||||
|
#expect(none.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Conversations tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `keeps one conversation per buyer and listing, and one with the shop at large`() async throws {
|
||||||
|
try await withTrade { trade, mara, _, _ in
|
||||||
|
let first = try await trade.conversation(aboutListing: slug, buyer: mara)
|
||||||
|
let again = try await trade.conversation(aboutListing: slug, buyer: mara)
|
||||||
|
let shop = try await trade.conversation(withShop: "kestrel-row-comics", buyer: mara)
|
||||||
|
let shopAgain = try await trade.conversation(withShop: "kestrel-row-comics", buyer: mara)
|
||||||
|
|
||||||
|
#expect(first == again)
|
||||||
|
#expect(shop == shopAgain)
|
||||||
|
#expect(first != shop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `counts what the other side said as unread until the conversation is opened`() async throws {
|
||||||
|
try await withTrade { trade, mara, joost, clock in
|
||||||
|
let id = try await trade.conversation(aboutListing: slug, buyer: mara)
|
||||||
|
|
||||||
|
clock.advance(by: 60)
|
||||||
|
try await trade.post("Is the case clean?", to: id, from: mara)
|
||||||
|
try await trade.post(" ", to: id, from: mara)
|
||||||
|
|
||||||
|
let before = try await trade.unreadCount(for: joost)
|
||||||
|
let own = try await trade.unreadCount(for: mara)
|
||||||
|
|
||||||
|
clock.advance(by: 60)
|
||||||
|
|
||||||
|
let opened = try #require(try await trade.conversation(id: id, for: joost))
|
||||||
|
let after = try await trade.unreadCount(for: joost)
|
||||||
|
|
||||||
|
#expect(before == 1)
|
||||||
|
#expect(own == 0)
|
||||||
|
#expect(opened.side == .selling)
|
||||||
|
#expect(opened.unreadCount == 1)
|
||||||
|
#expect(opened.entries.map(\.body) == ["Is the case clean?"])
|
||||||
|
#expect(after == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `keeps strangers out of a conversation`() async throws {
|
||||||
|
try await withDatabase { fluent in
|
||||||
|
let trade = TradeRepository(fluent: fluent)
|
||||||
|
let accounts = AccountRepository(fluent: fluent)
|
||||||
|
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||||
|
let sam = try await accounts.account(email: "sam@example.com")
|
||||||
|
let id = try await trade.conversation(aboutListing: slug, buyer: mara)
|
||||||
|
let peek = try await trade.conversation(id: id, for: sam)
|
||||||
|
|
||||||
|
#expect(peek == nil)
|
||||||
|
|
||||||
|
await #expect(throws: TradeError.notAllowed) {
|
||||||
|
try await trade.post("Hello", to: id, from: sam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `finds the shop an account owns`() async throws {
|
||||||
|
try await withTrade { trade, mara, joost, _ in
|
||||||
|
let owned = try await trade.shop(ownedBy: joost)
|
||||||
|
let none = try await trade.shop(ownedBy: mara)
|
||||||
|
|
||||||
|
#expect(owned?.slug == "kestrel-row-comics")
|
||||||
|
#expect(none == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// A clock the tests move by hand.
|
||||||
|
final class Clock: @unchecked Sendable {
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var date = Date(timeIntervalSince1970: 1_790_000_000)
|
||||||
|
|
||||||
|
/// The clock's reading.
|
||||||
|
@Sendable
|
||||||
|
func now() -> Date {
|
||||||
|
lock.withLock { date }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the clock forward.
|
||||||
|
func advance(
|
||||||
|
by interval: TimeInterval
|
||||||
|
) {
|
||||||
|
lock.withLock { date.addTimeInterval(interval) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension TradeRepositoryTests {
|
||||||
|
|
||||||
|
/// Runs the body against a seeded database, with the demo buyer, the first demo listing's seller, and the clock the repository reads.
|
||||||
|
func withTrade(
|
||||||
|
_ body: (TradeRepository, User, User, Clock) async throws -> Void
|
||||||
|
) async throws {
|
||||||
|
try await withDatabase { fluent in
|
||||||
|
let accounts = AccountRepository(fluent: fluent)
|
||||||
|
let clock = Clock()
|
||||||
|
|
||||||
|
try await body(
|
||||||
|
.init(fluent: fluent, now: clock.now),
|
||||||
|
try await accounts.account(email: "mara.v@example.com"),
|
||||||
|
try await accounts.account(email: "joost@kestrelrow.example"),
|
||||||
|
clock
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in new issue
Block a user