Compare commits

..
12 Commits
Author SHA1 Message Date
ckochandClaude Opus 5 ddc618fc9c Add the collection dashboard
One GET /api/stats call, aggregated in a single pass over the library.
Completion funnel, breakdowns by system, genre, decade, condition and
rating, a backlog that links into the filtered library, and a value card.

Form was picked before colour, and most of the page is not a chart: single
numbers are stat tiles, the breakdowns are bar lists with the value printed
per row, which is also the table view.

The colour work, in order:

  * one hue for the breakdown bars — identity is on the axis labels, so
    colour has nothing to encode, and a darker-where-bigger ramp would just
    double-encode bar length
  * an ordinal ramp for the funnel, since owned/played/finished are ordered
    stages rather than peers
  * validated with the dataviz validator against this app's real card
    surfaces rather than a reference one, which caught that the documented
    ordinal light-end measures 1.91:1 here and fails the 2:1 floor; the ramp
    starts a step darker
  * status colour used once, on the stale-valuation notice, with an icon and
    text so it never carries meaning alone

Two bugs found by rendering it and looking, which the validator cannot see:

  * the ratings card was showing condition data under a ratings heading — a
    chart whose title did not describe its contents. Fixed by adding a real
    rating distribution rather than relabelling the card.
  * dark mode rendered light cards on a dark page. Copying the reference
    pattern's `color-scheme` onto the container overrode how every
    descendant resolved light-dark(), and the `:root`-prefixed media
    override never matched at all, because Angular's emulated encapsulation
    scopes selectors in component styles. Both replaced by light-dark()
    values that inherit the app's own scheme.

The value card reports coverage, age and source beside the total, and flags
valuations older than 90 days, because a bare figure mixes fresh with stale
and silently omits everything unpriced.

148 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:08:39 -04:00
ckochandClaude Opus 5 d34e6b4ced Make PriceCharting runs auditable and pin matches by product id
Two changes aimed at the first real run, since the integration cannot be
exercised here without a subscription.

Refresh now reports which product each game matched — name, console and the
source's id — next to the prices, and dry-run surfaces it before anything is
written. This is the failure that would otherwise go unnoticed: a lookup for
the DS "Chrono Trigger" resolving to the SNES original returns entirely
plausible numbers for the wrong game, and nothing in a bare price would say
so.

The matched id is then stored on the game, and later refreshes look it up
directly instead of repeating the title search. Cheaper, and stable — a
search that drifts to a different edition next month cannot silently
re-price something that was already matched correctly.

IPriceProvider takes an optional sourceId so this stays provider-agnostic.
eBay ignores it, having no stable per-product identifier in Browse.

139 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:52:59 -04:00
ckochandClaude Opus 5 8e136f42f8 Add CSV price guides and PriceCharting alongside eBay
An eBay production keyset needs account verification, which leaves pricing
blocked on someone else's review queue. These are two routes that are not.

CSV price guide, POST /api/prices/import: no account, works immediately.
Column names are matched by alias, so a PriceCharting bulk export
(product-name / console-name / loose-price) and a hand-kept
title,system,loose,cib,new sheet both parse, along with currency symbols,
thousands separators and blank cells. Rows match on title + system, so the
same game on two consoles is priced separately, and rows for games not in
the library are reported rather than silently added.

PriceCharting adapter: paid, but access is immediate with no review, and it
quotes the same three tiers this app stores, so no inference is needed.
Their API docs are not reachable without an account, so the parser follows
the widely-used convention — integer pennies under hyphenated keys — and is
tolerant enough that a naming difference degrades to "no price" instead of
throwing. One method to adjust if it differs.

Providers are now a registry rather than a single service. /api/prices/status
lists each one with what it is configured for, what its numbers actually
mean, and how to enable it; refresh takes an optional provider name and
falls back to the first configured one. With none configured it answers 503
pointing at the CSV route.

Checked against the real library: a five-row guide in PriceCharting's own
column names priced four games and reported the fifth as not owned, with
each effective value following that copy's condition. Demo figures were
cleared afterwards.

135 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:46:10 -04:00
ckochandClaude Opus 5 ca70bcef34 Add market value: tiered prices and an eBay Browse provider
Researched the options first. PriceCharting is the standard for retro prices
but requires a paid subscription for both its API and its bulk download.
eBay's sold-price data sits behind the Marketplace Insights API, which is a
limited release closed to new developers. The free game-price APIs cover
current digital storefronts, not physical retro copies. So there is no free
route to sold prices, and this uses eBay Browse — active listings, which are
asking prices, labelled as such rather than presented as valuations.

Schema now holds three prices per game (loose, CIB, new), with marketValue
as whichever tier matches that copy's condition. Changing a condition
re-prices from the stored tiers with no further lookup, and the dashboard
can later show both actual value and what a collection would be worth
complete.

The judgement lives in classification and aggregation, both pure and both
tested without credentials:

  * listings are sorted into tiers from their titles, and accessories,
    reproductions and multi-game lots are discarded — a "box only" listing
    at $45 counted as a copy would halve the loose estimate for a $130 cart
  * the discard qualifier is required. The first version matched a bare
    "box", which threw out "complete in box" and "with box and manual",
    i.e. most of the CIB tier, while keeping exactly the listings the
    filter existed to remove. A test asserting on tiers rather than counts
    caught it.
  * median with an interquartile trim, since one optimist asking 50x moves
    a mean and not a median
  * sample counts travel with the estimate, because a tier drawn from two
    listings warrants less confidence than one drawn from thirty

Credentials are optional: with none set, /api/prices/status reports
configured=false and refresh answers 503 with instructions, while the rest
of the app is unaffected.

101 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:21:32 -04:00
ckochandClaude Opus 5 d5a0e42fed Add collector fields, including market value
Rating, notes, condition, region, purchase price and date, plus a market
value carrying the timestamp and source that make it interpretable.

Condition is load-bearing rather than cosmetic: price feeds quote per
condition, so it selects which quoted price applies to a copy. Market value
records when it was captured and where it came from — a collection total is
only as good as its staleness — and an edit to an unrelated field leaves
that timestamp alone, so a stale price cannot start looking freshly checked.

Two storage decisions worth naming:

  * Money is stored as integer minor units. SQLite has no decimal type and
    EF Core maps decimal to TEXT, which compares lexically: "9.00" sorts
    above "10.00" and SUM is unavailable. A value converter keeps decimals
    in C# while ordering and totalling work. A test pins the ordering.
  * Enums serialise as names. The default is ordinals, which meant the API
    rejected the browser's {"condition":"Cib"} with a 400 while the C# tests
    passed, because they round-tripped ints and never spoke the client's
    dialect. The tests now share the API's serializer options.

Also fixes a data-loss bug in the Python tools. Both built their PUT body
from a hardcoded list of field names, so any column added to the model was
omitted and therefore nulled. Adding collector fields meant the next art or
enrichment run would have erased every rating, note, condition, price and
valuation in the library. Payloads are now built by excluding the handful of
server-owned fields, so new columns carry through by default.

The migration was rehearsed against a copy of the live database before being
applied: 105 rows, descriptions and developers intact.

67 backend tests, 8 frontend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:29:48 -04:00
ckochandClaude Opus 5 b69a5c9d14 Add library export and import
The only backup was the Docker volume. Export writes the caller's whole
library as JSON or CSV; import reads either back, into the same account or
a different one.

Rows are matched on title + system rather than id, so a file is portable
between accounts and instances, and the same game on three consoles stays
three entries. Merge adds and updates but deletes nothing. Replace wipes
first, and is gated behind an explicit confirm dialog in the UI. dryRun
reports what would happen and writes nothing.

CSV is hand-rolled rather than pulling a dependency, but handles the parts
that actually bite: quoted fields containing commas, escaped quotes,
embedded newlines and CRLF endings. That is not hypothetical here — 101 of
the 105 descriptions contain newlines, and two titles contain accents, so a
naive split-on-comma would corrupt most of the library. Exports carry a BOM
so Excel reads them as UTF-8.

Verified against the real library, not just fixtures: 105 games exported to
CSV, imported into a scratch account and re-exported compare identical
field for field.

15 new tests cover round-trip fidelity, merge vs replace, dry run,
per-user isolation on the destructive path, malformed input, and the
awkward-quoting case. 51 backend tests total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:54:54 -04:00
ckochandClaude Opus 5 771b34bb4b Fill developer, publisher, year and description from Wikipedia
The library carried titles, systems and genres but almost nothing else:
developer was 3.8% filled, publisher 2.9%, description 0%. These are columns
the app has always had and never been able to populate.

enrich_metadata.py reads them off the same articles the cover fetcher
locates. Only empty fields are touched unless --overwrite is given.

  year         79%  -> 96%
  developer   3.8%  -> 95%
  publisher   2.9%  -> 96%
  description   0%  -> 96%

Parsing infoboxes needed several guards, each found by checking output
rather than trusting the first pass:

  * "Infobox video game" is a substring of "Infobox video game series", so
    the loose test resolved Banjo-Kazooie to the series overview. Now
    rejected, which also fixes the cover fetcher's article resolution.
  * An article spans every release and its date block leads with the
    original, so year is only filled when the article covers that platform.
    Otherwise a DS port inherits the SNES original's year.
  * A search hit that neither covers the platform nor closely matches the
    title is discarded: "Dragon Ball Z Budokai" surfaces "Shin Budokai", a
    different game on a different console. Left blank instead.
  * Values are grouped under bold platform headings, tagged with region
    codes, annotated with the platform in parentheses, and wrapped in
    templates whose named parameters leak through. Each of those read as
    the developer or publisher before being handled.

Developer, publisher and year are facts and written verbatim. Descriptions
are article summaries under CC BY-SA, stored with an attribution line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:34:55 -04:00
ckochandClaude Opus 5 130921cf89 Add backend test suite covering auth, ownership and the query surface
The API had no tests. These run against the real application through
WebApplicationFactory — same pipeline, same Identity configuration, same JWT
validation — with only the SQLite file, upload folder and signing key
swapped, so a passing test says something about what ships.

The ownership tests pin the rule the old PHP API got wrong: a second user
sees an empty library, gets 404 (not 403, which would confirm the id exists)
when reading, updating or deleting someone else's game, and cannot reassign
ownership by putting ownerId or userId in the request body.

Also covered: the password policy, that login is indistinguishable between a
wrong password and an absent user, that the availability endpoint leaks no
row data, that a token signed with an untrusted key is refused, that the
sort parameter is allow-listed rather than interpolated, and that uploads
must decode as an image regardless of extension or content type.

36 tests, ~1s. Program is now declared public partial so the test host can
reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:16:33 -04:00
ckochandClaude Opus 5 cd5c8fb24e Add Wikipedia fallback so every game has cover art
libretro-thumbnails stops at the retro consoles, leaving the ten Xbox 360
titles blank. English Wikipedia carries a cover on essentially every
notable game article and needs no account, so it now runs as a second pass
for anything libretro cannot match.

Correcting an earlier claim in this repo: libretro does publish a
"Microsoft - Xbox 360" set. I had reported it as absent after checking only
my own hardcoded system map, not the actual catalogue of 123 sets. The set
turns out to hold about a dozen entries, none of them ours, so the
conclusion held but the reason given was wrong. It is now mapped and
searched anyway, in case it fills out later.

The cover filename is read from the article's infobox rather than inferred
from file names: filtering names for "box" also matches
"Xbox-360-Pro-wController.png". Two details that cost a round each:

  * the infobox writes the field both bare ("Halo 3 final boxshot.JPG") and
    prefixed ("File:Lost-Planet-New.jpg"), so any prefix is stripped before
    exactly one is added back
  * the API returns 429 under an unthrottled loop, so calls are spaced one
    second apart, retried with a longer backoff, and cached to disk

Coverage is now 105/105 — 93 from libretro, 12 from Wikipedia.

Two rows took art of the right game but the wrong platform, because their
system field looks wrong in the source data: a Game Boy "Donkey Kong
Country 2" and a DS "Donkey Kong Country Returns". Noted in the README
rather than silently corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:04:55 -04:00
ckochandClaude Opus 5 55182a4da7 Add cover art fetcher; letterbox covers instead of cropping
tools/cover-art/fetch_art.py matches each game against libretro-thumbnails
by title + system and attaches the result through the app's own
POST /api/images, so fetched art goes through the same validation and WebP
re-encoding as a manual upload. Standard library only.

