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