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

This commit is contained in:
javier committed 2026-09-21 13:52:52 +02:00
1 parent 31d2d98d49
commit 256d22df40
10 files changed
+473 -4

No files matched your search

@@ -32,6 +32,7 @@ public struct PrepareDB: Sendable {
CreateTrade(),
CreatePasskey(),
CreateCollecting(),
CreateCommunity(),
])
}
@@ -0,0 +1,39 @@
import Foundation
/// A `Sendable` snapshot of a con where shops trade in person, so a sale agreed on the site can be collected there.
public struct Event: Sendable, Equatable {
// MARK: Properties
/// The event's URL slug.
public let slug: String
/// The event's name.
public let name: String
/// Where it is held, e.g. `Jaarbeurs, Utrecht`.
public let venue: String
/// The first day.
public let startsOn: Date
/// The last day.
public let endsOn: Date
/// The shops trading there.
public let shops: [Shop]
// MARK: Initializers
public init(
slug: String,
name: String,
venue: String,
startsOn: Date,
endsOn: Date,
shops: [Shop] = []
) {
self.slug = slug
self.name = name
self.venue = venue
self.startsOn = startsOn
self.endsOn = endsOn
self.shops = shops
}
}
@@ -31,6 +31,8 @@ public struct ListingFilter: Sendable, Equatable {
public var verifiedShops: Bool
/// The slug of the one shop to match, or `nil` for all shops.
public var shop: String?
/// The slugs of the shops to match — those trading at an event, say — or `nil` for all shops.
public var shops: [String]?
/// The order of the results.
public var sort: Sort
@@ -51,6 +53,7 @@ public struct ListingFilter: Sendable, Equatable {
acceptsOffers: Bool = false,
verifiedShops: Bool = false,
shop: String? = nil,
shops: [String]? = nil,
sort: Sort = .newest
) {
self.query = query
@@ -67,6 +70,7 @@ public struct ListingFilter: Sendable, Equatable {
self.acceptsOffers = acceptsOffers
self.verifiedShops = verifiedShops
self.shop = shop
self.shops = shops
self.sort = sort
}
@@ -117,6 +121,10 @@ extension ListingFilter {
return false
}
if let shops, !shops.contains(listing.shop.slug) {
return false
}
if !formats.isEmpty, !formats.contains(listing.format) {
return false
}
@@ -133,6 +133,27 @@ public struct AccountRepository: Sendable {
).save(on: fluent.db())
}
/// Replaces every passkey of an account with a new one: what recovering an account comes down to.
public func replacePasskeys(
of user: User,
credentialID: String,
publicKey: [UInt8],
signCount: UInt32
) async throws {
try await fluent.db().transaction { database in
try await PasskeyRecord.query(on: database)
.filter(\.$user.$id == user.id)
.delete()
try await PasskeyRecord(
userID: user.id,
credentialID: credentialID,
publicKey: publicKey,
signCount: signCount,
at: .init()
).save(on: database)
}
}
/// Fetches the passkeys registered to an account, the oldest first, with when each was created.
public func passkeys(
of user: User
@@ -192,3 +192,105 @@ public extension CollectingRepository {
}
}
// MARK: - Collection
public extension CollectingRepository {
/// Adds issues of a series to an account's collection; the ones already in it, or outside the run, are skipped.
func own(
issues: [Int],
ofSeries slug: String,
by user: User
) async throws {
guard let series = try await series(slug: slug) else {
return
}
let owned = try await ownedIssues(of: user)[slug] ?? []
let run = series.issueCount > 0 ? 1...series.issueCount : 1...999
for issue in Set(issues).subtracting(owned) where run.contains(issue) {
try await OwnedIssueRecord(userID: user.id, seriesSlug: slug, issue: issue).save(on: fluent.db())
}
}
/// Fetches the issues an account owns, by series slug.
func ownedIssues(
of user: User
) async throws -> [String: Set<Int>] {
try await OwnedIssueRecord.query(on: fluent.db())
.filter(\.$user.$id == user.id)
.all()
.reduce(into: [:]) { $0[$1.seriesSlug, default: []].insert($1.issue) }
}
/// Takes an issue out of an account's collection.
func disown(
issue: Int,
ofSeries slug: String,
by user: User
) async throws {
try await OwnedIssueRecord.query(on: fluent.db())
.filter(\.$user.$id == user.id)
.filter(\.$seriesSlug == slug)
.filter(\.$issue == issue)
.delete()
}
}
// MARK: - Events
public extension CollectingRepository {
/// Fetches the events still to come or under way, soonest first, each with the shops trading there.
func upcomingEvents() async throws -> [Event] {
try await EventRecord.query(on: fluent.db())
.filter(\.$endsOn >= now().addingTimeInterval(-86_400))
.sort(\.$startsOn)
.with(\.$shops)
.all()
.map { record in
.init(
slug: record.slug,
name: record.name,
venue: record.venue,
startsOn: record.startsOn,
endsOn: record.endsOn,
shops: record.shops.map(\.snapshot)
)
}
}
/// Sets the upcoming events the shop an account owns trades at.
func setEvents(
_ slugs: [String],
forShopOwnedBy user: User
) async throws {
try await fluent.db().transaction { database in
guard let shop = try await ShopRecord.query(on: database)
.filter(\.$owner.$id == user.id)
.first()
else {
return
}
let upcoming = try await EventRecord.query(on: database)
.filter(\.$endsOn >= now().addingTimeInterval(-86_400))
.with(\.$shops)
.all()
for event in upcoming {
let trades = try event.shops.contains { try $0.requireID() == shop.requireID() }
if slugs.contains(event.slug), !trades {
try await event.$shops.attach(shop, on: database)
} else if !slugs.contains(event.slug), trades {
try await event.$shops.detach(shop, on: database)
}
}
}
}
}