Skip to content

fix(assets): honour the asset model's cover image on its assets - #2774

Open
carlosvirreira wants to merge 7 commits into
mainfrom
fix/asset-model-image-inheritance
Open

fix(assets): honour the asset model's cover image on its assets#2774
carlosvirreira wants to merge 7 commits into
mainfrom
fix/asset-model-image-inheritance

Conversation

@carlosvirreira

@carlosvirreira carlosvirreira commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Problem

AssetModel.image and AssetModel.imageExpiration shipped as columns with the
asset-models feature, but nothing ever read or wrote them:

  • the Asset Models settings form renders exactly four fields (Name, Description,
    Default category, Default valuation) — there is no way to upload an image;
  • modules/asset-model/service.server.ts contained the string image zero
    times, so the columns could never be populated by any user action;
  • an asset linked to a model still rendered /static/images/asset-placeholder.jpg.

Net effect: a workspace with 40 identical units of the same model has to pick and
upload the same photo 40 times, and store 40 copies of it. Reported from a user
trying the product out: "Would there be a way to have an asset's model determine
what image is used? It would help reduce having to upload and store the same
image over and over again."

Kit has the identical image / imageExpiration pair plus all five
plumbing pieces (multipart form, file input, clone-then-parse route, update
service, expired-URL re-sign helper). AssetModel got the two columns and none
of them.

Approach

Upload once on the model; every asset of that model that has no image of its own
points at that same single storage object.

The inheritance is applied where the link is made (createAsset, updateAsset)
and re-applied when the model's image changes. That means every surface which
already reads Asset.mainImage shows the model image with no change to those
call sites
— asset index (simple + advanced), asset detail, booking rows,
scanner drawers, reports, the booking/audit PDFs, the command palette, and the
companion app.

Storage holds one image plus one thumbnail per model, regardless of how many
assets share it. No migration: both columns already exist.

