Files
ccn/Packages/Persistence/Sources/Public/Repositories/CollectingRepository.swift
T

297 lines
8.9 KiB
Swift

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