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

This commit is contained in:
javier committed 2026-09-21 12:50:53 +02:00
1 parent 0dc3fe6bea
commit 95e0a455e8
14 files changed
+743 -9

No files matched your search

@@ -0,0 +1,29 @@
import FluentKit
/// Adds the declared defects and the photos to listings.
///
/// Registered ahead of ``SeedCatalogue``, whose model writes both columns.
struct AddListingDeclarations: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
// One column per update: SQLite adds no more in a single statement.
try await database.schema(ListingRecord.schema)
.field("defects", .string)
.update()
try await database.schema(ListingRecord.schema)
.field("photos", .string)
.update()
}
func revert(on database: Database) async throws {
try await database.schema(ListingRecord.schema)
.deleteField("photos")
.update()
try await database.schema(ListingRecord.schema)
.deleteField("defects")
.update()
}
}
@@ -0,0 +1,25 @@
import FluentKit
/// Creates and drops the `passkeys` table backing ``PasskeyRecord``.
struct CreatePasskey: AsyncMigration {
// MARK: Methods
func prepare(on database: Database) async throws {
try await database.schema(PasskeyRecord.schema)
.id()
.field("user_id", .uuid, .required, .references(UserRecord.schema, "id", onDelete: .cascade))
.field("credential_id", .string, .required)
.field("public_key", .string, .required)
.field("sign_count", .int, .required)
.field("created_at", .datetime, .required)
.unique(on: "credential_id")
.create()
}
func revert(on database: Database) async throws {
try await database.schema(PasskeyRecord.schema)
.delete()
}
}
@@ -94,6 +94,14 @@ final class ListingRecord: Model, @unchecked Sendable {
@OptionalField(key: "sold_at")
var soldAt: Date?
/// The declared defects, one per line.
@OptionalField(key: "defects")
var defects: String?
/// The URL paths of the photos, one per line, the front first.
@OptionalField(key: "photos")
var photos: String?
// MARK: Initializers
init() {}
@@ -132,6 +140,8 @@ final class ListingRecord: Model, @unchecked Sendable {
self.coverImprint = listing.cover.imprint
self.coverNumber = listing.cover.number
self.listedAt = listing.listedAt
self.defects = listing.defects.isEmpty ? nil : listing.defects.joined(separator: "\n")
self.photos = listing.photos.isEmpty ? nil : listing.photos.joined(separator: "\n")
}
}
@@ -173,7 +183,9 @@ extension ListingRecord {
number: coverNumber
),
listedAt: listedAt,
soldAt: soldAt
soldAt: soldAt,
defects: defects?.split(separator: "\n").map(String.init) ?? [],
photos: photos?.split(separator: "\n").map(String.init) ?? []
)
}
@@ -0,0 +1,69 @@
import FluentKit
import Foundation
/// A FluentKit model of a single `passkeys` row: a WebAuthn credential registered to an account.
final class PasskeyRecord: Model, @unchecked Sendable {
// MARK: Properties
static let schema = "passkeys"
@ID(key: .id)
var id: UUID?
@Parent(key: "user_id")
var user: UserRecord
/// The credential's identifier, base64url-encoded without padding.
@Field(key: "credential_id")
var credentialID: String
/// The credential's public key, COSE-encoded, as base64.
@Field(key: "public_key")
var publicKey: String
@Field(key: "sign_count")
var signCount: Int
@Field(key: "created_at")
var createdAt: Date
// MARK: Initializers
init() {}
init(
userID: UUID,
credentialID: String,
publicKey: [UInt8],
signCount: UInt32,
at date: Date
) {
self.$user.id = userID
self.credentialID = credentialID
self.publicKey = Data(publicKey).base64EncodedString()
self.signCount = Int(signCount)
self.createdAt = date
}
}
// MARK: - Extensions
extension PasskeyRecord {
// MARK: Computed
/// The `Sendable` snapshot of the saved record; its user must be eager-loaded.
var snapshot: Passkey {
get throws {
.init(
credentialID: credentialID,
publicKey: Array(Data(base64Encoded: publicKey) ?? .init()),
signCount: UInt32(clamping: signCount),
user: try user.snapshot
)
}
}
}
@@ -25,9 +25,11 @@ public struct PrepareDB: Sendable {
CreateShop(),
CreateListing(),
AddTradeColumns(),
AddListingDeclarations(),
SeedCatalogue(),
CreateUser(),
CreateTrade(),
CreatePasskey(),
])
}
@@ -53,6 +53,10 @@ public struct Listing: Sendable, Equatable {
public let listedAt: Date
/// When a sale of the copy was agreed, or `nil` while it is for sale.
public let soldAt: Date?
/// The defects the seller declared, from the grading guide's checklist.
public let defects: [String]
/// The URL paths of the photos of this copy, the front first; empty while the listing shows its placeholder cover.
public let photos: [String]
// MARK: Initializers
@@ -80,7 +84,9 @@ public struct Listing: Sendable, Equatable {
acceptsOffers: Bool,
cover: Cover,
listedAt: Date,
soldAt: Date? = nil
soldAt: Date? = nil,
defects: [String] = [],
photos: [String] = []
) {
self.slug = slug
self.shop = shop
@@ -106,6 +112,8 @@ public struct Listing: Sendable, Equatable {
self.cover = cover
self.listedAt = listedAt
self.soldAt = soldAt
self.defects = defects
self.photos = photos
}
}
@@ -116,6 +124,15 @@ public extension Listing {
// MARK: Computed
/// What the seller declared of the copy's condition, in a sentence: the defects, then the notes. Frozen into a sale at agreement.
var declaration: String {
let defects = defects.isEmpty
? "No defects declared."
: "Declared defects: \(defects.joined(separator: "; "))."
return sellerNotes.isEmpty ? defects : "\(defects) \(sellerNotes)"
}
/// 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 {
@@ -0,0 +1,221 @@
import Foundation
/// What a seller fills in to list a copy. The listing's slug and placeholder cover are derived from it on publishing.
public struct ListingDraft: Sendable, Equatable {
// MARK: Properties
/// The series or book title, e.g. `Nightward`.
public var series: String
/// The issue number or volume, e.g. `1`; empty for a one-off book.
public var issue: String
/// The publisher's name.
public var publisher: String
/// The cover year.
public var year: Int
/// The format token, e.g. `single-issues`.
public var format: String
/// The printing, counted from one; zero for later or unknown.
public var printing: Int
/// The edition, e.g. `Direct`; empty when not applicable.
public var edition: String
/// The company that graded and slabbed the copy, or `nil` for a raw copy.
public var grader: String?
/// The numeric grade, or `nil` for formats that are not graded.
public var grade: Double?
/// The condition as the seller states it, e.g. `NM` or `Like new`.
public var condition: String
/// The grader's certificate number, or `nil` for a raw copy.
public var certNumber: String?
/// The page quality, or `nil` when not stated.
public var pageQuality: String?
/// Why the issue is a key, or `nil` when it is not one.
public var keyNotes: String?
/// The defects declared, from the grading guide's checklist.
public var defects: [String]
/// The seller's notes on the copy.
public var sellerNotes: String
/// The asking price, in euro cents.
public var priceCents: Int
/// The shipping cost to the Netherlands, in euro cents.
public var shippingCents: Int
/// Whether the seller takes offers on the copy.
public var acceptsOffers: Bool
/// The URL paths of the photos of this copy, the front first.
public var photos: [String]
// MARK: Initializers
public init(
series: String,
issue: String,
publisher: String,
year: Int,
format: String,
printing: Int,
edition: String,
grader: String?,
grade: Double?,
condition: String,
certNumber: String?,
pageQuality: String?,
keyNotes: String?,
defects: [String],
sellerNotes: String,
priceCents: Int,
shippingCents: Int,
acceptsOffers: Bool,
photos: [String]
) {
self.series = series
self.issue = issue
self.publisher = publisher
self.year = year
self.format = format
self.printing = printing
self.edition = edition
self.grader = grader
self.grade = grade
self.condition = condition
self.certNumber = certNumber
self.pageQuality = pageQuality
self.keyNotes = keyNotes
self.defects = defects
self.sellerNotes = sellerNotes
self.priceCents = priceCents
self.shippingCents = shippingCents
self.acceptsOffers = acceptsOffers
self.photos = photos
}
}
// MARK: - Extensions
extension ListingDraft {
// MARK: Constants
/// The tones a placeholder cover is drawn in.
static let tones = ["navy", "wine", "forest", "rust", "slate", "plum", "brick", "olive", "ink"]
// MARK: Computed
/// The placeholder cover: the series lettered over a few lines, on a tone picked by the series so every copy of it matches.
var cover: Listing.Cover {
var lines = [String]()
for word in series.uppercased().split(separator: " ").map(String.init) {
if let last = lines.last, last.count + word.count < 10 {
lines[lines.count - 1] = "\(last) \(word)"
} else {
lines.append(word)
}
}
let marker = switch format {
case "manga": issue.isEmpty ? "MANGA" : "VOL \(issue)"
case "trade-paperbacks": "TPB"
case "hardcovers": "HC"
default: issue
}
return .init(
tone: Self.tones[series.unicodeScalars.reduce(0) { ($0 + Int($1.value)) % Self.tones.count }],
title: lines.prefix(3).joined(separator: "\n"),
imprint: publisher.uppercased(),
number: marker
)
}
/// Whether the copy is a single issue.
var isSingleIssue: Bool {
format == "single-issues"
}
/// The printing in a few words, e.g. `1st print`.
var printingLabel: String {
let noun = isSingleIssue ? "print" : "printing"
switch printing {
case 1: return "1st \(noun)"
case 2: return "2nd \(noun)"
case 3: return "3rd \(noun)"
default: return "later \(noun)"
}
}
/// The printing in full, e.g. `First printing, direct edition`.
var printingDetail: String {
let ordinal = switch printing {
case 1: "First"
case 2: "Second"
case 3: "Third"
default: "Later"
}
return edition.isEmpty ? "\(ordinal) printing" : "\(ordinal) printing, \(edition.lowercased()) edition"
}
/// The listing's title, e.g. `Nightward #1`, or `Hollow Comet, Vol. 4`.
var title: String {
guard !issue.isEmpty else {
return series
}
return switch format {
case "single-issues": "\(series) #\(issue)"
case "manga": "\(series), Vol. \(issue)"
default: "\(series) \(issue)"
}
}
// MARK: Methods
/// The listing the draft publishes as.
func listing(
slug: String,
shop: Shop,
at date: Date
) -> Listing {
.init(
slug: slug,
shop: shop,
title: title,
seriesTitle: isSingleIssue ? "\(series) (\(year) series)" : series,
issueLabel: issue.isEmpty ? "—" : (isSingleIssue ? "#\(issue)" : issue),
publisher: publisher,
year: year,
format: format,
printing: printingLabel,
printingDetail: printingDetail,
isFirstPrint: printing == 1,
grader: grader,
grade: grade,
condition: condition,
certNumber: certNumber,
pageQuality: pageQuality,
keyNotes: keyNotes,
sellerNotes: sellerNotes,
priceCents: priceCents,
shippingCents: shippingCents,
acceptsOffers: acceptsOffers,
cover: cover,
listedAt: date,
defects: defects,
photos: photos
)
}
/// A string as a URL slug: lowercased, its letters and digits kept, everything else a single hyphen.
static func slug(
_ string: String
) -> String {
string
.lowercased()
.folding(options: .diacriticInsensitive, locale: nil)
.split { !($0.isASCII && ($0.isLetter || $0.isNumber)) }
.joined(separator: "-")
}
}
@@ -0,0 +1,29 @@
/// A `Sendable` snapshot of a WebAuthn credential registered to an account.
public struct Passkey: Sendable, Equatable {
// MARK: Properties
/// The credential's identifier, base64url-encoded without padding.
public let credentialID: String
/// The credential's public key, COSE-encoded.
public let publicKey: [UInt8]
/// The authenticator's signature counter as last seen; a lower one later on signals a cloned authenticator.
public let signCount: UInt32
/// The account the credential signs in.
public let user: User
// MARK: Initializers
public init(
credentialID: String,
publicKey: [UInt8],
signCount: UInt32,
user: User
) {
self.credentialID = credentialID
self.publicKey = publicKey
self.signCount = signCount
self.user = user
}
}
@@ -1,4 +1,5 @@
import FluentKit
import Foundation
import HummingbirdFluent
/// Reads and creates accounts through the default database.
@@ -25,6 +26,97 @@ public struct AccountRepository: Sendable {
// MARK: Methods
/// Fetches the account with the given email, without creating one.
/// - Returns: the account, or `nil` when no account has the email.
public func existingAccount(
email: String
) async throws -> User? {
try await UserRecord.query(on: fluent.db())
.filter(\.$email == email)
.first()?
.snapshot
}
/// Whether a credential identifier is still free to register.
public func isUnregistered(
credentialID: String
) async throws -> Bool {
try await PasskeyRecord.query(on: fluent.db())
.filter(\.$credentialID == credentialID)
.first() == nil
}
/// Creates an account together with its first passkey: one step both joins and enrolls.
///
/// A passkey is only ever added to an account created in the same step. Adding one to an existing account would hand that account to
/// whoever asks, since the prototype has no other proof of who is asking.
/// - Returns: the new account, or `nil` when the email already has one.
public func join(
email: String,
credentialID: String,
publicKey: [UInt8],
signCount: UInt32
) async throws -> User? {
try await fluent.db().transaction { database in
guard try await UserRecord.query(on: database)
.filter(\.$email == email)
.first() == nil
else {
return nil
}
let user = UserRecord(
email: email,
displayName: String(email.prefix { $0 != "@" })
)
try await user.save(on: database)
try await PasskeyRecord(
userID: try user.requireID(),
credentialID: credentialID,
publicKey: publicKey,
signCount: signCount,
at: .init()
).save(on: database)
return try user.snapshot
}
}
/// Fetches the passkey with the given credential identifier, and the account it signs in.
public func passkey(
credentialID: String
) async throws -> Passkey? {
try await PasskeyRecord.query(on: fluent.db())
.filter(\.$credentialID == credentialID)
.with(\.$user)
.first()?
.snapshot
}
/// Fetches the passkeys registered to the account with the given email.
public func passkeys(
email: String
) async throws -> [Passkey] {
try await PasskeyRecord.query(on: fluent.db())
.join(UserRecord.self, on: \PasskeyRecord.$user.$id == \UserRecord.$id)
.filter(UserRecord.self, \.$email == email)
.with(\.$user)
.all()
.map { try $0.snapshot }
}
/// Records a passkey's signature counter after a sign-in.
public func recordUse(
ofPasskey credentialID: String,
signCount: UInt32
) async throws {
try await PasskeyRecord.query(on: fluent.db())
.filter(\.$credentialID == credentialID)
.set(\.$signCount, to: Int(signCount))
.update()
}
/// 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.
@@ -1,4 +1,5 @@
import FluentKit
import Foundation
import HummingbirdFluent
/// Reads the shops and their listings through the default database.
@@ -49,6 +50,48 @@ public struct CatalogueRepository: Sendable {
.sorted(by: filter.ordersBefore)
}
/// 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.
/// - seller: the account listing the copy.
/// - date: the moment the copy is listed; the present by default.
/// - Returns: the listing, or `nil` when the seller owns no shop.
public func publish(
_ draft: ListingDraft,
by seller: User,
at date: Date = .init()
) async throws -> Listing? {
try await fluent.db().transaction { database in
guard let shop = try await ShopRecord.query(on: database)
.filter(\.$owner.$id == seller.id)
.first()
else {
return nil
}
let listing = draft.listing(slug: "", shop: shop.snapshot, at: date)
let base = ListingDraft.slug("\(listing.title) \(listing.gradeLabel)")
let taken = Set(
try await ListingRecord.query(on: database)
.filter(\.$slug =~ base)
.all(\.$slug)
)
var slug = base
var suffix = 2
while taken.contains(slug) {
slug = "\(base)-\(suffix)"
suffix += 1
}
let published = draft.listing(slug: slug, shop: shop.snapshot, at: date)
try await ListingRecord(published, shopID: try shop.requireID()).save(on: database)
return published
}
}
/// 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())
@@ -472,7 +472,7 @@ private extension TradeRepository {
sale.declaredGrade = declared.gradeLabel + (declared.certNumber.map { " · cert \($0)" } ?? "")
sale.declaredPageQuality = declared.pageQuality
sale.declaredPrinting = declared.printingDetail
sale.declaredNotes = declared.sellerNotes
sale.declaredNotes = declared.declaration
sale.agreedAt = now()
listing.soldAt = now()