Skip to content

feat: bulk update of movies, music and games (#89) - #161

Merged
mforce merged 11 commits into
mainfrom
feat/89-bulk-update
Aug 23, 2026
Merged

feat: bulk update of movies, music and games (#89)#161
mforce merged 11 commits into
mainfrom
feat/89-bulk-update

Conversation

@mforce

@mforce mforce commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Bulk update movies/music/games from the collection lists: select rows, set shared/per-type
fields in one PATCH /api/:type/bulk. One generic endpoint reused across all three media types (#97
generic module). Partial-update semantics; atomic ownership isolation; write-boundary enum validation.

Closes #89

Kyoder added 7 commits August 22, 2026 22:02
)

- JsonSerializer.Deserialize<TEnum> with no options only accepts numeric
  JSON tokens; the default converter has no string-name support, unlike
  the app-wide JsonOptions used for normal DTO binding.
- Every enum-typed field elsewhere in the app accepts a JSON string like
  "Sold"; the bulk endpoint's enum parser was the only place that would
  400 on that same input.
- Runbook defect found by probe tests before Increment 3 tests were
  authored (see session report).
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b72af1a5b0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const updates: BulkUpdates = {};
if (bulkStatus) updates.status = bulkStatus;
if (bulkRating != null) updates.personalRating = bulkRating;
if (bulkTags.length > 0) updates.tags = bulkTags;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Whitelist tags before sending bulk tag updates

When a user enters any tag, the client includes tags in updates, but none of the Movies, Music, or Games BulkFields dictionaries registers that key. The generic endpoint therefore rejects every tag-only or mixed-field edit as an unknown field before reaching its special tag-resolution branch, so the entire bulk operation returns 400 and persists nothing. Register tags as supported or exempt it from the whitelist check.

Useful? React with 👍 / 👎.

BulkFields = new Dictionary<string, BulkField<Movie>>
{
["status"] = BulkFieldBuilder.Enum<Movie, CollectionStatus>("status", (e, v) => e.Status = v),
["condition"] = BulkFieldBuilder.Enum<Movie, Condition>("condition", (e, v) => e.Condition = v),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow nullable conditions to be cleared

When an API client sends the declared condition: null update to clear this nullable field, every media configuration routes it through the non-nullable enum builder, which cannot pass null to the setter and rejects the request instead. This differs from POST/PUT behavior for the nullable Condition? property and from the new BulkUpdates type; use a nullable-enum parser for condition.

Useful? React with 👍 / 👎.

Comment on lines +78 to +80
value = el.ValueKind == JsonValueKind.String
? System.Enum.Parse<TEnum>(el.GetString()!)
: JsonSerializer.Deserialize<TEnum>(el.GetRawText());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve case-insensitive enum parsing

When a client sends a valid enum name with different casing, such as "sold", POST/PUT accepts it through DefinedEnumConverter, which parses names case-insensitively, but this one-argument Enum.Parse is case-sensitive and makes the bulk endpoint return 400. Parse with ignoreCase: true so bulk updates preserve the existing write-boundary contract.

Useful? React with 👍 / 👎.


<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={closeBulkEdit}>Cancel</Button>
<Button type="button" onClick={confirmBulkEdit} disabled={bulkUpdate.isPending}>Confirm</Button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable confirmation until an update is selected

If a user opens the modal and immediately presses Confirm, all controls still represent “leave unchanged,” so confirmBulkEdit sends an empty updates object. The server explicitly rejects that payload, causing a guaranteed request error from an enabled normal UI path; disable Confirm until at least one field is set or handle the no-op locally.

Useful? React with 👍 / 👎.

@mforce

mforce commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

@codex Please review this PR (bulk update #89). Return merge-or-defer verdicts on findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b72af1a5b0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const confirmBulkEdit = () => {
const updates: BulkUpdates = {};
if (bulkStatus) updates.status = bulkStatus;
if (bulkRating != null) updates.personalRating = bulkRating;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer merge until ratings have a distinct clear state

When a user needs to clear the existing ratings on the selected items, the modal cannot represent that operation: null is both the initial “leave unchanged” state and the value produced by RatingInput's Clear action, and this guard then omits personalRating from the request. Although the bulk API and BulkUpdates explicitly support personalRating: null, the UI can only assign ratings, so users cannot remove them in bulk. Verdict: defer merge.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — agreed: the modal cannot clear an existing rating (null is both 'unchanged' and 'cleared'), and hasBulkEdit omits personalRating at that state. Real gap affecting tags/acquiredOn clears too. Tracked as issue #167. Server and BulkUpdates already support personalRating: null; client tri-state follow-up, not a merge-blocker for the endpoint.

@mforce

mforce commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

Round 1 findings addressed in commit 2815719 (all 6 fixes). Driver-verified on the new head: server 657/657 pass, client 183 pass, build clean, PR CI green, 3 new-guard mutations reddened (tags, case-insensitive enum, nullable-condition). @codex please re-review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2815719b12

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +269 to +271
const hasBulkEdit = Boolean(
bulkStatus || bulkRating != null || bulkTags.length > 0 || bulkAcquiredOn
|| (category === 'movies' && bulkWatchStatus),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track clear actions separately from unchanged values

When selected records already have ratings, tags, or acquisition dates, the modal cannot clear them: null, [], and '' are simultaneously the initial “leave unchanged” values, and this guard disables Confirm for those values while confirmBulkEdit omits the corresponding keys. The new hasBulkEdit guard added in 2815719 is fresh evidence for the previously reported rating issue because clearing a chosen rating now explicitly returns the modal to a disabled state; use touched flags or an explicit tri-state so these API-supported clear operations can be submitted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — real gap. The modal overloads null/[]/'' as both 'unchanged' and 'clear', so API-supported clears (personalRating: null, clearing tags, clearing acquiredOn) aren't reachable from the UI. Tracked as issue #167. Not merge-blocking for the bulk-update feature itself (server handles the clears correctly when sent); filed for a client follow-up.

Comment on lines +192 to +194
var names = tagsEl.ValueKind == JsonValueKind.Null
? null
: JsonSerializer.Deserialize<string[]?>(tagsEl.GetRawText());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject malformed tag values instead of throwing

When an authenticated client sends a non-array tag value such as {"updates":{"tags":42}}, JsonSerializer.Deserialize<string[]?> throws JsonException; unlike every BulkFieldBuilder parser, this path does not catch it or return a validation result, so malformed input reaches the unhandled-exception response rather than the endpoint's stable 400 error shape. Validate tagsEl as an array and catch deserialization failures before invoking TagResolver.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 53c2d08: the tags deserialize is wrapped in try/catch returning 400 { error='tags: invalid value for tags.' }. Driver-verified — reverted the try/catch → BulkUpdate_Tags_MalformedValue_Returns400 goes RED 3/3, restored green. Matches every sibling BulkField.Apply. No global exception middleware needed.

Comment on lines +214 to +215
if (s.Length != 3) return $"{name} must be 3 characters.";
set(e, s.Trim().ToUpperInvariant());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate currency length after trimming

For a three-character value containing surrounding whitespace, such as " U ", the raw-length check succeeds and the subsequent trim persists the one-character value "U", violating this builder's three-letter currency contract. Check s.Trim().Length before storing the normalized value, or stop trimming if raw three-character parity is intended.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid parity gap, but intentionally NOT applied in this PR: it lives in BulkFieldBuilder.cs (outside the round's allow-list). Filed as follow-up issue #162 (P3, cosmetic, ' U ' edge case). Agreed the cleanest fix is s.Trim().Length parity with the single-write boundary.

@mforce

mforce commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

@codex please re-review against the current head 53c2d08. Status of your 8 inline findings: 5 fixed/verified (tags whitelist, nullable condition, case-insensitive enum, confirm-disable, malformed-tags 400); 3 tracked as follow-up issues — client clear-state gap #167 (CollectionList:271/272), currency parity #162 (BulkFieldBuilder:215). Appreciate verdicts on whether anything is still merge-blocking.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 53c2d08459

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const btnClass = category ? BTN_CLASS[category] : '';

// ─── Bulk selection + edit ─────────────────────────────────────
const [selected, setSelected] = useState<Set<number>>(new Set());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Drop selections that leave the current result set

When a user selects rows and then changes the search or filters, items is replaced but this state is never reconciled; confirmBulkEdit still submits every ID remaining in selected. The modal can therefore overwrite status, tags, or other fields on records that are no longer displayed, despite the selection control being scoped to the current page. Clear or intersect the selection with the current result IDs when the query, filters, or result set changes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified real and confirmed independently (selected state line 233 is never reconciled when items/query/filters change; confirmBulkEdit submits [...selected] line 283). This is a genuine P1 data-integrity gap. Fix dispatched (reconcile selected against current result set + RED test).

Comment on lines +78 to +80
value = el.ValueKind == JsonValueKind.String
? System.Enum.Parse<TEnum>(el.GetString()!, ignoreCase: true)
: JsonSerializer.Deserialize<TEnum>(el.GetRawText());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return 400 for overflowing enum strings

When an authenticated client sends an enum string outside the underlying integer range, such as "999999999999999999999999", Enum.Parse throws OverflowException, which is not caught by this parser, so the bulk endpoint returns 500 instead of its documented validation response. The same pattern exists in NullableEnum; use TryParse or catch OverflowException in both helpers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified real: Enum.Parse throws OverflowException on huge numeric enum strings, and both Enum and NullableEnum catch only JsonException + ArgumentException (OverflowException derives from ArithmeticException, so catch(ArgumentException) does not cover it). Fix dispatched (add OverflowException catch → 400 in both builders + RED test).

@mforce

mforce commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

@codex please re-review against the current head d6eecc8. Your two findings (stale-selection P1, enum-overflow P2) are fixed in d6eecc8 with RED-capable tests (BulkUpdate_EnumValue_Overflows_Returns400 ×3, client stale-selection test) + both mutations reddened by the driver and restored green. Server 667/667, client 184/184, build clean. Appreciate a fresh verdict on this head.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d6eecc8c84

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

["description"] = BulkFieldBuilder.Text<Game>("description", (e, v) => e.Description = v),
["notes"] = BulkFieldBuilder.Text<Game>("notes", (e, v) => e.Notes = v),
["completionStatus"] = BulkFieldBuilder.Enum<Game, CompletionStatus>("completionStatus", (e, v) => e.CompletionStatus = v),
["hoursPlayed"] = BulkFieldBuilder.Scalar<Game, int>("hoursPlayed", (e, v) => e.HoursPlayed = v),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject negative bulk playtime

When an authenticated API client bulk-updates games with {"updates":{"hoursPlayed":-1}}, the generic scalar parser accepts and persists the negative value. Playtime is treated as non-negative elsewhere—the game form enforces min="0", and the adjacent watch/listen count bulk fields use the validating Count builder—so this route can introduce invalid collection data across many games at once. Use a nullable non-negative integer parser for hoursPlayed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified real: hoursPlayed uses Scalar<Game,int> (accepts negatives) while watchCount/listenCount use the Count builder that rejects negatives (BulkFieldBuilder.cs:202) and the game form enforces min=0. Bulk hoursPlayed:-1 persists invalid data. Fix dispatched (non-negative nullable parser for hoursPlayed + RED test).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hoursPlayed now non-negative in bulk (NonNegativeInt, RED/GREEN verified). Independent round-5 reviewer flagged the companion gap: the single-write PUT path (GamesEndpoints Validate/ApplyDto) still has no HoursPlayed<0 guard — a negative value is rejectable via bulk-PATCH but still persistable via create/update. Filing as a follow-up ticket for parity.

@mforce

mforce commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

@codex please re-review against current head d6f404d. Your negative-hoursPlayed finding is fixed via NonNegativeInt (null clears, negative 400, non-negative accepted); independent follow-up NullableEnum overflow path now has its own test. Driver-verified both mutations RED then restored GREEN; server 674/674. Please return a fresh verdict on this head.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: d6f404db31

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@mforce
mforce merged commit e80f3d8 into main Aug 23, 2026
10 checks passed
@mforce
mforce deleted the feat/89-bulk-update branch August 23, 2026 22:31
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.

Allow bulk update of movies, music and games

1 participant