Added the catalogue models and seed to support phase 5 of the POC prototype in the Persistence package.

This commit is contained in:
javier committed 2026-09-21 13:31:44 +02:00
1 parent 9403931109
commit 89a7a7b8e5
19 files changed
+990 -15

No files matched your search

@@ -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
}
@@ -26,10 +26,12 @@ public struct PrepareDB: Sendable {
CreateListing(),
AddTradeColumns(),
AddListingDeclarations(),
AddShopRegistration(),
SeedCatalogue(),
CreateUser(),
CreateTrade(),
CreatePasskey(),
CreateCollecting(),
])
}
@@ -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
@@ -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
}
}
@@ -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"
}
}
@@ -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
}
}
@@ -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
)
}
}
@@ -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)
}
}
@@ -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.
@@ -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: ""
)
}
}
}