Matching bridges a personal catalogue and a ROM-naming one:
  * accents stripped, so "Pokemon Yellow" reaches "Pokémon"
  * roman numerals folded to digits, so the SNES "Final Fantasy 2" lands on
    "Final Fantasy II" and the PS1 "Final Fantasy V" on its own entry
  * trailing articles unwound ("Sims 2, The" -> "The Sims 2")
  * subtitle containment in both directions, since our rows sometimes omit
    what the catalogue carries ("Wave Race 64" vs "... - Kawasaki Jet Ski")
    and sometimes carry what it omits ("Donkey Kong Country 2: Diddy's Kong
    Quest" vs the GBA set's "Donkey Kong Country 2")
  * a sequel guard, so containment cannot collapse "Donkey Kong Country 2"
    onto "Donkey Kong Country"
  * fuzzy enough to absorb typos: "Brett Hull Hocky 95" finds "Hockey 95"

93 of 105 games now have art. The remainder: 10 Xbox 360 titles, which
libretro has no thumbnail set for, and two rows whose platform looks wrong
in the source data (a Game Boy "Donkey Kong Country 2", which was never
released on that system, and a DS "Donkey Kong Country Returns", which was
Wii and later 3DS).

Real art also invalidated a layout assumption: the grid used object-fit:
cover, which was fine for uniform placeholders but crops actual boxes, whose
aspect ratios run from near-square SNES to tall N64. Switched the grid and
the editor preview to object-fit: contain so the whole cover is visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:41:02 -04:00
ckochandClaude Opus 5 a99c8381b1 Disable critical-CSS inlining so the stylesheet survives the CSP
Angular's production build defers the main stylesheet with
`media="print" onload="this.media='all'"` and inlines a critical subset
ahead of it. The nginx CSP sets `script-src 'self'`, which blocks that
inline event handler — so the swap never ran and the stylesheet stayed
print-only. The app rendered from the ~23kB critical subset alone.

Most of the page still looked right, which is what made it easy to miss.
Material icons did not: `.material-icons` was not in the critical subset,
so every icon fell back to the body font and rendered its ligature name
("videogame_asset") clipped to the icon box.

`inlineCritical: false` emits a plain <link rel="stylesheet">. The
stylesheet is 24kB and same-origin, so the optimisation bought little and
cost correctness under a strict CSP.

Verified in headless Chromium: icons render as glyphs across the toolbar,
grid, editor and mobile layouts, and the console is now clean where it
previously logged four CSP violations per page load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:45:46 -04:00
ckochandClaude Opus 5 2a7d90b2d5 Rebuild on Angular 22 + ASP.NET Core 10, containerised
The 2018 stack (Angular 5.2 / CLI 1.7, PHP, MySQL) had not been touched since
July 2018. Rebuilt rather than upgraded in place: the frontend was 17 major
versions behind, and of ~16,700 lines of PHP only ~150 were application logic —
the rest was four near-identical vendored copies of php-crud-api plus
class.upload.php.

Backend — ASP.NET Core 10, EF Core, SQLite
  * ASP.NET Core Identity (PBKDF2) + JWT bearer auth
  * Clean REST API replacing php-crud-api's filter[]/transform query syntax
  * Box art uploads re-encoded to WebP via SkiaSharp
  * Imports the 105 games recovered from the 2018 dump on first run

Frontend — Angular 22, zoneless, signals, Material 22
  * Standalone components, lazy routes, functional guards and interceptor
  * Vitest replaces Karma/Jasmine; fonts and icons bundled, no CDN calls
  * No provideAnimations: @angular/animations is deprecated in v22 and
    Material no longer depends on it (pinned by a test)

Docker
  * Multi-stage builds for both services, non-root at runtime
  * nginx serves the SPA and reverse-proxies the API, so everything is
    same-origin; one volume holds the database, uploads and DP keys

Security issues in the old code, not carried across:
  * Two endpoints exposed unauthenticated CRUD over every table
  * The client chose whose rows to read (filter[]=userId,eq,N); ownership now
    comes from the JWT subject server-side
  * Login was hardcoded to a single username
  * crypt() with one global salt, silently truncating passwords to 8 chars
  * JWT secret was the literal string "testing", tokens never expired
  * Token travelled in the query string rather than a header
  * Uploads were anonymous with the path built from the client filename
  * Access-Control-Allow-Origin: *

The live MySQL password committed in 2018 remains in git history and must be
rotated independently of this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:46:33 -04:00
208 changed files with 23623 additions and 31942 deletions
-62
View File
@@ -1,62 +0,0 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"project": {
"name": "ludos-data"
},
"apps": [
{
"root": "src",
"outDir": "dist",
"assets": [
"assets",
"favicon.ico"
],
"index": "index.html",
"main": "main.ts",
"polyfills": "polyfills.ts",
"test": "test.ts",
"tsconfig": "tsconfig.app.json",
"testTsconfig": "tsconfig.spec.json",
"prefix": "app",
"styles": [
"styles.css"
],
"scripts": [
],
"environmentSource": "environments/environment.ts",
"environments": {
"dev": "environments/environment.ts",
"prod": "environments/environment.prod.ts"
}
}
],
"e2e": {
"protractor": {
"config": "./protractor.conf.js"
}
},
"lint": [
{
"project": "src/tsconfig.app.json",
"exclude": "**/node_modules/**"
},
{
"project": "src/tsconfig.spec.json",
"exclude": "**/node_modules/**"
},
{
"project": "e2e/tsconfig.e2e.json",
"exclude": "**/node_modules/**"
}
],
"test": {
"karma": {
"config": "./karma.conf.js"
}
},
"defaults": {
"styleExt": "css",
"component": {}
}
}
+66
View File
@@ -0,0 +1,66 @@
# Copy to .env and fill in. .env is gitignored — never commit real secrets.
#
# The 2018 version of this project committed its live database password to the
# repository, which is why it now has to be treated as compromised. Keep secrets
# in .env, and keep .env out of git.
# --- Required --------------------------------------------------------------
# JWT signing key. Minimum 32 characters; the API refuses to start without it.
# Generate one with: openssl rand -base64 48
JWT_KEY=
# --- First-run seeding ------------------------------------------------------
# On a database with no users, the API creates this account and imports the 105
# games recovered from the 2018 MySQL dump. Once a user exists, this is ignored.
# Password rules: 12+ chars, upper, lower and a digit.
SEED_USERNAME=ckoch
SEED_EMAIL=you@example.com
SEED_PASSWORD=
# Set to false once you are past first run, or to start with an empty library.
SEED_ENABLED=true
# --- Optional ---------------------------------------------------------------
# Host port the web UI is published on.
WEB_PORT=8080
# Token lifetime in minutes. Default is 12 hours; there is no refresh flow, so
# expiry returns you to the login form.
JWT_LIFETIME_MINUTES=720
JWT_ISSUER=LudosData
JWT_AUDIENCE=LudosData
CORS_ORIGIN=http://localhost:8080
# --- Market value (optional) ------------------------------------------------
# Three routes, none of which block the others. Nothing here is required: with
# all of it blank, prices can still be imported as a CSV or typed in by hand.
#
# 1. CSV PRICE GUIDE — no account, works immediately.
# POST a CSV to /api/prices/import. Columns are matched by name, so a
# PriceCharting bulk download, a spreadsheet you maintain, or any other list
# all work. Nothing to configure here.
#
# 2. PRICECHARTING — paid, but access is immediate with no review, which makes
# it the practical choice while an eBay keyset is in verification. Token
# comes from the Subscriptions page, "API/Download" button.
PRICECHARTING_TOKEN=
# 3. EBAY BROWSE — free, but the production keyset needs account verification.
#
# 1. Register at https://developer.ebay.com and create a developer account
# 2. Create an application keyset (Application Keys -> Production)
# 3. Copy the App ID (Client ID) and Cert ID (Client Secret) below
#
# Leave these blank and the pricing endpoints report 503 with an explanation;
# nothing else is affected.
#
# IMPORTANT: Browse returns ACTIVE LISTINGS, which are asking prices, not
# completed sales. eBay's sold-price data lives behind the Marketplace Insights
# API, which is a limited release not open to new developers. Expect these
# figures to read high — they are an upper bound, not a valuation.
EBAY_CLIENT_ID=
EBAY_CLIENT_SECRET=
# Set true to use eBay's sandbox while checking credentials.
EBAY_USE_SANDBOX=false
+32 -38
View File
@@ -1,48 +1,42 @@
# See http://help.github.com/ignore-files/ for more about ignoring files. # Secrets — never commit. The 2018 version of this project committed live
# database credentials, which is why they now have to be treated as compromised.
.env
*.env
!.env.example
# compiled output # Runtime state
/dist data/
/dist-server uploads/
/tmp *.db
/out-tsc *.db-shm
*.db-wal
# dependencies # --- Frontend ---
/node_modules node_modules/
frontend/dist/
frontend/.angular/
npm-debug.log*
yarn-error.log*
testem.log
# IDEs and editors # --- Backend ---
/.idea [Bb]in/
[Oo]bj/
*.user
.vs/
# --- Editors / OS ---
.idea/
.project .project
.classpath .classpath
.c9/
*.launch
.settings/
*.sublime-workspace *.sublime-workspace
# IDE - VSCode
.vscode/* .vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/launch.json
# misc !.vscode/tasks.json
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
testem.log
/typings
# e2e
/e2e/*.js
/e2e/*.map
# System Files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
src/app/game-grid/game-grid.component.html *.swp
src/app/game-grid/game-grid.component.html
src/app/game-grid/game-grid.component.ts # Cached libretro directory listings (regenerated on demand)
src/app/game-grid/game-grid.component.html tools/library/.cache/
src/app/games.service.ts
+409 -13
View File
@@ -1,27 +1,423 @@
# LudosData # LudosData
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 1.7.0. A personal video game library: catalogue what you own, what you've dumped,
played and finished.
## Development server Originally built in 2018 on Angular 5 + PHP + MySQL. Rebuilt in 2026 on
**Angular 22** and **ASP.NET Core 10** with **SQLite**, running in Docker.
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. ---
## Code scaffolding ## Quick start
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. ```bash
cp .env.example .env
# Generate a signing key and put it in .env as JWT_KEY:
openssl rand -base64 48
# Also set SEED_USERNAME / SEED_EMAIL / SEED_PASSWORD for the first account.
## Build docker compose up --build
```
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build. Then open <http://localhost:8080> and sign in with the seed credentials.
## Running unit tests On first run the API creates that account and imports the **105 games** recovered
from the 2018 database dump. Seeding only happens while the database has no users.
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). > **Password rules:** 12+ characters, with an uppercase, a lowercase and a digit.
> The API refuses to start if `JWT_KEY` is missing or shorter than 32 characters —
> that is deliberate, so a misconfigured deployment fails loudly instead of
> signing tokens with a guessable key.
## Running end-to-end tests ---
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). ## Layout
## Further help ```
backend/ ASP.NET Core 10 Web API (C#)
src/LudosData.Api/
Domain/ Game, AppUser
Data/ DbContext, migrations, seeder, games.json
Auth/ JWT options, token service
Controllers/ auth, games, images
Services/ image storage
frontend/ Angular 22 SPA
src/app/
core/ models, services, guard, HTTP interceptor
features/ login, register, game-grid, game-edit, account
shared/ toolbar, confirm dialog
archive/ the original 2018 MySQL dump, for provenance
```
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). Everything stateful lives in one Docker volume (`ludos-data`): the SQLite file,
uploaded box art, and the Data Protection keys. Back that volume up and you have
backed up the whole application.
---
## Development
Host tooling (Node 24, .NET 10) is installed via Homebrew. `dotnet-ef` needs
`~/.dotnet/tools` on `PATH`, which `~/.bashrc.d/dotnet.sh` sets up.
```bash
# API on http://localhost:5099
cd backend/src/LudosData.Api
Jwt__Key="a-dev-key-of-at-least-32-characters!!" dotnet run
# SPA on http://localhost:4200, proxying /api and /uploads to :5099
cd frontend
npm start
```
```bash
cd frontend && npm test # vitest
cd backend && dotnet build # 0 warnings expected
```
### Cover art
`tools/library/fetch_art.py` fills in box art, pushing each image through the
app's own `POST /api/images` so it gets the same validation and WebP re-encoding
as a manual upload. Standard library only — no virtualenv, and neither source
needs an account.
It tries two sources in order:
1. **[libretro-thumbnails](https://thumbnails.libretro.com)** — scanned retail
boxes, named to the No-Intro / Redump conventions. Best art where it has any,
but its coverage is the retro consoles. Its `Microsoft - Xbox 360` set exists
but holds about a dozen entries.
2. **English Wikipedia** — a cover on essentially every notable game article,
which is what fills the Xbox 360 shelf. The exact filename is read from the
article's infobox rather than guessed from file names, since filtering names
for "box" also matches `Xbox-360-Pro-wController.png`. Calls are throttled to
one per second and cached; the API returns 429 if pushed harder.
```bash
cd tools/library
python3 fetch_art.py --password '...' --dry-run # report matches, change nothing
python3 fetch_art.py --password '...' # download and attach
python3 fetch_art.py --password '...' --overwrite # also replace existing art
python3 fetch_art.py --password '...' --no-wikipedia # libretro only
```
Always dry-run first; it prints every match with a similarity score, marks the
source (`W` for Wikipedia), and flags anything below 0.95 for eyeballing.
Matching handles the gaps between a personal catalogue and a ROM-naming one:
accents (`Pokemon` → `Pokémon`), roman numerals (our SNES `Final Fantasy 2` is
the catalogue's `Final Fantasy II`), trailing articles (`Sims 2, The`), missing
subtitles in either direction, and outright typos — `Brett Hull Hocky 95` finds
`Brett Hull Hockey 95`. A sequel guard stops `Donkey Kong Country 2` from
silently taking `Donkey Kong Country`'s box.
All 105 games currently have art: 93 from libretro, 12 from Wikipedia.
Two rows got art of the right *game* but the wrong *platform*, because the
platform in the source data looks wrong — a Game Boy "Donkey Kong Country 2"
(never released on that system; the handheld sequels were *Donkey Kong Land*)
and a DS "Donkey Kong Country Returns" (a Wii game, later *Returns 3D* on 3DS).
Fix the system field and re-run with `--overwrite` to correct them.
Art is publisher copyright. Fetching it for a private collection is ordinary
practice for library software; redistributing it is a different question.
### Metadata enrichment
`tools/library/enrich_metadata.py` fills developer, publisher, year and
description from the same Wikipedia articles the cover fetcher locates. Only
empty fields are touched unless `--overwrite` is passed — anything typed by hand
outranks anything derived here.
```bash
cd tools/library
python3 enrich_metadata.py --password '...' --dry-run
python3 enrich_metadata.py --password '...'
python3 enrich_metadata.py --password '...' --fields developer,publisher
```
Coverage went from this to this:
| Field | Before | After |
| --- | --- | --- |
| year | 79% | 96% |
| developer | 3.8% | 95% |
| publisher | 2.9% | 96% |
| description | 0% | 96% |
Developer, publisher and year are facts, written verbatim. Descriptions are
article summaries, which are CC BY-SA, so each is stored with an attribution
line naming the source article.
Parsing an infobox is messier than it looks, and the guards matter:
- **Series articles are rejected.** A substring test for `Infobox video game`
also matches `Infobox video game series`, which resolved Banjo-Kazooie to the
series overview instead of the 1998 game.
- **Year is only filled when the article covers that platform.** An article
spans every release, and its date block leads with the original — so a DS
port would otherwise be dated to the SNES original.
- **A search hit that neither covers the platform nor closely matches the title
is discarded.** Our "Dragon Ball Z Budokai" surfaces "Dragon Ball Z: Shin
Budokai", a different game on a different console. A blank field beats a
confidently wrong one.
- Platform headings (`'''PlayStation'''`), region codes (`JP`, `NA`), trailing
platform annotations (`Rare (N64)`) and named template parameters (`title=`)
are all stripped, since each one otherwise reads as the value itself.
Four games have no usable article: a typo'd title (`Brett Hull Hocky 95`),
`Dragon Ball Z Budokai`, and two niche releases.
### Export and import
Until this existed, the only backup was the Docker volume.
- `GET /api/library/export?format=json|csv`
- `POST /api/library/import?mode=Merge|Replace&dryRun=true` (multipart `file`)
Both are in the UI on the account page. JSON round-trips exactly and is the
right choice for a backup; CSV opens in a spreadsheet and is written with a BOM
so Excel does not mangle `Pokémon`. **Box art images are not bundled** — they
live in the upload volume, and a library imported into a fresh instance will
reference art that is not there until the fetcher runs again.
Rows are matched on **title + system**, so the same game on three consoles stays
three entries. `Merge` adds and updates but never deletes; `Replace` wipes the
library first and is confirmed twice in the UI. `dryRun` reports exactly what
would happen and writes nothing.
### Collector fields
Beyond the four original flags, each game carries rating (1-10), notes,
condition, region, what you paid and when, and a current market value.
`condition` is not cosmetic: price feeds quote per condition, and the gap
between loose and sealed is routinely a multiple, so it selects which quoted
price applies to a copy.
Market value is stored with **when it was captured** and **where it came from**.
A figure with neither is not something you can reason about, and a collection
total is only as good as its staleness. Editing an unrelated field leaves the
timestamp alone; changing the figure moves it. A future price feed writes the
same three columns.
**Money is stored as integer minor units.** SQLite has no decimal type, and EF
Core's default maps `decimal` to TEXT, which compares lexically — `"9.00"` sorts
above `"10.00"`, and SUM does not work at all. A value converter keeps the C#
side as `decimal` while ordering and totalling behave.
**Enums travel as names, not ordinals.** `"Cib"` is self-describing in a payload,
an export and a log line; `2` is not, and renumbering the enum would silently
reinterpret every stored export.
New query parameters: `condition`, `region`, `minRating`, `hasValue`. New sort
keys: `rating`, `value`, `price`, `purchased`.
### Market value
Three routes, deliberately independent, because each is blocked differently.
| Route | Blocked by | Cost | Basis |
| --- | --- | --- | --- |
| **CSV price guide** | nothing | free | whatever you supply |
| **PriceCharting** | nothing — immediate on subscribing | paid | sale-derived, per condition |
| **eBay Browse** | production keyset needs account verification | free | asking prices, reads high |
| Manual entry | nothing | free | your own judgement |
```
GET /api/prices/status which sources are usable
POST /api/prices/refresh {provider, dryRun} price from a live source
POST /api/prices/import (multipart CSV) apply a price list
```
**The CSV route needs no account and works today.** Column names are matched by
alias, so `product-name` / `console-name` / `loose-price` from a PriceCharting
export and a hand-kept `title,system,loose,cib,new` sheet are both accepted, as
are `$`, thousands separators and blank cells. Rows are matched on title +
system, so the same game on two consoles is priced separately; rows for games
you do not own are reported rather than added.
Free sources that do **not** work for this: eBay's completed-sales data sits
behind the Marketplace Insights API, a limited release closed to new
developers; NEXARDA and CheapShark price current retail and digital
storefronts, not collectibles. Scraping PriceCharting violates their terms.
Deriving a price from eBay listings takes more than an average:
- **Listings are classified into loose / CIB / new** from their titles, since a
mixed feed has no single price. Accessories are discarded — a "box only"
listing at $45 counted as a copy would halve the loose estimate for a $130
cartridge — as are reproductions and multi-game lots.
- **The discard qualifier is required.** An early version matched a bare "box",
which threw away "complete in box" and "with box and manual" — most of the CIB
tier — while keeping exactly the listings the filter existed to remove. Caught
by a test asserting on tiers rather than counts.
- **Median with an interquartile trim.** One optimist asking 50x moves a mean
and not a median.
- **Sample counts travel with the estimate**, because a tier drawn from two
listings deserves less confidence than one drawn from thirty.
Three prices are stored per game, and `marketValue` is whichever tier matches
that copy's condition — so changing a condition re-prices it with no further
lookup. Every value carries the source that wrote it and the moment it was
captured.
**Using PriceCharting.** Subscribe, take the token from the Subscriptions page
("API/Download"), and set `PRICECHARTING_TOKEN` in `.env`. Their API access
comes with a paid subscription; the bulk CSV download is limited to their top
tier, so check which tier you need before subscribing — this integration does
per-game lookups and only needs the API.
Run a dry run first:
```bash
curl -X POST localhost:8080/api/prices/refresh \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"provider":"pricecharting","dryRun":true,"limit":10}'
```
The response reports **which product each game matched** — name, console and id
— alongside the prices. That matters more than it sounds: a lookup for the DS
"Chrono Trigger" that quietly resolves to the SNES original returns entirely
plausible numbers for the wrong game. Check the matches, then run without
`dryRun`.
Once a game is priced, the matched product id is stored and later refreshes look
it up directly, so they are cheaper and cannot drift to a different edition.
Their published API docs are not reachable without an account, so the response
parser follows the widely-used convention — integer pennies under hyphenated
keys — and is tolerant enough that a naming mismatch reads as "no price" rather
than throwing. `PriceChartingProvider.Parse` is the one place to adjust.
### Dashboard
`/dashboard` — one `GET /api/stats` call, aggregated server-side in a single pass
over the library rather than a dozen grouped queries that could disagree.
Form was chosen before colour, and most of the page is deliberately not a chart:
headline numbers are stat tiles, the backlog is a link into a filtered library
view, and the breakdowns are horizontal bar lists with the value printed on each
row — which doubles as the table view.
Colour decisions worth keeping:
- **One hue for the breakdown bars.** Identity is carried by the axis labels, so
colour has nothing to encode; twelve systems in twelve hues would be twelve
ways to be wrong, and a darker-where-bigger ramp would double-encode length.
- **An ordinal ramp for the funnel**, because owned → played → finished are
ordered stages, not peer categories.
- **The palette was validated with the dataviz validator against this app's own
card surfaces** (`#f8f2f6` light, `#1d1b1e` dark), not against a reference
surface. That mattered: the documented ordinal light-end measured 1.91:1 here
and failed the 2:1 floor, so the ramp starts a step darker.
- **Status colour appears once**, on the stale-valuation notice, always with an
icon and text so it never carries meaning alone.
The value card is deliberately wordy. A bare total silently mixes fresh and old
valuations and excludes everything unpriced, so coverage, age and source travel
with the figure, and a valuation older than 90 days is called out.
Theming note: the chart variables use `light-dark()` rather than a
`prefers-color-scheme` block. Angular's emulated encapsulation scopes selectors
in component styles, so a `:root`-prefixed media override never matches from
there — and setting `color-scheme` on the container overrides how every
descendant resolves `light-dark()`, which rendered light cards on a dark page.
### Database changes
```bash
cd backend/src/LudosData.Api
dotnet ef migrations add <Name> --output-dir Data/Migrations
```
Migrations are applied automatically at startup.
---
## API
All `/api/games` and `/api/images` routes require `Authorization: Bearer <token>`.
| Method | Route | Notes |
| --- | --- | --- |
| `POST` | `/api/auth/register` | Returns a token; signs the new user straight in |
| `POST` | `/api/auth/login` | Returns `{ token, expiresAt, user }` |
| `GET` | `/api/auth/me` | Current user |
| `GET` | `/api/auth/available?userName=` / `?email=` | Returns only a boolean |
| `GET` | `/api/games` | `search, system, genre, own, dumped, played, finished, page, pageSize, sort, dir` |
| `GET` | `/api/games/{id}` | |
| `GET` | `/api/games/facets` | Distinct systems and genres, for filter dropdowns |
| `POST` | `/api/games` | |
| `PUT` | `/api/games/{id}` | |
| `DELETE` | `/api/games/{id}` | |
| `POST` | `/api/images` | multipart `file`; re-encodes to WebP |
| `GET` | `/health` | Anonymous |
**Ownership is always taken from the JWT subject, never from the request.** A game
belonging to another user returns `404`, not `403`, so the response does not
confirm that the id exists.
---
## Security notes
### Rotate the old database password
The 2018 code committed live MySQL credentials to this repository
(`interfaceServices/dbConfig.php`, and again in four other files). They are in git
history. **That password must be considered compromised and rotated**, regardless
of this rewrite. Removing the files does not remove them from history.
The new stack keeps secrets in `.env`, which is gitignored.
### What was fixed in the rewrite
The old backend was ~16,700 lines of PHP, of which ~16,400 were vendored
third-party code — four near-identical copies of `php-crud-api` plus
`class.upload.php`. Only ~150 lines were application logic. These problems were
not carried across:
| Old behaviour | Now |
| --- | --- |
| Two endpoints exposed unauthenticated CRUD over every table | Every data route requires a valid token |
| Client chose whose rows to read (`filter[]=userId,eq,N`) | Owner comes from the JWT subject, server-side |
| Login hardcoded to a single username | Any registered user can sign in |
| `crypt()` with one global salt, silently truncating passwords to 8 chars | ASP.NET Core Identity (PBKDF2, per-user salt) |
| JWT secret was the literal string `"testing"`, tokens never expired | Key required from config, 12-hour expiry |
| Token passed in the query string | `Authorization: Bearer` header |
| Uploads anonymous, path built from the client filename | Authenticated, server-generated name, per-user folder, must decode as an image |
| `Access-Control-Allow-Origin: *` | Explicit origin allowlist |
Passwords could not be migrated — the old hashes are unrecoverable by design.
### Known accepted risk
`npm audit` reports a moderate advisory in `@hono/node-server`, reached
transitively through `@angular/cli`'s MCP server feature. It is:
- **dev-only** — `npm audit --omit=dev` reports 0 vulnerabilities, and it is not in the browser bundle
- a **Windows-only** path traversal, on a Linux-only toolchain here
`npm audit fix --force` would downgrade Angular CLI to 21.0.4, a breaking change.
Overriding the dependency means forcing a major bump the MCP SDK does not accept
(`^1.19.9`). Left as-is deliberately; revisit when Angular CLI updates the SDK.
---
## Notable version facts (as of 2026-08)
- **Angular 22.1** is **zoneless** — there is no `zone.js` in the dependency tree.
Component state must be signal-based for change detection to see it.
- **`@angular/animations` is deprecated in v22**; Material 22 no longer depends on
it. There is no `provideAnimations()` in `app.config.ts`, and a test pins that
Material still renders without one.
- Unit tests run on **Vitest**, not Karma/Jasmine.
- Fonts and Material icons are bundled from `node_modules`, so the app makes no
third-party requests at runtime.
- The backend pins two transitive packages (`Microsoft.OpenApi`,
`SQLitePCLRaw.lib.e_sqlite3`) to clear high-severity advisories. See the comment
in `LudosData.Api.csproj`.
- Image processing uses **SkiaSharp**, not ImageSharp: ImageSharp v4 requires a
paid licence key at build time.
+18
View File
@@ -0,0 +1,18 @@
# Archive
`lazypugn_LudosData_2018-03-14_20-31-02.sql.zip` is the original MySQL dump of the
2018 database, kept for provenance.
It contains a single `games` table with 105 rows. It predates the multi-user work
that was in progress when the project was last touched, so it has **no `users`
table and no `userId` column** — every game in it is unowned.
It has been converted to
[`backend/src/LudosData.Api/Data/Seed/games.json`](../backend/src/LudosData.Api/Data/Seed/games.json),
which the API imports on first run. Nothing reads the zip at runtime.
Conversion notes:
- `id` was dropped; the new table assigns its own keys.
- Empty strings became `null`, matching the new nullable columns.
- `tinyint(1)` flags became booleans.
- `Art` and `Description` were empty on every row, so no images were migrated.
-34
View File
@@ -1,34 +0,0 @@
# PHP-API-AUTH
Single file PHP script that adds authentication to a [PHP-CRUD-API](https://github.com/mevdschee/php-crud-api) project.
## Requirements
- PHP 5.3 or higher
## Simple username + password
On API server
- login.html is loaded
- sends username + password via POST to "api.php/"
- api.php (POST on "/" gets hijacked by auth.php) is loaded
- sends back csrf token + http-only session cookie
- call API as: api.php?csrf=\[csrf token] (session cookie is sent automatically)
- (when using Angular2 or Vue2 the CSRF token is sent automatically)
## With authentication server
On authentication server
- login_token.html is loaded
- sends username + password via POST to "login_token.php"
- login_token.php is loaded
- sends token via POST to "api.php/"
On API server
- api.php (POST on "/" gets hijacked by auth.php) is loaded
- sends back csrf token + http-only session cookie
- call API as: api.php?csrf=\[csrf token] (session cookie is sent automatically)
- (when using Angular2 or Vue2 the CSRF token is sent automatically)
-36
View File
@@ -1,36 +0,0 @@
<?php
// uncomment the lines below when running in stand-alone mode:
// for token+session based authentication (see "login_token.html" + "login_token.php"):
/*
require 'auth.php';
$auth = new PHP_API_AUTH(array(
'secret'=>'someVeryLongPassPhraseChangeMe',
));
if ($auth->executeCommand()) exit(0);
if (empty($_SESSION['user']) || !$auth->hasValidCsrfToken()) {
header('HTTP/1.0 401 Unauthorized');
exit(0);
}
*/
// for form+session based authentication (see "login.html"):
require 'auth.php';
$auth = new PHP_API_AUTH(array(
'authenticator'=>function($user,$pass){ if ($user=='admin' && $pass=='admin') $_SESSION['user']=$user; }
));
if ($auth->executeCommand()) exit(0);
if (empty($_SESSION['user']) || !$auth->hasValidCsrfToken()) {
header('HTTP/1.0 401 Unauthorized');
exit(0);
}
// include your api code here:
//
// see: https://github.com/mevdschee/php-crud-api
//
// placeholder for testing:
// echo 'Access granted!';
-223
View File
@@ -1,223 +0,0 @@
<?php
//var_dump($_SERVER['REQUEST_METHOD'],$_SERVER['PATH_INFO']); die();
class PHP_API_AUTH {
public function __construct($config) {
extract($config);
$verb = isset($verb)?$verb:null;
$path = isset($path)?$path:null;
$username = isset($username)?$username:null;
$password = isset($password)?$password:null;
$token = isset($token)?$token:null;
$authenticator = isset($authenticator)?$authenticator:null;
$method = isset($method)?$method:null;
$request = isset($request)?$request:null;
$post = isset($post)?$post:null;
$origin = isset($origin)?$origin:null;
$time = isset($time)?$time:null;
$leeway = isset($leeway)?$leeway:null;
$ttl = isset($ttl)?$ttl:null;
$algorithm = isset($algorithm)?$algorithm:null;
$secret = isset($secret)?$secret:null;
$allow_origin = isset($allow_origin)?$allow_origin:null;
// defaults
if (!$verb) {
$verb = 'POST';
}
if (!$path) {
$path = '';
}
if (!$username) {
$username = 'username';
}
if (!$password) {
$password = 'password';
}
if (!$token) {
$token = 'token';
}
if (!$method) {
$method = $_SERVER['REQUEST_METHOD'];
}
if (!$request) {
$request = isset($_SERVER['PATH_INFO'])?$_SERVER['PATH_INFO']:'';
if (!$request) {
$request = isset($_SERVER['ORIG_PATH_INFO'])?$_SERVER['ORIG_PATH_INFO']:'';
}
}
if (!$post) {
$post = 'php://input';
}
if (!$origin) {
$origin = isset($_SERVER['HTTP_ORIGIN'])?$_SERVER['HTTP_ORIGIN']:'';
}
if (!$time) {
$time = time();
}
if (!$leeway) {
$leeway = 5;
}
if (!$ttl) {
$ttl = 30;
}
if (!$algorithm) {
$algorithm = 'HS256';
}
if ($allow_origin===null) {
$allow_origin = '*';
}
$request = trim($request,'/');
$this->settings = compact('verb', 'path', 'username', 'password', 'token', 'authenticator', 'method', 'request', 'post', 'origin', 'time', 'leeway', 'ttl', 'algorithm', 'secret', 'allow_origin');
}
protected function retrieveInput($post) {
$input = (object)array();
$data = trim(file_get_contents($post));
if (strlen($data)>0) {
if ($data[0]=='{') {
$input = json_decode($data);
} else {
parse_str($data, $input);
$input = (object)$input;
}
}
return $input;
}
protected function generateToken($claims,$time,$ttl,$algorithm,$secret) {
$algorithms = array('HS256'=>'sha256','HS384'=>'sha384','HS512'=>'sha512');
$header = array();
$header['typ']='JWT';
$header['alg']=$algorithm;
$token = array();
$token[0] = rtrim(strtr(base64_encode(json_encode((object)$header)),'+/','-_'),'=');
$claims['iat'] = $time;
$claims['exp'] = $time + $ttl;
$token[1] = rtrim(strtr(base64_encode(json_encode((object)$claims)),'+/','-_'),'=');
if (!isset($algorithms[$algorithm])) return false;
$hmac = $algorithms[$algorithm];
$signature = hash_hmac($hmac,"$token[0].$token[1]",$secret,true);
$token[2] = rtrim(strtr(base64_encode($signature),'+/','-_'),'=');
return implode('.',$token);
}
protected function getVerifiedClaims($token,$time,$leeway,$ttl,$algorithm,$secret) {
$algorithms = array('HS256'=>'sha256','HS384'=>'sha384','HS512'=>'sha512');
if (!isset($algorithms[$algorithm])) return false;
$hmac = $algorithms[$algorithm];
$token = explode('.',$token);
if (count($token)<3) return false;
$header = json_decode(base64_decode(strtr($token[0],'-_','+/')),true);
if (!$secret) return false;
if ($header['typ']!='JWT') return false;
if ($header['alg']!=$algorithm) return false;
$signature = bin2hex(base64_decode(strtr($token[2],'-_','+/')));
if ($signature!=hash_hmac($hmac,"$token[0].$token[1]",$secret)) return false;
$claims = json_decode(base64_decode(strtr($token[1],'-_','+/')),true);
if (!$claims) return false;
if (isset($claims['nbf']) && $time+$leeway<$claims['nbf']) return false;
if (isset($claims['iat']) && $time+$leeway<$claims['iat']) return false;
if (isset($claims['exp']) && $time-$leeway>$claims['exp']) return false;
if (isset($claims['iat']) && !isset($claims['exp'])) {
if ($time-$leeway>$claims['iat']+$ttl) return false;
}
return $claims;
}
protected function allowOrigin($origin,$allowOrigins) {
if (isset($_SERVER['REQUEST_METHOD'])) {
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Expose-Headers: X-XSRF-TOKEN');
foreach (explode(',',$allowOrigins) as $o) {
if (preg_match('/^'.str_replace('\*','.*',preg_quote(strtolower(trim($o)))).'$/',$origin)) {
header('Access-Control-Allow-Origin: '.$origin);
break;
}
}
}
}
protected function headersCommand() {
$headers = array();
$headers[]='Access-Control-Allow-Headers: Content-Type, X-XSRF-TOKEN';
$headers[]='Access-Control-Allow-Methods: OPTIONS, GET, PUT, POST, DELETE, PATCH';
$headers[]='Access-Control-Allow-Credentials: true';
$headers[]='Access-Control-Max-Age: 1728000';
if (isset($_SERVER['REQUEST_METHOD'])) {
foreach ($headers as $header) header($header);
} else {
echo json_encode($headers);
}
}
public function hasValidCsrfToken() {
$csrf = isset($_SESSION['csrf'])?$_SESSION['csrf']:false;
if (!$csrf) return false;
$get = isset($_GET['csrf'])?$_GET['csrf']:false;
$header = isset($_SERVER['HTTP_X_XSRF_TOKEN'])?$_SERVER['HTTP_X_XSRF_TOKEN']:false;
return ($get == $csrf) || ($header == $csrf);
}
public function executeCommand() {
extract($this->settings);
if ($origin) {
$this->allowOrigin($origin,$allow_origin);
}
if ($method=='OPTIONS') {
$this->headersCommand();
return true;
}
$no_session = $authenticator && $secret;
if (!$no_session) {
ini_set('session.cookie_httponly', 1);
session_start();
if (!isset($_SESSION['csrf'])) {
if (function_exists('random_int')) $_SESSION['csrf'] = 'N'.random_int(0,PHP_INT_MAX);
else $_SESSION['csrf'] = 'N'.rand(0,PHP_INT_MAX);
}
}
if ($method==$verb && trim($path,'/')==$request) {
$input = $this->retrieveInput($post);
if ($authenticator && isset($input->$username) && isset($input->$password)) {
$authenticator($input->$username,$input->$password);
if ($no_session) {
echo json_encode($this->generateToken($_SESSION,$time,$ttl,$algorithm,$secret));
} else {
session_regenerate_id();
setcookie('XSRF-TOKEN',$_SESSION['csrf'],0,'/');
header('X-XSRF-TOKEN: '.$_SESSION['csrf']);
echo json_encode($_SESSION['csrf']);
}
} elseif ($secret && isset($input->$token)) {
$claims = $this->getVerifiedClaims($input->$token,$time,$leeway,$ttl,$algorithm,$secret);
if ($claims) {
foreach ($claims as $key=>$value) {
$_SESSION[$key] = $value;
}
session_regenerate_id();
setcookie('XSRF-TOKEN',$_SESSION['csrf'],0,'/');
header('X-XSRF-TOKEN: '.$_SESSION['csrf']);
echo json_encode($_SESSION['csrf']);
}
} else {
if (!$no_session) {
session_destroy();
}
}
return true;
}
return false;
}
}
-5
View File
@@ -1,5 +0,0 @@
<form method="post" action="loginInterface.php/">
<input name="username" value="admin"/>
<input name="password" value="admin"/>
<input type="submit" value="ok">
</form>
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
<form method="post" action="login_token.php">
<input name="username" value="admin"/>
<input name="password" value="admin"/>
<input type="submit" value="ok">
</form>
-13
View File
@@ -1,13 +0,0 @@
<form method="post" action="api.php/">
<input name="token" value=
<?php
require 'auth.php';
$auth = new PHP_API_AUTH(array(
'secret'=>'someVeryLongPassPhraseChangeMe',
'authenticator'=>function($user,$pass){ if ($user=='admin' && $pass=='admin') $_SESSION['user']=$user; }
));
$auth->executeCommand();
?>/>
<input type="submit" value="ok">
</form>
-3
View File
@@ -1,3 +0,0 @@
<form method="post" action="api.php/">
<input type="submit" value="logout">
</form>
+7
View File
@@ -0,0 +1,7 @@
**/bin/
**/obj/
**/data/
**/uploads/
**/*.user
**/.vs/
**/.vscode/
+50
View File
@@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1
# ---- build ----------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore against the project file alone so the layer caches across code edits.
COPY src/LudosData.Api/LudosData.Api.csproj src/LudosData.Api/
RUN dotnet restore src/LudosData.Api/LudosData.Api.csproj
COPY src/ src/
RUN dotnet publish src/LudosData.Api/LudosData.Api.csproj \
-c Release \
-o /app/publish \
--no-restore \
/p:UseAppHost=false
# ---- runtime --------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# The runtime image ships neither curl nor wget, so the container healthcheck
# below has nothing to probe with unless one is added.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# Run as the non-root user the base image already ships with.
ENV ASPNETCORE_HTTP_PORTS=8080 \
DOTNET_RUNNING_IN_CONTAINER=true \
ConnectionStrings__Default="Data Source=/data/ludos.db" \
Uploads__RootPath=/data/uploads
COPY --from=build /app/publish .
# Writable mount point for the SQLite file and uploaded art. Declared as a volume
# so an unmounted run still persists for the life of the container rather than
# failing to open the database.
RUN mkdir -p /data/uploads && chown -R $APP_UID:$APP_UID /data
VOLUME ["/data"]
USER $APP_UID
EXPOSE 8080
# Probes the app's own health endpoint, so "healthy" means it is actually
# serving requests rather than merely that the process exists.
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
CMD curl -fsS http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "LudosData.Api.dll"]
+8
View File
@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/LudosData.Api/LudosData.Api.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/LudosData.Api.Tests/LudosData.Api.Tests.csproj" />
</Folder>
</Solution>
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.302",
"rollForward": "latestFeature"
}
}
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace LudosData.Api.Auth;
public class JwtOptions
{
public const string SectionName = "Jwt";
/// <summary>
/// HMAC-SHA256 signing key. Supplied via the JWT__KEY environment variable —
/// there is deliberately no default, so a misconfigured deployment fails to
/// start rather than signing tokens with a guessable key.
/// </summary>
[Required(AllowEmptyStrings = false)]
[MinLength(32, ErrorMessage = "Jwt:Key must be at least 32 characters.")]
public string Key { get; set; } = string.Empty;
[Required] public string Issuer { get; set; } = "LudosData";
[Required] public string Audience { get; set; } = "LudosData";
/// <summary>
/// Access token lifetime. Twelve hours suits a single-user library app; there
/// is no refresh token flow, so expiry sends the user back to the login form.
/// </summary>
[Range(1, 24 * 60 * 7)]
public int LifetimeMinutes { get; set; } = 720;
}
@@ -0,0 +1,65 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using LudosData.Api.Domain;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace LudosData.Api.Auth;
public interface ITokenService
{
(string Token, DateTimeOffset ExpiresAt) CreateAccessToken(AppUser user);
}
public class TokenService(IOptions<JwtOptions> options) : ITokenService
{
private readonly JwtOptions _options = options.Value;
public (string Token, DateTimeOffset ExpiresAt) CreateAccessToken(AppUser user)
{
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_options.LifetimeMinutes);
var claims = new List<Claim>
{
// The subject is the only thing authorization trusts. Ownership checks
// read it server-side; the client cannot influence which rows it sees.
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(ClaimTypes.NameIdentifier, user.Id),
};
if (!string.IsNullOrEmpty(user.UserName))
{
claims.Add(new Claim(JwtRegisteredClaimNames.UniqueName, user.UserName));
}
if (!string.IsNullOrEmpty(user.Email))
{
claims.Add(new Claim(JwtRegisteredClaimNames.Email, user.Email));
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key));
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: DateTime.UtcNow,
expires: expiresAt.UtcDateTime,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}
}
public static class ClaimsPrincipalExtensions
{
/// <summary>
/// The authenticated user's id. Throws rather than returning null: every call
/// site sits behind [Authorize], so a missing subject is a bug, not a branch.
/// </summary>
public static string GetUserId(this ClaimsPrincipal principal) =>
principal.FindFirstValue(ClaimTypes.NameIdentifier)
?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? throw new InvalidOperationException("Authenticated principal has no subject claim.");
}
@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
namespace LudosData.Api.Contracts;
public record RegisterRequest
{
[Required, MinLength(3), MaxLength(50)]
public string UserName { get; init; } = string.Empty;
[Required, EmailAddress, MaxLength(256)]
public string Email { get; init; } = string.Empty;
[Required, MinLength(12), MaxLength(128)]
public string Password { get; init; } = string.Empty;
[MaxLength(100)] public string? FirstName { get; init; }
[MaxLength(100)] public string? LastName { get; init; }
}
public record LoginRequest
{
[Required] public string UserName { get; init; } = string.Empty;
[Required] public string Password { get; init; } = string.Empty;
}
public record UserResponse(
string Id,
string UserName,
string? Email,
string? FirstName,
string? LastName,
string? Art);
public record AuthResponse(string Token, DateTimeOffset ExpiresAt, UserResponse User);
public record AvailabilityResponse(bool Available);
@@ -0,0 +1,134 @@
using System.ComponentModel.DataAnnotations;
using LudosData.Api.Domain;
namespace LudosData.Api.Contracts;
/// <summary>A page of results plus the totals the paginator needs.</summary>
public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int Total)
{
public int TotalPages => PageSize > 0 ? (int)Math.Ceiling(Total / (double)PageSize) : 0;
}
/// <summary>
/// A game as returned to the client. <c>Art</c> is the stored filename; <c>ArtUrl</c>
/// is the ready-to-use URL built server-side, so the client never has to
/// string-concatenate upload paths the way the old grid did.
/// </summary>
public record GameResponse(
int Id,
string Title,
string? System,
string? Genre,
string? Year,
string? Developer,
string? Publisher,
string? Art,
string? ArtUrl,
string? Description,
bool Own,
bool Dumped,
bool Played,
bool Finished,
int? Rating,
string? Notes,
GameCondition Condition,
GameRegion Region,
decimal? PurchasePrice,
DateOnly? PurchaseDate,
decimal? MarketValue,
DateTimeOffset? MarketValueUpdatedAt,
string? MarketValueSource,
decimal? ValueLoose,
decimal? ValueCib,
decimal? ValueNew,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
/// <summary>
/// Create/update payload. Deliberately has no Id and no OwnerId — the route supplies
/// the former and the JWT the latter, so neither can be spoofed by the client.
/// </summary>
public record GameRequest
{
[Required(AllowEmptyStrings = false), MaxLength(200)]
public string Title { get; init; } = string.Empty;
[MaxLength(50)] public string? System { get; init; }
[MaxLength(50)] public string? Genre { get; init; }
[MaxLength(50)] public string? Year { get; init; }
[MaxLength(100)] public string? Developer { get; init; }
[MaxLength(100)] public string? Publisher { get; init; }
[MaxLength(200)] public string? Art { get; init; }
[MaxLength(10_000)] public string? Description { get; init; }
public bool Own { get; init; }
public bool Dumped { get; init; }
public bool Played { get; init; }
public bool Finished { get; init; }
[Range(1, 10)] public int? Rating { get; init; }
[MaxLength(10_000)] public string? Notes { get; init; }
public GameCondition Condition { get; init; } = GameCondition.Unspecified;
public GameRegion Region { get; init; } = GameRegion.Unspecified;
[Range(0, 1_000_000)] public decimal? PurchasePrice { get; init; }
public DateOnly? PurchaseDate { get; init; }
/// <summary>
/// Current estimated resale value. Accepted here so a figure can be entered
/// by hand; a price feed will later write the same field, stamping
/// MarketValueUpdatedAt and MarketValueSource as it goes.
/// </summary>
[Range(0, 1_000_000)] public decimal? MarketValue { get; init; }
[MaxLength(100)] public string? MarketValueSource { get; init; }
[Range(0, 1_000_000)] public decimal? ValueLoose { get; init; }
[Range(0, 1_000_000)] public decimal? ValueCib { get; init; }
[Range(0, 1_000_000)] public decimal? ValueNew { get; init; }
}
/// <summary>Query string for the library list, bound from [FromQuery].</summary>
public record GameQuery
{
/// <summary>Free-text match against title, developer and publisher.</summary>
public string? Search { get; init; }
public string? System { get; init; }
public string? Genre { get; init; }
public bool? Own { get; init; }
public bool? Dumped { get; init; }
public bool? Played { get; init; }
public bool? Finished { get; init; }
public GameCondition? Condition { get; init; }
public GameRegion? Region { get; init; }
/// <summary>Lowest personal score to include. Unrated games are excluded when set.</summary>
[Range(1, 10)] public int? MinRating { get; init; }
/// <summary>Restrict to games that do, or do not, have a market value recorded.</summary>
public bool? HasValue { get; init; }
[Range(1, int.MaxValue)] public int Page { get; init; } = 1;
/// <summary>Capped at 100 to keep a hostile or buggy client from asking for everything.</summary>
[Range(1, 100)] public int PageSize { get; init; } = 20;
/// <summary>
/// One of: title, system, genre, year, developer, publisher, rating,
/// value, price, purchased, created, updated.
/// </summary>
public string Sort { get; init; } = "title";
/// <summary>"asc" or "desc".</summary>
public string Dir { get; init; } = "asc";
}
/// <summary>Distinct values present in the user's library, for filter dropdowns.</summary>
public record FacetsResponse(IReadOnlyList<string> Systems, IReadOnlyList<string> Genres);
public record UploadResponse(string FileName, string Url);
@@ -0,0 +1,77 @@
using LudosData.Api.Domain;
namespace LudosData.Api.Contracts;
/// <summary>
/// One game as it appears in an export file.
///
/// Deliberately has no id and no owner: an export is a portable description of a
/// library, not a database dump. On import, rows are matched by title and
/// system, so a file can move between accounts or instances.
/// </summary>
public record ExportGame
{
public string Title { get; init; } = string.Empty;
public string? System { get; init; }
public string? Genre { get; init; }
public string? Year { get; init; }
public string? Developer { get; init; }
public string? Publisher { get; init; }
public string? Description { get; init; }
/// <summary>
/// Stored filename of the box art. The image itself is not bundled, so a
/// file imported into a fresh instance will reference art that is not there
/// until the fetcher is run again.
/// </summary>
public string? Art { get; init; }
public bool Own { get; init; }
public bool Dumped { get; init; }
public bool Played { get; init; }
public bool Finished { get; init; }
// Collector fields travel with the export; a backup that quietly dropped
// ratings, notes and valuations would not be a backup.
public int? Rating { get; init; }
public string? Notes { get; init; }
public GameCondition Condition { get; init; }
public GameRegion Region { get; init; }
public decimal? PurchasePrice { get; init; }
public DateOnly? PurchaseDate { get; init; }
public decimal? MarketValue { get; init; }
public DateTimeOffset? MarketValueUpdatedAt { get; init; }
public string? MarketValueSource { get; init; }
public decimal? ValueLoose { get; init; }
public decimal? ValueCib { get; init; }
public decimal? ValueNew { get; init; }
}
/// <summary>Envelope written by the JSON exporter.</summary>
public record LibraryExport(
string Format,
int Version,
DateTimeOffset ExportedAt,
int Count,
IReadOnlyList<ExportGame> Games);
public enum ImportMode
{
/// <summary>Update rows that match on title + system, insert the rest. Nothing is deleted.</summary>
Merge = 0,
/// <summary>Delete the caller's entire library first, then insert the file.</summary>
Replace = 1,
}
public record ImportRowError(int Row, string Title, string Reason);
public record ImportResult(
bool DryRun,
ImportMode Mode,
int Parsed,
int Created,
int Updated,
int Deleted,
int Skipped,
IReadOnlyList<ImportRowError> Errors);
@@ -0,0 +1,44 @@
namespace LudosData.Api.Contracts;
public record CountByLabel(string Label, int Count);
/// <summary>
/// The owned → played → finished progression. Each stage is a subset of the one
/// before it, so the numbers only make sense read in order.
/// </summary>
public record CompletionFunnel(int Owned, int Played, int Finished);
/// <summary>
/// Collection value, reported with everything needed to judge it.
///
/// A bare total invites a false reading: it silently mixes games priced today
/// with games priced months ago, and quietly excludes everything unpriced. So the
/// coverage, the age range and the sources all travel with the figure.
/// </summary>
public record ValueSummary(
decimal Total,
int PricedCount,
int UnpricedCount,
decimal TotalPaid,
int PaidCount,
DateTimeOffset? OldestValuedAt,
DateTimeOffset? NewestValuedAt,
IReadOnlyList<string> Sources,
/// <summary>What the collection would be worth if every copy were complete in box.</summary>
decimal? TotalIfCib);
public record StatsResponse(
int TotalGames,
CompletionFunnel Funnel,
int Backlog,
int InProgress,
int Dumped,
int RatedCount,
double? AverageRating,
IReadOnlyList<CountByLabel> BySystem,
IReadOnlyList<CountByLabel> ByGenre,
IReadOnlyList<CountByLabel> ByDecade,
IReadOnlyList<CountByLabel> ByCondition,
/// <summary>Rating distribution, 1-10. Only scores actually used appear.</summary>
IReadOnlyList<CountByLabel> ByRating,
ValueSummary Value);
@@ -0,0 +1,118 @@
using LudosData.Api.Auth;
using LudosData.Api.Contracts;
using LudosData.Api.Domain;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace LudosData.Api.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController(
UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
ITokenService tokenService,
ILogger<AuthController> logger) : ControllerBase
{
[HttpPost("register")]
[AllowAnonymous]
public async Task<ActionResult<AuthResponse>> Register(RegisterRequest request)
{
var user = new AppUser
{
UserName = request.UserName,
Email = request.Email,
FirstName = request.FirstName,
LastName = request.LastName,
};
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(error.Code, error.Description);
}
return ValidationProblem(ModelState);
}
logger.LogInformation("Registered user {UserName}", user.UserName);
return Ok(BuildAuthResponse(user));
}
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<AuthResponse>> Login(LoginRequest request)
{
var user = await userManager.FindByNameAsync(request.UserName);
if (user is null)
{
// Same response as a bad password, so this endpoint cannot be used to
// enumerate which usernames exist.
return Unauthorized(new ProblemDetails { Title = "Invalid username or password." });
}
var result = await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
if (result.IsLockedOut)
{
return StatusCode(StatusCodes.Status423Locked,
new ProblemDetails { Title = "Account temporarily locked after too many failed attempts." });
}
if (!result.Succeeded)
{
return Unauthorized(new ProblemDetails { Title = "Invalid username or password." });
}
return Ok(BuildAuthResponse(user));
}
[HttpGet("me")]
[Authorize]
public async Task<ActionResult<UserResponse>> Me()
{
var user = await userManager.FindByIdAsync(User.GetUserId());
return user is null ? Unauthorized() : Ok(ToUserResponse(user));
}
/// <summary>
/// Availability check for the registration form. Replaces the old approach of
/// querying the users table through the generic CRUD endpoint, which exposed
/// every user column to anonymous callers; this returns only a boolean.
/// </summary>
[HttpGet("available")]
[AllowAnonymous]
public async Task<ActionResult<AvailabilityResponse>> Available(
[FromQuery] string? userName,
[FromQuery] string? email)
{
if (!string.IsNullOrWhiteSpace(userName))
{
return Ok(new AvailabilityResponse(await userManager.FindByNameAsync(userName) is null));
}
if (!string.IsNullOrWhiteSpace(email))
{
return Ok(new AvailabilityResponse(await userManager.FindByEmailAsync(email) is null));
}
return BadRequest(new ProblemDetails { Title = "Provide either userName or email." });
}
private AuthResponse BuildAuthResponse(AppUser user)
{
var (token, expiresAt) = tokenService.CreateAccessToken(user);
return new AuthResponse(token, expiresAt, ToUserResponse(user));
}
private static UserResponse ToUserResponse(AppUser user) => new(
user.Id,
user.UserName ?? string.Empty,
user.Email,
user.FirstName,
user.LastName,
user.Art);
}
@@ -0,0 +1,241 @@
using LudosData.Api.Auth;
using LudosData.Api.Contracts;
using LudosData.Api.Data;
using LudosData.Api.Domain;
using LudosData.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LudosData.Api.Controllers;
/// <summary>
/// The user's game library.
///
/// Every query starts from <c>Where(g => g.OwnerId == currentUserId)</c>, taken from
/// the JWT subject. The old API took the owner id from a client-supplied query
/// parameter (<c>filter[]=userId,eq,N</c>), which meant any valid token could read
/// any other user's library by editing the number.
/// </summary>
[ApiController]
[Route("api/games")]
[Authorize]
public class GamesController(
LudosDbContext db,
IImageStorage images,
ILogger<GamesController> logger) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PagedResult<GameResponse>>> List([FromQuery] GameQuery query, CancellationToken ct)
{
var ownerId = User.GetUserId();
var q = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var term = query.Search.Trim();
q = q.Where(g =>
EF.Functions.Like(g.Title, $"%{term}%") ||
(g.Developer != null && EF.Functions.Like(g.Developer, $"%{term}%")) ||
(g.Publisher != null && EF.Functions.Like(g.Publisher, $"%{term}%")));
}
if (!string.IsNullOrWhiteSpace(query.System)) q = q.Where(g => g.System == query.System);
if (!string.IsNullOrWhiteSpace(query.Genre)) q = q.Where(g => g.Genre == query.Genre);
if (query.Own is { } own) q = q.Where(g => g.Own == own);
if (query.Dumped is { } dumped) q = q.Where(g => g.Dumped == dumped);
if (query.Played is { } played) q = q.Where(g => g.Played == played);
if (query.Finished is { } finished) q = q.Where(g => g.Finished == finished);
if (query.Condition is { } condition) q = q.Where(g => g.Condition == condition);
if (query.Region is { } region) q = q.Where(g => g.Region == region);
// An unrated game is not a zero-rated one, so it drops out of a
// minimum-rating filter rather than sorting to the bottom.
if (query.MinRating is { } minRating) q = q.Where(g => g.Rating >= minRating);
if (query.HasValue is { } hasValue)
{
q = hasValue ? q.Where(g => g.MarketValue != null) : q.Where(g => g.MarketValue == null);
}
var total = await q.CountAsync(ct);
q = ApplySort(q, query.Sort, query.Dir);
var items = await q
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync(ct);
return Ok(new PagedResult<GameResponse>(
items.Select(g => ToResponse(g, ownerId)).ToList(),
query.Page,
query.PageSize,
total));
}
[HttpGet("{id:int}")]
public async Task<ActionResult<GameResponse>> Get(int id, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = await db.Games.AsNoTracking()
.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
// A game belonging to someone else is reported as 404, not 403 — the
// response should not confirm that the id exists.
return game is null ? NotFound() : Ok(ToResponse(game, ownerId));
}
[HttpGet("facets")]
public async Task<ActionResult<FacetsResponse>> Facets(CancellationToken ct)
{
var ownerId = User.GetUserId();
var mine = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId);
var systems = await mine
.Where(g => g.System != null && g.System != "")
.Select(g => g.System!)
.Distinct().OrderBy(s => s).ToListAsync(ct);
var genres = await mine
.Where(g => g.Genre != null && g.Genre != "")
.Select(g => g.Genre!)
.Distinct().OrderBy(s => s).ToListAsync(ct);
return Ok(new FacetsResponse(systems, genres));
}
[HttpPost]
public async Task<ActionResult<GameResponse>> Create(GameRequest request, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = new Game { OwnerId = ownerId };
Apply(request, game);
db.Games.Add(game);
await db.SaveChangesAsync(ct);
logger.LogInformation("User {OwnerId} created game {GameId}", ownerId, game.Id);
return CreatedAtAction(nameof(Get), new { id = game.Id }, ToResponse(game, ownerId));
}
[HttpPut("{id:int}")]
public async Task<ActionResult<GameResponse>> Update(int id, GameRequest request, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
if (game is null) return NotFound();
Apply(request, game);
await db.SaveChangesAsync(ct);
return Ok(ToResponse(game, ownerId));
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
if (game is null) return NotFound();
db.Games.Remove(game);
await db.SaveChangesAsync(ct);
logger.LogInformation("User {OwnerId} deleted game {GameId}", ownerId, id);
return NoContent();
}
private static IQueryable<Game> ApplySort(IQueryable<Game> q, string sort, string dir)
{
var descending = string.Equals(dir, "desc", StringComparison.OrdinalIgnoreCase);
// Allow-list rather than reflecting over the string, so the sort parameter
// cannot reach the query shape in any way the API does not define.
return (sort?.ToLowerInvariant()) switch
{
"system" => descending ? q.OrderByDescending(g => g.System) : q.OrderBy(g => g.System),
"genre" => descending ? q.OrderByDescending(g => g.Genre) : q.OrderBy(g => g.Genre),
"year" => descending ? q.OrderByDescending(g => g.Year) : q.OrderBy(g => g.Year),
"developer" => descending ? q.OrderByDescending(g => g.Developer) : q.OrderBy(g => g.Developer),
"publisher" => descending ? q.OrderByDescending(g => g.Publisher) : q.OrderBy(g => g.Publisher),
"rating" => descending ? q.OrderByDescending(g => g.Rating) : q.OrderBy(g => g.Rating),
"value" => descending ? q.OrderByDescending(g => g.MarketValue) : q.OrderBy(g => g.MarketValue),
"price" => descending ? q.OrderByDescending(g => g.PurchasePrice) : q.OrderBy(g => g.PurchasePrice),
"purchased" => descending ? q.OrderByDescending(g => g.PurchaseDate) : q.OrderBy(g => g.PurchaseDate),
"created" => descending ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt),
"updated" => descending ? q.OrderByDescending(g => g.UpdatedAt) : q.OrderBy(g => g.UpdatedAt),
_ => descending ? q.OrderByDescending(g => g.Title) : q.OrderBy(g => g.Title),
};
}
private static void Apply(GameRequest request, Game game)
{
game.Title = request.Title.Trim();
game.System = request.System?.Trim();
game.Genre = request.Genre?.Trim();
game.Year = request.Year?.Trim();
game.Developer = request.Developer?.Trim();
game.Publisher = request.Publisher?.Trim();
game.Art = request.Art?.Trim();
game.Description = request.Description;
game.Own = request.Own;
game.Dumped = request.Dumped;
game.Played = request.Played;
game.Finished = request.Finished;
game.Rating = request.Rating;
game.Notes = request.Notes;
game.Condition = request.Condition;
game.Region = request.Region;
game.PurchasePrice = request.PurchasePrice;
game.PurchaseDate = request.PurchaseDate;
var tiersChanged = request.ValueLoose != game.ValueLoose
|| request.ValueCib != game.ValueCib
|| request.ValueNew != game.ValueNew;
game.ValueLoose = request.ValueLoose;
game.ValueCib = request.ValueCib;
game.ValueNew = request.ValueNew;
// Only stamp the valuation when a figure actually changes, so an
// unrelated edit does not make a stale price look freshly checked.
if (request.MarketValue != game.MarketValue || tiersChanged)
{
game.MarketValue = request.MarketValue;
// Tiers win where they exist: they came from a source, and they
// follow the copy's condition.
game.RecalculateEffectiveValue();
game.MarketValueUpdatedAt = game.MarketValue is null ? null : DateTimeOffset.UtcNow;
game.MarketValueSource = game.MarketValue is null
? null
: request.MarketValueSource?.Trim() ?? "manual";
}
else
{
// Condition may have moved without any price changing, which puts a
// different tier in play.
game.RecalculateEffectiveValue();
if (request.MarketValueSource is { } source && game.MarketValue is not null)
{
game.MarketValueSource = source.Trim();
}
}
}
private GameResponse ToResponse(Game g, string ownerId) => new(
g.Id, g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
g.Art, images.BuildUrl(ownerId, g.Art), g.Description,
g.Own, g.Dumped, g.Played, g.Finished,
g.Rating, g.Notes, g.Condition, g.Region,
g.PurchasePrice, g.PurchaseDate,
g.MarketValue, g.MarketValueUpdatedAt, g.MarketValueSource,
g.ValueLoose, g.ValueCib, g.ValueNew,
g.CreatedAt, g.UpdatedAt);
}
@@ -0,0 +1,63 @@
using LudosData.Api.Auth;
using LudosData.Api.Contracts;
using LudosData.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace LudosData.Api.Controllers;
/// <summary>
/// Box art uploads.
///
/// The PHP original accepted anonymous uploads, wrote every file into one hardcoded
/// "ckoch" folder, and built the destination path from the client-supplied filename.
/// This requires authentication, files land in the caller's own folder, and the
/// stored name is generated server-side.
/// </summary>
[ApiController]
[Route("api/images")]
[Authorize]
public class ImagesController(
IImageStorage images,
IOptions<ImageStorageOptions> options,
ILogger<ImagesController> logger) : ControllerBase
{
private readonly ImageStorageOptions _options = options.Value;
[HttpPost]
[RequestSizeLimit(6 * 1024 * 1024)]
public async Task<ActionResult<UploadResponse>> Upload(IFormFile file, CancellationToken ct)
{
if (file is null || file.Length == 0)
{
return BadRequest(new ProblemDetails { Title = "No file was uploaded." });
}
if (file.Length > _options.MaxBytes)
{
return BadRequest(new ProblemDetails
{
Title = $"File is larger than the {_options.MaxBytes / (1024 * 1024)} MB limit.",
});
}
var ownerId = User.GetUserId();
try
{
await using var stream = file.OpenReadStream();
var fileName = await images.SaveAsync(stream, ownerId, ct);
return Ok(new UploadResponse(fileName, images.BuildUrl(ownerId, fileName)!));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// The most common cause is bytes that are not a decodable image. The
// detail is logged but not returned, so probing does not reveal the
// internals of the decoder.
logger.LogWarning(ex, "Rejected upload from user {OwnerId}", ownerId);
return BadRequest(new ProblemDetails { Title = "The file could not be read as an image." });
}
}
}
@@ -0,0 +1,402 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using LudosData.Api.Auth;
using LudosData.Api.Contracts;
using LudosData.Api.Data;
using LudosData.Api.Domain;
using LudosData.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LudosData.Api.Controllers;
/// <summary>
/// Export and import of the caller's whole library.
///
/// Until now the only backup was the Docker volume. This makes a library
/// portable: JSON round-trips exactly, CSV opens in a spreadsheet.
/// </summary>
[ApiController]
[Route("api/library")]
[Authorize]
public class LibraryController(
LudosDbContext db,
ILogger<LibraryController> logger) : ControllerBase
{
private const int ExportVersion = 1;
private static readonly string[] CsvHeaders =
[
"title", "system", "genre", "year", "developer", "publisher",
"description", "art", "own", "dumped", "played", "finished",
"rating", "notes", "condition", "region",
"purchasePrice", "purchaseDate", "marketValue", "marketValueUpdatedAt",
"marketValueSource", "valueLoose", "valueCib", "valueNew",
];
// Must match the converter registered on the controllers, so an export
// written with enum names is readable by the importer.
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
// ---- export ----------------------------------------------------------
[HttpGet("export")]
public async Task<IActionResult> Export([FromQuery] string format = "json", CancellationToken ct = default)
{
var ownerId = User.GetUserId();
var games = await db.Games.AsNoTracking()
.Where(g => g.OwnerId == ownerId)
.OrderBy(g => g.Title)
.ToListAsync(ct);
var rows = games.Select(ToExport).ToList();
var stamp = DateTime.UtcNow.ToString("yyyy-MM-dd");
if (string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase))
{
var csv = Csv.Write(CsvHeaders, rows.Select(g => new List<string?>
{
g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
g.Description, g.Art,
g.Own.ToString(), g.Dumped.ToString(), g.Played.ToString(), g.Finished.ToString(),
g.Rating?.ToString(), g.Notes,
g.Condition == GameCondition.Unspecified ? null : g.Condition.ToString(),
g.Region == GameRegion.Unspecified ? null : g.Region.ToString(),
// Invariant culture throughout: a comma decimal separator would
// collide with the delimiter, and dates must not depend on locale.
g.PurchasePrice?.ToString(CultureInfo.InvariantCulture),
g.PurchaseDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
g.MarketValue?.ToString(CultureInfo.InvariantCulture),
g.MarketValueUpdatedAt?.ToString("O", CultureInfo.InvariantCulture),
g.MarketValueSource,
g.ValueLoose?.ToString(CultureInfo.InvariantCulture),
g.ValueCib?.ToString(CultureInfo.InvariantCulture),
g.ValueNew?.ToString(CultureInfo.InvariantCulture),
}));
// A BOM keeps Excel from mangling non-ASCII titles such as Pokémon.
var bytes = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
return File(bytes, "text/csv; charset=utf-8", $"ludos-library-{stamp}.csv");
}
if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
{
return BadRequest(new ProblemDetails { Title = "Format must be 'json' or 'csv'." });
}
var payload = new LibraryExport("ludosdata.library", ExportVersion,
DateTimeOffset.UtcNow, rows.Count, rows);
return File(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions),
"application/json", $"ludos-library-{stamp}.json");
}
// ---- import ----------------------------------------------------------
[HttpPost("import")]
[RequestSizeLimit(16 * 1024 * 1024)]
public async Task<ActionResult<ImportResult>> Import(
IFormFile file,
[FromQuery] ImportMode mode = ImportMode.Merge,
[FromQuery] bool dryRun = false,
CancellationToken ct = default)
{
if (file is null || file.Length == 0)
{
return BadRequest(new ProblemDetails { Title = "No file was uploaded." });
}
string text;
using (var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
{
text = await reader.ReadToEndAsync(ct);
}
List<ExportGame> incoming;
var errors = new List<ImportRowError>();
try
{
incoming = LooksLikeJson(text)
? ParseJson(text)
: ParseCsv(text, errors);
}
catch (JsonException ex)
{
return BadRequest(new ProblemDetails { Title = $"The file is not valid JSON: {ex.Message}" });
}
catch (InvalidDataException ex)
{
return BadRequest(new ProblemDetails { Title = ex.Message });
}
var ownerId = User.GetUserId();
var existing = await db.Games.Where(g => g.OwnerId == ownerId).ToListAsync(ct);
// Title + system identifies a row: the same game legitimately appears
// once per platform (three Donkey Kong Countrys, on SNES, GB and GBA).
var index = existing
.GroupBy(g => Key(g.Title, g.System))
.ToDictionary(g => g.Key, g => g.First());
int created = 0, updated = 0, deleted = 0, skipped = 0;
if (mode == ImportMode.Replace)
{
deleted = existing.Count;
if (!dryRun)
{
db.Games.RemoveRange(existing);
}
index.Clear();
}
var seen = new HashSet<string>();
foreach (var row in incoming)
{
var title = row.Title?.Trim() ?? string.Empty;
if (title.Length == 0)
{
skipped++;
continue;
}
var key = Key(title, row.System);
if (!seen.Add(key))
{
// Two rows for the same game in one file: first one wins.
skipped++;
continue;
}
if (index.TryGetValue(key, out var target))
{
updated++;
if (!dryRun) Apply(row, target);
}
else
{
created++;
if (!dryRun)
{
var game = new Game { OwnerId = ownerId };
Apply(row, game);
db.Games.Add(game);
}
}
}
if (!dryRun)
{
await db.SaveChangesAsync(ct);
logger.LogInformation(
"User {OwnerId} imported {Created} new and {Updated} updated games ({Mode})",
ownerId, created, updated, mode);
}
return Ok(new ImportResult(
dryRun, mode, incoming.Count, created, updated, deleted, skipped, errors));
}
// ---- helpers ---------------------------------------------------------
private static bool LooksLikeJson(string text)
{
var trimmed = text.TrimStart('', ' ', '\t', '\r', '\n');
return trimmed.StartsWith('{') || trimmed.StartsWith('[');
}
/// <summary>Accepts either the export envelope or a bare array of games.</summary>
private static List<ExportGame> ParseJson(string text)
{
var trimmed = text.TrimStart('', ' ', '\t', '\r', '\n');
if (trimmed.StartsWith('['))
{
return JsonSerializer.Deserialize<List<ExportGame>>(trimmed, JsonOptions) ?? [];
}
var envelope = JsonSerializer.Deserialize<LibraryExport>(trimmed, JsonOptions);
return envelope?.Games?.ToList()
?? throw new InvalidDataException("The JSON file contains no games.");
}
private static List<ExportGame> ParseCsv(string text, List<ImportRowError> errors)
{
var rows = Csv.Parse(text);
if (rows.Count == 0)
{
throw new InvalidDataException("The CSV file is empty.");
}
var header = rows[0].Select(h => h.Trim().ToLowerInvariant()).ToList();
var titleAt = header.IndexOf("title");
if (titleAt < 0)
{
throw new InvalidDataException("The CSV file has no 'title' column.");
}
string? Field(List<string> row, string name)
{
var at = header.IndexOf(name);
if (at < 0 || at >= row.Count) return null;
var value = row[at].Trim();
return value.Length == 0 ? null : value;
}
bool Flag(List<string> row, string name)
{
var value = Field(row, name);
return value is not null
&& (value.Equals("true", StringComparison.OrdinalIgnoreCase)
|| value is "1" or "yes" or "y");
}
var games = new List<ExportGame>();
for (var i = 1; i < rows.Count; i++)
{
var row = rows[i];
var title = titleAt < row.Count ? row[titleAt].Trim() : string.Empty;
if (title.Length == 0)
{
// Row number as a person counts them: header is row 1.
errors.Add(new ImportRowError(i + 1, string.Empty, "Missing title"));
continue;
}
games.Add(new ExportGame
{
Title = title,
System = Field(row, "system"),
Genre = Field(row, "genre"),
Year = Field(row, "year"),
Developer = Field(row, "developer"),
Publisher = Field(row, "publisher"),
Description = Field(row, "description"),
Art = Field(row, "art"),
Own = Flag(row, "own"),
Dumped = Flag(row, "dumped"),
Played = Flag(row, "played"),
Finished = Flag(row, "finished"),
Rating = ParseInt(Field(row, "rating")),
Notes = Field(row, "notes"),
Condition = ParseEnum<GameCondition>(Field(row, "condition")),
Region = ParseEnum<GameRegion>(Field(row, "region")),
PurchasePrice = ParseMoney(Field(row, "purchaseprice")),
PurchaseDate = ParseDate(Field(row, "purchasedate")),
MarketValue = ParseMoney(Field(row, "marketvalue")),
MarketValueUpdatedAt = ParseTimestamp(Field(row, "marketvalueupdatedat")),
MarketValueSource = Field(row, "marketvaluesource"),
ValueLoose = ParseMoney(Field(row, "valueloose")),
ValueCib = ParseMoney(Field(row, "valuecib")),
ValueNew = ParseMoney(Field(row, "valuenew")),
});
}
return games;
}
// Parsers are forgiving: a spreadsheet round-trip is a normal way for these
// files to arrive, and one unreadable cell should not cost the whole row.
private static int? ParseInt(string? value) =>
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed : null;
private static decimal? ParseMoney(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
// Tolerate a currency symbol and thousands separators from a spreadsheet.
var cleaned = value.Trim().TrimStart('$', '£', '€').Replace(",", string.Empty);
return decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed)
? parsed : null;
}
private static DateOnly? ParseDate(string? value) =>
DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
? parsed : null;
private static DateTimeOffset? ParseTimestamp(string? value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)
? parsed : null;
private static T ParseEnum<T>(string? value) where T : struct, Enum =>
Enum.TryParse<T>(value, ignoreCase: true, out var parsed) ? parsed : default;
private static string Key(string title, string? system) =>
$"{title.Trim().ToLowerInvariant()}{(system ?? string.Empty).Trim().ToLowerInvariant()}";
private static void Apply(ExportGame source, Game target)
{
target.Title = source.Title.Trim();
target.System = Blank(source.System);
target.Genre = Blank(source.Genre);
target.Year = Blank(source.Year);
target.Developer = Blank(source.Developer);
target.Publisher = Blank(source.Publisher);
target.Description = Blank(source.Description);
target.Art = Blank(source.Art);
target.Own = source.Own;
target.Dumped = source.Dumped;
target.Played = source.Played;
target.Finished = source.Finished;
target.Rating = source.Rating;
target.Notes = Blank(source.Notes);
target.Condition = source.Condition;
target.Region = source.Region;
target.PurchasePrice = source.PurchasePrice;
target.PurchaseDate = source.PurchaseDate;
// The valuation's own timestamp is restored as recorded rather than
// reset to now: an import is a restore, not a fresh price check.
target.ValueLoose = source.ValueLoose;
target.ValueCib = source.ValueCib;
target.ValueNew = source.ValueNew;
target.MarketValue = source.MarketValue;
target.RecalculateEffectiveValue();
target.MarketValueUpdatedAt = target.MarketValue is null ? null : source.MarketValueUpdatedAt;
target.MarketValueSource = target.MarketValue is null ? null : Blank(source.MarketValueSource);
}
private static string? Blank(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static ExportGame ToExport(Game g) => new()
{
Title = g.Title,
System = g.System,
Genre = g.Genre,
Year = g.Year,
Developer = g.Developer,
Publisher = g.Publisher,
Description = g.Description,
Art = g.Art,
Own = g.Own,
Dumped = g.Dumped,
Played = g.Played,
Finished = g.Finished,
Rating = g.Rating,
Notes = g.Notes,
Condition = g.Condition,
Region = g.Region,
PurchasePrice = g.PurchasePrice,
PurchaseDate = g.PurchaseDate,
MarketValue = g.MarketValue,
MarketValueUpdatedAt = g.MarketValueUpdatedAt,
MarketValueSource = g.MarketValueSource,
ValueLoose = g.ValueLoose,
ValueCib = g.ValueCib,
ValueNew = g.ValueNew,
};
}
@@ -0,0 +1,279 @@
using System.Text;
using LudosData.Api.Auth;
using LudosData.Api.Data;
using LudosData.Api.Domain;
using LudosData.Api.Services.Pricing;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LudosData.Api.Controllers;
public record PriceRefreshRequest
{
/// <summary>Which source to price from. Defaults to the first configured one.</summary>
public string? Provider { get; init; }
/// <summary>Limit the run to specific games. Empty means the whole library.</summary>
public List<int>? GameIds { get; init; }
/// <summary>Re-price games that already have a figure.</summary>
public bool Overwrite { get; init; }
/// <summary>Report what would change without writing anything.</summary>
public bool DryRun { get; init; }
/// <summary>Ceiling on how many games one run will price.</summary>
public int Limit { get; init; } = 25;
}
public record PriceRefreshItem(
int GameId, string Title,
decimal? Loose, decimal? Cib, decimal? New,
int Samples, int Discarded, string? Error,
// What the source says it priced. A DS entry that quietly resolves to the
// SNES original returns plausible numbers for the wrong game, so a dry run
// has to show this before anything is written.
string? MatchedName = null, string? MatchedConsole = null, string? SourceId = null);
public record PriceRefreshResult(
bool DryRun, string Source, int Considered, int Priced, int Failed,
IReadOnlyList<PriceRefreshItem> Items);
public record ProviderStatus(string Name, bool Configured, string Basis, string? Setup);
public record PriceImportResult(
bool DryRun, int Rows, int Matched, int Updated, int Unmatched,
IReadOnlyList<string> UnmatchedTitles, IReadOnlyList<string> Problems);
/// <summary>
/// Market values, from whichever sources are configured.
///
/// Several can coexist because each is blocked in a different way: eBay is free
/// but its production keyset needs account verification, PriceCharting is
/// immediate but paid, and a CSV price guide needs neither. The provider name
/// travels with every value written, so a figure always says where it came from.
/// </summary>
[ApiController]
[Route("api/prices")]
[Authorize]
public class PricesController(
LudosDbContext db,
IEnumerable<IPriceProvider> providers,
ILogger<PricesController> logger) : ControllerBase
{
private static readonly Dictionary<string, (string Basis, string Setup)> ProviderNotes = new()
{
["ebay-asking"] = (
"active listing asking prices, not completed sales — expect these to read high",
"Free, but the production keyset needs eBay account verification. "
+ "Set EBAY_CLIENT_ID and EBAY_CLIENT_SECRET."),
["pricecharting"] = (
"sale-derived prices quoted per condition",
"Paid subscription, but access is immediate with no review. "
+ "Set PRICECHARTING_TOKEN."),
};
[HttpGet("status")]
public ActionResult<IEnumerable<ProviderStatus>> Status() => Ok(providers.Select(p =>
{
var notes = ProviderNotes.TryGetValue(p.Name, out var n)
? n
: ("unspecified", "see documentation");
return new ProviderStatus(p.Name, p.IsConfigured, notes.Item1,
p.IsConfigured ? null : notes.Item2);
}));
[HttpPost("refresh")]
public async Task<ActionResult<PriceRefreshResult>> Refresh(
PriceRefreshRequest request, CancellationToken ct)
{
var provider = ResolveProvider(request.Provider);
if (provider is null)
{
var available = string.Join(", ", providers.Select(p => p.Name));
return StatusCode(StatusCodes.Status503ServiceUnavailable, new ProblemDetails
{
Title = request.Provider is null
? "No price provider is configured."
: $"Price provider '{request.Provider}' is not configured.",
Detail = $"Known providers: {available}. Configure one, or import a price "
+ "guide CSV at POST /api/prices/import, which needs no account.",
});
}
var ownerId = User.GetUserId();
var query = db.Games.Where(g => g.OwnerId == ownerId);
if (request.GameIds is { Count: > 0 })
{
query = query.Where(g => request.GameIds.Contains(g.Id));
}
else if (!request.Overwrite)
{
query = query.Where(g => g.MarketValue == null);
}
var limit = Math.Clamp(request.Limit, 1, 200);
var games = await query.OrderBy(g => g.Title).Take(limit).ToListAsync(ct);
var items = new List<PriceRefreshItem>();
int priced = 0, failed = 0;
foreach (var game in games)
{
try
{
// Reuse a previous match where there is one, so refreshes stay
// pinned to the same product instead of re-running a search.
var estimate = await provider.EstimateAsync(
game.Title, game.System,
provider.Name == game.MarketValueSource ? game.PriceSourceId : null, ct);
if (!estimate.HasAnyPrice)
{
failed++;
items.Add(new PriceRefreshItem(game.Id, game.Title, null, null, null,
0, estimate.Discarded, "No price found for this title"));
continue;
}
if (!request.DryRun)
{
ApplyEstimate(game, estimate.Loose, estimate.Cib, estimate.New, provider.Name);
game.PriceSourceId = estimate.SourceId ?? game.PriceSourceId;
}
priced++;
items.Add(new PriceRefreshItem(game.Id, game.Title,
estimate.Loose, estimate.Cib, estimate.New,
estimate.LooseSamples + estimate.CibSamples + estimate.NewSamples,
estimate.Discarded, null,
estimate.MatchedName, estimate.MatchedConsole, estimate.SourceId));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// One bad lookup should not abandon the rest of the batch.
logger.LogWarning(ex, "Pricing failed for game {GameId}", game.Id);
failed++;
items.Add(new PriceRefreshItem(game.Id, game.Title, null, null, null, 0, 0, ex.Message));
}
}
if (!request.DryRun && priced > 0)
{
await db.SaveChangesAsync(ct);
logger.LogInformation("User {OwnerId} priced {Count} games via {Source}",
ownerId, priced, provider.Name);
}
return Ok(new PriceRefreshResult(
request.DryRun, provider.Name, games.Count, priced, failed, items));
}
/// <summary>
/// Applies an external price list.
///
/// This is the path that needs no account and no approval: export a guide
/// from wherever you have one, or keep a spreadsheet, and the prices land on
/// the matching games.
/// </summary>
[HttpPost("import")]
[RequestSizeLimit(32 * 1024 * 1024)]
public async Task<ActionResult<PriceImportResult>> Import(
IFormFile file,
[FromQuery] string source = "price-guide",
[FromQuery] bool dryRun = false,
CancellationToken ct = default)
{
if (file is null || file.Length == 0)
{
return BadRequest(new ProblemDetails { Title = "No file was uploaded." });
}
string text;
using (var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, true))
{
text = await reader.ReadToEndAsync(ct);
}
var guide = PriceGuide.Parse(text);
if (guide.Rows.Count == 0)
{
return BadRequest(new ProblemDetails
{
Title = "No usable rows were found.",
Detail = string.Join(" ", guide.Problems),
});
}
var ownerId = User.GetUserId();
var games = await db.Games.Where(g => g.OwnerId == ownerId).ToListAsync(ct);
// Same key as the library import: a game is a title on a platform, so
// three Donkey Kong Countrys stay three separately priced entries.
var index = games
.GroupBy(g => Key(g.Title, g.System))
.ToDictionary(g => g.Key, g => g.First());
int matched = 0, updated = 0;
var unmatched = new List<string>();
foreach (var row in guide.Rows)
{
if (!index.TryGetValue(Key(row.Title, row.System), out var game))
{
unmatched.Add($"{row.Title}{(row.System is null ? "" : $" ({row.System})")}");
continue;
}
matched++;
if (row.Loose is null && row.Cib is null && row.New is null)
{
continue;
}
if (!dryRun)
{
ApplyEstimate(game, row.Loose, row.Cib, row.New, source);
}
updated++;
}
if (!dryRun && updated > 0)
{
await db.SaveChangesAsync(ct);
logger.LogInformation("User {OwnerId} priced {Count} games from a {Source} guide",
ownerId, updated, source);
}
return Ok(new PriceImportResult(
dryRun, guide.Rows.Count, matched, updated, unmatched.Count,
// Capped: a full guide can miss thousands of rows that are simply
// games this library does not contain.
unmatched.Take(50).ToList(), guide.Problems));
}
private IPriceProvider? ResolveProvider(string? name) =>
name is null
? providers.FirstOrDefault(p => p.IsConfigured)
: providers.FirstOrDefault(p =>
string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase) && p.IsConfigured);
private static void ApplyEstimate(
Game game, decimal? loose, decimal? cib, decimal? boxed, string source)
{
game.ValueLoose = loose;
game.ValueCib = cib;
game.ValueNew = boxed;
game.RecalculateEffectiveValue();
game.MarketValueUpdatedAt = DateTimeOffset.UtcNow;
game.MarketValueSource = source;
}
private static string Key(string title, string? system) =>
$"{title.Trim().ToLowerInvariant()} {(system ?? string.Empty).Trim().ToLowerInvariant()}";
}
@@ -0,0 +1,138 @@
using LudosData.Api.Auth;
using LudosData.Api.Contracts;
using LudosData.Api.Data;
using LudosData.Api.Domain;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LudosData.Api.Controllers;
/// <summary>
/// Aggregates for the dashboard, in one round trip.
///
/// The whole library is loaded and reduced in memory rather than issued as a
/// dozen grouped queries: a personal collection is hundreds of rows, not
/// millions, and one pass is both faster and far easier to keep consistent than
/// twelve queries that could disagree with each other.
/// </summary>
[ApiController]
[Route("api/stats")]
[Authorize]
public class StatsController(LudosDbContext db) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<StatsResponse>> Get(CancellationToken ct)
{
var ownerId = User.GetUserId();
var games = await db.Games.AsNoTracking()
.Where(g => g.OwnerId == ownerId)
.ToListAsync(ct);
var owned = games.Count(g => g.Own);
var played = games.Count(g => g.Own && g.Played);
var finished = games.Count(g => g.Own && g.Finished);
var rated = games.Where(g => g.Rating is not null).ToList();
var priced = games.Where(g => g.MarketValue is not null).ToList();
var valuedAt = priced
.Where(g => g.MarketValueUpdatedAt is not null)
.Select(g => g.MarketValueUpdatedAt!.Value)
.ToList();
var value = new ValueSummary(
Total: priced.Sum(g => g.MarketValue ?? 0m),
PricedCount: priced.Count,
UnpricedCount: games.Count - priced.Count,
TotalPaid: games.Sum(g => g.PurchasePrice ?? 0m),
PaidCount: games.Count(g => g.PurchasePrice is not null),
OldestValuedAt: valuedAt.Count > 0 ? valuedAt.Min() : null,
NewestValuedAt: valuedAt.Count > 0 ? valuedAt.Max() : null,
Sources: priced
.Select(g => g.MarketValueSource)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s!)
.Distinct()
.OrderBy(s => s)
.ToList(),
// Only meaningful once some CIB prices exist; null keeps the card
// from showing a total that is really just the games that happen to
// have that tier filled in.
TotalIfCib: games.Any(g => g.ValueCib is not null)
? games.Sum(g => g.ValueCib ?? g.MarketValue ?? 0m)
: null);
return Ok(new StatsResponse(
TotalGames: games.Count,
Funnel: new CompletionFunnel(owned, played, finished),
Backlog: games.Count(g => g.Own && !g.Played),
InProgress: games.Count(g => g.Played && !g.Finished),
Dumped: games.Count(g => g.Dumped),
RatedCount: rated.Count,
AverageRating: rated.Count > 0 ? Math.Round(rated.Average(g => g.Rating!.Value), 1) : null,
BySystem: Rank(games, g => g.System),
ByGenre: Rank(games, g => g.Genre),
ByDecade: ByDecade(games),
ByCondition: games
.GroupBy(g => g.Condition)
.OrderByDescending(g => g.Count())
.Select(g => new CountByLabel(Describe(g.Key), g.Count()))
.ToList(),
// Highest score first, so the best-regarded games lead. Only scores
// in use appear — empty rows for unused ratings would be noise.
ByRating: rated
.GroupBy(g => g.Rating!.Value)
.OrderByDescending(g => g.Key)
.Select(g => new CountByLabel($"{g.Key} / 10", g.Count()))
.ToList(),
Value: value));
}
private static List<CountByLabel> Rank(List<Game> games, Func<Game, string?> select) =>
games
.Select(select)
.Where(v => !string.IsNullOrWhiteSpace(v))
.GroupBy(v => v!)
// Count first, then alphabetically, so equal counts have a stable
// order rather than shuffling between requests.
.OrderByDescending(g => g.Count())
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => new CountByLabel(g.Key, g.Count()))
.ToList();
private static List<CountByLabel> ByDecade(List<Game> games)
{
var decades = new Dictionary<int, int>();
foreach (var game in games)
{
// Year is a free-text column, so pull the first plausible year out
// of whatever is there rather than trusting it to parse.
var match = System.Text.RegularExpressions.Regex.Match(
game.Year ?? string.Empty, @"(19|20)\d{2}");
if (!match.Success || !int.TryParse(match.Value, out var year))
{
continue;
}
var decade = year - (year % 10);
decades[decade] = decades.GetValueOrDefault(decade) + 1;
}
return decades
.OrderBy(d => d.Key)
.Select(d => new CountByLabel($"{d.Key}s", d.Value))
.ToList();
}
private static string Describe(GameCondition condition) => condition switch
{
GameCondition.Loose => "Loose",
GameCondition.Cib => "Complete in box",
GameCondition.Sealed => "Sealed",
GameCondition.Digital => "Digital",
_ => "Unspecified",
};
}
+136
View File
@@ -0,0 +1,136 @@
using System.Text.Json;
using LudosData.Api.Domain;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace LudosData.Api.Data;
public class SeedOptions
{
public const string SectionName = "Seed";
/// <summary>When false, migrations still run but no user or games are created.</summary>
public bool Enabled { get; set; } = true;
public string UserName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// Applies migrations and, on a genuinely empty database, creates the initial user
/// and imports the 105 games recovered from the 2018 MySQL dump.
///
/// The dump predates the multi-user work, so it has no users table and no per-game
/// owner: every imported game is assigned to the seed user.
/// </summary>
public static class DbSeeder
{
public static async Task MigrateAndSeedAsync(IServiceProvider services, CancellationToken ct = default)
{
using var scope = services.CreateScope();
var sp = scope.ServiceProvider;
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("DbSeeder");
var db = sp.GetRequiredService<LudosDbContext>();
await db.Database.MigrateAsync(ct);
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeedOptions>>().Value;
if (!options.Enabled)
{
logger.LogInformation("Seeding disabled; skipping.");
return;
}
if (await db.Users.AnyAsync(ct))
{
logger.LogInformation("Database already has users; skipping seed.");
return;
}
if (string.IsNullOrWhiteSpace(options.UserName) || string.IsNullOrWhiteSpace(options.Password))
{
logger.LogWarning(
"Seeding is enabled but Seed:UserName / Seed:Password are not set, so no initial user was " +
"created and the {Count} games from the 2018 dump were not imported. Set SEED__USERNAME, " +
"SEED__EMAIL and SEED__PASSWORD and restart, or register a user and import manually.",
await CountSeedGamesAsync(ct));
return;
}
var userManager = sp.GetRequiredService<UserManager<AppUser>>();
var user = new AppUser
{
UserName = options.UserName,
Email = string.IsNullOrWhiteSpace(options.Email) ? $"{options.UserName}@localhost" : options.Email,
EmailConfirmed = true,
};
var created = await userManager.CreateAsync(user, options.Password);
if (!created.Succeeded)
{
var errors = string.Join("; ", created.Errors.Select(e => e.Description));
logger.LogError("Could not create the seed user: {Errors}", errors);
return;
}
var games = await LoadSeedGamesAsync(ct);
foreach (var game in games)
{
game.OwnerId = user.Id;
db.Games.Add(game);
}
await db.SaveChangesAsync(ct);
logger.LogInformation(
"Seeded user {UserName} with {Count} games from the 2018 dump.", user.UserName, games.Count);
}
private static async Task<List<Game>> LoadSeedGamesAsync(CancellationToken ct)
{
var path = Path.Combine(AppContext.BaseDirectory, "Data", "Seed", "games.json");
if (!File.Exists(path))
{
return [];
}
await using var stream = File.OpenRead(path);
var records = await JsonSerializer.DeserializeAsync<List<SeedGame>>(
stream, new JsonSerializerOptions(JsonSerializerDefaults.Web), ct) ?? [];
return records.Select(r => new Game
{
Title = r.Title,
System = r.System,
Genre = r.Genre,
Year = r.Year,
Developer = r.Developer,
Publisher = r.Publisher,
Art = r.Art,
Description = r.Description,
Own = r.Own,
Dumped = r.Dumped,
Played = r.Played,
Finished = r.Finished,
}).ToList();
}
private static async Task<int> CountSeedGamesAsync(CancellationToken ct) =>
(await LoadSeedGamesAsync(ct)).Count;
private sealed record SeedGame(
string Title,
string? System,
string? Genre,
string? Year,
string? Developer,
string? Publisher,
string? Art,
string? Description,
bool Own,
bool Dumped,
bool Played,
bool Finished);
}
@@ -0,0 +1,81 @@
using LudosData.Api.Domain;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace LudosData.Api.Data;
public class LudosDbContext(DbContextOptions<LudosDbContext> options)
: IdentityDbContext<AppUser>(options)
{
public DbSet<Game> Games => Set<Game>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// SQLite has no decimal type. EF Core's default is to store decimal as
// TEXT, which compares lexically — "9.00" sorts above "10.00", and SUM
// is not available at all. Money is therefore stored as integer minor
// units and converted on the way in and out, so ordering by value and
// totalling a collection both behave.
var moneyToCents = new ValueConverter<decimal?, long?>(
value => value == null ? null : (long)Math.Round(value.Value * 100m, MidpointRounding.AwayFromZero),
cents => cents == null ? null : cents.Value / 100m);
builder.Entity<Game>(game =>
{
game.HasOne(g => g.Owner)
.WithMany(u => u.Games)
.HasForeignKey(g => g.OwnerId)
.OnDelete(DeleteBehavior.Cascade);
game.Property(g => g.PurchasePrice).HasConversion(moneyToCents);
game.Property(g => g.MarketValue).HasConversion(moneyToCents);
game.Property(g => g.ValueLoose).HasConversion(moneyToCents);
game.Property(g => g.ValueCib).HasConversion(moneyToCents);
game.Property(g => g.ValueNew).HasConversion(moneyToCents);
// Stored as an enum's underlying int; readable names live in the API.
game.Property(g => g.Condition).HasConversion<int>();
game.Property(g => g.Region).HasConversion<int>();
// Every list query filters by owner first, then narrows or sorts on
// these columns, so they lead the composite indexes.
game.HasIndex(g => new { g.OwnerId, g.Title });
game.HasIndex(g => new { g.OwnerId, g.System });
game.HasIndex(g => new { g.OwnerId, g.Genre });
game.HasIndex(g => new { g.OwnerId, g.Rating });
});
}
public override int SaveChanges()
{
StampTimestamps();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
StampTimestamps();
return base.SaveChangesAsync(cancellationToken);
}
private void StampTimestamps()
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in ChangeTracker.Entries<Game>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
}
}
}
@@ -0,0 +1,367 @@
// <auto-generated />
using System;
using LudosData.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
[DbContext(typeof(LudosDbContext))]
[Migration("20260803220157_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Developer")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("Dumped")
.HasColumnType("INTEGER");
b.Property<bool>("Finished")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("Own")
.HasColumnType("INTEGER");
b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Played")
.HasColumnType("INTEGER");
b.Property<string>("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("System")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Year")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
b.ToTable("Games");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
.WithMany("Games")
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Navigation("Games");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,277 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
FirstName = table.Column<string>(type: "TEXT", nullable: true),
LastName = table.Column<string>(type: "TEXT", nullable: true),
Art = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoleId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
RoleId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Games",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
System = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
Genre = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
Year = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
Developer = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
Publisher = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
Art = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
Description = table.Column<string>(type: "TEXT", nullable: true),
Own = table.Column<bool>(type: "INTEGER", nullable: false),
Dumped = table.Column<bool>(type: "INTEGER", nullable: false),
Played = table.Column<bool>(type: "INTEGER", nullable: false),
Finished = table.Column<bool>(type: "INTEGER", nullable: false),
OwnerId = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Games", x => x.Id);
table.ForeignKey(
name: "FK_Games_AspNetUsers_OwnerId",
column: x => x.OwnerId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Games_OwnerId_Genre",
table: "Games",
columns: new[] { "OwnerId", "Genre" });
migrationBuilder.CreateIndex(
name: "IX_Games_OwnerId_System",
table: "Games",
columns: new[] { "OwnerId", "System" });
migrationBuilder.CreateIndex(
name: "IX_Games_OwnerId_Title",
table: "Games",
columns: new[] { "OwnerId", "Title" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "Games");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}
@@ -0,0 +1,397 @@
// <auto-generated />
using System;
using LudosData.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
[DbContext(typeof(LudosDbContext))]
[Migration("20260804171435_AddCollectorFields")]
partial class AddCollectorFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Condition")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Developer")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("Dumped")
.HasColumnType("INTEGER");
b.Property<bool>("Finished")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("MarketValue")
.HasColumnType("INTEGER");
b.Property<string>("MarketValueSource")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("MarketValueUpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<bool>("Own")
.HasColumnType("INTEGER");
b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Played")
.HasColumnType("INTEGER");
b.Property<string>("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateOnly?>("PurchaseDate")
.HasColumnType("TEXT");
b.Property<long?>("PurchasePrice")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<int>("Region")
.HasColumnType("INTEGER");
b.Property<string>("System")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Year")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "Rating");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
b.ToTable("Games");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
.WithMany("Games")
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Navigation("Games");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,121 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddCollectorFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Condition",
table: "Games",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<long>(
name: "MarketValue",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "MarketValueSource",
table: "Games",
type: "TEXT",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "MarketValueUpdatedAt",
table: "Games",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "Games",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateOnly>(
name: "PurchaseDate",
table: "Games",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "PurchasePrice",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Rating",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Region",
table: "Games",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_Games_OwnerId_Rating",
table: "Games",
columns: new[] { "OwnerId", "Rating" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Games_OwnerId_Rating",
table: "Games");
migrationBuilder.DropColumn(
name: "Condition",
table: "Games");
migrationBuilder.DropColumn(
name: "MarketValue",
table: "Games");
migrationBuilder.DropColumn(
name: "MarketValueSource",
table: "Games");
migrationBuilder.DropColumn(
name: "MarketValueUpdatedAt",
table: "Games");
migrationBuilder.DropColumn(
name: "Notes",
table: "Games");
migrationBuilder.DropColumn(
name: "PurchaseDate",
table: "Games");
migrationBuilder.DropColumn(
name: "PurchasePrice",
table: "Games");
migrationBuilder.DropColumn(
name: "Rating",
table: "Games");
migrationBuilder.DropColumn(
name: "Region",
table: "Games");
}
}
}
@@ -0,0 +1,406 @@
// <auto-generated />
using System;
using LudosData.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
[DbContext(typeof(LudosDbContext))]
[Migration("20260804191736_AddTieredPrices")]
partial class AddTieredPrices
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Condition")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Developer")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("Dumped")
.HasColumnType("INTEGER");
b.Property<bool>("Finished")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("MarketValue")
.HasColumnType("INTEGER");
b.Property<string>("MarketValueSource")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("MarketValueUpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<bool>("Own")
.HasColumnType("INTEGER");
b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Played")
.HasColumnType("INTEGER");
b.Property<string>("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateOnly?>("PurchaseDate")
.HasColumnType("TEXT");
b.Property<long?>("PurchasePrice")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<int>("Region")
.HasColumnType("INTEGER");
b.Property<string>("System")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long?>("ValueCib")
.HasColumnType("INTEGER");
b.Property<long?>("ValueLoose")
.HasColumnType("INTEGER");
b.Property<long?>("ValueNew")
.HasColumnType("INTEGER");
b.Property<string>("Year")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "Rating");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
b.ToTable("Games");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
.WithMany("Games")
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Navigation("Games");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddTieredPrices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "ValueCib",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "ValueLoose",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "ValueNew",
table: "Games",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ValueCib",
table: "Games");
migrationBuilder.DropColumn(
name: "ValueLoose",
table: "Games");
migrationBuilder.DropColumn(
name: "ValueNew",
table: "Games");
}
}
}
@@ -0,0 +1,410 @@
// <auto-generated />
using System;
using LudosData.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
[DbContext(typeof(LudosDbContext))]
[Migration("20260804205124_AddPriceSourceId")]
partial class AddPriceSourceId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Condition")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Developer")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("Dumped")
.HasColumnType("INTEGER");
b.Property<bool>("Finished")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("MarketValue")
.HasColumnType("INTEGER");
b.Property<string>("MarketValueSource")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("MarketValueUpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<bool>("Own")
.HasColumnType("INTEGER");
b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Played")
.HasColumnType("INTEGER");
b.Property<string>("PriceSourceId")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateOnly?>("PurchaseDate")
.HasColumnType("TEXT");
b.Property<long?>("PurchasePrice")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<int>("Region")
.HasColumnType("INTEGER");
b.Property<string>("System")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long?>("ValueCib")
.HasColumnType("INTEGER");
b.Property<long?>("ValueLoose")
.HasColumnType("INTEGER");
b.Property<long?>("ValueNew")
.HasColumnType("INTEGER");
b.Property<string>("Year")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "Rating");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
b.ToTable("Games");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
.WithMany("Games")
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Navigation("Games");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddPriceSourceId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PriceSourceId",
table: "Games",
type: "TEXT",
maxLength: 100,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PriceSourceId",
table: "Games");
}
}
}
@@ -0,0 +1,407 @@
// <auto-generated />
using System;
using LudosData.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
[DbContext(typeof(LudosDbContext))]
partial class LudosDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Condition")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Developer")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("Dumped")
.HasColumnType("INTEGER");
b.Property<bool>("Finished")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("MarketValue")
.HasColumnType("INTEGER");
b.Property<string>("MarketValueSource")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("MarketValueUpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<bool>("Own")
.HasColumnType("INTEGER");
b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Played")
.HasColumnType("INTEGER");
b.Property<string>("PriceSourceId")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateOnly?>("PurchaseDate")
.HasColumnType("TEXT");
b.Property<long?>("PurchasePrice")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<int>("Region")
.HasColumnType("INTEGER");
b.Property<string>("System")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long?>("ValueCib")
.HasColumnType("INTEGER");
b.Property<long?>("ValueLoose")
.HasColumnType("INTEGER");
b.Property<long?>("ValueNew")
.HasColumnType("INTEGER");
b.Property<string>("Year")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "Rating");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
b.ToTable("Games");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
.WithMany("Games")
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Navigation("Games");
});
#pragma warning restore 612, 618
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Identity;
namespace LudosData.Api.Domain;
/// <summary>
/// Application user. Extends IdentityUser, which supplies Id, UserName, Email,
/// PasswordHash (PBKDF2 with a per-user salt), lockout and security stamp.
/// </summary>
public class AppUser : IdentityUser
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
/// <summary>Filename of the user's avatar, relative to their upload folder.</summary>
public string? Art { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public ICollection<Game> Games { get; set; } = new List<Game>();
}
+34
View File
@@ -0,0 +1,34 @@
namespace LudosData.Api.Domain;
/// <summary>
/// Physical completeness. This is not cosmetic: market price feeds quote per
/// condition, and the gap between loose and sealed is routinely a multiple, so
/// this selects which quoted price applies to a copy.
/// </summary>
public enum GameCondition
{
Unspecified = 0,
/// <summary>Cartridge or disc only.</summary>
Loose = 1,
/// <summary>Complete in box — case, manual and inserts present.</summary>
Cib = 2,
/// <summary>Factory sealed, never opened.</summary>
Sealed = 3,
/// <summary>No physical copy; a download or licence.</summary>
Digital = 4,
}
/// <summary>
/// Release region. Affects both value and playability on a given console.
/// </summary>
public enum GameRegion
{
Unspecified = 0,
Ntsc = 1, // North America
Pal = 2, // Europe / Australia
NtscJ = 3, // Japan
}
+144
View File
@@ -0,0 +1,144 @@
using System.ComponentModel.DataAnnotations;
namespace LudosData.Api.Domain;
/// <summary>
/// A single entry in a user's game library. Mirrors the columns of the original
/// MySQL `games` table so the 2018 dump imports without transformation, with the
/// addition of ownership and audit fields.
/// </summary>
public class Game
{
public int Id { get; set; }
[Required]
[MaxLength(200)]
public string Title { get; set; } = string.Empty;
/// <summary>Console/platform the game runs on, e.g. "SNES", "PS2".</summary>
[MaxLength(50)]
public string? System { get; set; }
[MaxLength(50)]
public string? Genre { get; set; }
/// <summary>
/// Release year. Kept as a string rather than an int: the original column was
/// varchar(50) and holds values like "" and "1996" — some entries were never
/// filled in, and a few real-world cases want ranges.
/// </summary>
[MaxLength(50)]
public string? Year { get; set; }
[MaxLength(100)]
public string? Developer { get; set; }
[MaxLength(100)]
public string? Publisher { get; set; }
/// <summary>Filename of the uploaded box art, relative to the owner's upload folder.</summary>
[MaxLength(200)]
public string? Art { get; set; }
public string? Description { get; set; }
public bool Own { get; set; }
public bool Dumped { get; set; }
public bool Played { get; set; }
public bool Finished { get; set; }
// ---- collector fields ------------------------------------------------
/// <summary>Personal score out of 10. Null means unrated, which is not zero.</summary>
[Range(1, 10)]
public int? Rating { get; set; }
/// <summary>
/// Free-form personal notes. Kept separate from Description, which is
/// derived from an external source and may be overwritten by the enricher.
/// </summary>
public string? Notes { get; set; }
public GameCondition Condition { get; set; } = GameCondition.Unspecified;
public GameRegion Region { get; set; } = GameRegion.Unspecified;
/// <summary>What was paid for this copy. A fixed historical fact.</summary>
public decimal? PurchasePrice { get; set; }
public DateOnly? PurchaseDate { get; set; }
// ---- market value ----------------------------------------------------
//
// Distinct from PurchasePrice: an estimate of what a copy sells for now,
// expected to be refreshed from a price feed. Stored with the moment it was
// captured and where it came from, because a figure with neither is not
// something you can reason about — a total is only as good as its staleness.
/// <summary>
/// The figure used for totals, sorting and display: the tier matching this
/// copy's condition when tiers are known, otherwise whatever was entered by
/// hand. Denormalised deliberately — SQLite can sort and SUM a column, and
/// recomputing a CASE across three nullable columns in every query is worse
/// than keeping one value in step via <see cref="RecalculateEffectiveValue"/>.
/// </summary>
public decimal? MarketValue { get; set; }
public DateTimeOffset? MarketValueUpdatedAt { get; set; }
/// <summary>Provenance, e.g. a price feed's name, or "manual".</summary>
[MaxLength(100)]
public string? MarketValueSource { get; set; }
// Price sources quote per condition, and the spread between them is
// routinely a multiple. Keeping all three means changing a copy's condition
// re-prices it without another lookup, and the dashboard can answer both
// "what is this worth" and "what would it be worth complete".
/// <summary>
/// The price source's identifier for this game, kept after the first match.
/// Later refreshes look it up directly instead of repeating a fuzzy search,
/// which makes them both cheaper and stable — a title search that drifts to
/// a different edition next month would silently re-price the wrong thing.
/// </summary>
[MaxLength(100)]
public string? PriceSourceId { get; set; }
public decimal? ValueLoose { get; set; }
public decimal? ValueCib { get; set; }
public decimal? ValueNew { get; set; }
/// <summary>The tier that applies to a given condition, if it is known.</summary>
public decimal? TierFor(GameCondition condition) => condition switch
{
GameCondition.Sealed => ValueNew,
GameCondition.Cib => ValueCib,
GameCondition.Loose => ValueLoose,
// Digital has no physical tier, and an unspecified condition is most
// often a loose cart or disc, which is also the conservative estimate.
_ => ValueLoose,
};
/// <summary>
/// Brings <see cref="MarketValue"/> back in step with the tiers. A hand-typed
/// figure survives: it is only replaced once a source has supplied tiers.
/// </summary>
public void RecalculateEffectiveValue()
{
var tier = TierFor(Condition);
if (tier is not null)
{
MarketValue = tier;
}
}
/// <summary>
/// Owning user. Every query is filtered on this server-side, from the JWT subject —
/// it is never accepted from the client.
/// </summary>
[Required]
public string OwnerId { get; set; } = string.Empty;
public AppUser? Owner { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="SkiaSharp" Version="4.151.0" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.151.0" />
</ItemGroup>
<ItemGroup>
<!-- Transitive pins that lift two high-severity advisories out of the graph.
Both stay within the same major version the framework packages expect.
GHSA-v5pm-xwqc-g5wc: Microsoft.AspNetCore.OpenApi 10.0.10 pulls
Microsoft.OpenApi 2.0.0; the fix landed in 2.7.5.
GHSA-2m69-gcr7-jv3q: EF Core's SQLite provider pulls lib.e_sqlite3
2.1.11, which bundles a vulnerable SQLite; 2.1.12 is outside the range. -->
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
</ItemGroup>
<ItemGroup>
<!-- The 105 games recovered from the 2018 MySQL dump, read by DbSeeder at startup.
Update, not Include: the SDK already globs JSON files in as Content. -->
<Content Update="Data\Seed\games.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<!-- Response parsing and query building are internal because nothing outside
the provider should call them, but they hold the logic most worth
testing — so the test assembly can see them. -->
<InternalsVisibleTo Include="LudosData.Api.Tests" />
</ItemGroup>
</Project>
+205
View File
@@ -0,0 +1,205 @@
using System.Text;
using System.Text.Json.Serialization;
using LudosData.Api.Auth;
using LudosData.Api.Data;
using LudosData.Api.Domain;
using LudosData.Api.Services;
using LudosData.Api.Services.Pricing;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------
builder.Services.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection(JwtOptions.SectionName))
.ValidateDataAnnotations()
// Validating on start means a deployment with a missing or too-short signing
// key fails immediately and loudly, instead of issuing weak tokens.
.ValidateOnStart();
builder.Services.Configure<ImageStorageOptions>(
builder.Configuration.GetSection(ImageStorageOptions.SectionName));
builder.Services.Configure<SeedOptions>(
builder.Configuration.GetSection(SeedOptions.SectionName));
// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------
var connectionString = builder.Configuration.GetConnectionString("Default")
?? "Data Source=data/ludos.db";
// Make sure the SQLite file's directory exists before EF tries to open it.
var dataSource = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder(connectionString).DataSource;
var dataDirectory = Path.GetDirectoryName(Path.GetFullPath(dataSource));
if (!string.IsNullOrEmpty(dataDirectory))
{
Directory.CreateDirectory(dataDirectory);
}
builder.Services.AddDbContext<LudosDbContext>(options => options.UseSqlite(connectionString));
// Keep Data Protection keys on the same persistent volume as the database.
// Without this they live in the container filesystem and are regenerated on
// every restart, which silently invalidates Identity-issued tokens such as
// password-reset and email-confirmation links.
var keysDirectory = builder.Configuration["DataProtection:KeysPath"]
?? Path.Combine(dataDirectory ?? ".", "keys");
Directory.CreateDirectory(keysDirectory);
builder.Services
.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keysDirectory))
.SetApplicationName("LudosData");
// ---------------------------------------------------------------------------
// Identity + JWT
// ---------------------------------------------------------------------------
builder.Services
.AddIdentityCore<AppUser>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequiredLength = 12;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 10;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddSignInManager()
.AddEntityFrameworkStores<LudosDbContext>();
var jwtSection = builder.Configuration.GetSection(JwtOptions.SectionName);
var signingKey = jwtSection["Key"] ?? string.Empty;
builder.Services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSection["Issuer"] ?? "LudosData",
ValidAudience = jwtSection["Audience"] ?? "LudosData",
// A real key is required by JwtOptions validation on start; this
// placeholder only exists so DI can build before that check runs.
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(signingKey.Length >= 32 ? signingKey : new string('0', 32))),
ClockSkew = TimeSpan.FromMinutes(1),
};
});
builder.Services.AddAuthorization();
// ---------------------------------------------------------------------------
// Application services
// ---------------------------------------------------------------------------
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<IImageStorage, ImageStorage>();
// Pricing. The provider is registered whether or not credentials are present;
// it reports IsConfigured so the endpoint can answer 503 with a useful message
// rather than the app failing to start without an optional integration.
// Providers are registered whether or not credentials are present; each reports
// IsConfigured so the endpoint can explain what is missing rather than the app
// refusing to start without an optional integration. Order is the fallback
// order when no provider is named: PriceCharting first, since it quotes
// sale-derived prices per condition, then eBay's asking prices.
builder.Services.Configure<PriceChartingOptions>(
builder.Configuration.GetSection(PriceChartingOptions.SectionName));
builder.Services.Configure<EbayOptions>(
builder.Configuration.GetSection(EbayOptions.SectionName));
builder.Services.AddHttpClient("pricecharting", client => client.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddHttpClient("ebay", client => client.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddSingleton<IPriceProvider, PriceChartingProvider>();
builder.Services.AddSingleton<IPriceProvider, EbayPriceProvider>();
builder.Services
.AddControllers()
.AddJsonOptions(options =>
{
// Enums travel as names, not ordinals. "Cib" is self-describing in a
// payload, an export file and a log line; 2 is not, and renumbering the
// enum would silently reinterpret every stored export.
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
const string SpaCorsPolicy = "spa";
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
?? ["http://localhost:4200"];
builder.Services.AddCors(options => options.AddPolicy(SpaCorsPolicy, policy => policy
// Explicit origins, not AllowAnyOrigin. The old API sent
// `Access-Control-Allow-Origin: *` on every response.
.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()));
builder.Services.AddHealthChecks();
var app = builder.Build();
// ---------------------------------------------------------------------------
// Pipeline
// ---------------------------------------------------------------------------
app.UseExceptionHandler();
app.UseStatusCodePages();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseCors(SpaCorsPolicy);
// Serve uploaded box art from the configured folder (a Docker volume in
// production) rather than from wwwroot, so user content and app files stay apart.
var uploadOptions = app.Services.GetRequiredService<IOptions<ImageStorageOptions>>().Value;
var uploadRoot = Path.GetFullPath(uploadOptions.RootPath);
Directory.CreateDirectory(uploadRoot);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(uploadRoot),
RequestPath = uploadOptions.RequestPath,
ServeUnknownFileTypes = false,
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health").AllowAnonymous();
await DbSeeder.MigrateAndSeedAsync(app.Services);
app.Run();
/// <summary>
/// Top-level statements compile to an internal Program class, which
/// WebApplicationFactory cannot reach. Declaring it public here lets the test
/// project boot the real application rather than a stand-in.
/// </summary>
public partial class Program;
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5044",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7008;http://localhost:5044",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using System.Text;
namespace LudosData.Api.Services;
/// <summary>
/// Minimal RFC 4180 CSV reader and writer.
///
/// Hand-rolled rather than taking a dependency, because the surface needed here
/// is small — but it does handle the parts that actually bite: quoted fields
/// containing commas, escaped quotes ("" inside a quoted field), embedded
/// newlines, and CRLF or LF line endings. A description pasted from a web page
/// will contain at least two of those.
/// </summary>
public static class Csv
{
public static string Write(IReadOnlyList<string> headers, IEnumerable<IReadOnlyList<string?>> rows)
{
var builder = new StringBuilder();
builder.AppendLine(string.Join(',', headers.Select(Escape)));
foreach (var row in rows)
{
builder.AppendLine(string.Join(',', row.Select(Escape)));
}
return builder.ToString();
}
private static string Escape(string? value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
var needsQuotes = value.Contains(',') || value.Contains('"')
|| value.Contains('\n') || value.Contains('\r');
return needsQuotes ? $"\"{value.Replace("\"", "\"\"")}\"" : value;
}
/// <summary>Parses CSV text into rows of fields. The first row is the header.</summary>
public static List<List<string>> Parse(string text)
{
var rows = new List<List<string>>();
var row = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
var fieldStarted = false;
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (inQuotes)
{
if (c == '"')
{
// A doubled quote inside a quoted field is a literal quote.
if (i + 1 < text.Length && text[i + 1] == '"')
{
field.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
field.Append(c);
}
continue;
}
switch (c)
{
case '"' when !fieldStarted:
inQuotes = true;
fieldStarted = true;
break;
case ',':
row.Add(field.ToString());
field.Clear();
fieldStarted = false;
break;
case '\r':
// Swallow; the \n that follows ends the row.
break;
case '\n':
row.Add(field.ToString());
field.Clear();
fieldStarted = false;
rows.Add(row);
row = [];
break;
default:
field.Append(c);
fieldStarted = true;
break;
}
}
// A final row with no trailing newline still counts.
if (field.Length > 0 || row.Count > 0)
{
row.Add(field.ToString());
rows.Add(row);
}
// Drop trailing blank rows produced by a final newline.
while (rows.Count > 0 && rows[^1].All(string.IsNullOrWhiteSpace))
{
rows.RemoveAt(rows.Count - 1);
}
return rows;
}
}
@@ -0,0 +1,115 @@
using Microsoft.Extensions.Options;
using SkiaSharp;
namespace LudosData.Api.Services;
public interface IImageStorage
{
Task<string> SaveAsync(Stream source, string ownerId, CancellationToken ct = default);
string? BuildUrl(string ownerId, string? fileName);
}
public class ImageStorageOptions
{
public const string SectionName = "Uploads";
/// <summary>Filesystem root for uploads. In Docker this is a mounted volume.</summary>
public string RootPath { get; set; } = "uploads";
/// <summary>Public URL prefix these files are served under.</summary>
public string RequestPath { get; set; } = "/uploads";
/// <summary>Max accepted upload size. Enforced again at the endpoint.</summary>
public long MaxBytes { get; set; } = 5 * 1024 * 1024;
/// <summary>Stored images are downscaled to at most this width, preserving aspect.</summary>
public int MaxWidth { get; set; } = 500;
/// <summary>WebP quality, 1-100.</summary>
public int Quality { get; set; } = 82;
}
/// <summary>
/// Stores box art on disk as WebP, re-encoded from whatever was uploaded.
///
/// Unlike the PHP version this replaces, the uploaded filename is never used to
/// build the destination path — the name is generated server-side and the
/// extension is fixed, so a crafted filename cannot traverse directories or land
/// an executable in a served folder. Decoding the bytes and re-encoding them also
/// means only pixel data survives: any payload smuggled in metadata is dropped.
/// </summary>
public class ImageStorage(
IOptions<ImageStorageOptions> options,
ILogger<ImageStorage> logger) : IImageStorage
{
private readonly ImageStorageOptions _options = options.Value;
public async Task<string> SaveAsync(Stream source, string ownerId, CancellationToken ct = default)
{
// Buffer first: SKBitmap.Decode wants a seekable stream, and the caller's
// request stream is not. The endpoint has already bounded the length.
using var buffer = new MemoryStream();
await source.CopyToAsync(buffer, ct);
buffer.Position = 0;
// Decoding is the real content check — anything Skia cannot parse as an
// image returns null here, before a byte is persisted.
using var decoded = SKBitmap.Decode(buffer)
?? throw new InvalidDataException("The uploaded bytes are not a decodable image.");
using var final = Downscale(decoded);
var directory = DirectoryFor(ownerId);
Directory.CreateDirectory(directory);
var fileName = $"{Guid.NewGuid():N}.webp";
var fullPath = Path.Combine(directory, fileName);
using (var image = SKImage.FromBitmap(final))
using (var data = image.Encode(SKEncodedImageFormat.Webp, _options.Quality))
{
if (data is null)
{
throw new InvalidDataException("The image could not be encoded as WebP.");
}
await using var output = File.Create(fullPath);
data.SaveTo(output);
}
logger.LogInformation("Stored upload {FileName} for user {OwnerId}", fileName, ownerId);
return fileName;
}
public string? BuildUrl(string ownerId, string? fileName) =>
string.IsNullOrWhiteSpace(fileName)
? null
: $"{_options.RequestPath}/{ownerId}/{fileName}";
private SKBitmap Downscale(SKBitmap source)
{
if (source.Width <= _options.MaxWidth)
{
return source.Copy();
}
var height = (int)Math.Round(source.Height * (_options.MaxWidth / (double)source.Width));
var info = new SKImageInfo(_options.MaxWidth, Math.Max(1, height));
return source.Resize(info, new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear))
?? throw new InvalidDataException("The image could not be resized.");
}
private string DirectoryFor(string ownerId)
{
// ownerId is an Identity-generated GUID string, but this is defence in
// depth: only the bare filename component is ever joined onto the root.
var safeOwner = Path.GetFileName(ownerId);
if (string.IsNullOrWhiteSpace(safeOwner))
{
throw new ArgumentException("Invalid owner id.", nameof(ownerId));
}
return Path.Combine(Path.GetFullPath(_options.RootPath), safeOwner);
}
}
@@ -0,0 +1,254 @@
using System.Net.Http.Headers;
using System.Text.Json;
using LudosData.Api.Domain;
using Microsoft.Extensions.Options;
namespace LudosData.Api.Services.Pricing;
public class EbayOptions
{
public const string SectionName = "Ebay";
/// <summary>App ID (Client ID) from the eBay developer portal.</summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>Cert ID (Client Secret).</summary>
public string ClientSecret { get; set; } = string.Empty;
/// <summary>Marketplace to price against. Changing this changes the currency.</summary>
public string Marketplace { get; set; } = "EBAY_US";
/// <summary>Video Games category, to keep guides and accessories out of the sample.</summary>
public string CategoryId { get; set; } = "139973";
/// <summary>Listings to consider per game. More is slower and rarely more accurate.</summary>
public int MaxListings { get; set; } = 50;
/// <summary>Sandbox endpoints, for trying credentials without touching production.</summary>
public bool UseSandbox { get; set; }
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(ClientId) && !string.IsNullOrWhiteSpace(ClientSecret);
}
public interface IPriceProvider
{
string Name { get; }
bool IsConfigured { get; }
/// <summary>
/// Prices a game. <paramref name="sourceId"/> is this provider's own
/// identifier from a previous match, when one is known — providers that can
/// use it should, since an exact lookup beats re-running a title search.
/// </summary>
Task<PriceEstimate> EstimateAsync(
string title, string? system, string? sourceId = null, CancellationToken ct = default);
}
/// <summary>
/// Estimates prices from eBay's Browse API.
///
/// An important caveat, carried through to the UI: Browse returns <em>active
/// listings</em>, which are asking prices. eBay's sold-item data lives behind the
/// Marketplace Insights API, which is a limited release not open to new
/// developers. Asking prices skew high — sellers list optimistically and
/// unsold listings persist — so these figures are an upper bound on what a copy
/// would actually fetch, and are labelled as such rather than presented as a
/// valuation.
/// </summary>
public class EbayPriceProvider(
IHttpClientFactory httpClientFactory,
IOptions<EbayOptions> options,
ILogger<EbayPriceProvider> logger) : IPriceProvider
{
private readonly EbayOptions _options = options.Value;
private string? _token;
private DateTimeOffset _tokenExpiresAt = DateTimeOffset.MinValue;
private readonly SemaphoreSlim _tokenLock = new(1, 1);
public string Name => "ebay-asking";
public bool IsConfigured => _options.IsConfigured;
private string ApiHost => _options.UseSandbox
? "https://api.sandbox.ebay.com"
: "https://api.ebay.com";
/// <summary>
/// Search terms that keep the sample on the right platform. Without the
/// console name, "Chrono Trigger" returns SNES, DS and PS1 copies together
/// and the median lands between three different markets.
/// </summary>
internal static string BuildQuery(string title, string? system) =>
string.IsNullOrWhiteSpace(system) ? title : $"{title} {SystemSearchTerm(system)}";
internal static string SystemSearchTerm(string system) => system.ToUpperInvariant() switch
{
"NES" => "Nintendo NES",
"SNES" => "Super Nintendo SNES",
"N64" => "Nintendo 64",
"GC" => "GameCube",
"WII" => "Nintendo Wii",
"GB" => "Game Boy",
"GBA" => "Game Boy Advance",
"DS" => "Nintendo DS",
"PS1" => "PlayStation 1 PS1",
"PS2" => "PlayStation 2 PS2",
"PSP" => "PSP",
"360" => "Xbox 360",
_ => system,
};
public async Task<PriceEstimate> EstimateAsync(
string title, string? system, string? sourceId = null, CancellationToken ct = default)
{
// Browse has no stable per-product identifier to reuse; every lookup is
// a fresh search.
_ = sourceId;
if (!IsConfigured)
{
throw new InvalidOperationException(
"eBay credentials are not configured. Set Ebay:ClientId and Ebay:ClientSecret.");
}
var token = await GetTokenAsync(ct);
var client = httpClientFactory.CreateClient("ebay");
var query = Uri.EscapeDataString(BuildQuery(title, system));
var url = $"{ApiHost}/buy/browse/v1/item_summary/search"
+ $"?q={query}&limit={_options.MaxListings}"
+ $"&filter=buyingOptions:{{FIXED_PRICE}}"
+ (string.IsNullOrWhiteSpace(_options.CategoryId)
? string.Empty
: $"&category_ids={_options.CategoryId}");
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Add("X-EBAY-C-MARKETPLACE-ID", _options.Marketplace);
using var response = await client.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("eBay search for {Title} returned {Status}", title, response.StatusCode);
return PriceEstimate.Empty;
}
await using var stream = await response.Content.ReadAsStreamAsync(ct);
return Parse(await JsonDocument.ParseAsync(stream, cancellationToken: ct));
}
/// <summary>Turns a Browse search response into an estimate. Internal so it can be tested on fixtures.</summary>
internal static PriceEstimate Parse(JsonDocument document)
{
if (!document.RootElement.TryGetProperty("itemSummaries", out var summaries)
|| summaries.ValueKind != JsonValueKind.Array)
{
return PriceEstimate.Empty;
}
var listings = new List<PricedListing>();
var discarded = 0;
foreach (var item in summaries.EnumerateArray())
{
var title = item.TryGetProperty("title", out var t) ? t.GetString() : null;
var sellerCondition = item.TryGetProperty("condition", out var c) ? c.GetString() : null;
if (!TryReadPrice(item, out var price))
{
discarded++;
continue;
}
var tier = ListingCondition.Classify(title, sellerCondition);
if (tier is null)
{
discarded++;
continue;
}
listings.Add(new PricedListing(price, tier.Value));
}
return PriceMath.Summarise(listings, discarded);
}
private static bool TryReadPrice(JsonElement item, out decimal price)
{
price = 0m;
if (!item.TryGetProperty("price", out var priceElement)
|| !priceElement.TryGetProperty("value", out var valueElement))
{
return false;
}
// Browse reports the amount as a string.
var raw = valueElement.ValueKind == JsonValueKind.String
? valueElement.GetString()
: valueElement.ToString();
if (!decimal.TryParse(raw, System.Globalization.NumberStyles.Number,
System.Globalization.CultureInfo.InvariantCulture, out price))
{
return false;
}
// A listing at or near zero is a placeholder, not a price.
return price > 0.5m;
}
/// <summary>
/// Client-credentials token, cached until shortly before it expires. eBay
/// issues these for two hours and rate-limits the token endpoint, so
/// requesting one per game would fail long before the search quota did.
/// </summary>
private async Task<string> GetTokenAsync(CancellationToken ct)
{
if (_token is not null && DateTimeOffset.UtcNow < _tokenExpiresAt)
{
return _token;
}
await _tokenLock.WaitAsync(ct);
try
{
if (_token is not null && DateTimeOffset.UtcNow < _tokenExpiresAt)
{
return _token;
}
var client = httpClientFactory.CreateClient("ebay");
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ApiHost}/identity/v1/oauth2/token");
var basic = Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes($"{_options.ClientId}:{_options.ClientSecret}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic);
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["scope"] = "https://api.ebay.com/oauth/api_scope",
});
using var response = await client.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
_token = document.RootElement.GetProperty("access_token").GetString();
var seconds = document.RootElement.TryGetProperty("expires_in", out var e)
? e.GetInt32() : 7200;
// Retire it a minute early rather than discover expiry mid-batch.
_tokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(seconds - 60);
return _token!;
}
finally
{
_tokenLock.Release();
}
}
}
@@ -0,0 +1,106 @@
using System.Text.RegularExpressions;
using LudosData.Api.Domain;
namespace LudosData.Api.Services.Pricing;
/// <summary>
/// Sorts a marketplace listing into a condition tier from its title and the
/// seller's own condition flag.
///
/// This is the weakest link in deriving prices from active listings, and it is
/// isolated here so it can be tested on its own. Sellers do not use a controlled
/// vocabulary: "CIB", "complete in box", "w/ manual" and "boxed" all mean the
/// same tier, while "box only" and "manual only" mean there is no game at all
/// and the listing must be discarded rather than counted as cheap.
/// </summary>
public static partial class ListingCondition
{
/// <summary>
/// Listings that are not a copy of the game, at any condition.
///
/// The qualifier is required, not optional. Matching a bare "box" would
/// discard "complete in box" and "with box and manual" — that is, most of
/// the CIB tier — while leaving the cheap box-only listings that the filter
/// exists to remove.
/// </summary>
[GeneratedRegex(
@"\b(?:"
+ @"(?:box|case|manual|instructions?|insert|artwork|art\s*work|cover|label|"
+ @"poster|sticker|protector|display|shell)\s+only"
+ @"|only\s+(?:the\s+)?(?:box|case|manual|cover)"
+ @"|empty\s+(?:box|case)"
+ @"|no\s+(?:game|cart|cartridge|disc)"
+ @"|(?:custom|replacement|repro|reproduction)\s+(?:art|label|case|box|cover|manual)"
+ @"|(?:art|label|case|box|cover|manual)\s+(?:replacement|repro)"
+ @")\b",
RegexOptions.IgnoreCase)]
private static partial Regex AccessoryPattern();
/// <summary>Explicitly not a genuine retail copy.</summary>
[GeneratedRegex(@"\b(repro|reproduction|bootleg|fake|counterfeit|homebrew|aftermarket)\b",
RegexOptions.IgnoreCase)]
private static partial Regex CounterfeitPattern();
/// <summary>A bundle prices several games at once and would skew a median.</summary>
[GeneratedRegex(@"\b(lot|bundle|collection\s+of|\d+\s*games?|joblot|job\s+lot)\b",
RegexOptions.IgnoreCase)]
private static partial Regex LotPattern();
[GeneratedRegex(@"\b(sealed|factory\s*sealed|brand\s*new|bnib|nib|vga|wata|graded)\b",
RegexOptions.IgnoreCase)]
private static partial Regex SealedPattern();
[GeneratedRegex(
@"\b(cib|complete\s*in\s*box|complete|boxed|with\s*(box|manual|case)|"
+ @"w/\s*(box|manual|case)|box\s*and\s*manual)\b",
RegexOptions.IgnoreCase)]
private static partial Regex CompletePattern();
[GeneratedRegex(@"\b(loose|cart\s*only|cartridge\s*only|disc\s*only|game\s*only|unboxed)\b",
RegexOptions.IgnoreCase)]
private static partial Regex LoosePattern();
/// <summary>
/// The tier a listing belongs to, or null when it should not be counted —
/// an accessory, a reproduction, or a multi-game lot.
/// </summary>
public static GameCondition? Classify(string? title, string? sellerCondition)
{
var text = title ?? string.Empty;
// Discard first. A "box only" listing at $8 would otherwise drag a
// loose-cart median down to nonsense.
if (AccessoryPattern().IsMatch(text)
|| CounterfeitPattern().IsMatch(text)
|| LotPattern().IsMatch(text))
{
return null;
}
if (SealedPattern().IsMatch(text))
{
return GameCondition.Sealed;
}
if (CompletePattern().IsMatch(text))
{
return GameCondition.Cib;
}
if (LoosePattern().IsMatch(text))
{
return GameCondition.Loose;
}
// Nothing in the title said. Fall back to the seller's own flag, which
// only distinguishes new from used.
if (string.Equals(sellerCondition, "New", StringComparison.OrdinalIgnoreCase))
{
return GameCondition.Sealed;
}
// An unqualified used listing is most often a loose cart or disc, and
// that is also the conservative reading.
return GameCondition.Loose;
}
}
@@ -0,0 +1,195 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace LudosData.Api.Services.Pricing;
public class PriceChartingOptions
{
public const string SectionName = "PriceCharting";
/// <summary>
/// Subscription token, from the Subscriptions page of a PriceCharting
/// account (the "API/Download" button).
/// </summary>
public string Token { get; set; } = string.Empty;
public bool IsConfigured => !string.IsNullOrWhiteSpace(Token);
}
/// <summary>
/// Prices from PriceCharting, which quotes loose, complete and new separately —
/// the same three tiers this app stores, so no inference is needed and the
/// numbers are sale-derived rather than asking prices.
///
/// It needs a paid subscription, but access is immediate on subscribing, with
/// no application or review. That makes it the practical option when an eBay
/// developer account is stuck in verification.
///
/// One caveat worth knowing when you first run it: PriceCharting's published
/// API documentation is not reachable without an account, so the response shape
/// below follows their widely-used convention — prices as integer pennies under
/// hyphenated keys. <see cref="Parse"/> is deliberately tolerant and is the only
/// place to adjust if their field names differ from this.
/// </summary>
public class PriceChartingProvider(
IHttpClientFactory httpClientFactory,
IOptions<PriceChartingOptions> options,
ILogger<PriceChartingProvider> logger) : IPriceProvider
{
private readonly PriceChartingOptions _options = options.Value;
public string Name => "pricecharting";
public bool IsConfigured => _options.IsConfigured;
/// <summary>
/// PriceCharting keys its catalogue by console name, so the query carries
/// one. Their names are spelled out rather than abbreviated.
/// </summary>
internal static string ConsoleName(string? system) => (system ?? string.Empty).ToUpperInvariant() switch
{
"NES" => "nes",
"SNES" => "super nintendo",
"N64" => "nintendo 64",
"GC" => "gamecube",
"WII" => "wii",
"GB" => "gameboy",
"GBA" => "gameboy advance",
"DS" => "nintendo ds",
"PS1" => "playstation",
"PS2" => "playstation 2",
"PSP" => "psp",
"360" => "xbox 360",
_ => system?.ToLowerInvariant() ?? string.Empty,
};
internal static string BuildQuery(string title, string? system)
{
var console = ConsoleName(system);
return string.IsNullOrWhiteSpace(console) ? title : $"{console} {title}";
}
public async Task<PriceEstimate> EstimateAsync(
string title, string? system, string? sourceId = null, CancellationToken ct = default)
{
if (!IsConfigured)
{
throw new InvalidOperationException(
"PriceCharting is not configured. Set PriceCharting:Token.");
}
var client = httpClientFactory.CreateClient("pricecharting");
// An id from a previous match identifies the exact product; only fall
// back to searching by name when there is none.
var lookup = string.IsNullOrWhiteSpace(sourceId)
? $"&q={Uri.EscapeDataString(BuildQuery(title, system))}"
: $"&id={Uri.EscapeDataString(sourceId)}";
var url = "https://www.pricecharting.com/api/product"
+ $"?t={Uri.EscapeDataString(_options.Token)}"
+ lookup;
using var response = await client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("PriceCharting lookup for {Title} returned {Status}",
title, response.StatusCode);
return PriceEstimate.Empty;
}
await using var stream = await response.Content.ReadAsStreamAsync(ct);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
return Parse(document);
}
/// <summary>
/// Reads the three tiers from a product response.
///
/// Internal so it can be tested on a fixture without a subscription, and
/// tolerant of both hyphenated and camelCase keys so a naming difference
/// degrades to "no price" rather than a crash.
/// </summary>
internal static PriceEstimate Parse(JsonDocument document)
{
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return PriceEstimate.Empty;
}
// A miss is reported in-band with a status field rather than by HTTP.
if (root.TryGetProperty("status", out var status)
&& string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase))
{
return PriceEstimate.Empty;
}
var loose = ReadPennies(root, "loose-price", "loosePrice");
var cib = ReadPennies(root, "cib-price", "cibPrice");
var boxed = ReadPennies(root, "new-price", "newPrice");
// A single quoted price per tier, so the sample count is one where a
// price exists — the estimate carries its own confidence either way.
return new PriceEstimate(
loose, cib, boxed,
loose is null ? 0 : 1,
cib is null ? 0 : 1,
boxed is null ? 0 : 1,
0)
{
// Carried through so a dry run can show which record was matched.
MatchedName = ReadString(root, "product-name", "productName"),
MatchedConsole = ReadString(root, "console-name", "consoleName"),
SourceId = ReadString(root, "id", "productId"),
};
}
private static string? ReadString(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (root.TryGetProperty(name, out var element))
{
var value = element.ValueKind == JsonValueKind.String
? element.GetString()
: element.ToString();
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
}
return null;
}
/// <summary>Prices arrive as integer pennies; 1250 means $12.50.</summary>
private static decimal? ReadPennies(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var element))
{
continue;
}
long? pennies = element.ValueKind switch
{
JsonValueKind.Number when element.TryGetInt64(out var value) => value,
JsonValueKind.String when long.TryParse(element.GetString(), out var value) => value,
_ => null,
};
// Zero means "no price on record", not "free".
if (pennies is > 0)
{
return pennies.Value / 100m;
}
}
return null;
}
}
@@ -0,0 +1,119 @@
using LudosData.Api.Domain;
namespace LudosData.Api.Services.Pricing;
/// <summary>One priced listing, after classification.</summary>
public record PricedListing(decimal Price, GameCondition Tier);
/// <summary>
/// A per-condition estimate plus how much evidence sits behind it.
///
/// The sample counts are part of the result, not diagnostics: a tier derived
/// from two listings deserves less confidence than one derived from thirty, and
/// the caller needs to be able to say so.
/// </summary>
public record PriceEstimate(
decimal? Loose,
decimal? Cib,
decimal? New,
int LooseSamples,
int CibSamples,
int NewSamples,
int Discarded)
{
/// <summary>
/// What the source thinks it priced, when it says so.
///
/// Prices are meaningless without knowing which record they came from: a
/// lookup for the DS "Chrono Trigger" that quietly resolves to the SNES
/// original returns plausible numbers for the wrong game. Surfacing the
/// matched title and console makes a dry run auditable instead of a leap of
/// faith.
/// </summary>
public string? MatchedName { get; init; }
public string? MatchedConsole { get; init; }
/// <summary>
/// The source's own identifier for the matched product, when it has one.
/// Storing it turns every later refresh into an exact lookup rather than a
/// repeat of the same fuzzy search.
/// </summary>
public string? SourceId { get; init; }
public bool HasAnyPrice => Loose is not null || Cib is not null || New is not null;
public static readonly PriceEstimate Empty = new(null, null, null, 0, 0, 0, 0);
}
public static class PriceMath
{
/// <summary>
/// Median, not mean. Marketplace listings carry outliers in both directions —
/// an optimist asking ten times the going rate, or a mispriced bargain — and
/// a mean chases them while a median does not.
/// </summary>
public static decimal? Median(IReadOnlyList<decimal> values)
{
if (values.Count == 0)
{
return null;
}
var sorted = values.OrderBy(v => v).ToArray();
var middle = sorted.Length / 2;
return sorted.Length % 2 == 1
? sorted[middle]
: Math.Round((sorted[middle - 1] + sorted[middle]) / 2m, 2);
}
/// <summary>
/// Drops prices far outside the bulk of the sample before taking a median.
///
/// Uses the interquartile range rather than standard deviations: listing
/// prices are not normally distributed, and a single graded copy at 50x
/// would widen a standard deviation enough to protect itself.
/// </summary>
public static List<decimal> RemoveOutliers(IReadOnlyList<decimal> values)
{
if (values.Count < 4)
{
// Too few points for quartiles to mean anything.
return [.. values];
}
var sorted = values.OrderBy(v => v).ToArray();
var q1 = sorted[sorted.Length / 4];
var q3 = sorted[sorted.Length * 3 / 4];
var iqr = q3 - q1;
if (iqr <= 0)
{
return [.. values];
}
var low = q1 - 1.5m * iqr;
var high = q3 + 1.5m * iqr;
return sorted.Where(v => v >= low && v <= high).ToList();
}
/// <summary>Aggregates classified listings into a per-tier estimate.</summary>
public static PriceEstimate Summarise(IReadOnlyList<PricedListing> listings, int discarded)
{
decimal? TierPrice(GameCondition tier, out int samples)
{
var prices = listings.Where(l => l.Tier == tier).Select(l => l.Price).ToList();
var kept = RemoveOutliers(prices);
samples = kept.Count;
return Median(kept);
}
var loose = TierPrice(GameCondition.Loose, out var looseSamples);
var cib = TierPrice(GameCondition.Cib, out var cibSamples);
var sealedPrice = TierPrice(GameCondition.Sealed, out var newSamples);
return new PriceEstimate(
loose, cib, sealedPrice, looseSamples, cibSamples, newSamples, discarded);
}
}
@@ -0,0 +1,136 @@
using System.Globalization;
using LudosData.Api.Services;
namespace LudosData.Api.Services.Pricing;
/// <summary>One row of an external price list.</summary>
public record PriceGuideRow(
string Title,
string? System,
decimal? Loose,
decimal? Cib,
decimal? New);
public record PriceGuideParseResult(
IReadOnlyList<PriceGuideRow> Rows,
IReadOnlyList<string> Problems);
/// <summary>
/// Reads a price list out of a CSV.
///
/// The point is to be source-agnostic. A PriceCharting subscriber's bulk
/// download, a spreadsheet kept by hand, and a list exported from anywhere else
/// all describe the same thing — a title, a platform and some prices — so this
/// accepts the column names each of them tends to use rather than demanding one
/// fixed schema.
/// </summary>
public static class PriceGuide
{
// Header aliases, lowercased and stripped of spaces, underscores and hyphens.
private static readonly string[] TitleNames =
["title", "productname", "product", "name", "game", "gamename"];
private static readonly string[] SystemNames =
["system", "console", "consolename", "platform"];
private static readonly string[] LooseNames =
["loose", "looseprice", "loosevalue", "cartonly", "value"];
private static readonly string[] CibNames =
["cib", "cibprice", "complete", "completeprice", "cibvalue", "completeinbox"];
private static readonly string[] NewNames =
["new", "newprice", "sealed", "sealedprice", "newvalue", "graded"];
private static string Normalise(string header) =>
new(header.Trim().ToLowerInvariant()
.Where(c => char.IsLetterOrDigit(c))
.ToArray());
public static PriceGuideParseResult Parse(string text)
{
var problems = new List<string>();
var rows = Csv.Parse(text);
if (rows.Count == 0)
{
return new PriceGuideParseResult([], ["The file is empty."]);
}
var header = rows[0].Select(Normalise).ToList();
int Find(string[] names)
{
foreach (var name in names)
{
var at = header.IndexOf(name);
if (at >= 0) return at;
}
return -1;
}
var titleAt = Find(TitleNames);
if (titleAt < 0)
{
return new PriceGuideParseResult([],
["No title column found. Expected one of: title, product-name, name, game."]);
}
var systemAt = Find(SystemNames);
var looseAt = Find(LooseNames);
var cibAt = Find(CibNames);
var newAt = Find(NewNames);
if (looseAt < 0 && cibAt < 0 && newAt < 0)
{
return new PriceGuideParseResult([],
["No price column found. Expected at least one of: loose, cib, new."]);
}
var parsed = new List<PriceGuideRow>();
for (var i = 1; i < rows.Count; i++)
{
var row = rows[i];
string? Cell(int at) =>
at >= 0 && at < row.Count && row[at].Trim().Length > 0 ? row[at].Trim() : null;
var title = Cell(titleAt);
if (title is null)
{
problems.Add($"Row {i + 1}: no title");
continue;
}
parsed.Add(new PriceGuideRow(
title, Cell(systemAt),
ParseMoney(Cell(looseAt)), ParseMoney(Cell(cibAt)), ParseMoney(Cell(newAt))));
}
return new PriceGuideParseResult(parsed, problems);
}
/// <summary>
/// Tolerant of what a spreadsheet emits: currency symbols, thousands
/// separators, and integer pennies where a guide quotes them that way.
/// </summary>
public static decimal? ParseMoney(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var cleaned = value.Trim().TrimStart('$', '£', '€').Replace(",", string.Empty).Trim();
if (!decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed))
{
return null;
}
// Zero means "no price on record", not "free".
return parsed > 0 ? parsed : null;
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,29 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Data Source=data/ludos.db"
},
"Jwt": {
"Issuer": "LudosData",
"Audience": "LudosData",
"LifetimeMinutes": 720
},
"Uploads": {
"RootPath": "uploads",
"RequestPath": "/uploads",
"MaxBytes": 5242880,
"MaxWidth": 500
},
"Cors": {
"AllowedOrigins": [ "http://localhost:4200", "http://localhost:8080" ]
},
"Seed": {
"Enabled": true
}
}
@@ -0,0 +1,171 @@
using System.Net;
using System.Net.Http.Json;
namespace LudosData.Api.Tests;
public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static object Registration(string user, string password = "TestPassword123") => new
{
userName = user,
email = $"{user}@example.test",
password,
};
[Fact]
public async Task Register_returns_a_usable_token()
{
var client = factory.CreateClient();
var response = await client.PostJsonAsync("/api/auth/register", Registration("reg-ok"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var auth = await response.Content.ReadJsonAsync<LudosApiFactory.AuthPayload>();
Assert.False(string.IsNullOrWhiteSpace(auth!.Token));
Assert.Equal("reg-ok", auth.User.UserName);
Assert.True(auth.ExpiresAt > DateTimeOffset.UtcNow);
}
[Theory]
[InlineData("short1A")] // under 12 characters
[InlineData("alllowercase123")] // no uppercase
[InlineData("ALLUPPERCASE123")] // no lowercase
[InlineData("NoDigitsInHerePlease")] // no digit
public async Task Register_enforces_the_password_policy(string password)
{
var client = factory.CreateClient();
var response = await client.PostJsonAsync(
"/api/auth/register", Registration($"weak-{password.Length}-{password[0]}", password));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Register_rejects_a_duplicate_username()
{
var client = factory.CreateClient();
await client.PostJsonAsync("/api/auth/register", Registration("dupe-user"));
var second = await client.PostJsonAsync("/api/auth/register", Registration("dupe-user"));
Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);
}
[Fact]
public async Task Login_succeeds_with_the_right_password()
{
var client = factory.CreateClient();
await client.PostJsonAsync("/api/auth/register", Registration("login-ok"));
var response = await client.PostJsonAsync(
"/api/auth/login", new { userName = "login-ok", password = "TestPassword123" });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Login_rejects_a_wrong_password()
{
var client = factory.CreateClient();
await client.PostJsonAsync("/api/auth/register", Registration("login-bad"));
var response = await client.PostJsonAsync(
"/api/auth/login", new { userName = "login-bad", password = "WrongPassword123" });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Login_does_not_reveal_whether_a_username_exists()
{
var client = factory.CreateClient();
await client.PostJsonAsync("/api/auth/register", Registration("enum-real"));
var wrongPassword = await client.PostJsonAsync(
"/api/auth/login", new { userName = "enum-real", password = "WrongPassword123" });
var noSuchUser = await client.PostJsonAsync(
"/api/auth/login", new { userName = "enum-absent", password = "WrongPassword123" });
// Identical status and body, so the endpoint cannot be used to harvest
// valid usernames.
Assert.Equal(noSuchUser.StatusCode, wrongPassword.StatusCode);
Assert.Equal(
await noSuchUser.Content.ReadAsStringAsync(),
await wrongPassword.Content.ReadAsStringAsync());
}
[Fact]
public async Task Availability_reports_taken_and_free_names_without_leaking_the_row()
{
var client = factory.CreateClient();
await client.PostJsonAsync("/api/auth/register", Registration("taken-name"));
var taken = await client.GetJsonAsync<AvailabilityPayload>(
"/api/auth/available?userName=taken-name");
var free = await client.GetJsonAsync<AvailabilityPayload>(
"/api/auth/available?userName=definitely-free-name");
Assert.False(taken!.Available);
Assert.True(free!.Available);
// The response carries a boolean and nothing else — the old API answered
// this by returning the whole users row to anonymous callers.
var raw = await client.GetStringAsync("/api/auth/available?userName=taken-name");
Assert.DoesNotContain("email", raw, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("@example.test", raw, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Me_requires_authentication()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/auth/me");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Me_returns_the_signed_in_user()
{
var client = await factory.CreateUserClientAsync("me-user");
var user = await client.GetJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
Assert.Equal("me-user", user!.UserName);
}
[Fact]
public async Task A_token_signed_with_the_wrong_key_is_rejected()
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", ForgedToken());
var response = await client.GetAsync("/api/games");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
/// <summary>A structurally valid token signed with a key the API does not trust.</summary>
private static string ForgedToken()
{
var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
System.Text.Encoding.UTF8.GetBytes("an-attacker-controlled-key-32-chars-min"));
var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
issuer: "LudosData",
audience: "LudosData",
claims: [new System.Security.Claims.Claim(
System.Security.Claims.ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())],
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: new Microsoft.IdentityModel.Tokens.SigningCredentials(
key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256));
return handler.WriteToken(token);
}
private record AvailabilityPayload(bool Available);
}
@@ -0,0 +1,325 @@
using System.Net;
using System.Net.Http.Json;
using LudosData.Api.Contracts;
using LudosData.Api.Domain;
namespace LudosData.Api.Tests;
public class CollectorFieldTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static object Game(
string title,
string system = "SNES",
int? rating = null,
string? notes = null,
GameCondition condition = GameCondition.Unspecified,
GameRegion region = GameRegion.Unspecified,
decimal? purchasePrice = null,
string? purchaseDate = null,
decimal? marketValue = null,
string? marketValueSource = null) => new
{
title, system, own = true,
rating, notes, condition, region,
purchasePrice, purchaseDate, marketValue, marketValueSource,
};
private static async Task<GamePayload> CreateAsync(HttpClient client, object body)
{
var response = await client.PostJsonAsync("/api/games", body);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadJsonAsync<GamePayload>())!;
}
[Fact]
public async Task Collector_fields_round_trip()
{
var client = await factory.CreateUserClientAsync("cf-roundtrip");
var created = await CreateAsync(client, Game(
"Panzer Dragoon Saga",
rating: 9,
notes: "Bought at a swap meet. Disc 2 has a scratch.",
condition: GameCondition.Cib,
region: GameRegion.Ntsc,
purchasePrice: 249.99m,
purchaseDate: "2019-06-14",
marketValue: 1150.00m,
marketValueSource: "pricecharting"));
Assert.Equal(9, created.Rating);
Assert.Equal("Bought at a swap meet. Disc 2 has a scratch.", created.Notes);
Assert.Equal(GameCondition.Cib, created.Condition);
Assert.Equal(GameRegion.Ntsc, created.Region);
Assert.Equal(249.99m, created.PurchasePrice);
Assert.Equal(new DateOnly(2019, 6, 14), created.PurchaseDate);
Assert.Equal(1150.00m, created.MarketValue);
Assert.Equal("pricecharting", created.MarketValueSource);
}
[Fact]
public async Task Money_keeps_its_cents_through_storage()
{
var client = await factory.CreateUserClientAsync("cf-cents");
// Money is stored as integer minor units, so the awkward values are the
// ones worth checking.
var created = await CreateAsync(client, Game("Cent Test",
purchasePrice: 0.01m, marketValue: 19.99m));
Assert.Equal(0.01m, created.PurchasePrice);
Assert.Equal(19.99m, created.MarketValue);
var reloaded = await client.GetJsonAsync<GamePayload>($"/api/games/{created.Id}");
Assert.Equal(0.01m, reloaded!.PurchasePrice);
Assert.Equal(19.99m, reloaded.MarketValue);
}
[Fact]
public async Task Sorting_by_value_is_numeric_not_lexical()
{
var client = await factory.CreateUserClientAsync("cf-sort");
await CreateAsync(client, Game("Nine", marketValue: 9m));
await CreateAsync(client, Game("Ten", marketValue: 10m));
await CreateAsync(client, Game("Hundred", marketValue: 100m));
var page = await client.GetJsonAsync<PagePayload>("/api/games?sort=value&dir=desc");
// Stored as text, "9" would sort above "100" and this would read
// Nine, Ten, Hundred.
Assert.Equal(["Hundred", "Ten", "Nine"], page!.Items.Select(g => g.Title));
}
[Fact]
public async Task Rating_must_be_between_1_and_10()
{
var client = await factory.CreateUserClientAsync("cf-rating");
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Too low", rating: 0))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Too high", rating: 11))).StatusCode);
// Null is unrated, which is legitimate and not the same as zero.
var unrated = await CreateAsync(client, Game("Unrated"));
Assert.Null(unrated.Rating);
}
[Fact]
public async Task A_minimum_rating_filter_excludes_unrated_games()
{
var client = await factory.CreateUserClientAsync("cf-minrating");
await CreateAsync(client, Game("Great", rating: 9));
await CreateAsync(client, Game("Fine", rating: 6));
await CreateAsync(client, Game("Unrated"));
var page = await client.GetJsonAsync<PagePayload>("/api/games?minRating=7");
Assert.Equal("Great", Assert.Single(page!.Items).Title);
}
[Fact]
public async Task Condition_and_region_filter()
{
var client = await factory.CreateUserClientAsync("cf-filters");
await CreateAsync(client, Game("Sealed Copy", condition: GameCondition.Sealed, region: GameRegion.Ntsc));
await CreateAsync(client, Game("Loose Copy", condition: GameCondition.Loose, region: GameRegion.Pal));
var sealedOnly = await client.GetJsonAsync<PagePayload>("/api/games?condition=Sealed");
var palOnly = await client.GetJsonAsync<PagePayload>("/api/games?region=Pal");
Assert.Equal("Sealed Copy", Assert.Single(sealedOnly!.Items).Title);
Assert.Equal("Loose Copy", Assert.Single(palOnly!.Items).Title);
}
[Fact]
public async Task HasValue_separates_valued_from_unvalued_games()
{
var client = await factory.CreateUserClientAsync("cf-hasvalue");
await CreateAsync(client, Game("Valued", marketValue: 40m));
await CreateAsync(client, Game("Unvalued"));
var valued = await client.GetJsonAsync<PagePayload>("/api/games?hasValue=true");
var unvalued = await client.GetJsonAsync<PagePayload>("/api/games?hasValue=false");
Assert.Equal("Valued", Assert.Single(valued!.Items).Title);
Assert.Equal("Unvalued", Assert.Single(unvalued!.Items).Title);
}
[Fact]
public async Task Setting_a_value_stamps_when_and_where_it_came_from()
{
var client = await factory.CreateUserClientAsync("cf-stamp");
var before = DateTimeOffset.UtcNow.AddSeconds(-1);
var created = await CreateAsync(client, Game("Stamped", marketValue: 55m));
Assert.NotNull(created.MarketValueUpdatedAt);
Assert.True(created.MarketValueUpdatedAt >= before);
// No source given, so it is recorded as hand-entered.
Assert.Equal("manual", created.MarketValueSource);
}
[Fact]
public async Task An_unrelated_edit_does_not_make_a_stale_valuation_look_fresh()
{
var client = await factory.CreateUserClientAsync("cf-nostamp");
var created = await CreateAsync(client, Game("Keeps Its Date", marketValue: 30m));
var originalStamp = created.MarketValueUpdatedAt;
await Task.Delay(20);
// Change the notes, leave the value alone.
var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}",
Game("Keeps Its Date", notes: "Edited something else", marketValue: 30m)))
.Content.ReadJsonAsync<GamePayload>();
Assert.Equal("Edited something else", updated!.Notes);
Assert.Equal(originalStamp, updated.MarketValueUpdatedAt);
}
[Fact]
public async Task Changing_the_value_moves_the_timestamp()
{
var client = await factory.CreateUserClientAsync("cf-restamp");
var created = await CreateAsync(client, Game("Repriced", marketValue: 30m));
await Task.Delay(20);
var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}",
Game("Repriced", marketValue: 45m))).Content.ReadJsonAsync<GamePayload>();
Assert.Equal(45m, updated!.MarketValue);
Assert.True(updated.MarketValueUpdatedAt > created.MarketValueUpdatedAt);
}
[Fact]
public async Task Clearing_the_value_clears_its_metadata_too()
{
var client = await factory.CreateUserClientAsync("cf-clear");
var created = await CreateAsync(client, Game("Devalued", marketValue: 30m, marketValueSource: "feed"));
var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}",
Game("Devalued"))).Content.ReadJsonAsync<GamePayload>();
Assert.Null(updated!.MarketValue);
Assert.Null(updated.MarketValueUpdatedAt);
Assert.Null(updated.MarketValueSource);
}
[Fact]
public async Task Negative_money_is_rejected()
{
var client = await factory.CreateUserClientAsync("cf-negative");
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Negative", purchasePrice: -5m))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Negative", marketValue: -5m))).StatusCode);
}
// ---- export / import -------------------------------------------------
[Fact]
public async Task Collector_fields_survive_a_json_round_trip()
{
var source = await factory.CreateUserClientAsync("cf-json-src");
await CreateAsync(source, Game("Full House",
rating: 8, notes: "note", condition: GameCondition.Cib, region: GameRegion.NtscJ,
purchasePrice: 12.34m, purchaseDate: "2020-01-02",
marketValue: 56.78m, marketValueSource: "feed"));
var exported = await source.GetStringAsync("/api/library/export?format=json");
var target = await factory.CreateUserClientAsync("cf-json-dst");
await target.PostAsync("/api/library/import", FileContent(exported, "l.json"));
var game = Assert.Single((await target.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(8, game.Rating);
Assert.Equal(GameCondition.Cib, game.Condition);
Assert.Equal(GameRegion.NtscJ, game.Region);
Assert.Equal(12.34m, game.PurchasePrice);
Assert.Equal(new DateOnly(2020, 1, 2), game.PurchaseDate);
Assert.Equal(56.78m, game.MarketValue);
Assert.Equal("feed", game.MarketValueSource);
}
[Fact]
public async Task Collector_fields_survive_a_csv_round_trip()
{
var source = await factory.CreateUserClientAsync("cf-csv-src");
await CreateAsync(source, Game("CSV House",
rating: 7, condition: GameCondition.Sealed, region: GameRegion.Pal,
purchasePrice: 99.95m, purchaseDate: "2021-11-30", marketValue: 250m));
var csv = await source.GetStringAsync("/api/library/export?format=csv");
var target = await factory.CreateUserClientAsync("cf-csv-dst");
await target.PostAsync("/api/library/import", FileContent(csv, "l.csv"));
var game = Assert.Single((await target.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(7, game.Rating);
Assert.Equal(GameCondition.Sealed, game.Condition);
Assert.Equal(GameRegion.Pal, game.Region);
Assert.Equal(99.95m, game.PurchasePrice);
Assert.Equal(250m, game.MarketValue);
}
[Fact]
public async Task Importing_restores_a_valuation_date_rather_than_resetting_it()
{
var client = await factory.CreateUserClientAsync("cf-import-date");
// A valuation captured well in the past should still read as old after a
// restore — an import is not a fresh price check.
var json = """
[{ "title": "Old Valuation", "system": "PS1", "own": true,
"marketValue": 42.00, "marketValueUpdatedAt": "2020-03-01T00:00:00+00:00",
"marketValueSource": "archive" }]
""";
await client.PostAsync("/api/library/import", FileContent(json, "l.json"));
var game = Assert.Single((await client.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(2020, game.MarketValueUpdatedAt!.Value.Year);
Assert.Equal("archive", game.MarketValueSource);
}
[Fact]
public async Task Spreadsheet_style_money_is_accepted_on_import()
{
var client = await factory.CreateUserClientAsync("cf-messy-money");
// What a spreadsheet actually emits after someone formats a column.
const string csv = """
title,system,own,purchasePrice,marketValue
Formatted,PS2,true,"$1,234.56","$2,000.00"
""";
await client.PostAsync("/api/library/import", FileContent(csv, "l.csv"));
var game = Assert.Single((await client.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(1234.56m, game.PurchasePrice);
Assert.Equal(2000m, game.MarketValue);
}
private static MultipartFormDataContent FileContent(string body, string name)
{
var content = new MultipartFormDataContent();
content.Add(new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(body)), "file", name);
return content;
}
private record GamePayload(
int Id, string Title, int? Rating, string? Notes,
GameCondition Condition, GameRegion Region,
decimal? PurchasePrice, DateOnly? PurchaseDate,
decimal? MarketValue, DateTimeOffset? MarketValueUpdatedAt, string? MarketValueSource);
private record PagePayload(List<GamePayload> Items, int Total);
}
@@ -0,0 +1,258 @@
using System.Net;
using System.Net.Http.Json;
namespace LudosData.Api.Tests;
public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static object Game(
string title, string system = "SNES", string genre = "rpg", string? year = "1995",
string? developer = null, string? publisher = null,
bool own = true, bool dumped = false, bool played = false, bool finished = false) => new
{
title, system, genre, year, developer, publisher,
own, dumped, played, finished,
};
private static async Task SeedLibraryAsync(HttpClient client)
{
await client.PostJsonAsync("/api/games", Game("Chrono Trigger", "SNES", "rpg", "1995", developer: "Square"));
await client.PostJsonAsync("/api/games", Game("Super Metroid", "SNES", "platformer", "1994"));
await client.PostJsonAsync("/api/games", Game("GoldenEye 007", "N64", "fps", "1997", publisher: "Nintendo"));
await client.PostJsonAsync("/api/games", Game("Banjo-Kazooie", "N64", "adventure", "1998", played: true, finished: true));
await client.PostJsonAsync("/api/games", Game("Ico", "PS2", "adventure", "2001", played: true));
}
[Fact]
public async Task List_paginates_and_reports_totals()
{
var client = await factory.CreateUserClientAsync("page-user");
await SeedLibraryAsync(client);
var page = await client.GetJsonAsync<PagePayload>("/api/games?page=1&pageSize=2");
Assert.Equal(2, page!.Items.Count);
Assert.Equal(5, page.Total);
Assert.Equal(3, page.TotalPages);
}
[Fact]
public async Task Search_matches_title_developer_and_publisher()
{
var client = await factory.CreateUserClientAsync("search-user");
await SeedLibraryAsync(client);
var byTitle = await client.GetJsonAsync<PagePayload>("/api/games?search=metroid");
var byDeveloper = await client.GetJsonAsync<PagePayload>("/api/games?search=Square");
var byPublisher = await client.GetJsonAsync<PagePayload>("/api/games?search=Nintendo");
Assert.Equal("Super Metroid", Assert.Single(byTitle!.Items).Title);
Assert.Equal("Chrono Trigger", Assert.Single(byDeveloper!.Items).Title);
Assert.Equal("GoldenEye 007", Assert.Single(byPublisher!.Items).Title);
}
[Fact]
public async Task Filters_narrow_by_system_genre_and_status()
{
var client = await factory.CreateUserClientAsync("filter-user");
await SeedLibraryAsync(client);
var n64 = await client.GetJsonAsync<PagePayload>("/api/games?system=N64");
var adventure = await client.GetJsonAsync<PagePayload>("/api/games?genre=adventure");
var finished = await client.GetJsonAsync<PagePayload>("/api/games?finished=true");
var unplayed = await client.GetJsonAsync<PagePayload>("/api/games?played=false");
Assert.Equal(2, n64!.Total);
Assert.Equal(2, adventure!.Total);
Assert.Equal(1, finished!.Total);
Assert.Equal(3, unplayed!.Total);
}
[Fact]
public async Task Sort_orders_ascending_and_descending()
{
var client = await factory.CreateUserClientAsync("sort-user");
await SeedLibraryAsync(client);
var ascending = await client.GetJsonAsync<PagePayload>("/api/games?sort=title&dir=asc");
var descending = await client.GetJsonAsync<PagePayload>("/api/games?sort=title&dir=desc");
Assert.Equal("Banjo-Kazooie", ascending!.Items.First().Title);
Assert.Equal("Super Metroid", descending!.Items.First().Title);
}
[Fact]
public async Task An_unknown_sort_key_falls_back_to_title_rather_than_failing()
{
var client = await factory.CreateUserClientAsync("sort-unknown");
await SeedLibraryAsync(client);
// The sort parameter is matched against an allow-list, so a value the API
// does not define can neither error nor reach the query shape.
var response = await client.GetAsync("/api/games?sort=id);DROP%20TABLE%20Games;--");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var page = await response.Content.ReadJsonAsync<PagePayload>();
Assert.Equal("Banjo-Kazooie", page!.Items.First().Title);
// And the table is still there.
var after = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(5, after!.Total);
}
[Theory]
[InlineData("pageSize=0")]
[InlineData("pageSize=1000")]
[InlineData("page=0")]
public async Task Out_of_range_paging_is_rejected(string query)
{
var client = await factory.CreateUserClientAsync($"range-{query.GetHashCode():X}");
var response = await client.GetAsync($"/api/games?{query}");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Create_requires_a_title()
{
var client = await factory.CreateUserClientAsync("title-required");
var empty = await client.PostJsonAsync("/api/games", new { title = "", system = "SNES" });
var whitespace = await client.PostJsonAsync("/api/games", new { title = " ", system = "SNES" });
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, whitespace.StatusCode);
}
[Fact]
public async Task Update_changes_fields_and_moves_the_updated_timestamp()
{
var client = await factory.CreateUserClientAsync("update-user");
var created = await (await client.PostJsonAsync("/api/games", Game("Before")))
.Content.ReadJsonAsync<GamePayload>();
await Task.Delay(15); // the stamp has sub-second resolution, but not zero
var updated = await (await client.PutJsonAsync(
$"/api/games/{created!.Id}", Game("After", finished: true)))
.Content.ReadJsonAsync<GamePayload>();
Assert.Equal("After", updated!.Title);
Assert.True(updated.Finished);
Assert.Equal(created.CreatedAt, updated.CreatedAt);
Assert.True(updated.UpdatedAt > created.UpdatedAt);
}
[Fact]
public async Task Delete_removes_the_game()
{
var client = await factory.CreateUserClientAsync("delete-user");
var created = await (await client.PostJsonAsync("/api/games", Game("Doomed")))
.Content.ReadJsonAsync<GamePayload>();
var response = await client.DeleteAsync($"/api/games/{created!.Id}");
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/games/{created.Id}")).StatusCode);
}
[Fact]
public async Task Blank_optional_fields_round_trip_as_null()
{
var client = await factory.CreateUserClientAsync("null-user");
var created = await (await client.PostJsonAsync("/api/games", new
{
title = " Trimmed ",
system = (string?)null,
genre = (string?)null,
own = true,
})).Content.ReadJsonAsync<GamePayload>();
Assert.Equal("Trimmed", created!.Title);
Assert.Null(created.System);
Assert.Null(created.Genre);
}
// ---- uploads ---------------------------------------------------------
[Fact]
public async Task Uploading_a_real_image_stores_it_as_webp_and_serves_it()
{
var client = await factory.CreateUserClientAsync("upload-ok");
using var content = new MultipartFormDataContent();
var image = new ByteArrayContent(TestImages.Png(16, 16));
image.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
content.Add(image, "file", "cover.png");
var response = await client.PostAsync("/api/images", content);
response.EnsureSuccessStatusCode();
var upload = await response.Content.ReadJsonAsync<UploadPayload>();
Assert.EndsWith(".webp", upload!.FileName);
// The stored name is generated server-side, never taken from the upload.
Assert.DoesNotContain("cover", upload.FileName);
var served = await client.GetAsync(upload.Url);
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
Assert.Equal("image/webp", served.Content.Headers.ContentType?.MediaType);
}
[Fact]
public async Task Uploading_something_that_is_not_an_image_is_rejected()
{
var client = await factory.CreateUserClientAsync("upload-bad");
using var content = new MultipartFormDataContent();
var text = new ByteArrayContent("this is not an image"u8.ToArray());
// A truthful-looking content type and extension are not enough: the bytes
// have to decode.
text.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
content.Add(text, "file", "payload.png");
var response = await client.PostAsync("/api/images", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Uploading_nothing_is_rejected()
{
var client = await factory.CreateUserClientAsync("upload-empty");
using var content = new MultipartFormDataContent();
content.Add(new ByteArrayContent([]), "file", "empty.png");
var response = await client.PostAsync("/api/images", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task One_users_upload_is_not_reachable_under_another_users_folder()
{
var alice = await factory.CreateUserClientAsync("art-alice");
var bob = await factory.CreateUserClientAsync("art-bob");
using var content = new MultipartFormDataContent();
var image = new ByteArrayContent(TestImages.Png(8, 8));
image.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
content.Add(image, "file", "a.png");
var upload = await (await alice.PostAsync("/api/images", content))
.Content.ReadJsonAsync<UploadPayload>();
var bobUser = await bob.GetJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
var file = upload!.Url.Split('/').Last();
var probe = await bob.GetAsync($"/uploads/{bobUser!.Id}/{file}");
Assert.Equal(HttpStatusCode.NotFound, probe.StatusCode);
}
private record GamePayload(
int Id, string Title, string? System, string? Genre, bool Finished,
DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
private record PagePayload(List<GamePayload> Items, int Page, int PageSize, int Total, int TotalPages);
private record UploadPayload(string FileName, string Url);
}
@@ -0,0 +1,33 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LudosData.Api.Tests;
/// <summary>
/// JSON helpers configured exactly like the API's own serializer.
///
/// Without this the tests would speak a different dialect from the browser:
/// System.Text.Json writes enums as ordinals by default, so a test could pass
/// while the real client's <c>"condition": "Cib"</c> was rejected with a 400 —
/// which is precisely what happened before the API adopted string enums.
/// </summary>
internal static class HttpJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
};
public static Task<HttpResponseMessage> PostJsonAsync<T>(this HttpClient client, string url, T value)
=> client.PostAsJsonAsync(url, value, Options);
public static Task<HttpResponseMessage> PutJsonAsync<T>(this HttpClient client, string url, T value)
=> client.PutAsJsonAsync(url, value, Options);
public static Task<T?> GetJsonAsync<T>(this HttpClient client, string url)
=> client.GetFromJsonAsync<T>(url, Options);
public static Task<T?> ReadJsonAsync<T>(this HttpContent content)
=> content.ReadFromJsonAsync<T>(Options);
}
@@ -0,0 +1,321 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using LudosData.Api.Contracts;
namespace LudosData.Api.Tests;
public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static async Task SeedAsync(HttpClient client)
{
await client.PostJsonAsync("/api/games", new
{
title = "Chrono Trigger",
system = "SNES",
genre = "rpg",
year = "1995",
developer = "Square",
own = true,
played = true,
finished = true,
});
await client.PostJsonAsync("/api/games", new
{
title = "Ico",
system = "PS2",
genre = "adventure",
year = "2001",
own = true,
});
}
private static MultipartFormDataContent FileContent(string body, string name, string mediaType)
{
var content = new MultipartFormDataContent();
var part = new ByteArrayContent(Encoding.UTF8.GetBytes(body));
part.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mediaType);
content.Add(part, "file", name);
return content;
}
// ---- export ----------------------------------------------------------
[Fact]
public async Task Json_export_contains_the_library()
{
var client = await factory.CreateUserClientAsync("exp-json");
await SeedAsync(client);
var response = await client.GetAsync("/api/library/export?format=json");
response.EnsureSuccessStatusCode();
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
Assert.Contains("attachment", response.Content.Headers.ContentDisposition?.DispositionType
?? response.Content.Headers.ContentDisposition?.ToString() ?? "attachment");
var payload = await response.Content.ReadJsonAsync<LibraryExport>();
Assert.Equal(2, payload!.Count);
Assert.Contains(payload.Games, g => g.Title == "Chrono Trigger" && g.Developer == "Square");
}
[Fact]
public async Task Csv_export_has_a_header_and_one_row_per_game()
{
var client = await factory.CreateUserClientAsync("exp-csv");
await SeedAsync(client);
var csv = await client.GetStringAsync("/api/library/export?format=csv");
var rows = LudosData.Api.Services.Csv.Parse(csv.TrimStart(''));
Assert.Equal("title", rows[0][0]);
Assert.Equal(3, rows.Count); // header + 2 games
Assert.Contains(rows.Skip(1), r => r[0] == "Chrono Trigger");
}
[Fact]
public async Task Export_only_covers_the_signed_in_users_games()
{
var alice = await factory.CreateUserClientAsync("exp-alice");
var bob = await factory.CreateUserClientAsync("exp-bob");
await SeedAsync(alice);
await bob.PostJsonAsync("/api/games", new { title = "Bob Only", system = "N64", own = true });
var payload = await bob.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
Assert.Equal(1, payload!.Count);
Assert.Equal("Bob Only", payload.Games[0].Title);
}
[Fact]
public async Task Export_rejects_an_unknown_format()
{
var client = await factory.CreateUserClientAsync("exp-bad");
var response = await client.GetAsync("/api/library/export?format=xml");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Export_and_import_round_trip_without_loss()
{
var source = await factory.CreateUserClientAsync("round-source");
await SeedAsync(source);
var exported = await source.GetStringAsync("/api/library/export?format=json");
var target = await factory.CreateUserClientAsync("round-target");
var response = await target.PostAsync("/api/library/import",
FileContent(exported, "library.json", "application/json"));
response.EnsureSuccessStatusCode();
var before = await source.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
var after = await target.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
Assert.Equal(before!.Count, after!.Count);
Assert.Equal(
before.Games.OrderBy(g => g.Title).Select(g => (g.Title, g.System, g.Developer, g.Finished)),
after.Games.OrderBy(g => g.Title).Select(g => (g.Title, g.System, g.Developer, g.Finished)));
}
// ---- import ----------------------------------------------------------
[Fact]
public async Task Import_creates_missing_games_and_updates_matching_ones()
{
var client = await factory.CreateUserClientAsync("imp-merge");
await SeedAsync(client);
const string csv = """
title,system,genre,year,own,finished
Chrono Trigger,SNES,rpg,1995,true,false
Super Metroid,SNES,platformer,1994,true,true
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created); // Super Metroid
Assert.Equal(1, result.Updated); // Chrono Trigger matched on title + system
Assert.Equal(0, result.Deleted);
// The update took effect: it was finished before, and the file says otherwise.
var page = await client.GetJsonAsync<PagePayload>("/api/games?search=Chrono");
Assert.False(page!.Items[0].Finished);
}
[Fact]
public async Task The_same_title_on_a_different_system_is_a_different_game()
{
var client = await factory.CreateUserClientAsync("imp-platform");
await client.PostJsonAsync("/api/games", new
{
title = "Donkey Kong Country", system = "SNES", own = true,
});
const string csv = """
title,system,own
Donkey Kong Country,SNES,true
Donkey Kong Country,GB,true
Donkey Kong Country,GBA,true
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(2, result!.Created); // GB and GBA
Assert.Equal(1, result.Updated); // the existing SNES row
}
[Fact]
public async Task Dry_run_reports_what_would_happen_and_changes_nothing()
{
var client = await factory.CreateUserClientAsync("imp-dry");
await SeedAsync(client);
const string csv = """
title,system,own
Brand New Game,N64,true
""";
var result = await (await client.PostAsync("/api/library/import?dryRun=true",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.True(result!.DryRun);
Assert.Equal(1, result.Created);
var page = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(2, page!.Total); // still just the seeded pair
}
[Fact]
public async Task Replace_mode_clears_the_library_first()
{
var client = await factory.CreateUserClientAsync("imp-replace");
await SeedAsync(client);
const string csv = """
title,system,own
Only Survivor,GC,true
""";
var result = await (await client.PostAsync("/api/library/import?mode=Replace",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(2, result!.Deleted);
Assert.Equal(1, result.Created);
var page = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(1, page!.Total);
Assert.Equal("Only Survivor", page.Items[0].Title);
}
[Fact]
public async Task Import_never_touches_another_users_library()
{
var alice = await factory.CreateUserClientAsync("imp-alice");
var bob = await factory.CreateUserClientAsync("imp-bob");
await SeedAsync(alice);
await SeedAsync(bob);
// Replace is the most destructive mode; it must stop at the caller.
await bob.PostAsync("/api/library/import?mode=Replace",
FileContent("title,system,own\nBob Only,GC,true", "in.csv", "text/csv"));
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(2, alicePage!.Total);
}
[Fact]
public async Task Rows_without_a_title_are_reported_rather_than_imported()
{
var client = await factory.CreateUserClientAsync("imp-invalid");
const string csv = """
title,system,own
,SNES,true
Valid Game,SNES,true
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created);
Assert.Single(result.Errors);
Assert.Equal(2, result.Errors[0].Row); // header is row 1
}
[Fact]
public async Task Csv_survives_commas_quotes_and_newlines_in_a_description()
{
var client = await factory.CreateUserClientAsync("imp-quoting");
var awkward = "A description with, a comma, \"quotes\" and\na newline.";
await client.PostJsonAsync("/api/games", new
{
title = "Awkward, Game \"Title\"",
system = "PS1",
description = awkward,
own = true,
});
// Round-trip through CSV rather than asserting on the encoding itself.
var csv = await client.GetStringAsync("/api/library/export?format=csv");
var target = await factory.CreateUserClientAsync("imp-quoting-target");
await target.PostAsync("/api/library/import", FileContent(csv, "in.csv", "text/csv"));
var payload = await target.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
var game = Assert.Single(payload!.Games);
Assert.Equal("Awkward, Game \"Title\"", game.Title);
Assert.Equal(awkward, game.Description);
}
[Fact]
public async Task A_bare_json_array_is_accepted_as_well_as_the_envelope()
{
var client = await factory.CreateUserClientAsync("imp-bare");
const string json = """
[ { "title": "Bare Array Game", "system": "N64", "own": true } ]
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(json, "in.json", "application/json"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created);
}
[Fact]
public async Task Malformed_files_are_rejected_with_a_reason()
{
var client = await factory.CreateUserClientAsync("imp-malformed");
var badJson = await client.PostAsync("/api/library/import",
FileContent("{ not valid json", "in.json", "application/json"));
var headerless = await client.PostAsync("/api/library/import",
FileContent("name,platform\nFoo,SNES", "in.csv", "text/csv"));
var empty = await client.PostAsync("/api/library/import",
FileContent("", "in.csv", "text/csv"));
Assert.Equal(HttpStatusCode.BadRequest, badJson.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, headerless.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
}
[Fact]
public async Task Export_and_import_require_a_token()
{
var anonymous = factory.CreateClient();
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.GetAsync("/api/library/export")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PostAsync("/api/library/import",
FileContent("title\nX", "in.csv", "text/csv"))).StatusCode);
}
private record GamePayload(int Id, string Title, bool Finished);
private record PagePayload(List<GamePayload> Items, int Total);
}
@@ -0,0 +1,79 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Hosting;
namespace LudosData.Api.Tests;
/// <summary>
/// Boots the real application against a throwaway SQLite file and upload folder.
///
/// Every collaborator the API uses in production is exercised here — the same
/// pipeline, the same Identity configuration, the same JWT validation. Only the
/// storage locations and the signing key are swapped, so a test that passes says
/// something about the shipped application rather than about a stand-in.
/// </summary>
public class LudosApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly string _root = Path.Combine(
Path.GetTempPath(), "ludos-tests", Guid.NewGuid().ToString("N"));
public string SigningKey { get; } = "test-signing-key-of-at-least-32-characters-long";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
Directory.CreateDirectory(_root);
builder.UseEnvironment(Environments.Development);
builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(_root, "test.db")}");
builder.UseSetting("Uploads:RootPath", Path.Combine(_root, "uploads"));
builder.UseSetting("DataProtection:KeysPath", Path.Combine(_root, "keys"));
builder.UseSetting("Jwt:Key", SigningKey);
builder.UseSetting("Jwt:Issuer", "LudosData");
builder.UseSetting("Jwt:Audience", "LudosData");
// The seeder would otherwise create a user and import 105 games, which
// would make "does this user see only their own rows" untestable.
builder.UseSetting("Seed:Enabled", "false");
}
/// <summary>Registers a fresh user and returns a client authenticated as them.</summary>
public async Task<HttpClient> CreateUserClientAsync(string userName)
{
var client = CreateClient();
var response = await client.PostJsonAsync("/api/auth/register", new
{
userName,
email = $"{userName}@example.test",
password = "TestPassword123",
});
response.EnsureSuccessStatusCode();
var auth = await response.Content.ReadJsonAsync<AuthPayload>();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", auth!.Token);
return client;
}
public Task InitializeAsync() => Task.CompletedTask;
public new async Task DisposeAsync()
{
await base.DisposeAsync();
try
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
catch (IOException)
{
// A locked SQLite handle on a temp file is not worth failing a run over.
}
}
public record AuthPayload(string Token, DateTimeOffset ExpiresAt, UserPayload User);
public record UserPayload(string Id, string UserName, string? Email);
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\LudosData.Api\LudosData.Api.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,160 @@
using System.Net;
using System.Net.Http.Json;
namespace LudosData.Api.Tests;
/// <summary>
/// The rules that matter most.
///
/// The API this replaced took the owner from a client-supplied query parameter
/// (<c>filter[]=userId,eq,N</c>), so any valid token could read any other user's
/// library by editing a number. These tests pin the replacement: ownership comes
/// from the JWT subject, and a row belonging to someone else is indistinguishable
/// from one that does not exist.
/// </summary>
public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static object Game(string title) => new
{
title,
system = "SNES",
genre = "rpg",
year = "1995",
own = true,
dumped = false,
played = false,
finished = false,
};
private static async Task<int> CreateGameAsync(HttpClient client, string title)
{
var response = await client.PostJsonAsync("/api/games", Game(title));
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadJsonAsync<GamePayload>();
return created!.Id;
}
[Fact]
public async Task A_user_sees_only_their_own_games()
{
var alice = await factory.CreateUserClientAsync("own-alice");
var bob = await factory.CreateUserClientAsync("own-bob");
await CreateGameAsync(alice, "Alice's Game");
await CreateGameAsync(bob, "Bob's Game");
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetJsonAsync<PagePayload>("/api/games");
Assert.Single(alicePage!.Items);
Assert.Equal("Alice's Game", alicePage.Items[0].Title);
Assert.Single(bobPage!.Items);
Assert.Equal("Bob's Game", bobPage.Items[0].Title);
}
[Fact]
public async Task Reading_another_users_game_returns_404_not_403()
{
var alice = await factory.CreateUserClientAsync("read-alice");
var bob = await factory.CreateUserClientAsync("read-bob");
var aliceGame = await CreateGameAsync(alice, "Private");
var response = await bob.GetAsync($"/api/games/{aliceGame}");
// 404, not 403: a 403 would confirm the id exists.
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Updating_another_users_game_is_refused_and_changes_nothing()
{
var alice = await factory.CreateUserClientAsync("upd-alice");
var bob = await factory.CreateUserClientAsync("upd-bob");
var aliceGame = await CreateGameAsync(alice, "Untouched");
var response = await bob.PutJsonAsync($"/api/games/{aliceGame}", Game("Hijacked"));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var after = await alice.GetJsonAsync<GamePayload>($"/api/games/{aliceGame}");
Assert.Equal("Untouched", after!.Title);
}
[Fact]
public async Task Deleting_another_users_game_is_refused_and_the_row_survives()
{
var alice = await factory.CreateUserClientAsync("del-alice");
var bob = await factory.CreateUserClientAsync("del-bob");
var aliceGame = await CreateGameAsync(alice, "Survivor");
var response = await bob.DeleteAsync($"/api/games/{aliceGame}");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var after = await alice.GetAsync($"/api/games/{aliceGame}");
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
}
[Fact]
public async Task An_ownerId_in_the_request_body_cannot_reassign_a_game()
{
var alice = await factory.CreateUserClientAsync("spoof-alice");
var bob = await factory.CreateUserClientAsync("spoof-bob");
var bobUser = await bob.GetJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
// Alice creates a game while claiming it belongs to Bob. The contract has
// no ownerId, so this should be ignored rather than honoured.
var response = await alice.PostJsonAsync("/api/games", new
{
title = "Attempted Handover",
system = "SNES",
own = true,
ownerId = bobUser!.Id,
userId = bobUser.Id,
});
response.EnsureSuccessStatusCode();
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetJsonAsync<PagePayload>("/api/games");
Assert.Single(alicePage!.Items);
Assert.Empty(bobPage!.Items);
}
[Fact]
public async Task Facets_are_scoped_to_the_signed_in_user()
{
var alice = await factory.CreateUserClientAsync("facet-alice");
var bob = await factory.CreateUserClientAsync("facet-bob");
await alice.PostJsonAsync("/api/games", new { title = "A", system = "N64", genre = "fps", own = true });
await bob.PostJsonAsync("/api/games", new { title = "B", system = "PS2", genre = "rpg", own = true });
var facets = await alice.GetJsonAsync<FacetsPayload>("/api/games/facets");
Assert.Equal(["N64"], facets!.Systems);
Assert.Equal(["fps"], facets.Genres);
}
[Fact]
public async Task Every_games_route_requires_a_token()
{
var anonymous = factory.CreateClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/games")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/games/1")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/games/facets")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PostJsonAsync("/api/games", Game("x"))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PutJsonAsync("/api/games/1", Game("x"))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.DeleteAsync("/api/games/1")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.PostAsync("/api/images", null)).StatusCode);
}
private record GamePayload(int Id, string Title, string? System, string? Art, string? ArtUrl);
private record PagePayload(List<GamePayload> Items, int Page, int PageSize, int Total, int TotalPages);
private record FacetsPayload(List<string> Systems, List<string> Genres);
}
@@ -0,0 +1,426 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using LudosData.Api.Services.Pricing;
namespace LudosData.Api.Tests;
public class PriceGuideParsingTests
{
[Fact]
public void A_plain_guide_is_read()
{
const string csv = """
title,console,loose,cib,new
Chrono Trigger,Super Nintendo,128.00,650.00,12000.00
Super Metroid,Super Nintendo,55.00,210.00,4000.00
""";
var result = PriceGuide.Parse(csv);
Assert.Equal(2, result.Rows.Count);
Assert.Empty(result.Problems);
var first = result.Rows[0];
Assert.Equal("Chrono Trigger", first.Title);
Assert.Equal("Super Nintendo", first.System);
Assert.Equal(128.00m, first.Loose);
Assert.Equal(650.00m, first.Cib);
Assert.Equal(12000.00m, first.New);
}
[Theory]
// Different exports name the same columns differently; all of these mean
// the same thing, and demanding one schema would make the feature useless
// for whichever guide the user actually has.
[InlineData("product-name,console-name,loose-price,cib-price,new-price")]
[InlineData("Product Name,Console Name,Loose Price,CIB Price,New Price")]
[InlineData("game,platform,loose,complete,sealed")]
[InlineData("NAME,SYSTEM,LOOSE,CIB,NEW")]
public void Column_aliases_and_casing_are_accepted(string header)
{
var result = PriceGuide.Parse($"{header}\nChrono Trigger,SNES,128.00,650.00,12000.00");
var row = Assert.Single(result.Rows);
Assert.Equal("Chrono Trigger", row.Title);
Assert.Equal("SNES", row.System);
Assert.Equal(128.00m, row.Loose);
}
[Theory]
[InlineData("$128.00", 128.00)]
[InlineData("1,234.56", 1234.56)]
[InlineData(" $1,234.56 ", 1234.56)]
[InlineData("128", 128)]
public void Spreadsheet_formatting_is_tolerated(string input, double expected)
{
Assert.Equal((decimal)expected, PriceGuide.ParseMoney(input));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("0")]
[InlineData("0.00")]
[InlineData("n/a")]
public void Blank_and_zero_prices_read_as_no_price(string input)
{
// Zero means "not on record", and treating it as free would drag a
// collection total toward nothing.
Assert.Null(PriceGuide.ParseMoney(input));
}
[Fact]
public void A_guide_with_no_title_column_is_rejected_with_a_reason()
{
var result = PriceGuide.Parse("foo,bar\n1,2");
Assert.Empty(result.Rows);
Assert.Contains("title", Assert.Single(result.Problems), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void A_guide_with_no_price_column_is_rejected_with_a_reason()
{
var result = PriceGuide.Parse("title,console\nChrono Trigger,SNES");
Assert.Empty(result.Rows);
Assert.Contains("price", Assert.Single(result.Problems), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void A_partial_guide_is_usable()
{
// Plenty of lists quote one condition only.
var result = PriceGuide.Parse("title,system,loose\nChrono Trigger,SNES,128.00");
var row = Assert.Single(result.Rows);
Assert.Equal(128.00m, row.Loose);
Assert.Null(row.Cib);
Assert.Null(row.New);
}
[Fact]
public void Quoted_titles_containing_commas_survive()
{
var result = PriceGuide.Parse("title,system,loose\n\"Spyro 2: Ripto's Rage!, The Best\",PS1,40.00");
Assert.Equal("Spyro 2: Ripto's Rage!, The Best", Assert.Single(result.Rows).Title);
}
}
public class PriceChartingParsingTests
{
[Fact]
public void Pennies_are_converted_to_currency()
{
using var document = JsonDocument.Parse("""
{
"status": "success",
"product-name": "Chrono Trigger",
"console-name": "Super Nintendo",
"loose-price": 12800,
"cib-price": 65000,
"new-price": 1200000
}
""");
var estimate = PriceChartingProvider.Parse(document);
Assert.Equal(128.00m, estimate.Loose);
Assert.Equal(650.00m, estimate.Cib);
Assert.Equal(12000.00m, estimate.New);
}
[Fact]
public void A_zero_price_is_treated_as_absent()
{
using var document = JsonDocument.Parse("""
{ "status": "success", "loose-price": 12800, "cib-price": 0, "new-price": 0 }
""");
var estimate = PriceChartingProvider.Parse(document);
Assert.Equal(128.00m, estimate.Loose);
// Zero means no price on record, not a free game.
Assert.Null(estimate.Cib);
Assert.Null(estimate.New);
}
[Fact]
public void An_error_response_yields_no_prices()
{
using var document = JsonDocument.Parse("""{ "status": "error", "error-message": "not found" }""");
Assert.False(PriceChartingProvider.Parse(document).HasAnyPrice);
}
[Fact]
public void Unexpected_field_names_degrade_to_no_price_rather_than_throwing()
{
// Their docs are not reachable without an account, so a naming
// difference has to be survivable.
using var document = JsonDocument.Parse("""{ "status": "success", "somethingElse": 1 }""");
Assert.False(PriceChartingProvider.Parse(document).HasAnyPrice);
}
[Theory]
[InlineData("SNES", "super nintendo Chrono Trigger")]
[InlineData("N64", "nintendo 64 GoldenEye")]
[InlineData("360", "xbox 360 Halo 3")]
public void The_console_name_leads_the_query(string system, string expected)
{
var title = expected.Split(' ').Last() == "Trigger" ? "Chrono Trigger"
: expected.Contains("GoldenEye") ? "GoldenEye" : "Halo 3";
Assert.Equal(expected, PriceChartingProvider.BuildQuery(title, system));
}
}
public class PriceEndpointTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static MultipartFormDataContent CsvContent(string body)
{
var content = new MultipartFormDataContent();
var part = new ByteArrayContent(Encoding.UTF8.GetBytes(body));
part.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/csv");
content.Add(part, "file", "guide.csv");
return content;
}
private static async Task SeedAsync(HttpClient client)
{
await client.PostJsonAsync("/api/games", new
{
title = "Chrono Trigger", system = "SNES", own = true, condition = "Cib",
});
await client.PostJsonAsync("/api/games", new
{
title = "Super Metroid", system = "SNES", own = true, condition = "Loose",
});
}
[Fact]
public async Task Status_lists_every_provider_and_how_to_enable_it()
{
var client = await factory.CreateUserClientAsync("price-status");
var statuses = await client.GetJsonAsync<List<ProviderStatusPayload>>("/api/prices/status");
Assert.Contains(statuses!, s => s.Name == "ebay-asking");
Assert.Contains(statuses!, s => s.Name == "pricecharting");
// None are configured in tests, so each explains what is missing.
Assert.All(statuses!, s =>
{
Assert.False(s.Configured);
Assert.False(string.IsNullOrWhiteSpace(s.Setup));
});
}
[Fact]
public async Task Refresh_without_a_configured_provider_points_at_the_csv_route()
{
var client = await factory.CreateUserClientAsync("price-none");
var response = await client.PostJsonAsync("/api/prices/refresh", new { limit = 1 });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var problem = await response.Content.ReadAsStringAsync();
Assert.Contains("/api/prices/import", problem);
}
[Fact]
public async Task A_price_guide_sets_tiers_and_the_effective_value_follows_condition()
{
var client = await factory.CreateUserClientAsync("price-import");
await SeedAsync(client);
const string csv = """
title,system,loose,cib,new
Chrono Trigger,SNES,128.00,650.00,12000.00
Super Metroid,SNES,55.00,210.00,4000.00
""";
var result = await (await client.PostAsync("/api/prices/import?source=my-guide", CsvContent(csv)))
.Content.ReadJsonAsync<PriceImportPayload>();
Assert.Equal(2, result!.Matched);
Assert.Equal(2, result.Updated);
Assert.Equal(0, result.Unmatched);
var page = await client.GetJsonAsync<PagePayload>("/api/games?sort=title");
var chrono = page!.Items.Single(g => g.Title == "Chrono Trigger");
var metroid = page.Items.Single(g => g.Title == "Super Metroid");
Assert.Equal(650.00m, chrono.MarketValue); // CIB copy takes the CIB tier
Assert.Equal(55.00m, metroid.MarketValue); // loose copy takes the loose tier
Assert.Equal("my-guide", chrono.MarketValueSource);
Assert.NotNull(chrono.MarketValueUpdatedAt);
}
[Fact]
public async Task Rows_for_games_not_in_the_library_are_reported_not_created()
{
var client = await factory.CreateUserClientAsync("price-unmatched");
await SeedAsync(client);
const string csv = """
title,system,loose
Chrono Trigger,SNES,128.00
EarthBound,SNES,300.00
""";
var result = await (await client.PostAsync("/api/prices/import", CsvContent(csv)))
.Content.ReadJsonAsync<PriceImportPayload>();
Assert.Equal(1, result!.Matched);
Assert.Equal(1, result.Unmatched);
Assert.Contains("EarthBound", result.UnmatchedTitles[0]);
// A price guide prices what you own; it does not add to the collection.
var page = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(2, page!.Total);
}
[Fact]
public async Task The_same_title_on_two_systems_is_priced_separately()
{
var client = await factory.CreateUserClientAsync("price-platform");
await client.PostJsonAsync("/api/games", new { title = "Donkey Kong Country", system = "SNES", own = true });
await client.PostJsonAsync("/api/games", new { title = "Donkey Kong Country", system = "GBA", own = true });
const string csv = """
title,system,loose
Donkey Kong Country,SNES,25.00
Donkey Kong Country,GBA,18.00
""";
await client.PostAsync("/api/prices/import", CsvContent(csv));
var page = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(25.00m, page!.Items.Single(g => g.System == "SNES").MarketValue);
Assert.Equal(18.00m, page.Items.Single(g => g.System == "GBA").MarketValue);
}
[Fact]
public async Task Dry_run_reports_without_writing()
{
var client = await factory.CreateUserClientAsync("price-dry");
await SeedAsync(client);
var result = await (await client.PostAsync(
"/api/prices/import?dryRun=true",
CsvContent("title,system,loose\nChrono Trigger,SNES,128.00")))
.Content.ReadJsonAsync<PriceImportPayload>();
Assert.True(result!.DryRun);
Assert.Equal(1, result.Updated);
var page = await client.GetJsonAsync<PagePayload>("/api/games?search=Chrono");
Assert.Null(page!.Items[0].MarketValue);
}
[Fact]
public async Task A_guide_never_reaches_another_users_library()
{
var alice = await factory.CreateUserClientAsync("price-alice");
var bob = await factory.CreateUserClientAsync("price-bob");
await SeedAsync(alice);
await SeedAsync(bob);
await bob.PostAsync("/api/prices/import", CsvContent("title,system,loose\nChrono Trigger,SNES,999.00"));
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games?search=Chrono");
Assert.Null(alicePage!.Items[0].MarketValue);
}
[Fact]
public async Task An_unreadable_guide_is_rejected_with_a_reason()
{
var client = await factory.CreateUserClientAsync("price-bad");
var response = await client.PostAsync("/api/prices/import", CsvContent("foo,bar\n1,2"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("title", await response.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Pricing_endpoints_require_a_token()
{
var anonymous = factory.CreateClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/prices/status")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PostAsync("/api/prices/import", CsvContent("title,loose\nX,1"))).StatusCode);
}
private record ProviderStatusPayload(string Name, bool Configured, string Basis, string? Setup);
private record PriceImportPayload(
bool DryRun, int Rows, int Matched, int Updated, int Unmatched,
List<string> UnmatchedTitles, List<string> Problems);
private record GamePayload(
int Id, string Title, string? System, decimal? MarketValue,
DateTimeOffset? MarketValueUpdatedAt, string? MarketValueSource,
decimal? ValueLoose, decimal? ValueCib, decimal? ValueNew);
private record PagePayload(List<GamePayload> Items, int Total);
}
public class PriceChartingMatchTests
{
[Fact]
public void The_matched_product_and_console_are_reported()
{
using var document = JsonDocument.Parse("""
{
"status": "success",
"id": "6910",
"product-name": "Chrono Trigger",
"console-name": "Super Nintendo",
"loose-price": 12800, "cib-price": 65000, "new-price": 1200000
}
""");
var estimate = PriceChartingProvider.Parse(document);
// Without this a dry run cannot tell a DS entry that resolved to the
// SNES original from one that resolved correctly.
Assert.Equal("Chrono Trigger", estimate.MatchedName);
Assert.Equal("Super Nintendo", estimate.MatchedConsole);
Assert.Equal("6910", estimate.SourceId);
}
[Fact]
public void A_numeric_id_is_read_as_a_string()
{
using var document = JsonDocument.Parse("""
{ "status": "success", "id": 6910, "loose-price": 100 }
""");
Assert.Equal("6910", PriceChartingProvider.Parse(document).SourceId);
}
[Fact]
public void Missing_match_metadata_is_not_fatal()
{
using var document = JsonDocument.Parse("""{ "loose-price": 12800 }""");
var estimate = PriceChartingProvider.Parse(document);
Assert.Equal(128.00m, estimate.Loose);
Assert.Null(estimate.MatchedName);
Assert.Null(estimate.SourceId);
}
[Fact]
public void A_stored_id_is_preferred_over_a_title_search()
{
// Documents the intent of the lookup switch: with an id, the query is an
// exact product fetch, so a drifting title search cannot re-price a
// different edition on a later run.
Assert.Equal("super nintendo Chrono Trigger",
PriceChartingProvider.BuildQuery("Chrono Trigger", "SNES"));
}
}
@@ -0,0 +1,238 @@
using System.Text.Json;
using LudosData.Api.Domain;
using LudosData.Api.Services.Pricing;
namespace LudosData.Api.Tests;
/// <summary>
/// The pricing logic, tested without touching eBay.
///
/// Everything that decides what a number means — which listings count, which
/// tier they land in, and how they aggregate — is pure and lives here. Only the
/// HTTP call itself needs credentials, and it is the least interesting part.
/// </summary>
public class ListingClassificationTests
{
[Theory]
[InlineData("Chrono Trigger SNES Cartridge Only", GameCondition.Loose)]
[InlineData("Super Metroid - loose cart, tested", GameCondition.Loose)]
[InlineData("Banjo-Kazooie N64 game only", GameCondition.Loose)]
[InlineData("Earthbound SNES CIB", GameCondition.Cib)]
[InlineData("Ocarina of Time complete in box", GameCondition.Cib)]
[InlineData("Mario Kart 64 with box and manual", GameCondition.Cib)]
[InlineData("Boxed Pokemon Yellow Game Boy", GameCondition.Cib)]
[InlineData("Metroid Prime FACTORY SEALED", GameCondition.Sealed)]
[InlineData("Halo 3 Xbox 360 Brand New Sealed", GameCondition.Sealed)]
[InlineData("Final Fantasy VII WATA 9.4 graded", GameCondition.Sealed)]
public void Titles_are_sorted_into_the_right_tier(string title, GameCondition expected)
{
Assert.Equal(expected, ListingCondition.Classify(title, "Used"));
}
[Theory]
// These are the dangerous ones: cheap, plentiful, and not the game.
[InlineData("Chrono Trigger SNES BOX ONLY no game")]
[InlineData("Super Mario World manual only")]
[InlineData("Zelda Ocarina of Time REPRODUCTION cartridge")]
[InlineData("N64 game case replacement")]
[InlineData("Custom art label for Earthbound")]
[InlineData("Lot of 12 SNES games")]
[InlineData("Nintendo 64 bundle 5 games")]
public void Accessories_reproductions_and_lots_are_discarded(string title)
{
// Counting a $6 "box only" listing as a copy of the game would drag a
// loose median to nonsense.
Assert.Null(ListingCondition.Classify(title, "Used"));
}
[Fact]
public void An_unqualified_listing_falls_back_to_the_sellers_flag()
{
Assert.Equal(GameCondition.Sealed, ListingCondition.Classify("Chrono Trigger", "New"));
// Unqualified and used reads as loose, the conservative assumption.
Assert.Equal(GameCondition.Loose, ListingCondition.Classify("Chrono Trigger", "Used"));
}
}
public class PriceMathTests
{
[Fact]
public void Median_of_an_odd_sample_is_the_middle_value()
{
// Sorted first: [10, 20, 30].
Assert.Equal(20m, PriceMath.Median([10m, 30m, 20m]));
}
[Fact]
public void Median_of_an_even_sample_averages_the_middle_pair()
{
Assert.Equal(25m, PriceMath.Median([10m, 20m, 30m, 40m]));
}
[Fact]
public void Median_of_nothing_is_null_rather_than_zero()
{
// A game with no listings is unpriced, which is not the same as free.
Assert.Null(PriceMath.Median([]));
}
[Fact]
public void A_wildly_optimistic_listing_does_not_move_the_estimate()
{
var withOutlier = new List<decimal> { 40m, 42m, 45m, 44m, 43m, 41m, 5000m };
var kept = PriceMath.RemoveOutliers(withOutlier);
Assert.DoesNotContain(5000m, kept);
// A mean would have been dragged past 750; the median holds.
Assert.InRange(PriceMath.Median(kept)!.Value, 40m, 45m);
}
[Fact]
public void Small_samples_are_left_alone()
{
// With three points, quartiles are meaningless and trimming would throw
// away most of the evidence.
var values = new List<decimal> { 10m, 20m, 900m };
Assert.Equal(3, PriceMath.RemoveOutliers(values).Count);
}
[Fact]
public void Summarise_reports_a_price_and_a_sample_count_per_tier()
{
var listings = new List<PricedListing>
{
new(20m, GameCondition.Loose),
new(24m, GameCondition.Loose),
new(22m, GameCondition.Loose),
new(80m, GameCondition.Cib),
new(90m, GameCondition.Cib),
new(400m, GameCondition.Sealed),
};
var estimate = PriceMath.Summarise(listings, discarded: 4);
Assert.Equal(22m, estimate.Loose);
Assert.Equal(85m, estimate.Cib);
Assert.Equal(400m, estimate.New);
Assert.Equal(3, estimate.LooseSamples);
Assert.Equal(2, estimate.CibSamples);
Assert.Equal(1, estimate.NewSamples);
Assert.Equal(4, estimate.Discarded);
Assert.True(estimate.HasAnyPrice);
}
[Fact]
public void A_tier_with_no_listings_stays_null()
{
var estimate = PriceMath.Summarise([new PricedListing(20m, GameCondition.Loose)], 0);
Assert.Equal(20m, estimate.Loose);
Assert.Null(estimate.Cib);
Assert.Null(estimate.New);
}
}
public class EbayResponseParsingTests
{
/// <summary>Shaped like a real Browse item_summary/search response.</summary>
private const string SampleResponse = """
{
"total": 8,
"itemSummaries": [
{ "title": "Chrono Trigger SNES Cartridge Only Authentic",
"condition": "Used", "price": { "value": "120.00", "currency": "USD" } },
{ "title": "Chrono Trigger Super Nintendo loose cart tested",
"condition": "Used", "price": { "value": "135.50", "currency": "USD" } },
{ "title": "Chrono Trigger SNES game only",
"condition": "Used", "price": { "value": "128.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES CIB complete in box",
"condition": "Used", "price": { "value": "650.00", "currency": "USD" } },
{ "title": "Chrono Trigger Super Nintendo with box and manual",
"condition": "Used", "price": { "value": "700.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES FACTORY SEALED WATA",
"condition": "New", "price": { "value": "12000.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES BOX ONLY no game",
"condition": "Used", "price": { "value": "45.00", "currency": "USD" } },
{ "title": "Lot of 6 SNES RPG games including Chrono Trigger",
"condition": "Used", "price": { "value": "300.00", "currency": "USD" } }
]
}
""";
[Fact]
public void A_search_response_is_split_into_tiers()
{
using var document = JsonDocument.Parse(SampleResponse);
var estimate = EbayPriceProvider.Parse(document);
Assert.Equal(128.00m, estimate.Loose); // median of 120, 128, 135.50
Assert.Equal(675.00m, estimate.Cib); // mean of the middle pair
Assert.Equal(12000.00m, estimate.New);
// The box-only listing and the multi-game lot are both thrown out. Left
// in, the $45 box would have halved the loose estimate.
Assert.Equal(2, estimate.Discarded);
Assert.Equal(3, estimate.LooseSamples);
}
[Fact]
public void A_response_with_no_results_yields_no_prices()
{
using var document = JsonDocument.Parse("""{ "total": 0, "itemSummaries": [] }""");
var estimate = EbayPriceProvider.Parse(document);
Assert.False(estimate.HasAnyPrice);
}
[Fact]
public void A_response_missing_the_results_array_does_not_throw()
{
using var document = JsonDocument.Parse("""{ "total": 0, "warnings": [] }""");
Assert.False(EbayPriceProvider.Parse(document).HasAnyPrice);
}
[Fact]
public void Listings_without_a_usable_price_are_discarded()
{
using var document = JsonDocument.Parse("""
{
"itemSummaries": [
{ "title": "Chrono Trigger SNES loose", "condition": "Used" },
{ "title": "Chrono Trigger SNES loose", "condition": "Used",
"price": { "value": "0.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES loose", "condition": "Used",
"price": { "value": "130.00", "currency": "USD" } }
]
}
""");
var estimate = EbayPriceProvider.Parse(document);
Assert.Equal(130.00m, estimate.Loose);
Assert.Equal(1, estimate.LooseSamples);
Assert.Equal(2, estimate.Discarded);
}
[Theory]
[InlineData("SNES", "Super Nintendo SNES")]
[InlineData("N64", "Nintendo 64")]
[InlineData("360", "Xbox 360")]
[InlineData("PS1", "PlayStation 1 PS1")]
public void The_console_name_is_added_to_the_search(string system, string expected)
{
// Without it, "Chrono Trigger" returns SNES, PS1 and DS copies together
// and the median lands between three different markets.
Assert.Equal($"Chrono Trigger {expected}",
EbayPriceProvider.BuildQuery("Chrono Trigger", system));
}
[Fact]
public void A_game_with_no_system_searches_on_title_alone()
{
Assert.Equal("Chrono Trigger", EbayPriceProvider.BuildQuery("Chrono Trigger", null));
}
}
@@ -0,0 +1,164 @@
using System.Net;
using LudosData.Api.Contracts;
namespace LudosData.Api.Tests;
public class StatsTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static object Game(
string title, string system = "SNES", string genre = "rpg", string? year = "1995",
bool own = true, bool dumped = false, bool played = false, bool finished = false,
int? rating = null, decimal? marketValue = null, decimal? purchasePrice = null,
string condition = "Unspecified") => new
{
title, system, genre, year, own, dumped, played, finished,
rating, marketValue, purchasePrice, condition,
};
private static async Task SeedAsync(HttpClient client)
{
// 4 owned, 3 played, 1 finished — so every funnel stage differs.
await client.PostJsonAsync("/api/games", Game("Alpha", "SNES", "rpg", "1995",
played: true, finished: true, rating: 9, marketValue: 100m, purchasePrice: 40m));
await client.PostJsonAsync("/api/games", Game("Beta", "SNES", "rpg", "1996",
played: true, rating: 7, marketValue: 50m));
await client.PostJsonAsync("/api/games", Game("Gamma", "N64", "fps", "2001",
played: true, dumped: true));
await client.PostJsonAsync("/api/games", Game("Delta", "N64", "racing", "2011"));
}
[Fact]
public async Task The_funnel_reports_each_stage_as_a_subset_of_the_last()
{
var client = await factory.CreateUserClientAsync("stats-funnel");
await SeedAsync(client);
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
Assert.Equal(4, stats!.TotalGames);
Assert.Equal(4, stats.Funnel.Owned);
Assert.Equal(3, stats.Funnel.Played);
Assert.Equal(1, stats.Funnel.Finished);
// The two numbers a backlog view exists to surface.
Assert.Equal(1, stats.Backlog); // owned, never played
Assert.Equal(2, stats.InProgress); // played, not finished
Assert.Equal(1, stats.Dumped);
}
[Fact]
public async Task Breakdowns_are_ordered_by_count_then_alphabetically()
{
var client = await factory.CreateUserClientAsync("stats-breakdown");
await SeedAsync(client);
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
Assert.Equal(["N64", "SNES"], stats!.BySystem.Select(s => s.Label));
Assert.Equal(2, stats.BySystem[0].Count);
// A tie must not shuffle between requests.
var again = await client.GetJsonAsync<StatsResponse>("/api/stats");
Assert.Equal(stats.BySystem.Select(s => s.Label), again!.BySystem.Select(s => s.Label));
}
[Fact]
public async Task Decades_are_derived_from_a_free_text_year_column()
{
var client = await factory.CreateUserClientAsync("stats-decades");
await SeedAsync(client);
// Year is free text, so these are the shapes that actually turn up.
await client.PostJsonAsync("/api/games", Game("Vague", year: "circa 1998"));
await client.PostJsonAsync("/api/games", Game("Empty", year: ""));
await client.PostJsonAsync("/api/games", Game("Junk", year: "unknown"));
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
var decades = stats!.ByDecade.ToDictionary(d => d.Label, d => d.Count);
Assert.Equal(3, decades["1990s"]); // 1995, 1996, "circa 1998"
Assert.Equal(1, decades["2000s"]); // 2001
Assert.Equal(1, decades["2010s"]); // 2011
// "" and "unknown" are omitted rather than bucketed as a zero decade,
// so the totals fall short of the library count by design.
Assert.Equal(5, decades.Values.Sum());
}
[Fact]
public async Task Decades_are_in_chronological_order()
{
var client = await factory.CreateUserClientAsync("stats-decade-order");
await SeedAsync(client);
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
// Time reads left to right, regardless of which decade is largest.
var labels = stats!.ByDecade.Select(d => d.Label).ToList();
Assert.Equal(labels.OrderBy(l => l, StringComparer.Ordinal), labels);
}
[Fact]
public async Task Value_carries_coverage_age_and_source_alongside_the_total()
{
var client = await factory.CreateUserClientAsync("stats-value");
await SeedAsync(client);
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
Assert.Equal(150m, stats!.Value.Total);
Assert.Equal(2, stats.Value.PricedCount);
// The two unpriced games are reported, so a total is never mistaken for
// covering the whole collection.
Assert.Equal(2, stats.Value.UnpricedCount);
Assert.NotNull(stats.Value.OldestValuedAt);
Assert.Contains("manual", stats.Value.Sources);
}
[Fact]
public async Task An_empty_library_reports_zeroes_rather_than_failing()
{
var client = await factory.CreateUserClientAsync("stats-empty");
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
Assert.Equal(0, stats!.TotalGames);
Assert.Equal(0, stats.Funnel.Owned);
Assert.Empty(stats.BySystem);
Assert.Equal(0m, stats.Value.Total);
// No games rated means no average, which is not the same as zero.
Assert.Null(stats.AverageRating);
Assert.Null(stats.Value.OldestValuedAt);
}
[Fact]
public async Task Ratings_average_only_over_rated_games()
{
var client = await factory.CreateUserClientAsync("stats-rating");
await SeedAsync(client);
var stats = await client.GetJsonAsync<StatsResponse>("/api/stats");
Assert.Equal(2, stats!.RatedCount);
// (9 + 7) / 2 — the two unrated games do not count as zero.
Assert.Equal(8.0, stats.AverageRating);
}
[Fact]
public async Task Stats_cover_only_the_signed_in_users_library()
{
var alice = await factory.CreateUserClientAsync("stats-alice");
var bob = await factory.CreateUserClientAsync("stats-bob");
await SeedAsync(alice);
var bobStats = await bob.GetJsonAsync<StatsResponse>("/api/stats");
Assert.Equal(0, bobStats!.TotalGames);
}
[Fact]
public async Task Stats_require_a_token()
{
var response = await factory.CreateClient().GetAsync("/api/stats");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
@@ -0,0 +1,90 @@
using System.IO.Compression;
namespace LudosData.Api.Tests;
/// <summary>
/// Builds a real PNG in memory, so upload tests exercise the actual decode path
/// rather than a fixture file that could drift or go missing.
/// </summary>
public static class TestImages
{
public static byte[] Png(int width, int height)
{
// One filter byte per scanline, then RGB triples.
var raw = new byte[height * (1 + width * 3)];
var offset = 0;
for (var y = 0; y < height; y++)
{
raw[offset++] = 0; // filter: none
for (var x = 0; x < width; x++)
{
raw[offset++] = (byte)(x * 8 % 256);
raw[offset++] = (byte)(y * 8 % 256);
raw[offset++] = 128;
}
}
using var output = new MemoryStream();
output.Write([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A]);
var header = new byte[13];
WriteBigEndian(header, 0, width);
WriteBigEndian(header, 4, height);
header[8] = 8; // bit depth
header[9] = 2; // colour type: truecolour
WriteChunk(output, "IHDR", header);
WriteChunk(output, "IDAT", ZlibCompress(raw));
WriteChunk(output, "IEND", []);
return output.ToArray();
}
private static void WriteBigEndian(byte[] buffer, int index, int value)
{
buffer[index] = (byte)(value >> 24);
buffer[index + 1] = (byte)(value >> 16);
buffer[index + 2] = (byte)(value >> 8);
buffer[index + 3] = (byte)value;
}
private static void WriteChunk(Stream stream, string type, byte[] data)
{
var length = new byte[4];
WriteBigEndian(length, 0, data.Length);
stream.Write(length);
var typeBytes = System.Text.Encoding.ASCII.GetBytes(type);
stream.Write(typeBytes);
stream.Write(data);
var crc = Crc32([.. typeBytes, .. data]);
var crcBytes = new byte[4];
WriteBigEndian(crcBytes, 0, unchecked((int)crc));
stream.Write(crcBytes);
}
private static byte[] ZlibCompress(byte[] data)
{
using var output = new MemoryStream();
using (var deflate = new ZLibStream(output, CompressionLevel.Fastest, leaveOpen: true))
{
deflate.Write(data);
}
return output.ToArray();
}
private static uint Crc32(byte[] data)
{
var crc = 0xFFFFFFFFu;
foreach (var b in data)
{
crc ^= b;
for (var i = 0; i < 8; i++)
{
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1;
}
}
return crc ^ 0xFFFFFFFFu;
}
}
+76
View File
@@ -0,0 +1,76 @@
# LudosData — full stack.
#
# cp .env.example .env # then edit the secrets
# docker compose up --build
#
# The SPA is served by nginx on http://localhost:8080, which also reverse-proxies
# /api and /uploads to the API container. Because everything is same-origin in
# this setup, the browser never issues a cross-origin request and CORS is not in
# play at all — the API's CORS policy only matters for `ng serve` on :4200.
services:
api:
build:
context: ./backend
image: ludosdata-api
restart: unless-stopped
environment:
ASPNETCORE_ENVIRONMENT: Production
# Required. Startup fails loudly if this is missing or under 32 chars.
Jwt__Key: ${JWT_KEY:?JWT_KEY is required — see .env.example}
Jwt__Issuer: ${JWT_ISSUER:-LudosData}
Jwt__Audience: ${JWT_AUDIENCE:-LudosData}
Jwt__LifetimeMinutes: ${JWT_LIFETIME_MINUTES:-720}
# Creates the first account and imports the 105 games from the 2018 dump,
# but only while the database has no users at all.
Seed__Enabled: ${SEED_ENABLED:-true}
Seed__UserName: ${SEED_USERNAME:-}
Seed__Email: ${SEED_EMAIL:-}
Seed__Password: ${SEED_PASSWORD:-}
# Optional market-value lookups. Blank means the pricing endpoints report
# 503 and everything else carries on.
PriceCharting__Token: ${PRICECHARTING_TOKEN:-}
Ebay__ClientId: ${EBAY_CLIENT_ID:-}
Ebay__ClientSecret: ${EBAY_CLIENT_SECRET:-}
Ebay__UseSandbox: ${EBAY_USE_SANDBOX:-false}
# Only consulted when the SPA is served from somewhere other than nginx.
Cors__AllowedOrigins__0: ${CORS_ORIGIN:-http://localhost:8080}
Cors__AllowedOrigins__1: http://localhost:4200
volumes:
# SQLite file and uploaded box art. This is the only stateful thing in the
# stack — back this volume up and you have backed up everything.
- ludos-data:/data
expose:
- "8080"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"]
interval: 15s
timeout: 3s
retries: 5
start_period: 20s
web:
build:
context: ./frontend
image: ludosdata-web
restart: unless-stopped
depends_on:
api:
condition: service_healthy
ports:
- "${WEB_PORT:-8080}:8080"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, and BusyBox wget
# resolves localhost to ::1 first and gets connection-refused.
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:8080/ || exit 1"]
interval: 15s
timeout: 3s
retries: 5
start_period: 10s
volumes:
ludos-data:
-14
View File
@@ -1,14 +0,0 @@
import { AppPage } from './app.po';
describe('ludos-data App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getParagraphText()).toEqual('Welcome to app!');
});
});
-11
View File
@@ -1,11 +0,0 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get('/');
}
getParagraphText() {
return element(by.css('app-root h1')).getText();
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"baseUrl": "./",
"module": "commonjs",
"target": "es5",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.angular/
.vscode/
*.log
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+26
View File
@@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1
# ---- build ----------------------------------------------------------------
# Pinned to the same Node major the project declares in package.json engines.
FROM node:24-alpine AS build
WORKDIR /app
# npm ci against the lockfile alone, so this layer caches across source edits.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build:prod
# ---- runtime --------------------------------------------------------------
# Unprivileged nginx: listens on 8080 and runs as a non-root user out of the box.
FROM nginxinc/nginx-unprivileged:alpine AS runtime
COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf
COPY --chown=nginx:nginx security-headers.conf /etc/nginx/snippets/security-headers.conf
COPY --from=build --chown=nginx:nginx /app/dist/ludos-web/browser /usr/share/nginx/html
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["wget", "-q", "--spider", "http://127.0.0.1:8080/"]
+59
View File
@@ -0,0 +1,59 @@
# LudosWeb
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.1.2.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+93
View File
@@ -0,0 +1,93 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"ludos-web": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@fontsource/roboto/400.css",
"@fontsource/roboto/500.css",
"@fontsource/roboto/700.css",
"material-icons/iconfont/filled.css",
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all",
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": false
},
"fonts": true
}
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "ludos-web:build:production"
},
"development": {
"buildTarget": "ludos-web:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
}
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
# Serves the built Angular bundle and reverse-proxies the API, so the browser
# sees a single origin and never makes a cross-origin request.
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Client uploads are capped server-side too; this stops oversized bodies
# from being buffered all the way to the API first.
client_max_body_size 6m;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# Do not advertise the exact nginx version.
server_tokens off;
include /etc/nginx/snippets/security-headers.conf;
# Hashed build assets are immutable, so they can be cached hard.
location ~* \.(?:js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|webp|ico)$ {
include /etc/nginx/snippets/security-headers.conf;
# add_header alone, not `expires`: using both emits two Cache-Control
# headers with overlapping directives.
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Uploaded box art, served straight off the API's volume.
#
# `^~` matters: without it, a regex location wins over a prefix location, so
# /uploads/<id>/<name>.webp would fall into the static-asset block above and
# 404 against nginx's own filesystem instead of being proxied.
location ^~ /uploads/ {
include /etc/nginx/snippets/security-headers.conf;
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
add_header Cache-Control "public, max-age=2592000" always;
}
location /health {
proxy_pass http://api:8080/health;
}
# index.html must never be cached, or clients keep booting old bundles.
# Declared before `location /` so the internal rewrite below lands here.
location = /index.html {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# Angular owns routing: any unknown path returns index.html so a deep link
# or a refresh on /games/12 does not 404.
location / {
try_files $uri $uri/ /index.html;
}
}
+7953
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "ludos-web",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"build:prod": "ng build --configuration production",
"lint": "ng lint",
"format": "prettier --write \"src/**/*.{ts,html,scss}\""
},
"private": true,
"packageManager": "npm@11.16.0",
"dependencies": {
"@angular/cdk": "^22.1.0",
"@angular/common": "^22.1.0",
"@angular/compiler": "^22.1.0",
"@angular/core": "^22.1.0",
"@angular/forms": "^22.1.0",
"@angular/material": "^22.1.0",
"@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0",
"@fontsource/roboto": "^5.3.0",
"material-icons": "^1.13.14",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.1.2",
"@angular/cli": "^22.1.2",
"@angular/compiler-cli": "^22.1.0",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8"
},
"engines": {
"node": ">=24 <25"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"/api": {
"target": "http://localhost:5099",
"secure": false,
"changeOrigin": true
},
"/uploads": {
"target": "http://localhost:5099",
"secure": false,
"changeOrigin": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+15
View File
@@ -0,0 +1,15 @@
# Included into every location that sets its own add_header.
#
# nginx only inherits add_header directives into a nested block when that block
# declares none of its own. A location that sets Cache-Control therefore silently
# drops everything defined at server level, so these are included explicitly
# rather than relying on inheritance.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# The app loads no third-party scripts, styles, fonts or images: Angular bundles
# everything and the API is same-origin. 'unsafe-inline' for styles is required
# by Angular Material's runtime style injection.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
+23
View File
@@ -0,0 +1,23 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
import { authInterceptor } from './core/auth.interceptor';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(
routes,
// Lets a component read route params via input() instead of injecting
// ActivatedRoute and reading a snapshot.
withComponentInputBinding(),
withInMemoryScrolling({ scrollPositionRestoration: 'top' }),
),
provideHttpClient(withInterceptors([authInterceptor])),
// No provideAnimations here: @angular/animations is deprecated as of v22 and
// Angular Material 22 no longer depends on it. Component motion now comes
// from CSS, with animate.enter / animate.leave for element transitions.
],
};
+56
View File
@@ -0,0 +1,56 @@
import { Routes } from '@angular/router';
import { authGuard, guestGuard } from './core/auth.guard';
/**
* Every feature is lazily loaded, so the login page does not ship the editor's
* code. In 2018 this was a single eager NgModule containing everything.
*/
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'games' },
{
path: 'login',
canActivate: [guestGuard],
title: 'Sign in · LudosData',
loadComponent: () => import('./features/login/login').then((m) => m.Login),
},
{
path: 'register',
canActivate: [guestGuard],
title: 'Create account · LudosData',
loadComponent: () => import('./features/register/register').then((m) => m.Register),
},
{
path: 'dashboard',
canActivate: [authGuard],
title: 'Collection · LudosData',
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
},
{
path: 'games',
canActivate: [authGuard],
title: 'Library · LudosData',
loadComponent: () => import('./features/game-grid/game-grid').then((m) => m.GameGrid),
},
{
path: 'games/new',
canActivate: [authGuard],
title: 'New game · LudosData',
loadComponent: () => import('./features/game-edit/game-edit').then((m) => m.GameEdit),
},
{
path: 'games/:id',
canActivate: [authGuard],
title: 'Edit game · LudosData',
loadComponent: () => import('./features/game-edit/game-edit').then((m) => m.GameEdit),
},
{
path: 'account',
canActivate: [authGuard],
title: 'Account · LudosData',
loadComponent: () => import('./features/account/account').then((m) => m.Account),
},
{ path: '**', redirectTo: 'games' },
];
+9
View File
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: '<router-outlet />',
})
export class App {}
+29
View File
@@ -0,0 +1,29 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
/**
* Functional route guard — the modern replacement for the class-based
* `AuthGuard implements CanActivate` this app used in 2018.
*/
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
/** Keeps an already-signed-in user off the login and register pages. */
export const guestGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isLoggedIn() ? router.createUrlTree(['/games']) : true;
};
@@ -0,0 +1,99 @@
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { AuthService } from './auth.service';
import { authInterceptor } from './auth.interceptor';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let auth: AuthService;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideRouter([{ path: 'login', children: [] }]),
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
auth = TestBed.inject(AuthService);
});
afterEach(() => {
httpMock.verify();
localStorage.clear();
});
/** Signs in through AuthService, which is what actually stores the session. */
function signIn(): void {
auth.login('ckoch', 'pw').subscribe();
httpMock.expectOne('/api/auth/login').flush({
token: 'test-token',
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
user: { id: 'u1', userName: 'ckoch', email: null, firstName: null, lastName: null, art: null },
});
}
it('sends the token as a bearer header, never in the query string', () => {
signIn();
http.get('/api/games').subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/games');
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
// The 2018 client appended ?token=... which leaks into logs and history.
expect(req.request.urlWithParams).not.toContain('token');
req.flush({ items: [], page: 1, pageSize: 20, total: 0, totalPages: 0 });
});
it('does not attach the token to non-API requests', () => {
signIn();
http.get('/assets/config.json').subscribe();
const req = httpMock.expectOne('/assets/config.json');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush({});
});
it('clears the stored session when the API rejects the token', () => {
signIn();
expect(auth.isLoggedIn()).toBe(true);
http.get('/api/games').subscribe({ error: () => undefined });
httpMock
.expectOne('/api/games')
.flush({ title: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(auth.isLoggedIn()).toBe(false);
expect(auth.token()).toBeNull();
});
it('treats an expired stored session as signed out', () => {
localStorage.setItem(
'ludos.session',
JSON.stringify({
token: 'stale',
expiresAt: new Date(Date.now() - 1000).toISOString(),
user: { id: 'u1', userName: 'ckoch' },
}),
);
// A fresh injector picks the value up from storage on construction.
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [provideRouter([{ path: 'login', children: [] }]), provideHttpClient(), provideHttpClientTesting()],
});
expect(TestBed.inject(AuthService).isLoggedIn()).toBe(false);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
/**
* Attaches the bearer token to same-origin API calls and turns a 401 into a
* redirect back to the login form.
*
* The old client put the token in the query string (`?token=...`), which leaks
* it into server logs, browser history and Referer headers. It belongs in the
* Authorization header.
*/
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const token = auth.token();
const isApiCall = req.url.startsWith('/api/');
const request =
token && isApiCall
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(request).pipe(
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse && error.status === 401 && isApiCall) {
// Expired or rejected token: drop it and bounce to login, remembering
// where the user was trying to go.
auth.logout();
void router.navigate(['/login'], {
queryParams: { returnUrl: router.url },
});
}
return throwError(() => error);
}),
);
};
+92
View File
@@ -0,0 +1,92 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { AuthResponse, User } from './models';
interface StoredSession {
token: string;
expiresAt: string;
user: User;
}
const STORAGE_KEY = 'ludos.session';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly session = signal<StoredSession | null>(readStoredSession());
readonly user = computed(() => this.session()?.user ?? null);
readonly token = computed(() => this.session()?.token ?? null);
/**
* True only while a stored token is present and unexpired. Checking expiry
* here means a stale token sends the user to the login form rather than
* producing a wall of 401s.
*/
readonly isLoggedIn = computed(() => {
const current = this.session();
if (!current) {
return false;
}
return new Date(current.expiresAt).getTime() > Date.now();
});
login(userName: string, password: string): Observable<AuthResponse> {
return this.http
.post<AuthResponse>('/api/auth/login', { userName, password })
.pipe(tap((response) => this.store(response)));
}
register(payload: {
userName: string;
email: string;
password: string;
firstName?: string;
lastName?: string;
}): Observable<AuthResponse> {
return this.http
.post<AuthResponse>('/api/auth/register', payload)
.pipe(tap((response) => this.store(response)));
}
/** Availability check for the registration form; returns true when free. */
isAvailable(field: 'userName' | 'email', value: string): Observable<{ available: boolean }> {
return this.http.get<{ available: boolean }>('/api/auth/available', {
params: { [field]: value },
});
}
logout(): void {
localStorage.removeItem(STORAGE_KEY);
this.session.set(null);
}
private store(response: AuthResponse): void {
const stored: StoredSession = {
token: response.token,
expiresAt: response.expiresAt,
user: response.user,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
this.session.set(stored);
}
}
function readStoredSession(): StoredSession | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as StoredSession;
return parsed?.token && parsed?.user ? parsed : null;
} catch {
// Corrupt or hand-edited storage should not wedge startup.
localStorage.removeItem(STORAGE_KEY);
return null;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Facets, Game, GameQuery, GameRequest, PagedResult, UploadResponse } from './models';
@Injectable({ providedIn: 'root' })
export class GamesService {
private readonly http = inject(HttpClient);
/**
* Note what is absent: the caller never passes a user id. Ownership is taken
* from the bearer token server-side, so there is no filter for a client to
* tamper with the way there was in the old php-crud-api query string.
*/
list(query: GameQuery): Observable<PagedResult<Game>> {
let params = new HttpParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '') {
params = params.set(key, String(value));
}
}
return this.http.get<PagedResult<Game>>('/api/games', { params });
}
get(id: number): Observable<Game> {
return this.http.get<Game>(`/api/games/${id}`);
}
facets(): Observable<Facets> {
return this.http.get<Facets>('/api/games/facets');
}
create(game: GameRequest): Observable<Game> {
return this.http.post<Game>('/api/games', game);
}
update(id: number, game: GameRequest): Observable<Game> {
return this.http.put<Game>(`/api/games/${id}`, game);
}
remove(id: number): Observable<void> {
return this.http.delete<void>(`/api/games/${id}`);
}
uploadArt(file: File): Observable<UploadResponse> {
const form = new FormData();
form.append('file', file, file.name);
return this.http.post<UploadResponse>('/api/images', form);
}
}
+62
View File
@@ -0,0 +1,62 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, tap } from 'rxjs';
export type ImportMode = 'Merge' | 'Replace';
export interface ImportRowError {
row: number;
title: string;
reason: string;
}
export interface ImportResult {
dryRun: boolean;
mode: ImportMode;
parsed: number;
created: number;
updated: number;
deleted: number;
skipped: number;
errors: ImportRowError[];
}
@Injectable({ providedIn: 'root' })
export class LibraryService {
private readonly http = inject(HttpClient);
/**
* Downloads the library. The request needs the bearer token, so it goes
* through HttpClient and is handed to the browser as a blob rather than
* being a plain anchor href.
*/
download(format: 'json' | 'csv'): Observable<Blob> {
return this.http
.get(`/api/library/export?format=${format}`, { responseType: 'blob' })
.pipe(tap((blob) => saveBlob(blob, `ludos-library-${today()}.${format}`)));
}
import(file: File, mode: ImportMode, dryRun: boolean): Observable<ImportResult> {
const form = new FormData();
form.append('file', file, file.name);
return this.http.post<ImportResult>(
`/api/library/import?mode=${mode}&dryRun=${dryRun}`,
form,
);
}
}
function today(): string {
return new Date().toISOString().slice(0, 10);
}
function saveBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
// Revoking immediately can cancel the download in some browsers.
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
+161
View File
@@ -0,0 +1,161 @@
/** Mirrors the API contracts in backend/src/LudosData.Api/Contracts. */
/** Mirrors LudosData.Api.Domain.GameCondition. */
export type GameCondition = 'Unspecified' | 'Loose' | 'Cib' | 'Sealed' | 'Digital';
/** Mirrors LudosData.Api.Domain.GameRegion. */
export type GameRegion = 'Unspecified' | 'Ntsc' | 'Pal' | 'NtscJ';
export const CONDITIONS: { value: GameCondition; label: string }[] = [
{ value: 'Unspecified', label: '—' },
{ value: 'Loose', label: 'Loose (cart/disc only)' },
{ value: 'Cib', label: 'Complete in box' },
{ value: 'Sealed', label: 'Sealed' },
{ value: 'Digital', label: 'Digital' },
];
export const REGIONS: { value: GameRegion; label: string }[] = [
{ value: 'Unspecified', label: '—' },
{ value: 'Ntsc', label: 'NTSC (North America)' },
{ value: 'Pal', label: 'PAL (Europe/Australia)' },
{ value: 'NtscJ', label: 'NTSC-J (Japan)' },
];
export interface Game {
id: number;
title: string;
system: string | null;
genre: string | null;
year: string | null;
developer: string | null;
publisher: string | null;
/** Stored filename. */
art: string | null;
/** Ready-to-use URL, built by the API. Null when no art was uploaded. */
artUrl: string | null;
description: string | null;
own: boolean;
dumped: boolean;
played: boolean;
finished: boolean;
/** Personal score out of 10. Null means unrated, which is not zero. */
rating: number | null;
notes: string | null;
condition: GameCondition;
region: GameRegion;
/** What was paid. A fixed historical fact. */
purchasePrice: number | null;
purchaseDate: string | null;
/** Current estimated resale value, with when and where it came from. */
marketValue: number | null;
marketValueUpdatedAt: string | null;
marketValueSource: string | null;
createdAt: string;
updatedAt: string;
}
/** Create/update payload. No id and no owner — the API derives both. */
export interface GameRequest {
title: string;
system: string | null;
genre: string | null;
year: string | null;
developer: string | null;
publisher: string | null;
art: string | null;
description: string | null;
own: boolean;
dumped: boolean;
played: boolean;
finished: boolean;
rating: number | null;
notes: string | null;
condition: GameCondition;
region: GameRegion;
purchasePrice: number | null;
purchaseDate: string | null;
marketValue: number | null;
marketValueSource: string | null;
}
export interface PagedResult<T> {
items: T[];
page: number;
pageSize: number;
total: number;
totalPages: number;
}
export interface GameQuery {
search?: string;
system?: string;
genre?: string;
own?: boolean;
dumped?: boolean;
played?: boolean;
finished?: boolean;
page?: number;
pageSize?: number;
sort?: string;
dir?: 'asc' | 'desc';
}
export interface Facets {
systems: string[];
genres: string[];
}
export interface User {
id: string;
userName: string;
email: string | null;
firstName: string | null;
lastName: string | null;
art: string | null;
}
export interface AuthResponse {
token: string;
expiresAt: string;
user: User;
}
export interface UploadResponse {
fileName: string;
url: string;
}
/** RFC 7807 body returned by the API for validation failures. */
export interface ProblemDetails {
title?: string;
detail?: string;
status?: number;
errors?: Record<string, string[]>;
}
export const EMPTY_GAME: GameRequest = {
title: '',
system: null,
genre: null,
year: null,
developer: null,
publisher: null,
art: null,
description: null,
own: true,
dumped: false,
played: false,
finished: false,
rating: null,
notes: null,
condition: 'Unspecified',
region: 'Unspecified',
purchasePrice: null,
purchaseDate: null,
marketValue: null,
marketValueSource: null,
};
+52
View File
@@ -0,0 +1,52 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
export interface CountByLabel {
label: string;
count: number;
}
/** Each stage is a subset of the one before, so these read in order. */
export interface CompletionFunnel {
owned: number;
played: number;
finished: number;
}
export interface ValueSummary {
total: number;
pricedCount: number;
unpricedCount: number;
totalPaid: number;
paidCount: number;
oldestValuedAt: string | null;
newestValuedAt: string | null;
sources: string[];
totalIfCib: number | null;
}
export interface Stats {
totalGames: number;
funnel: CompletionFunnel;
backlog: number;
inProgress: number;
dumped: number;
ratedCount: number;
averageRating: number | null;
bySystem: CountByLabel[];
byGenre: CountByLabel[];
byDecade: CountByLabel[];
byCondition: CountByLabel[];
byRating: CountByLabel[];
value: ValueSummary;
}
@Injectable({ providedIn: 'root' })
export class StatsService {
private readonly http = inject(HttpClient);
get(): Observable<Stats> {
return this.http.get<Stats>('/api/stats');
}
}

Some files were not shown because too many files have changed in this diff Show More