Added the catalogue models and seed to support phase 1 of the POC prototype in the Persistence package.
This commit is contained in:
1 parent
717df5208c
commit
6d7ab4bcba
19 files changed
+1381
-151
No files matched your search
@@ -22,7 +22,9 @@ public struct PrepareDB: Sendable {
|
||||
for fluent: Fluent
|
||||
) async {
|
||||
await fluent.migrations.add([
|
||||
CreateExampleRecord()
|
||||
CreateShop(),
|
||||
CreateListing(),
|
||||
SeedCatalogue(),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
|
||||
/// A `Sendable` snapshot of one copy for sale, with the shop selling it.
|
||||
public struct Listing: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The listing's URL slug.
|
||||
public let slug: String
|
||||
/// The shop selling the copy.
|
||||
public let shop: Shop
|
||||
/// The listing's title, e.g. `Nightward #1`.
|
||||
public let title: String
|
||||
/// The series the copy belongs to, e.g. `Nightward (2019 series)`.
|
||||
public let seriesTitle: String
|
||||
/// The issue or volume, e.g. `#1, cover A`.
|
||||
public let issueLabel: String
|
||||
/// The publisher's name.
|
||||
public let publisher: String
|
||||
/// The cover year.
|
||||
public let year: Int
|
||||
/// The format token, e.g. `single-issues`.
|
||||
public let format: String
|
||||
/// The printing in a few words, e.g. `1st print`.
|
||||
public let printing: String
|
||||
/// The printing in full, e.g. `First printing, direct edition`.
|
||||
public let printingDetail: String
|
||||
/// Whether the copy is a first printing.
|
||||
public let isFirstPrint: Bool
|
||||
/// The company that graded and slabbed the copy, or `nil` for a raw copy.
|
||||
public let grader: String?
|
||||
/// The numeric grade, or `nil` for formats that are not graded.
|
||||
public let grade: Double?
|
||||
/// The condition as the seller states it, e.g. `NM` or `Like new`.
|
||||
public let condition: String
|
||||
/// The grader's certificate number, or `nil` for a raw copy.
|
||||
public let certNumber: String?
|
||||
/// The page quality, or `nil` when not stated.
|
||||
public let pageQuality: String?
|
||||
/// Why the issue is a key, or `nil` when it is not one.
|
||||
public let keyNotes: String?
|
||||
/// The seller's notes on the copy.
|
||||
public let sellerNotes: String
|
||||
/// The asking price, in euro cents.
|
||||
public let priceCents: Int
|
||||
/// The shipping cost to the Netherlands, in euro cents.
|
||||
public let shippingCents: Int
|
||||
/// Whether the seller takes offers on the copy.
|
||||
public let acceptsOffers: Bool
|
||||
/// The placeholder cover shown until listings carry photos.
|
||||
public let cover: Cover
|
||||
/// When the copy was listed.
|
||||
public let listedAt: Date
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
slug: String,
|
||||
shop: Shop,
|
||||
title: String,
|
||||
seriesTitle: String,
|
||||
issueLabel: String,
|
||||
publisher: String,
|
||||
year: Int,
|
||||
format: String,
|
||||
printing: String,
|
||||
printingDetail: String,
|
||||
isFirstPrint: Bool,
|
||||
grader: String?,
|
||||
grade: Double?,
|
||||
condition: String,
|
||||
certNumber: String?,
|
||||
pageQuality: String?,
|
||||
keyNotes: String?,
|
||||
sellerNotes: String,
|
||||
priceCents: Int,
|
||||
shippingCents: Int,
|
||||
acceptsOffers: Bool,
|
||||
cover: Cover,
|
||||
listedAt: Date
|
||||
) {
|
||||
self.slug = slug
|
||||
self.shop = shop
|
||||
self.title = title
|
||||
self.seriesTitle = seriesTitle
|
||||
self.issueLabel = issueLabel
|
||||
self.publisher = publisher
|
||||
self.year = year
|
||||
self.format = format
|
||||
self.printing = printing
|
||||
self.printingDetail = printingDetail
|
||||
self.isFirstPrint = isFirstPrint
|
||||
self.grader = grader
|
||||
self.grade = grade
|
||||
self.condition = condition
|
||||
self.certNumber = certNumber
|
||||
self.pageQuality = pageQuality
|
||||
self.keyNotes = keyNotes
|
||||
self.sellerNotes = sellerNotes
|
||||
self.priceCents = priceCents
|
||||
self.shippingCents = shippingCents
|
||||
self.acceptsOffers = acceptsOffers
|
||||
self.cover = cover
|
||||
self.listedAt = listedAt
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
public extension Listing {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// Whether the issue is a key.
|
||||
var isKey: Bool {
|
||||
keyNotes != nil
|
||||
}
|
||||
|
||||
/// Whether the copy is graded and slabbed by a grading company.
|
||||
var isSlabbed: Bool {
|
||||
grader != nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
public extension Listing {
|
||||
/// A placeholder cover: lettering on a toned ground.
|
||||
struct Cover: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The tone token naming the cover's colours, e.g. `navy`.
|
||||
public let tone: String
|
||||
/// The title lettering, one line per newline.
|
||||
public let title: String
|
||||
/// The publisher's imprint line.
|
||||
public let imprint: String
|
||||
/// The issue number or volume marker.
|
||||
public let number: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
tone: String,
|
||||
title: String,
|
||||
imprint: String,
|
||||
number: String
|
||||
) {
|
||||
self.tone = tone
|
||||
self.title = title
|
||||
self.imprint = imprint
|
||||
self.number = number
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/// The criteria a listing search is narrowed and ordered by; the default matches every listing, newest first.
|
||||
public struct ListingFilter: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// Words that must each appear in the listing's title, publisher, or shop name.
|
||||
public var query: String?
|
||||
/// The format tokens to match; empty matches all.
|
||||
public var formats: [String]
|
||||
/// `true` for slabbed copies only, `false` for raw only, `nil` for both.
|
||||
public var isSlabbed: Bool?
|
||||
/// The lowest numeric grade to match.
|
||||
public var gradeMin: Double?
|
||||
/// The highest numeric grade to match.
|
||||
public var gradeMax: Double?
|
||||
/// Whether to match first printings only.
|
||||
public var firstPrintOnly: Bool
|
||||
/// Whether to match key issues only.
|
||||
public var keysOnly: Bool
|
||||
/// The publishers to match; empty matches all.
|
||||
public var publishers: [String]
|
||||
/// The lowest price to match, in euro cents.
|
||||
public var priceMin: Int?
|
||||
/// The highest price to match, in euro cents.
|
||||
public var priceMax: Int?
|
||||
/// Where matching listings ship from.
|
||||
public var shipsFrom: Region
|
||||
/// Whether to match only listings whose seller takes offers.
|
||||
public var acceptsOffers: Bool
|
||||
/// Whether to match only listings from verified shops.
|
||||
public var verifiedShops: Bool
|
||||
/// The slug of the one shop to match, or `nil` for all shops.
|
||||
public var shop: String?
|
||||
/// The order of the results.
|
||||
public var sort: Sort
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
query: String? = nil,
|
||||
formats: [String] = [],
|
||||
isSlabbed: Bool? = nil,
|
||||
gradeMin: Double? = nil,
|
||||
gradeMax: Double? = nil,
|
||||
firstPrintOnly: Bool = false,
|
||||
keysOnly: Bool = false,
|
||||
publishers: [String] = [],
|
||||
priceMin: Int? = nil,
|
||||
priceMax: Int? = nil,
|
||||
shipsFrom: Region = .anywhere,
|
||||
acceptsOffers: Bool = false,
|
||||
verifiedShops: Bool = false,
|
||||
shop: String? = nil,
|
||||
sort: Sort = .newest
|
||||
) {
|
||||
self.query = query
|
||||
self.formats = formats
|
||||
self.isSlabbed = isSlabbed
|
||||
self.gradeMin = gradeMin
|
||||
self.gradeMax = gradeMax
|
||||
self.firstPrintOnly = firstPrintOnly
|
||||
self.keysOnly = keysOnly
|
||||
self.publishers = publishers
|
||||
self.priceMin = priceMin
|
||||
self.priceMax = priceMax
|
||||
self.shipsFrom = shipsFrom
|
||||
self.acceptsOffers = acceptsOffers
|
||||
self.verifiedShops = verifiedShops
|
||||
self.shop = shop
|
||||
self.sort = sort
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
public extension ListingFilter {
|
||||
/// Where a listing ships from.
|
||||
enum Region: String, CaseIterable, Sendable {
|
||||
/// The Netherlands.
|
||||
case nl
|
||||
/// Any member state of the European Union.
|
||||
case eu
|
||||
/// Any country.
|
||||
case anywhere
|
||||
}
|
||||
|
||||
/// The order of a listing search's results.
|
||||
enum Sort: String, CaseIterable, Sendable {
|
||||
/// Most recently listed first.
|
||||
case newest
|
||||
/// Cheapest first.
|
||||
case priceAscending = "price-asc"
|
||||
/// Most expensive first.
|
||||
case priceDescending = "price-desc"
|
||||
/// Highest grade first; ungraded listings last.
|
||||
case gradeDescending = "grade-desc"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension ListingFilter {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Whether the listing meets every criterion.
|
||||
/// - Parameter listing: the listing to test.
|
||||
func matches(
|
||||
_ listing: Listing
|
||||
) -> Bool {
|
||||
if let shop, listing.shop.slug != shop {
|
||||
return false
|
||||
}
|
||||
|
||||
if !formats.isEmpty, !formats.contains(listing.format) {
|
||||
return false
|
||||
}
|
||||
|
||||
if let isSlabbed, listing.isSlabbed != isSlabbed {
|
||||
return false
|
||||
}
|
||||
|
||||
if gradeMin != nil || gradeMax != nil {
|
||||
guard
|
||||
let grade = listing.grade,
|
||||
grade >= gradeMin ?? 0,
|
||||
grade <= gradeMax ?? 10
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if firstPrintOnly, !listing.isFirstPrint {
|
||||
return false
|
||||
}
|
||||
|
||||
if keysOnly, !listing.isKey {
|
||||
return false
|
||||
}
|
||||
|
||||
if !publishers.isEmpty, !publishers.contains(listing.publisher) {
|
||||
return false
|
||||
}
|
||||
|
||||
if let priceMin, listing.priceCents < priceMin {
|
||||
return false
|
||||
}
|
||||
|
||||
if let priceMax, listing.priceCents > priceMax {
|
||||
return false
|
||||
}
|
||||
|
||||
if acceptsOffers, !listing.acceptsOffers {
|
||||
return false
|
||||
}
|
||||
|
||||
if verifiedShops, !listing.shop.isVerified {
|
||||
return false
|
||||
}
|
||||
|
||||
switch shipsFrom {
|
||||
case .nl where listing.shop.country != "NL": return false
|
||||
case .eu where !Self.euCountries.contains(listing.shop.country): return false
|
||||
default: break
|
||||
}
|
||||
|
||||
let haystack = "\(listing.title) \(listing.publisher) \(listing.shop.name)".lowercased()
|
||||
|
||||
return (query ?? "")
|
||||
.lowercased()
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.allSatisfy { haystack.contains($0) }
|
||||
}
|
||||
|
||||
/// Whether the first listing sorts ahead of the second.
|
||||
func ordersBefore(
|
||||
_ lhs: Listing,
|
||||
_ rhs: Listing
|
||||
) -> Bool {
|
||||
switch sort {
|
||||
case .newest: lhs.listedAt > rhs.listedAt
|
||||
case .priceAscending: lhs.priceCents < rhs.priceCents
|
||||
case .priceDescending: lhs.priceCents > rhs.priceCents
|
||||
case .gradeDescending: (lhs.grade ?? -1) > (rhs.grade ?? -1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The ISO 3166-1 alpha-2 codes of the European Union's member states.
|
||||
static let euCountries: Set<String> = [
|
||||
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HR", "HU",
|
||||
"IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK",
|
||||
]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// A `Sendable` snapshot of a seller's shop.
|
||||
public struct Shop: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The shop's URL slug.
|
||||
public let slug: String
|
||||
/// The shop's name.
|
||||
public let name: String
|
||||
/// The city the shop ships from.
|
||||
public let city: String
|
||||
/// The ISO 3166-1 alpha-2 code of the country the shop ships from.
|
||||
public let country: String
|
||||
/// The year the shop started selling on the site.
|
||||
public let sinceYear: Int
|
||||
/// Whether the shop's identity has been verified.
|
||||
public let isVerified: Bool
|
||||
/// The shop's average rating, out of five.
|
||||
public let rating: Double
|
||||
/// The number of rated sales behind ``rating``.
|
||||
public let ratedSales: Int
|
||||
/// The number of items the shop has sold.
|
||||
public let soldCount: Int
|
||||
/// The shop's handling time, in business days.
|
||||
public let handlingDays: Int
|
||||
/// Where and how the shop ships.
|
||||
public let shipsNote: String
|
||||
/// The shop's returns policy, in a few words.
|
||||
public let returnsNote: String
|
||||
/// The shop's description of itself.
|
||||
public let about: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
slug: String,
|
||||
name: String,
|
||||
city: String,
|
||||
country: String,
|
||||
sinceYear: Int,
|
||||
isVerified: Bool,
|
||||
rating: Double,
|
||||
ratedSales: Int,
|
||||
soldCount: Int,
|
||||
handlingDays: Int,
|
||||
shipsNote: String,
|
||||
returnsNote: String,
|
||||
about: String
|
||||
) {
|
||||
self.slug = slug
|
||||
self.name = name
|
||||
self.city = city
|
||||
self.country = country
|
||||
self.sinceYear = sinceYear
|
||||
self.isVerified = isVerified
|
||||
self.rating = rating
|
||||
self.ratedSales = ratedSales
|
||||
self.soldCount = soldCount
|
||||
self.handlingDays = handlingDays
|
||||
self.shipsNote = shipsNote
|
||||
self.returnsNote = returnsNote
|
||||
self.about = about
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import FluentKit
|
||||
import HummingbirdFluent
|
||||
|
||||
/// Reads the shops and their listings through the default database.
|
||||
public struct CatalogueRepository: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let fluent: Fluent
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a repository backed by the given `Fluent` service.
|
||||
/// - Parameter fluent: the service whose default database the repository operates on.
|
||||
public init(
|
||||
fluent: Fluent
|
||||
) {
|
||||
self.fluent = fluent
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Fetches the listing with the given slug.
|
||||
/// - Parameter slug: the listing's URL slug.
|
||||
/// - Returns: the listing, or `nil` when none has the slug.
|
||||
public func listing(
|
||||
slug: String
|
||||
) async throws -> Listing? {
|
||||
try await ListingRecord.query(on: fluent.db())
|
||||
.filter(\.$slug == slug)
|
||||
.with(\.$shop)
|
||||
.first()?
|
||||
.snapshot
|
||||
}
|
||||
|
||||
/// Fetches the listings meeting the filter, in its order.
|
||||
///
|
||||
/// The prototype filters in memory, which its few dozen seeded listings allow; the criteria move into the query with real data.
|
||||
/// - Parameter filter: the criteria the listings are narrowed and ordered by.
|
||||
/// - Returns: every matching listing.
|
||||
public func listings(
|
||||
matching filter: ListingFilter = .init()
|
||||
) async throws -> [Listing] {
|
||||
try await ListingRecord.query(on: fluent.db())
|
||||
.with(\.$shop)
|
||||
.all()
|
||||
.map(\.snapshot)
|
||||
.filter(filter.matches)
|
||||
.sorted(by: filter.ordersBefore)
|
||||
}
|
||||
|
||||
/// Fetches the name of every publisher with a listing, in alphabetical order.
|
||||
public func publishers() async throws -> [String] {
|
||||
try await ListingRecord.query(on: fluent.db())
|
||||
.unique()
|
||||
.sort(\.$publisher)
|
||||
.all(\.$publisher)
|
||||
}
|
||||
|
||||
/// Fetches the shop with the given slug.
|
||||
/// - Parameter slug: the shop's URL slug.
|
||||
/// - Returns: the shop, or `nil` when none has the slug.
|
||||
public func shop(
|
||||
slug: String
|
||||
) async throws -> Shop? {
|
||||
try await ShopRecord.query(on: fluent.db())
|
||||
.filter(\.$slug == slug)
|
||||
.first()?
|
||||
.snapshot
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user