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: "" ) } } }