From 0c752b50076112cf580e440a324b90dab6718d26 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 6 Jul 2026 01:02:49 +0200 Subject: [PATCH 01/44] Update NuGet packages --- Directory.Packages.props | 45 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 068affa..99b320e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,32 +4,33 @@ true - + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - + + + + + - - - + + + From dc0edcec0cc441fd7b0e08ea4dddde1e82271c6f Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 6 Jul 2026 01:19:51 +0200 Subject: [PATCH 02/44] Minor md change --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f57029..cac0d35 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ Name | Description ## Configuration -### Web API appsettings +### Web API settings Key | Description ------------------------------------------|-------------------------- @@ -103,7 +103,7 @@ Template for `src/WebApi/appsettings.Development.json`: } ``` -### Blazor Server App appsettings +### Blazor Server App settings Key | Required | Default value ----------------------------------------|----------|-------------- From d8c287607d214a53c108bb8415aa2d64acc73901 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 6 Jul 2026 01:20:11 +0200 Subject: [PATCH 03/44] Fix obsolete warning on Google credentials --- src/BlazorApp/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorApp/Program.cs b/src/BlazorApp/Program.cs index 296b76e..279b3e3 100644 --- a/src/BlazorApp/Program.cs +++ b/src/BlazorApp/Program.cs @@ -17,7 +17,7 @@ if (FirebaseApp.DefaultInstance is null) { var firebaseJson = builder.Configuration.TryGetSection("Firebase:ServiceAccount"); - var googleCredential = GoogleCredential.FromJson(firebaseJson); + var googleCredential = CredentialFactory.FromJson(firebaseJson).ToGoogleCredential(); FirebaseApp.Create(new AppOptions { Credential = googleCredential }); } builder.Services.AddHttpContextAccessor(); From b6bba5c00bb6c7867a6504f81b0cbb6f87d8f92a Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 6 Jul 2026 01:42:41 +0200 Subject: [PATCH 04/44] Add CLAUDE files --- CLAUDE.md | 118 ++++++++++++++++++++++++++++++++++ Keeptrack.slnx | 2 + docs/code-quality-findings.md | 91 ++++++++++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/code-quality-findings.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..946cce3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,118 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project overview + +Keeptrack is an open source application that lets users save and review everything they read, watch, listen to or play (books, movies, TV shows, music albums, video games, cars/car history). + +It is a three-tier .NET 10 / C# solution: + +- Frontend: `BlazorApp`, a Blazor Server application. +- Backend: `WebApi`, an ASP.NET Web API (REST). +- Database: MongoDB. + +## Commands + +```bash +# restore and build the whole solution +dotnet restore +dotnet build + +# run the Web API (https://localhost:5011/) +dotnet run --project src/WebApi + +# run the Blazor Server app (https://localhost:5021/) +dotnet run --project src/BlazorApp + +# run all tests (uses the Microsoft.Testing.Platform runner, xunit v3) +dotnet test + +# run a single test project +dotnet test test/WebApi.UnitTests/WebApi.UnitTests.csproj +dotnet test test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj + +# run a single test by fully qualified name +dotnet test --filter-method "Keeptrack.WebApi.UnitTests.MappingProfiles.AutoMapperConfigurationTest.WebApiAutoMapperProfile_ShouldBeValid" + +# build container images +docker build . -t devprofr/keeptrack-blazorapp:local -f src/BlazorApp/Dockerfile +docker build . -t devprofr/keeptrack-webapi:local -f src/WebApi/Dockerfile +``` + +A local MongoDB instance is required to run the Web API or the integration tests: + +```bash +docker run --name mongodb -d -p 27017:27017 mongo:8.2 +``` + +Integration tests also need Firebase test-user credentials and MongoDB connection settings. Provide them as environment variables, or in a `Local.runsettings` file at the repository root (see `CONTRIBUTING.md` for the template). Never commit this file. + +## Architecture + +The solution follows a layered / clean-architecture style split across small, single-purpose projects (`src/*`), with `Domain` at the center and no project referencing "outward": + +| Project | Depends on | Responsibility | +|---|---|---| +| `Common.System` | — | Cross-cutting primitives shared by every layer: `IHasId`, `IHasIdAndOwnerId`, `PagedRequest`, `PagedResult`. | +| `Domain` | `Common.System` | Business models (`*Model` in `Models/`) and repository interfaces (`I*Repository` in `Repositories/`). No persistence or web concerns. | +| `Infrastructure.MongoDb` | `Domain` | MongoDB implementation: BSON `Entities/` and `Repositories/` implementing the `Domain` interfaces. | +| `WebApi.Contracts` | `Common.System` | Public REST DTOs (`Dto/`), shared between `WebApi` and `BlazorApp` so the Blazor client can deserialize API responses without duplicating classes. | +| `WebApi` | `Infrastructure.MongoDb`, `Domain`, `WebApi.Contracts` | ASP.NET Web API: controllers, AutoMapper profiles, DI wiring, JWT authentication, OpenAPI/Scalar docs. | +| `BlazorApp` | `Common.System`, `WebApi.Contracts` | Blazor Server UI. Talks to `WebApi` over HTTP using the shared DTOs; it never references `Domain` or `Infrastructure.MongoDb` directly. | + +### Data model conventions + +Every entity that belongs to a user implements `IHasIdAndOwnerId` (`Id` + `OwnerId`) at all three layers (`Domain` model, MongoDB entity, contract DTO), each with its own class. AutoMapper profiles convert between them: + +- `Infrastructure.MongoDb/../MappingProfiles`-equivalent lives in `WebApi/MappingProfiles/DataStorageMappingProfile.cs`: MongoDB entity <-> Domain model. +- `WebApi/MappingProfiles/WebServiceMappingProfile.cs`: DTO <-> Domain model. `OwnerId` is always ignored on the DTO -> model direction; it is set server-side from the authenticated user's claims, never trusted from client input. + +`AutoMapperConfigurationTest` (in `WebApi.UnitTests`) asserts the whole mapping configuration is valid; add a mapping there whenever a new model/entity/DTO triple is introduced. + +### Adding a new trackable item type + +Follow the existing types (`Book`, `Movie`, `MusicAlbum`, `TvShow`, `VideoGame`, `Car`/`CarHistory`) as the template. A new type touches every layer: + +1. `Domain/Models/Model.cs` and `Domain/Repositories/IRepository.cs` (extends `IDataRepository`). +2. `Infrastructure.MongoDb/Entities/.cs` (BSON attributes, `snake_case` element names via `[BsonElement]`) and `Infrastructure.MongoDb/Repositories/Repository.cs` (extends `MongoDbRepositoryBase`, overrides `CollectionName` and, if searchable, `GetFilter`). +3. `WebApi.Contracts/Dto/Dto.cs` (XML doc comments drive the generated OpenAPI spec). +4. `WebApi/Controllers/Controller.cs`: a one-line class extending `DataCrudControllerBase` — CRUD logic is never duplicated per controller. +5. Register the repository in `WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs`. +6. Add both AutoMapper maps (`DataStorageMappingProfile`, `WebServiceMappingProfile`). +7. `BlazorApp/Components/Inventory/Clients/ApiClient.cs` extending `InventoryApiClientBase`, plus a `Pages/.razor` / `.razor.cs` pair extending `InventoryPageBase`. + +### Web API request flow + +`DataCrudControllerBase` (`WebApi/Controllers/DataCrudControllerBase.cs`) implements the full CRUD surface (`GET`, `GET/{id}`, `POST`, `PUT/{id}`, `DELETE/{id}`) once, generically. It reads the caller's `user_id` claim to scope every query and to stamp `OwnerId` on writes, so per-type controllers only need routing and generic type arguments. Unhandled exceptions are converted to JSON error responses by `ApiExceptionFilterAttribute` (`ArgumentException`/`ArgumentNullException` -> 400, everything else -> 500). + +### Blazor app + +`InventoryPageBase` (`BlazorApp/Components/Inventory/InventoryPageBase.cs`) centralizes list/paging/search/inline-edit state and calls into `InventoryApiClientBase`, which wraps the typed `HttpClient` calls to the Web API. Each concrete page (`Books.razor.cs`, `Movies.razor.cs`, ...) only supplies its `Api` instance and `CloneItem`. Authentication uses Firebase (cookie auth in the Blazor app, JWT bearer validated against Firebase in the Web API); `AuthenticationTokenHandler` attaches the bearer token to outgoing API calls. + +## Code style + +- Enforced by `.editorconfig`: 4-space indent for C#/Razor, LF line endings, `var` preferred everywhere, braces required, `_camelCase` for private instance fields, `s_camelCase` for private static fields, PascalCase for everything else. Avoid `this.` qualification. +- Primary constructors are the norm for controllers, repositories and API clients (see `BookController`, `BookRepository`, `BookApiClient` above). +- Nullable reference types are enabled across all `src` projects; use `required` for non-nullable properties that have no sensible default (e.g. `BookModel.Title`). +- Public WebApi.Contracts DTOs and WebApi controllers carry XML doc comments (`GenerateDocumentationFile` is enabled) because they feed the generated OpenAPI/Scalar documentation. +- Markdown files: 2-space indent, no trailing-whitespace trimming, max line length 240 (`.markdownlint-cli2.yaml`). In addition, write one sentence per line inside multi-sentence paragraphs: never cut a sentence, keep each sentence as short as possible, and start a new line right after the period that ends it. Single-sentence paragraphs/list items don't need to be split further. + +## Tests + +- `test/WebApi.UnitTests`: xunit v3 unit tests, e.g. AutoMapper configuration validation. +- `test/WebApi.IntegrationTests`: xunit v3 tests booted against a real Kestrel host (`KestrelWebAppFactory`) and a real MongoDB instance. `ResourceTestBase` provides typed `GetAsync`/`PostAsync`/`PutAsync`/`DeleteAsync` helpers and an `Authenticate()` helper that logs in against Firebase to obtain a bearer token. Resource tests (`BookResourceTest`, `MovieResourceTest`) exercise a full create/read/update/delete cycle against the live API and clean up what they create. +- Assertions use `AwesomeAssertions` (a `FluentAssertions`-compatible API); test data is generated with `Bogus`. + +## CI + +GitHub Actions (`.github/workflows/ci.yaml`) runs, on push/PR to `main`: a git/markup lint job, a .NET quality job (build, test with coverage, SonarCloud, FOSSA license/security scan) gated on app changes, and a container image scan for both Dockerfiles. Equivalent pipelines exist for GitLab CI and Azure DevOps. + +## Quality bar + +The owner has zero tolerance for bad design or duplicated algorithms. Hold every change to this standard, not just new code. + +- No duplicated algorithms or logic. Duplicated data shapes (a `Model`, an `Entity`, and a `Dto` that mirror the same fields) are fine and expected; duplicating the logic that operates on them is not. If two repositories, controllers, or components need the same behavior, it belongs in a shared base class or method, following the existing `DataCrudControllerBase` / `MongoDbRepositoryBase` / `InventoryPageBase` pattern. +- Every non-trivial piece of logic needs a test, especially per-type overrides like `GetFilter`. These are the most duplicated, least-reused pieces of code in the solution, and history has already shown they are where bugs hide (see `docs/code-quality-findings.md`). +- Before proposing a fix, verify the current best-practice for the specific library/framework version in use (e.g. MongoDB driver filter semantics, current ASP.NET Core guidance) rather than relying on older patterns from training data. +- Known findings from past reviews, including which items are confirmed bugs versus intentional by-design behavior, are tracked in `docs/code-quality-findings.md`. Check it before re-reporting something already triaged there, and update it when a listed item is fixed. diff --git a/Keeptrack.slnx b/Keeptrack.slnx index c48f897..1b6f05a 100644 --- a/Keeptrack.slnx +++ b/Keeptrack.slnx @@ -4,12 +4,14 @@ + + diff --git a/docs/code-quality-findings.md b/docs/code-quality-findings.md new file mode 100644 index 0000000..db38963 --- /dev/null +++ b/docs/code-quality-findings.md @@ -0,0 +1,91 @@ +# Code quality findings + +This document tracks a code review performed on 2026-07-06 against current .NET and MongoDB best practices. +Each finding is classified as a confirmed bug, a confirmed by-design behavior, or a known gap that is not yet implemented. +Update this file as items are fixed or as new reviews are performed. + +## Confirmed bugs + +These are true defects. They should be fixed. + +### Search is a no-op for Movie and Music Album + +`MovieRepository.GetFilter` and `MusicAlbumRepository.GetFilter` build a MongoDB filter with `builder.Where(...)`. +The result is never combined into the returned `filter`. +As a result, the `search` box on the Movies and Music Albums pages currently filters nothing. + +Files: + +- `src/Infrastructure.MongoDb/Repositories/MovieRepository.cs` +- `src/Infrastructure.MongoDb/Repositories/MusicAlbumRepository.cs` + +Fix: combine the built expression back into the filter, e.g. `filter &= builder.Where(...)`. + +### Car and CarHistory search relies on a `$text` index that does not exist + +`CarRepository` has no `GetFilter` override, so it falls back to `MongoDbRepositoryBase.GetFilter`, which calls `builder.Text(search)`. +`CarHistoryRepository.GetFilter` also calls `builder.Text(...)`. +Both require a MongoDB text index on their collection. +`scripts/mongodb-create-index.js` only creates text indexes for `book`, `movie`, `tvshow`, and `videogame`. +No index exists for `car` or `car_history`. +A search request against either resource will throw a runtime error ("text index required for $text query"). + +Files: + +- `src/Infrastructure.MongoDb/Repositories/CarRepository.cs` +- `src/Infrastructure.MongoDb/Repositories/CarHistoryRepository.cs` +- `scripts/mongodb-create-index.js` + +Fix: either create the missing indexes, or switch these two repositories to the same per-field filter strategy used by Book/TvShow/VideoGame. + +### CarHistory treats a car ID as free text + +`CarHistoryRepository.GetFilter` calls `builder.Text(input.CarId)`. +A car ID is an exact identifier, not a free-text search term, so this is the wrong filter type regardless of the missing index above. +MongoDB also only allows one `$text` expression per query. +If both `input.CarId` and `search` are non-empty at the same time, the query throws ("only one $text expression allowed per query"). + +File: `src/Infrastructure.MongoDb/Repositories/CarHistoryRepository.cs` + +Fix: filter `CarId` with an equality filter (`builder.Eq(f => f.CarId, input.CarId)`), not `Text`. + +## Confirmed by design + +These were reviewed with the project owner and are intentional. No action needed. + +### Each entity searches its own fields + +`Book` searches `Title` + `Series` + `Author`. +`VideoGame` adds exact-match filters on `Platform` and `State`. +`TvShow` and `Movie` (once fixed) search `Title` only. +This is intentional: each entity type exposes the search behavior that fits its own fields, not a shared generic contract. + +## Known gaps (not yet implemented) + +These are acknowledged as incomplete rather than deliberately permanent. Track and prioritize separately. + +### `Car` has no controller or Blazor page + +`ICarRepository`, `CarRepository`, and their DI registration exist, but there is no `CarController` and no Blazor Inventory page for `Car`. +Only `CarHistoryController` exists, and it references a `CarId` that currently cannot be created through the app. + +### No `CancellationToken` propagation + +Controllers, `MongoDbRepositoryBase`, and `InventoryApiClientBase` (Blazor) do not accept or forward a `CancellationToken`. +Requests keep running server-side work after a client disconnects. +The test project already uses `TestContext.Current.CancellationToken` (xunit v3), so the pattern is known, just not applied to production code yet. + +### No pagination bounds + +`PagedRequest.Page` and `PagedRequest.PageSize` have no `[Range]` validation or clamping. +A negative `Page` produces a negative `Skip` value, which the MongoDB driver rejects. +A very large `PageSize` forces an unbounded fetch. + +### Thin test coverage + +Only `Book` and `Movie` have integration tests (`BookResourceTest`, `MovieResourceTest`). +`CarHistory`, `MusicAlbum`, `TvShow`, and `VideoGame` have none. +No test exercises the `search` query parameter for any resource, which is why the search bugs above went unnoticed. +No test asserts ownership isolation (that user A cannot read, update, or delete user B's record). +There is no test project for `BlazorApp`. +`BookResourceTest` and `MovieResourceTest` are also close to copy-pasted; a generic/parameterized test base (mirroring `DataCrudControllerBase` on the production side) would cover all resources without duplicating the test code per type. From abb7a3aa428aab5fa0ef4fe5504a23a60b31a57c Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 6 Jul 2026 10:44:14 +0200 Subject: [PATCH 05/44] TV Time import step 1 --- CLAUDE.md | 20 +- Directory.Packages.props | 1 + docs/code-quality-findings.md | 23 +- scripts/mongodb-create-index.js | 29 ++ src/BlazorApp/Components/App.razor | 1 + .../Components/Import/ImportPage.razor | 84 ++++ .../Import/TvTimeImportApiClient.cs | 21 + .../Inventory/Clients/EpisodeApiClient.cs | 9 + .../Clients/InventoryApiClientBase.cs | 10 +- .../Components/Inventory/Pages/Movies.razor | 30 ++ .../Inventory/Pages/TvShowDetail.razor | 172 ++++++++ .../Components/Inventory/Pages/TvShows.razor | 34 +- src/BlazorApp/Components/Layout/NavMenu.razor | 17 + .../WatchNext/WatchNextApiClient.cs | 12 + .../Components/WatchNext/WatchNextPage.razor | 79 ++++ ...frastructureServiceCollectionExtensions.cs | 6 + .../wwwroot/Keeptrack.BlazorApp.lib.module.js | 7 + src/BlazorApp/wwwroot/app.css | 402 ++++++++++++------ src/BlazorApp/wwwroot/theme.js | 15 + src/Domain/Models/EpisodeModel.cs | 21 + src/Domain/Models/MovieModel.cs | 4 + src/Domain/Models/TvShowModel.cs | 4 + src/Domain/Repositories/IEpisodeRepository.cs | 7 + .../Entities/Episode.cs | 30 ++ src/Infrastructure.MongoDb/Entities/Movie.cs | 6 + src/Infrastructure.MongoDb/Entities/TvShow.cs | 6 + .../Repositories/EpisodeRepository.cs | 22 + .../Repositories/MovieRepository.cs | 27 +- .../Repositories/MusicAlbumRepository.cs | 4 +- .../Repositories/TvShowRepository.cs | 2 + src/WebApi.Contracts/Dto/EpisodeDto.cs | 37 ++ src/WebApi.Contracts/Dto/ImportResultDto.cs | 26 ++ src/WebApi.Contracts/Dto/MovieDto.cs | 4 + src/WebApi.Contracts/Dto/TvShowDto.cs | 4 + src/WebApi.Contracts/Dto/WatchNextDto.cs | 30 ++ .../Controllers/ControllerBaseExtensions.cs | 15 + .../Controllers/DataCrudControllerBase.cs | 22 +- src/WebApi/Controllers/EpisodeController.cs | 12 + ...frastructureServiceCollectionExtensions.cs | 1 + .../Parsers/EpisodeCommentsCsvParser.cs | 40 ++ .../Parsers/FavoriteMoviesListParser.cs | 63 +++ .../Import/Parsers/FollowedShowsCsvParser.cs | 29 ++ .../Import/Parsers/MovieVotesCsvParser.cs | 36 ++ .../Import/Parsers/SeenEpisodesCsvParser.cs | 37 ++ .../Import/Parsers/ShowCommentsCsvParser.cs | 34 ++ .../Import/Parsers/ShowRatingsCsvParser.cs | 29 ++ .../Import/Parsers/ShowStatusCsvParser.cs | 36 ++ .../Import/Parsers/TvTimeCsvConfiguration.cs | 16 + src/WebApi/Import/TvTimeImportController.cs | 32 ++ src/WebApi/Import/TvTimeImportService.cs | 202 +++++++++ .../DataStorageMappingProfile.cs | 3 + .../WebServiceMappingProfile.cs | 4 + src/WebApi/Program.cs | 2 + src/WebApi/WatchNext/WatchNextController.cs | 39 ++ src/WebApi/WatchNext/WatchNextService.cs | 42 ++ src/WebApi/WebApi.csproj | 1 + .../Resources/MovieResourceTest.cs | 26 +- .../Resources/ResourceTestBase.cs | 15 + .../Resources/TvTimeFixtureZipBuilder.cs | 80 ++++ .../Resources/TvTimeImportResourceTest.cs | 77 ++++ .../Import/Parsers/CsvTestHelper.cs | 9 + .../Parsers/EpisodeCommentsCsvParserTest.cs | 26 ++ .../Parsers/FavoriteMoviesListParserTest.cs | 41 ++ .../Parsers/FollowedShowsCsvParserTest.cs | 24 ++ .../Import/Parsers/MovieVotesCsvParserTest.cs | 39 ++ .../Parsers/SeenEpisodesCsvParserTest.cs | 27 ++ .../Parsers/ShowCommentsCsvParserTest.cs | 22 + .../Parsers/ShowRatingsCsvParserTest.cs | 24 ++ .../Import/Parsers/ShowStatusCsvParserTest.cs | 26 ++ .../WatchNext/WatchNextServiceTest.cs | 91 ++++ 70 files changed, 2241 insertions(+), 187 deletions(-) create mode 100644 src/BlazorApp/Components/Import/ImportPage.razor create mode 100644 src/BlazorApp/Components/Import/TvTimeImportApiClient.cs create mode 100644 src/BlazorApp/Components/Inventory/Clients/EpisodeApiClient.cs create mode 100644 src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor create mode 100644 src/BlazorApp/Components/WatchNext/WatchNextApiClient.cs create mode 100644 src/BlazorApp/Components/WatchNext/WatchNextPage.razor create mode 100644 src/BlazorApp/wwwroot/Keeptrack.BlazorApp.lib.module.js create mode 100644 src/BlazorApp/wwwroot/theme.js create mode 100644 src/Domain/Models/EpisodeModel.cs create mode 100644 src/Domain/Repositories/IEpisodeRepository.cs create mode 100644 src/Infrastructure.MongoDb/Entities/Episode.cs create mode 100644 src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs create mode 100644 src/WebApi.Contracts/Dto/EpisodeDto.cs create mode 100644 src/WebApi.Contracts/Dto/ImportResultDto.cs create mode 100644 src/WebApi.Contracts/Dto/WatchNextDto.cs create mode 100644 src/WebApi/Controllers/ControllerBaseExtensions.cs create mode 100644 src/WebApi/Controllers/EpisodeController.cs create mode 100644 src/WebApi/Import/Parsers/EpisodeCommentsCsvParser.cs create mode 100644 src/WebApi/Import/Parsers/FavoriteMoviesListParser.cs create mode 100644 src/WebApi/Import/Parsers/FollowedShowsCsvParser.cs create mode 100644 src/WebApi/Import/Parsers/MovieVotesCsvParser.cs create mode 100644 src/WebApi/Import/Parsers/SeenEpisodesCsvParser.cs create mode 100644 src/WebApi/Import/Parsers/ShowCommentsCsvParser.cs create mode 100644 src/WebApi/Import/Parsers/ShowRatingsCsvParser.cs create mode 100644 src/WebApi/Import/Parsers/ShowStatusCsvParser.cs create mode 100644 src/WebApi/Import/Parsers/TvTimeCsvConfiguration.cs create mode 100644 src/WebApi/Import/TvTimeImportController.cs create mode 100644 src/WebApi/Import/TvTimeImportService.cs create mode 100644 src/WebApi/WatchNext/WatchNextController.cs create mode 100644 src/WebApi/WatchNext/WatchNextService.cs create mode 100644 test/WebApi.IntegrationTests/Resources/TvTimeFixtureZipBuilder.cs create mode 100644 test/WebApi.IntegrationTests/Resources/TvTimeImportResourceTest.cs create mode 100644 test/WebApi.UnitTests/Import/Parsers/CsvTestHelper.cs create mode 100644 test/WebApi.UnitTests/Import/Parsers/EpisodeCommentsCsvParserTest.cs create mode 100644 test/WebApi.UnitTests/Import/Parsers/FavoriteMoviesListParserTest.cs create mode 100644 test/WebApi.UnitTests/Import/Parsers/FollowedShowsCsvParserTest.cs create mode 100644 test/WebApi.UnitTests/Import/Parsers/MovieVotesCsvParserTest.cs create mode 100644 test/WebApi.UnitTests/Import/Parsers/SeenEpisodesCsvParserTest.cs create mode 100644 test/WebApi.UnitTests/Import/Parsers/ShowCommentsCsvParserTest.cs create mode 100644 test/WebApi.UnitTests/Import/Parsers/ShowRatingsCsvParserTest.cs create mode 100644 test/WebApi.UnitTests/Import/Parsers/ShowStatusCsvParserTest.cs create mode 100644 test/WebApi.UnitTests/WatchNext/WatchNextServiceTest.cs diff --git a/CLAUDE.md b/CLAUDE.md index 946cce3..0c34020 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,13 +82,25 @@ Follow the existing types (`Book`, `Movie`, `MusicAlbum`, `TvShow`, `VideoGame`, 6. Add both AutoMapper maps (`DataStorageMappingProfile`, `WebServiceMappingProfile`). 7. `BlazorApp/Components/Inventory/Clients/ApiClient.cs` extending `InventoryApiClientBase`, plus a `Pages/.razor` / `.razor.cs` pair extending `InventoryPageBase`. +### Child entities (1-to-many owned by another entity) + +`CarHistory` (owned by `Car`) and `Episode` (owned by `TvShow`) are separate top-level collections referencing their parent by id (`car_id`, `tv_show_id`), not embedded arrays. This is deliberate MongoDB schema design, not an accident: these child collections can grow unbounded per parent over years of use, and features that need to query the child across *all* of a user's parents at once (e.g. Watch Next, below) need a plain indexed query rather than an `$unwind` aggregation. Embedding is the right call for genuinely small, always-together, never-queried-alone data; neither condition holds here. `EpisodeRepository.GetFilter` filters by `input.TvShowId` with `Eq` (not `Text` — see the `CarHistory` bug in `docs/code-quality-findings.md` for what happens when an exact-id filter uses `Text` instead). New multi-word BSON fields get an explicit `[BsonElement("snake_case_name")]`; don't rely on the `CamelCaseElementNameConvention` registered in `AddMongoDbInfrastructure` — every existing multi-word field already overrides it explicitly, so it's effectively dead configuration. Indexes for new collections are declared in `scripts/mongodb-create-index.js` (natural-key uniqueness, query-shape support, and partial indexes for sparse boolean flags like `is_favorite`/`want_to_watch`). + ### Web API request flow -`DataCrudControllerBase` (`WebApi/Controllers/DataCrudControllerBase.cs`) implements the full CRUD surface (`GET`, `GET/{id}`, `POST`, `PUT/{id}`, `DELETE/{id}`) once, generically. It reads the caller's `user_id` claim to scope every query and to stamp `OwnerId` on writes, so per-type controllers only need routing and generic type arguments. Unhandled exceptions are converted to JSON error responses by `ApiExceptionFilterAttribute` (`ArgumentException`/`ArgumentNullException` -> 400, everything else -> 500). +`DataCrudControllerBase` (`WebApi/Controllers/DataCrudControllerBase.cs`) implements the full CRUD surface (`GET`, `GET/{id}`, `POST`, `PUT/{id}`, `DELETE/{id}`) once, generically. It calls the shared `ControllerBaseExtensions.GetUserId()` extension (`WebApi/Controllers/ControllerBaseExtensions.cs`) to read the caller's `user_id` claim, scope every query, and stamp `OwnerId` on writes, so per-type controllers only need routing and generic type arguments — any new controller (CRUD or not) should call the same extension rather than re-reading the claim. Unhandled exceptions are converted to JSON error responses by `ApiExceptionFilterAttribute` (`ArgumentException`/`ArgumentNullException` -> 400, everything else -> 500). + +Not every endpoint is per-item CRUD. `WebApi/WatchNext/` (a read-only cross-entity aggregation) and `WebApi/Import/` (the TV Time GDPR-export upsert, using `CsvHelper` for parsing) are their own feature folders with a plain `ControllerBase` and a small service class, rather than being force-fit into `DataCrudControllerBase`. Follow that folder-per-feature shape for future non-CRUD endpoints instead of stretching the generic CRUD base to cover them. ### Blazor app -`InventoryPageBase` (`BlazorApp/Components/Inventory/InventoryPageBase.cs`) centralizes list/paging/search/inline-edit state and calls into `InventoryApiClientBase`, which wraps the typed `HttpClient` calls to the Web API. Each concrete page (`Books.razor.cs`, `Movies.razor.cs`, ...) only supplies its `Api` instance and `CloneItem`. Authentication uses Firebase (cookie auth in the Blazor app, JWT bearer validated against Firebase in the Web API); `AuthenticationTokenHandler` attaches the bearer token to outgoing API calls. +`InventoryPageBase` (`BlazorApp/Components/Inventory/InventoryPageBase.cs`) centralizes list/paging/search/inline-edit state and calls into `InventoryApiClientBase`, which wraps the typed `HttpClient` calls to the Web API (its `GetAsync` takes an optional extra-query-parameters dictionary, used by features that filter on more than search/page/pageSize). Each concrete page (`Books.razor.cs`, `Movies.razor.cs`, ...) only supplies its `Api` instance and `CloneItem`. Authentication uses Firebase (cookie auth in the Blazor app, JWT bearer validated against Firebase in the Web API); `AuthenticationTokenHandler` attaches the bearer token to outgoing API calls. + +Pages that aren't a generic CRUD list (`TvShowDetail.razor`, `WatchNext/WatchNextPage.razor`, `Import/ImportPage.razor`) don't extend `InventoryPageBase`/`InventoryList` — they're free to build their own layout on top of the shared `kt-*` CSS classes in `app.css`. Their API clients live next to them in a feature folder rather than in `Inventory/Clients/`. + +### Theme + +`app.css` is a light+dark theme driven by `data-bs-theme` on `` (Bootstrap 5.3's native color-mode support), with Keeptrack's own `--kt-*` tokens layered on top for custom components (sidebar, `kt-table-wrap`, `kt-modal`, etc.). `wwwroot/theme.js` sets the initial theme from `localStorage`/`prefers-color-scheme` before first paint (avoiding a flash of the wrong theme) and exposes `ktToggleTheme()`, called directly from a plain button in `NavMenu.razor` — no Blazor/JS interop needed for the toggle itself. Use system-ui fonts only; no decorative/display webfonts. ## Code style @@ -100,8 +112,8 @@ Follow the existing types (`Book`, `Movie`, `MusicAlbum`, `TvShow`, `VideoGame`, ## Tests -- `test/WebApi.UnitTests`: xunit v3 unit tests, e.g. AutoMapper configuration validation. -- `test/WebApi.IntegrationTests`: xunit v3 tests booted against a real Kestrel host (`KestrelWebAppFactory`) and a real MongoDB instance. `ResourceTestBase` provides typed `GetAsync`/`PostAsync`/`PutAsync`/`DeleteAsync` helpers and an `Authenticate()` helper that logs in against Firebase to obtain a bearer token. Resource tests (`BookResourceTest`, `MovieResourceTest`) exercise a full create/read/update/delete cycle against the live API and clean up what they create. +- `test/WebApi.UnitTests`: xunit v3 unit tests, e.g. AutoMapper configuration validation, the TV Time import parsers (`Import/Parsers/`, pure stream-in/records-out, no I/O beyond an in-memory stream), and `WatchNextService` (pure next-episode computation). +- `test/WebApi.IntegrationTests`: xunit v3 tests booted against a real Kestrel host (`KestrelWebAppFactory`) and a real MongoDB instance. `ResourceTestBase` provides typed `GetAsync`/`PostAsync`/`PutAsync`/`DeleteAsync`/`PostFileAsync` helpers and an `Authenticate()` helper that logs in against Firebase to obtain a bearer token. Resource tests (`BookResourceTest`, `MovieResourceTest`, `TvTimeImportResourceTest`) exercise a full create/read/update/delete (or upsert) cycle against the live API and clean up what they create. `TvTimeFixtureZipBuilder` builds a small synthetic TV Time export in memory for the import test — never commit a real personal export as a test fixture. - Assertions use `AwesomeAssertions` (a `FluentAssertions`-compatible API); test data is generated with `Bogus`. ## CI diff --git a/Directory.Packages.props b/Directory.Packages.props index 99b320e..1bd8407 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/docs/code-quality-findings.md b/docs/code-quality-findings.md index db38963..ebde40a 100644 --- a/docs/code-quality-findings.md +++ b/docs/code-quality-findings.md @@ -4,22 +4,23 @@ This document tracks a code review performed on 2026-07-06 against current .NET Each finding is classified as a confirmed bug, a confirmed by-design behavior, or a known gap that is not yet implemented. Update this file as items are fixed or as new reviews are performed. -## Confirmed bugs - -These are true defects. They should be fixed. +## Fixed -### Search is a no-op for Movie and Music Album +### Search was a no-op for Movie and Music Album -`MovieRepository.GetFilter` and `MusicAlbumRepository.GetFilter` build a MongoDB filter with `builder.Where(...)`. -The result is never combined into the returned `filter`. -As a result, the `search` box on the Movies and Music Albums pages currently filters nothing. +Fixed on 2026-07-06 while building the TV Time import feature (both repositories were touched anyway to add the `IsFavorite`/`WantToWatch` filters). +`MovieRepository.GetFilter` and `MusicAlbumRepository.GetFilter` built a MongoDB filter with `builder.Where(...)` but never combined the result back into the returned `filter`. +Both now do `filter &= builder.Where(...)`. +A regression test (`MovieResourceTest.MovieResourceSearch_FiltersToMatchingTitle_IsOk`) locks in the Movie fix; `MusicAlbum` still has no integration test at all (see "Thin test coverage" below), so its fix is unverified beyond the code change itself. Files: - `src/Infrastructure.MongoDb/Repositories/MovieRepository.cs` - `src/Infrastructure.MongoDb/Repositories/MusicAlbumRepository.cs` -Fix: combine the built expression back into the filter, e.g. `filter &= builder.Where(...)`. +## Confirmed bugs + +These are true defects. They should be fixed. ### Car and CarHistory search relies on a `$text` index that does not exist @@ -83,9 +84,9 @@ A very large `PageSize` forces an unbounded fetch. ### Thin test coverage -Only `Book` and `Movie` have integration tests (`BookResourceTest`, `MovieResourceTest`). -`CarHistory`, `MusicAlbum`, `TvShow`, and `VideoGame` have none. -No test exercises the `search` query parameter for any resource, which is why the search bugs above went unnoticed. +`Book` and `Movie` have integration tests (`BookResourceTest`, `MovieResourceTest`); `Movie`'s now also covers `?search=`. +`Episode` and `TvShow` gained partial coverage as a side effect of `TvTimeImportResourceTest` (create/upsert/search paths, plus `Episode`'s `TvShowId` filter), but neither has a dedicated full CRUD test of its own yet. +`CarHistory`, `MusicAlbum`, and `VideoGame` still have none. No test asserts ownership isolation (that user A cannot read, update, or delete user B's record). There is no test project for `BlazorApp`. `BookResourceTest` and `MovieResourceTest` are also close to copy-pasted; a generic/parameterized test base (mirroring `DataCrudControllerBase` on the production side) would cover all resources without duplicating the test code per type. diff --git a/scripts/mongodb-create-index.js b/scripts/mongodb-create-index.js index fc7e275..6198f37 100644 --- a/scripts/mongodb-create-index.js +++ b/scripts/mongodb-create-index.js @@ -2,3 +2,32 @@ db.book.createIndex({ title: "text" }, { name: "book_text" }); db.movie.createIndex({ title: "text" }, { name: "movie_text" }); db.tvshow.createIndex({ title: "text" }, { name: "tvshow_text" }); db.videogame.createIndex({ title: "text" }, { name: "videogame_text" }); + +// episode: enforces the (show, season, episode) natural key and makes re-imports idempotent at the database level +db.episode.createIndex( + { owner_id: 1, tv_show_id: 1, season_number: 1, episode_number: 1 }, + { name: "episode_natural_key", unique: true } +); +// episode: supports the Watch Next "most recently watched episode per show" lookup +db.episode.createIndex( + { owner_id: 1, tv_show_id: 1, watched_at: -1 }, + { name: "episode_last_watched" } +); + +// movie / tvshow: partial indexes over the sparse favorite/want-to-watch flags (most documents are false) +db.movie.createIndex( + { owner_id: 1, is_favorite: 1 }, + { name: "movie_favorite", partialFilterExpression: { is_favorite: true } } +); +db.movie.createIndex( + { owner_id: 1, want_to_watch: 1 }, + { name: "movie_want_to_watch", partialFilterExpression: { want_to_watch: true } } +); +db.tvshow.createIndex( + { owner_id: 1, is_favorite: 1 }, + { name: "tvshow_favorite", partialFilterExpression: { is_favorite: true } } +); +db.tvshow.createIndex( + { owner_id: 1, want_to_watch: 1 }, + { name: "tvshow_want_to_watch", partialFilterExpression: { want_to_watch: true } } +); diff --git a/src/BlazorApp/Components/App.razor b/src/BlazorApp/Components/App.razor index 5d44783..db1b5fc 100644 --- a/src/BlazorApp/Components/App.razor +++ b/src/BlazorApp/Components/App.razor @@ -6,6 +6,7 @@ + diff --git a/src/BlazorApp/Components/Import/ImportPage.razor b/src/BlazorApp/Components/Import/ImportPage.razor new file mode 100644 index 0000000..95a5f8d --- /dev/null +++ b/src/BlazorApp/Components/Import/ImportPage.razor @@ -0,0 +1,84 @@ +@page "/import" +@rendermode InteractiveServer +@attribute [Authorize] + +
+

