Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3d2619ad | ||
|
|
e0524da525 | ||
|
|
06953a1be0 | ||
|
|
c1a391b9f6 | ||
|
|
911424de24 | ||
|
|
326299fc35 | ||
|
|
359fe0d6ac | ||
|
|
f2f445827b | ||
|
|
256d22df40 | ||
|
|
31d2d98d49 | ||
|
|
89a7a7b8e5 | ||
|
|
9403931109 | ||
|
|
95e0a455e8 | ||
|
|
0dc3fe6bea | ||
|
|
b1ceed7790 | ||
|
|
43dcc03da6 | ||
|
|
09c7b8a1a7 | ||
|
|
ea89ea892a | ||
|
|
6d7ab4bcba | ||
|
|
717df5208c |
No files matched your search
+3
-1
@@ -58,4 +58,6 @@ Services/Website/Tests/DB/
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/test_output
|
||||
# Photos uploaded to listings while running locally
|
||||
Services/Website/Resources/Static/uploads/
|
||||
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version: 6.3
|
||||
// swift-tools-version: 6.4
|
||||
|
||||
import PackageDescription
|
||||
|
||||
@@ -15,6 +15,16 @@ let package = Package(
|
||||
]
|
||||
),
|
||||
],
|
||||
traits: [
|
||||
.trait(
|
||||
name: "Analytics",
|
||||
description: "Analytics events, their element tagging, and the tracker script pages embed."
|
||||
),
|
||||
.trait(
|
||||
name: "StructuredData",
|
||||
description: "Schema.org structured data, rendered as a JSON-LD script."
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(
|
||||
path: "../Localization"
|
||||
@@ -82,3 +92,15 @@ let package = Package(
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
|
||||
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
|
||||
for target in package.targets {
|
||||
target.swiftSettings = (target.swiftSettings ?? []) + [
|
||||
.strictMemorySafety(),
|
||||
.treatAllWarnings(as: .error),
|
||||
.treatWarning("DeprecatedDeclaration", as: .warning),
|
||||
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
|
||||
.enableUpcomingFeature("InferIsolatedConformances"),
|
||||
]
|
||||
}
|
||||
@@ -8,8 +8,8 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too
|
||||
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `CompressionMiddleware`, `RateLimitMiddleware`, `NotFoundMiddleware` |
|
||||
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
||||
| Link previews | `SocialCard`, its `locale` and the `alternateLocales` of its other language editions, its `Image` and `Style`, and the `Tag` meta tags it derives |
|
||||
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, the open `Name` and `Kind` vocabularies, and the site-wide initializer building the `Organization`/`WebSite` pair |
|
||||
| Analytics | `Analytics`, the `Event`s a page reports (with `tagging()` to apply one to any attribute-bearing HTML or SVG tag), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets |
|
||||
| Structured data | Opt-in `StructuredData` trait: `StructuredData`, its graph types (`Node`, `Property`, `Value`, `Address`, `Organization`), the open `Name` and `Kind` vocabularies, and the site-wide `Organization`/`WebSite` initializer |
|
||||
| Analytics | Opt-in `Analytics` trait: `Analytics`, the `Event`s a page reports (with `reporting(_:on:)` to tag an element, for a click or a view), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets |
|
||||
| Responses | `CachedHTMLResponse` (rendered and gzipped once), `LocalizedHTMLCollectionResponse` (one of those per language) |
|
||||
| Localization | The `Negotiate` extension resolving a request's language from its `lang` override, path prefix, and `Accept-Language` |
|
||||
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
|
||||
@@ -18,7 +18,7 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too
|
||||
The package holds only what every service can reuse. Anything a service owns is injected, never referenced.
|
||||
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. What a type needs, it takes as a parameter: the `bundle:` whose String Catalog names the supported languages, the `document:` closure that builds a page for a locale, a `Page` conformer's `metadata` and its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive absolute and fully formed — composing them stays with the page. The same holds for request semantics: only the caller knows whether it negotiates the language or pins it by route, so `variesOnAcceptLanguage:` declares whether the responses carry `Vary: Accept-Language`.
|
||||
- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready form — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — which `Page` applies without knowing the vocabulary. A page's `scripts` and the tracker render as `defer`red head tags, the tracker preceded by a `preconnect` to its cross-origin host.
|
||||
- **Nodes are joined by `@id`, not repetition.** A node another page must point at gets its identifier from a helper rather than a hand-spelled fragment — `organizationID(forSiteURL:)` names the node the site-wide initializer builds, and that initializer's `founder` takes such an identifier back. A service adds the helper for any node it owns, so neither side can drift.
|
||||
- **Nodes are joined by `@id`, not repetition.** Identifiers come from helpers, never hand-spelled: `organizationID(forSiteURL:)` and `websiteID(forSiteURL:)` name the site-wide nodes, and `Organization.founder` takes one back. A service adds a helper for each node it owns.
|
||||
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites retroactively — the Website's `*+Defaults` are the pattern. The open schema.org vocabularies work the same way: the package declares the shared `Property.Name` and `Node.Kind` constants, a service adds its own.
|
||||
- **Method structs.** Single-operation types such as `FingerprintAssets` take lifetime-fixed configuration in `init` and per-call inputs in `callAsFunction`.
|
||||
- **Wrap upstream, do not restate it.** Where a Hummingbird middleware is almost right, the package delegates to it and adds only the missing decision — `CompressionMiddleware` passes everything to `ResponseCompressionMiddleware` except responses that already name an encoding, which it would otherwise compress twice.
|
||||
@@ -31,7 +31,7 @@ Sources/
|
||||
├── Public/ public API
|
||||
│ ├── Builders/ RouteCollectionBuilder
|
||||
│ ├── Enumerations/ AssetExtension
|
||||
│ ├── Extensions/ addController, Analytics.Event tagging, and the request-language negotiation, plus the default header names, rate limits, and header values
|
||||
│ ├── Extensions/ addController, Analytics.Event tagging and reporting, and the request-language negotiation, plus the default header names, rate limits, and header values
|
||||
│ ├── Methods/ FingerprintAssets
|
||||
│ ├── Middlewares/ the seven HTTP middlewares
|
||||
│ ├── Protocols/ Asset, Page, RouterController
|
||||
@@ -49,7 +49,9 @@ Tests/
|
||||
## Testing
|
||||
Every suite carries a tag for the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, `.type` — declared in `Tests/Utils/Extensions/Tag+Constants.swift`, so a run can be sliced by kind. A new suite takes the tag matching its subject, or adds one when none fits.
|
||||
|
||||
Run `swift test --traits Analytics,StructuredData` to include the trait-gated suites.
|
||||
|
||||
## Requirements
|
||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
|
||||
- macOS 15, matching the sibling packages. The services deploy to Linux containers; the packages declare no UI platforms.
|
||||
- Package dependencies: the local `Localization` package, `elementary`, `hummingbird`, and — for `CompressionMiddleware` and the responses' gzip — `hummingbird-compression` and `compress-nio`.
|
||||
@@ -1,5 +1,3 @@
|
||||
import Foundation
|
||||
|
||||
/// Hashes bytes with the FNV-1a 64-bit algorithm.
|
||||
///
|
||||
/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it suits values that must agree between instances and survive
|
||||
@@ -25,10 +23,9 @@ struct FNV1aHash {
|
||||
///
|
||||
/// Reading it does not consume the running hash: more bytes can be combined afterwards.
|
||||
var digest: String {
|
||||
String(
|
||||
format: "%016llx",
|
||||
hash
|
||||
)
|
||||
let hex = String(hash, radix: 16)
|
||||
|
||||
return String(repeating: "0", count: 16 - hex.count) + hex
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#if Analytics
|
||||
import Elementary
|
||||
|
||||
public extension Analytics.Event {
|
||||
@@ -11,9 +12,12 @@ public extension Analytics.Event {
|
||||
/// .attributes(contentsOf: Analytics.Event(name: "playlist").tagging())
|
||||
/// ```
|
||||
///
|
||||
/// - Parameter trigger: what reports the event; a click by default.
|
||||
/// - Returns: one HTML attribute per event attribute, applied verbatim.
|
||||
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>() -> [HTMLAttribute<Tag>] {
|
||||
attributes.map {
|
||||
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>(
|
||||
on trigger: Trigger = .click
|
||||
) -> [HTMLAttribute<Tag>] {
|
||||
attributes(on: trigger).map {
|
||||
.custom(
|
||||
name: $0.name,
|
||||
value: $0.value
|
||||
@@ -22,3 +26,4 @@ public extension Analytics.Event {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -5,6 +5,8 @@ public extension HTTPField.Name {
|
||||
static let permissionsPolicy = Self("Permissions-Policy")!
|
||||
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let referrerPolicy = Self("Referrer-Policy")!
|
||||
/// The `Sec-Fetch-Mode` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let secFetchMode = Self("Sec-Fetch-Mode")!
|
||||
/// The `X-Robots-Tag` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let robotsTag = Self("X-Robots-Tag")!
|
||||
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#if Analytics
|
||||
import Elementary
|
||||
|
||||
public extension MarkupContent where Self: _Attributed, Tag: MarkupTrait.AllowsAttributes {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Tags the element with the event it reports to the analytics tracker.
|
||||
///
|
||||
/// ```swift
|
||||
/// a(.href(url)) { "Listen" }
|
||||
/// .reporting(.init(name: "playlist"))
|
||||
/// ```
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - event: the event the element reports.
|
||||
/// - trigger: what reports the event; a click by default.
|
||||
/// - Returns: the element, carrying the event's ``Analytics/Event/attributes(on:)``.
|
||||
func reporting(
|
||||
_ event: Analytics.Event,
|
||||
on trigger: Analytics.Event.Trigger = .click
|
||||
) -> Self {
|
||||
attributes(contentsOf: event.tagging(on: trigger))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension MarkupContent where Tag: MarkupTrait.AllowsAttributes {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Tags the content with the event it reports to the analytics tracker.
|
||||
///
|
||||
/// The fallback for content that is not an element itself, mirroring Elementary's `attributes(contentsOf:)`.
|
||||
/// - Parameters:
|
||||
/// - event: the event the content reports.
|
||||
/// - trigger: what reports the event; a click by default.
|
||||
/// - Returns: the content, carrying the event's ``Analytics/Event/attributes(on:)``.
|
||||
@_disfavoredOverload
|
||||
func reporting(
|
||||
_ event: Analytics.Event,
|
||||
on trigger: Analytics.Event.Trigger = .click
|
||||
) -> _AttributedContent<Self> {
|
||||
attributes(contentsOf: event.tagging(on: trigger))
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -47,7 +47,7 @@ extension CompressionMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
let response = try await next(
|
||||
request,
|
||||
|
||||
@@ -59,7 +59,7 @@ extension HTTPSRedirectMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
guard
|
||||
isEnabled,
|
||||
|
||||
@@ -60,7 +60,7 @@ extension NotFoundMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
do {
|
||||
return try await next(request, context)
|
||||
|
||||
@@ -57,7 +57,7 @@ extension RateLimitMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
let admission = buckets.admit(
|
||||
client(
|
||||
|
||||
@@ -49,7 +49,7 @@ extension SecurityHeadersMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
var response: Response
|
||||
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ extension TrailingSlashRedirectMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
let path = request.uri.path
|
||||
let canonical = canonicalPath(of: path)
|
||||
|
||||
@@ -46,7 +46,7 @@ extension VaryMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
var response = try await next(
|
||||
request,
|
||||
|
||||
@@ -6,6 +6,8 @@ import Foundation
|
||||
/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the
|
||||
/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, the analytics
|
||||
/// tracker script, and the metadata followed by the stylesheet links and the deferred script tags in the head, and the content as the body.
|
||||
///
|
||||
/// ``analytics`` requires the `Analytics` trait; ``structuredData``, the `StructuredData` trait.
|
||||
public protocol Page: HTMLDocument, Sendable {
|
||||
|
||||
// MARK: Associated types
|
||||
@@ -18,8 +20,10 @@ public protocol Page: HTMLDocument, Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
#if Analytics
|
||||
/// The analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it.
|
||||
var analytics: Analytics? { get }
|
||||
#endif
|
||||
|
||||
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||
var assetVersion: String? { get }
|
||||
@@ -48,8 +52,10 @@ public protocol Page: HTMLDocument, Sendable {
|
||||
/// to omit them.
|
||||
var socialCard: SocialCard? { get }
|
||||
|
||||
#if StructuredData
|
||||
/// The page's structured data, rendered as a JSON-LD script in the document head, or `nil` (the default) to omit it.
|
||||
var structuredData: StructuredData? { get }
|
||||
#endif
|
||||
|
||||
/// The stylesheets linked in the document head, in order.
|
||||
var stylesheets: [any Asset] { get }
|
||||
@@ -94,6 +100,7 @@ public extension Page {
|
||||
.content("width=device-width, initial-scale=1")
|
||||
)
|
||||
|
||||
#if Analytics
|
||||
// Rendered first so the cross-origin handshake starts before the parser reaches the tracker script tag.
|
||||
if let origin = analytics?.origin {
|
||||
link(
|
||||
@@ -101,6 +108,7 @@ public extension Page {
|
||||
.href(origin)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
if let summary {
|
||||
meta(
|
||||
@@ -128,6 +136,7 @@ public extension Page {
|
||||
}
|
||||
}
|
||||
|
||||
#if StructuredData
|
||||
if let structuredData {
|
||||
script(.custom(
|
||||
name: "type",
|
||||
@@ -136,7 +145,9 @@ public extension Page {
|
||||
HTMLRaw(structuredData.payload)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if Analytics
|
||||
if let analytics {
|
||||
script(
|
||||
.defer,
|
||||
@@ -160,6 +171,7 @@ public extension Page {
|
||||
) {}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
metadata
|
||||
|
||||
@@ -184,20 +196,24 @@ public extension Page {
|
||||
}
|
||||
}
|
||||
|
||||
#if Analytics
|
||||
/// The analytics tracker is omitted unless the page provides one.
|
||||
var analytics: Analytics? {
|
||||
nil
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The social card is omitted unless the page provides one.
|
||||
var socialCard: SocialCard? {
|
||||
nil
|
||||
}
|
||||
|
||||
#if StructuredData
|
||||
/// The structured data is omitted unless the page provides one.
|
||||
var structuredData: StructuredData? {
|
||||
nil
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The summary is omitted unless the page provides one.
|
||||
var summary: String? {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#if Analytics
|
||||
import Foundation
|
||||
|
||||
/// The Umami analytics tracker a page embeds as a deferred `<script>` in its document head.
|
||||
@@ -114,3 +115,4 @@ public struct Analytics: Sendable {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -1,3 +1,4 @@
|
||||
#if Analytics
|
||||
extension Analytics {
|
||||
/// A name-value pair rendered as an HTML attribute.
|
||||
///
|
||||
@@ -29,3 +30,4 @@ extension Analytics {
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,8 +1,11 @@
|
||||
#if Analytics
|
||||
extension Analytics {
|
||||
/// An interaction a page reports to the tracker, rendered as `data-` attributes on the element that carries it.
|
||||
///
|
||||
/// The tracker records a click on any element carrying `data-umami-event`; each `data-umami-event-<name>` beside it becomes a property
|
||||
/// the report can be broken down by. Reports group by ``name``, so related interactions should share one name and differ by a property.
|
||||
///
|
||||
/// A ``Trigger/view`` renders under `data-view-event`, which a page script reports instead of the tracker.
|
||||
public struct Event: Equatable, Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
@@ -34,22 +37,51 @@ extension Analytics {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The event's attributes — the event name, then each property — as full `data-` attribute names an element applies verbatim.
|
||||
/// The event's attributes when clicked; see ``attributes(on:)``.
|
||||
public var attributes: [Attribute] {
|
||||
[.init(Constant.Name.prefix, value: name)]
|
||||
attributes(on: .click)
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// The event's attributes — the event name, then each property — as full `data-` attribute names an element applies verbatim.
|
||||
/// - Parameter trigger: what reports the event.
|
||||
/// - Returns: one attribute for the name, then one per property.
|
||||
public func attributes(on trigger: Trigger) -> [Attribute] {
|
||||
[.init(trigger.prefix, value: name)]
|
||||
+ properties.map {
|
||||
.init("\(Constant.Name.prefix)-\($0.name)", value: $0.value)
|
||||
.init("\(trigger.prefix)-\($0.name)", value: $0.value)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
// MARK: - Types
|
||||
|
||||
private enum Constant {
|
||||
enum Name {
|
||||
/// The attribute name the tracker watches for, and the prefix each property renders under.
|
||||
static let prefix = "data-umami-event"
|
||||
extension Analytics.Event {
|
||||
/// What reports an event.
|
||||
public enum Trigger: Sendable {
|
||||
/// A click, which the tracker records itself.
|
||||
case click
|
||||
/// The element first coming fully on screen, which a page script records.
|
||||
case view
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension Analytics.Event.Trigger {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The attribute name marking the event, and the prefix each property renders under.
|
||||
var prefix: String {
|
||||
switch self {
|
||||
case .click: "data-umami-event"
|
||||
case .view: "data-view-event"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -45,6 +45,7 @@ extension SocialCard {
|
||||
Tag(String(width), name: .imageWidth),
|
||||
Tag(String(height), name: .imageHeight),
|
||||
alt.map { Tag($0, name: .imageAlt) },
|
||||
alt.map { Tag($0, name: .twitterImageAlt) },
|
||||
]
|
||||
|
||||
return tags.compactMap { $0 }
|
||||
|
||||
@@ -68,6 +68,8 @@ extension SocialCard.Tag {
|
||||
case title = "og:title"
|
||||
/// The `twitter:card` tag, carrying the layout a Twitter card scraper gives the card.
|
||||
case twitter = "twitter:card"
|
||||
/// The `twitter:image:alt` tag, carrying the share image's text for assistive technologies.
|
||||
case twitterImageAlt = "twitter:image:alt"
|
||||
/// The `og:type` tag, carrying the Open Graph type of the object the card describes.
|
||||
case type = "og:type"
|
||||
/// The `og:url` tag, carrying the absolute URL the card's page is served at.
|
||||
@@ -84,7 +86,7 @@ public extension SocialCard.Tag.Name {
|
||||
|
||||
/// The attribute keying a tag with this name: `property` for the Open Graph names, `name` for the Twitter ones.
|
||||
var attribute: SocialCard.Tag.Attribute {
|
||||
self == .twitter ? .name : .property
|
||||
rawValue.hasPrefix("twitter:") ? .name : .property
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
#if StructuredData
|
||||
/// The structured data of a page, rendered as a JSON-LD script in the document head.
|
||||
///
|
||||
/// The data is a graph of schema.org ``Node`` values — each a ``Node/type``, an optional ``Node/id``, and ``Property`` values in render
|
||||
/// order — and derives the ``payload`` embedding them for the search engines that read it. A page either composes the nodes of its own
|
||||
/// shape directly, or uses ``init(name:url:logo:profiles:)`` for the site-wide pair every page shares.
|
||||
/// shape directly, or uses ``init(organization:inLanguage:)`` for the site-wide pair every page shares.
|
||||
///
|
||||
/// Search engines require absolute URLs, so a node takes its URLs fully formed; composing them from an origin and a versioned asset path stays with the
|
||||
/// page providing the data.
|
||||
@@ -37,6 +38,15 @@ public struct StructuredData: Equatable, Sendable {
|
||||
url + "#organization"
|
||||
}
|
||||
|
||||
/// The `@id` of the `WebSite` node the site-wide initializer builds, for a page node's `isPartOf` to reference.
|
||||
/// - Parameter url: the absolute URL the site is served at, as passed to that initializer.
|
||||
/// - Returns: the website node's `@id`.
|
||||
public static func websiteID(
|
||||
forSiteURL url: String
|
||||
) -> String {
|
||||
url + "#website"
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
|
||||
@@ -53,75 +63,18 @@ public struct StructuredData: Equatable, Sendable {
|
||||
|
||||
public extension StructuredData {
|
||||
|
||||
/// Creates the site-wide structured data: an `Organization` node carrying the name, URL, logo, and profiles, and a `WebSite` node
|
||||
/// carrying the name and URL and referencing the organization as its `publisher`. The nodes are linked through an `@id` derived
|
||||
/// from the URL, so search engines read the site as published by the organization rather than as two unrelated assertions.
|
||||
/// A property whose fact the data does not carry is left out.
|
||||
/// Creates the site-wide structured data: the organization's node and the `WebSite` node it publishes, both with an `@id`.
|
||||
/// - Parameters:
|
||||
/// - name: the name of the organization and the site.
|
||||
/// - url: the absolute URL the site is served at.
|
||||
/// - alternateName: the name the organization is also known by, or `nil` (the default) to omit its property.
|
||||
/// - description: what the organization does, or `nil` (the default) to omit its property.
|
||||
/// - areaServed: the area the organization serves, or `nil` (the default) to omit its property.
|
||||
/// - email: the address the organization is written to, or `nil` (the default) to omit its property.
|
||||
/// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
|
||||
/// - organization: the organization publishing the site.
|
||||
/// - inLanguage: the language codes the site is published in; empty (the default) to omit the property.
|
||||
/// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
|
||||
/// - founder: the `@id` of the `Person` node founding the organization, or `nil` (the default) to omit its property.
|
||||
init(
|
||||
name: String,
|
||||
url: String,
|
||||
alternateName: String? = nil,
|
||||
description: String? = nil,
|
||||
areaServed: String? = nil,
|
||||
email: String? = nil,
|
||||
logo: String? = nil,
|
||||
inLanguage: [String] = [],
|
||||
profiles: [String] = [],
|
||||
founder: String? = nil
|
||||
organization: Organization,
|
||||
inLanguage: [String] = []
|
||||
) {
|
||||
let id = Self.organizationID(forSiteURL: url)
|
||||
|
||||
var organization: [Property] = [
|
||||
.init(.name, value: .string(name)),
|
||||
.init(.url, value: .string(url)),
|
||||
]
|
||||
|
||||
if let alternateName {
|
||||
organization.append(.init(.alternateName, value: .string(alternateName)))
|
||||
}
|
||||
|
||||
if let description {
|
||||
organization.append(.init(.description, value: .string(description)))
|
||||
}
|
||||
|
||||
if let areaServed {
|
||||
organization.append(.init(.areaServed, value: .string(areaServed)))
|
||||
}
|
||||
|
||||
if let email {
|
||||
organization.append(.init(.email, value: .string(email)))
|
||||
}
|
||||
|
||||
if let logo {
|
||||
organization.append(.init(.logo, value:.string(logo)))
|
||||
}
|
||||
|
||||
if !profiles.isEmpty {
|
||||
organization.append(.init(
|
||||
.sameAs,
|
||||
value: .array(profiles.map(Value.string))
|
||||
))
|
||||
}
|
||||
|
||||
if let founder {
|
||||
organization.append(.init(.founder, value: .reference(founder)))
|
||||
}
|
||||
|
||||
var website: [Property] = [
|
||||
.init(.name, value: .string(name)),
|
||||
.init(.url, value: .string(url)),
|
||||
.init(.publisher, value: .reference(id)),
|
||||
.init(.name, value: .string(organization.name)),
|
||||
.init(.url, value: .string(organization.url)),
|
||||
.init(.publisher, value: .reference(Self.organizationID(forSiteURL: organization.url))),
|
||||
]
|
||||
|
||||
if !inLanguage.isEmpty {
|
||||
@@ -132,13 +85,10 @@ public extension StructuredData {
|
||||
}
|
||||
|
||||
self.init(nodes: [
|
||||
.init(
|
||||
type: .organization,
|
||||
id: id,
|
||||
properties: organization
|
||||
),
|
||||
organization.node,
|
||||
.init(
|
||||
type: .website,
|
||||
id: Self.websiteID(forSiteURL: organization.url),
|
||||
properties: website
|
||||
),
|
||||
])
|
||||
@@ -159,3 +109,4 @@ private extension StructuredData {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#if StructuredData
|
||||
extension StructuredData {
|
||||
/// A schema.org `PostalAddress`.
|
||||
public struct Address: Equatable, Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The ISO 3166-1 alpha-2 country code (e.g. `NL`).
|
||||
public let country: String
|
||||
|
||||
/// The city.
|
||||
public let locality: String
|
||||
|
||||
/// The postal code.
|
||||
public let postalCode: String
|
||||
|
||||
/// The street and house number.
|
||||
public let street: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a postal address.
|
||||
/// - Parameters:
|
||||
/// - street: the street and house number.
|
||||
/// - postalCode: the postal code.
|
||||
/// - locality: the city.
|
||||
/// - country: the ISO 3166-1 alpha-2 country code (e.g. `NL`).
|
||||
public init(
|
||||
street: String,
|
||||
postalCode: String,
|
||||
locality: String,
|
||||
country: String
|
||||
) {
|
||||
self.country = country
|
||||
self.locality = locality
|
||||
self.postalCode = postalCode
|
||||
self.street = street
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The address's node.
|
||||
var node: Node {
|
||||
.init(
|
||||
type: .postalAddress,
|
||||
properties: [
|
||||
.init(.streetAddress, value: .string(street)),
|
||||
.init(.postalCode, value: .string(postalCode)),
|
||||
.init(.addressLocality, value: .string(locality)),
|
||||
.init(.addressCountry, value: .string(country)),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,3 +1,4 @@
|
||||
#if StructuredData
|
||||
extension StructuredData {
|
||||
/// A schema.org node of a ``StructuredData`` graph: its type, its optional identifier, and its properties.
|
||||
public struct Node: Equatable, Sendable {
|
||||
@@ -87,6 +88,9 @@ extension StructuredData.Node {
|
||||
public extension StructuredData.Node.Kind {
|
||||
/// An organization, e.g. the one publishing a website.
|
||||
static let organization: Self = "Organization"
|
||||
/// A postal address.
|
||||
static let postalAddress: Self = "PostalAddress"
|
||||
/// A website.
|
||||
static let website: Self = "WebSite"
|
||||
}
|
||||
#endif
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
#if StructuredData
|
||||
extension StructuredData {
|
||||
/// A schema.org `Organization` publishing a site; a fact it does not carry is left out.
|
||||
public struct Organization: Equatable, Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The postal address.
|
||||
public let address: Address?
|
||||
|
||||
/// The name it is also known by.
|
||||
public let alternateName: String?
|
||||
|
||||
/// The area it serves.
|
||||
public let areaServed: String?
|
||||
|
||||
/// What it does.
|
||||
public let description: String?
|
||||
|
||||
/// The email address.
|
||||
public let email: String?
|
||||
|
||||
/// The `@id` of the `Person` node founding it.
|
||||
public let founder: String?
|
||||
|
||||
/// The ISO 8601 date it was founded.
|
||||
public let foundingDate: String?
|
||||
|
||||
/// The ISO 6523 identifier.
|
||||
public let iso6523Code: String?
|
||||
|
||||
/// The registered name.
|
||||
public let legalName: String?
|
||||
|
||||
/// The absolute URL of the logo.
|
||||
public let logo: String?
|
||||
|
||||
/// The name, shared by the site.
|
||||
public let name: String
|
||||
|
||||
/// The absolute URLs of its public profiles.
|
||||
public let profiles: [String]
|
||||
|
||||
/// The phone number.
|
||||
public let telephone: String?
|
||||
|
||||
/// The absolute URL of the site.
|
||||
public let url: String
|
||||
|
||||
/// The VAT number.
|
||||
public let vatID: String?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an organization.
|
||||
/// - Parameters:
|
||||
/// - name: the name, shared by the site.
|
||||
/// - url: the absolute URL of the site.
|
||||
/// - alternateName: the name it is also known by.
|
||||
/// - legalName: the registered name.
|
||||
/// - description: what it does.
|
||||
/// - areaServed: the area it serves.
|
||||
/// - email: the email address.
|
||||
/// - telephone: the phone number.
|
||||
/// - address: the postal address.
|
||||
/// - vatID: the VAT number.
|
||||
/// - iso6523Code: the ISO 6523 identifier.
|
||||
/// - logo: the absolute URL of the logo.
|
||||
/// - profiles: the absolute URLs of its public profiles.
|
||||
/// - foundingDate: the ISO 8601 date it was founded.
|
||||
/// - founder: the `@id` of the `Person` node founding it.
|
||||
public init(
|
||||
name: String,
|
||||
url: String,
|
||||
alternateName: String? = nil,
|
||||
legalName: String? = nil,
|
||||
description: String? = nil,
|
||||
areaServed: String? = nil,
|
||||
email: String? = nil,
|
||||
telephone: String? = nil,
|
||||
address: Address? = nil,
|
||||
vatID: String? = nil,
|
||||
iso6523Code: String? = nil,
|
||||
logo: String? = nil,
|
||||
profiles: [String] = [],
|
||||
foundingDate: String? = nil,
|
||||
founder: String? = nil
|
||||
) {
|
||||
self.address = address
|
||||
self.alternateName = alternateName
|
||||
self.areaServed = areaServed
|
||||
self.description = description
|
||||
self.email = email
|
||||
self.founder = founder
|
||||
self.foundingDate = foundingDate
|
||||
self.iso6523Code = iso6523Code
|
||||
self.legalName = legalName
|
||||
self.logo = logo
|
||||
self.name = name
|
||||
self.profiles = profiles
|
||||
self.telephone = telephone
|
||||
self.url = url
|
||||
self.vatID = vatID
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The organization's node.
|
||||
var node: Node {
|
||||
let properties: [Property?] = [
|
||||
.init(.name, value: .string(name)),
|
||||
.init(.url, value: .string(url)),
|
||||
alternateName.map { .init(.alternateName, value: .string($0)) },
|
||||
legalName.map { .init(.legalName, value: .string($0)) },
|
||||
description.map { .init(.description, value: .string($0)) },
|
||||
areaServed.map { .init(.areaServed, value: .string($0)) },
|
||||
email.map { .init(.email, value: .string($0)) },
|
||||
telephone.map { .init(.telephone, value: .string($0)) },
|
||||
address.map { .init(.address, value: .node($0.node)) },
|
||||
vatID.map { .init(.vatID, value: .string($0)) },
|
||||
iso6523Code.map { .init(.iso6523Code, value: .string($0)) },
|
||||
logo.map { .init(.logo, value: .string($0)) },
|
||||
profiles.isEmpty ? nil : .init(.sameAs, value: .array(profiles.map(Value.string))),
|
||||
foundingDate.map { .init(.foundingDate, value: .string($0)) },
|
||||
founder.map { .init(.founder, value: .reference($0)) },
|
||||
]
|
||||
|
||||
return .init(
|
||||
type: .organization,
|
||||
id: StructuredData.organizationID(forSiteURL: url),
|
||||
properties: properties.compactMap { $0 }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+22
@@ -1,3 +1,4 @@
|
||||
#if StructuredData
|
||||
extension StructuredData {
|
||||
/// A named property of a ``Node``, in the position it renders.
|
||||
public struct Property: Equatable, Sendable {
|
||||
@@ -69,6 +70,12 @@ extension StructuredData.Property {
|
||||
// MARK: - Constants
|
||||
|
||||
public extension StructuredData.Property.Name {
|
||||
/// The postal address of the thing a node describes.
|
||||
static let address: Self = "address"
|
||||
/// The country of a postal address.
|
||||
static let addressCountry: Self = "addressCountry"
|
||||
/// The city of a postal address.
|
||||
static let addressLocality: Self = "addressLocality"
|
||||
/// The name the thing a node describes is also known by.
|
||||
static let alternateName: Self = "alternateName"
|
||||
/// The area an organization serves.
|
||||
@@ -79,16 +86,31 @@ public extension StructuredData.Property.Name {
|
||||
static let email: Self = "email"
|
||||
/// The person who founded an organization.
|
||||
static let founder: Self = "founder"
|
||||
/// The ISO 8601 date an organization was founded.
|
||||
static let foundingDate: Self = "foundingDate"
|
||||
/// The language a creative work — a site, a page — is published in.
|
||||
static let inLanguage: Self = "inLanguage"
|
||||
/// An organization's ISO 6523 identifier.
|
||||
static let iso6523Code: Self = "iso6523Code"
|
||||
/// An organization's registered name.
|
||||
static let legalName: Self = "legalName"
|
||||
/// The absolute URL of an organization's logo.
|
||||
static let logo: Self = "logo"
|
||||
/// The name of the thing a node describes.
|
||||
static let name: Self = "name"
|
||||
/// The postal code of a postal address.
|
||||
static let postalCode: Self = "postalCode"
|
||||
/// The organization publishing a website.
|
||||
static let publisher: Self = "publisher"
|
||||
/// The absolute URLs of the profiles that also identify the thing a node describes.
|
||||
static let sameAs: Self = "sameAs"
|
||||
/// The street and house number of a postal address.
|
||||
static let streetAddress: Self = "streetAddress"
|
||||
/// The phone number of the thing a node describes.
|
||||
static let telephone: Self = "telephone"
|
||||
/// The absolute URL of the thing a node describes.
|
||||
static let url: Self = "url"
|
||||
/// An organization's VAT number.
|
||||
static let vatID: Self = "vatID"
|
||||
}
|
||||
#endif
|
||||
@@ -1,3 +1,4 @@
|
||||
#if StructuredData
|
||||
extension StructuredData {
|
||||
/// A value of a ``Property``: a string, a number, a list, a nested node, or a reference to another node.
|
||||
///
|
||||
@@ -69,3 +70,4 @@ extension StructuredData.Value {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -1,3 +1,4 @@
|
||||
#if Analytics
|
||||
import Elementary
|
||||
import Testing
|
||||
|
||||
@@ -57,3 +58,4 @@ struct AnalyticsEventTaggingTests {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#if Analytics
|
||||
import Elementary
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite(
|
||||
"MarkupContent+Reporting extension",
|
||||
.tags(.extension)
|
||||
)
|
||||
struct MarkupContentReportingTests {
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `renders the same markup as applying the event's tagging`() {
|
||||
let event = Analytics.Event(
|
||||
name: "playlist",
|
||||
properties: ["set": "avc-xi"]
|
||||
)
|
||||
|
||||
let reported = a(.href("/")) { "Listen" }
|
||||
.reporting(event)
|
||||
.render()
|
||||
let tagged = a(.href("/")) { "Listen" }
|
||||
.attributes(contentsOf: event.tagging())
|
||||
.render()
|
||||
|
||||
#expect(reported == tagged)
|
||||
#expect(reported == #"<a href="/" data-umami-event="playlist" data-umami-event-set="avc-xi">Listen</a>"#)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `keeps the attributes chained before it`() {
|
||||
let rendered = a(.href("https://example.com")) { "Listen" }
|
||||
.attributes(.target(.blank), when: true)
|
||||
.reporting(.init(name: "playlist"))
|
||||
.render()
|
||||
|
||||
#expect(rendered == #"<a href="https://example.com" target="_blank" data-umami-event="playlist">Listen</a>"#)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `tags a view under its own prefix`() {
|
||||
let rendered = h2 { "KLM" }
|
||||
.reporting(
|
||||
.init(
|
||||
name: "case_view",
|
||||
properties: ["case": "klm"]
|
||||
),
|
||||
on: .view
|
||||
)
|
||||
.render()
|
||||
|
||||
#expect(rendered == #"<h2 data-view-event="case_view" data-view-event-case="klm">KLM</h2>"#)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `reports from an SVG tag`() {
|
||||
let rendered = SVG.path {}
|
||||
.reporting(.init(name: "logo"))
|
||||
.render()
|
||||
|
||||
#expect(rendered.contains(#"data-umami-event="logo""#))
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -101,6 +101,7 @@ struct PageTests {
|
||||
#expect(html.contains(#"<meta name="twitter:card" content="summary_large_image">"#))
|
||||
}
|
||||
|
||||
#if StructuredData
|
||||
@Test
|
||||
func `omits the structured data script by default`() {
|
||||
let html = StubPage().render()
|
||||
@@ -111,21 +112,25 @@ struct PageTests {
|
||||
@Test
|
||||
func `renders the structured data script when provided`() {
|
||||
let html = StubPage(structuredData: .init(
|
||||
name: "Stub Site",
|
||||
url: "https://stub.example/",
|
||||
logo: "https://stub.example/logo.png",
|
||||
profiles: ["https://social.example/stub"]
|
||||
organization: .init(
|
||||
name: "Stub Site",
|
||||
url: "https://stub.example/",
|
||||
logo: "https://stub.example/logo.png",
|
||||
profiles: ["https://social.example/stub"]
|
||||
)
|
||||
)).render()
|
||||
|
||||
#expect(html.contains(
|
||||
#"<script type="application/ld+json">"# +
|
||||
#"{"@context":"https://schema.org","@graph":["# +
|
||||
#"{"@type":"Organization","@id":"https://stub.example/#organization","name":"Stub Site","url":"https://stub.example/","logo":"https://stub.example/logo.png","sameAs":["https://social.example/stub"]},"# +
|
||||
#"{"@type":"WebSite","name":"Stub Site","url":"https://stub.example/","publisher":{"@id":"https://stub.example/#organization"}}]}"# +
|
||||
#"{"@type":"WebSite","@id":"https://stub.example/#website","name":"Stub Site","url":"https://stub.example/","publisher":{"@id":"https://stub.example/#organization"}}]}"# +
|
||||
#"</script>"#
|
||||
))
|
||||
}
|
||||
#endif
|
||||
|
||||
#if Analytics
|
||||
@Test
|
||||
func `omits the analytics tracker by default`() {
|
||||
let html = StubPage().render()
|
||||
@@ -194,6 +199,7 @@ struct PageTests {
|
||||
#expect(!html.contains("data-do-not-track"))
|
||||
#expect(!html.contains("data-performance"))
|
||||
}
|
||||
#endif
|
||||
|
||||
@Test
|
||||
func `appends the version token to the asset URLs`() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#if Analytics
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
@@ -35,6 +36,21 @@ struct AnalyticsEventTests {
|
||||
))
|
||||
}
|
||||
|
||||
/// The tracker ignores `data-view-event`, so a view is never recorded as a click.
|
||||
@Test
|
||||
func `renders a view under its own prefix`() {
|
||||
let event = Analytics.Event(
|
||||
name: "case_view",
|
||||
properties: ["case": "klm"]
|
||||
)
|
||||
|
||||
#expect(event.attributes(on: .view) == [
|
||||
.init("data-view-event", value: "case_view"),
|
||||
.init("data-view-event-case", value: "klm"),
|
||||
])
|
||||
#expect(event.attributes == event.attributes(on: .click))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders the name first, then the properties in the order given`() {
|
||||
let event = Analytics.Event(
|
||||
@@ -103,3 +119,4 @@ struct AnalyticsEventTests {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -1,3 +1,4 @@
|
||||
#if Analytics
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
@@ -133,3 +134,4 @@ struct AnalyticsTests {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -40,6 +40,7 @@ struct SocialCardTests {
|
||||
.init("2400", name: .imageWidth),
|
||||
.init("1260", name: .imageHeight),
|
||||
.init("An image.", name: .imageAlt),
|
||||
.init("An image.", name: .twitterImageAlt),
|
||||
.init("summary_large_image", name: .twitter),
|
||||
])
|
||||
}
|
||||
@@ -70,6 +71,7 @@ struct SocialCardTests {
|
||||
|
||||
#expect(names.contains(.image))
|
||||
#expect(!names.contains(.imageAlt))
|
||||
#expect(!names.contains(.twitterImageAlt))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -87,6 +89,7 @@ struct SocialCardTests {
|
||||
@Test
|
||||
func `keys a tag by the attribute its name dictates`() {
|
||||
#expect(SocialCard.Tag.Name.twitter.attribute == .name)
|
||||
#expect(SocialCard.Tag.Name.twitterImageAlt.attribute == .name)
|
||||
#expect(SocialCard.Tag.Name.title.attribute == .property)
|
||||
#expect(SocialCard.Tag("website", name: .type).attribute == .property)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#if StructuredData
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@@ -14,46 +15,80 @@ struct StructuredDataTests {
|
||||
@Test
|
||||
func `derives the full payload from complete data`() {
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
logo: "https://site.example/logo.png",
|
||||
inLanguage: ["en", "nl"],
|
||||
profiles: [
|
||||
"https://social.example/a-site",
|
||||
"https://videos.example/a-site",
|
||||
]
|
||||
organization: .init(
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
logo: "https://site.example/logo.png",
|
||||
profiles: [
|
||||
"https://social.example/a-site",
|
||||
"https://videos.example/a-site",
|
||||
]
|
||||
),
|
||||
inLanguage: ["en", "nl"]
|
||||
)
|
||||
|
||||
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
|
||||
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/","logo":"https://site.example/logo.png","# +
|
||||
#""sameAs":["https://social.example/a-site","https://videos.example/a-site"]},"# +
|
||||
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"},"inLanguage":["en","nl"]}]}"#
|
||||
#"{"@type":"WebSite","@id":"https://site.example/#website","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"},"inLanguage":["en","nl"]}]}"#
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `carries the organization's alternate name, description, area, and address in that order`() {
|
||||
func `carries the organization's optional facts in order`() {
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
alternateName: "The Site",
|
||||
description: "What the site is.",
|
||||
areaServed: "A City",
|
||||
email: "hello@site.example"
|
||||
organization: .init(
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
alternateName: "The Site",
|
||||
legalName: "A Site B.V.",
|
||||
description: "What the site is.",
|
||||
areaServed: "A City",
|
||||
email: "hello@site.example",
|
||||
telephone: "+31000000000",
|
||||
address: .init(
|
||||
street: "A Street 1",
|
||||
postalCode: "1000 AA",
|
||||
locality: "A City",
|
||||
country: "NL"
|
||||
),
|
||||
vatID: "NL000000000B01",
|
||||
iso6523Code: "0106:00000000",
|
||||
foundingDate: "2000"
|
||||
)
|
||||
)
|
||||
|
||||
#expect(data.payload.contains(
|
||||
#""name":"A Site","url":"https://site.example/","alternateName":"The Site","description":"What the site is.","areaServed":"A City","email":"hello@site.example""#
|
||||
#""name":"A Site","url":"https://site.example/","alternateName":"The Site","legalName":"A Site B.V.","description":"What the site is.","# +
|
||||
#""areaServed":"A City","email":"hello@site.example","telephone":"+31000000000","# +
|
||||
#""address":{"@type":"PostalAddress","streetAddress":"A Street 1","postalCode":"1000 AA","addressLocality":"A City","addressCountry":"NL"},"# +
|
||||
#""vatID":"NL000000000B01","iso6523Code":"0106:00000000","foundingDate":"2000"}"#
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits the optional facts when the data carries none`() {
|
||||
let payload = StructuredData(
|
||||
organization: .init(
|
||||
name: "A Site",
|
||||
url: "https://site.example/"
|
||||
)
|
||||
).payload
|
||||
|
||||
for name in ["legalName", "telephone", "address", "vatID", "iso6523Code", "foundingDate"] {
|
||||
#expect(!payload.contains(name))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `references the organization's founder by identifier`() {
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
profiles: ["https://social.example/a-site"],
|
||||
founder: "https://site.example/who#person"
|
||||
organization: .init(
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
profiles: ["https://social.example/a-site"],
|
||||
founder: "https://site.example/who#person"
|
||||
)
|
||||
)
|
||||
|
||||
// Last of the organization's properties, after the profiles.
|
||||
@@ -65,8 +100,10 @@ struct StructuredDataTests {
|
||||
@Test
|
||||
func `omits the founder when the data carries none`() {
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: "https://site.example/"
|
||||
organization: .init(
|
||||
name: "A Site",
|
||||
url: "https://site.example/"
|
||||
)
|
||||
)
|
||||
|
||||
#expect(!data.payload.contains("founder"))
|
||||
@@ -76,8 +113,10 @@ struct StructuredDataTests {
|
||||
func `derives the organization's identifier from the site URL`() {
|
||||
let url = "https://site.example/"
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: url
|
||||
organization: .init(
|
||||
name: "A Site",
|
||||
url: url
|
||||
)
|
||||
)
|
||||
|
||||
// Another page references the organization by this, so it must name the node the initializer actually builds.
|
||||
@@ -85,16 +124,33 @@ struct StructuredDataTests {
|
||||
#expect(data.payload.contains(##""@id":"\##(StructuredData.organizationID(forSiteURL: url))""##))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `derives the website's identifier from the site URL`() {
|
||||
let url = "https://site.example/"
|
||||
let data = StructuredData(
|
||||
organization: .init(
|
||||
name: "A Site",
|
||||
url: url
|
||||
)
|
||||
)
|
||||
|
||||
// A page's own node references the website by this, so it must name the node the initializer actually builds.
|
||||
#expect(StructuredData.websiteID(forSiteURL: url) == "https://site.example/#website")
|
||||
#expect(data.payload.contains(##""@type":"WebSite","@id":"\##(StructuredData.websiteID(forSiteURL: url))""##))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits the properties of the facts minimal data does not carry`() {
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: "https://site.example/"
|
||||
organization: .init(
|
||||
name: "A Site",
|
||||
url: "https://site.example/"
|
||||
)
|
||||
)
|
||||
|
||||
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
|
||||
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/"},"# +
|
||||
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
|
||||
#"{"@type":"WebSite","@id":"https://site.example/#website","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,8 +181,10 @@ struct StructuredDataTests {
|
||||
@Test
|
||||
func `escapes the values it embeds in the payload`() {
|
||||
let data = StructuredData(
|
||||
name: #"A "Quoted" \ Site"#,
|
||||
url: "https://site.example/</script>"
|
||||
organization: .init(
|
||||
name: #"A "Quoted" \ Site"#,
|
||||
url: "https://site.example/</script>"
|
||||
)
|
||||
)
|
||||
|
||||
#expect(data.payload.contains(#""name":"A \"Quoted\" \\ Site""#))
|
||||
@@ -199,10 +257,12 @@ struct StructuredDataTests {
|
||||
func `derives a payload that parses back to the facts it carries`() throws {
|
||||
let name = "A \"Site\"\nwith \\ every <hazard>"
|
||||
let data = StructuredData(
|
||||
name: name,
|
||||
url: "https://site.example/</script>",
|
||||
logo: "https://site.example/logo.png",
|
||||
profiles: ["https://social.example/a-site"]
|
||||
organization: .init(
|
||||
name: name,
|
||||
url: "https://site.example/</script>",
|
||||
logo: "https://site.example/logo.png",
|
||||
profiles: ["https://social.example/a-site"]
|
||||
)
|
||||
)
|
||||
|
||||
let object = try JSONSerialization.jsonObject(with: Data(data.payload.utf8))
|
||||
@@ -215,3 +275,4 @@ struct StructuredDataTests {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -7,8 +7,10 @@ struct StubPage: Page {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
#if Analytics
|
||||
/// The analytics tracker rendered as a deferred script in the document head, or `nil` to omit it.
|
||||
let analytics: Analytics?
|
||||
var analytics: Analytics? = nil
|
||||
#endif
|
||||
|
||||
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||
let assetVersion: String?
|
||||
@@ -22,8 +24,10 @@ struct StubPage: Page {
|
||||
/// The card rendered as link-preview tags in the document head, or `nil` to omit them.
|
||||
let socialCard: SocialCard?
|
||||
|
||||
#if StructuredData
|
||||
/// The structured data rendered as a JSON-LD script in the document head, or `nil` to omit it.
|
||||
let structuredData: StructuredData?
|
||||
var structuredData: StructuredData? = nil
|
||||
#endif
|
||||
|
||||
/// The summary rendered in the document head, or `nil` to omit it.
|
||||
let summary: String?
|
||||
@@ -37,29 +41,21 @@ struct StubPage: Page {
|
||||
/// default) to leave them unversioned.
|
||||
/// - canonicalURL: the canonical URL rendered in the document head, or `nil` (the default)
|
||||
/// to omit it.
|
||||
/// - analytics: the analytics tracker rendered as a deferred script in the document head,
|
||||
/// or `nil` (the default) to omit it.
|
||||
/// - socialCard: the card rendered as link-preview tags in the document head, or `nil`
|
||||
/// (the default) to omit them.
|
||||
/// - structuredData: the structured data rendered as a JSON-LD script in the document
|
||||
/// head, or `nil` (the default) to omit it.
|
||||
/// - summary: the summary rendered in the document head, or `nil` (the default)
|
||||
/// to omit it.
|
||||
init(
|
||||
locale: Locale = .init(identifier: "en"),
|
||||
assetVersion: String? = nil,
|
||||
canonicalURL: String? = nil,
|
||||
analytics: Analytics? = nil,
|
||||
socialCard: SocialCard? = nil,
|
||||
structuredData: StructuredData? = nil,
|
||||
summary: String? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.canonicalURL = canonicalURL
|
||||
self.locale = locale
|
||||
self.socialCard = socialCard
|
||||
self.structuredData = structuredData
|
||||
self.summary = summary
|
||||
}
|
||||
|
||||
@@ -99,3 +95,37 @@ struct StubPage: Page {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if Analytics
|
||||
// MARK: - Analytics
|
||||
|
||||
extension StubPage {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a stub page embedding the given analytics tracker.
|
||||
/// - Parameter analytics: the analytics tracker rendered as a deferred script in the document head.
|
||||
init(analytics: Analytics) {
|
||||
self.init()
|
||||
self.analytics = analytics
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
|
||||
#if StructuredData
|
||||
// MARK: - Structured data
|
||||
|
||||
extension StubPage {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a stub page with the given structured data.
|
||||
/// - Parameter structuredData: the structured data to render.
|
||||
init(structuredData: StructuredData) {
|
||||
self.init()
|
||||
self.structuredData = structuredData
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version: 6.3
|
||||
// swift-tools-version: 6.4
|
||||
|
||||
import PackageDescription
|
||||
|
||||
@@ -35,3 +35,15 @@ let package = Package(
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
|
||||
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
|
||||
for target in package.targets {
|
||||
target.swiftSettings = (target.swiftSettings ?? []) + [
|
||||
.strictMemorySafety(),
|
||||
.treatAllWarnings(as: .error),
|
||||
.treatWarning("DeprecatedDeclaration", as: .warning),
|
||||
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
|
||||
.enableUpcomingFeature("InferIsolatedConformances"),
|
||||
]
|
||||
}
|
||||
@@ -38,6 +38,6 @@ Tests/
|
||||
Every suite carries a tag for the kind of API it exercises — `.method` or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
|
||||
|
||||
## Requirements
|
||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
|
||||
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
- No package dependencies — Foundation and Synchronization only.
|
||||
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version: 6.3
|
||||
// swift-tools-version: 6.4
|
||||
|
||||
import PackageDescription
|
||||
|
||||
@@ -97,3 +97,15 @@ let package = Package(
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
|
||||
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
|
||||
for target in package.targets {
|
||||
target.swiftSettings = (target.swiftSettings ?? []) + [
|
||||
.strictMemorySafety(),
|
||||
.treatAllWarnings(as: .error),
|
||||
.treatWarning("DeprecatedDeclaration", as: .warning),
|
||||
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
|
||||
.enableUpcomingFeature("InferIsolatedConformances"),
|
||||
]
|
||||
}
|
||||
@@ -8,13 +8,18 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da
|
||||
| Service | `Service`, which builds the `Fluent` service configured for the chosen driver |
|
||||
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
|
||||
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline |
|
||||
| Scaffolding | The internal `ExampleRecord` and `CreateExampleRecord`, and the public `Example` snapshot and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model |
|
||||
| Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot, and keeps each account's `Passkey`s — one is only ever registered together with a new account; `CreateUser` seeds the prototype's demo accounts |
|
||||
| Collecting | `CollectingRepository`, which keeps an account's `Want`s with the listings meeting each, its collection of owned issues, the `Series` catalogue, and the upcoming `Event`s with the shops trading at each |
|
||||
| Trade | `TradeRepository`, which reads and writes `Conversation`, `Offer`, and `Sale` snapshots and enforces who may do what, throwing a `TradeError` otherwise; `Sale` carries the five-step handshake's rules as pure functions |
|
||||
| Catalogue | `CatalogueRepository`, which opens a shop from a `ShopDraft`, publishes a seller's `ListingDraft` under a slug of its own, and returns `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data |
|
||||
|
||||
## Design rules
|
||||
- **The package reads no configuration.** The executable maps its `database.*` keys onto a `Driver` and hands it over; connection values arrive as plain data. The Website service's `ConfigReader+Properties` has the mapping.
|
||||
- **One default database.** `Service` registers the selected backend as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which driver is in use.
|
||||
- **Migrations are declared once, and append-only.** `PrepareDB` registers every migration in the order it must run; alter the schema by adding a migration, never by editing one that has already run. Registering does not apply them: the in-memory backend migrates on startup, while a shared PostgreSQL database is migrated out of band (the executable's migrate-and-exit mode), so booting instances never race.
|
||||
- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types, so they stay internal to the package and repositories return `Sendable` value-type snapshots (e.g. `Example`) instead.
|
||||
- **Migrations are declared once, and append-only.** `PrepareDB` registers every migration in the order it must run; alter the schema by adding a migration, never by editing one that has already run. Registering does not apply them: the in-memory backend is migrated at launch, while a shared PostgreSQL database is migrated out of band (the executable's migrate-and-exit mode), so booting instances never race.
|
||||
- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types, so they stay internal to the package and repositories return `Sendable` value-type snapshots (e.g. `Listing`) instead.
|
||||
- **Registration order is not creation order.** `AddTradeColumns`, `AddListingDeclarations`, and `AddShopRegistration` are registered ahead of `SeedCatalogue`, whose models write their columns; Fluent tracks migrations by name, so a database already past the seed still gains them.
|
||||
- **Prototype shortcuts.** `CatalogueRepository` filters in memory, the catalogue fields (series, issue, publisher) are folded into the listing, and the demo data ships as a migration. All three go once real data arrives.
|
||||
- **Readiness never throws, and never hangs.** `Probe` runs a schema-independent `SELECT 1`, maps every failure to `false`, and races the query against a deadline (2 seconds by default) — a hanging database yields a prompt "not ready" instead of a stalled endpoint.
|
||||
- **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop, so every query reaches the same in-memory database instead of each pooled connection getting a private one.
|
||||
- **Method structs.** `Service`, `PrepareDB`, and `Probe` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
|
||||
@@ -26,21 +31,21 @@ Sources are split by visibility, then by kind, one type per file:
|
||||
```
|
||||
Sources/
|
||||
├── Public/
|
||||
│ ├── Enumerations/ Driver, TLS
|
||||
│ ├── Enumerations/ Driver, TLS, ShopError, TradeError
|
||||
│ ├── Methods/ Service, PrepareDB, Probe
|
||||
│ ├── Models/ Shop, ShopDraft, Listing, ListingDraft, ListingFilter, User, Passkey, Want, Series, Event, Conversation, Offer, Sale
|
||||
│ ├── Repositories/ CatalogueRepository, AccountRepository, CollectingRepository, TradeRepository
|
||||
│ └── Types/ Configuration
|
||||
└── Internal/
|
||||
├── Migrations/ CreateExampleRecord
|
||||
├── Models/ ExampleRecord
|
||||
└── Repositories/ ExampleRepository (returning the Example snapshot)
|
||||
├── Migrations/ CreateShop, CreateListing, AddTradeColumns, AddListingDeclarations, AddShopRegistration, SeedCatalogue, CreateUser, CreateTrade, CreatePasskey, CreateCollecting, CreateCommunity
|
||||
├── Models/ ShopRecord, ListingRecord, UserRecord, PasskeyRecord, WantRecord, SeriesRecord, EventRecord, EventShopRecord, OwnedIssueRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord
|
||||
└── Seeds/ Seed
|
||||
Tests/
|
||||
├── Cases/ the test suites, mirroring the Sources/ layout
|
||||
└── Utils/ the NotSQL* fakes backing the probe's non-SQL-database case, the
|
||||
plaintext-only and silent fake PostgreSQL servers, and the suite Tag constants
|
||||
```
|
||||
|
||||
> **Exception:** `Example` and `ExampleRepository` are `public` despite sitting under `Internal/`, so they are API a service can call. Move them to `Public/Models` and `Public/Repositories` when the first real domain model replaces them.
|
||||
|
||||
## Testing
|
||||
The suite runs against the in-memory backend by default, so `swift test` needs no database. The PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at one (with optional `POSTGRES_TEST_PORT`, `POSTGRES_TEST_NAME`, `POSTGRES_TEST_USERNAME`, and `POSTGRES_TEST_PASSWORD`); it reverts its migrations afterwards, leaving a shared database as it was found:
|
||||
```sh
|
||||
@@ -53,6 +58,6 @@ Outside the application's service group, a built `Fluent` service must be shut d
|
||||
Every suite carries a tag for the kind of API it exercises — `.enumeration` or `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
|
||||
|
||||
## Requirements
|
||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
|
||||
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
- Package dependencies: `hummingbird-fluent`, `fluent-postgres-driver`, `fluent-sqlite-driver`, `sql-kit`, `postgres-nio`, and `swift-nio-ssl`; the test target additionally depends on `swift-nio` for the TLS fallback tests' fake server.
|
||||
@@ -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,30 @@
|
||||
import FluentKit
|
||||
|
||||
/// Adds the seller kind and the business registration to shops.
|
||||
///
|
||||
/// Registered ahead of ``SeedCatalogue``, whose model writes the columns. One column per update: SQLite adds no more in a statement.
|
||||
struct AddShopRegistration: AsyncMigration {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private static let columns = ["kind", "kvk_number", "vat_id", "legal_name"]
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func prepare(on database: Database) async throws {
|
||||
for column in Self.columns {
|
||||
try await database.schema(ShopRecord.schema)
|
||||
.field(.string(column), .string)
|
||||
.update()
|
||||
}
|
||||
}
|
||||
|
||||
func revert(on database: Database) async throws {
|
||||
for column in Self.columns.reversed() {
|
||||
try await database.schema(ShopRecord.schema)
|
||||
.deleteField(.string(column))
|
||||
.update()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import FluentKit
|
||||
|
||||
/// Adds an owner to shops and a sold date to listings.
|
||||
///
|
||||
/// Registered ahead of ``SeedCatalogue``, whose models write both columns. The owner carries no foreign key: on a fresh database the
|
||||
/// `users` table does not exist yet at this point.
|
||||
struct AddTradeColumns: AsyncMigration {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func prepare(on database: Database) async throws {
|
||||
try await database.schema(ShopRecord.schema)
|
||||
.field("owner_id", .uuid)
|
||||
.update()
|
||||
try await database.schema(ListingRecord.schema)
|
||||
.field("sold_at", .datetime)
|
||||
.update()
|
||||
}
|
||||
|
||||
func revert(on database: Database) async throws {
|
||||
try await database.schema(ListingRecord.schema)
|
||||
.deleteField("sold_at")
|
||||
.update()
|
||||
try await database.schema(ShopRecord.schema)
|
||||
.deleteField("owner_id")
|
||||
.update()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import FluentKit
|
||||
|
||||
/// Creates the `wants` table, and the `series` table seeded with the runs the demo listings belong to.
|
||||
struct CreateCollecting: AsyncMigration {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func prepare(on database: Database) async throws {
|
||||
try await database.schema(WantRecord.schema)
|
||||
.id()
|
||||
.field("user_id", .uuid, .required, .references(UserRecord.schema, "id", onDelete: .cascade))
|
||||
.field("title", .string, .required)
|
||||
.field("min_grade", .double)
|
||||
.field("max_price_cents", .int)
|
||||
.field("slabbed_only", .bool, .required)
|
||||
.field("alerts", .bool, .required)
|
||||
.field("created_at", .datetime, .required)
|
||||
.field("seen_at", .datetime, .required)
|
||||
.create()
|
||||
try await database.schema(SeriesRecord.schema)
|
||||
.id()
|
||||
.field("slug", .string, .required)
|
||||
.field("name", .string, .required)
|
||||
.field("publisher", .string, .required)
|
||||
.field("years", .string, .required)
|
||||
.field("issue_count", .int, .required)
|
||||
.field("blurb", .string, .required)
|
||||
.unique(on: "slug")
|
||||
.create()
|
||||
|
||||
for series in Seed.series {
|
||||
try await SeriesRecord(series).save(on: database)
|
||||
}
|
||||
}
|
||||
|
||||
func revert(on database: Database) async throws {
|
||||
try await database.schema(SeriesRecord.schema).delete()
|
||||
try await database.schema(WantRecord.schema).delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import FluentKit
|
||||
|
||||
/// Creates the `events`, `event_shops`, and `owned_issues` tables, seeded with the demo cons and the shops trading at them.
|
||||
struct CreateCommunity: AsyncMigration {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func prepare(on database: Database) async throws {
|
||||
try await database.schema(EventRecord.schema)
|
||||
.id()
|
||||
.field("slug", .string, .required)
|
||||
.field("name", .string, .required)
|
||||
.field("venue", .string, .required)
|
||||
.field("starts_on", .datetime, .required)
|
||||
.field("ends_on", .datetime, .required)
|
||||
.unique(on: "slug")
|
||||
.create()
|
||||
try await database.schema(EventShopRecord.schema)
|
||||
.id()
|
||||
.field("event_id", .uuid, .required, .references(EventRecord.schema, "id", onDelete: .cascade))
|
||||
.field("shop_id", .uuid, .required, .references(ShopRecord.schema, "id", onDelete: .cascade))
|
||||
.unique(on: "event_id", "shop_id")
|
||||
.create()
|
||||
try await database.schema(OwnedIssueRecord.schema)
|
||||
.id()
|
||||
.field("user_id", .uuid, .required, .references(UserRecord.schema, "id", onDelete: .cascade))
|
||||
.field("series_slug", .string, .required)
|
||||
.field("issue", .int, .required)
|
||||
.unique(on: "user_id", "series_slug", "issue")
|
||||
.create()
|
||||
|
||||
for (event, shops) in Seed.events(after: .init()) {
|
||||
let record = EventRecord(event)
|
||||
|
||||
try await record.save(on: database)
|
||||
|
||||
for shop in try await ShopRecord.query(on: database).filter(\.$slug ~~ shops).all() {
|
||||
try await record.$shops.attach(shop, on: database)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func revert(on database: Database) async throws {
|
||||
for schema in [OwnedIssueRecord.schema, EventShopRecord.schema, EventRecord.schema] {
|
||||
try await database.schema(schema).delete()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import FluentKit
|
||||
|
||||
/// Creates and drops the `example_records` table backing ``ExampleRecord``.
|
||||
///
|
||||
/// Reference scaffolding paired with ``ExampleRecord``; replace it with the first real migration once a
|
||||
/// domain model is defined. Migrations are append-only in production — add a new migration to alter the
|
||||
/// schema rather than editing one that has already run.
|
||||
struct CreateExampleRecord: AsyncMigration {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Creates the `example_records` table with an `id` primary key and a required `name` column.
|
||||
/// - Parameter database: the database the schema change is applied to.
|
||||
func prepare(on database: Database) async throws {
|
||||
try await database.schema(ExampleRecord.schema)
|
||||
.id()
|
||||
.field("name", .string, .required)
|
||||
.create()
|
||||
}
|
||||
|
||||
/// Drops the `example_records` table, reverting ``prepare(on:)``.
|
||||
/// - Parameter database: the database the schema change is applied to.
|
||||
func revert(on database: Database) async throws {
|
||||
try await database.schema(ExampleRecord.schema)
|
||||
.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import FluentKit
|
||||
|
||||
/// Creates and drops the `listings` table backing ``ListingRecord``.
|
||||
struct CreateListing: AsyncMigration {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func prepare(on database: Database) async throws {
|
||||
try await database.schema(ListingRecord.schema)
|
||||
.id()
|
||||
.field("shop_id", .uuid, .required, .references(ShopRecord.schema, "id", onDelete: .cascade))
|
||||
.field("slug", .string, .required)
|
||||
.field("title", .string, .required)
|
||||
.field("series_title", .string, .required)
|
||||
.field("issue_label", .string, .required)
|
||||
.field("publisher", .string, .required)
|
||||
.field("year", .int, .required)
|
||||
.field("format", .string, .required)
|
||||
.field("printing", .string, .required)
|
||||
.field("printing_detail", .string, .required)
|
||||
.field("is_first_print", .bool, .required)
|
||||
.field("grader", .string)
|
||||
.field("grade", .double)
|
||||
.field("condition", .string, .required)
|
||||
.field("cert_number", .string)
|
||||
.field("page_quality", .string)
|
||||
.field("key_notes", .string)
|
||||
.field("seller_notes", .string, .required)
|
||||
.field("price_cents", .int, .required)
|
||||
.field("shipping_cents", .int, .required)
|
||||
.field("accepts_offers", .bool, .required)
|
||||
.field("cover_tone", .string, .required)
|
||||
.field("cover_title", .string, .required)
|
||||
.field("cover_imprint", .string, .required)
|
||||
.field("cover_number", .string, .required)
|
||||
.field("listed_at", .datetime, .required)
|
||||
.unique(on: "slug")
|
||||
.create()
|
||||
}
|
||||
|
||||
func revert(on database: Database) async throws {
|
||||
try await database.schema(ListingRecord.schema)
|
||||
.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import FluentKit
|
||||
|
||||
/// Creates and drops the `shops` table backing ``ShopRecord``.
|
||||
struct CreateShop: AsyncMigration {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func prepare(on database: Database) async throws {
|
||||
try await database.schema(ShopRecord.schema)
|
||||
.id()
|
||||
.field("slug", .string, .required)
|
||||
.field("name", .string, .required)
|
||||
.field("city", .string, .required)
|
||||
.field("country", .string, .required)
|
||||
.field("since_year", .int, .required)
|
||||
.field("is_verified", .bool, .required)
|
||||
.field("rating", .double, .required)
|
||||
.field("rated_sales", .int, .required)
|
||||
.field("sold_count", .int, .required)
|
||||
.field("handling_days", .int, .required)
|
||||
.field("ships_note", .string, .required)
|
||||
.field("returns_note", .string, .required)
|
||||
.field("about", .string, .required)
|
||||
.unique(on: "slug")
|
||||
.create()
|
||||
}
|
||||
|
||||
func revert(on database: Database) async throws {
|
||||
try await database.schema(ShopRecord.schema)
|
||||
.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import FluentKit
|
||||
|
||||
/// Creates the conversations, offers, sales, and messages tables, and hands the demo shops to seller accounts, created here when missing.
|
||||
struct CreateTrade: AsyncMigration {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func prepare(on database: Database) async throws {
|
||||
try await database.schema(ConversationRecord.schema)
|
||||
.id()
|
||||
.field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id"))
|
||||
.field("shop_id", .uuid, .required, .references(ShopRecord.schema, "id"))
|
||||
.field("listing_id", .uuid, .references(ListingRecord.schema, "id"))
|
||||
.field("created_at", .datetime, .required)
|
||||
.field("buyer_read_at", .datetime, .required)
|
||||
.field("seller_read_at", .datetime, .required)
|
||||
.create()
|
||||
try await database.schema(OfferRecord.schema)
|
||||
.id()
|
||||
.field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id"))
|
||||
.field("listing_id", .uuid, .required, .references(ListingRecord.schema, "id"))
|
||||
.field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id"))
|
||||
.field("amount_cents", .int, .required)
|
||||
.field("status", .string, .required)
|
||||
.field("created_at", .datetime, .required)
|
||||
.field("expires_at", .datetime, .required)
|
||||
.create()
|
||||
try await database.schema(SaleRecord.schema)
|
||||
.id()
|
||||
.field("number", .int, .required)
|
||||
.field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id"))
|
||||
.field("listing_id", .uuid, .required, .references(ListingRecord.schema, "id"))
|
||||
.field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id"))
|
||||
.field("seller_id", .uuid, .required, .references(UserRecord.schema, "id"))
|
||||
.field("price_cents", .int, .required)
|
||||
.field("asked_cents", .int, .required)
|
||||
.field("shipping_cents", .int, .required)
|
||||
.field("declared_grade", .string, .required)
|
||||
.field("declared_page_quality", .string)
|
||||
.field("declared_printing", .string, .required)
|
||||
.field("declared_notes", .string, .required)
|
||||
.field("agreed_at", .datetime, .required)
|
||||
.field("paid_at", .datetime)
|
||||
.field("payment_note", .string)
|
||||
.field("sent_at", .datetime)
|
||||
.field("shipping_note", .string)
|
||||
.field("received_at", .datetime)
|
||||
.field("buyer_rating", .int)
|
||||
.field("seller_rating", .int)
|
||||
.unique(on: "number")
|
||||
.create()
|
||||
try await database.schema(MessageRecord.schema)
|
||||
.id()
|
||||
.field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id"))
|
||||
.field("sender_id", .uuid, .required, .references(UserRecord.schema, "id"))
|
||||
.field("kind", .string, .required)
|
||||
.field("body", .string, .required)
|
||||
.field("offer_id", .uuid, .references(OfferRecord.schema, "id"))
|
||||
.field("sale_number", .int)
|
||||
.field("created_at", .datetime, .required)
|
||||
.create()
|
||||
|
||||
for (slug, seller) in Seed.owners {
|
||||
let owner = try await UserRecord.query(on: database)
|
||||
.filter(\.$email == seller.email)
|
||||
.first() ?? UserRecord(email: seller.email, displayName: seller.displayName)
|
||||
|
||||
try await owner.save(on: database)
|
||||
try await ShopRecord.query(on: database)
|
||||
.filter(\.$slug == slug)
|
||||
.set(\.$owner.$id, to: try owner.requireID())
|
||||
.update()
|
||||
}
|
||||
}
|
||||
|
||||
func revert(on database: Database) async throws {
|
||||
for schema in [MessageRecord.schema, SaleRecord.schema, OfferRecord.schema, ConversationRecord.schema] {
|
||||
try await database.schema(schema).delete()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import FluentKit
|
||||
|
||||
/// Creates and drops the `users` table backing ``UserRecord``, seeded with the prototype's demo accounts.
|
||||
struct CreateUser: AsyncMigration {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func prepare(on database: Database) async throws {
|
||||
try await database.schema(UserRecord.schema)
|
||||
.id()
|
||||
.field("email", .string, .required)
|
||||
.field("display_name", .string, .required)
|
||||
.unique(on: "email")
|
||||
.create()
|
||||
|
||||
for account in Seed.accounts {
|
||||
try await UserRecord(email: account.email, displayName: account.displayName).save(on: database)
|
||||
}
|
||||
}
|
||||
|
||||
func revert(on database: Database) async throws {
|
||||
try await database.schema(UserRecord.schema)
|
||||
.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// Re-dates the demo cons from the first weekend after it runs, for databases seeded when ``CreateCommunity`` used fixed dates.
|
||||
struct RedateEvents: AsyncMigration {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func prepare(on database: Database) async throws {
|
||||
for (event, _) in Seed.events(after: .init()) {
|
||||
try await EventRecord.query(on: database)
|
||||
.filter(\.$slug == event.slug)
|
||||
.set(\.$startsOn, to: event.startsOn)
|
||||
.set(\.$endsOn, to: event.endsOn)
|
||||
.update()
|
||||
}
|
||||
}
|
||||
|
||||
func revert(on database: Database) async throws {}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import FluentKit
|
||||
|
||||
/// Fills the catalogue with the prototype's demo shops and listings.
|
||||
struct SeedCatalogue: AsyncMigration {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func prepare(on database: Database) async throws {
|
||||
for shop in Seed.shops {
|
||||
let record = ShopRecord(shop)
|
||||
|
||||
try await record.save(on: database)
|
||||
|
||||
let shopID = try record.requireID()
|
||||
|
||||
for listing in Seed.listings where listing.shop.slug == shop.slug {
|
||||
try await ListingRecord(listing, shopID: shopID).save(on: database)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func revert(on database: Database) async throws {
|
||||
try await ListingRecord.query(on: database).delete()
|
||||
try await ShopRecord.query(on: database).delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// A FluentKit model of a single `events` row: a con where shops trade in person.
|
||||
final class EventRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "events"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Field(key: "slug")
|
||||
var slug: String
|
||||
|
||||
@Field(key: "name")
|
||||
var name: String
|
||||
|
||||
@Field(key: "venue")
|
||||
var venue: String
|
||||
|
||||
@Field(key: "starts_on")
|
||||
var startsOn: Date
|
||||
|
||||
@Field(key: "ends_on")
|
||||
var endsOn: Date
|
||||
|
||||
@Siblings(through: EventShopRecord.self, from: \.$event, to: \.$shop)
|
||||
var shops: [ShopRecord]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init() {}
|
||||
|
||||
init(
|
||||
_ event: Event
|
||||
) {
|
||||
self.slug = event.slug
|
||||
self.name = event.name
|
||||
self.venue = event.venue
|
||||
self.startsOn = event.startsOn
|
||||
self.endsOn = event.endsOn
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// A FluentKit model of a single `event_shops` row: a shop trading at an event.
|
||||
final class EventShopRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "event_shops"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Parent(key: "event_id")
|
||||
var event: EventRecord
|
||||
|
||||
@Parent(key: "shop_id")
|
||||
var shop: ShopRecord
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init() {}
|
||||
|
||||
}
|
||||
|
||||
/// A FluentKit model of a single `owned_issues` row: an issue of a series in an account's collection.
|
||||
final class OwnedIssueRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "owned_issues"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Parent(key: "user_id")
|
||||
var user: UserRecord
|
||||
|
||||
@Field(key: "series_slug")
|
||||
var seriesSlug: String
|
||||
|
||||
@Field(key: "issue")
|
||||
var issue: Int
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init() {}
|
||||
|
||||
init(
|
||||
userID: UUID,
|
||||
seriesSlug: String,
|
||||
issue: Int
|
||||
) {
|
||||
self.$user.id = userID
|
||||
self.seriesSlug = seriesSlug
|
||||
self.issue = issue
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// A FluentKit model of a single `conversations` row: a buyer and a shop, talking about a listing or the shop at large.
|
||||
final class ConversationRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "conversations"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Parent(key: "buyer_id")
|
||||
var buyer: UserRecord
|
||||
|
||||
@Parent(key: "shop_id")
|
||||
var shop: ShopRecord
|
||||
|
||||
@OptionalParent(key: "listing_id")
|
||||
var listing: ListingRecord?
|
||||
|
||||
@Field(key: "created_at")
|
||||
var createdAt: Date
|
||||
|
||||
/// When the buyer last opened the conversation; what came later is unread to them.
|
||||
@Field(key: "buyer_read_at")
|
||||
var buyerReadAt: Date
|
||||
|
||||
/// When the seller last opened the conversation; what came later is unread to them.
|
||||
@Field(key: "seller_read_at")
|
||||
var sellerReadAt: Date
|
||||
|
||||
@Children(for: \.$conversation)
|
||||
var messages: [MessageRecord]
|
||||
|
||||
@Children(for: \.$conversation)
|
||||
var offers: [OfferRecord]
|
||||
|
||||
@Children(for: \.$conversation)
|
||||
var sales: [SaleRecord]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init() {}
|
||||
|
||||
init(
|
||||
buyerID: UUID,
|
||||
shopID: UUID,
|
||||
listingID: UUID?,
|
||||
at date: Date
|
||||
) {
|
||||
self.$buyer.id = buyerID
|
||||
self.$shop.id = shopID
|
||||
self.$listing.id = listingID
|
||||
self.createdAt = date
|
||||
self.buyerReadAt = date
|
||||
self.sellerReadAt = date
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// A FluentKit model of a single `example_records` row.
|
||||
///
|
||||
/// This is reference scaffolding: it demonstrates the model → migration → repository pattern the rest of
|
||||
/// the package is built around, and is what the tests exercise. Replace it with the first real domain model
|
||||
/// (paired with its own migration and repository) once one is defined.
|
||||
///
|
||||
/// FluentKit models are mutable reference types whose property wrappers are not `Sendable`; the model never
|
||||
/// crosses a concurrency boundary (repositories map it to a `Sendable` snapshot before returning), so the
|
||||
/// conformance is declared `@unchecked Sendable`.
|
||||
final class ExampleRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The name of the backing table.
|
||||
static let schema = "example_records"
|
||||
|
||||
/// The row's primary key, assigned on first save.
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
/// The row's name column.
|
||||
@Field(key: "name")
|
||||
var name: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an empty record, as required by FluentKit to hydrate query results.
|
||||
init() {}
|
||||
|
||||
/// Creates a record with the given values.
|
||||
/// - Parameters:
|
||||
/// - id: the primary key, or `nil` to have one assigned on save.
|
||||
/// - name: the value of the name column.
|
||||
init(
|
||||
id: UUID? = nil,
|
||||
name: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// A FluentKit model of a single `listings` row.
|
||||
///
|
||||
/// The catalogue fields (series, issue, publisher) are folded into the listing; they move to tables of their own once the series screen needs them.
|
||||
final class ListingRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "listings"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Parent(key: "shop_id")
|
||||
var shop: ShopRecord
|
||||
|
||||
@Field(key: "slug")
|
||||
var slug: String
|
||||
|
||||
@Field(key: "title")
|
||||
var title: String
|
||||
|
||||
@Field(key: "series_title")
|
||||
var seriesTitle: String
|
||||
|
||||
@Field(key: "issue_label")
|
||||
var issueLabel: String
|
||||
|
||||
@Field(key: "publisher")
|
||||
var publisher: String
|
||||
|
||||
@Field(key: "year")
|
||||
var year: Int
|
||||
|
||||
@Field(key: "format")
|
||||
var format: String
|
||||
|
||||
@Field(key: "printing")
|
||||
var printing: String
|
||||
|
||||
@Field(key: "printing_detail")
|
||||
var printingDetail: String
|
||||
|
||||
@Field(key: "is_first_print")
|
||||
var isFirstPrint: Bool
|
||||
|
||||
@OptionalField(key: "grader")
|
||||
var grader: String?
|
||||
|
||||
@OptionalField(key: "grade")
|
||||
var grade: Double?
|
||||
|
||||
@Field(key: "condition")
|
||||
var condition: String
|
||||
|
||||
@OptionalField(key: "cert_number")
|
||||
var certNumber: String?
|
||||
|
||||
@OptionalField(key: "page_quality")
|
||||
var pageQuality: String?
|
||||
|
||||
@OptionalField(key: "key_notes")
|
||||
var keyNotes: String?
|
||||
|
||||
@Field(key: "seller_notes")
|
||||
var sellerNotes: String
|
||||
|
||||
@Field(key: "price_cents")
|
||||
var priceCents: Int
|
||||
|
||||
@Field(key: "shipping_cents")
|
||||
var shippingCents: Int
|
||||
|
||||
@Field(key: "accepts_offers")
|
||||
var acceptsOffers: Bool
|
||||
|
||||
@Field(key: "cover_tone")
|
||||
var coverTone: String
|
||||
|
||||
@Field(key: "cover_title")
|
||||
var coverTitle: String
|
||||
|
||||
@Field(key: "cover_imprint")
|
||||
var coverImprint: String
|
||||
|
||||
@Field(key: "cover_number")
|
||||
var coverNumber: String
|
||||
|
||||
@Field(key: "listed_at")
|
||||
var listedAt: Date
|
||||
|
||||
@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() {}
|
||||
|
||||
/// Creates a record from a snapshot.
|
||||
/// - Parameters:
|
||||
/// - listing: the snapshot the record takes its values from.
|
||||
/// - shopID: the identifier of the shop the listing belongs to.
|
||||
init(
|
||||
_ listing: Listing,
|
||||
shopID: UUID
|
||||
) {
|
||||
self.$shop.id = shopID
|
||||
self.slug = listing.slug
|
||||
self.title = listing.title
|
||||
self.seriesTitle = listing.seriesTitle
|
||||
self.issueLabel = listing.issueLabel
|
||||
self.publisher = listing.publisher
|
||||
self.year = listing.year
|
||||
self.format = listing.format
|
||||
self.printing = listing.printing
|
||||
self.printingDetail = listing.printingDetail
|
||||
self.isFirstPrint = listing.isFirstPrint
|
||||
self.grader = listing.grader
|
||||
self.grade = listing.grade
|
||||
self.condition = listing.condition
|
||||
self.certNumber = listing.certNumber
|
||||
self.pageQuality = listing.pageQuality
|
||||
self.keyNotes = listing.keyNotes
|
||||
self.sellerNotes = listing.sellerNotes
|
||||
self.priceCents = listing.priceCents
|
||||
self.shippingCents = listing.shippingCents
|
||||
self.acceptsOffers = listing.acceptsOffers
|
||||
self.coverTone = listing.cover.tone
|
||||
self.coverTitle = listing.cover.title
|
||||
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")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension ListingRecord {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The `Sendable` snapshot of the record; its shop must be eager-loaded.
|
||||
var snapshot: Listing {
|
||||
.init(
|
||||
slug: slug,
|
||||
shop: shop.snapshot,
|
||||
title: title,
|
||||
seriesTitle: seriesTitle,
|
||||
issueLabel: issueLabel,
|
||||
publisher: publisher,
|
||||
year: year,
|
||||
format: format,
|
||||
printing: printing,
|
||||
printingDetail: printingDetail,
|
||||
isFirstPrint: isFirstPrint,
|
||||
grader: grader,
|
||||
grade: grade,
|
||||
condition: condition,
|
||||
certNumber: certNumber,
|
||||
pageQuality: pageQuality,
|
||||
keyNotes: keyNotes,
|
||||
sellerNotes: sellerNotes,
|
||||
priceCents: priceCents,
|
||||
shippingCents: shippingCents,
|
||||
acceptsOffers: acceptsOffers,
|
||||
cover: .init(
|
||||
tone: coverTone,
|
||||
title: coverTitle,
|
||||
imprint: coverImprint,
|
||||
number: coverNumber
|
||||
),
|
||||
listedAt: listedAt,
|
||||
soldAt: soldAt,
|
||||
defects: defects?.split(separator: "\n").map(String.init) ?? [],
|
||||
photos: photos?.split(separator: "\n").map(String.init) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// A FluentKit model of a single `messages` row: something said, an offer made, or an event stamped into a conversation.
|
||||
final class MessageRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "messages"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Parent(key: "conversation_id")
|
||||
var conversation: ConversationRecord
|
||||
|
||||
/// Who wrote the message, made the offer, or caused the event.
|
||||
@Parent(key: "sender_id")
|
||||
var sender: UserRecord
|
||||
|
||||
@Field(key: "kind")
|
||||
var kind: String
|
||||
|
||||
@Field(key: "body")
|
||||
var body: String
|
||||
|
||||
@OptionalParent(key: "offer_id")
|
||||
var offer: OfferRecord?
|
||||
|
||||
@OptionalField(key: "sale_number")
|
||||
var saleNumber: Int?
|
||||
|
||||
@Field(key: "created_at")
|
||||
var createdAt: Date
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init() {}
|
||||
|
||||
init(
|
||||
conversationID: UUID,
|
||||
senderID: UUID,
|
||||
kind: Conversation.Entry.Kind,
|
||||
body: String,
|
||||
offerID: UUID? = nil,
|
||||
saleNumber: Int? = nil,
|
||||
at date: Date
|
||||
) {
|
||||
self.$conversation.id = conversationID
|
||||
self.$sender.id = senderID
|
||||
self.kind = kind.rawValue
|
||||
self.body = body
|
||||
self.$offer.id = offerID
|
||||
self.saleNumber = saleNumber
|
||||
self.createdAt = date
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// A FluentKit model of a single `offers` row.
|
||||
final class OfferRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "offers"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Parent(key: "conversation_id")
|
||||
var conversation: ConversationRecord
|
||||
|
||||
@Parent(key: "listing_id")
|
||||
var listing: ListingRecord
|
||||
|
||||
@Parent(key: "buyer_id")
|
||||
var buyer: UserRecord
|
||||
|
||||
@Field(key: "amount_cents")
|
||||
var amountCents: Int
|
||||
|
||||
@Field(key: "status")
|
||||
var status: String
|
||||
|
||||
@Field(key: "created_at")
|
||||
var createdAt: Date
|
||||
|
||||
@Field(key: "expires_at")
|
||||
var expiresAt: Date
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init() {}
|
||||
|
||||
init(
|
||||
conversationID: UUID,
|
||||
listingID: UUID,
|
||||
buyerID: UUID,
|
||||
amountCents: Int,
|
||||
at date: Date
|
||||
) {
|
||||
self.$conversation.id = conversationID
|
||||
self.$listing.id = listingID
|
||||
self.$buyer.id = buyerID
|
||||
self.amountCents = amountCents
|
||||
self.status = Offer.Status.open.rawValue
|
||||
self.createdAt = date
|
||||
self.expiresAt = date.addingTimeInterval(Offer.lifetime)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension OfferRecord {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The `Sendable` snapshot of the saved record; its buyer, and its listing with its shop, must be eager-loaded.
|
||||
/// - Parameter now: the moment an open offer's expiry is judged at.
|
||||
func snapshot(
|
||||
at now: Date
|
||||
) throws -> Offer {
|
||||
let stored = Offer.Status(rawValue: status) ?? .declined
|
||||
|
||||
return .init(
|
||||
id: try requireID(),
|
||||
conversationID: $conversation.id,
|
||||
listing: listing.snapshot,
|
||||
buyer: try buyer.snapshot,
|
||||
amountCents: amountCents,
|
||||
status: stored == .open && expiresAt <= now ? .expired : stored,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// A FluentKit model of a single `sales` row: the record of an agreement and of its handshake.
|
||||
final class SaleRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "sales"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Field(key: "number")
|
||||
var number: Int
|
||||
|
||||
@Parent(key: "conversation_id")
|
||||
var conversation: ConversationRecord
|
||||
|
||||
@Parent(key: "listing_id")
|
||||
var listing: ListingRecord
|
||||
|
||||
@Parent(key: "buyer_id")
|
||||
var buyer: UserRecord
|
||||
|
||||
@Parent(key: "seller_id")
|
||||
var seller: UserRecord
|
||||
|
||||
@Field(key: "price_cents")
|
||||
var priceCents: Int
|
||||
|
||||
@Field(key: "asked_cents")
|
||||
var askedCents: Int
|
||||
|
||||
@Field(key: "shipping_cents")
|
||||
var shippingCents: Int
|
||||
|
||||
@Field(key: "declared_grade")
|
||||
var declaredGrade: String
|
||||
|
||||
@OptionalField(key: "declared_page_quality")
|
||||
var declaredPageQuality: String?
|
||||
|
||||
@Field(key: "declared_printing")
|
||||
var declaredPrinting: String
|
||||
|
||||
@Field(key: "declared_notes")
|
||||
var declaredNotes: String
|
||||
|
||||
@Field(key: "agreed_at")
|
||||
var agreedAt: Date
|
||||
|
||||
@OptionalField(key: "paid_at")
|
||||
var paidAt: Date?
|
||||
|
||||
@OptionalField(key: "payment_note")
|
||||
var paymentNote: String?
|
||||
|
||||
@OptionalField(key: "sent_at")
|
||||
var sentAt: Date?
|
||||
|
||||
@OptionalField(key: "shipping_note")
|
||||
var shippingNote: String?
|
||||
|
||||
@OptionalField(key: "received_at")
|
||||
var receivedAt: Date?
|
||||
|
||||
@OptionalField(key: "buyer_rating")
|
||||
var buyerRating: Int?
|
||||
|
||||
@OptionalField(key: "seller_rating")
|
||||
var sellerRating: Int?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init() {}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension SaleRecord {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The `Sendable` snapshot of the saved record; its buyer, its seller, and its listing with its shop must be eager-loaded.
|
||||
var snapshot: Sale {
|
||||
get throws {
|
||||
.init(
|
||||
number: number,
|
||||
conversationID: $conversation.id,
|
||||
listing: listing.snapshot,
|
||||
buyer: try buyer.snapshot,
|
||||
seller: try seller.snapshot,
|
||||
priceCents: priceCents,
|
||||
askedCents: askedCents,
|
||||
shippingCents: shippingCents,
|
||||
declared: .init(
|
||||
grade: declaredGrade,
|
||||
pageQuality: declaredPageQuality,
|
||||
printing: declaredPrinting,
|
||||
notes: declaredNotes
|
||||
),
|
||||
agreedAt: agreedAt,
|
||||
paidAt: paidAt,
|
||||
paymentNote: paymentNote,
|
||||
sentAt: sentAt,
|
||||
shippingNote: shippingNote,
|
||||
receivedAt: receivedAt,
|
||||
buyerRating: buyerRating,
|
||||
sellerRating: sellerRating
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// A FluentKit model of a single `series` row: a run of issues in the catalogue.
|
||||
final class SeriesRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "series"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Field(key: "slug")
|
||||
var slug: String
|
||||
|
||||
@Field(key: "name")
|
||||
var name: String
|
||||
|
||||
@Field(key: "publisher")
|
||||
var publisher: String
|
||||
|
||||
@Field(key: "years")
|
||||
var years: String
|
||||
|
||||
@Field(key: "issue_count")
|
||||
var issueCount: Int
|
||||
|
||||
@Field(key: "blurb")
|
||||
var blurb: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init() {}
|
||||
|
||||
init(
|
||||
_ series: Series
|
||||
) {
|
||||
self.slug = series.slug
|
||||
self.name = series.name
|
||||
self.publisher = series.publisher
|
||||
self.years = series.years
|
||||
self.issueCount = series.issueCount
|
||||
self.blurb = series.blurb
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension SeriesRecord {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The `Sendable` snapshot of the record.
|
||||
var snapshot: Series {
|
||||
.init(
|
||||
slug: slug,
|
||||
name: name,
|
||||
publisher: publisher,
|
||||
years: years,
|
||||
issueCount: issueCount,
|
||||
blurb: blurb
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// A FluentKit model of a single `shops` row.
|
||||
final class ShopRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "shops"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Field(key: "slug")
|
||||
var slug: String
|
||||
|
||||
@Field(key: "name")
|
||||
var name: String
|
||||
|
||||
@Field(key: "city")
|
||||
var city: String
|
||||
|
||||
@Field(key: "country")
|
||||
var country: String
|
||||
|
||||
@Field(key: "since_year")
|
||||
var sinceYear: Int
|
||||
|
||||
@Field(key: "is_verified")
|
||||
var isVerified: Bool
|
||||
|
||||
@Field(key: "rating")
|
||||
var rating: Double
|
||||
|
||||
@Field(key: "rated_sales")
|
||||
var ratedSales: Int
|
||||
|
||||
@Field(key: "sold_count")
|
||||
var soldCount: Int
|
||||
|
||||
@Field(key: "handling_days")
|
||||
var handlingDays: Int
|
||||
|
||||
@Field(key: "ships_note")
|
||||
var shipsNote: String
|
||||
|
||||
@Field(key: "returns_note")
|
||||
var returnsNote: String
|
||||
|
||||
@Field(key: "about")
|
||||
var about: String
|
||||
|
||||
@OptionalParent(key: "owner_id")
|
||||
var owner: UserRecord?
|
||||
|
||||
/// The seller kind token; `nil` reads as a business, which every seeded shop is.
|
||||
@OptionalField(key: "kind")
|
||||
var kind: String?
|
||||
|
||||
@OptionalField(key: "kvk_number")
|
||||
var kvkNumber: String?
|
||||
|
||||
@OptionalField(key: "vat_id")
|
||||
var vatID: String?
|
||||
|
||||
@OptionalField(key: "legal_name")
|
||||
var legalName: String?
|
||||
|
||||
@Children(for: \.$shop)
|
||||
var listings: [ListingRecord]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init() {}
|
||||
|
||||
/// Creates a record from a snapshot.
|
||||
/// - Parameter shop: the snapshot the record takes its values from.
|
||||
init(
|
||||
_ shop: Shop
|
||||
) {
|
||||
self.slug = shop.slug
|
||||
self.name = shop.name
|
||||
self.city = shop.city
|
||||
self.country = shop.country
|
||||
self.sinceYear = shop.sinceYear
|
||||
self.isVerified = shop.isVerified
|
||||
self.rating = shop.rating
|
||||
self.ratedSales = shop.ratedSales
|
||||
self.soldCount = shop.soldCount
|
||||
self.handlingDays = shop.handlingDays
|
||||
self.shipsNote = shop.shipsNote
|
||||
self.returnsNote = shop.returnsNote
|
||||
self.about = shop.about
|
||||
self.kind = shop.kind.rawValue
|
||||
self.kvkNumber = shop.kvkNumber
|
||||
self.vatID = shop.vatID
|
||||
self.legalName = shop.legalName
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension ShopRecord {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The `Sendable` snapshot of the record.
|
||||
var snapshot: Shop {
|
||||
.init(
|
||||
slug: slug,
|
||||
name: name,
|
||||
city: city,
|
||||
country: country,
|
||||
sinceYear: sinceYear,
|
||||
isVerified: isVerified,
|
||||
rating: rating,
|
||||
ratedSales: ratedSales,
|
||||
soldCount: soldCount,
|
||||
handlingDays: handlingDays,
|
||||
shipsNote: shipsNote,
|
||||
returnsNote: returnsNote,
|
||||
about: about,
|
||||
kind: kind.flatMap(Shop.Kind.init) ?? .business,
|
||||
kvkNumber: kvkNumber,
|
||||
vatID: vatID,
|
||||
legalName: legalName
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// A FluentKit model of a single `users` row.
|
||||
final class UserRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "users"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Field(key: "email")
|
||||
var email: String
|
||||
|
||||
@Field(key: "display_name")
|
||||
var displayName: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init() {}
|
||||
|
||||
/// Creates a record with the given values.
|
||||
/// - Parameters:
|
||||
/// - email: the account's normalized email address.
|
||||
/// - displayName: the name other people see.
|
||||
init(
|
||||
email: String,
|
||||
displayName: String
|
||||
) {
|
||||
self.email = email
|
||||
self.displayName = displayName
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension UserRecord {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The `Sendable` snapshot of the saved record.
|
||||
var snapshot: User {
|
||||
get throws {
|
||||
.init(
|
||||
id: try requireID(),
|
||||
email: email,
|
||||
displayName: displayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
|
||||
/// A FluentKit model of a single `wants` row: a copy an account is looking for.
|
||||
final class WantRecord: Model, @unchecked Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let schema = "wants"
|
||||
|
||||
@ID(key: .id)
|
||||
var id: UUID?
|
||||
|
||||
@Parent(key: "user_id")
|
||||
var user: UserRecord
|
||||
|
||||
@Field(key: "title")
|
||||
var title: String
|
||||
|
||||
@OptionalField(key: "min_grade")
|
||||
var minGrade: Double?
|
||||
|
||||
@OptionalField(key: "max_price_cents")
|
||||
var maxPriceCents: Int?
|
||||
|
||||
@Field(key: "slabbed_only")
|
||||
var slabbedOnly: Bool
|
||||
|
||||
@Field(key: "alerts")
|
||||
var alerts: Bool
|
||||
|
||||
@Field(key: "created_at")
|
||||
var createdAt: Date
|
||||
|
||||
/// When the account last looked at the want; matches listed later are new to them.
|
||||
@Field(key: "seen_at")
|
||||
var seenAt: Date
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init() {}
|
||||
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
import HummingbirdFluent
|
||||
|
||||
/// A `Sendable` snapshot of an ``ExampleRecord``, safe to return across concurrency boundaries.
|
||||
///
|
||||
/// Repositories return these value-type snapshots rather than FluentKit models, which are mutable reference
|
||||
/// types that must not escape the database's execution context.
|
||||
public struct Example: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The record's primary key, or `nil` if it has never been saved.
|
||||
public let id: UUID?
|
||||
/// The record's name.
|
||||
public let name: String
|
||||
|
||||
}
|
||||
|
||||
/// Reads and writes ``ExampleRecord`` rows through the default database.
|
||||
///
|
||||
/// This is the shape every real repository takes: it holds the `Sendable` `Fluent` service, resolves the
|
||||
/// default database per call, and maps FluentKit models to `Sendable` snapshots before returning — so no
|
||||
/// model ever escapes across an async boundary. It is reference scaffolding paired with ``ExampleRecord``;
|
||||
/// replace it with the first real repository once a domain model is defined.
|
||||
public struct ExampleRepository: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The service providing the default database the repository reads and writes through.
|
||||
private let fluent: Fluent
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a repository backed by the given `Fluent` service.
|
||||
/// - Parameter fluent: the service whose default database the repository operates on.
|
||||
public init(fluent: Fluent) {
|
||||
self.fluent = fluent
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Inserts a record with the given name.
|
||||
/// - Parameter name: the name of the record to insert.
|
||||
/// - Returns: a `Sendable` snapshot of the inserted record, including its assigned identifier.
|
||||
public func create(name: String) async throws -> Example {
|
||||
let record = ExampleRecord(name: name)
|
||||
|
||||
try await record.save(on: fluent.db())
|
||||
|
||||
return Example(id: record.id, name: record.name)
|
||||
}
|
||||
|
||||
/// Fetches every record, ordered by name.
|
||||
/// - Returns: a `Sendable` snapshot of each record, sorted by name.
|
||||
public func all() async throws -> [Example] {
|
||||
try await ExampleRecord.query(on: fluent.db())
|
||||
.sort(\.$name)
|
||||
.all()
|
||||
.map { Example(id: $0.id, name: $0.name) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import Foundation
|
||||
|
||||
/// The prototype's demo catalogue, taken from the design canvas.
|
||||
enum Seed {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The demo accounts: the buyer the stubbed passkey signs in, then a seller.
|
||||
static let accounts: [(email: String, displayName: String)] = [
|
||||
("mara.v@example.com", "Mara Visser"),
|
||||
("joost@kestrelrow.example", "Joost de Wit"),
|
||||
]
|
||||
|
||||
/// The seller account owning each demo shop, by the shop's slug.
|
||||
static let owners: [(shop: String, seller: (email: String, displayName: String))] = [
|
||||
("kestrel-row-comics", accounts[1]),
|
||||
("third-panel-supply", ("hello@thirdpanel.example", "Noor Janssens")),
|
||||
("longbox-atlantic", ("sales@longboxatlantic.example", "Owen Pryce")),
|
||||
("silverfish-comics", ("shop@silverfish.example", "Fenna Bos")),
|
||||
("inkwell-and-staple", ("post@inkwellstaple.example", "Lukas Brandt")),
|
||||
]
|
||||
|
||||
/// The demo listings, newest first.
|
||||
static let listings: [Listing] = [
|
||||
single(
|
||||
"nightward-1-cgc-9-8", kestrelRow, "Nightward", "1", "Meridian Comics", 2019,
|
||||
price: 240, shipping: 6, grade: 9.8, condition: "NM/M", grader: "CGC", cert: "4312087-002",
|
||||
pages: "White", key: "First appearance of the Nightward; first issue of the series",
|
||||
offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN",
|
||||
notes: "Bought from the original owner and sent for grading in one batch. Case is clean with no scuffs or scratches; inner well intact. Photos are of this exact copy — the label photo shows the full cert number, which you can check on the grader's site before you buy."
|
||||
),
|
||||
single(
|
||||
"ferrous-saint-12", thirdPanel, "Ferrous Saint", "12", "Blackline", 1994,
|
||||
price: 38, shipping: 7, grade: 9.0, condition: "VF/NM",
|
||||
printing: "newsstand", printingDetail: "First printing, newsstand edition",
|
||||
tone: "wine", lettering: "FERROUS\nSAINT", imprint: "BLACKLINE",
|
||||
notes: "Newsstand copy with the barcode box. Light spine stress at the lower staple, otherwise sharp corners and full gloss."
|
||||
),
|
||||
single(
|
||||
"echo-country-3", kestrelRow, "Echo Country", "3", "Harbour & Vale", 2021,
|
||||
price: 14, shipping: 5, grade: 9.4, condition: "NM",
|
||||
printing: "2nd print", printingDetail: "Second printing", isFirstPrint: false,
|
||||
tone: "forest", lettering: "ECHO\nCOUNTRY", imprint: "HARBOUR & VALE",
|
||||
notes: "Second printing with the recoloured cover. Read once, bagged and boarded since."
|
||||
),
|
||||
single(
|
||||
"vermilion-lake-7-cgc-6-5", longbox, "Vermilion Lake", "7", "Meridian Comics", 1987,
|
||||
price: 95, shipping: 14, grade: 6.5, condition: "FN+", grader: "CGC", cert: "3907714-011",
|
||||
pages: "Off-white to white", key: "Origin of the Lake Warden",
|
||||
printing: "direct", printingDetail: "First printing, direct edition", offers: true,
|
||||
tone: "rust", lettering: "VERMILION\nLAKE", imprint: "MERIDIAN",
|
||||
notes: "Older slab with light shelf wear on the case. The grader's notes list a small corner crease and light spine stress."
|
||||
),
|
||||
single(
|
||||
"paper-tigers-1-variant-b", silverfish, "Paper Tigers", "1", "Cutwater Press", 2023,
|
||||
price: 22, shipping: 5, grade: 9.6, condition: "NM+",
|
||||
key: "First issue of the series", printing: "variant B", printingDetail: "First printing, variant cover B",
|
||||
tone: "slate", lettering: "PAPER\nTIGERS", imprint: "CUTWATER",
|
||||
notes: "Pulled from the box on release day and never read. 1:10 retailer variant."
|
||||
),
|
||||
collected(
|
||||
"hollow-comet-vol-4", thirdPanel, "Hollow Comet, Vol. 4", "Hollow Comet", "Vol. 4", "Sōkai Books", 2022,
|
||||
format: "manga", price: 11, shipping: 5, condition: "Like new", printingDetail: "English first printing",
|
||||
tone: "plum", lettering: "HOLLOW\nCOMET", imprint: "SŌKAI BOOKS", number: "VOL 4",
|
||||
notes: "Read once. No spine creases, no sun fading; obi strip not included."
|
||||
),
|
||||
collected(
|
||||
"nightward-the-first-year-hc", kestrelRow, "Nightward: The First Year", "Nightward", "Hardcover, collects #1–12", "Meridian Comics", 2022,
|
||||
format: "hardcovers", price: 45, shipping: 8, condition: "Sealed", printingDetail: "First printing",
|
||||
tone: "navy", lettering: "NIGHTWARD\nTHE FIRST\nYEAR", imprint: "MERIDIAN", number: "HC",
|
||||
notes: "Still in the publisher's shrink-wrap. Ships in a book box with corner protection."
|
||||
),
|
||||
collected(
|
||||
"solar-divide-vol-1", longbox, "Solar Divide, Vol. 1", "Solar Divide", "Vol. 1", "Sōkai Books", 2020,
|
||||
format: "manga", price: 28, shipping: 12, condition: "Very good", printingDetail: "English first printing",
|
||||
tone: "brick", lettering: "SOLAR\nDIVIDE", imprint: "SŌKAI BOOKS", number: "VOL 1",
|
||||
notes: "Out of print in English. Light shelf wear to the lower edge; pages clean."
|
||||
),
|
||||
collected(
|
||||
"the-gilded-hour-tpb", silverfish, "The Gilded Hour", "The Gilded Hour", "Trade paperback, complete", "Harbour & Vale", 2019,
|
||||
format: "trade-paperbacks", price: 19, shipping: 6, condition: "Like new", printing: "3rd printing",
|
||||
printingDetail: "Third printing", isFirstPrint: false,
|
||||
tone: "olive", lettering: "THE\nGILDED\nHOUR", imprint: "HARBOUR & VALE", number: "TPB",
|
||||
notes: "Third printing with the corrected lettering in chapter four. Spine uncreased."
|
||||
),
|
||||
collected(
|
||||
"ferrous-saint-omnibus-hc", thirdPanel, "Ferrous Saint Omnibus", "Ferrous Saint", "Hardcover omnibus, collects #1–30", "Blackline", 2020,
|
||||
format: "hardcovers", price: 88, shipping: 10, condition: "Sealed", printingDetail: "First printing", offers: true,
|
||||
tone: "ink", lettering: "FERROUS\nSAINT\nOMNIBUS", imprint: "BLACKLINE", number: "HC",
|
||||
notes: "Sealed. Heavy book: ships double-boxed."
|
||||
),
|
||||
single(
|
||||
"nightward-2-cgc-9-6", kestrelRow, "Nightward", "2", "Meridian Comics", 2019,
|
||||
price: 85, shipping: 6, grade: 9.6, condition: "NM+", grader: "CGC", cert: "4312087-003",
|
||||
pages: "White", offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN",
|
||||
notes: "Graded in the same batch as our #1. Clean case."
|
||||
),
|
||||
single(
|
||||
"nightward-1-cgc-9-4", longbox, "Nightward", "1", "Meridian Comics", 2019,
|
||||
price: 150, shipping: 14, grade: 9.4, condition: "NM", grader: "CGC", cert: "4198220-007",
|
||||
pages: "White", key: "First appearance of the Nightward; first issue of the series",
|
||||
offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN",
|
||||
notes: "A tidy 9.4 for anyone who wants the key without the 9.8 premium."
|
||||
),
|
||||
single(
|
||||
"nightward-1-raw-nm", thirdPanel, "Nightward", "1", "Meridian Comics", 2019,
|
||||
price: 120, shipping: 7, grade: 9.4, condition: "NM",
|
||||
key: "First appearance of the Nightward; first issue of the series",
|
||||
offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN",
|
||||
notes: "Raw copy, a grading candidate. One faint non-colour-breaking stress line on the spine, visible in photo 3."
|
||||
),
|
||||
single(
|
||||
"nightward-1-2nd-print", silverfish, "Nightward", "1", "Meridian Comics", 2019,
|
||||
price: 35, shipping: 5, grade: 8.0, condition: "VF",
|
||||
key: "First appearance of the Nightward",
|
||||
printing: "2nd print", printingDetail: "Second printing, red logo", isFirstPrint: false,
|
||||
tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN",
|
||||
notes: "Second printing with the red logo. Small corner blunt, top right."
|
||||
),
|
||||
single(
|
||||
"ferrous-saint-1-cbcs-9-0", inkwell, "Ferrous Saint", "1", "Blackline", 1993,
|
||||
price: 130, shipping: 9, grade: 9.0, condition: "VF/NM", grader: "CBCS", cert: "21-2E4F771-004",
|
||||
pages: "White", key: "First appearance of the Ferrous Saint", offers: true,
|
||||
tone: "wine", lettering: "FERROUS\nSAINT", imprint: "BLACKLINE",
|
||||
notes: "CBCS slab, verified signature series not applicable. Case has one light scuff on the back."
|
||||
),
|
||||
single(
|
||||
"echo-country-1", inkwell, "Echo Country", "1", "Harbour & Vale", 2021,
|
||||
price: 24, shipping: 6, grade: 9.4, condition: "NM", key: "First issue of the series",
|
||||
tone: "forest", lettering: "ECHO\nCOUNTRY", imprint: "HARBOUR & VALE",
|
||||
notes: "First printing. Flat, glossy, no spine ticks."
|
||||
),
|
||||
single(
|
||||
"vermilion-lake-12", kestrelRow, "Vermilion Lake", "12", "Meridian Comics", 1988,
|
||||
price: 18, shipping: 5, grade: 8.5, condition: "VF+",
|
||||
printing: "direct", printingDetail: "First printing, direct edition",
|
||||
tone: "rust", lettering: "VERMILION\nLAKE", imprint: "MERIDIAN",
|
||||
notes: "Two small spine ticks; cover gloss is strong for its age."
|
||||
),
|
||||
single(
|
||||
"paper-tigers-2", silverfish, "Paper Tigers", "2", "Cutwater Press", 2023,
|
||||
price: 9, shipping: 5, grade: 9.4, condition: "NM",
|
||||
tone: "slate", lettering: "PAPER\nTIGERS", imprint: "CUTWATER",
|
||||
notes: "Cover A. Bagged and boarded from new."
|
||||
),
|
||||
collected(
|
||||
"hollow-comet-vol-1", inkwell, "Hollow Comet, Vol. 1", "Hollow Comet", "Vol. 1", "Sōkai Books", 2021,
|
||||
format: "manga", price: 14, shipping: 6, condition: "Very good", printingDetail: "English first printing",
|
||||
tone: "plum", lettering: "HOLLOW\nCOMET", imprint: "SŌKAI BOOKS", number: "VOL 1",
|
||||
notes: "First English printing with the misprinted spine number, corrected in later runs."
|
||||
),
|
||||
collected(
|
||||
"vermilion-lake-tpb-vol-1", inkwell, "Vermilion Lake, Vol. 1", "Vermilion Lake", "Trade paperback, collects #1–6", "Meridian Comics", 2015,
|
||||
format: "trade-paperbacks", price: 16, shipping: 6, condition: "Good", printing: "2nd printing",
|
||||
printingDetail: "Second printing", isFirstPrint: false,
|
||||
tone: "rust", lettering: "VERMILION\nLAKE", imprint: "MERIDIAN", number: "TPB",
|
||||
notes: "A reading copy: creased spine and a previous owner's name inside the front cover."
|
||||
),
|
||||
]
|
||||
|
||||
/// The demo cons, soonest first, each with the slugs of the shops trading there, dated from the first weekend after `date`.
|
||||
static func events(
|
||||
after date: Date
|
||||
) -> [(event: Event, shops: [String])] {
|
||||
[
|
||||
("dutch-comic-con", "Dutch Comic Con", "Jaarbeurs, Utrecht", 0, ["kestrel-row-comics", "silverfish-comics", "third-panel-supply"]),
|
||||
("stripdagen-haarlem", "Stripdagen Haarlem", "Grote Markt, Haarlem", 1, ["silverfish-comics"]),
|
||||
("comic-con-amsterdam", "Comic Con Amsterdam", "RAI, Amsterdam", 8, ["kestrel-row-comics", "inkwell-and-staple"]),
|
||||
("comic-con-brussels", "Comic Con Brussels", "Tour & Taxis, Brussels", 10, ["third-panel-supply"]),
|
||||
]
|
||||
.map { slug, name, venue, week, shops in
|
||||
let (startsOn, endsOn) = weekend(week, after: date)
|
||||
|
||||
return (.init(slug: slug, name: name, venue: venue, startsOn: startsOn, endsOn: endsOn), shops)
|
||||
}
|
||||
}
|
||||
|
||||
/// Noon on the Saturday and Sunday `week` weeks on from the first weekend after `date`, in Amsterdam, where the demo cons are held.
|
||||
static func weekend(
|
||||
_ week: Int,
|
||||
after date: Date
|
||||
) -> (startsOn: Date, endsOn: Date) {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
|
||||
calendar.timeZone = TimeZone(identifier: "Europe/Amsterdam") ?? .gmt
|
||||
|
||||
let saturday = calendar.nextDate(after: date, matching: .init(hour: 12, weekday: 7), matchingPolicy: .nextTime) ?? date
|
||||
let startsOn = calendar.date(byAdding: .weekOfYear, value: week, to: saturday) ?? saturday
|
||||
|
||||
return (startsOn, calendar.date(byAdding: .day, value: 1, to: startsOn) ?? startsOn)
|
||||
}
|
||||
|
||||
/// The runs the demo listings belong to.
|
||||
static let series: [Series] = [
|
||||
.init(slug: "nightward", name: "Nightward", publisher: "Meridian Comics", years: "2019–2021", issueCount: 24,
|
||||
blurb: "Two-year run, one printing for most issues. #1 has a second printing with a red logo; the hardcover collects #1–12."),
|
||||
.init(slug: "ferrous-saint", name: "Ferrous Saint", publisher: "Blackline", years: "1993–1996", issueCount: 30,
|
||||
blurb: "A nineties independent with direct and newsstand editions throughout. The omnibus collects the whole run."),
|
||||
.init(slug: "echo-country", name: "Echo Country", publisher: "Harbour & Vale", years: "2021–2022", issueCount: 6,
|
||||
blurb: "A six-issue limited series. Later printings carry recoloured covers."),
|
||||
.init(slug: "vermilion-lake", name: "Vermilion Lake", publisher: "Meridian Comics", years: "1986–1989", issueCount: 18,
|
||||
blurb: "Meridian's late-eighties flagship. #7 tells the origin of the Lake Warden."),
|
||||
.init(slug: "paper-tigers", name: "Paper Tigers", publisher: "Cutwater Press", years: "2023–", issueCount: 4,
|
||||
blurb: "Ongoing. Every issue ships with a 1:10 retailer variant."),
|
||||
]
|
||||
|
||||
/// The demo shops.
|
||||
static let shops: [Shop] = [kestrelRow, thirdPanel, longbox, silverfish, inkwell]
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension Seed {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
static let inkwell = Shop(
|
||||
slug: "inkwell-and-staple", name: "Inkwell & Staple", city: "Berlin", country: "DE", sinceYear: 2024,
|
||||
isVerified: true, rating: 4.8, ratedSales: 96, soldCount: 140, handlingDays: 3,
|
||||
shipsNote: "ships within the EU, tracked", returnsNote: "14-day returns",
|
||||
about: "A two-person shop working through a warehouse find of nineties independents. Everything is graded against the public guide, and anything we are unsure of is graded down."
|
||||
)
|
||||
|
||||
static let kestrelRow = Shop(
|
||||
slug: "kestrel-row-comics", name: "Kestrel Row Comics", city: "Amsterdam", country: "NL", sinceYear: 2023,
|
||||
isVerified: true, rating: 4.9, ratedSales: 312, soldCount: 1108, handlingDays: 2,
|
||||
shipsNote: "ships worldwide, tracked", returnsNote: "14-day returns on raw books",
|
||||
about: "Modern keys and Bronze Age runs, mostly slabbed. Every raw book is photographed front, back and spine under daylight; grades are conservative and defects are listed, not hinted at. Offers welcome on anything over €50."
|
||||
)
|
||||
|
||||
static let longbox = Shop(
|
||||
slug: "longbox-atlantic", name: "Longbox Atlantic", city: "Bristol", country: "GB", sinceYear: 2024,
|
||||
isVerified: false, rating: 4.6, ratedSales: 58, soldCount: 71, handlingDays: 4,
|
||||
shipsNote: "ships worldwide from the UK; import VAT may apply", returnsNote: "returns by agreement",
|
||||
about: "A collector thinning out twenty years of long boxes. Mid-grade keys and out-of-print manga, priced to move."
|
||||
)
|
||||
|
||||
static let silverfish = Shop(
|
||||
slug: "silverfish-comics", name: "Silverfish Comics", city: "Utrecht", country: "NL", sinceYear: 2022,
|
||||
isVerified: true, rating: 4.7, ratedSales: 204, soldCount: 655, handlingDays: 1,
|
||||
shipsNote: "ships within the EU, tracked", returnsNote: "14-day returns",
|
||||
about: "New-release variants and recent indies, shipped the next working day. Pickup from the Utrecht shop is always free."
|
||||
)
|
||||
|
||||
static let thirdPanel = Shop(
|
||||
slug: "third-panel-supply", name: "Third Panel Supply", city: "Antwerp", country: "BE", sinceYear: 2023,
|
||||
isVerified: true, rating: 4.8, ratedSales: 167, soldCount: 420, handlingDays: 2,
|
||||
shipsNote: "ships within the EU, tracked", returnsNote: "14-day returns",
|
||||
about: "Collected editions, manga and the odd newsstand oddity. Sealed means sealed; everything else is described page by page."
|
||||
)
|
||||
|
||||
/// The moment the newest demo listing was listed; each later entry in ``listings`` is a few hours older.
|
||||
static let epoch = Date(timeIntervalSince1970: 1_789_900_000)
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds a collected edition — manga, trade paperback, or hardcover — which carries a condition but no grade.
|
||||
static func collected(
|
||||
_ slug: String,
|
||||
_ shop: Shop,
|
||||
_ title: String,
|
||||
_ series: String,
|
||||
_ issueLabel: String,
|
||||
_ publisher: String,
|
||||
_ year: Int,
|
||||
format: String,
|
||||
price: Int,
|
||||
shipping: Int,
|
||||
condition: String,
|
||||
printing: String = "1st printing",
|
||||
printingDetail: String,
|
||||
isFirstPrint: Bool = true,
|
||||
offers: Bool = false,
|
||||
tone: String,
|
||||
lettering: String,
|
||||
imprint: String,
|
||||
number: String,
|
||||
notes: String
|
||||
) -> Listing {
|
||||
.init(
|
||||
slug: slug, shop: shop, title: title, seriesTitle: series, issueLabel: issueLabel,
|
||||
publisher: publisher, year: year, format: format, printing: printing, printingDetail: printingDetail,
|
||||
isFirstPrint: isFirstPrint, grader: nil, grade: nil, condition: condition, certNumber: nil,
|
||||
pageQuality: nil, keyNotes: nil, sellerNotes: notes, priceCents: price * 100,
|
||||
shippingCents: shipping * 100, acceptsOffers: offers,
|
||||
cover: .init(tone: tone, title: lettering, imprint: imprint, number: number),
|
||||
listedAt: listedAt(slug)
|
||||
)
|
||||
}
|
||||
|
||||
/// The listing date of the entry with the given slug: the ``epoch``, less seven hours per earlier entry.
|
||||
static func listedAt(
|
||||
_ slug: String
|
||||
) -> Date {
|
||||
epoch.addingTimeInterval(-25_200 * Double(order.firstIndex(of: slug) ?? order.count))
|
||||
}
|
||||
|
||||
/// Builds a single issue, slabbed when a grader is given and raw otherwise.
|
||||
static func single(
|
||||
_ slug: String,
|
||||
_ shop: Shop,
|
||||
_ series: String,
|
||||
_ number: String,
|
||||
_ publisher: String,
|
||||
_ year: Int,
|
||||
price: Int,
|
||||
shipping: Int,
|
||||
grade: Double,
|
||||
condition: String,
|
||||
grader: String? = nil,
|
||||
cert: String? = nil,
|
||||
pages: String? = nil,
|
||||
key: String? = nil,
|
||||
printing: String = "1st print",
|
||||
printingDetail: String = "First printing",
|
||||
isFirstPrint: Bool = true,
|
||||
offers: Bool = false,
|
||||
tone: String,
|
||||
lettering: String,
|
||||
imprint: String,
|
||||
notes: String
|
||||
) -> Listing {
|
||||
.init(
|
||||
slug: slug, shop: shop, title: "\(series) #\(number)", seriesTitle: "\(series) (\(year) series)",
|
||||
issueLabel: "#\(number)", publisher: publisher, year: year, format: "single-issues",
|
||||
printing: printing, printingDetail: printingDetail, isFirstPrint: isFirstPrint, grader: grader,
|
||||
grade: grade, condition: condition, certNumber: cert, pageQuality: pages, keyNotes: key,
|
||||
sellerNotes: notes, priceCents: price * 100, shippingCents: shipping * 100, acceptsOffers: offers,
|
||||
cover: .init(tone: tone, title: lettering, imprint: imprint, number: number),
|
||||
listedAt: listedAt(slug)
|
||||
)
|
||||
}
|
||||
|
||||
/// The listing slugs, newest first; ``listedAt(_:)`` spaces the listing dates by this order.
|
||||
static let order = [
|
||||
"nightward-1-cgc-9-8", "ferrous-saint-12", "echo-country-3", "vermilion-lake-7-cgc-6-5",
|
||||
"paper-tigers-1-variant-b", "hollow-comet-vol-4", "nightward-the-first-year-hc", "solar-divide-vol-1",
|
||||
"the-gilded-hour-tpb", "ferrous-saint-omnibus-hc", "nightward-2-cgc-9-6", "nightward-1-cgc-9-4",
|
||||
"nightward-1-raw-nm", "nightward-1-2nd-print", "ferrous-saint-1-cbcs-9-0", "echo-country-1",
|
||||
"vermilion-lake-12", "paper-tigers-2", "hollow-comet-vol-1", "vermilion-lake-tpb-vol-1",
|
||||
]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/// Why a shop could not be opened.
|
||||
public enum ShopError: Error, Equatable, Sendable {
|
||||
/// The account already owns a shop.
|
||||
case alreadyOwner
|
||||
/// Another shop has the handle.
|
||||
case handleTaken
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/// Why a step towards a sale was turned down.
|
||||
public enum TradeError: Error, Equatable, Sendable {
|
||||
/// The amount is not a positive number of cents at or under the asking price.
|
||||
case invalidAmount
|
||||
/// The listing does not exist, or is sold.
|
||||
case listingUnavailable
|
||||
/// The account is not a party to the conversation, offer, or sale — or the step is not theirs to take now.
|
||||
case notAllowed
|
||||
/// The buyer already has an open offer on the listing.
|
||||
case offerAlreadyOpen
|
||||
/// The offer is under the floor the seller hears offers from, in euro cents.
|
||||
case offerTooLow(floorCents: Int)
|
||||
/// The listing's seller takes no offers.
|
||||
case offersNotAccepted
|
||||
/// The listing, shop, conversation, or sale belongs to the account itself.
|
||||
case ownListing
|
||||
/// The shop has no owner to sell on its behalf.
|
||||
case shopUnattended
|
||||
}
|
||||
@@ -22,7 +22,18 @@ public struct PrepareDB: Sendable {
|
||||
for fluent: Fluent
|
||||
) async {
|
||||
await fluent.migrations.add([
|
||||
CreateExampleRecord()
|
||||
CreateShop(),
|
||||
CreateListing(),
|
||||
AddTradeColumns(),
|
||||
AddListingDeclarations(),
|
||||
AddShopRegistration(),
|
||||
SeedCatalogue(),
|
||||
CreateUser(),
|
||||
CreateTrade(),
|
||||
CreatePasskey(),
|
||||
CreateCollecting(),
|
||||
CreateCommunity(),
|
||||
RedateEvents(),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
|
||||
/// A `Sendable` snapshot of a conversation between a buyer and a shop, as one of them sees it.
|
||||
public struct Conversation: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The conversation's identifier.
|
||||
public let id: UUID
|
||||
/// The account buying.
|
||||
public let buyer: User
|
||||
/// The shop selling.
|
||||
public let shop: Shop
|
||||
/// The listing the conversation is about, or `nil` when it is about the shop at large.
|
||||
public let listing: Listing?
|
||||
/// Which side of the conversation the viewer is on.
|
||||
public let side: Side
|
||||
/// What was said, offered, and stamped, oldest first.
|
||||
public let entries: [Entry]
|
||||
/// The number of entries from the other side the viewer has not seen yet.
|
||||
public let unreadCount: Int
|
||||
/// The number of the sale agreed in the conversation, or `nil` when none was.
|
||||
public let saleNumber: Int?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
buyer: User,
|
||||
shop: Shop,
|
||||
listing: Listing?,
|
||||
side: Side,
|
||||
entries: [Entry],
|
||||
unreadCount: Int,
|
||||
saleNumber: Int?
|
||||
) {
|
||||
self.id = id
|
||||
self.buyer = buyer
|
||||
self.shop = shop
|
||||
self.listing = listing
|
||||
self.side = side
|
||||
self.entries = entries
|
||||
self.unreadCount = unreadCount
|
||||
self.saleNumber = saleNumber
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
public extension Conversation {
|
||||
/// One thing in a conversation: something said, an offer made, or an event stamped.
|
||||
struct Entry: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// What the entry is.
|
||||
public let kind: Kind
|
||||
/// Who wrote the message, made the offer, or caused the event.
|
||||
public let sender: User
|
||||
/// The message, the offer's note, or the event in a sentence.
|
||||
public let body: String
|
||||
/// The offer made, for an offer entry.
|
||||
public let offer: Offer?
|
||||
/// The number of the sale an event opened, or `nil`.
|
||||
public let saleNumber: Int?
|
||||
/// When the entry was made.
|
||||
public let createdAt: Date
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
kind: Kind,
|
||||
sender: User,
|
||||
body: String,
|
||||
offer: Offer? = nil,
|
||||
saleNumber: Int? = nil,
|
||||
createdAt: Date
|
||||
) {
|
||||
self.kind = kind
|
||||
self.sender = sender
|
||||
self.body = body
|
||||
self.offer = offer
|
||||
self.saleNumber = saleNumber
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
// MARK: Enumerations
|
||||
|
||||
/// What a conversation entry is.
|
||||
public enum Kind: String, Sendable {
|
||||
/// Something a party wrote.
|
||||
case text
|
||||
/// An offer, with its optional note.
|
||||
case offer
|
||||
/// Something that happened: an offer answered, a sale agreed, a step of the handshake taken.
|
||||
case event
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
public extension Conversation {
|
||||
/// A side of a conversation.
|
||||
enum Side: String, Sendable {
|
||||
case buying
|
||||
case selling
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
public extension Conversation {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The latest entry, or `nil` in a conversation nothing was said in yet.
|
||||
var latest: Entry? {
|
||||
entries.last
|
||||
}
|
||||
|
||||
/// The offer still waiting for an answer, or `nil` when there is none.
|
||||
var openOffer: Offer? {
|
||||
entries
|
||||
.compactMap(\.offer)
|
||||
.last { $0.status == .open }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import Foundation
|
||||
|
||||
/// A `Sendable` snapshot of one copy for sale, with the shop selling it.
|
||||
public struct Listing: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The listing's URL slug.
|
||||
public let slug: String
|
||||
/// The shop selling the copy.
|
||||
public let shop: Shop
|
||||
/// The listing's title, e.g. `Nightward #1`.
|
||||
public let title: String
|
||||
/// The series the copy belongs to, e.g. `Nightward (2019 series)`.
|
||||
public let seriesTitle: String
|
||||
/// The issue or volume, e.g. `#1, cover A`.
|
||||
public let issueLabel: String
|
||||
/// The publisher's name.
|
||||
public let publisher: String
|
||||
/// The cover year.
|
||||
public let year: Int
|
||||
/// The format token, e.g. `single-issues`.
|
||||
public let format: String
|
||||
/// The printing in a few words, e.g. `1st print`.
|
||||
public let printing: String
|
||||
/// The printing in full, e.g. `First printing, direct edition`.
|
||||
public let printingDetail: String
|
||||
/// Whether the copy is a first printing.
|
||||
public let isFirstPrint: Bool
|
||||
/// The company that graded and slabbed the copy, or `nil` for a raw copy.
|
||||
public let grader: String?
|
||||
/// The numeric grade, or `nil` for formats that are not graded.
|
||||
public let grade: Double?
|
||||
/// The condition as the seller states it, e.g. `NM` or `Like new`.
|
||||
public let condition: String
|
||||
/// The grader's certificate number, or `nil` for a raw copy.
|
||||
public let certNumber: String?
|
||||
/// The page quality, or `nil` when not stated.
|
||||
public let pageQuality: String?
|
||||
/// Why the issue is a key, or `nil` when it is not one.
|
||||
public let keyNotes: String?
|
||||
/// The seller's notes on the copy.
|
||||
public let sellerNotes: String
|
||||
/// The asking price, in euro cents.
|
||||
public let priceCents: Int
|
||||
/// The shipping cost to the Netherlands, in euro cents.
|
||||
public let shippingCents: Int
|
||||
/// Whether the seller takes offers on the copy.
|
||||
public let acceptsOffers: Bool
|
||||
/// The placeholder cover shown until listings carry photos.
|
||||
public let cover: Cover
|
||||
/// When the copy was listed.
|
||||
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
|
||||
|
||||
public init(
|
||||
slug: String,
|
||||
shop: Shop,
|
||||
title: String,
|
||||
seriesTitle: String,
|
||||
issueLabel: String,
|
||||
publisher: String,
|
||||
year: Int,
|
||||
format: String,
|
||||
printing: String,
|
||||
printingDetail: String,
|
||||
isFirstPrint: Bool,
|
||||
grader: String?,
|
||||
grade: Double?,
|
||||
condition: String,
|
||||
certNumber: String?,
|
||||
pageQuality: String?,
|
||||
keyNotes: String?,
|
||||
sellerNotes: String,
|
||||
priceCents: Int,
|
||||
shippingCents: Int,
|
||||
acceptsOffers: Bool,
|
||||
cover: Cover,
|
||||
listedAt: Date,
|
||||
soldAt: Date? = nil,
|
||||
defects: [String] = [],
|
||||
photos: [String] = []
|
||||
) {
|
||||
self.slug = slug
|
||||
self.shop = shop
|
||||
self.title = title
|
||||
self.seriesTitle = seriesTitle
|
||||
self.issueLabel = issueLabel
|
||||
self.publisher = publisher
|
||||
self.year = year
|
||||
self.format = format
|
||||
self.printing = printing
|
||||
self.printingDetail = printingDetail
|
||||
self.isFirstPrint = isFirstPrint
|
||||
self.grader = grader
|
||||
self.grade = grade
|
||||
self.condition = condition
|
||||
self.certNumber = certNumber
|
||||
self.pageQuality = pageQuality
|
||||
self.keyNotes = keyNotes
|
||||
self.sellerNotes = sellerNotes
|
||||
self.priceCents = priceCents
|
||||
self.shippingCents = shippingCents
|
||||
self.acceptsOffers = acceptsOffers
|
||||
self.cover = cover
|
||||
self.listedAt = listedAt
|
||||
self.soldAt = soldAt
|
||||
self.defects = defects
|
||||
self.photos = photos
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
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 {
|
||||
return "\(grader) \(grade.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en"))))"
|
||||
}
|
||||
|
||||
return format == "single-issues" ? "Raw · \(condition)" : condition
|
||||
}
|
||||
|
||||
/// Whether the copy is still for sale.
|
||||
var isAvailable: Bool {
|
||||
soldAt == nil
|
||||
}
|
||||
|
||||
/// Whether the issue is a key.
|
||||
var isKey: Bool {
|
||||
keyNotes != nil
|
||||
}
|
||||
|
||||
/// The issue's number within its series, or `nil` for a copy that is not a numbered single issue.
|
||||
var issueNumber: Int? {
|
||||
format == "single-issues"
|
||||
? Int(issueLabel.drop { !$0.isNumber }.prefix { $0.isNumber })
|
||||
: nil
|
||||
}
|
||||
|
||||
/// The name of the series the copy belongs to: its series title, less the `(2019 series)` a single issue's carries.
|
||||
var seriesName: String {
|
||||
seriesTitle
|
||||
.replacing(#/\s\(\d{4} series\)$/#, with: "")
|
||||
}
|
||||
|
||||
/// The slug of the series the copy belongs to.
|
||||
var seriesSlug: String {
|
||||
ListingDraft.slug(seriesName)
|
||||
}
|
||||
|
||||
/// Whether the copy is graded and slabbed by a grading company.
|
||||
var isSlabbed: Bool {
|
||||
grader != nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
public extension Listing {
|
||||
/// A placeholder cover: lettering on a toned ground.
|
||||
struct Cover: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The tone token naming the cover's colours, e.g. `navy`.
|
||||
public let tone: String
|
||||
/// The title lettering, one line per newline.
|
||||
public let title: String
|
||||
/// The publisher's imprint line.
|
||||
public let imprint: String
|
||||
/// The issue number or volume marker.
|
||||
public let number: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
tone: String,
|
||||
title: String,
|
||||
imprint: String,
|
||||
number: String
|
||||
) {
|
||||
self.tone = tone
|
||||
self.title = title
|
||||
self.imprint = imprint
|
||||
self.number = number
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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,209 @@
|
||||
/// The criteria a listing search is narrowed and ordered by; the default matches every listing for sale, newest first.
|
||||
public struct ListingFilter: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// Words that must each appear in the listing's title, publisher, or shop name.
|
||||
public var query: String?
|
||||
/// The format tokens to match; empty matches all.
|
||||
public var formats: [String]
|
||||
/// `true` for slabbed copies only, `false` for raw only, `nil` for both.
|
||||
public var isSlabbed: Bool?
|
||||
/// The lowest numeric grade to match.
|
||||
public var gradeMin: Double?
|
||||
/// The highest numeric grade to match.
|
||||
public var gradeMax: Double?
|
||||
/// Whether to match first printings only.
|
||||
public var firstPrintOnly: Bool
|
||||
/// Whether to match key issues only.
|
||||
public var keysOnly: Bool
|
||||
/// The publishers to match; empty matches all.
|
||||
public var publishers: [String]
|
||||
/// The lowest price to match, in euro cents.
|
||||
public var priceMin: Int?
|
||||
/// The highest price to match, in euro cents.
|
||||
public var priceMax: Int?
|
||||
/// Where matching listings ship from.
|
||||
public var shipsFrom: Region
|
||||
/// Whether to match only listings whose seller takes offers.
|
||||
public var acceptsOffers: Bool
|
||||
/// Whether to match only listings from verified shops.
|
||||
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
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
query: String? = nil,
|
||||
formats: [String] = [],
|
||||
isSlabbed: Bool? = nil,
|
||||
gradeMin: Double? = nil,
|
||||
gradeMax: Double? = nil,
|
||||
firstPrintOnly: Bool = false,
|
||||
keysOnly: Bool = false,
|
||||
publishers: [String] = [],
|
||||
priceMin: Int? = nil,
|
||||
priceMax: Int? = nil,
|
||||
shipsFrom: Region = .anywhere,
|
||||
acceptsOffers: Bool = false,
|
||||
verifiedShops: Bool = false,
|
||||
shop: String? = nil,
|
||||
shops: [String]? = nil,
|
||||
sort: Sort = .newest
|
||||
) {
|
||||
self.query = query
|
||||
self.formats = formats
|
||||
self.isSlabbed = isSlabbed
|
||||
self.gradeMin = gradeMin
|
||||
self.gradeMax = gradeMax
|
||||
self.firstPrintOnly = firstPrintOnly
|
||||
self.keysOnly = keysOnly
|
||||
self.publishers = publishers
|
||||
self.priceMin = priceMin
|
||||
self.priceMax = priceMax
|
||||
self.shipsFrom = shipsFrom
|
||||
self.acceptsOffers = acceptsOffers
|
||||
self.verifiedShops = verifiedShops
|
||||
self.shop = shop
|
||||
self.shops = shops
|
||||
self.sort = sort
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
public extension ListingFilter {
|
||||
/// Where a listing ships from.
|
||||
enum Region: String, CaseIterable, Sendable {
|
||||
/// The Netherlands.
|
||||
case nl
|
||||
/// Any member state of the European Union.
|
||||
case eu
|
||||
/// Any country.
|
||||
case anywhere
|
||||
}
|
||||
|
||||
/// The order of a listing search's results.
|
||||
enum Sort: String, CaseIterable, Sendable {
|
||||
/// Most recently listed first.
|
||||
case newest
|
||||
/// Cheapest first.
|
||||
case priceAscending = "price-asc"
|
||||
/// Most expensive first.
|
||||
case priceDescending = "price-desc"
|
||||
/// Highest grade first; ungraded listings last.
|
||||
case gradeDescending = "grade-desc"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension ListingFilter {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Whether the listing meets every criterion.
|
||||
/// - Parameter listing: the listing to test.
|
||||
func matches(
|
||||
_ listing: Listing
|
||||
) -> Bool {
|
||||
guard listing.isAvailable else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let shop, listing.shop.slug != shop {
|
||||
return false
|
||||
}
|
||||
|
||||
if let shops, !shops.contains(listing.shop.slug) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !formats.isEmpty, !formats.contains(listing.format) {
|
||||
return false
|
||||
}
|
||||
|
||||
if let isSlabbed, listing.isSlabbed != isSlabbed {
|
||||
return false
|
||||
}
|
||||
|
||||
if gradeMin != nil || gradeMax != nil {
|
||||
guard
|
||||
let grade = listing.grade,
|
||||
grade >= gradeMin ?? 0,
|
||||
grade <= gradeMax ?? 10
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if firstPrintOnly, !listing.isFirstPrint {
|
||||
return false
|
||||
}
|
||||
|
||||
if keysOnly, !listing.isKey {
|
||||
return false
|
||||
}
|
||||
|
||||
if !publishers.isEmpty, !publishers.contains(listing.publisher) {
|
||||
return false
|
||||
}
|
||||
|
||||
if let priceMin, listing.priceCents < priceMin {
|
||||
return false
|
||||
}
|
||||
|
||||
if let priceMax, listing.priceCents > priceMax {
|
||||
return false
|
||||
}
|
||||
|
||||
if acceptsOffers, !listing.acceptsOffers {
|
||||
return false
|
||||
}
|
||||
|
||||
if verifiedShops, !listing.shop.isVerified {
|
||||
return false
|
||||
}
|
||||
|
||||
switch shipsFrom {
|
||||
case .nl where listing.shop.country != "NL": return false
|
||||
case .eu where !Self.euCountries.contains(listing.shop.country): return false
|
||||
default: break
|
||||
}
|
||||
|
||||
let haystack = "\(listing.title) \(listing.publisher) \(listing.shop.name)".lowercased()
|
||||
|
||||
return (query ?? "")
|
||||
.lowercased()
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.allSatisfy { haystack.contains($0) }
|
||||
}
|
||||
|
||||
/// Whether the first listing sorts ahead of the second.
|
||||
func ordersBefore(
|
||||
_ lhs: Listing,
|
||||
_ rhs: Listing
|
||||
) -> Bool {
|
||||
switch sort {
|
||||
case .newest: lhs.listedAt > rhs.listedAt
|
||||
case .priceAscending: lhs.priceCents < rhs.priceCents
|
||||
case .priceDescending: lhs.priceCents > rhs.priceCents
|
||||
case .gradeDescending: (lhs.grade ?? -1) > (rhs.grade ?? -1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The ISO 3166-1 alpha-2 codes of the European Union's member states.
|
||||
static let euCountries: Set<String> = [
|
||||
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HR", "HU",
|
||||
"IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK",
|
||||
]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
/// A `Sendable` snapshot of a buyer's offer on a listing.
|
||||
public struct Offer: Sendable, Equatable {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The share of the asking price under which an offer is declined outright.
|
||||
public static let floorShare = 0.8
|
||||
|
||||
/// How long an offer binds its buyer: 48 hours.
|
||||
public static let lifetime: TimeInterval = 172_800
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The offer's identifier.
|
||||
public let id: UUID
|
||||
/// The identifier of the conversation the offer was made in.
|
||||
public let conversationID: UUID
|
||||
/// The listing offered on.
|
||||
public let listing: Listing
|
||||
/// The account offering.
|
||||
public let buyer: User
|
||||
/// The amount offered, in euro cents.
|
||||
public let amountCents: Int
|
||||
/// Where the offer stands; an open offer past its expiry reads as expired.
|
||||
public let status: Status
|
||||
/// When the offer was made.
|
||||
public let createdAt: Date
|
||||
/// When the offer stops binding its buyer.
|
||||
public let expiresAt: Date
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
conversationID: UUID,
|
||||
listing: Listing,
|
||||
buyer: User,
|
||||
amountCents: Int,
|
||||
status: Status,
|
||||
createdAt: Date,
|
||||
expiresAt: Date
|
||||
) {
|
||||
self.id = id
|
||||
self.conversationID = conversationID
|
||||
self.listing = listing
|
||||
self.buyer = buyer
|
||||
self.amountCents = amountCents
|
||||
self.status = status
|
||||
self.createdAt = createdAt
|
||||
self.expiresAt = expiresAt
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
public extension Offer {
|
||||
/// Where an offer stands.
|
||||
enum Status: String, Sendable {
|
||||
/// Waiting for the seller's answer.
|
||||
case open
|
||||
/// Accepted: a sale record was opened.
|
||||
case accepted
|
||||
/// Declined by the seller.
|
||||
case declined
|
||||
/// Left unanswered past its expiry, or overtaken by a sale of the listing.
|
||||
case expired
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
public extension Offer {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The lowest offer a listing's seller hears, in euro cents: ``floorShare`` of the asking price, rounded up to a whole euro.
|
||||
/// - Parameter askingCents: the listing's asking price, in euro cents.
|
||||
static func floor(
|
||||
forAsking askingCents: Int
|
||||
) -> Int {
|
||||
Int((Double(askingCents) * floorShare / 100).rounded(.up)) * 100
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Foundation
|
||||
|
||||
/// A `Sendable` snapshot of a sale record: what was agreed, and how far its handshake has come.
|
||||
///
|
||||
/// The handshake has five steps — agreed, paid, sent, received, rated — each stamped by the person who took it. Payment is settled between
|
||||
/// buyer and seller off-platform; the record keeps the amounts and the stamps.
|
||||
public struct Sale: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The record's number, shown as `CCN-48213`.
|
||||
public let number: Int
|
||||
/// The identifier of the conversation the sale was agreed in.
|
||||
public let conversationID: UUID
|
||||
/// The listing sold.
|
||||
public let listing: Listing
|
||||
/// The account buying.
|
||||
public let buyer: User
|
||||
/// The account selling: the owner of the listing's shop.
|
||||
public let seller: User
|
||||
/// The agreed price, in euro cents.
|
||||
public let priceCents: Int
|
||||
/// The asking price at the moment of agreement, in euro cents.
|
||||
public let askedCents: Int
|
||||
/// The agreed shipping cost, in euro cents.
|
||||
public let shippingCents: Int
|
||||
/// The condition the seller declared, frozen at the moment of agreement.
|
||||
public let declared: Declaration
|
||||
/// When the sale was agreed.
|
||||
public let agreedAt: Date
|
||||
/// When the buyer marked the sale paid.
|
||||
public let paidAt: Date?
|
||||
/// How the buyer says they paid.
|
||||
public let paymentNote: String?
|
||||
/// When the seller marked the copy sent.
|
||||
public let sentAt: Date?
|
||||
/// The carrier and tracking the seller gave.
|
||||
public let shippingNote: String?
|
||||
/// When the buyer marked the copy received.
|
||||
public let receivedAt: Date?
|
||||
/// The stars the buyer gave the seller, out of five.
|
||||
public let buyerRating: Int?
|
||||
/// The stars the seller gave the buyer, out of five.
|
||||
public let sellerRating: Int?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
number: Int,
|
||||
conversationID: UUID,
|
||||
listing: Listing,
|
||||
buyer: User,
|
||||
seller: User,
|
||||
priceCents: Int,
|
||||
askedCents: Int,
|
||||
shippingCents: Int,
|
||||
declared: Declaration,
|
||||
agreedAt: Date,
|
||||
paidAt: Date? = nil,
|
||||
paymentNote: String? = nil,
|
||||
sentAt: Date? = nil,
|
||||
shippingNote: String? = nil,
|
||||
receivedAt: Date? = nil,
|
||||
buyerRating: Int? = nil,
|
||||
sellerRating: Int? = nil
|
||||
) {
|
||||
self.number = number
|
||||
self.conversationID = conversationID
|
||||
self.listing = listing
|
||||
self.buyer = buyer
|
||||
self.seller = seller
|
||||
self.priceCents = priceCents
|
||||
self.askedCents = askedCents
|
||||
self.shippingCents = shippingCents
|
||||
self.declared = declared
|
||||
self.agreedAt = agreedAt
|
||||
self.paidAt = paidAt
|
||||
self.paymentNote = paymentNote
|
||||
self.sentAt = sentAt
|
||||
self.shippingNote = shippingNote
|
||||
self.receivedAt = receivedAt
|
||||
self.buyerRating = buyerRating
|
||||
self.sellerRating = sellerRating
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
public extension Sale {
|
||||
/// The condition a seller declared, frozen when the sale was agreed: a problem report is only ever for what is not in it.
|
||||
struct Declaration: Sendable, Equatable {
|
||||
/// The grade or condition, e.g. `CGC 9.8`.
|
||||
public let grade: String
|
||||
/// The page quality, or `nil` when not stated.
|
||||
public let pageQuality: String?
|
||||
/// The printing in full.
|
||||
public let printing: String
|
||||
/// The seller's notes on the copy.
|
||||
public let notes: String
|
||||
|
||||
public init(
|
||||
grade: String,
|
||||
pageQuality: String?,
|
||||
printing: String,
|
||||
notes: String
|
||||
) {
|
||||
self.grade = grade
|
||||
self.pageQuality = pageQuality
|
||||
self.printing = printing
|
||||
self.notes = notes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
public extension Sale {
|
||||
/// Something a party does to move the handshake on.
|
||||
enum Action: Sendable, Equatable {
|
||||
/// The buyer says they paid, and how.
|
||||
case markPaid(note: String)
|
||||
/// The seller says the copy is on its way, and how.
|
||||
case markSent(note: String)
|
||||
/// The buyer says the copy arrived.
|
||||
case markReceived
|
||||
/// Either party rates the other, out of five.
|
||||
case rate(stars: Int)
|
||||
}
|
||||
|
||||
/// A party to a sale.
|
||||
enum Role: Sendable {
|
||||
case buyer
|
||||
case seller
|
||||
}
|
||||
|
||||
/// A step of the handshake, in order.
|
||||
enum Step: Int, CaseIterable, Comparable, Sendable {
|
||||
case agreed = 1
|
||||
case paid
|
||||
case sent
|
||||
case received
|
||||
case rated
|
||||
|
||||
public static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.rawValue < rhs.rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
public extension Sale {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The last step taken. Rated counts once both parties have rated.
|
||||
var completed: Step {
|
||||
if buyerRating != nil, sellerRating != nil {
|
||||
return .rated
|
||||
}
|
||||
|
||||
if receivedAt != nil {
|
||||
return .received
|
||||
}
|
||||
|
||||
if sentAt != nil {
|
||||
return .sent
|
||||
}
|
||||
|
||||
return paidAt != nil ? .paid : .agreed
|
||||
}
|
||||
|
||||
/// The step waited on, or `nil` once the handshake is done.
|
||||
var next: Step? {
|
||||
.init(rawValue: completed.rawValue + 1)
|
||||
}
|
||||
|
||||
/// The agreed price and shipping together, in euro cents.
|
||||
var totalCents: Int {
|
||||
priceCents + shippingCents
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Whether a party may take an action now.
|
||||
///
|
||||
/// Each step waits for the one before it and is taken once, by the party it belongs to: the buyer pays and receives, the seller sends.
|
||||
/// Ratings unlock only after the copy is received, so every rating maps to a finished sale.
|
||||
/// - Parameters:
|
||||
/// - action: the action to take.
|
||||
/// - role: the party taking it.
|
||||
func allows(
|
||||
_ action: Action,
|
||||
by role: Role
|
||||
) -> Bool {
|
||||
switch action {
|
||||
case .markPaid: role == .buyer && completed == .agreed
|
||||
case .markSent: role == .seller && completed == .paid
|
||||
case .markReceived: role == .buyer && completed == .sent
|
||||
case .rate(let stars): receivedAt != nil && (1...5).contains(stars) && rating(by: role) == nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The stars a party gave, or `nil` when they have not rated yet.
|
||||
func rating(
|
||||
by role: Role
|
||||
) -> Int? {
|
||||
role == .buyer ? buyerRating : sellerRating
|
||||
}
|
||||
|
||||
/// The party an account is to the sale, or `nil` when it is neither.
|
||||
/// - Parameter userID: the account's identifier.
|
||||
func role(
|
||||
of userID: UUID
|
||||
) -> Role? {
|
||||
if userID == buyer.id {
|
||||
return .buyer
|
||||
}
|
||||
|
||||
return userID == seller.id ? .seller : nil
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// A `Sendable` snapshot of a series in the catalogue: a run of issues, which is what lets a gap in it show.
|
||||
public struct Series: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The series' URL slug: its name, slugged.
|
||||
public let slug: String
|
||||
/// The series' name, e.g. `Nightward`.
|
||||
public let name: String
|
||||
/// The publisher's name.
|
||||
public let publisher: String
|
||||
/// The years the series ran, e.g. `2019–2021`.
|
||||
public let years: String
|
||||
/// The number of issues in the run; zero for a series the catalogue only knows from its listings.
|
||||
public let issueCount: Int
|
||||
/// The series in a sentence or two.
|
||||
public let blurb: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
slug: String,
|
||||
name: String,
|
||||
publisher: String,
|
||||
years: String,
|
||||
issueCount: Int,
|
||||
blurb: String
|
||||
) {
|
||||
self.slug = slug
|
||||
self.name = name
|
||||
self.publisher = publisher
|
||||
self.years = years
|
||||
self.issueCount = issueCount
|
||||
self.blurb = blurb
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/// A `Sendable` snapshot of a seller's shop.
|
||||
public struct Shop: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The shop's URL slug.
|
||||
public let slug: String
|
||||
/// The shop's name.
|
||||
public let name: String
|
||||
/// The city the shop ships from.
|
||||
public let city: String
|
||||
/// The ISO 3166-1 alpha-2 code of the country the shop ships from.
|
||||
public let country: String
|
||||
/// The year the shop started selling on the site.
|
||||
public let sinceYear: Int
|
||||
/// Whether the shop's identity has been verified.
|
||||
public let isVerified: Bool
|
||||
/// The shop's average rating, out of five.
|
||||
public let rating: Double
|
||||
/// The number of rated sales behind ``rating``.
|
||||
public let ratedSales: Int
|
||||
/// The number of items the shop has sold.
|
||||
public let soldCount: Int
|
||||
/// The shop's handling time, in business days.
|
||||
public let handlingDays: Int
|
||||
/// Where and how the shop ships.
|
||||
public let shipsNote: String
|
||||
/// The shop's returns policy, in a few words.
|
||||
public let returnsNote: String
|
||||
/// The shop's description of itself.
|
||||
public let about: String
|
||||
/// Whether the seller sells as a private collector or as a business.
|
||||
public let kind: Kind
|
||||
/// The business's Chamber of Commerce (KvK) number, shown on its shop page.
|
||||
public let kvkNumber: String?
|
||||
/// The business's VAT (BTW) identifier.
|
||||
public let vatID: String?
|
||||
/// The business's registered name.
|
||||
public let legalName: String?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
slug: String,
|
||||
name: String,
|
||||
city: String,
|
||||
country: String,
|
||||
sinceYear: Int,
|
||||
isVerified: Bool,
|
||||
rating: Double,
|
||||
ratedSales: Int,
|
||||
soldCount: Int,
|
||||
handlingDays: Int,
|
||||
shipsNote: String,
|
||||
returnsNote: String,
|
||||
about: String,
|
||||
kind: Kind = .business,
|
||||
kvkNumber: String? = nil,
|
||||
vatID: String? = nil,
|
||||
legalName: String? = nil
|
||||
) {
|
||||
self.slug = slug
|
||||
self.name = name
|
||||
self.city = city
|
||||
self.country = country
|
||||
self.sinceYear = sinceYear
|
||||
self.isVerified = isVerified
|
||||
self.rating = rating
|
||||
self.ratedSales = ratedSales
|
||||
self.soldCount = soldCount
|
||||
self.handlingDays = handlingDays
|
||||
self.shipsNote = shipsNote
|
||||
self.returnsNote = returnsNote
|
||||
self.about = about
|
||||
self.kind = kind
|
||||
self.kvkNumber = kvkNumber
|
||||
self.vatID = vatID
|
||||
self.legalName = legalName
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
public extension Shop {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// Whether the shop ships from a member state of the European Union, so a delivery within it crosses no customs border.
|
||||
var shipsFromEU: Bool {
|
||||
ListingFilter.euCountries.contains(country)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
public extension Shop {
|
||||
/// How a seller sells, which decides what consumer law asks of them.
|
||||
enum Kind: String, CaseIterable, Sendable {
|
||||
/// A business: its KvK number shows on its shop page, and consumers get the 14-day right of withdrawal.
|
||||
case business
|
||||
/// A private collector selling their own books: no statutory right of withdrawal.
|
||||
case privateSeller = "private"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/// What an account fills in to open a shop.
|
||||
public struct ShopDraft: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The shop's name.
|
||||
public var name: String
|
||||
/// The shop's handle: its URL slug.
|
||||
public var handle: String
|
||||
/// The city the shop ships from.
|
||||
public var city: String
|
||||
/// The ISO 3166-1 alpha-2 code of the country the shop ships from.
|
||||
public var country: String
|
||||
/// The shop's description of itself.
|
||||
public var about: String
|
||||
/// Whether the seller sells as a private collector or as a business.
|
||||
public var kind: Shop.Kind
|
||||
/// The business's KvK number.
|
||||
public var kvkNumber: String?
|
||||
/// The business's VAT identifier.
|
||||
public var vatID: String?
|
||||
/// The business's registered name.
|
||||
public var legalName: String?
|
||||
/// The shop's returns policy, in a few words.
|
||||
public var returnsNote: String
|
||||
/// The shop's handling time, in business days.
|
||||
public var handlingDays: Int
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
handle: String,
|
||||
city: String,
|
||||
country: String,
|
||||
about: String,
|
||||
kind: Shop.Kind,
|
||||
kvkNumber: String?,
|
||||
vatID: String?,
|
||||
legalName: String?,
|
||||
returnsNote: String,
|
||||
handlingDays: Int
|
||||
) {
|
||||
self.name = name
|
||||
self.handle = handle
|
||||
self.city = city
|
||||
self.country = country
|
||||
self.about = about
|
||||
self.kind = kind
|
||||
self.kvkNumber = kvkNumber
|
||||
self.vatID = vatID
|
||||
self.legalName = legalName
|
||||
self.returnsNote = returnsNote
|
||||
self.handlingDays = handlingDays
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// A `Sendable` snapshot of an account.
|
||||
public struct User: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The account's identifier.
|
||||
public let id: UUID
|
||||
/// The account's normalized email address.
|
||||
public let email: String
|
||||
/// The name other people see.
|
||||
public let displayName: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
email: String,
|
||||
displayName: String
|
||||
) {
|
||||
self.id = id
|
||||
self.email = email
|
||||
self.displayName = displayName
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
/// A `Sendable` snapshot of a copy an account is looking for, with the listings that meet it.
|
||||
public struct Want: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The want's identifier.
|
||||
public let id: UUID
|
||||
/// What is wanted: words that must each appear in a listing's title, e.g. `Nightward #7`.
|
||||
public let title: String
|
||||
/// The lowest grade wanted, or `nil` for any.
|
||||
public let minGrade: Double?
|
||||
/// The most the account would pay, in euro cents, or `nil` for any price.
|
||||
public let maxPriceCents: Int?
|
||||
/// Whether only slabbed copies are wanted.
|
||||
public let slabbedOnly: Bool
|
||||
/// Whether new matches alert the account.
|
||||
public let alerts: Bool
|
||||
/// The listings for sale that meet the want, newest first. Wants match within the EU alone.
|
||||
public let matches: [Listing]
|
||||
/// The number of matches listed since the account last looked at its wants.
|
||||
public let newMatchCount: Int
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
title: String,
|
||||
minGrade: Double?,
|
||||
maxPriceCents: Int?,
|
||||
slabbedOnly: Bool,
|
||||
alerts: Bool,
|
||||
matches: [Listing],
|
||||
newMatchCount: Int
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.minGrade = minGrade
|
||||
self.maxPriceCents = maxPriceCents
|
||||
self.slabbedOnly = slabbedOnly
|
||||
self.alerts = alerts
|
||||
self.matches = matches
|
||||
self.newMatchCount = newMatchCount
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
public extension Want {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The filter finding the want's matches: the title's words, within the limits set, shipping from the EU.
|
||||
var filter: ListingFilter {
|
||||
Self.filter(title: title, minGrade: minGrade, maxPriceCents: maxPriceCents, slabbedOnly: slabbedOnly)
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The filter finding a want's matches.
|
||||
static func filter(
|
||||
title: String,
|
||||
minGrade: Double?,
|
||||
maxPriceCents: Int?,
|
||||
slabbedOnly: Bool
|
||||
) -> ListingFilter {
|
||||
.init(
|
||||
query: title.replacingOccurrences(of: "·", with: " "),
|
||||
isSlabbed: slabbedOnly ? true : nil,
|
||||
gradeMin: minGrade,
|
||||
priceMax: maxPriceCents,
|
||||
shipsFrom: .eu
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
import HummingbirdFluent
|
||||
|
||||
/// Reads and creates accounts through the default database.
|
||||
public struct AccountRepository: Sendable {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The email of the demo buyer the prototype's stubbed passkey signs in.
|
||||
public static let demoEmail = Seed.accounts[0].email
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let fluent: Fluent
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a repository backed by the given `Fluent` service.
|
||||
/// - Parameter fluent: the service whose default database the repository operates on.
|
||||
public init(
|
||||
fluent: Fluent
|
||||
) {
|
||||
self.fluent = fluent
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
/// Adds a passkey to an account. Only ever for the account's own session: holding one is the proof of who is asking.
|
||||
public func addPasskey(
|
||||
to user: User,
|
||||
credentialID: String,
|
||||
publicKey: [UInt8],
|
||||
signCount: UInt32
|
||||
) async throws {
|
||||
try await PasskeyRecord(
|
||||
userID: user.id,
|
||||
credentialID: credentialID,
|
||||
publicKey: publicKey,
|
||||
signCount: signCount,
|
||||
at: .init()
|
||||
).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
|
||||
) async throws -> [(passkey: Passkey, createdAt: Date)] {
|
||||
try await PasskeyRecord.query(on: fluent.db())
|
||||
.filter(\.$user.$id == user.id)
|
||||
.sort(\.$createdAt)
|
||||
.with(\.$user)
|
||||
.all()
|
||||
.map { (try $0.snapshot, $0.createdAt) }
|
||||
}
|
||||
|
||||
/// Changes an account's display name.
|
||||
/// - Returns: the account, renamed.
|
||||
public func rename(
|
||||
_ user: User,
|
||||
to displayName: String
|
||||
) async throws -> User {
|
||||
try await UserRecord.query(on: fluent.db())
|
||||
.filter(\.$id == user.id)
|
||||
.set(\.$displayName, to: displayName)
|
||||
.update()
|
||||
|
||||
return .init(
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: displayName
|
||||
)
|
||||
}
|
||||
|
||||
/// Revokes one of an account's passkeys, taking effect at once.
|
||||
public func revokePasskey(
|
||||
_ credentialID: String,
|
||||
of user: User
|
||||
) async throws {
|
||||
try await PasskeyRecord.query(on: fluent.db())
|
||||
.filter(\.$credentialID == credentialID)
|
||||
.filter(\.$user.$id == user.id)
|
||||
.delete()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// - Parameter email: the account's normalized email address.
|
||||
/// - Returns: the account, existing or new.
|
||||
public func account(
|
||||
email: String
|
||||
) async throws -> User {
|
||||
try await signIn(email: email).user
|
||||
}
|
||||
|
||||
/// Fetches the account with the given email, creating it when there is none, and says which happened.
|
||||
/// - Returns: the account, and whether this step created it.
|
||||
public func signIn(
|
||||
email: String
|
||||
) async throws -> (user: User, isNew: Bool) {
|
||||
if let user = try await existingAccount(email: email) {
|
||||
return (user, false)
|
||||
}
|
||||
|
||||
let record = UserRecord(
|
||||
email: email,
|
||||
displayName: String(email.prefix { $0 != "@" })
|
||||
)
|
||||
|
||||
try await record.save(on: fluent.db())
|
||||
|
||||
return (try record.snapshot, true)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
import HummingbirdFluent
|
||||
|
||||
/// Reads the shops and their listings through the default database.
|
||||
public struct CatalogueRepository: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let fluent: Fluent
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a repository backed by the given `Fluent` service.
|
||||
/// - Parameter fluent: the service whose default database the repository operates on.
|
||||
public init(
|
||||
fluent: Fluent
|
||||
) {
|
||||
self.fluent = fluent
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Fetches the listing with the given slug.
|
||||
/// - Parameter slug: the listing's URL slug.
|
||||
/// - Returns: the listing, or `nil` when none has the slug.
|
||||
public func listing(
|
||||
slug: String
|
||||
) async throws -> Listing? {
|
||||
try await ListingRecord.query(on: fluent.db())
|
||||
.filter(\.$slug == slug)
|
||||
.with(\.$shop)
|
||||
.first()?
|
||||
.snapshot
|
||||
}
|
||||
|
||||
/// Fetches the listings meeting the filter, in its order.
|
||||
///
|
||||
/// The prototype filters in memory, which its few dozen seeded listings allow; the criteria move into the query with real data.
|
||||
/// - Parameter filter: the criteria the listings are narrowed and ordered by.
|
||||
/// - Returns: every matching listing.
|
||||
public func listings(
|
||||
matching filter: ListingFilter = .init()
|
||||
) async throws -> [Listing] {
|
||||
try await ListingRecord.query(on: fluent.db())
|
||||
.with(\.$shop)
|
||||
.all()
|
||||
.map(\.snapshot)
|
||||
.filter(filter.matches)
|
||||
.sorted(by: filter.ordersBefore)
|
||||
}
|
||||
|
||||
/// Opens a shop for an account: unverified and unrated, public from this moment.
|
||||
/// - Throws: a ``ShopError`` when the account already owns a shop, or the handle is taken.
|
||||
public func openShop(
|
||||
_ draft: ShopDraft,
|
||||
for owner: User,
|
||||
at date: Date = .init()
|
||||
) async throws -> Shop {
|
||||
try await fluent.db().transaction { database in
|
||||
guard try await ShopRecord.query(on: database)
|
||||
.filter(\.$owner.$id == owner.id)
|
||||
.first() == nil
|
||||
else {
|
||||
throw ShopError.alreadyOwner
|
||||
}
|
||||
|
||||
guard try await ShopRecord.query(on: database)
|
||||
.filter(\.$slug == draft.handle)
|
||||
.first() == nil
|
||||
else {
|
||||
throw ShopError.handleTaken
|
||||
}
|
||||
|
||||
let shop = Shop(
|
||||
slug: draft.handle,
|
||||
name: draft.name,
|
||||
city: draft.city,
|
||||
country: draft.country,
|
||||
sinceYear: Calendar(identifier: .gregorian).component(.year, from: date),
|
||||
isVerified: false,
|
||||
rating: 0,
|
||||
ratedSales: 0,
|
||||
soldCount: 0,
|
||||
handlingDays: draft.handlingDays,
|
||||
shipsNote: "ships tracked",
|
||||
returnsNote: draft.returnsNote,
|
||||
about: draft.about,
|
||||
kind: draft.kind,
|
||||
kvkNumber: draft.kind == .business ? draft.kvkNumber : nil,
|
||||
vatID: draft.kind == .business ? draft.vatID : nil,
|
||||
legalName: draft.kind == .business ? draft.legalName : nil
|
||||
)
|
||||
let record = ShopRecord(shop)
|
||||
|
||||
record.$owner.id = owner.id
|
||||
|
||||
try await record.save(on: database)
|
||||
|
||||
return shop
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())
|
||||
.unique()
|
||||
.sort(\.$publisher)
|
||||
.all(\.$publisher)
|
||||
}
|
||||
|
||||
/// Fetches the shop with the given slug.
|
||||
/// - Parameter slug: the shop's URL slug.
|
||||
/// - Returns: the shop, or `nil` when none has the slug.
|
||||
public func shop(
|
||||
slug: String
|
||||
) async throws -> Shop? {
|
||||
try await ShopRecord.query(on: fluent.db())
|
||||
.filter(\.$slug == slug)
|
||||
.first()?
|
||||
.snapshot
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
import FluentKit
|
||||
import Foundation
|
||||
import HummingbirdFluent
|
||||
|
||||
/// Reads and writes what leads to a sale: conversations, offers, and the sale records with their handshake.
|
||||
///
|
||||
/// Every write checks that the account may make it, and throws a ``TradeError`` when not. A write touching several rows runs in a transaction.
|
||||
public struct TradeRepository: Sendable {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The number before the first sale record's: the prototype's records count up from `CCN-48213`.
|
||||
private static let firstNumber = 48_212
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let fluent: Fluent
|
||||
|
||||
/// The clock entries are stamped and expiries 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 entries are stamped and expiries judged by; the system's by default.
|
||||
public init(
|
||||
fluent: Fluent,
|
||||
now: @escaping @Sendable () -> Date = { .init() }
|
||||
) {
|
||||
self.fluent = fluent
|
||||
self.now = now
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Conversations
|
||||
|
||||
public extension TradeRepository {
|
||||
|
||||
/// Fetches a conversation as one of its parties sees it, marking it read by them.
|
||||
/// - Returns: the conversation, or `nil` when there is none the account is a party to.
|
||||
func conversation(
|
||||
id: UUID,
|
||||
for user: User
|
||||
) async throws -> Conversation? {
|
||||
guard
|
||||
let record = try await conversations(on: fluent.db())
|
||||
.filter(\.$id == id)
|
||||
.first(),
|
||||
let side = side(of: user.id, in: record)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let conversation = try snapshot(of: record, as: side)
|
||||
|
||||
if side == .buying {
|
||||
record.buyerReadAt = now()
|
||||
} else {
|
||||
record.sellerReadAt = now()
|
||||
}
|
||||
|
||||
try await record.save(on: fluent.db())
|
||||
|
||||
return conversation
|
||||
}
|
||||
|
||||
/// Fetches every conversation an account is a party to, the latest spoken in first.
|
||||
func conversations(
|
||||
for user: User
|
||||
) async throws -> [Conversation] {
|
||||
try await conversations(on: fluent.db())
|
||||
.all()
|
||||
.compactMap { record in
|
||||
try side(of: user.id, in: record).map { try snapshot(of: record, as: $0) }
|
||||
}
|
||||
.sorted { ($0.latest?.createdAt ?? .distantPast) > ($1.latest?.createdAt ?? .distantPast) }
|
||||
}
|
||||
|
||||
/// Finds the buyer's conversation about a listing, starting it when there is none.
|
||||
/// - Returns: the conversation's identifier.
|
||||
func conversation(
|
||||
aboutListing slug: String,
|
||||
buyer: User
|
||||
) async throws -> UUID {
|
||||
try await fluent.db().transaction { database in
|
||||
let listing = try await listing(slug: slug, on: database)
|
||||
|
||||
return try await conversation(
|
||||
between: buyer,
|
||||
and: listing.shop,
|
||||
about: listing,
|
||||
on: database
|
||||
).requireID()
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the buyer's conversation with a shop at large, starting it when there is none.
|
||||
/// - Returns: the conversation's identifier.
|
||||
func conversation(
|
||||
withShop slug: String,
|
||||
buyer: User
|
||||
) async throws -> UUID {
|
||||
try await fluent.db().transaction { database in
|
||||
guard let shop = try await ShopRecord.query(on: database)
|
||||
.filter(\.$slug == slug)
|
||||
.with(\.$owner)
|
||||
.first()
|
||||
else {
|
||||
throw TradeError.listingUnavailable
|
||||
}
|
||||
|
||||
return try await conversation(
|
||||
between: buyer,
|
||||
and: shop,
|
||||
about: nil,
|
||||
on: database
|
||||
).requireID()
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a message to a conversation; one that is blank is dropped.
|
||||
func post(
|
||||
_ body: String,
|
||||
to id: UUID,
|
||||
from user: User
|
||||
) async throws {
|
||||
let body = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard
|
||||
let record = try await conversations(on: fluent.db())
|
||||
.filter(\.$id == id)
|
||||
.first(),
|
||||
side(of: user.id, in: record) != nil
|
||||
else {
|
||||
throw TradeError.notAllowed
|
||||
}
|
||||
|
||||
guard !body.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
try await MessageRecord(
|
||||
conversationID: id,
|
||||
senderID: user.id,
|
||||
kind: .text,
|
||||
body: String(body.prefix(2_000)),
|
||||
at: now()
|
||||
).save(on: fluent.db())
|
||||
}
|
||||
|
||||
/// The number of conversations holding something an account has not seen yet.
|
||||
func unreadCount(
|
||||
for user: User
|
||||
) async throws -> Int {
|
||||
try await conversations(for: user)
|
||||
.count { $0.unreadCount > 0 }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Offers
|
||||
|
||||
public extension TradeRepository {
|
||||
|
||||
/// Makes a buyer's offer on a listing, in their conversation about it.
|
||||
///
|
||||
/// The offer binds the buyer for ``Offer/lifetime``. One under the listing's floor is turned down outright, and a buyer holds one open
|
||||
/// offer per listing.
|
||||
/// - Returns: the identifier of the conversation the offer was made in.
|
||||
func makeOffer(
|
||||
onListing slug: String,
|
||||
buyer: User,
|
||||
amountCents: Int,
|
||||
note: String
|
||||
) async throws -> UUID {
|
||||
try await fluent.db().transaction { database in
|
||||
let listing = try await listing(slug: slug, on: database)
|
||||
|
||||
guard listing.acceptsOffers else {
|
||||
throw TradeError.offersNotAccepted
|
||||
}
|
||||
|
||||
guard amountCents > 0, amountCents <= listing.priceCents else {
|
||||
throw TradeError.invalidAmount
|
||||
}
|
||||
|
||||
let floor = Offer.floor(forAsking: listing.priceCents)
|
||||
|
||||
guard amountCents >= floor else {
|
||||
throw TradeError.offerTooLow(floorCents: floor)
|
||||
}
|
||||
|
||||
let listingID = try listing.requireID()
|
||||
let isOpen = try await OfferRecord.query(on: database)
|
||||
.filter(\.$listing.$id == listingID)
|
||||
.filter(\.$buyer.$id == buyer.id)
|
||||
.filter(\.$status == Offer.Status.open.rawValue)
|
||||
.filter(\.$expiresAt > now())
|
||||
.first() != nil
|
||||
|
||||
guard !isOpen else {
|
||||
throw TradeError.offerAlreadyOpen
|
||||
}
|
||||
|
||||
let conversationID = try await conversation(
|
||||
between: buyer,
|
||||
and: listing.shop,
|
||||
about: listing,
|
||||
on: database
|
||||
).requireID()
|
||||
let offer = OfferRecord(
|
||||
conversationID: conversationID,
|
||||
listingID: listingID,
|
||||
buyerID: buyer.id,
|
||||
amountCents: amountCents,
|
||||
at: now()
|
||||
)
|
||||
|
||||
try await offer.save(on: database)
|
||||
try await MessageRecord(
|
||||
conversationID: conversationID,
|
||||
senderID: buyer.id,
|
||||
kind: .offer,
|
||||
body: String(note.trimmingCharacters(in: .whitespacesAndNewlines).prefix(500)),
|
||||
offerID: try offer.requireID(),
|
||||
at: now()
|
||||
).save(on: database)
|
||||
|
||||
return conversationID
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the offers waiting for a seller's answer, the soonest to expire first.
|
||||
func openOffers(
|
||||
for seller: User
|
||||
) async throws -> [Offer] {
|
||||
try await OfferRecord.query(on: fluent.db())
|
||||
.filter(\.$status == Offer.Status.open.rawValue)
|
||||
.filter(\.$expiresAt > now())
|
||||
.sort(\.$expiresAt)
|
||||
.with(\.$buyer)
|
||||
.with(\.$listing) { $0.with(\.$shop) { $0.with(\.$owner) } }
|
||||
.all()
|
||||
.filter { $0.listing.shop.$owner.id == seller.id }
|
||||
.map { try $0.snapshot(at: now()) }
|
||||
}
|
||||
|
||||
/// Answers an offer on the seller's behalf. Accepting it opens a sale record.
|
||||
/// - Returns: the number of the sale opened, or `nil` when the offer was declined.
|
||||
@discardableResult
|
||||
func respond(
|
||||
toOffer id: UUID,
|
||||
accepting: Bool,
|
||||
by seller: User
|
||||
) async throws -> Int? {
|
||||
try await fluent.db().transaction { database in
|
||||
guard
|
||||
let offer = try await OfferRecord.query(on: database)
|
||||
.filter(\.$id == id)
|
||||
.with(\.$listing, { $0.with(\.$shop) })
|
||||
.first(),
|
||||
offer.listing.shop.$owner.id == seller.id,
|
||||
offer.status == Offer.Status.open.rawValue,
|
||||
offer.expiresAt > now()
|
||||
else {
|
||||
throw TradeError.notAllowed
|
||||
}
|
||||
|
||||
guard accepting else {
|
||||
offer.status = Offer.Status.declined.rawValue
|
||||
|
||||
try await offer.save(on: database)
|
||||
try await MessageRecord(
|
||||
conversationID: offer.$conversation.id,
|
||||
senderID: seller.id,
|
||||
kind: .event,
|
||||
body: "declined the offer",
|
||||
at: now()
|
||||
).save(on: database)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
guard offer.listing.soldAt == nil else {
|
||||
throw TradeError.listingUnavailable
|
||||
}
|
||||
|
||||
offer.status = Offer.Status.accepted.rawValue
|
||||
|
||||
try await offer.save(on: database)
|
||||
|
||||
return try await agree(
|
||||
on: offer.listing,
|
||||
conversationID: offer.$conversation.id,
|
||||
buyerID: offer.$buyer.id,
|
||||
sellerID: seller.id,
|
||||
priceCents: offer.amountCents,
|
||||
event: (seller.id, "accepted the offer"),
|
||||
on: database
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Sales
|
||||
|
||||
public extension TradeRepository {
|
||||
|
||||
/// Buys a listing outright, at its asking price, opening a sale record.
|
||||
/// - Returns: the number of the sale opened.
|
||||
func buy(
|
||||
listing slug: String,
|
||||
buyer: User
|
||||
) async throws -> Int {
|
||||
try await fluent.db().transaction { database in
|
||||
let listing = try await listing(slug: slug, on: database)
|
||||
|
||||
guard let sellerID = listing.shop.$owner.id else {
|
||||
throw TradeError.shopUnattended
|
||||
}
|
||||
|
||||
let conversation = try await conversation(
|
||||
between: buyer,
|
||||
and: listing.shop,
|
||||
about: listing,
|
||||
on: database
|
||||
)
|
||||
|
||||
return try await agree(
|
||||
on: listing,
|
||||
conversationID: try conversation.requireID(),
|
||||
buyerID: buyer.id,
|
||||
sellerID: sellerID,
|
||||
priceCents: listing.priceCents,
|
||||
event: (buyer.id, "bought the copy at the asking price"),
|
||||
on: database
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a step of a sale's handshake, stamping it into the sale's conversation.
|
||||
/// - Returns: the sale after the step.
|
||||
@discardableResult
|
||||
func perform(
|
||||
_ action: Sale.Action,
|
||||
onSale number: Int,
|
||||
by user: User
|
||||
) async throws -> Sale {
|
||||
try await fluent.db().transaction { database in
|
||||
guard
|
||||
let record = try await sales(on: database)
|
||||
.filter(\.$number == number)
|
||||
.first(),
|
||||
let role = try record.snapshot.role(of: user.id),
|
||||
try record.snapshot.allows(action, by: role)
|
||||
else {
|
||||
throw TradeError.notAllowed
|
||||
}
|
||||
|
||||
let event: String
|
||||
|
||||
switch action {
|
||||
case .markPaid(let note):
|
||||
record.paidAt = now()
|
||||
record.paymentNote = Self.note(note)
|
||||
event = "marked the sale paid"
|
||||
case .markSent(let note):
|
||||
record.sentAt = now()
|
||||
record.shippingNote = Self.note(note)
|
||||
event = "marked the copy sent"
|
||||
case .markReceived:
|
||||
record.receivedAt = now()
|
||||
event = "marked the copy received"
|
||||
case .rate(let stars):
|
||||
if role == .buyer {
|
||||
record.buyerRating = stars
|
||||
} else {
|
||||
record.sellerRating = stars
|
||||
}
|
||||
|
||||
event = "left a rating"
|
||||
}
|
||||
|
||||
try await record.save(on: database)
|
||||
try await MessageRecord(
|
||||
conversationID: record.$conversation.id,
|
||||
senderID: user.id,
|
||||
kind: .event,
|
||||
body: event,
|
||||
saleNumber: number,
|
||||
at: now()
|
||||
).save(on: database)
|
||||
|
||||
return try record.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a sale record as one of its parties.
|
||||
/// - Returns: the sale, or `nil` when there is none the account is a party to.
|
||||
func sale(
|
||||
number: Int,
|
||||
for user: User
|
||||
) async throws -> Sale? {
|
||||
try await sales(on: fluent.db())
|
||||
.filter(\.$number == number)
|
||||
.first()
|
||||
.map { try $0.snapshot }
|
||||
.flatMap { $0.role(of: user.id) == nil ? nil : $0 }
|
||||
}
|
||||
|
||||
/// Fetches every sale an account is a party to, the latest agreed first.
|
||||
func sales(
|
||||
for user: User
|
||||
) async throws -> [Sale] {
|
||||
try await sales(on: fluent.db())
|
||||
.group(.or) {
|
||||
$0.filter(\.$buyer.$id == user.id)
|
||||
$0.filter(\.$seller.$id == user.id)
|
||||
}
|
||||
.sort(\.$agreedAt, .descending)
|
||||
.all()
|
||||
.map { try $0.snapshot }
|
||||
}
|
||||
|
||||
/// Fetches the shop an account owns.
|
||||
/// - Returns: the shop, or `nil` when the account owns none.
|
||||
func shop(
|
||||
ownedBy user: User
|
||||
) async throws -> Shop? {
|
||||
try await ShopRecord.query(on: fluent.db())
|
||||
.filter(\.$owner.$id == user.id)
|
||||
.first()?
|
||||
.snapshot
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension TradeRepository {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Opens the sale record of an agreement, takes the listing off sale, lapses the other offers on it, and stamps the event.
|
||||
/// - Returns: the number of the sale opened.
|
||||
func agree(
|
||||
on listing: ListingRecord,
|
||||
conversationID: UUID,
|
||||
buyerID: UUID,
|
||||
sellerID: UUID,
|
||||
priceCents: Int,
|
||||
event: (actor: UUID, body: String),
|
||||
on database: any Database
|
||||
) async throws -> Int {
|
||||
let listingID = try listing.requireID()
|
||||
let number = (try await SaleRecord.query(on: database).max(\.$number) ?? Self.firstNumber) + 1
|
||||
let declared = listing.snapshot
|
||||
let sale = SaleRecord()
|
||||
|
||||
sale.number = number
|
||||
sale.$conversation.id = conversationID
|
||||
sale.$listing.id = listingID
|
||||
sale.$buyer.id = buyerID
|
||||
sale.$seller.id = sellerID
|
||||
sale.priceCents = priceCents
|
||||
sale.askedCents = listing.priceCents
|
||||
sale.shippingCents = listing.shippingCents
|
||||
sale.declaredGrade = declared.gradeLabel + (declared.certNumber.map { " · cert \($0)" } ?? "")
|
||||
sale.declaredPageQuality = declared.pageQuality
|
||||
sale.declaredPrinting = declared.printingDetail
|
||||
sale.declaredNotes = declared.declaration
|
||||
sale.agreedAt = now()
|
||||
|
||||
listing.soldAt = now()
|
||||
|
||||
try await sale.save(on: database)
|
||||
try await listing.save(on: database)
|
||||
try await OfferRecord.query(on: database)
|
||||
.filter(\.$listing.$id == listingID)
|
||||
.filter(\.$status == Offer.Status.open.rawValue)
|
||||
.set(\.$status, to: Offer.Status.expired.rawValue)
|
||||
.update()
|
||||
try await MessageRecord(
|
||||
conversationID: conversationID,
|
||||
senderID: event.actor,
|
||||
kind: .event,
|
||||
body: event.body,
|
||||
saleNumber: number,
|
||||
at: now()
|
||||
).save(on: database)
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
/// Finds the conversation between a buyer and a shop about a listing — or about the shop at large — starting it when there is none.
|
||||
func conversation(
|
||||
between buyer: User,
|
||||
and shop: ShopRecord,
|
||||
about listing: ListingRecord?,
|
||||
on database: any Database
|
||||
) async throws -> ConversationRecord {
|
||||
guard let ownerID = shop.$owner.id else {
|
||||
throw TradeError.shopUnattended
|
||||
}
|
||||
|
||||
guard ownerID != buyer.id else {
|
||||
throw TradeError.ownListing
|
||||
}
|
||||
|
||||
let shopID = try shop.requireID()
|
||||
let listingID = try listing?.requireID()
|
||||
let existing = try await ConversationRecord.query(on: database)
|
||||
.filter(\.$buyer.$id == buyer.id)
|
||||
.filter(\.$shop.$id == shopID)
|
||||
.all()
|
||||
.first { $0.$listing.id == listingID }
|
||||
|
||||
if let existing {
|
||||
return existing
|
||||
}
|
||||
|
||||
let conversation = ConversationRecord(
|
||||
buyerID: buyer.id,
|
||||
shopID: shopID,
|
||||
listingID: listingID,
|
||||
at: now()
|
||||
)
|
||||
|
||||
try await conversation.save(on: database)
|
||||
|
||||
return conversation
|
||||
}
|
||||
|
||||
/// The conversations query, with everything a snapshot reads eager-loaded.
|
||||
func conversations(
|
||||
on database: any Database
|
||||
) -> QueryBuilder<ConversationRecord> {
|
||||
ConversationRecord.query(on: database)
|
||||
.with(\.$buyer)
|
||||
.with(\.$shop)
|
||||
.with(\.$listing) { $0.with(\.$shop) }
|
||||
.with(\.$sales)
|
||||
.with(\.$messages) { message in
|
||||
message
|
||||
.with(\.$sender)
|
||||
.with(\.$offer) { offer in
|
||||
offer
|
||||
.with(\.$buyer)
|
||||
.with(\.$listing) { $0.with(\.$shop) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a listing still for sale, with its shop.
|
||||
func listing(
|
||||
slug: String,
|
||||
on database: any Database
|
||||
) async throws -> ListingRecord {
|
||||
guard
|
||||
let listing = try await ListingRecord.query(on: database)
|
||||
.filter(\.$slug == slug)
|
||||
.with(\.$shop)
|
||||
.first(),
|
||||
listing.soldAt == nil
|
||||
else {
|
||||
throw TradeError.listingUnavailable
|
||||
}
|
||||
|
||||
return listing
|
||||
}
|
||||
|
||||
/// A party's note, trimmed and capped, or `nil` when blank.
|
||||
static func note(
|
||||
_ note: String
|
||||
) -> String? {
|
||||
let note = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
return note.isEmpty ? nil : String(note.prefix(200))
|
||||
}
|
||||
|
||||
/// The sales query, with everything a snapshot reads eager-loaded.
|
||||
func sales(
|
||||
on database: any Database
|
||||
) -> QueryBuilder<SaleRecord> {
|
||||
SaleRecord.query(on: database)
|
||||
.with(\.$buyer)
|
||||
.with(\.$seller)
|
||||
.with(\.$listing) { $0.with(\.$shop) }
|
||||
}
|
||||
|
||||
/// The side of a conversation an account is on, or `nil` when it is a party to neither.
|
||||
func side(
|
||||
of userID: UUID,
|
||||
in record: ConversationRecord
|
||||
) -> Conversation.Side? {
|
||||
if record.$buyer.id == userID {
|
||||
return .buying
|
||||
}
|
||||
|
||||
return record.shop.$owner.id == userID ? .selling : nil
|
||||
}
|
||||
|
||||
/// The snapshot of a conversation, as the given side sees it.
|
||||
func snapshot(
|
||||
of record: ConversationRecord,
|
||||
as side: Conversation.Side
|
||||
) throws -> Conversation {
|
||||
let viewerID = side == .buying ? record.$buyer.id : record.shop.$owner.id
|
||||
let readAt = side == .buying ? record.buyerReadAt : record.sellerReadAt
|
||||
let messages = record.messages.sorted { $0.createdAt < $1.createdAt }
|
||||
|
||||
return .init(
|
||||
id: try record.requireID(),
|
||||
buyer: try record.buyer.snapshot,
|
||||
shop: record.shop.snapshot,
|
||||
listing: record.listing?.snapshot,
|
||||
side: side,
|
||||
entries: try messages.map { message in
|
||||
.init(
|
||||
kind: .init(rawValue: message.kind) ?? .text,
|
||||
sender: try message.sender.snapshot,
|
||||
body: message.body,
|
||||
offer: try message.offer?.snapshot(at: now()),
|
||||
saleNumber: message.saleNumber,
|
||||
createdAt: message.createdAt
|
||||
)
|
||||
},
|
||||
unreadCount: messages.count { $0.createdAt > readAt && $0.$sender.id != viewerID },
|
||||
saleNumber: record.sales.first?.number
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,14 +19,18 @@ struct TLSTests {
|
||||
// With TLS disabled the client skips the `SSLRequest` and sends its startup message directly,
|
||||
// which the fake server answers in plaintext.
|
||||
let server = try await PlaintextPostgresServer.start()
|
||||
|
||||
defer {
|
||||
try? await server.stop()
|
||||
}
|
||||
|
||||
let connection = try await connect(to: server, tls: .off)
|
||||
|
||||
let isConnected = !connection.isClosed
|
||||
defer {
|
||||
try? await connection.close()
|
||||
}
|
||||
|
||||
try await connection.close()
|
||||
try await server.stop()
|
||||
|
||||
#expect(isConnected)
|
||||
#expect(!connection.isClosed)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -34,14 +38,18 @@ struct TLSTests {
|
||||
// The fake server refuses the `SSLRequest`, so this connection can only succeed by downgrading
|
||||
// to plaintext — pinning the driver behavior the `prefer` posture relies on.
|
||||
let server = try await PlaintextPostgresServer.start()
|
||||
|
||||
defer {
|
||||
try? await server.stop()
|
||||
}
|
||||
|
||||
let connection = try await connect(to: server, tls: .prefer)
|
||||
|
||||
let isConnected = !connection.isClosed
|
||||
defer {
|
||||
try? await connection.close()
|
||||
}
|
||||
|
||||
try await connection.close()
|
||||
try await server.stop()
|
||||
|
||||
#expect(isConnected)
|
||||
#expect(!connection.isClosed)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -64,6 +64,11 @@ struct ProbeTests {
|
||||
// ever be resolved by its deadline — without one, it would wait out the driver's own connect
|
||||
// timeout (10 seconds) instead.
|
||||
let server = try await SilentPostgresServer.start()
|
||||
|
||||
defer {
|
||||
try? await server.stop()
|
||||
}
|
||||
|
||||
let service = try Service(
|
||||
driver: .postgres(
|
||||
.init(
|
||||
@@ -91,7 +96,6 @@ struct ProbeTests {
|
||||
let elapsed = clock.now - start
|
||||
|
||||
try await fluent.shutdown()
|
||||
try await server.stop()
|
||||
|
||||
#expect(!isReachable)
|
||||
// Well past the 100-millisecond deadline to absorb scheduling noise, yet far below the driver's
|
||||
|
||||
@@ -69,26 +69,22 @@ struct ServiceTests {
|
||||
|
||||
let fluent = service()
|
||||
|
||||
do {
|
||||
let database = try #require(fluent.db() as? any SQLDatabase)
|
||||
|
||||
try await database.raw("SELECT 1").run()
|
||||
} catch {
|
||||
defer {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
let database = try #require(fluent.db() as? any SQLDatabase)
|
||||
|
||||
try await database.raw("SELECT 1").run()
|
||||
}
|
||||
|
||||
@Test("in-memory: migrate, insert, read back")
|
||||
@Test("in-memory: migrate, seed, read back")
|
||||
func inMemoryRoundTrip() async throws {
|
||||
try await roundTrip(driver: .inMemory)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"postgres: migrate, insert, read back",
|
||||
"postgres: migrate, seed, read back",
|
||||
.enabled(if: postgresDriver != nil)
|
||||
)
|
||||
func postgresRoundTrip() async throws {
|
||||
@@ -104,7 +100,7 @@ struct ServiceTests {
|
||||
|
||||
private extension ServiceTests {
|
||||
|
||||
/// Migrates, inserts, and reads back a record against the given driver, shutting the pool down after.
|
||||
/// Migrates, seeds, and reads the catalogue back against the given driver, shutting the pool down after.
|
||||
///
|
||||
/// The `Fluent` service normally owns pool shutdown via its `run()` in the service group; outside that,
|
||||
/// the test must shut it down explicitly — even on failure — or the pool asserts on `deinit`.
|
||||
@@ -124,26 +120,21 @@ private extension ServiceTests {
|
||||
|
||||
let fluent = service()
|
||||
|
||||
do {
|
||||
await prepareDB(for: fluent)
|
||||
|
||||
try await fluent.migrate()
|
||||
|
||||
let repository = ExampleRepository(fluent: fluent)
|
||||
let created = try await repository.create(name: "site")
|
||||
|
||||
#expect(try await repository.all().contains(created))
|
||||
|
||||
if revertAfter {
|
||||
try await fluent.revert()
|
||||
}
|
||||
} catch {
|
||||
defer {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
await prepareDB(for: fluent)
|
||||
|
||||
try await fluent.migrate()
|
||||
|
||||
let repository = CatalogueRepository(fluent: fluent)
|
||||
|
||||
#expect(try await !repository.listings().isEmpty)
|
||||
|
||||
if revertAfter {
|
||||
try await fluent.revert()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Persistence
|
||||
|
||||
@Suite(
|
||||
"ListingDraft model",
|
||||
.tags(.model)
|
||||
)
|
||||
struct ListingDraftTests {
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test
|
||||
func `titles a copy by its format`() {
|
||||
#expect(Self.draft().title == "Nightward #3")
|
||||
#expect(Self.draft(format: "manga").title == "Nightward, Vol. 3")
|
||||
#expect(Self.draft(issue: "", format: "hardcovers").title == "Nightward")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `words the printing short and in full`() {
|
||||
#expect(Self.draft().printingLabel == "1st print")
|
||||
#expect(Self.draft().printingDetail == "First printing, direct edition")
|
||||
#expect(Self.draft(format: "manga", printing: 2, edition: "").printingLabel == "2nd printing")
|
||||
#expect(Self.draft(printing: 0, edition: "").printingDetail == "Later printing")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `letters the placeholder cover over a few lines, toned by the series`() {
|
||||
let cover = Self.draft(series: "The Gilded Hour of Saints", format: "trade-paperbacks").cover
|
||||
|
||||
#expect(cover.title == "THE GILDED\nHOUR OF\nSAINTS")
|
||||
#expect(cover.number == "TPB")
|
||||
#expect(cover.imprint == "MERIDIAN COMICS")
|
||||
#expect(ListingDraft.tones.contains(cover.tone))
|
||||
#expect(Self.draft(issue: "1").cover.tone == Self.draft(issue: "2").cover.tone)
|
||||
#expect(Self.draft(format: "manga").cover.number == "VOL 3")
|
||||
}
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `publishes as a listing carrying what was declared`() {
|
||||
let listing = Self.draft().listing(slug: "nightward-3-raw-nm", shop: Seed.shops[0], at: .init(timeIntervalSince1970: 0))
|
||||
|
||||
#expect(listing.seriesTitle == "Nightward (2019 series)")
|
||||
#expect(listing.issueLabel == "#3")
|
||||
#expect(listing.isFirstPrint)
|
||||
#expect(listing.gradeLabel == "Raw · NM")
|
||||
#expect(listing.defects == ["Spine stress / ticks"])
|
||||
#expect(listing.photos == ["/uploads/a.jpg"])
|
||||
#expect(listing.declaration == "Declared defects: Spine stress / ticks. Two ticks near the top staple.")
|
||||
#expect(listing.isAvailable)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
["Nightward #1 CGC 9.8", "Hollow Comet, Vol. 4 — Sōkai", " Raw · NM+ ", "???"],
|
||||
["nightward-1-cgc-9-8", "hollow-comet-vol-4-sokai", "raw-nm", ""]
|
||||
))
|
||||
func `slugs a title down to letters, digits, and hyphens`(
|
||||
_ title: String,
|
||||
expects slug: String
|
||||
) {
|
||||
#expect(ListingDraft.slug(title) == slug)
|
||||
}
|
||||
|
||||
// MARK: Repository tests
|
||||
|
||||
@Test
|
||||
func `publishes into the seller's shop, under a slug of its own, and into the search`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let catalogue = CatalogueRepository(fluent: fluent)
|
||||
let joost = try await AccountRepository(fluent: fluent).account(email: "joost@kestrelrow.example")
|
||||
let first = try #require(try await catalogue.publish(Self.draft(), by: joost))
|
||||
let second = try #require(try await catalogue.publish(Self.draft(), by: joost))
|
||||
let found = try await catalogue.listing(slug: first.slug)
|
||||
let newest = try await catalogue.listings().first
|
||||
|
||||
#expect(first.slug == "nightward-3-raw-nm")
|
||||
#expect(second.slug == "nightward-3-raw-nm-2")
|
||||
#expect(first.shop.slug == "kestrel-row-comics")
|
||||
#expect(found?.defects == ["Spine stress / ticks"])
|
||||
#expect(found?.photos == ["/uploads/a.jpg"])
|
||||
#expect(newest?.slug == second.slug)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `publishes nothing for an account without a shop`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let mara = try await AccountRepository(fluent: fluent).account(email: "mara.v@example.com")
|
||||
let listing = try await CatalogueRepository(fluent: fluent).publish(Self.draft(), by: mara)
|
||||
|
||||
#expect(listing == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `freezes the declared defects into a sale`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let joost = try await accounts.account(email: "joost@kestrelrow.example")
|
||||
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||
let listing = try #require(try await CatalogueRepository(fluent: fluent).publish(Self.draft(), by: joost))
|
||||
let trade = TradeRepository(fluent: fluent)
|
||||
let number = try await trade.buy(listing: listing.slug, buyer: mara)
|
||||
let sale = try await trade.sale(number: number, for: mara)
|
||||
|
||||
#expect(sale?.declared.notes == "Declared defects: Spine stress / ticks. Two ticks near the top staple.")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
extension ListingDraftTests {
|
||||
|
||||
/// A draft of a raw Nightward #3 with one declared defect and one photo, unless told otherwise.
|
||||
static func draft(
|
||||
series: String = "Nightward",
|
||||
issue: String = "3",
|
||||
format: String = "single-issues",
|
||||
printing: Int = 1,
|
||||
edition: String = "Direct"
|
||||
) -> ListingDraft {
|
||||
.init(
|
||||
series: series, issue: issue, publisher: "Meridian Comics", year: 2019, format: format,
|
||||
printing: printing, edition: edition, grader: nil, grade: 9.4, condition: "NM", certNumber: nil,
|
||||
pageQuality: nil, keyNotes: nil, defects: ["Spine stress / ticks"],
|
||||
sellerNotes: "Two ticks near the top staple.", priceCents: 3_000, shippingCents: 500,
|
||||
acceptsOffers: true, photos: ["/uploads/a.jpg"]
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Testing
|
||||
|
||||
@testable import Persistence
|
||||
|
||||
@Suite(
|
||||
"ListingFilter model",
|
||||
.tags(.model)
|
||||
)
|
||||
struct ListingFilterTests {
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `matches every listing by default`() {
|
||||
#expect(slugs(.init()).count == Seed.listings.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `matches every word of the query across title, publisher, and shop`() {
|
||||
#expect(slugs(.init(query: "NIGHTWARD kestrel")) == [
|
||||
"nightward-1-cgc-9-8", "nightward-the-first-year-hc", "nightward-2-cgc-9-6",
|
||||
])
|
||||
#expect(slugs(.init(query: "no such book")).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `matches the given formats`() {
|
||||
#expect(slugs(.init(formats: ["manga"])) == ["hollow-comet-vol-4", "solar-divide-vol-1", "hollow-comet-vol-1"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `matches slabbed or raw copies`() {
|
||||
#expect(slugs(.init(isSlabbed: true)).count == 5)
|
||||
#expect(slugs(.init(formats: ["single-issues"], isSlabbed: false)).count == 8)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `leaves ungraded listings out of a grade range`() {
|
||||
#expect(slugs(.init(gradeMin: 9.6)) == ["nightward-1-cgc-9-8", "paper-tigers-1-variant-b", "nightward-2-cgc-9-6"])
|
||||
#expect(slugs(.init(gradeMax: 7)) == ["vermilion-lake-7-cgc-6-5"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `matches a price range in cents`() {
|
||||
#expect(slugs(.init(priceMin: 10_000, priceMax: 20_000)) == [
|
||||
"nightward-1-cgc-9-4", "nightward-1-raw-nm", "ferrous-saint-1-cbcs-9-0",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `matches where a listing ships from`() {
|
||||
#expect(slugs(.init(shipsFrom: .nl)).count == 9)
|
||||
#expect(slugs(.init(shipsFrom: .eu)).count == Seed.listings.count - 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `matches the remaining flags`() {
|
||||
#expect(slugs(.init(verifiedShops: true)).count == Seed.listings.count - 3)
|
||||
#expect(slugs(.init(keysOnly: true)).count == 8)
|
||||
#expect(slugs(.init(firstPrintOnly: true)).count == Seed.listings.count - 4)
|
||||
#expect(slugs(.init(acceptsOffers: true)).count == 7)
|
||||
#expect(slugs(.init(publishers: ["Cutwater Press"])) == ["paper-tigers-1-variant-b", "paper-tigers-2"])
|
||||
#expect(slugs(.init(shop: "silverfish-comics")).count == 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `orders ungraded listings last by grade`() {
|
||||
let grades = Seed.listings
|
||||
.sorted(by: ListingFilter(sort: .gradeDescending).ordersBefore)
|
||||
.map(\.grade)
|
||||
|
||||
#expect(grades.first == 9.8)
|
||||
#expect(grades.suffix(6).allSatisfy { $0 == nil })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ListingFilterTests {
|
||||
|
||||
/// The slugs of the demo listings the filter matches, in seed order.
|
||||
func slugs(
|
||||
_ filter: ListingFilter
|
||||
) -> [String] {
|
||||
Seed.listings
|
||||
.filter(filter.matches)
|
||||
.map(\.slug)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Persistence
|
||||
|
||||
@Suite(
|
||||
"Sale model",
|
||||
.tags(.model)
|
||||
)
|
||||
struct SaleTests {
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test
|
||||
func `walks the handshake one step at a time`() {
|
||||
#expect(sale().completed == .agreed)
|
||||
#expect(sale().next == .paid)
|
||||
#expect(sale(paid: true).completed == .paid)
|
||||
#expect(sale(paid: true, sent: true).completed == .sent)
|
||||
#expect(sale(paid: true, sent: true, received: true).completed == .received)
|
||||
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5).completed == .received)
|
||||
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4).completed == .rated)
|
||||
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4).next == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `adds the shipping to the price`() {
|
||||
#expect(sale().totalCents == 22_100)
|
||||
}
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `gives each step to its party, once, in order`() {
|
||||
#expect(sale().allows(.markPaid(note: ""), by: .buyer))
|
||||
#expect(!sale().allows(.markPaid(note: ""), by: .seller))
|
||||
#expect(!sale(paid: true).allows(.markPaid(note: ""), by: .buyer))
|
||||
|
||||
#expect(!sale().allows(.markSent(note: ""), by: .seller))
|
||||
#expect(sale(paid: true).allows(.markSent(note: ""), by: .seller))
|
||||
#expect(!sale(paid: true).allows(.markSent(note: ""), by: .buyer))
|
||||
|
||||
#expect(!sale(paid: true).allows(.markReceived, by: .buyer))
|
||||
#expect(sale(paid: true, sent: true).allows(.markReceived, by: .buyer))
|
||||
#expect(!sale(paid: true, sent: true).allows(.markReceived, by: .seller))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `unlocks ratings only once the copy is received, one per party, out of five`() {
|
||||
let received = sale(paid: true, sent: true, received: true)
|
||||
|
||||
#expect(!sale(paid: true, sent: true).allows(.rate(stars: 5), by: .buyer))
|
||||
#expect(received.allows(.rate(stars: 5), by: .buyer))
|
||||
#expect(received.allows(.rate(stars: 1), by: .seller))
|
||||
#expect(!received.allows(.rate(stars: 0), by: .buyer))
|
||||
#expect(!received.allows(.rate(stars: 6), by: .buyer))
|
||||
#expect(!sale(paid: true, sent: true, received: true, buyerRating: 5).allows(.rate(stars: 4), by: .buyer))
|
||||
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5).allows(.rate(stars: 4), by: .seller))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `knows its parties`() {
|
||||
let sale = sale()
|
||||
|
||||
#expect(sale.role(of: sale.buyer.id) == .buyer)
|
||||
#expect(sale.role(of: sale.seller.id) == .seller)
|
||||
#expect(sale.role(of: .init()) == nil)
|
||||
}
|
||||
|
||||
// MARK: Offer tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
[24_000, 1_400, 4_500, 100],
|
||||
[19_200, 1_200, 3_600, 100]
|
||||
))
|
||||
func `floors offers at four fifths of the asking price, in whole euros`(
|
||||
asking: Int,
|
||||
expects floor: Int
|
||||
) {
|
||||
#expect(Offer.floor(forAsking: asking) == floor)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SaleTests {
|
||||
|
||||
/// A sale of the first demo listing at €215 + €6, as far along as told.
|
||||
func sale(
|
||||
paid: Bool = false,
|
||||
sent: Bool = false,
|
||||
received: Bool = false,
|
||||
buyerRating: Int? = nil,
|
||||
sellerRating: Int? = nil
|
||||
) -> Sale {
|
||||
let stamp = Date(timeIntervalSince1970: 0)
|
||||
|
||||
return .init(
|
||||
number: 48_213,
|
||||
conversationID: .init(),
|
||||
listing: Seed.listings[0],
|
||||
buyer: .init(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser"),
|
||||
seller: .init(id: .init(), email: "joost@kestrelrow.example", displayName: "Joost de Wit"),
|
||||
priceCents: 21_500,
|
||||
askedCents: 24_000,
|
||||
shippingCents: 600,
|
||||
declared: .init(grade: "CGC 9.8", pageQuality: "White", printing: "First printing", notes: ""),
|
||||
agreedAt: stamp,
|
||||
paidAt: paid ? stamp : nil,
|
||||
sentAt: sent ? stamp : nil,
|
||||
receivedAt: received ? stamp : nil,
|
||||
buyerRating: buyerRating,
|
||||
sellerRating: sellerRating
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Testing
|
||||
|
||||
@testable import Persistence
|
||||
|
||||
@Suite(
|
||||
"AccountRepository repository",
|
||||
.tags(.repository)
|
||||
)
|
||||
struct AccountRepositoryTests {
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `finds the seeded demo buyer`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let user = try await AccountRepository(fluent: fluent).account(email: AccountRepository.demoEmail)
|
||||
|
||||
#expect(user.email == "mara.v@example.com")
|
||||
#expect(user.displayName == "Mara Visser")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `joins an unknown email once, named after its local part`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let repository = AccountRepository(fluent: fluent)
|
||||
let joined = try await repository.account(email: "sam@example.com")
|
||||
let returning = try await repository.account(email: "sam@example.com")
|
||||
|
||||
#expect(joined.displayName == "sam")
|
||||
#expect(returning == joined)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Passkeys
|
||||
|
||||
extension AccountRepositoryTests {
|
||||
|
||||
@Test
|
||||
func `joins an account together with its first passkey`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let free = try await accounts.isUnregistered(credentialID: "cred-1")
|
||||
let sam = try #require(try await accounts.join(email: "sam@example.com", credentialID: "cred-1", publicKey: [1, 2, 3], signCount: 0))
|
||||
let passkey = try await accounts.passkey(credentialID: "cred-1")
|
||||
let byEmail = try await accounts.passkeys(email: "sam@example.com")
|
||||
let taken = try await accounts.isUnregistered(credentialID: "cred-1")
|
||||
|
||||
#expect(free)
|
||||
#expect(!taken)
|
||||
#expect(sam.displayName == "sam")
|
||||
#expect(passkey == Passkey(credentialID: "cred-1", publicKey: [1, 2, 3], signCount: 0, user: sam))
|
||||
#expect(byEmail == [passkey])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `never adds a passkey to an account that already exists`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let hijack = try await accounts.join(email: AccountRepository.demoEmail, credentialID: "cred-x", publicKey: [9], signCount: 0)
|
||||
let passkeys = try await accounts.passkeys(email: AccountRepository.demoEmail)
|
||||
let stillFree = try await accounts.isUnregistered(credentialID: "cred-x")
|
||||
|
||||
#expect(hijack == nil)
|
||||
#expect(passkeys.isEmpty)
|
||||
#expect(stillFree)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `records a passkey's signature counter, and finds accounts without creating them`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
|
||||
_ = try await accounts.join(email: "sam@example.com", credentialID: "cred-1", publicKey: [1], signCount: 0)
|
||||
try await accounts.recordUse(ofPasskey: "cred-1", signCount: 7)
|
||||
|
||||
let passkey = try await accounts.passkey(credentialID: "cred-1")
|
||||
let known = try await accounts.existingAccount(email: "sam@example.com")
|
||||
let unknown = try await accounts.existingAccount(email: "nobody@example.com")
|
||||
let stillUnknown = try await accounts.existingAccount(email: "nobody@example.com")
|
||||
|
||||
#expect(passkey?.signCount == 7)
|
||||
#expect(known?.email == "sam@example.com")
|
||||
#expect(unknown == nil)
|
||||
#expect(stillUnknown == nil)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Accounts and shops
|
||||
|
||||
extension AccountRepositoryTests {
|
||||
|
||||
@Test
|
||||
func `says whether a sign-in created the account`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let first = try await accounts.signIn(email: "sam@example.com")
|
||||
let again = try await accounts.signIn(email: "sam@example.com")
|
||||
|
||||
#expect(first.isNew)
|
||||
#expect(!again.isNew)
|
||||
#expect(first.user == again.user)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renames an account, and manages the passkeys of its own`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let mara = try await accounts.account(email: AccountRepository.demoEmail)
|
||||
let sam = try await accounts.account(email: "sam@example.com")
|
||||
let renamed = try await accounts.rename(mara, to: "Mara V.")
|
||||
let stored = try await accounts.existingAccount(email: AccountRepository.demoEmail)
|
||||
|
||||
try await accounts.addPasskey(to: mara, credentialID: "cred-m", publicKey: [1], signCount: 0)
|
||||
try await accounts.revokePasskey("cred-m", of: sam)
|
||||
|
||||
let kept = try await accounts.passkeys(of: mara)
|
||||
|
||||
try await accounts.revokePasskey("cred-m", of: mara)
|
||||
|
||||
let revoked = try await accounts.passkeys(of: mara)
|
||||
|
||||
#expect(renamed.displayName == "Mara V.")
|
||||
#expect(stored?.displayName == "Mara V.")
|
||||
#expect(kept.map(\.passkey.credentialID) == ["cred-m"])
|
||||
#expect(revoked.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `opens one shop per account, under a handle of its own`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let catalogue = CatalogueRepository(fluent: fluent)
|
||||
let mara = try await accounts.account(email: AccountRepository.demoEmail)
|
||||
let sam = try await accounts.account(email: "sam@example.com")
|
||||
let draft = ShopDraft(
|
||||
name: "Mara's Longbox", handle: "maras-longbox", city: "Haarlem", country: "NL", about: "Spare copies.",
|
||||
kind: .privateSeller, kvkNumber: "12345678", vatID: nil, legalName: nil, returnsNote: "14-day returns", handlingDays: 2
|
||||
)
|
||||
let shop = try await catalogue.openShop(draft, for: mara)
|
||||
let owned = try await TradeRepository(fluent: fluent).shop(ownedBy: mara)
|
||||
|
||||
#expect(shop.slug == "maras-longbox")
|
||||
#expect(shop.kind == .privateSeller)
|
||||
// A private seller shows no registration, whatever was typed.
|
||||
#expect(shop.kvkNumber == nil)
|
||||
#expect(!shop.isVerified)
|
||||
#expect(shop.ratedSales == 0)
|
||||
#expect(owned == shop)
|
||||
|
||||
await #expect(throws: ShopError.alreadyOwner) {
|
||||
try await catalogue.openShop(draft, for: mara)
|
||||
}
|
||||
await #expect(throws: ShopError.handleTaken) {
|
||||
try await catalogue.openShop(draft, for: sam)
|
||||
}
|
||||
|
||||
// And the new shop sells: its owner lists a copy, which a buyer finds.
|
||||
let listing = try await catalogue.publish(ListingDraftTests.draft(), by: mara)
|
||||
|
||||
#expect(listing?.shop.slug == "maras-longbox")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import HummingbirdFluent
|
||||
import Logging
|
||||
import Testing
|
||||
|
||||
@testable import Persistence
|
||||
|
||||
@Suite(
|
||||
"CatalogueRepository repository",
|
||||
.tags(.repository)
|
||||
)
|
||||
struct CatalogueRepositoryTests {
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `seeds every demo shop and listing`() async throws {
|
||||
try await withCatalogue { repository in
|
||||
let listings = try await repository.listings()
|
||||
|
||||
#expect(listings.count == Seed.listings.count)
|
||||
|
||||
for shop in Seed.shops {
|
||||
let found = try await repository.shop(slug: shop.slug)
|
||||
|
||||
#expect(found == shop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `lists the newest listing first by default`() async throws {
|
||||
try await withCatalogue { repository in
|
||||
let listings = try await repository.listings()
|
||||
|
||||
#expect(listings.map(\.slug) == Seed.listings.map(\.slug))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `finds a listing with its shop by slug`() async throws {
|
||||
try await withCatalogue { repository in
|
||||
let found = try await repository.listing(slug: "nightward-1-cgc-9-8")
|
||||
let missing = try await repository.listing(slug: "missing")
|
||||
let listing = try #require(found)
|
||||
|
||||
#expect(listing.title == "Nightward #1")
|
||||
#expect(listing.shop.name == "Kestrel Row Comics")
|
||||
#expect(missing == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `lists each publisher once, alphabetically`() async throws {
|
||||
try await withCatalogue { repository in
|
||||
let publishers = try await repository.publishers()
|
||||
|
||||
#expect(publishers == [
|
||||
"Blackline", "Cutwater Press", "Harbour & Vale", "Meridian Comics", "Sōkai Books",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `narrows and orders the listings by the filter`() async throws {
|
||||
try await withCatalogue { repository in
|
||||
let listings = try await repository.listings(matching: .init(
|
||||
query: "nightward 1",
|
||||
sort: .priceAscending
|
||||
))
|
||||
|
||||
#expect(listings.map(\.priceCents) == [3_500, 12_000, 15_000, 24_000])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension CatalogueRepositoryTests {
|
||||
|
||||
/// Runs the body against a migrated, seeded in-memory catalogue.
|
||||
func withCatalogue(
|
||||
_ body: (CatalogueRepository) async throws -> Void
|
||||
) async throws {
|
||||
try await withDatabase {
|
||||
try await body(.init(fluent: $0))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Persistence
|
||||
|
||||
@Suite(
|
||||
"CollectingRepository repository",
|
||||
.tags(.repository)
|
||||
)
|
||||
struct CollectingRepositoryTests {
|
||||
|
||||
// MARK: Wants tests
|
||||
|
||||
@Test
|
||||
func `matches a want within its limits, shipping from the EU alone`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let collecting = CollectingRepository(fluent: fluent)
|
||||
let mara = try await AccountRepository(fluent: fluent).account(email: "mara.v@example.com")
|
||||
|
||||
try await collecting.addWant(title: " Nightward #1 ", minGrade: 9.0, maxPriceCents: 20_000, slabbedOnly: false, for: mara)
|
||||
try await collecting.addWant(title: " ", minGrade: nil, maxPriceCents: nil, slabbedOnly: false, for: mara)
|
||||
|
||||
let wants = try await collecting.wants(of: mara)
|
||||
let want = try #require(wants.first)
|
||||
|
||||
#expect(wants.count == 1)
|
||||
#expect(want.title == "Nightward #1")
|
||||
// The raw NM at €120 from Antwerp; the €150 CGC 9.4 ships from the UK, and the rest cost more or grade lower.
|
||||
#expect(want.matches.map(\.slug) == ["nightward-1-raw-nm"])
|
||||
#expect(want.newMatchCount == 1)
|
||||
#expect(want.alerts)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `counts a match as new until the wants are looked at, and again when a copy is listed`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let clock = Clock()
|
||||
let collecting = CollectingRepository(fluent: fluent, now: clock.now)
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||
let joost = try await accounts.account(email: "joost@kestrelrow.example")
|
||||
|
||||
try await collecting.addWant(title: "Nightward #3", minGrade: nil, maxPriceCents: nil, slabbedOnly: false, for: mara)
|
||||
|
||||
let nothing = try await collecting.newMatchCount(for: mara)
|
||||
|
||||
_ = try await CatalogueRepository(fluent: fluent).publish(ListingDraftTests.draft(), by: joost, at: clock.now())
|
||||
|
||||
clock.advance(by: 60)
|
||||
|
||||
let listed = try await collecting.newMatchCount(for: mara)
|
||||
|
||||
try await collecting.markWantsSeen(by: mara)
|
||||
|
||||
let seen = try await collecting.newMatchCount(for: mara)
|
||||
let stillMatching = try await collecting.wants(of: mara).first?.matches.count
|
||||
|
||||
#expect(nothing == 0)
|
||||
#expect(listed == 1)
|
||||
#expect(seen == 0)
|
||||
#expect(stillMatching == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `keeps a want's alerts and removal to its owner`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let collecting = CollectingRepository(fluent: fluent)
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||
let sam = try await accounts.account(email: "sam@example.com")
|
||||
|
||||
try await collecting.addWant(title: "Nightward #1", minGrade: nil, maxPriceCents: nil, slabbedOnly: true, for: mara)
|
||||
|
||||
let id = try #require(try await collecting.wants(of: mara).first?.id)
|
||||
|
||||
try await collecting.toggleAlerts(ofWant: id, of: sam)
|
||||
try await collecting.removeWant(id, of: sam)
|
||||
|
||||
let untouched = try await collecting.wants(of: mara)
|
||||
|
||||
try await collecting.toggleAlerts(ofWant: id, of: mara)
|
||||
|
||||
let silenced = try await collecting.wants(of: mara)
|
||||
let quiet = try await collecting.newMatchCount(for: mara)
|
||||
|
||||
try await collecting.removeWant(id, of: mara)
|
||||
|
||||
let removed = try await collecting.wants(of: mara)
|
||||
|
||||
#expect(untouched.first?.alerts == true)
|
||||
#expect(untouched.first?.matches.allSatisfy(\.isSlabbed) == true)
|
||||
#expect(silenced.first?.alerts == false)
|
||||
#expect(quiet == 0)
|
||||
#expect(removed.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Series tests
|
||||
|
||||
@Test
|
||||
func `knows the seeded runs, and makes up a series it only knows from its listings`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let collecting = CollectingRepository(fluent: fluent)
|
||||
let all = try await collecting.allSeries()
|
||||
let nightward = try await collecting.series(slug: "nightward")
|
||||
let listedOnly = try await collecting.series(slug: "hollow-comet")
|
||||
let unknown = try await collecting.series(slug: "no-such-series")
|
||||
|
||||
#expect(all.map(\.slug) == ["echo-country", "ferrous-saint", "nightward", "paper-tigers", "vermilion-lake"])
|
||||
#expect(nightward?.issueCount == 24)
|
||||
#expect(listedOnly?.name == "Hollow Comet")
|
||||
#expect(listedOnly?.issueCount == 0)
|
||||
#expect(unknown == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `derives a listing's series and issue number from what it states`() {
|
||||
let single = Seed.listings[0]
|
||||
let manga = Seed.listings[5]
|
||||
|
||||
#expect(single.seriesName == "Nightward")
|
||||
#expect(single.seriesSlug == "nightward")
|
||||
#expect(single.issueNumber == 1)
|
||||
#expect(manga.seriesSlug == "hollow-comet")
|
||||
#expect(manga.issueNumber == nil)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Persistence
|
||||
|
||||
@Suite(
|
||||
"CollectingRepository repository, collection and events",
|
||||
.tags(.repository)
|
||||
)
|
||||
struct CommunityRepositoryTests {
|
||||
|
||||
// MARK: Collection tests
|
||||
|
||||
@Test
|
||||
func `keeps a collection per account, within a series' run`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let collecting = CollectingRepository(fluent: fluent)
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||
let sam = try await accounts.account(email: "sam@example.com")
|
||||
|
||||
// Echo Country runs six issues: #9 is outside it, and #2 is given twice.
|
||||
try await collecting.own(issues: [1, 2, 2, 9, 0], ofSeries: "echo-country", by: mara)
|
||||
try await collecting.own(issues: [2, 3], ofSeries: "echo-country", by: mara)
|
||||
try await collecting.own(issues: [1], ofSeries: "no-such-series", by: mara)
|
||||
try await collecting.disown(issue: 1, ofSeries: "echo-country", by: sam)
|
||||
|
||||
let owned = try await collecting.ownedIssues(of: mara)
|
||||
let others = try await collecting.ownedIssues(of: sam)
|
||||
|
||||
try await collecting.disown(issue: 1, ofSeries: "echo-country", by: mara)
|
||||
|
||||
let after = try await collecting.ownedIssues(of: mara)
|
||||
|
||||
#expect(owned == ["echo-country": [1, 2, 3]])
|
||||
#expect(others.isEmpty)
|
||||
#expect(after == ["echo-country": [2, 3]])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Events tests
|
||||
|
||||
@Test
|
||||
func `lists the events still to come, soonest first, with the shops trading there`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let clock = Clock()
|
||||
let collecting = CollectingRepository(fluent: fluent, now: clock.now)
|
||||
|
||||
// The present, which every demo con is seeded after.
|
||||
clock.advance(by: Date().timeIntervalSince(clock.now()))
|
||||
|
||||
let all = try await collecting.upcomingEvents()
|
||||
|
||||
// The first con's last day: still on. Two weeks later it is over, and so is Stripdagen.
|
||||
clock.advance(by: try #require(all.first).endsOn.timeIntervalSince(clock.now()))
|
||||
|
||||
let lastDay = try await collecting.upcomingEvents()
|
||||
|
||||
clock.advance(by: 14 * 86_400)
|
||||
|
||||
let later = try await collecting.upcomingEvents()
|
||||
|
||||
#expect(all.map(\.slug) == ["dutch-comic-con", "stripdagen-haarlem", "comic-con-amsterdam", "comic-con-brussels"])
|
||||
#expect(Set(all[0].shops.map(\.slug)) == ["kestrel-row-comics", "silverfish-comics", "third-panel-supply"])
|
||||
#expect(lastDay.first?.slug == "dutch-comic-con")
|
||||
#expect(later.first?.slug == "comic-con-amsterdam")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `dates the demo cons on weekends, from the first one after seeding`() {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
|
||||
calendar.timeZone = TimeZone(identifier: "Europe/Amsterdam") ?? .gmt
|
||||
|
||||
// Monday 21 September 2026: the dates the prototype first shipped with.
|
||||
let days = Seed.events(after: Date(timeIntervalSince1970: 1_790_000_000)).map { entry in
|
||||
[entry.event.startsOn, entry.event.endsOn]
|
||||
.map { calendar.dateComponents([.month, .day, .hour], from: $0) }
|
||||
.map { "\($0.month ?? 0)/\($0.day ?? 0) \($0.hour ?? 0):00" }
|
||||
}
|
||||
|
||||
#expect(days == [
|
||||
["9/26 12:00", "9/27 12:00"],
|
||||
["10/3 12:00", "10/4 12:00"],
|
||||
["11/21 12:00", "11/22 12:00"],
|
||||
["12/5 12:00", "12/6 12:00"],
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `lets a seller pick the events their shop trades at`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let collecting = CollectingRepository(fluent: fluent)
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let joost = try await accounts.account(email: "joost@kestrelrow.example")
|
||||
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||
|
||||
try await collecting.setEvents(["stripdagen-haarlem", "comic-con-amsterdam", "no-such-event"], forShopOwnedBy: joost)
|
||||
// An account without a shop changes nothing.
|
||||
try await collecting.setEvents(["dutch-comic-con"], forShopOwnedBy: mara)
|
||||
|
||||
let trading = try await collecting.upcomingEvents()
|
||||
.filter { $0.shops.contains { $0.slug == "kestrel-row-comics" } }
|
||||
.map(\.slug)
|
||||
|
||||
#expect(trading == ["stripdagen-haarlem", "comic-con-amsterdam"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `narrows a search to the shops trading at an event`() {
|
||||
let filter = ListingFilter(shops: ["silverfish-comics"])
|
||||
|
||||
#expect(Seed.listings.filter(filter.matches).map(\.shop.slug).allSatisfy { $0 == "silverfish-comics" })
|
||||
#expect(Seed.listings.filter(filter.matches).count == 4)
|
||||
#expect(Seed.listings.filter(ListingFilter(shops: []).matches).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Recovery tests
|
||||
|
||||
@Test
|
||||
func `replaces every passkey of an account with the one it recovers with`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let sam = try #require(try await accounts.join(email: "sam@example.com", credentialID: "old-1", publicKey: [1], signCount: 3))
|
||||
|
||||
try await accounts.addPasskey(to: sam, credentialID: "old-2", publicKey: [2], signCount: 0)
|
||||
try await accounts.replacePasskeys(of: sam, credentialID: "new", publicKey: [9], signCount: 0)
|
||||
|
||||
let passkeys = try await accounts.passkeys(of: sam)
|
||||
let lost = try await accounts.passkey(credentialID: "old-1")
|
||||
|
||||
#expect(passkeys.map(\.passkey.credentialID) == ["new"])
|
||||
#expect(lost == nil)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import Foundation
|
||||
import HummingbirdFluent
|
||||
import Testing
|
||||
|
||||
@testable import Persistence
|
||||
|
||||
@Suite(
|
||||
"TradeRepository repository",
|
||||
.tags(.repository)
|
||||
)
|
||||
struct TradeRepositoryTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The first demo listing: Kestrel Row's Nightward #1, asking €240, open to offers.
|
||||
private let slug = "nightward-1-cgc-9-8"
|
||||
|
||||
// MARK: Offers tests
|
||||
|
||||
@Test
|
||||
func `makes an offer in the buyer's conversation about the listing`() async throws {
|
||||
try await withTrade { trade, mara, joost, _ in
|
||||
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: " Would you take €215? ")
|
||||
let buying = try #require(try await trade.conversation(id: id, for: mara))
|
||||
let inbox = try await trade.openOffers(for: joost)
|
||||
|
||||
#expect(buying.side == .buying)
|
||||
#expect(buying.listing?.slug == slug)
|
||||
#expect(buying.entries.map(\.kind) == [.offer])
|
||||
#expect(buying.entries[0].body == "Would you take €215?")
|
||||
#expect(buying.openOffer?.amountCents == 21_500)
|
||||
#expect(inbox.map(\.amountCents) == [21_500])
|
||||
#expect(inbox[0].buyer == mara)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `turns down offers that break the rules`() async throws {
|
||||
try await withTrade { trade, mara, joost, _ in
|
||||
await #expect(throws: TradeError.offerTooLow(floorCents: 19_200)) {
|
||||
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 19_100, note: "")
|
||||
}
|
||||
await #expect(throws: TradeError.invalidAmount) {
|
||||
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 24_100, note: "")
|
||||
}
|
||||
await #expect(throws: TradeError.invalidAmount) {
|
||||
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 0, note: "")
|
||||
}
|
||||
await #expect(throws: TradeError.offersNotAccepted) {
|
||||
try await trade.makeOffer(onListing: "echo-country-3", buyer: mara, amountCents: 1_300, note: "")
|
||||
}
|
||||
await #expect(throws: TradeError.ownListing) {
|
||||
try await trade.makeOffer(onListing: slug, buyer: joost, amountCents: 21_500, note: "")
|
||||
}
|
||||
await #expect(throws: TradeError.listingUnavailable) {
|
||||
try await trade.makeOffer(onListing: "missing", buyer: mara, amountCents: 21_500, note: "")
|
||||
}
|
||||
|
||||
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
|
||||
|
||||
await #expect(throws: TradeError.offerAlreadyOpen) {
|
||||
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 22_000, note: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `lapses an offer left unanswered for 48 hours`() async throws {
|
||||
try await withTrade { trade, mara, joost, clock in
|
||||
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
|
||||
let offer = try #require(try await trade.openOffers(for: joost).first)
|
||||
|
||||
clock.advance(by: Offer.lifetime)
|
||||
|
||||
let conversation = try #require(try await trade.conversation(id: id, for: mara))
|
||||
let inbox = try await trade.openOffers(for: joost)
|
||||
|
||||
#expect(conversation.entries[0].offer?.status == .expired)
|
||||
#expect(conversation.openOffer == nil)
|
||||
#expect(inbox.isEmpty)
|
||||
|
||||
await #expect(throws: TradeError.notAllowed) {
|
||||
try await trade.respond(toOffer: offer.id, accepting: true, by: joost)
|
||||
}
|
||||
|
||||
// The listing is free for a new offer.
|
||||
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 22_000, note: "")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `declines an offer, leaving the listing for sale`() async throws {
|
||||
try await withTrade { trade, mara, joost, _ in
|
||||
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
|
||||
let offer = try #require(try await trade.openOffers(for: joost).first)
|
||||
let number = try await trade.respond(toOffer: offer.id, accepting: false, by: joost)
|
||||
let conversation = try #require(try await trade.conversation(id: id, for: mara))
|
||||
|
||||
#expect(number == nil)
|
||||
#expect(conversation.entries.map(\.kind) == [.offer, .event])
|
||||
#expect(conversation.entries[0].offer?.status == .declined)
|
||||
#expect(conversation.entries[1].body == "declined the offer")
|
||||
#expect(conversation.saleNumber == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `lets only the shop's owner answer an offer`() async throws {
|
||||
try await withTrade { trade, mara, joost, _ in
|
||||
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
|
||||
|
||||
let offer = try #require(try await trade.openOffers(for: joost).first)
|
||||
|
||||
await #expect(throws: TradeError.notAllowed) {
|
||||
try await trade.respond(toOffer: offer.id, accepting: true, by: mara)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Sales tests
|
||||
|
||||
@Test
|
||||
func `accepting an offer opens a sale, takes the listing off sale, and lapses the other offers`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let clock = Clock()
|
||||
let trade = TradeRepository(fluent: fluent, now: clock.now)
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||
let sam = try await accounts.account(email: "sam@example.com")
|
||||
let joost = try await accounts.account(email: "joost@kestrelrow.example")
|
||||
|
||||
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
|
||||
|
||||
let other = try await trade.makeOffer(onListing: slug, buyer: sam, amountCents: 20_000, note: "")
|
||||
let offer = try #require(try await trade.openOffers(for: joost).first { $0.buyer == mara })
|
||||
let number = try #require(try await trade.respond(toOffer: offer.id, accepting: true, by: joost))
|
||||
let sale = try #require(try await trade.sale(number: number, for: mara))
|
||||
let listing = try await CatalogueRepository(fluent: fluent).listing(slug: slug)
|
||||
let forSale = try await CatalogueRepository(fluent: fluent).listings()
|
||||
let lapsed = try #require(try await trade.conversation(id: other, for: sam))
|
||||
let inbox = try await trade.openOffers(for: joost)
|
||||
|
||||
#expect(number == 48_213)
|
||||
#expect(sale.priceCents == 21_500)
|
||||
#expect(sale.askedCents == 24_000)
|
||||
#expect(sale.shippingCents == 600)
|
||||
#expect(sale.declared.grade == "CGC 9.8 · cert 4312087-002")
|
||||
#expect(sale.buyer == mara)
|
||||
#expect(sale.seller == joost)
|
||||
#expect(sale.completed == .agreed)
|
||||
#expect(listing?.isAvailable == false)
|
||||
#expect(!forSale.contains { $0.slug == slug })
|
||||
#expect(lapsed.entries[0].offer?.status == .expired)
|
||||
#expect(inbox.isEmpty)
|
||||
|
||||
await #expect(throws: TradeError.listingUnavailable) {
|
||||
try await trade.buy(listing: slug, buyer: sam)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `buys at the asking price, numbering the sales in order`() async throws {
|
||||
try await withTrade { trade, mara, _, _ in
|
||||
let first = try await trade.buy(listing: slug, buyer: mara)
|
||||
let second = try await trade.buy(listing: "echo-country-3", buyer: mara)
|
||||
let sale = try #require(try await trade.sale(number: second, for: mara))
|
||||
let sales = try await trade.sales(for: mara)
|
||||
|
||||
#expect(first == 48_213)
|
||||
#expect(second == 48_214)
|
||||
#expect(sale.priceCents == sale.askedCents)
|
||||
#expect(sales.count == 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `walks the handshake, stamping each step into the conversation`() async throws {
|
||||
try await withTrade { trade, mara, joost, _ in
|
||||
let number = try await trade.buy(listing: slug, buyer: mara)
|
||||
|
||||
await #expect(throws: TradeError.notAllowed) {
|
||||
try await trade.perform(.markSent(note: ""), onSale: number, by: joost)
|
||||
}
|
||||
await #expect(throws: TradeError.notAllowed) {
|
||||
try await trade.perform(.markPaid(note: ""), onSale: number, by: joost)
|
||||
}
|
||||
|
||||
try await trade.perform(.markPaid(note: " iDEAL "), onSale: number, by: mara)
|
||||
try await trade.perform(.markSent(note: "DHL 3S123"), onSale: number, by: joost)
|
||||
try await trade.perform(.markReceived, onSale: number, by: mara)
|
||||
try await trade.perform(.rate(stars: 5), onSale: number, by: mara)
|
||||
|
||||
let sale = try await trade.perform(.rate(stars: 4), onSale: number, by: joost)
|
||||
let conversation = try #require(try await trade.conversation(id: sale.conversationID, for: joost))
|
||||
|
||||
#expect(sale.completed == .rated)
|
||||
#expect(sale.paymentNote == "iDEAL")
|
||||
#expect(sale.shippingNote == "DHL 3S123")
|
||||
#expect(sale.buyerRating == 5)
|
||||
#expect(sale.sellerRating == 4)
|
||||
#expect(conversation.saleNumber == number)
|
||||
#expect(conversation.entries.map(\.body) == [
|
||||
"bought the copy at the asking price", "marked the sale paid", "marked the copy sent",
|
||||
"marked the copy received", "left a rating", "left a rating",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `shows a sale to its parties alone`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let trade = TradeRepository(fluent: fluent)
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||
let sam = try await accounts.account(email: "sam@example.com")
|
||||
let number = try await trade.buy(listing: slug, buyer: mara)
|
||||
let strangers = try await trade.sale(number: number, for: sam)
|
||||
let none = try await trade.sales(for: sam)
|
||||
|
||||
#expect(strangers == nil)
|
||||
#expect(none.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Conversations tests
|
||||
|
||||
@Test
|
||||
func `keeps one conversation per buyer and listing, and one with the shop at large`() async throws {
|
||||
try await withTrade { trade, mara, _, _ in
|
||||
let first = try await trade.conversation(aboutListing: slug, buyer: mara)
|
||||
let again = try await trade.conversation(aboutListing: slug, buyer: mara)
|
||||
let shop = try await trade.conversation(withShop: "kestrel-row-comics", buyer: mara)
|
||||
let shopAgain = try await trade.conversation(withShop: "kestrel-row-comics", buyer: mara)
|
||||
|
||||
#expect(first == again)
|
||||
#expect(shop == shopAgain)
|
||||
#expect(first != shop)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `counts what the other side said as unread until the conversation is opened`() async throws {
|
||||
try await withTrade { trade, mara, joost, clock in
|
||||
let id = try await trade.conversation(aboutListing: slug, buyer: mara)
|
||||
|
||||
clock.advance(by: 60)
|
||||
try await trade.post("Is the case clean?", to: id, from: mara)
|
||||
try await trade.post(" ", to: id, from: mara)
|
||||
|
||||
let before = try await trade.unreadCount(for: joost)
|
||||
let own = try await trade.unreadCount(for: mara)
|
||||
|
||||
clock.advance(by: 60)
|
||||
|
||||
let opened = try #require(try await trade.conversation(id: id, for: joost))
|
||||
let after = try await trade.unreadCount(for: joost)
|
||||
|
||||
#expect(before == 1)
|
||||
#expect(own == 0)
|
||||
#expect(opened.side == .selling)
|
||||
#expect(opened.unreadCount == 1)
|
||||
#expect(opened.entries.map(\.body) == ["Is the case clean?"])
|
||||
#expect(after == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `keeps strangers out of a conversation`() async throws {
|
||||
try await withDatabase { fluent in
|
||||
let trade = TradeRepository(fluent: fluent)
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let mara = try await accounts.account(email: "mara.v@example.com")
|
||||
let sam = try await accounts.account(email: "sam@example.com")
|
||||
let id = try await trade.conversation(aboutListing: slug, buyer: mara)
|
||||
let peek = try await trade.conversation(id: id, for: sam)
|
||||
|
||||
#expect(peek == nil)
|
||||
|
||||
await #expect(throws: TradeError.notAllowed) {
|
||||
try await trade.post("Hello", to: id, from: sam)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `finds the shop an account owns`() async throws {
|
||||
try await withTrade { trade, mara, joost, _ in
|
||||
let owned = try await trade.shop(ownedBy: joost)
|
||||
let none = try await trade.shop(ownedBy: mara)
|
||||
|
||||
#expect(owned?.slug == "kestrel-row-comics")
|
||||
#expect(none == nil)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// A clock the tests move by hand.
|
||||
final class Clock: @unchecked Sendable {
|
||||
|
||||
private let lock = NSLock()
|
||||
private var date = Date(timeIntervalSince1970: 1_790_000_000)
|
||||
|
||||
/// The clock's reading.
|
||||
@Sendable
|
||||
func now() -> Date {
|
||||
lock.withLock { date }
|
||||
}
|
||||
|
||||
/// Moves the clock forward.
|
||||
func advance(
|
||||
by interval: TimeInterval
|
||||
) {
|
||||
lock.withLock { date.addTimeInterval(interval) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension TradeRepositoryTests {
|
||||
|
||||
/// Runs the body against a seeded database, with the demo buyer, the first demo listing's seller, and the clock the repository reads.
|
||||
func withTrade(
|
||||
_ body: (TradeRepository, User, User, Clock) async throws -> Void
|
||||
) async throws {
|
||||
try await withDatabase { fluent in
|
||||
let accounts = AccountRepository(fluent: fluent)
|
||||
let clock = Clock()
|
||||
|
||||
try await body(
|
||||
.init(fluent: fluent, now: clock.now),
|
||||
try await accounts.account(email: "mara.v@example.com"),
|
||||
try await accounts.account(email: "joost@kestrelrow.example"),
|
||||
clock
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,4 +5,8 @@ extension Tag {
|
||||
@Tag static var enumeration: Tag
|
||||
/// Tests exercising a method of the Persistence package.
|
||||
@Tag static var method: Tag
|
||||
/// Tests exercising a model of the Persistence package.
|
||||
@Tag static var model: Tag
|
||||
/// Tests exercising a repository of the Persistence package.
|
||||
@Tag static var repository: Tag
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import HummingbirdFluent
|
||||
import Logging
|
||||
|
||||
@testable import Persistence
|
||||
|
||||
/// Runs the body against a migrated, seeded in-memory database, shutting the pool down after — even on failure, or the pool asserts on `deinit`.
|
||||
func withDatabase(
|
||||
_ body: (Fluent) async throws -> Void
|
||||
) async throws {
|
||||
let fluent = try Service(
|
||||
driver: .inMemory,
|
||||
logger: Logger(label: "test")
|
||||
)()
|
||||
|
||||
defer {
|
||||
try? await fluent.shutdown()
|
||||
}
|
||||
|
||||
await PrepareDB()(for: fluent)
|
||||
|
||||
try await fluent.migrate()
|
||||
try await body(fluent)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version: 6.3
|
||||
// swift-tools-version: 6.4
|
||||
|
||||
import PackageDescription
|
||||
|
||||
@@ -29,3 +29,15 @@ let package = Package(
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
|
||||
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
|
||||
for target in package.targets {
|
||||
target.swiftSettings = (target.swiftSettings ?? []) + [
|
||||
.strictMemorySafety(),
|
||||
.treatAllWarnings(as: .error),
|
||||
.treatWarning("DeprecatedDeclaration", as: .warning),
|
||||
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
|
||||
.enableUpcomingFeature("InferIsolatedConformances"),
|
||||
]
|
||||
}
|
||||
@@ -25,6 +25,6 @@ Tests/
|
||||
Every suite carries a tag for the kind of API it exercises — `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
|
||||
|
||||
## Requirements
|
||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
|
||||
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
- No package dependencies — Foundation only.
|
||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user