Added the catalogue models and seed to support phase 4 of the POC prototype in the Persistence package.
This commit is contained in:
1 parent
0dc3fe6bea
commit
95e0a455e8
14 files changed
+743
-9
No files matched your search
@@ -8,16 +8,16 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da
|
|||||||
| Service | `Service`, which builds the `Fluent` service configured for the chosen driver |
|
| Service | `Service`, which builds the `Fluent` service configured for the chosen driver |
|
||||||
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
|
| 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 |
|
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline |
|
||||||
| Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot; `CreateUser` seeds the prototype's demo accounts |
|
| 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 |
|
||||||
| 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 |
|
| 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`, returning `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data |
|
| Catalogue | `CatalogueRepository`, which 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
|
## 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.
|
- **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.
|
- **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 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.
|
- **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.
|
- **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` is registered ahead of `SeedCatalogue`, whose models write its columns; Fluent tracks migrations by name, so a database already past the seed still gains them.
|
- **Registration order is not creation order.** `AddTradeColumns` and `AddListingDeclarations` 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -32,12 +32,12 @@ Sources/
|
|||||||
├── Public/
|
├── Public/
|
||||||
│ ├── Enumerations/ Driver, TLS, TradeError
|
│ ├── Enumerations/ Driver, TLS, TradeError
|
||||||
│ ├── Methods/ Service, PrepareDB, Probe
|
│ ├── Methods/ Service, PrepareDB, Probe
|
||||||
│ ├── Models/ Shop, Listing, ListingFilter, User, Conversation, Offer, Sale
|
│ ├── Models/ Shop, Listing, ListingDraft, ListingFilter, User, Passkey, Conversation, Offer, Sale
|
||||||
│ ├── Repositories/ CatalogueRepository, AccountRepository, TradeRepository
|
│ ├── Repositories/ CatalogueRepository, AccountRepository, TradeRepository
|
||||||
│ └── Types/ Configuration
|
│ └── Types/ Configuration
|
||||||
└── Internal/
|
└── Internal/
|
||||||
├── Migrations/ CreateShop, CreateListing, AddTradeColumns, SeedCatalogue, CreateUser, CreateTrade
|
├── Migrations/ CreateShop, CreateListing, AddTradeColumns, AddListingDeclarations, SeedCatalogue, CreateUser, CreateTrade, CreatePasskey
|
||||||
├── Models/ ShopRecord, ListingRecord, UserRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord
|
├── Models/ ShopRecord, ListingRecord, UserRecord, PasskeyRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord
|
||||||
└── Seeds/ Seed
|
└── Seeds/ Seed
|
||||||
Tests/
|
Tests/
|
||||||
├── Cases/ the test suites, mirroring the Sources/ layout
|
├── Cases/ the test suites, mirroring the Sources/ layout
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -94,6 +94,14 @@ final class ListingRecord: Model, @unchecked Sendable {
|
|||||||
@OptionalField(key: "sold_at")
|
@OptionalField(key: "sold_at")
|
||||||
var soldAt: Date?
|
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
|
// MARK: Initializers
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
@@ -132,6 +140,8 @@ final class ListingRecord: Model, @unchecked Sendable {
|
|||||||
self.coverImprint = listing.cover.imprint
|
self.coverImprint = listing.cover.imprint
|
||||||
self.coverNumber = listing.cover.number
|
self.coverNumber = listing.cover.number
|
||||||
self.listedAt = listing.listedAt
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -173,7 +183,9 @@ extension ListingRecord {
|
|||||||
number: coverNumber
|
number: coverNumber
|
||||||
),
|
),
|
||||||
listedAt: listedAt,
|
listedAt: listedAt,
|
||||||
soldAt: soldAt
|
soldAt: soldAt,
|
||||||
|
defects: defects?.split(separator: "\n").map(String.init) ?? [],
|
||||||
|
photos: photos?.split(separator: "\n").map(String.init) ?? []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -25,9 +25,11 @@ public struct PrepareDB: Sendable {
|
|||||||
CreateShop(),
|
CreateShop(),
|
||||||
CreateListing(),
|
CreateListing(),
|
||||||
AddTradeColumns(),
|
AddTradeColumns(),
|
||||||
|
AddListingDeclarations(),
|
||||||
SeedCatalogue(),
|
SeedCatalogue(),
|
||||||
CreateUser(),
|
CreateUser(),
|
||||||
CreateTrade(),
|
CreateTrade(),
|
||||||
|
CreatePasskey(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ public struct Listing: Sendable, Equatable {
|
|||||||
public let listedAt: Date
|
public let listedAt: Date
|
||||||
/// When a sale of the copy was agreed, or `nil` while it is for sale.
|
/// When a sale of the copy was agreed, or `nil` while it is for sale.
|
||||||
public let soldAt: Date?
|
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
|
// MARK: Initializers
|
||||||
|
|
||||||
@@ -80,7 +84,9 @@ public struct Listing: Sendable, Equatable {
|
|||||||
acceptsOffers: Bool,
|
acceptsOffers: Bool,
|
||||||
cover: Cover,
|
cover: Cover,
|
||||||
listedAt: Date,
|
listedAt: Date,
|
||||||
soldAt: Date? = nil
|
soldAt: Date? = nil,
|
||||||
|
defects: [String] = [],
|
||||||
|
photos: [String] = []
|
||||||
) {
|
) {
|
||||||
self.slug = slug
|
self.slug = slug
|
||||||
self.shop = shop
|
self.shop = shop
|
||||||
@@ -106,6 +112,8 @@ public struct Listing: Sendable, Equatable {
|
|||||||
self.cover = cover
|
self.cover = cover
|
||||||
self.listedAt = listedAt
|
self.listedAt = listedAt
|
||||||
self.soldAt = soldAt
|
self.soldAt = soldAt
|
||||||
|
self.defects = defects
|
||||||
|
self.photos = photos
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -116,6 +124,15 @@ public extension Listing {
|
|||||||
|
|
||||||
// MARK: Computed
|
// 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.
|
/// 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 {
|
var gradeLabel: String {
|
||||||
if let grader, let grade {
|
if let grader, let grade {
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import FluentKit
|
import FluentKit
|
||||||
|
import Foundation
|
||||||
import HummingbirdFluent
|
import HummingbirdFluent
|
||||||
|
|
||||||
/// Reads and creates accounts through the default database.
|
/// Reads and creates accounts through the default database.
|
||||||
@@ -25,6 +26,97 @@ public struct AccountRepository: Sendable {
|
|||||||
|
|
||||||
// MARK: Methods
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches the account with the given email, creating it when there is none: one step both signs in and joins.
|
/// 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.
|
/// A new account is named after the email's local part until its owner picks a display name.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import FluentKit
|
import FluentKit
|
||||||
|
import Foundation
|
||||||
import HummingbirdFluent
|
import HummingbirdFluent
|
||||||
|
|
||||||
/// Reads the shops and their listings through the default database.
|
/// Reads the shops and their listings through the default database.
|
||||||
@@ -49,6 +50,48 @@ public struct CatalogueRepository: Sendable {
|
|||||||
.sorted(by: filter.ordersBefore)
|
.sorted(by: filter.ordersBefore)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Fetches the name of every publisher with a listing, in alphabetical order.
|
||||||
public func publishers() async throws -> [String] {
|
public func publishers() async throws -> [String] {
|
||||||
try await ListingRecord.query(on: fluent.db())
|
try await ListingRecord.query(on: fluent.db())
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ private extension TradeRepository {
|
|||||||
sale.declaredGrade = declared.gradeLabel + (declared.certNumber.map { " · cert \($0)" } ?? "")
|
sale.declaredGrade = declared.gradeLabel + (declared.certNumber.map { " · cert \($0)" } ?? "")
|
||||||
sale.declaredPageQuality = declared.pageQuality
|
sale.declaredPageQuality = declared.pageQuality
|
||||||
sale.declaredPrinting = declared.printingDetail
|
sale.declaredPrinting = declared.printingDetail
|
||||||
sale.declaredNotes = declared.sellerNotes
|
sale.declaredNotes = declared.declaration
|
||||||
sale.agreedAt = now()
|
sale.agreedAt = now()
|
||||||
|
|
||||||
listing.soldAt = now()
|
listing.soldAt = now()
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -33,3 +33,61 @@ struct AccountRepositoryTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in new issue
Block a user