Skip to content

feat: per-user favorites — asset_favorite overlay, viewer hearts, cross-scope filters (#763) - #819

Open
Deeds67 wants to merge 67 commits into
mainfrom
worktree-fix-763-space-member-favorite
Open

feat: per-user favorites — asset_favorite overlay, viewer hearts, cross-scope filters (#763)#819
Deeds67 wants to merge 67 commits into
mainfrom
worktree-fix-763-space-member-favorite

Conversation

@Deeds67

@Deeds67 Deeds67 commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Closes #763.

What

Favoriting becomes a per-user fact — a (userId, assetId) overlay table (asset_favorite) replaces the global asset.isFavorite boolean, which is dropped. Any user who can read an asset can favorite it (space viewers included); nobody's favorite is visible to, or mutable by, anyone else. Design: docs/superpowers/specs/2026-07-20-per-user-favorites-design.md.

Highlights per layer

  • Schema (slice 0): asset_favorite + asset_favorite_audit (delete tombstones), owner-backfilled from the old column; revert-to-immich.sql gains its first column-restore step (non-owner rows are intentionally discarded on revert — documented).
  • Server reads (slices 1/1b): every ownership-masking expression became a favoriteExistsFor(caller) overlay join; AssetResponseDto shape unchanged.
  • Writes (slice 2): canonical PUT /assets/favorites gated on Permission.AssetRead, explicit shared-link rejection; the old isFavorite DTO fields remain as a strictly-narrower deprecated alias into the same service method.
  • Secondary writes (slice 7): upload/copy write overlay rows; duplicate-merge does a per-user union before sources are trashed.
  • Cross-scope (slice 4): the isFavorite × withSharedSpaces/withPartners 400-guards are gone (favorite arm only — archive/trash guards stay); favorited stack children surface standalone (spec §5.4); map + filtered-map compose. Perf budget on 10 spaces / 10k cross-owner favorites: buckets ~45-70ms, first bucket ~0.9-1.9s vs 5000ms ceiling (favorite-cross-scope.spec.ts).
  • Web (slice 5): the heart is un-gated from ownership everywhere (viewer menu + f shortcut, multi-select bar, cmdk, 7 route surfaces) with an explicit shared-link guard; writes go to the canonical endpoint; Playwright proves the literal [bug] Favorite action missing from photo action menu for non admin Space members #763 scenario.
  • Mobile (slice 6): all sync streams emit the recipient-resolved favorite; asset_favorite is its own synced entity (AssetFavoritesV1, upserts + tombstones — favorite writes never bump asset.updateId, so no cross-member re-sync amplification); the heart un-gates with the E32 mixed-selection direction bug fixed; both stream request AND write path are version-gated (> 5.2.0) with a legacy-endpoint fallback so app↔server skew degrades gracefully.
  • Drop (slice 3, last): column dropped; plugin-facing workflowAssetV1.isFavorite and job websocket payloads resolve the owner's overlay (fixing a latent staleness bug found in review); a grep-gate unit test permanently forbids raw column references.

Review posture

Every task passed an independent spec+quality review; slices 4/6 and the whole branch got additional cross-cutting reviews (RBAC leak analysis, sync two-device story, version-skew matrix, §10 acceptance sweep). Full gates at HEAD: server unit 5107, medium 543 (incl. 65-file sync suite), e2e API 339 across 9 files, web 3669 + tsc/lint, mobile 2824 + dart analyze --fatal-infos, full lint/prettier sweeps clean.

Decisions worth reviewer attention

  • /favorites named web route unchanged: it is archive-inclusive by design (default visibility), while withSharedSpaces requires explicit visibility=timeline (retained guard) — wiring spaces in would silently drop archived favorites. Cross-scope favorites surface via the photos-page favorite filter, search, and map. Follow-up decision documented in the slice-4 plan.
  • Revert is lossy for non-owner favorites (plain Immich has nowhere to put them) — stated in the script and switch-back guide.
  • Sibling actions (archive, rating) keep the old owner-gated model; the overlay pattern is documented for them (spec §11.1).

Open item before merge

  • Spec §10.13: manual verification on a real space with a viewer-role member (deploy an RC and click the heart).

@github-actions github-actions Bot added documentation Improvements or additions to documentation 🗄️server 🖥️web 📱mobile labels Jul 20, 2026
@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Jul 20, 2026
@Deeds67
Deeds67 force-pushed the worktree-fix-763-space-member-favorite branch 2 times, most recently from 927665b to 06e9601 Compare July 22, 2026 18:14
@Deeds67
Deeds67 force-pushed the worktree-fix-763-space-member-favorite branch from 97ccb12 to 9f81e09 Compare July 30, 2026 07:30
Deeds67 added 21 commits July 30, 2026 10:26
- §5.1: shared-link rationale was wrong (auth.user is non-optional); requires
  an explicit auth.sharedLink guard or a link visitor writes as the link owner
- §4.2: revert-to-immich.spec.ts filters asset_favorite out; slice 0 must widen
  the filter or the table ships with zero revert coverage
- Slice 4: removing the guard alone is inert — timeline.service.ts:164 owner-scoping
  must be replaced with readable-asset scoping
- §9: slices are review units within one PR, not independently shippable
- Slice 1: no longer claims to be a pure refactor
- Add E16b, E27-E32 (isFavorite:false, concurrency, bulk limit, album/partner
  revocation, stacks, mobile toggle direction)
- Slice 6: favorite.action.dart:11 computes direction over the unfiltered selection
- §10: performance budget, shared-link and cross-scope criteria
Slice-0 planning refuted §4's no-audit-table assumption. Sync streams page on
updateId (sync.repository.ts:136-143); a favorite write that touches only
asset_favorite never advances asset.updateId, so the change never reaches
mobile via incremental sync — and a full resync masks it.

Rejected bumping asset.updateId: a viewer favoriting would mutate the owner's
row and re-sync it to every member. Chosen: asset_favorite is its own synced
entity with createId/updateId + asset_favorite_audit, mirroring
album_space_asset. Both tables land in slice 0, before the irreversible drop.
E10's leak test cannot go red in slice 1: isFavorite + withSharedSpaces is a
hard 400 (timeline.service.ts:177-200) and plain /favorites is owner-scoped
(asset.repository.ts:421-423), so no non-owned favorite can appear in any
listing until slice 4 removes both. No access filter in slice 1.
Threads an explicit authUserId (the caller, distinct from userIds — the
timeline target, which differs on space/album browse paths) through
TimeBucketOptions so withTimeBucketAssetFilters, getTimeBucket's masked
select, and getStatistics resolve isFavorite against the asset_favorite
overlay instead of the ownership-masked asset.isFavorite column.

mapAsset (asset-response.dto.ts:228) is deliberately NOT changed here —
see report to caller for why (B finding): virtually every query feeding
it (getById, getByIds, update, search/map, ...) still selects the raw
column, so flipping the mapper now would leak raw isFavorite to non-
owners on those paths. Left for a follow-up that also migrates those
queries.

Also dedupes a repeated asset.isFavorite entry in database.ts's
workflowAssetV1 column list.
…rlay (#763)

Threads an explicit authUserId through searchAssetBuilder, buildFilteredAssetIds
and getFilteredMapMarkers rather than reusing userIds[0], which is the timeline
target and not the caller on space/album paths.

SQL snapshots are NOT regenerated here — see follow-up.
… filters (#763)

Follow-up to the previous commit's note ("SQL snapshots are NOT
regenerated here"). Running the SQL generator against a freshly
migrated DB surfaced a real bug it caught: searchMetadata,
searchStatistics, searchRandom, searchLargeAssets and searchSmart's
@GenerateSql fixtures set isFavorite: true without an authUserId,
so favoriteExistsFor's asUuid(undefined) crashed the postgres.js
driver (UNDEFINED_VALUE) instead of producing a query to capture.
Added authUserId: DummyValue.UUID to each fixture and regenerated
src/queries/*.sql.

asset.repository.sql also picks up the previous slice's
(#763 timeline reads) already-landed EXISTS substitution, whose
snapshot was left stale. search.repository.sql now reflects the
same asset.isFavorite -> EXISTS (asset_favorite ...) substitution
for the search/facet paths. map.repository.sql and
shared.space.repository.sql are unchanged, as expected: their
@GenerateSql fixtures never set isFavorite, so the branch isn't
exercised by the snapshot generator (verified by direct medium
tests instead).
Threads the caller's id (authUserId) into every auth-bearing query that
feeds mapAsset/mapMemory/mapStack, projecting favoriteExistsFor(...) as
isFavoriteForUser so GET /assets/:id, search, memories, stacks, and the
folder view resolve isFavorite from the per-user asset_favorite overlay
instead of the fail-safe false left by slice 1b Task 1.

Four call sites are deliberately left unprojected (mapSharedLink, the
background job/notification websocket pushes, and duplicate resolution)
with comments explaining why, so they aren't "fixed" back into a leak.
addAll/removeAll write the asset_favorite overlay for a given user, mirroring
the tag.repository.ts bulk-array idiom (Chunked + onConflict doNothing).
Registers the repository in BaseService's three positional sites and the
medium/unit test factories (test/medium.factory.ts, test/utils.ts) that
construct BaseService positionally.
…te alias (#763)

Canonical per-user favorite write path gated on Permission.AssetRead (space
viewers included), with an explicit auth.sharedLink rejection so an
anonymous share-link visitor can never write a favorite attributed to the
link owner. The existing isFavorite field on PUT /assets/:id and PUT /assets
becomes a deprecated alias that routes into the same AssetService.updateFavorites
method, behind its own stricter Permission.AssetUpdate guard so it can never
be more permissive than the canonical endpoint.

Also fixes a boot-blocking gap from the prior commit: AssetFavoriteRepository
was wired into BaseService but never registered as a real Nest provider in
src/repositories/index.ts, so the server threw UnknownDependenciesException
on startup. Threads the per-user favorite overlay through the workflow/plugin
engine's AssetV1 read (workflow-execution.service.ts), which previously read
the legacy asset.isFavorite column and would have made an assetFavorite
plugin toggle step never converge.

Inverts a bug-encoding e2e assertion in shared-space-album.e2e-spec.ts: a
space editor's bulk-favorite of another member's asset now only creates the
editor's own row, leaving the asset owner's favorite state untouched.
Upload (asset-media.service.ts:352), copy (asset.service.ts:557) and
duplicate-merge (duplicate.service.ts:308) still write/read asset.isFavorite.
Slice 3 drops that column, so the original 3-before-7 ordering would have
broken the build at the one irreversible step.
Deeds67 added 24 commits July 30, 2026 10:27
Catches up overlay-projection snapshots that slices 1/1b/7 changed without
regenerating (asset, memory, search, stack, view isFavoriteForUser blocks),
converts the sync stream blocks to recipient-resolved overlay joins (slice 6
task 1), adds the new SyncRepository.assetFavorite.getUpserts/getDeletes
blocks (task 2), and renames asset-favorite.repository.sql to the
generator-canonical asset.favorite.repository.sql (content identical).
…erlay (#763)

job.service.ts's AssetEditReadyV2 / AssetUploadReadyV2 payloads mapped
isFavorite off the raw asset.isFavorite column via assetRepository.getById
/ getByIdsWithAllRelationsButStacks called with no authUserId -- a column
the overlay write path has stopped updating since slice 2, so these two
owner-scoped events went stale. Both repository methods now project
isFavorite unconditionally via favoriteExistsForOwner, restoring the raw
column's exact pre-#763 owner semantics for every consumer (only
job.service.ts reads .isFavorite directly off these methods; every other
consumer routes through mapAsset's isFavoriteForUser instead).
… the single source of truth (#763)

asset_favorite has been the write path since slice 1 and the read path
(mapAsset's isFavoriteForUser, the plugin-facing workflowAssetV1 projection,
the owner-scoped job.service.ts websocket payloads) since slice 1b/2. This
removes the column it replaced: a new migration
(1784100000000-DropAssetIsFavoriteColumn) drops it, with a down() that
restores it and backfills from the overlay for already-deployed DBs and the
revert-to-immich path.

Fallout from the field removal, fixed by overlay conversion or dead-code
deletion (never by re-adding the field):

- MapAsset.isFavorite (dtos/asset-response.dto.ts) deleted outright — dead
  since mapAsset already reads isFavoriteForUser exclusively.
- Test factories/fixtures that seeded the raw column (test/factories/asset.factory.ts,
  test/medium.factory.ts assetInsert, test/mappers.ts getForAsset) switch to
  asset_favorite inserts or a synthesized isFavorite default matching the new
  unconditional repository projection.
- Sync-spec "bump asset.updateId" helpers that repurposed a raw-column UPDATE
  as a no-op metadata change now touch originalFileName instead.
- asset-favorite.repository.spec.ts's two tests exercising the slice-0
  migration's backfill SQL against a live schema are removed — that schema
  no longer exists to run them against; the migration file's own historical
  correctness is untouched.
- scripts/revert-to-immich.sql step 8 lists the new migration name
  (revert-to-immich.spec.ts's step-8 test went red on the bare migration file
  before this, confirming the gap).
The revert-validation workflow runs the script against the released main
image, whose schema predates the asset_favorite overlay — the unguarded
UPDATE ... FROM asset_favorite aborted the transaction there. to_regclass
guard skips the backfill when the overlay never existed (the raw column
still carries the favorites in that state).
…763)

The web writes favorites via the canonical per-user endpoint since slice 5;
the ui-project mocks still intercepted the old bulk PUT /assets route, so
the awaited favorite request never matched and the three favorite flows
timed out in CI.
#763)

Rebase fallout from #813. That PR added `recently-added-filter-options`,
whose spec pinned the pre-#763 rule that a Favorites filter drops the
partner scope (a favorite used to be the owner's flag on the asset, so a
partner's favorites meant nothing to the viewer).

#763 makes favorites per-user via the `asset_favorite` overlay, so
`buildPhotosTimelineOptions` no longer strips `withPartners` /
`withSharedSpaces` when filtering by favorite — the viewer's favorites on
partner assets are their own and must stay in scope. Recently Added still
strips `withSharedSpaces` unconditionally (its own+partner invariant),
so only the partner expectation changes.

Updates the assertion plus the two now-stale comments describing the old
scoping (the module doc and the e2e scope-invariant note).
…troduce drift (#763)

Migration 1784000000000 creates the asset_favorite_delete_audit function, its
statement-level AFTER DELETE trigger, and both migration_overrides rows — but
nothing declared them in functions.ts or on AssetFavoriteTable. That is exactly
the drift class main just eliminated in #827 for album_space_asset_delete_audit:
getSchemaDrift runs with `functions: { ignoreExtra: false }`, so a function that
exists only in the database reports FunctionDrop plus two OverrideDrops, every
instance boots with "Detected schema drift", and `migrations:generate` against a
real database emits DROP TRIGGER + DROP FUNCTION — which executes cleanly and
would silently remove the favorite delete sync stream.

CI cannot catch this: SQL Schema Checks runs `schema:reset` (rebuilding the DB
from code) before `migrations:generate`, so a migration-vs-code divergence is
invisible there. Verified with the real generator that schemaFromCode({ overrides:
true }) emitted zero asset_favorite artefacts before this change.

Declares both halves byte-identically to the migration's DDL, mirroring
AlbumSpaceAssetTable, and extends the two parity specs with the asset_favorite
cases so the decorator/function can't rot away from the migration again.
…ine (#763)

Two fallouts from rebasing onto main, both in tests:

- selection-capabilities.spec.ts: the E1 space-viewer case asserts the full
  capability object via an ALL_FALSE spread, so it needed canFavorite: true
  explicitly now that favoriting is ownership-agnostic.

- search.repository.spec.ts (medium): main's #858 filter-narrowing tests seeded
  favorites with `ctx.newAsset({ isFavorite: true })`. That column is dropped in
  this branch, and git auto-merged both sides with no conflict, so the breakage
  only surfaced in tsc. Reseeded through the asset_favorite overlay, matching the
  pattern the other medium specs already use.
v3.1.0 introduced three new reads of the `asset.isFavorite` column this
branch drops, plus two toolbar assertions written against the ownership
gate #763 removes.

- `columns.searchAsset` (database.ts, new upstream explicit column list
  replacing `selectAll('asset')`) listed `asset.isFavorite`. Removed, with
  the same note as `syncAsset` / `workflowAssetV1`: the five consumers
  project the per-user overlay separately via
  `favoriteExistsFor(eb, options.authUserId)`.
- The dormant Search V3 `branchPredicates` compared `asset.isFavorite`
  directly. Ported onto `favoriteExistsFor`, resolved for the CALLER via a
  new optional `AssetSearchBuilderV3Options.authUserId` mirroring the legacy
  builder. V3 stays unwired; whoever wires it must thread the id.
- SQL snapshots: dropped the `asset.isFavorite` select entry from the 25
  affected generated select lists, replaced the three V3 `isFavorite`
  predicates with the overlay EXISTS form (param positions unchanged), and
  regenerated the stale `getAssetsByCity (with authUserId)` section that
  still carried the pre-v3.1.0 `asset.*`. All 30 touched sections verified
  byte-identical against an offline Kysely compile of the same builders.
- Flipped the two `space-album-detail-page.spec.ts` and the one
  `spaces-selection-toolbar-person.e2e-spec.ts` Favorite-absence assertions
  that v3.1.0 still gates on ownership, matching the already-flipped
  -album/-timeline siblings: `canFavorite` is unconditional, so a non-owned
  selection — including a read-only space viewer's — keeps the heart.
…s enum shape (#763)

Two v3.1.0 renames the rebase auto-merged around:

- openapi-generator v7.24 emits `SyncRequestType` as a real Dart enum, so
  the M14 capability filter reads `type.toJson()`, not `type.value`. The
  `_spaceAlbumSyncTypes` sibling line auto-merged onto the new form; the
  `_assetFavoriteSyncTypes` line added by #763 did not, and `.value` no
  longer exists — `flutter test` failed to compile five files. tsc/dart
  analyze on the pre-fix tree caught it only via the failing compile.
- `FavoriteAction.shouldFavorite` was renamed to `favorite` upstream;
  `favorite_action_test.dart` still read the old name.
@Deeds67
Deeds67 force-pushed the worktree-fix-763-space-member-favorite branch from 9f81e09 to 0a2e7e1 Compare July 30, 2026 08:45
Deeds67 added 4 commits July 30, 2026 13:45
…tes (#763)

`asset_favorite` rows are deliberately never cleaned up when a user loses
access (design §5.2): visibility is re-derived from live membership on read.
That makes the invariant a pure query-scoping property, so it is covered where
the scoping lives.

Medium spec drives the real TimelineService and SearchService so both halves
run for real — `getSpaceIdsForTimeline` resolving current membership, and the
favorite predicate being ANDed with the RBAC scope in
`withTimeBucketAssetFilters` / `searchAssetBuilderLegacy`. Two cases: the
member's last space is revoked (queries fall back to `asset.ownerId = caller`),
and a second membership survives (the space arm stays active but narrows).
Every absence assertion is paired with a must-remain control asset in the same
response, so a query that returned nothing could not read as green.

Adds matching compile-only assertions to the two query-builder specs, which run
in the unit job without a database: the favorite predicate must always sit
alongside either the ownerId scope or the timelineSpaceIds arm. Both go red if
the ownerId arm is removed.

E10 was already covered end-to-end in asset-favorite.e2e-spec.ts, but only via
bucket counts on the timeline path and with no control asset.
…voriting (#763)

`AssetService.update` / `AssetApiRepository.update` still declare an
`isFavorite` parameter (upstream Immich shapes, kept as-is for rebase hygiene)
that posts an `AssetBulkUpdateDto` to the owner-only `PUT /assets`. Favorites
are now a per-user overlay written through `PUT /assets/favorites`, so a
favorite routed that way would be owner-gated again. Nothing passes it today —
`AssetService.update` has no callers — but it is the obvious thing to reach for
when wiring the heart icon to the consolidated action.

Adds a policy test with two frozen inventories: the `AssetBulkUpdateDto`
construction sites carrying `isFavorite` (the choke point every route to the
bulk endpoint must pass through — sanctioned sites are the dead consolidated
`update` and the deliberate pre-5.2.0 `updateFavorite` fallback), and the
`.update(...)` call sites passing `isFavorite:`. Failures name the offending
member and point at `updateFavorite`.

Placed in the mobile suite rather than extending the server grep gate because CI
gates the server unit job on `server/**`, so a mobile-only change would not run
it; `mobile-unit-tests` is gated on `mobile/**`.

Also adds pointer comments at both `update({isFavorite})` declarations.
…filter (#763)

The dedicated /favorites page requested only { isFavorite, withStacked },
so it took the owner-only arm of searchAssetBuilderLegacy. The Photos
timeline favorites filter, by contrast, sets withPartners/withSharedSpaces
(buildPhotosTimelineOptions, slice 4).

That asymmetry meant a favorite a member placed on another member's Space
asset — which the per-user overlay explicitly permits — appeared in the
Photos filter but never on /favorites, so it was effectively unreachable.

Access is still enforced per request: the server recomputes
timelineSpaceIds from current shared_space_member rows, so losing Space
access drops the asset from results even though the overlay row survives.
That invariant is covered by favorite-access-revocation.medium.spec.ts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog:feat Feature change for changelog documentation Improvements or additions to documentation 📱mobile 🗄️server 🖥️web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Favorite action missing from photo action menu for non admin Space members

1 participant