From 89a7a7b8e5e7cb17e5d831a422d40e012700f8dd Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 21 Sep 2026 13:31:44 +0200 Subject: [PATCH] Added the catalogue models and seed to support phase 5 of the POC prototype in the Persistence package. --- Packages/Persistence/README.md | 15 +- .../Migrations/AddShopRegistration.swift | 30 +++ .../Migrations/CreateCollecting.swift | 41 ++++ .../Internal/Models/SeriesRecord.swift | 67 ++++++ .../Sources/Internal/Models/ShopRecord.swift | 23 ++- .../Sources/Internal/Models/WantRecord.swift | 43 ++++ .../Sources/Internal/Seeds/Seed.swift | 14 ++ .../Public/Enumerations/ShopError.swift | 7 + .../Sources/Public/Methods/PrepareDB.swift | 2 + .../Sources/Public/Models/Listing.swift | 18 ++ .../Sources/Public/Models/Series.swift | 37 ++++ .../Sources/Public/Models/Shop.swift | 43 +++- .../Sources/Public/Models/ShopDraft.swift | 57 +++++ .../Sources/Public/Models/Want.swift | 78 +++++++ .../Repositories/AccountRepository.swift | 74 ++++++- .../Repositories/CatalogueRepository.swift | 51 +++++ .../Repositories/CollectingRepository.swift | 194 ++++++++++++++++++ .../Repositories/AccountRepositoryTests.swift | 80 ++++++++ .../CollectingRepositoryTests.swift | 131 ++++++++++++ 19 files changed, 990 insertions(+), 15 deletions(-) create mode 100644 Packages/Persistence/Sources/Internal/Migrations/AddShopRegistration.swift create mode 100644 Packages/Persistence/Sources/Internal/Migrations/CreateCollecting.swift create mode 100644 Packages/Persistence/Sources/Internal/Models/SeriesRecord.swift create mode 100644 Packages/Persistence/Sources/Internal/Models/WantRecord.swift create mode 100644 Packages/Persistence/Sources/Public/Enumerations/ShopError.swift create mode 100644 Packages/Persistence/Sources/Public/Models/Series.swift create mode 100644 Packages/Persistence/Sources/Public/Models/ShopDraft.swift create mode 100644 Packages/Persistence/Sources/Public/Models/Want.swift create mode 100644 Packages/Persistence/Sources/Public/Repositories/CollectingRepository.swift create mode 100644 Packages/Persistence/Tests/Cases/Public/Repositories/CollectingRepositoryTests.swift diff --git a/Packages/Persistence/README.md b/Packages/Persistence/README.md index 080e656..2eca5c3 100644 --- a/Packages/Persistence/README.md +++ b/Packages/Persistence/README.md @@ -9,15 +9,16 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da | 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 | | Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot, and keeps each account's `Passkey`s — one is only ever registered together with a new account; `CreateUser` seeds the prototype's demo accounts | +| Collecting | `CollectingRepository`, which keeps an account's `Want`s with the listings meeting each, and reads the `Series` catalogue | | 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`, which publishes a seller's `ListingDraft` under a slug of its own, and returns `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data | +| Catalogue | `CatalogueRepository`, which opens a shop from a `ShopDraft`, publishes a seller's `ListingDraft` under a slug of its own, and returns `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data | ## Design rules - **The package reads no configuration.** The executable maps its `database.*` keys onto a `Driver` and hands it over; connection values arrive as plain data. The Website service's `ConfigReader+Properties` has the mapping. - **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. - **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` and `AddListingDeclarations` are registered ahead of `SeedCatalogue`, whose models write their columns; Fluent tracks migrations by name, so a database already past the seed still gains them. +- **Registration order is not creation order.** `AddTradeColumns`, `AddListingDeclarations`, and `AddShopRegistration` are registered ahead of `SeedCatalogue`, whose models write their 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. - **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. @@ -30,14 +31,14 @@ Sources are split by visibility, then by kind, one type per file: ``` Sources/ ├── Public/ -│ ├── Enumerations/ Driver, TLS, TradeError +│ ├── Enumerations/ Driver, TLS, ShopError, TradeError │ ├── Methods/ Service, PrepareDB, Probe -│ ├── Models/ Shop, Listing, ListingDraft, ListingFilter, User, Passkey, Conversation, Offer, Sale -│ ├── Repositories/ CatalogueRepository, AccountRepository, TradeRepository +│ ├── Models/ Shop, ShopDraft, Listing, ListingDraft, ListingFilter, User, Passkey, Want, Series, Conversation, Offer, Sale +│ ├── Repositories/ CatalogueRepository, AccountRepository, CollectingRepository, TradeRepository │ └── Types/ Configuration └── Internal/ - ├── Migrations/ CreateShop, CreateListing, AddTradeColumns, AddListingDeclarations, SeedCatalogue, CreateUser, CreateTrade, CreatePasskey - ├── Models/ ShopRecord, ListingRecord, UserRecord, PasskeyRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord + ├── Migrations/ CreateShop, CreateListing, AddTradeColumns, AddListingDeclarations, AddShopRegistration, SeedCatalogue, CreateUser, CreateTrade, CreatePasskey, CreateCollecting + ├── Models/ ShopRecord, ListingRecord, UserRecord, PasskeyRecord, WantRecord, SeriesRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord └── Seeds/ Seed Tests/ ├── Cases/ the test suites, mirroring the Sources/ layout diff --git a/Packages/Persistence/Sources/Internal/Migrations/AddShopRegistration.swift b/Packages/Persistence/Sources/Internal/Migrations/AddShopRegistration.swift new file mode 100644 index 0000000..a595a6e --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/AddShopRegistration.swift @@ -0,0 +1,30 @@ +import FluentKit + +/// Adds the seller kind and the business registration to shops. +/// +/// Registered ahead of ``SeedCatalogue``, whose model writes the columns. One column per update: SQLite adds no more in a statement. +struct AddShopRegistration: AsyncMigration { + + // MARK: Constants + + private static let columns = ["kind", "kvk_number", "vat_id", "legal_name"] + + // MARK: Methods + + func prepare(on database: Database) async throws { + for column in Self.columns { + try await database.schema(ShopRecord.schema) + .field(.string(column), .string) + .update() + } + } + + func revert(on database: Database) async throws { + for column in Self.columns.reversed() { + try await database.schema(ShopRecord.schema) + .deleteField(.string(column)) + .update() + } + } + +} diff --git a/Packages/Persistence/Sources/Internal/Migrations/CreateCollecting.swift b/Packages/Persistence/Sources/Internal/Migrations/CreateCollecting.swift new file mode 100644 index 0000000..73ff1aa --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/CreateCollecting.swift @@ -0,0 +1,41 @@ +import FluentKit + +/// Creates the `wants` table, and the `series` table seeded with the runs the demo listings belong to. +struct CreateCollecting: AsyncMigration { + + // MARK: Methods + + func prepare(on database: Database) async throws { + try await database.schema(WantRecord.schema) + .id() + .field("user_id", .uuid, .required, .references(UserRecord.schema, "id", onDelete: .cascade)) + .field("title", .string, .required) + .field("min_grade", .double) + .field("max_price_cents", .int) + .field("slabbed_only", .bool, .required) + .field("alerts", .bool, .required) + .field("created_at", .datetime, .required) + .field("seen_at", .datetime, .required) + .create() + try await database.schema(SeriesRecord.schema) + .id() + .field("slug", .string, .required) + .field("name", .string, .required) + .field("publisher", .string, .required) + .field("years", .string, .required) + .field("issue_count", .int, .required) + .field("blurb", .string, .required) + .unique(on: "slug") + .create() + + for series in Seed.series { + try await SeriesRecord(series).save(on: database) + } + } + + func revert(on database: Database) async throws { + try await database.schema(SeriesRecord.schema).delete() + try await database.schema(WantRecord.schema).delete() + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/SeriesRecord.swift b/Packages/Persistence/Sources/Internal/Models/SeriesRecord.swift new file mode 100644 index 0000000..5a2ff02 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/SeriesRecord.swift @@ -0,0 +1,67 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `series` row: a run of issues in the catalogue. +final class SeriesRecord: Model, @unchecked Sendable { + + // MARK: Properties + + static let schema = "series" + + @ID(key: .id) + var id: UUID? + + @Field(key: "slug") + var slug: String + + @Field(key: "name") + var name: String + + @Field(key: "publisher") + var publisher: String + + @Field(key: "years") + var years: String + + @Field(key: "issue_count") + var issueCount: Int + + @Field(key: "blurb") + var blurb: String + + // MARK: Initializers + + init() {} + + init( + _ series: Series + ) { + self.slug = series.slug + self.name = series.name + self.publisher = series.publisher + self.years = series.years + self.issueCount = series.issueCount + self.blurb = series.blurb + } + +} + +// MARK: - Extensions + +extension SeriesRecord { + + // MARK: Computed + + /// The `Sendable` snapshot of the record. + var snapshot: Series { + .init( + slug: slug, + name: name, + publisher: publisher, + years: years, + issueCount: issueCount, + blurb: blurb + ) + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/ShopRecord.swift b/Packages/Persistence/Sources/Internal/Models/ShopRecord.swift index 2b94955..6ddc2e2 100644 --- a/Packages/Persistence/Sources/Internal/Models/ShopRecord.swift +++ b/Packages/Persistence/Sources/Internal/Models/ShopRecord.swift @@ -53,6 +53,19 @@ final class ShopRecord: Model, @unchecked Sendable { @OptionalParent(key: "owner_id") var owner: UserRecord? + /// The seller kind token; `nil` reads as a business, which every seeded shop is. + @OptionalField(key: "kind") + var kind: String? + + @OptionalField(key: "kvk_number") + var kvkNumber: String? + + @OptionalField(key: "vat_id") + var vatID: String? + + @OptionalField(key: "legal_name") + var legalName: String? + @Children(for: \.$shop) var listings: [ListingRecord] @@ -78,6 +91,10 @@ final class ShopRecord: Model, @unchecked Sendable { self.shipsNote = shop.shipsNote self.returnsNote = shop.returnsNote self.about = shop.about + self.kind = shop.kind.rawValue + self.kvkNumber = shop.kvkNumber + self.vatID = shop.vatID + self.legalName = shop.legalName } } @@ -103,7 +120,11 @@ extension ShopRecord { handlingDays: handlingDays, shipsNote: shipsNote, returnsNote: returnsNote, - about: about + about: about, + kind: kind.flatMap(Shop.Kind.init) ?? .business, + kvkNumber: kvkNumber, + vatID: vatID, + legalName: legalName ) } diff --git a/Packages/Persistence/Sources/Internal/Models/WantRecord.swift b/Packages/Persistence/Sources/Internal/Models/WantRecord.swift new file mode 100644 index 0000000..271137b --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/WantRecord.swift @@ -0,0 +1,43 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `wants` row: a copy an account is looking for. +final class WantRecord: Model, @unchecked Sendable { + + // MARK: Properties + + static let schema = "wants" + + @ID(key: .id) + var id: UUID? + + @Parent(key: "user_id") + var user: UserRecord + + @Field(key: "title") + var title: String + + @OptionalField(key: "min_grade") + var minGrade: Double? + + @OptionalField(key: "max_price_cents") + var maxPriceCents: Int? + + @Field(key: "slabbed_only") + var slabbedOnly: Bool + + @Field(key: "alerts") + var alerts: Bool + + @Field(key: "created_at") + var createdAt: Date + + /// When the account last looked at the want; matches listed later are new to them. + @Field(key: "seen_at") + var seenAt: Date + + // MARK: Initializers + + init() {} + +} diff --git a/Packages/Persistence/Sources/Internal/Seeds/Seed.swift b/Packages/Persistence/Sources/Internal/Seeds/Seed.swift index 56e7ba2..2e48b9a 100644 --- a/Packages/Persistence/Sources/Internal/Seeds/Seed.swift +++ b/Packages/Persistence/Sources/Internal/Seeds/Seed.swift @@ -158,6 +158,20 @@ enum Seed { ), ] + /// The runs the demo listings belong to. + static let series: [Series] = [ + .init(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019–2021", issueCount: 24, + blurb: "Two-year run, one printing for most issues. #1 has a second printing with a red logo; the hardcover collects #1–12."), + .init(slug: "ferrous-saint", name: "Ferrous Saint", publisher: "Blackline", years: "1993–1996", issueCount: 30, + blurb: "A nineties independent with direct and newsstand editions throughout. The omnibus collects the whole run."), + .init(slug: "echo-country", name: "Echo Country", publisher: "Harbour & Vale", years: "2021–2022", issueCount: 6, + blurb: "A six-issue limited series. Later printings carry recoloured covers."), + .init(slug: "vermilion-lake", name: "Vermilion Lake", publisher: "Meridian Comics", years: "1986–1989", issueCount: 18, + blurb: "Meridian's late-eighties flagship. #7 tells the origin of the Lake Warden."), + .init(slug: "paper-tigers", name: "Paper Tigers", publisher: "Cutwater Press", years: "2023–", issueCount: 4, + blurb: "Ongoing. Every issue ships with a 1:10 retailer variant."), + ] + /// The demo shops. static let shops: [Shop] = [kestrelRow, thirdPanel, longbox, silverfish, inkwell] diff --git a/Packages/Persistence/Sources/Public/Enumerations/ShopError.swift b/Packages/Persistence/Sources/Public/Enumerations/ShopError.swift new file mode 100644 index 0000000..cc73bf0 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Enumerations/ShopError.swift @@ -0,0 +1,7 @@ +/// Why a shop could not be opened. +public enum ShopError: Error, Equatable, Sendable { + /// The account already owns a shop. + case alreadyOwner + /// Another shop has the handle. + case handleTaken +} diff --git a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift index b48d10e..bd8d39d 100644 --- a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift +++ b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift @@ -26,10 +26,12 @@ public struct PrepareDB: Sendable { CreateListing(), AddTradeColumns(), AddListingDeclarations(), + AddShopRegistration(), SeedCatalogue(), CreateUser(), CreateTrade(), CreatePasskey(), + CreateCollecting(), ]) } diff --git a/Packages/Persistence/Sources/Public/Models/Listing.swift b/Packages/Persistence/Sources/Public/Models/Listing.swift index 517488f..9fe06d6 100644 --- a/Packages/Persistence/Sources/Public/Models/Listing.swift +++ b/Packages/Persistence/Sources/Public/Models/Listing.swift @@ -152,6 +152,24 @@ public extension Listing { keyNotes != nil } + /// The issue's number within its series, or `nil` for a copy that is not a numbered single issue. + var issueNumber: Int? { + format == "single-issues" + ? Int(issueLabel.drop { !$0.isNumber }.prefix { $0.isNumber }) + : nil + } + + /// The name of the series the copy belongs to: its series title, less the `(2019 series)` a single issue's carries. + var seriesName: String { + seriesTitle + .replacing(#/\s\(\d{4} series\)$/#, with: "") + } + + /// The slug of the series the copy belongs to. + var seriesSlug: String { + ListingDraft.slug(seriesName) + } + /// Whether the copy is graded and slabbed by a grading company. var isSlabbed: Bool { grader != nil diff --git a/Packages/Persistence/Sources/Public/Models/Series.swift b/Packages/Persistence/Sources/Public/Models/Series.swift new file mode 100644 index 0000000..5da0a3f --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/Series.swift @@ -0,0 +1,37 @@ +/// A `Sendable` snapshot of a series in the catalogue: a run of issues, which is what lets a gap in it show. +public struct Series: Sendable, Equatable { + + // MARK: Properties + + /// The series' URL slug: its name, slugged. + public let slug: String + /// The series' name, e.g. `Nightward`. + public let name: String + /// The publisher's name. + public let publisher: String + /// The years the series ran, e.g. `2019–2021`. + public let years: String + /// The number of issues in the run; zero for a series the catalogue only knows from its listings. + public let issueCount: Int + /// The series in a sentence or two. + public let blurb: String + + // MARK: Initializers + + public init( + slug: String, + name: String, + publisher: String, + years: String, + issueCount: Int, + blurb: String + ) { + self.slug = slug + self.name = name + self.publisher = publisher + self.years = years + self.issueCount = issueCount + self.blurb = blurb + } + +} diff --git a/Packages/Persistence/Sources/Public/Models/Shop.swift b/Packages/Persistence/Sources/Public/Models/Shop.swift index 749529e..85daf81 100644 --- a/Packages/Persistence/Sources/Public/Models/Shop.swift +++ b/Packages/Persistence/Sources/Public/Models/Shop.swift @@ -29,6 +29,14 @@ public struct Shop: Sendable, Equatable { public let returnsNote: String /// The shop's description of itself. public let about: String + /// Whether the seller sells as a private collector or as a business. + public let kind: Kind + /// The business's Chamber of Commerce (KvK) number, shown on its shop page. + public let kvkNumber: String? + /// The business's VAT (BTW) identifier. + public let vatID: String? + /// The business's registered name. + public let legalName: String? // MARK: Initializers @@ -45,7 +53,11 @@ public struct Shop: Sendable, Equatable { handlingDays: Int, shipsNote: String, returnsNote: String, - about: String + about: String, + kind: Kind = .business, + kvkNumber: String? = nil, + vatID: String? = nil, + legalName: String? = nil ) { self.slug = slug self.name = name @@ -60,6 +72,35 @@ public struct Shop: Sendable, Equatable { self.shipsNote = shipsNote self.returnsNote = returnsNote self.about = about + self.kind = kind + self.kvkNumber = kvkNumber + self.vatID = vatID + self.legalName = legalName } } + +// MARK: - Extensions + +public extension Shop { + + // MARK: Computed + + /// Whether the shop ships from a member state of the European Union, so a delivery within it crosses no customs border. + var shipsFromEU: Bool { + ListingFilter.euCountries.contains(country) + } + +} + +// MARK: - Enumerations + +public extension Shop { + /// How a seller sells, which decides what consumer law asks of them. + enum Kind: String, CaseIterable, Sendable { + /// A business: its KvK number shows on its shop page, and consumers get the 14-day right of withdrawal. + case business + /// A private collector selling their own books: no statutory right of withdrawal. + case privateSeller = "private" + } +} diff --git a/Packages/Persistence/Sources/Public/Models/ShopDraft.swift b/Packages/Persistence/Sources/Public/Models/ShopDraft.swift new file mode 100644 index 0000000..8df80b0 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/ShopDraft.swift @@ -0,0 +1,57 @@ +/// What an account fills in to open a shop. +public struct ShopDraft: Sendable, Equatable { + + // MARK: Properties + + /// The shop's name. + public var name: String + /// The shop's handle: its URL slug. + public var handle: String + /// The city the shop ships from. + public var city: String + /// The ISO 3166-1 alpha-2 code of the country the shop ships from. + public var country: String + /// The shop's description of itself. + public var about: String + /// Whether the seller sells as a private collector or as a business. + public var kind: Shop.Kind + /// The business's KvK number. + public var kvkNumber: String? + /// The business's VAT identifier. + public var vatID: String? + /// The business's registered name. + public var legalName: String? + /// The shop's returns policy, in a few words. + public var returnsNote: String + /// The shop's handling time, in business days. + public var handlingDays: Int + + // MARK: Initializers + + public init( + name: String, + handle: String, + city: String, + country: String, + about: String, + kind: Shop.Kind, + kvkNumber: String?, + vatID: String?, + legalName: String?, + returnsNote: String, + handlingDays: Int + ) { + self.name = name + self.handle = handle + self.city = city + self.country = country + self.about = about + self.kind = kind + self.kvkNumber = kvkNumber + self.vatID = vatID + self.legalName = legalName + self.returnsNote = returnsNote + self.handlingDays = handlingDays + } + +} diff --git a/Packages/Persistence/Sources/Public/Models/Want.swift b/Packages/Persistence/Sources/Public/Models/Want.swift new file mode 100644 index 0000000..f2186a1 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/Want.swift @@ -0,0 +1,78 @@ +import Foundation + +/// A `Sendable` snapshot of a copy an account is looking for, with the listings that meet it. +public struct Want: Sendable, Equatable { + + // MARK: Properties + + /// The want's identifier. + public let id: UUID + /// What is wanted: words that must each appear in a listing's title, e.g. `Nightward #7`. + public let title: String + /// The lowest grade wanted, or `nil` for any. + public let minGrade: Double? + /// The most the account would pay, in euro cents, or `nil` for any price. + public let maxPriceCents: Int? + /// Whether only slabbed copies are wanted. + public let slabbedOnly: Bool + /// Whether new matches alert the account. + public let alerts: Bool + /// The listings for sale that meet the want, newest first. Wants match within the EU alone. + public let matches: [Listing] + /// The number of matches listed since the account last looked at its wants. + public let newMatchCount: Int + + // MARK: Initializers + + public init( + id: UUID, + title: String, + minGrade: Double?, + maxPriceCents: Int?, + slabbedOnly: Bool, + alerts: Bool, + matches: [Listing], + newMatchCount: Int + ) { + self.id = id + self.title = title + self.minGrade = minGrade + self.maxPriceCents = maxPriceCents + self.slabbedOnly = slabbedOnly + self.alerts = alerts + self.matches = matches + self.newMatchCount = newMatchCount + } + +} + +// MARK: - Extensions + +public extension Want { + + // MARK: Computed + + /// The filter finding the want's matches: the title's words, within the limits set, shipping from the EU. + var filter: ListingFilter { + Self.filter(title: title, minGrade: minGrade, maxPriceCents: maxPriceCents, slabbedOnly: slabbedOnly) + } + + // MARK: Methods + + /// The filter finding a want's matches. + static func filter( + title: String, + minGrade: Double?, + maxPriceCents: Int?, + slabbedOnly: Bool + ) -> ListingFilter { + .init( + query: title.replacingOccurrences(of: "·", with: " "), + isSlabbed: slabbedOnly ? true : nil, + gradeMin: minGrade, + priceMax: maxPriceCents, + shipsFrom: .eu + ) + } + +} diff --git a/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift b/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift index ae2251d..da5580e 100644 --- a/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift +++ b/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift @@ -117,6 +117,63 @@ public struct AccountRepository: Sendable { .update() } + /// Adds a passkey to an account. Only ever for the account's own session: holding one is the proof of who is asking. + public func addPasskey( + to user: User, + credentialID: String, + publicKey: [UInt8], + signCount: UInt32 + ) async throws { + try await PasskeyRecord( + userID: user.id, + credentialID: credentialID, + publicKey: publicKey, + signCount: signCount, + at: .init() + ).save(on: fluent.db()) + } + + /// Fetches the passkeys registered to an account, the oldest first, with when each was created. + public func passkeys( + of user: User + ) async throws -> [(passkey: Passkey, createdAt: Date)] { + try await PasskeyRecord.query(on: fluent.db()) + .filter(\.$user.$id == user.id) + .sort(\.$createdAt) + .with(\.$user) + .all() + .map { (try $0.snapshot, $0.createdAt) } + } + + /// Changes an account's display name. + /// - Returns: the account, renamed. + public func rename( + _ user: User, + to displayName: String + ) async throws -> User { + try await UserRecord.query(on: fluent.db()) + .filter(\.$id == user.id) + .set(\.$displayName, to: displayName) + .update() + + return .init( + id: user.id, + email: user.email, + displayName: displayName + ) + } + + /// Revokes one of an account's passkeys, taking effect at once. + public func revokePasskey( + _ credentialID: String, + of user: User + ) async throws { + try await PasskeyRecord.query(on: fluent.db()) + .filter(\.$credentialID == credentialID) + .filter(\.$user.$id == user.id) + .delete() + } + /// Fetches the account with the given email, creating it when there is none: one step both signs in and joins. /// /// A new account is named after the email's local part until its owner picks a display name. @@ -125,11 +182,16 @@ public struct AccountRepository: Sendable { public func account( email: String ) async throws -> User { - if let record = try await UserRecord.query(on: fluent.db()) - .filter(\.$email == email) - .first() - { - return try record.snapshot + try await signIn(email: email).user + } + + /// Fetches the account with the given email, creating it when there is none, and says which happened. + /// - Returns: the account, and whether this step created it. + public func signIn( + email: String + ) async throws -> (user: User, isNew: Bool) { + if let user = try await existingAccount(email: email) { + return (user, false) } let record = UserRecord( @@ -139,7 +201,7 @@ public struct AccountRepository: Sendable { try await record.save(on: fluent.db()) - return try record.snapshot + return (try record.snapshot, true) } } diff --git a/Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift b/Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift index 191e6ab..feaf44a 100644 --- a/Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift +++ b/Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift @@ -50,6 +50,57 @@ public struct CatalogueRepository: Sendable { .sorted(by: filter.ordersBefore) } + /// Opens a shop for an account: unverified and unrated, public from this moment. + /// - Throws: a ``ShopError`` when the account already owns a shop, or the handle is taken. + public func openShop( + _ draft: ShopDraft, + for owner: User, + at date: Date = .init() + ) async throws -> Shop { + try await fluent.db().transaction { database in + guard try await ShopRecord.query(on: database) + .filter(\.$owner.$id == owner.id) + .first() == nil + else { + throw ShopError.alreadyOwner + } + + guard try await ShopRecord.query(on: database) + .filter(\.$slug == draft.handle) + .first() == nil + else { + throw ShopError.handleTaken + } + + let shop = Shop( + slug: draft.handle, + name: draft.name, + city: draft.city, + country: draft.country, + sinceYear: Calendar(identifier: .gregorian).component(.year, from: date), + isVerified: false, + rating: 0, + ratedSales: 0, + soldCount: 0, + handlingDays: draft.handlingDays, + shipsNote: "ships tracked", + returnsNote: draft.returnsNote, + about: draft.about, + kind: draft.kind, + kvkNumber: draft.kind == .business ? draft.kvkNumber : nil, + vatID: draft.kind == .business ? draft.vatID : nil, + legalName: draft.kind == .business ? draft.legalName : nil + ) + let record = ShopRecord(shop) + + record.$owner.id = owner.id + + try await record.save(on: database) + + return shop + } + } + /// Publishes a draft as a listing of the shop the seller owns, under a slug of its own. /// - Parameters: /// - draft: what the seller filled in. diff --git a/Packages/Persistence/Sources/Public/Repositories/CollectingRepository.swift b/Packages/Persistence/Sources/Public/Repositories/CollectingRepository.swift new file mode 100644 index 0000000..d08911a --- /dev/null +++ b/Packages/Persistence/Sources/Public/Repositories/CollectingRepository.swift @@ -0,0 +1,194 @@ +import FluentKit +import Foundation +import HummingbirdFluent + +/// Reads and writes what a collector keeps: their want list, and the series catalogue they fill gaps in. +public struct CollectingRepository: Sendable { + + // MARK: Constants + + /// The most wants an account keeps. + public static let maxWants = 50 + + // MARK: Properties + + private let fluent: Fluent + + /// The clock wants are stamped and new matches 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 wants are stamped and new matches judged by; the system's by default. + public init( + fluent: Fluent, + now: @escaping @Sendable () -> Date = { .init() } + ) { + self.fluent = fluent + self.now = now + } + +} + +// MARK: - Wants + +public extension CollectingRepository { + + /// Adds a want to an account's list; a blank one, or one past the limit, is dropped. + func addWant( + title: String, + minGrade: Double?, + maxPriceCents: Int?, + slabbedOnly: Bool, + for user: User + ) async throws { + let title = title.trimmingCharacters(in: .whitespacesAndNewlines) + + guard + !title.isEmpty, + try await WantRecord.query(on: fluent.db()).filter(\.$user.$id == user.id).count() < Self.maxWants + else { + return + } + + let want = WantRecord() + + want.$user.id = user.id + want.title = String(title.prefix(120)) + want.minGrade = minGrade + want.maxPriceCents = maxPriceCents + want.slabbedOnly = slabbedOnly + want.alerts = true + want.createdAt = now() + // A match already listed is news to a want just made. + want.seenAt = .distantPast + + try await want.save(on: fluent.db()) + } + + /// Marks an account's wants as looked at: their matches so far are no longer new. + func markWantsSeen( + by user: User + ) async throws { + try await WantRecord.query(on: fluent.db()) + .filter(\.$user.$id == user.id) + .set(\.$seenAt, to: now()) + .update() + } + + /// The number of new matches across the wants an account has alerts on for. + func newMatchCount( + for user: User + ) async throws -> Int { + try await wants(of: user) + .filter(\.alerts) + .reduce(0) { $0 + $1.newMatchCount } + } + + /// Removes one of an account's wants. + func removeWant( + _ id: UUID, + of user: User + ) async throws { + try await WantRecord.query(on: fluent.db()) + .filter(\.$id == id) + .filter(\.$user.$id == user.id) + .delete() + } + + /// Switches the alerts of one of an account's wants. + func toggleAlerts( + ofWant id: UUID, + of user: User + ) async throws { + guard let want = try await WantRecord.query(on: fluent.db()) + .filter(\.$id == id) + .filter(\.$user.$id == user.id) + .first() + else { + return + } + + want.alerts.toggle() + + try await want.save(on: fluent.db()) + } + + /// Fetches an account's wants, the newest first, each with the listings that meet it. + func wants( + of user: User + ) async throws -> [Want] { + let listings = try await CatalogueRepository(fluent: fluent).listings() + + return try await WantRecord.query(on: fluent.db()) + .filter(\.$user.$id == user.id) + .sort(\.$createdAt, .descending) + .all() + .map { record in + let filter = Want.filter( + title: record.title, + minGrade: record.minGrade, + maxPriceCents: record.maxPriceCents, + slabbedOnly: record.slabbedOnly + ) + let matches = listings.filter(filter.matches) + + return .init( + id: try record.requireID(), + title: record.title, + minGrade: record.minGrade, + maxPriceCents: record.maxPriceCents, + slabbedOnly: record.slabbedOnly, + alerts: record.alerts, + matches: matches, + newMatchCount: matches.count { $0.listedAt > record.seenAt } + ) + } + } + +} + +// MARK: - Series + +public extension CollectingRepository { + + /// Fetches every series in the catalogue, by name. + func allSeries() async throws -> [Series] { + try await SeriesRecord.query(on: fluent.db()) + .sort(\.$name) + .all() + .map(\.snapshot) + } + + /// Fetches the series with the given slug. + /// + /// A series sellers list copies of, but the catalogue has no run for, is made up from those listings — without an issue count, so without gaps. + func series( + slug: String + ) async throws -> Series? { + if let record = try await SeriesRecord.query(on: fluent.db()) + .filter(\.$slug == slug) + .first() + { + return record.snapshot + } + + return try await CatalogueRepository(fluent: fluent) + .listings(matching: .init(sort: .newest)) + .first { $0.seriesSlug == slug } + .map { + .init( + slug: slug, + name: $0.seriesName, + publisher: $0.publisher, + years: String($0.year), + issueCount: 0, + blurb: "" + ) + } + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Repositories/AccountRepositoryTests.swift b/Packages/Persistence/Tests/Cases/Public/Repositories/AccountRepositoryTests.swift index e3b9099..ce606db 100644 --- a/Packages/Persistence/Tests/Cases/Public/Repositories/AccountRepositoryTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Repositories/AccountRepositoryTests.swift @@ -91,3 +91,83 @@ extension AccountRepositoryTests { } } + +// MARK: - Accounts and shops + +extension AccountRepositoryTests { + + @Test + func `says whether a sign-in created the account`() async throws { + try await withDatabase { fluent in + let accounts = AccountRepository(fluent: fluent) + let first = try await accounts.signIn(email: "sam@example.com") + let again = try await accounts.signIn(email: "sam@example.com") + + #expect(first.isNew) + #expect(!again.isNew) + #expect(first.user == again.user) + } + } + + @Test + func `renames an account, and manages the passkeys of its own`() async throws { + try await withDatabase { fluent in + let accounts = AccountRepository(fluent: fluent) + let mara = try await accounts.account(email: AccountRepository.demoEmail) + let sam = try await accounts.account(email: "sam@example.com") + let renamed = try await accounts.rename(mara, to: "Mara V.") + let stored = try await accounts.existingAccount(email: AccountRepository.demoEmail) + + try await accounts.addPasskey(to: mara, credentialID: "cred-m", publicKey: [1], signCount: 0) + try await accounts.revokePasskey("cred-m", of: sam) + + let kept = try await accounts.passkeys(of: mara) + + try await accounts.revokePasskey("cred-m", of: mara) + + let revoked = try await accounts.passkeys(of: mara) + + #expect(renamed.displayName == "Mara V.") + #expect(stored?.displayName == "Mara V.") + #expect(kept.map(\.passkey.credentialID) == ["cred-m"]) + #expect(revoked.isEmpty) + } + } + + @Test + func `opens one shop per account, under a handle of its own`() async throws { + try await withDatabase { fluent in + let accounts = AccountRepository(fluent: fluent) + let catalogue = CatalogueRepository(fluent: fluent) + let mara = try await accounts.account(email: AccountRepository.demoEmail) + let sam = try await accounts.account(email: "sam@example.com") + let draft = ShopDraft( + name: "Mara's Longbox", handle: "maras-longbox", city: "Haarlem", country: "NL", about: "Spare copies.", + kind: .privateSeller, kvkNumber: "12345678", vatID: nil, legalName: nil, returnsNote: "14-day returns", handlingDays: 2 + ) + let shop = try await catalogue.openShop(draft, for: mara) + let owned = try await TradeRepository(fluent: fluent).shop(ownedBy: mara) + + #expect(shop.slug == "maras-longbox") + #expect(shop.kind == .privateSeller) + // A private seller shows no registration, whatever was typed. + #expect(shop.kvkNumber == nil) + #expect(!shop.isVerified) + #expect(shop.ratedSales == 0) + #expect(owned == shop) + + await #expect(throws: ShopError.alreadyOwner) { + try await catalogue.openShop(draft, for: mara) + } + await #expect(throws: ShopError.handleTaken) { + try await catalogue.openShop(draft, for: sam) + } + + // And the new shop sells: its owner lists a copy, which a buyer finds. + let listing = try await catalogue.publish(ListingDraftTests.draft(), by: mara) + + #expect(listing?.shop.slug == "maras-longbox") + } + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Repositories/CollectingRepositoryTests.swift b/Packages/Persistence/Tests/Cases/Public/Repositories/CollectingRepositoryTests.swift new file mode 100644 index 0000000..d6b17ec --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Repositories/CollectingRepositoryTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing + +@testable import Persistence + +@Suite( + "CollectingRepository repository", + .tags(.repository) +) +struct CollectingRepositoryTests { + + // MARK: Wants tests + + @Test + func `matches a want within its limits, shipping from the EU alone`() async throws { + try await withDatabase { fluent in + let collecting = CollectingRepository(fluent: fluent) + let mara = try await AccountRepository(fluent: fluent).account(email: "mara.v@example.com") + + try await collecting.addWant(title: " Nightward #1 ", minGrade: 9.0, maxPriceCents: 20_000, slabbedOnly: false, for: mara) + try await collecting.addWant(title: " ", minGrade: nil, maxPriceCents: nil, slabbedOnly: false, for: mara) + + let wants = try await collecting.wants(of: mara) + let want = try #require(wants.first) + + #expect(wants.count == 1) + #expect(want.title == "Nightward #1") + // The raw NM at €120 from Antwerp; the €150 CGC 9.4 ships from the UK, and the rest cost more or grade lower. + #expect(want.matches.map(\.slug) == ["nightward-1-raw-nm"]) + #expect(want.newMatchCount == 1) + #expect(want.alerts) + } + } + + @Test + func `counts a match as new until the wants are looked at, and again when a copy is listed`() async throws { + try await withDatabase { fluent in + let clock = Clock() + let collecting = CollectingRepository(fluent: fluent, now: clock.now) + let accounts = AccountRepository(fluent: fluent) + let mara = try await accounts.account(email: "mara.v@example.com") + let joost = try await accounts.account(email: "joost@kestrelrow.example") + + try await collecting.addWant(title: "Nightward #3", minGrade: nil, maxPriceCents: nil, slabbedOnly: false, for: mara) + + let nothing = try await collecting.newMatchCount(for: mara) + + _ = try await CatalogueRepository(fluent: fluent).publish(ListingDraftTests.draft(), by: joost, at: clock.now()) + + clock.advance(by: 60) + + let listed = try await collecting.newMatchCount(for: mara) + + try await collecting.markWantsSeen(by: mara) + + let seen = try await collecting.newMatchCount(for: mara) + let stillMatching = try await collecting.wants(of: mara).first?.matches.count + + #expect(nothing == 0) + #expect(listed == 1) + #expect(seen == 0) + #expect(stillMatching == 1) + } + } + + @Test + func `keeps a want's alerts and removal to its owner`() async throws { + try await withDatabase { fluent in + let collecting = CollectingRepository(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") + + try await collecting.addWant(title: "Nightward #1", minGrade: nil, maxPriceCents: nil, slabbedOnly: true, for: mara) + + let id = try #require(try await collecting.wants(of: mara).first?.id) + + try await collecting.toggleAlerts(ofWant: id, of: sam) + try await collecting.removeWant(id, of: sam) + + let untouched = try await collecting.wants(of: mara) + + try await collecting.toggleAlerts(ofWant: id, of: mara) + + let silenced = try await collecting.wants(of: mara) + let quiet = try await collecting.newMatchCount(for: mara) + + try await collecting.removeWant(id, of: mara) + + let removed = try await collecting.wants(of: mara) + + #expect(untouched.first?.alerts == true) + #expect(untouched.first?.matches.allSatisfy(\.isSlabbed) == true) + #expect(silenced.first?.alerts == false) + #expect(quiet == 0) + #expect(removed.isEmpty) + } + } + + // MARK: Series tests + + @Test + func `knows the seeded runs, and makes up a series it only knows from its listings`() async throws { + try await withDatabase { fluent in + let collecting = CollectingRepository(fluent: fluent) + let all = try await collecting.allSeries() + let nightward = try await collecting.series(slug: "nightward") + let listedOnly = try await collecting.series(slug: "hollow-comet") + let unknown = try await collecting.series(slug: "no-such-series") + + #expect(all.map(\.slug) == ["echo-country", "ferrous-saint", "nightward", "paper-tigers", "vermilion-lake"]) + #expect(nightward?.issueCount == 24) + #expect(listedOnly?.name == "Hollow Comet") + #expect(listedOnly?.issueCount == 0) + #expect(unknown == nil) + } + } + + @Test + func `derives a listing's series and issue number from what it states`() { + let single = Seed.listings[0] + let manga = Seed.listings[5] + + #expect(single.seriesName == "Nightward") + #expect(single.seriesSlug == "nightward") + #expect(single.issueNumber == 1) + #expect(manga.seriesSlug == "hollow-comet") + #expect(manga.issueNumber == nil) + } + +}