Skip to content

feat(api): Star, like and hide an asset through PATCH /assets/{id}/interaction (#46) - #292

Merged
bitbiter-dev merged 3 commits into
masterfrom
feat/46-interaction-endpoint
Oct 20, 2026
Merged

bitbiter-dev merged 3 commits into
masterfrom
feat/46-interaction-endpoint

Conversation

@bitbiter-dev

Copy link
Copy Markdown
Owner

Closes #46. Design note: #46 (comment). Decision (A1 + B1 + C1): #46 (comment)

Summary

PATCH /api/v1/assets/{assetId}/interaction   { starred?, liked?, hidden? }   (no body = no change)
  userId ← ClaimsPrincipal only (a body "userId" is ignored)
  IAssetInteractionWriter.ApplyAsync(userId, assetId, InteractionChange)       # Core; the API never sees AssetInteraction
    load   VisibleTo(userId) asset + the caller's row, in one query           # no NotHiddenFor, so hidden can be unhidden
    none visible                     → null → 404
    no row and nothing to change     → default state, no row                  # C1
    apply present flags, SaveChangesAsync
    unique violation on IX_Interactions_UserId_AssetId
      ChangeTracker.Clear, reload, reapply once                               # B1; a second loss throws
  → 200 AssetInteractionDto(starred, liked, hidden, displayWeight)            # same shape as list responses

Departures from the note:

  • The retry is covered by a save interceptor that inserts the competing row and then throws the Postgres unique violation. The note expected that path to be reachable only on real PostgreSQL. No Stryker directive or ADR 0005 row was needed.
  • The writer calls SaveChangesAsync itself (on the context, which is the IUnitOfWork). Other Core repositories leave saving to the caller. This one cannot, because the retry has to own both the save and ChangeTracker.Clear.
  • The first-caller effects (star, like, hide/unhide) are tested by writing and then reading back through EfAssetViewReader with OnThisDay, not over HTTP. ResponseContractTests is unchanged: its reflection scan already covers the reused AssetInteractionDto.

Evidence

  • Before: PATCH /api/v1/assets/{id}/interaction is not mapped, and no code writes Interactions.
    After:
    • Endpoint (MediaEndpointsTests):
      • partial body → only those flags are passed on;
      • {} and no body at all → no change, 200;
      • a body with userId → the write is for the caller;
      • not visible → 404;
      • anonymous → 401 without a write.
    • Writer (AssetInteractionWriterTests):
      • a first write creates the caller's row, for each flag;
      • a partial change keeps the other flags, and false clears one;
      • a hidden asset can be unhidden;
      • another user's asset, an unknown, soft-deleted or inactive-config asset → null;
      • a lost race → one row with both changes merged; losing twice, or a violation on another index, throws;
      • ToQueryString() on Npgsql: one statement with VisibleTo and the caller's row, and no hidden-rule EXISTS.
    • Read back On This Day: starred and liked show; hide drops the asset and unhide brings it back.
    • Model: the unique index is named InteractionIndexNames.OnePerUserAndAsset.
  • Full suite passes (1,361 tests). scripts/mutation-changed.sh reports 100.00 %. dotnet format --verify-no-changes, scripts/derive-db-docs.sh --check and has-pending-model-changes are all clean.

Merge Danger

Door: two-way for the code; one-way-ish for the contract

No schema change. HasDatabaseName sets the index's existing name, so there is no migration. The route, the body field names and the response shape become a public contract once the UI depends on them.

Blast Radius: small

It adds one route. The only existing code touched is the view mapping in AssetDtoMapper, which now builds its interaction object through the new overload; the output is unchanged and covered by its tests.

🤖 Generated with Claude Code

bitbiter-dev and others added 3 commits October 19, 2026 15:10
…teraction (#46)

The caller's interaction state is written by a Core writer that takes and
returns plain records, so the API still never touches AssetInteraction. It
checks only VisibleTo, so a hidden asset can be unhidden; an empty change
creates no row; and a first write that loses the (UserId, AssetId) unique
race reloads and reapplies once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-ups: a bodiless PATCH now returns 200 with the current state
instead of 400, as decided for an empty body. The writer's load query is
pinned by ToQueryString on Npgsql (VisibleTo and the caller's row, no hidden
rule), the index constant is wired with HasDatabaseName like its siblings,
and the view mapping reuses the InteractionState overload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bitbiter-dev
bitbiter-dev merged commit e014c80 into master Oct 20, 2026
12 checks passed
@bitbiter-dev
bitbiter-dev deleted the feat/46-interaction-endpoint branch October 20, 2026 01:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement user interaction endpoints: star, like, hide

1 participant