Import from TV Time

+
+ +
+

+ Upload the zip file from TV Time's "Download my data" (GDPR) export. + Keeptrack reads your followed shows, episode watch history, ratings, favorite/want-to-watch status, + comments, and movies, and upserts them into your account. + Safe to run more than once. +

+ +
+ 📦 + +
+ + @if (_importing) + { +
+
+ Importing… +
+ } + + @if (_error is not null) + { +
@_error
+ } + + @if (_result is not null) + { +
+

Shows: @_result.ShowsCreated created, @_result.ShowsUpdated updated

+

Episodes: @_result.EpisodesCreated created, @_result.EpisodesUpdated updated

+

Movies: @_result.MoviesCreated created, @_result.MoviesUpdated updated

+ @if (_result.Warnings.Count > 0) + { +
+

Warnings:

+
    + @foreach (var warning in _result.Warnings) + { +
  • @warning
  • + } +
+ } +
+ } +
+ +@code { + private const long MaxFileSize = 50_000_000; + + [Inject] private TvTimeImportApiClient ImportApi { get; set; } = null!; + + private bool _importing; + private string? _error; + private ImportResultDto? _result; + + private async Task OnFileSelectedAsync(InputFileChangeEventArgs e) + { + _importing = true; + _error = null; + _result = null; + try + { + await using var stream = e.File.OpenReadStream(MaxFileSize); + _result = await ImportApi.ImportAsync(stream, e.File.Name); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _importing = false; + } + } +} diff --git a/src/BlazorApp/Components/Import/TvTimeImportApiClient.cs b/src/BlazorApp/Components/Import/TvTimeImportApiClient.cs new file mode 100644 index 0000000..7e499f9 --- /dev/null +++ b/src/BlazorApp/Components/Import/TvTimeImportApiClient.cs @@ -0,0 +1,21 @@ +using System.Net.Http.Headers; +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Import; + +public sealed class TvTimeImportApiClient(HttpClient http) +{ + public async Task ImportAsync(Stream zipStream, string fileName) + { + using var content = new MultipartFormDataContent(); + using var fileContent = new StreamContent(zipStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + content.Add(fileContent, "file", fileName); + + var response = await http.PostAsync("/api/import/tv-time", content); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(); + return result ?? new ImportResultDto(); + } +} diff --git a/src/BlazorApp/Components/Inventory/Clients/EpisodeApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/EpisodeApiClient.cs new file mode 100644 index 0000000..71eee17 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Clients/EpisodeApiClient.cs @@ -0,0 +1,9 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Inventory.Clients; + +public sealed class EpisodeApiClient(HttpClient http) + : InventoryApiClientBase(http) +{ + protected override string ApiResourceName => "/api/episodes"; +} diff --git a/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs b/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs index 5231937..1c4497e 100644 --- a/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs +++ b/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs @@ -7,9 +7,17 @@ public abstract class InventoryApiClientBase(HttpClient http) { protected abstract string ApiResourceName { get; } - public async Task> GetAsync(string search, int page, int pageSize) + public async Task> GetAsync(string search, int page, int pageSize, IReadOnlyDictionary? extraQuery = null) { var query = $"{ApiResourceName}?search={Uri.EscapeDataString(search)}&page={page}&pageSize={pageSize}"; + if (extraQuery is not null) + { + foreach (var (key, value) in extraQuery) + { + query += $"&{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"; + } + } + var result = await http.GetFromJsonAsync>(query); return result ?? new PagedResult([], 0, 1, 1); } diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor b/src/BlazorApp/Components/Inventory/Pages/Movies.razor index 5363fae..4ace1a5 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor @@ -32,12 +32,23 @@ Year Rating First seen + @movie.Title @(movie.Year > 0 ? movie.Year : "") @movie.FirstSeenAt?.ToString("yyyy-MM-dd") + + @if (movie.IsFavorite) + { + + } + @if (movie.WantToWatch) + { + 👁 + } + @@ -45,6 +56,14 @@ +
+ + +
+
+ + +
@@ -71,5 +90,16 @@
+
+ +
+ + +
+
+ + +
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor new file mode 100644 index 0000000..6961015 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor @@ -0,0 +1,172 @@ +@page "/tv-shows/{Id}" +@rendermode InteractiveServer +@attribute [Authorize] + +@if (_loading) +{ +
+
+ Loading… +
+} +else if (_show is null) +{ +
+
+

Show not found.

+
+} +else +{ +
+

@_show.Title

+
+ + +
+
+ +
+
+
+ +
+
+
+ +
@_show.Notes
+
+
+
+ +

Add episode watched

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ @if (_error is not null) + { +
@_error
+ } +
+ + @if (_seasons.Count == 0) + { +
+
+

No episodes tracked yet.

+
+ } + else + { +
+ @foreach (var season in _seasons) + { + + } +
+ +
+
Season @_selectedSeason
+ @foreach (var episode in _episodesBySeason[_selectedSeason]) + { +
+ E@(episode.EpisodeNumber.ToString("00")) + @episode.Notes + @episode.WatchedAt?.ToString("yyyy-MM-dd") +
+ } +
+ } +} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private TvShowApiClient TvShowApi { get; set; } = null!; + + [Inject] private EpisodeApiClient EpisodeApi { get; set; } = null!; + + private bool _loading = true; + private TvShowDto? _show; + private Dictionary> _episodesBySeason = new(); + private List _seasons = []; + private int _selectedSeason; + private string? _error; + + private int _newSeason = 1; + private int _newEpisode = 1; + private DateOnly? _newWatchedAt = DateOnly.FromDateTime(DateTime.Today); + + protected override async Task OnParametersSetAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _loading = true; + _show = await TvShowApi.GetOneAsync(Id); + var episodes = await EpisodeApi.GetAsync(string.Empty, 1, 5000, new Dictionary { ["TvShowId"] = Id }); + + // most recently aired first: latest season at the top, latest episode within a season at the top + _episodesBySeason = episodes.Items + .GroupBy(e => e.SeasonNumber) + .ToDictionary(g => g.Key, g => g.OrderByDescending(e => e.EpisodeNumber).ToList()); + _seasons = _episodesBySeason.Keys.OrderByDescending(s => s).ToList(); + + if (!_seasons.Contains(_selectedSeason)) + { + _selectedSeason = _seasons.Count > 0 ? _seasons[0] : 0; + } + + _loading = false; + } + + private void SelectSeason(int season) => _selectedSeason = season; + + private async Task ToggleFavoriteAsync() + { + if (_show is null) return; + _show.IsFavorite = !_show.IsFavorite; + await TvShowApi.UpdateAsync(_show); + } + + private async Task ToggleWantToWatchAsync() + { + if (_show is null) return; + _show.WantToWatch = !_show.WantToWatch; + await TvShowApi.UpdateAsync(_show); + } + + private async Task AddEpisodeAsync() + { + try + { + await EpisodeApi.AddAsync(new EpisodeDto + { + TvShowId = Id, + SeasonNumber = _newSeason, + EpisodeNumber = _newEpisode, + WatchedAt = _newWatchedAt + }); + _error = null; + await LoadAsync(); + } + catch (Exception ex) + { + _error = ex.Message; + } + } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor index c963dba..4af0e03 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor @@ -31,16 +31,37 @@ Title Last episode seen Rating + - @show.Title + + @show.Title + @show.LastEpisodeSeen + + @if (show.IsFavorite) + { + + } + @if (show.WantToWatch) + { + 👁 + } + +
+ + +
+
+ + +
@@ -67,5 +88,16 @@
+
+ +
+ + +
+
+ + +
+
diff --git a/src/BlazorApp/Components/Layout/NavMenu.razor b/src/BlazorApp/Components/Layout/NavMenu.razor index 66a22a4..ab8509d 100644 --- a/src/BlazorApp/Components/Layout/NavMenu.razor +++ b/src/BlazorApp/Components/Layout/NavMenu.razor @@ -14,6 +14,11 @@ + + +