Alternatives considered

  • Render-time read-through fallback (leave Asset.mainImage null, resolve
    asset.mainImage ?? assetModel.image at render time). Conceptually purer, but
    asset images are rendered by six independent implementations (AssetImage,
    ImageWithPreview, the command palette's private getAssetImage, the audit
    receipt PDF's raw <img>, the reminder email, the booking PDF) plus ~11
    companion screens, over ~7 server select shapes — one of which is the
    advanced-index raw-SQL jsonb_build_object. Same user-visible result, an order
    of magnitude more blast radius, and it could not reach the companion app
    without a mobile release.
  • A dedicated asset-models bucket. Actively breaks the fix — see the bucket
    note below.
  • Copying the image bytes per asset. This is the thing the report explicitly
    asks us not to do. (The CSV import path already demonstrates the cost: it
    uploads one storage object per asset for the same source image.)

Changes

modules/asset-model/service.server.ts

  • updateAssetModelImage — mirrors updateKitImage: multipart parse → resize →
    108px thumbnail → sign → persist, then fans out to inheriting assets. No-ops
    when no file was submitted, so a plain Save keeps the current image.
  • propagateAssetModelImageToAssets — one updateMany re-pointing the model's
    inheriting assets at the new image + shared thumbnail.
  • clearInheritedAssetModelImages — called from deleteAssetModel and
    bulkDeleteAssetModels before the delete, while the links still exist to
    identify (Asset.assetModelId is ON DELETE SET NULL).
  • getInheritableAssetModelImage — narrow org-scoped read used by the asset
    create/update paths.
  • refreshExpiredAssetModelImages — mirrors refreshExpiredKitImages.
  • isAssetModelImageUrl — the ownership test (see below).

modules/asset/service.server.tscreateAsset inherits the linked model's
image when no image was supplied; updateAsset reconciles on link, and clears an
inherited image on unlink. bulkCreateAssetsFromModel needed no change: it
routes through createAsset, so all N bulk-created assets inherit via the same
path.

Settings routes + formencType="multipart/form-data", clone-then-parse in
both actions, an Image row on the form with the shared 8 MB/type file guard and
a preview in edit mode, and the model's thumbnail in the list row.

utils/storage.server.ts — extracted getThumbnailStoragePath so the upload
path and the lazy api+/asset.generate-thumbnail route derive the thumbnail path
from one helper instead of two copies of the same rule.

Two decisions worth reviewing

Model images live in the assets bucket, not a new one. Inheriting assets
store the model's URL in Asset.mainImage, and three existing paths resolve it
with extractStoragePath(url, "assets") (refreshExpiredAssetImages,
api+/asset.refresh-main-image, api+/asset.generate-thumbnail). A foreign
bucket makes those return null, so every inherited image would 404 after 72h
with no recovery.

Ownership is decided by storage path, not URL string. Model images live at
<userId>/asset-models/<assetModelId>/image-<unix>, per-asset images at
<userId>/<assetId>/main-image-<unix>. Signed URLs get re-signed per asset over
time (refreshExpiredAssetImages writes a fresh token onto the asset row), so an
exact-URL comparison would lose track of the inheritance — the path is the only
stable part. An image the user uploaded for a specific asset always wins and is
never overwritten. This is the single load-bearing invariant of the design, so
it's pinned by tests.

Verification

typecheck clean, lint 0 errors, react-doctor 0 new errors, 3688 unit tests
pass (+18 new: ownership test incl. the re-signed-URL case, propagation,
delete-cleanup ordering, and the reconcile-on-relink case).

Driven end to end in a browser against a real Supabase bucket:

  1. Create a model with an image → stored once at
    assets/<userId>/asset-models/<modelId>/image-<unix>.png, thumbnail beside it.
  2. Create two assets from that model, uploading no image → both render the
    model's picture on the asset index and the asset detail header. Both rows'
    mainImage resolve to one storage path (verified in the DB: 2 assets,
    1 distinct path).
  3. Give one asset its own image, then replace the model's image → the inheriting
    asset follows to the new picture; the one with its own image is untouched.
  4. Delete the model → the inherited image clears (no asset left showing the photo
    of a deleted model); the own-image asset keeps its picture.

Out of scope

  • The companion app needs no change and picks this up on the next web deploy,
    since it reads Asset.mainImage. Per .claude/rules/code-bearing-entity-list-consistency.md
    the companion is owned by another team; nothing here touches it.
  • CSV-imported models are still created by name only (no image); an image can
    be added afterwards on the model, which then propagates.
  • Storage objects are never deleted when an image is replaced or a model is
    removed. That matches existing behaviour for asset and kit images
    (deleteAssetImage has no callers today) — deliberately not changed here.

Summary by CodeRabbit

  • New Features

    • Added optional cover image support for asset models in both inline/dialog and full-page editors, including thumbnail/preview, multipart upload, and file validation (supported types + 8MB limit).
    • Asset model list rows now display an image thumbnail alongside the model name.
  • Bug Fixes

    • Improved cover-image inheritance/reconciliation for assets when linking, unlinking, or changing asset models.
    • Expired cover-image signed links are now refreshed automatically in asset-model views and updates, with safer cleanup behavior.
  • Chores

    • Standardized thumbnail storage naming/path generation for consistent preview behavior.

`AssetModel.image` / `AssetModel.imageExpiration` shipped as columns with the
asset-models feature but nothing ever read or wrote them: the settings form had
no image field, and an asset linked to a model still rendered the grey
placeholder. Users had to pick and upload the same photo once per asset.

Upload once on the model; every asset of that model that has no image of its
own points at that single storage object.

- `updateAssetModelImage` mirrors `updateKitImage` (multipart parse -> resize ->
  108px thumbnail -> sign -> persist) and fans the result out to inheriting
  assets. `refreshExpiredAssetModelImages` mirrors `refreshExpiredKitImages`.
- Model images live in the `assets` bucket on purpose. Inheriting assets store
  the model's URL in `Asset.mainImage`, and the existing re-sign / thumbnail
  paths resolve it with `extractStoragePath(url, "assets")` — a separate bucket
  would break every inherited image after 72h.
- Inheritance is applied where the link is made (`createAsset`, `updateAsset`)
  and re-applied when the model's image changes, so every surface that already
  reads `Asset.mainImage` — asset index, detail, booking rows, scanner, reports,
  PDFs, the companion app — shows it with no change to those call sites.
- Ownership is decided by storage path, not URL string: signed URLs get
  re-signed per asset over time, so an exact-URL match would lose track of the
  inheritance. An image the user uploaded for a specific asset always wins.
- Unlinking or deleting a model clears the images its assets inherited, so no
  asset is left showing the photo of a model it no longer belongs to.
- Extracted `getThumbnailStoragePath` so the upload path and the lazy
  `generate-thumbnail` route derive the same path from one helper.

Storage holds one image plus one thumbnail per model, no matter how many assets
share it. No migration — both columns already exist.

Reported in support chat: "Would there be a way to have an asset's model
determine what image is used? It would help reduce having to upload and store
the same image over and over again."
@github-actions

Copy link
Copy Markdown

🩺 React Doctor — webapp

✅ No new findings on the files changed by this PR.

Run locally with pnpm webapp:doctor for a full scan, or cd apps/webapp && pnpm exec react-doctor . --diff for the same diff-only view.

@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: af3b592def

ℹ️ 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 thread apps/webapp/app/modules/asset-model/service.server.ts Outdated
Comment thread apps/webapp/app/modules/asset-model/service.server.ts Outdated
Comment thread apps/webapp/app/routes/_layout+/settings.asset-models.new.tsx Outdated
Comment thread apps/webapp/app/modules/asset-model/service.server.ts
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Asset-model cover images now support multipart upload, signed URL refresh, previews, inheritance by linked assets, preservation of user-uploaded asset images, and cleanup before model deletion. Thumbnail path generation is centralized for upload and thumbnail-generation flows.

Changes

Asset-model image lifecycle

Layer / File(s) Summary
Shared thumbnail path handling
apps/webapp/app/utils/storage.server.ts, apps/webapp/app/routes/api+/asset.generate-thumbnail.ts
Thumbnail storage naming is centralized through getThumbnailStoragePath.
Model image storage and propagation
apps/webapp/app/modules/asset-model/service.server.ts
Model image uploads are parsed, stored, signed, propagated to inheriting assets, refreshed when expired, and protected against stale concurrent writes.
Asset inheritance reconciliation
apps/webapp/app/modules/asset/service.server.ts, apps/webapp/app/modules/asset/service.server.test.ts
Asset creation and updates inherit model images when applicable, preserve uploaded images, and clear inherited fields when links change.
Deletion cleanup and service validation
apps/webapp/app/modules/asset-model/service.server.ts, apps/webapp/app/modules/asset-model/service.server.test.ts
Deletion resolves exact model IDs, clears inherited asset images before deletion, and tests image ownership, propagation, refresh, and cleanup.
Upload form and route integration
apps/webapp/app/components/asset-model/form.tsx, apps/webapp/app/routes/_layout+/settings.asset-models.*.tsx
Forms accept validated multipart images, create/edit actions persist uploads, loaders refresh signed URLs, and list rows display previews.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Editor
  participant AssetModelRoute
  participant AssetModelService
  participant Storage
  participant Assets
  Editor->>AssetModelRoute: Submit multipart asset-model form
  AssetModelRoute->>AssetModelService: Parse and persist image
  AssetModelService->>Storage: Upload and sign image
  AssetModelService->>Assets: Propagate inherited image fields
  Assets-->>AssetModelService: Return guarded update count
  AssetModelRoute-->>Editor: Return refreshed asset-model data
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: donkoko

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: asset-model cover images now propagate to linked assets.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/asset-model-image-inheritance

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
apps/webapp/app/components/asset-model/form.tsx (1)

365-382: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Associate the "Accepts PNG, JPG…" hint with the file input.

The helper text is rendered as sibling <p> elements with no id/aria-describedby link, so screen-reader users never hear the format/size constraint. Also worth collapsing the duplicated lg/mobile copy into one element with responsive classes.

As per coding guidelines, UI implementations must satisfy WCAG 2.1 AA requirements, including aria-describedby for helper/error text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/components/asset-model/form.tsx` around lines 365 - 382,
Update the file input in the form component around validateFile to reference a
uniquely identified helper-text element through aria-describedby, and assign
that id to a single shared format/size hint. Replace the duplicated
desktop/mobile <p> elements with one responsive element while preserving their
visibility behavior and ensure the existing imageError remains associated as
needed.

Source: Coding guidelines

apps/webapp/app/modules/asset/service.server.ts (1)

1484-1505: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test coverage for createAsset's new image-inheritance branch.

The updateAsset asset-model cover image suite added in apps/webapp/app/modules/asset/service.server.test.ts (lines 1405-1572) only exercises updateAsset. There's no equivalent case for createAsset stamping mainImage/mainImageExpiration/thumbnailImage from getInheritableAssetModelImage when a new asset is linked to a model at creation time. The PR objectives explicitly call out "tests covering inheritance, propagation, deletion cleanup, relinking" — the creation-time inheritance path is the one gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/modules/asset/service.server.ts` around lines 1484 - 1505,
Add coverage in the asset service tests for createAsset’s image-inheritance
branch: mock getInheritableAssetModelImage to return inherited image fields,
create an asset linked to the model without an explicit mainImage, and assert
mainImage, mainImageExpiration, and thumbnailImage are persisted from the
inherited result. Keep the explicit-mainImage precedence behavior covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/webapp/app/components/asset-model/form.tsx`:
- Around line 356-364: Add the corresponding imageUrl prop to both
ImageWithPreview call sites: use assetModel.image in
apps/webapp/app/components/asset-model/form.tsx lines 356-364 and item.image in
apps/webapp/app/routes/_layout+/settings.asset-models.index.tsx lines 167-174,
alongside each existing thumbnailUrl prop.

In `@apps/webapp/app/modules/asset-model/service.server.ts`:
- Around line 288-292: The inherited asset model response should assign a
72-hour expiration when assetModel.imageExpiration is null or missing. Update
the return mapping near signAssetModelThumbnail so imageExpiration uses the
existing value when present, otherwise derives a new expiration 72 hours from
the current time, ensuring legacy images become refreshable.

In `@apps/webapp/app/modules/asset/service.server.ts`:
- Around line 2230-2254: Gate the inherited-image unlink logic around the
visible currentAsset lookup and mainImage reset, and the corresponding
relink/reconciliation path, on whether the model association changed:
previousAsset.assetModelId !== assetModelId. Preserve reconciliation when
linking or unlinking a different model, but skip both paths when metadata saves
retain the same assetModelId to avoid re-fetching or re-signing the image.

---

Nitpick comments:
In `@apps/webapp/app/components/asset-model/form.tsx`:
- Around line 365-382: Update the file input in the form component around
validateFile to reference a uniquely identified helper-text element through
aria-describedby, and assign that id to a single shared format/size hint.
Replace the duplicated desktop/mobile <p> elements with one responsive element
while preserving their visibility behavior and ensure the existing imageError
remains associated as needed.

In `@apps/webapp/app/modules/asset/service.server.ts`:
- Around line 1484-1505: Add coverage in the asset service tests for
createAsset’s image-inheritance branch: mock getInheritableAssetModelImage to
return inherited image fields, create an asset linked to the model without an
explicit mainImage, and assert mainImage, mainImageExpiration, and
thumbnailImage are persisted from the inherited result. Keep the
explicit-mainImage precedence behavior covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bb7a3147-74e9-4fcf-80f2-b16494d36ce1

📥 Commits

Reviewing files that changed from the base of the PR and between ddb607c and af3b592.

📒 Files selected for processing (10)
  • apps/webapp/app/components/asset-model/form.tsx
  • apps/webapp/app/modules/asset-model/service.server.test.ts
  • apps/webapp/app/modules/asset-model/service.server.ts
  • apps/webapp/app/modules/asset/service.server.test.ts
  • apps/webapp/app/modules/asset/service.server.ts
  • apps/webapp/app/routes/_layout+/settings.asset-models.$assetModelId_.edit.tsx
  • apps/webapp/app/routes/_layout+/settings.asset-models.index.tsx
  • apps/webapp/app/routes/_layout+/settings.asset-models.new.tsx
  • apps/webapp/app/routes/api+/asset.generate-thumbnail.ts
  • apps/webapp/app/utils/storage.server.ts

Comment thread apps/webapp/app/components/asset-model/form.tsx
Comment thread apps/webapp/app/modules/asset-model/service.server.ts
Comment thread apps/webapp/app/modules/asset/service.server.ts
Addresses the Codex review on #2774.

- `propagateAssetModelImageToAssets` and `clearInheritedAssetModelImages` decided
  ownership in application code against a prior read, then wrote by id. An image
  uploaded for a specific asset in that gap was overwritten by the model cover,
  breaking the per-asset-image-wins contract. Both now carry the observed
  `mainImage` in each predicate (grouped by value, so the common case is still a
  single query), the same optimistic-concurrency shape
  `refreshExpiredAssetImages` uses.
- `refreshExpiredAssetModelImages` re-signed the expired URL and wrote it back
  guarded only by model + organization, so it could resurrect a superseded cover
  over one that had just been replaced and propagated. Now guarded on the image
  it read; a zero-count update is discarded and the next load re-reads.
- A failure in the image step of asset-model creation left the model row
  committed while the action reported an error, so the user retried and got a
  duplicate. The row is now rolled back before the error surfaces.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
apps/webapp/app/routes/_layout+/settings.asset-models.new.tsx (1)

113-127: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log the swallowed rollback failure.

If deleteAssetModel also fails, the orphaned model row disappears silently with no trace to reconcile later. A Logger.error in the catch keeps the original error as the thrown one while leaving a breadcrumb.

🔍 Proposed change
     } catch (cause) {
-      await deleteAssetModel({ id: assetModel.id, organizationId }).catch(
-        () => {
-          /* Best-effort rollback; the original failure is what matters. */
-        }
-      );
+      await deleteAssetModel({ id: assetModel.id, organizationId }).catch(
+        (rollbackCause) => {
+          /* Best-effort rollback; the original failure is what matters. */
+          Logger.error(
+            makeShelfError(rollbackCause, {
+              userId,
+              assetModelId: assetModel.id,
+              message:
+                "Failed to roll back asset model after image processing error",
+            })
+          );
+        }
+      );
       throw cause;
     }

Add the import:

-import { makeShelfError } from "~/utils/error";
+import { makeShelfError } from "~/utils/error";
+import { Logger } from "~/utils/logger";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/routes/_layout`+/settings.asset-models.new.tsx around lines
113 - 127, Update the rollback catch around updateAssetModelImage to log
failures from deleteAssetModel with Logger.error before swallowing them, while
preserving the original cause as the error rethrown by the outer catch.
apps/webapp/app/modules/asset-model/service.server.ts (1)

559-579: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Unbounded query fan-out when observed images differ per row.

Grouping collapses the common cases, but per-asset re-signed URLs (each asset gets its own signed token from refreshExpiredAssetImages) mean the map can degenerate to one group per asset — thousands of concurrent updateMany calls in a single Promise.all, which can saturate the Prisma connection pool. Consider capping concurrency (or chunking the entries) so the fan-out stays bounded.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/modules/asset-model/service.server.ts` around lines 559 -
579, The idsByObservedImage update flow currently launches one updateMany call
per observed image without a concurrency limit. Replace the unbounded
Promise.all around idsByObservedImage.entries() with bounded concurrency or
chunked processing, preserving each updateMany filter and the summed result
count while ensuring only a fixed number of database updates run at once.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/webapp/app/modules/asset-model/service.server.ts`:
- Around line 559-579: The idsByObservedImage update flow currently launches one
updateMany call per observed image without a concurrency limit. Replace the
unbounded Promise.all around idsByObservedImage.entries() with bounded
concurrency or chunked processing, preserving each updateMany filter and the
summed result count while ensuring only a fixed number of database updates run
at once.

In `@apps/webapp/app/routes/_layout`+/settings.asset-models.new.tsx:
- Around line 113-127: Update the rollback catch around updateAssetModelImage to
log failures from deleteAssetModel with Logger.error before swallowing them,
while preserving the original cause as the error rethrown by the outer catch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0fb9dda6-af9b-4560-bf34-5ca69a44dc80

📥 Commits

Reviewing files that changed from the base of the PR and between af3b592 and 881821f.

📒 Files selected for processing (3)
  • apps/webapp/app/modules/asset-model/service.server.test.ts
  • apps/webapp/app/modules/asset-model/service.server.ts
  • apps/webapp/app/routes/_layout+/settings.asset-models.new.tsx

Addresses the CodeRabbit review on #2774.

- `ImageWithPreview` was rendered with `withPreview` but no `imageUrl` on the
  model form and the models list. `withPreview` adds role="button", tabIndex and
  an "Open preview for …" label unconditionally, while the open handler no-ops
  without a full-size URL — both surfaces exposed a focusable control that did
  nothing. Pass `imageUrl` at both call sites.
- The asset edit route resends the current `assetModelId` on every save, so an
  unrelated metadata edit re-read the model and re-signed its thumbnail each
  time. Both reconcile paths are now gated on the link actually changing; a model
  whose image changed is already propagated to its assets.
- An asset-model image with no expiration would have been copied onto inheriting
  assets as `mainImageExpiration: null`, which `refreshExpiredAssetImages` skips,
  so the URL would lapse and never be re-signed. Treat an unknown expiration as
  already elapsed instead, which self-heals on the next read.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/webapp/app/modules/asset/service.server.test.ts`:
- Around line 1577-1579: Add a concise // why: comment directly above the
db.asset.findUnique mock in the “does not touch the image when a save keeps the
same model” test, explaining that it simulates the persisted inherited-image
state required for this update scenario.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e80519b4-0b15-4170-a69d-e3c51fdb7caf

📥 Commits

Reviewing files that changed from the base of the PR and between 881821f and f615666.

📒 Files selected for processing (5)
  • apps/webapp/app/components/asset-model/form.tsx
  • apps/webapp/app/modules/asset-model/service.server.ts
  • apps/webapp/app/modules/asset/service.server.test.ts
  • apps/webapp/app/modules/asset/service.server.ts
  • apps/webapp/app/routes/_layout+/settings.asset-models.index.tsx

Comment thread apps/webapp/app/modules/asset/service.server.test.ts
Every mock needs a `// why:` per the repo's testing conventions. Each override in
this suite encodes a different starting state (own image vs inherited, linked vs
unlinked), so the comments say which case the fixture is standing in for rather
than repeating that the database is mocked.
@carlosvirreira

Copy link
Copy Markdown
Contributor Author

Bot review round — summary for whoever reviews this

Codex and CodeRabbit raised 8 findings. Six were real and are fixed, one is a
convention nit that is fixed, and one I declined. Every thread has a reply,
but they are all resolved and therefore collapsed, so here is the round in one
place. The declined item is the only thing that needs a human call.

# Source Finding Outcome
1 Codex P1 propagateAssetModelImageToAssets / clearInheritedAssetModelImages decided ownership against a prior read, then wrote by id — an image uploaded in that gap was clobbered Fixed 881821f — observed mainImage now in each predicate, grouped by value
2 Codex P1 refreshExpiredAssetModelImages could resurrect a superseded cover over one just replaced and propagated Fixed 881821f — guarded on the image it read, zero-count discarded
3 Codex P2 A failed image step left the model row committed while the action reported an error → retry produced a duplicate Fixed 881821f — row rolled back before the error surfaces
4 Codex P2 Superseded model image objects are never deleted from storage Declined — see below
5 CodeRabbit ImageWithPreview rendered with withPreview but no imageUrl: a focusable, labelled control that did nothing (WCAG) Fixed f615666 — both call sites
6 CodeRabbit The edit route resends the current assetModelId on every save, so unrelated edits re-read the model and re-signed its thumbnail Fixed f615666 — both reconcile paths gated on the link changing
7 CodeRabbit A model image with no expiration would be copied as mainImageExpiration: null, which the refresh sweep skips Fixed f615666 — treated as already elapsed, self-heals
8 CodeRabbit Missing // why: on a test mock Fixed c1aaded — applied across the suite

The one I declined (#4), and why

Codex asked for the previous image and thumbnail to be deleted from storage when
a model's cover is replaced or the model is deleted. I did not do it here:

  • Nothing in Shelf deletes image objects today. deleteAssetImage exists in
    utils/storage.server with zero callers. Replacing an asset's main image via
    updateAssetMainImage leaves the old object; updateKitImage does the same
    for kits; deleting an asset or kit does not remove its image either. Adding
    cleanup for asset-model images alone would make one entity behave differently
    from every sibling, which is worse than a consistent leak.
  • It is the riskiest place to start. Inheriting assets point at the model's
    exact storage object, so deleting a superseded file turns a missed propagation
    into a broken image on N assets instead of a stale-but-working one. A leaked
    file is recoverable; a deleted file that rows still reference is not.

The right shape is one cleanup pass covering assets, kits and models together,
after the propagation ordering is guaranteed. Happy to file that as its own
issue, or to do it here if you'd rather it ship together — your call.

Verification after the fixes

typecheck, lint, react-doctor and 3688 unit tests green (+22 new). The two
paths whose logic changed were re-driven in a browser against a real Supabase
bucket: inherit-on-create still stamps image + thumbnail from one stored object,
and relinking an inheriting asset to a model with no image still clears the old
photo.

The image field only reached the full-page settings form. `AssetModelForm` has a
second, compact branch used by the "Create new asset model" shortcut inside the
asset form's model picker, and that branch had no file input and no multipart
encoding — so a model created from the asset form (the most discoverable path)
could never get an image, and its assets had nothing to inherit. Both halves of
the original report reproduce from that one gap.

Both branches now carry the same field, the same shared 8 MB/type file guard and
the same server-error surface. The dialog already posted to
`/settings/asset-models/new`, which handles the upload, so only the form needed
changing.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/webapp/app/components/asset-model/form.tsx (1)

112-127: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared image-error resolution.

Inline and full-page modes duplicate the “server image error first, otherwise client file error” logic. Move this into a focused helper so the two modes cannot drift.

As per coding guidelines, duplicated code patterns across files or functions should be extracted into focused reusable helpers.

Also applies to: 261-283

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/components/asset-model/form.tsx` around lines 112 - 127,
Extract the duplicated image-error resolution into a focused reusable helper,
using the existing server error data and fileError inputs. The helper should
return the server error when additionalData.field is "image", otherwise return
the client-side file error, and both inline and full-page modes should use it
instead of implementing their own fallback logic.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/webapp/app/components/asset-model/form.tsx`:
- Around line 112-127: Scope the client-side file validation error used by the
compact model form to its own upload instance instead of reading the
module-scoped fileErrorAtom directly. Update the validator and error wiring
around assetImageValidateFileAtom, inlineImageError, and the imageError usage so
sibling asset or kit inputs cannot set or clear this form’s error; preserve the
existing server-side image error handling.
- Around line 171-188: Update the image Input in the asset-model form to provide
an aria-describedby relationship covering both the helper text and any error
description generated internally by Input. Assign a stable id to the
requirements paragraph and merge it with the existing Input error-description id
without replacing either reference.

---

Nitpick comments:
In `@apps/webapp/app/components/asset-model/form.tsx`:
- Around line 112-127: Extract the duplicated image-error resolution into a
focused reusable helper, using the existing server error data and fileError
inputs. The helper should return the server error when additionalData.field is
"image", otherwise return the client-side file error, and both inline and
full-page modes should use it instead of implementing their own fallback logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a635cf21-0ef8-4177-b8bd-3daaffe4f8f2

📥 Commits

Reviewing files that changed from the base of the PR and between c1aaded and 28ec0d9.

📒 Files selected for processing (1)
  • apps/webapp/app/components/asset-model/form.tsx

Comment thread apps/webapp/app/components/asset-model/form.tsx Outdated
Comment thread apps/webapp/app/components/asset-model/form.tsx
Addresses the CodeRabbit review on the inline dialog.

- `fileErrorAtom` is module-scoped, and the create-model dialog opens inside the
  asset form, so rejecting a file in one input surfaced the error on the other
  and picking a valid file cleared an error the user still needed. The rule is
  extracted to a pure `validateSelectedFile`, and asset-model images now use a
  scoped error atom built by `createScopedValidateFile`. The shared factory and
  its three existing consumers keep the current behaviour.
- The image inputs had no relationship to the text stating accepted formats and
  size, so assistive tech never announced it. Both inputs now carry
  `aria-describedby`; the full-page form duplicates the text per breakpoint, so
  it references both ids (the display:none copy is out of the a11y tree).
@carlosvirreira

Copy link
Copy Markdown
Contributor Author

Update — the entry point that was actually broken

The image field only ever reached the full-page settings form. AssetModelForm has a second, compact branch behind the "Create new asset model" shortcut inside the asset form's model picker, and that branch had no file input and no multipart/form-data. A model created from the asset form — the most discoverable path — could therefore never get an image, and its assets had nothing to inherit. Both halves of the original report ("upload not showing on the model", "not being propagated") reproduce from that single gap; propagation was working, it just had nothing to propagate.

Fixed in 28ec0d91, then 87fe98f1 for the two findings that surfaced on it:

  • Shared file-error state. fileErrorAtom is module-scoped and this dialog opens inside the asset form, so the two image inputs shared one error slot. The validation rule is now a pure validateSelectedFile, with a createScopedValidateFile factory that pairs a validator with its own error atom. The three existing consumers (asset, kit, audit) keep the shared atom and are unchanged.
  • Unassociated helper text. Both image inputs now carry aria-describedby. The full-page form duplicates that text per breakpoint, so it references both ids — the display:none copy is out of the accessibility tree.

Browser-verified end to end on the dialog path: create a model there with an image → an asset created in the same flow with no upload of its own inherits the image and the shared thumbnail and renders in the asset list; and rejecting a file in one input no longer touches the other.

Total review round: 10 threads, 0 open. Nine fixed, one declined with reasoning (deleting superseded storage objects — see the earlier summary comment). All 8 checks green.

@DonKoko DonKoko added the fix label Aug 6, 2026
@carlosvirreira
carlosvirreira requested a review from DonKoko August 7, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants