diff --git a/docs/docs/guides/switch-back-to-immich.md b/docs/docs/guides/switch-back-to-immich.md index 64f109c4bf9260..e21aadb753370f 100644 --- a/docs/docs/guides/switch-back-to-immich.md +++ b/docs/docs/guides/switch-back-to-immich.md @@ -11,6 +11,7 @@ This guide walks through switching a Gallery instance back to upstream [Immich]( - Pet detection results, and every individual pet you named through pet recognition - Asset duplicate checksums - Library sync state +- Favorites belonging to anyone other than the asset's owner (see note below) Assets you uploaded through Gallery are preserved, as long as they exist in Immich-native tables (which is the normal case for every file uploaded via the web or mobile app). @@ -115,8 +116,9 @@ Watch the server log. A successful boot ends with the usual Immich startup banne Here is what the cleanup script changes: -- It drops the Gallery-only tables `shared_space*`, `album_space_asset*`, `library_user`, `library_audit`, `library_asset_audit`, `shared_space_library*`, `face_identity*`, `face_repair*`, `pet_search`, `user_group`, `user_group_member`, `classification_category`, `classification_prompt_embedding`, `storage_migration_log` and `asset_duplicate_checksum`. +- It drops the Gallery-only tables `shared_space*`, `album_space_asset*`, `library_user`, `library_audit`, `library_asset_audit`, `shared_space_library*`, `face_identity*`, `face_repair*`, `pet_search`, `user_group`, `user_group_member`, `classification_category`, `classification_prompt_embedding`, `storage_migration_log`, `asset_duplicate_checksum`, `asset_favorite` and `asset_favorite_audit`. - It drops the Gallery-added columns `person.type`, `person.species`, `asset_job_status.petsDetectedAt`, `asset_job_status.classifiedAt` and `library.createId`. +- It restores `asset.isFavorite`, an upstream Immich column Gallery moved into the `asset_favorite` overlay so each space member can favorite independently, before dropping `asset_favorite`, and backfills it from the overlay — but only for the asset's owner. Favorites belonging to anyone else (for example, a shared-space viewer who favorited another member's photo) have no equivalent in plain Immich's single-favorite-flag model and are discarded. Your own favorites on your own assets are preserved. - It drops the Gallery-only functions and triggers that reference the dropped tables. - It strips the `classification` key out of the `system-config` row in `system_metadata`. - It deletes fork migration rows from `kysely_migrations` and `migration_overrides`, so upstream Immich's migrator does not see them as unknown migrations. diff --git a/e2e/src/specs/server/api/asset-copy.e2e-spec.ts b/e2e/src/specs/server/api/asset-copy.e2e-spec.ts index 1cf69585301f2c..c5196890ee8928 100644 --- a/e2e/src/specs/server/api/asset-copy.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset-copy.e2e-spec.ts @@ -1,4 +1,4 @@ -import { type LoginResponseDto } from '@immich/sdk'; +import { type LoginResponseDto, SharedSpaceRole } from '@immich/sdk'; import { type Actor, authHeaders } from 'src/actors.js'; import { createUserDto } from 'src/fixtures.js'; import { errorDto } from 'src/responses.js'; @@ -203,6 +203,69 @@ describe('PUT /assets/copy', () => { expect((after.body as { isFavorite: boolean }).isFavorite).toBe(false); }); + // #763 slice 7 (E20): favorite carries the ACTING user's overlay row, never every user who + // favorited the source. `other` gains read access to both assets via a shared space (viewer + // role is enough under Permission.AssetRead, per #763 slice 2) purely so they can favorite the + // source and later read the target back — `other` still can't call PUT /assets/copy itself, + // since Permission.AssetCopy stays owner-only (asset.service.spec.ts already pins that). + it("copies only the ACTING user's favorite of the source, not another user's (E20)", async () => { + const [sourceId, targetId] = await createOwnerPair(); + + const space = await utils.createSpace(owner.accessToken, { name: 't26-copy-favorite-space' }); + await utils.addSpaceMember(owner.accessToken, space.id, { userId: other.userId, role: SharedSpaceRole.Viewer }); + await utils.addSpaceAssets(owner.accessToken, space.id, [sourceId, targetId]); + + // Both the owner (acting user) and `other` favorite the SOURCE — independent per-user facts. + const ownerFav = await request(app) + .put('/assets/favorites') + .set(asBearerAuth(owner.accessToken)) + .send({ ids: [sourceId], isFavorite: true }); + expect(ownerFav.status).toBe(204); + + const otherFav = await request(app) + .put('/assets/favorites') + .set(asBearerAuth(other.accessToken)) + .send({ ids: [sourceId], isFavorite: true }); + expect(otherFav.status).toBe(204); + + const copy = await request(app) + .put('/assets/copy') + .set(asBearerAuth(owner.accessToken)) + .send({ sourceId, targetId, favorite: true }); + expect(copy.status).toBe(204); + + // The acting user (owner) gets the target favorited (E20 happy path). + const ownerView = await request(app).get(`/assets/${targetId}`).set(asBearerAuth(owner.accessToken)); + expect((ownerView.body as { isFavorite: boolean }).isFavorite).toBe(true); + + // `other` favorited the SOURCE but never acted on this copy — their view of the target must + // stay unfavorited. This is the negative the old global-column copy could not express. + const otherView = await request(app).get(`/assets/${targetId}`).set(asBearerAuth(other.accessToken)); + expect((otherView.body as { isFavorite: boolean }).isFavorite).toBe(false); + }); + + it('clears the target favorite when the acting user did not favorite the source, even though favorite defaults to true', async () => { + const [sourceId, targetId] = await createOwnerPair(); + + // Owner favorites the TARGET directly (unrelated to the upcoming copy) but never favorites + // the source. A working copy still "copies the status" — including a false one — for the + // acting user, mirroring the old column-overwrite semantics scoped to the actor. + const preFav = await request(app) + .put('/assets/favorites') + .set(asBearerAuth(owner.accessToken)) + .send({ ids: [targetId], isFavorite: true }); + expect(preFav.status).toBe(204); + + const copy = await request(app) + .put('/assets/copy') + .set(asBearerAuth(owner.accessToken)) + .send({ sourceId, targetId }); + expect(copy.status).toBe(204); + + const after = await request(app).get(`/assets/${targetId}`).set(asBearerAuth(owner.accessToken)); + expect((after.body as { isFavorite: boolean }).isFavorite).toBe(false); + }); + it('owner can copy: album associations are propagated source → target', async () => { const [sourceId, targetId] = await createOwnerPair(); diff --git a/e2e/src/specs/server/api/asset-favorite.e2e-spec.ts b/e2e/src/specs/server/api/asset-favorite.e2e-spec.ts new file mode 100644 index 00000000000000..0c80b3b5635886 --- /dev/null +++ b/e2e/src/specs/server/api/asset-favorite.e2e-spec.ts @@ -0,0 +1,607 @@ +import { AlbumUserRole, AssetMediaResponseDto, LoginResponseDto, SharedLinkType, SharedSpaceRole } from '@immich/sdk'; +import { randomUUID } from 'node:crypto'; +import { addPartner } from 'src/actors'; +import { createUserDto } from 'src/fixtures'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +// #763 slice 2 — canonical PUT /assets/favorites + the deprecated PUT /assets/:id / PUT /assets +// `isFavorite` alias. +// +// See docs/superpowers/specs/2026-07-20-per-user-favorites-design.md §5.1, §8.1 and +// docs/superpowers/plans/2026-07-20-per-user-favorites-slice-2.md Task 2. +// +// Favorites are per (user, asset): the canonical endpoint gates on Permission.AssetRead, which +// resolves own assets, shared-space assets at ANY role including viewer, shared-album assets, and +// partner-shared assets — a deliberate widening vs. the deprecated alias's Permission.AssetUpdate +// gate (owner or space EDITOR only, utils/access.ts:155-159), which stays viewer-closed on purpose: +// §8.1 requires the alias never be MORE permissive than the canonical endpoint. + +const putFavorites = (token: string, body: Record) => + request(app).put('/assets/favorites').set('Authorization', `Bearer ${token}`).send(body); + +const singleUpdateAsset = (token: string, id: string, body: Record) => + request(app).put(`/assets/${id}`).set('Authorization', `Bearer ${token}`).send(body); + +// The exact timeline query the /favorites page issues (baseTimelineOptions in +// web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte, plus the +// visibility the Timeline component supplies). Kept as one literal so the role suite below asserts +// against what the page really sends rather than a hand-tuned superset that can drift green. +const FAVORITES_PAGE_QUERY = + 'visibility=timeline&isFavorite=true&withStacked=true&withPartners=true&withSharedSpaces=true'; + +// The set of asset IDs the /favorites page would render for `token`. Buckets are enumerated and +// then fetched so callers can assert on identity rather than a running total, which keeps the role +// tests below order-independent. +const favoritedAssetIds = async (token: string, query = FAVORITES_PAGE_QUERY): Promise> => { + const buckets = await request(app).get(`/timeline/buckets?${query}`).set(asBearerAuth(token)); + expect(buckets.status).toBe(200); + + const ids = new Set(); + for (const bucket of buckets.body as Array<{ timeBucket: string }>) { + const page = await request(app) + .get(`/timeline/bucket?${query}&timeBucket=${bucket.timeBucket}`) + .set(asBearerAuth(token)); + expect(page.status).toBe(200); + for (const id of (page.body as { id?: string[] }).id ?? []) { + ids.add(id); + } + } + return ids; +}; + +// Shared across every describe in this file so the access-lifecycle suite below (#763 slice 4) +// can reuse the same admin session without re-running resetDatabase (which would wipe the +// order-dependent alice/bob/carol state above). +let admin: LoginResponseDto; + +describe('PUT /assets/favorites (#763 per-user favorites)', () => { + let alice: LoginResponseDto; // space owner; owns every asset in this suite + let bob: LoginResponseDto; // space VIEWER — never owns anything + let carol: LoginResponseDto; // non-member — no relationship to the space at all + + let spaceId: string; + let assetX: AssetMediaResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup(); + [alice, bob, carol] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.create('fav-alice')), + utils.userSetup(admin.accessToken, createUserDto.create('fav-bob')), + utils.userSetup(admin.accessToken, createUserDto.create('fav-carol')), + ]); + + const space = await utils.createSpace(alice.accessToken, { name: 'fav-space' }); + spaceId = space.id; + await utils.addSpaceMember(alice.accessToken, spaceId, { userId: bob.userId, role: SharedSpaceRole.Viewer }); + + assetX = await utils.createAsset(alice.accessToken); + await utils.addSpaceAssets(alice.accessToken, spaceId, [assetX.id]); + }); + + it("a space VIEWER can favorite an asset they only have read access to; the owner's view is unaffected (E3)", async () => { + const { status } = await putFavorites(bob.accessToken, { ids: [assetX.id], isFavorite: true }); + expect(status).toBe(204); + + const bobInfo = await utils.getAssetInfo(bob.accessToken, assetX.id); + expect(bobInfo.isFavorite).toBe(true); + const aliceInfo = await utils.getAssetInfo(alice.accessToken, assetX.id); + expect(aliceInfo.isFavorite).toBe(false); + }); + + it("the owner favoriting/unfavoriting their own asset never touches a member's independent favorite row (E2)", async () => { + // Alice (the owner) favorites the same asset Bob favorited above — an independent fact. + let res = await putFavorites(alice.accessToken, { ids: [assetX.id], isFavorite: true }); + expect(res.status).toBe(204); + let aliceInfo = await utils.getAssetInfo(alice.accessToken, assetX.id); + expect(aliceInfo.isFavorite).toBe(true); + let bobInfo = await utils.getAssetInfo(bob.accessToken, assetX.id); + expect(bobInfo.isFavorite).toBe(true); + + // Alice unfavorites — Bob's row must survive untouched. + res = await putFavorites(alice.accessToken, { ids: [assetX.id], isFavorite: false }); + expect(res.status).toBe(204); + aliceInfo = await utils.getAssetInfo(alice.accessToken, assetX.id); + expect(aliceInfo.isFavorite).toBe(false); + bobInfo = await utils.getAssetInfo(bob.accessToken, assetX.id); + expect(bobInfo.isFavorite).toBe(true); + }); + + it('a non-member cannot favorite an asset they cannot read; no row is created (E5)', async () => { + const { status, body } = await putFavorites(carol.accessToken, { ids: [assetX.id], isFavorite: true }); + // requireAccess(AssetRead) denial → BadRequestException, the same convention as every other + // asset-access guard in this codebase (e.g. asset.e2e-spec.ts:541 "Not found or no asset.delete + // access") — not a literal 403. + expect(status).toBe(400); + expect(body.message).toMatch(/asset\.read access/); + + // Carol never had read access, so assert via the owner that nothing changed. + const aliceInfo = await utils.getAssetInfo(alice.accessToken, assetX.id); + expect(aliceInfo.isFavorite).toBe(false); + }); + + it('a shared-link session is rejected outright; no row is created for the link owner (E6)', async () => { + const sharedLink = await utils.createSharedLink(alice.accessToken, { + type: SharedLinkType.Individual, + assetIds: [assetX.id], + }); + + // No Authorization header — only the share key, exactly like the read-side shared-link tests. + const { status } = await request(app) + .put(`/assets/favorites?key=${sharedLink.key}`) + .send({ ids: [assetX.id], isFavorite: true }); + // This route never declares `sharedLink: true`, so AuthGuard rejects a share-key-only request + // before the service is reached. The explicit `auth.sharedLink` guard inside updateFavorites + // (§5.1) is defense-in-depth for if that route metadata is ever loosened to match the read-side + // GET /assets/:id (which does declare `sharedLink: true`). + expect(status).toBe(403); + + const aliceInfo = await utils.getAssetInfo(alice.accessToken, assetX.id); + expect(aliceInfo.isFavorite).toBe(false); + }); + + it("elevated session permission does not widen the favorite subject — only the caller's own row is created (E7)", async () => { + await utils.addSpaceMember(alice.accessToken, spaceId, { userId: admin.userId, role: SharedSpaceRole.Viewer }); + + await request(app).post('/auth/pin-code').set(asBearerAuth(admin.accessToken)).send({ pinCode: '445566' }); + const unlock = await request(app) + .post('/auth/session/unlock') + .set(asBearerAuth(admin.accessToken)) + .send({ pinCode: '445566' }); + expect(unlock.status).toBe(204); + + const { status } = await putFavorites(admin.accessToken, { ids: [assetX.id], isFavorite: true }); + expect(status).toBe(204); + + const adminInfo = await utils.getAssetInfo(admin.accessToken, assetX.id); + expect(adminInfo.isFavorite).toBe(true); + // The asset owner's row is untouched by the elevated-session caller's write. + const aliceInfo = await utils.getAssetInfo(alice.accessToken, assetX.id); + expect(aliceInfo.isFavorite).toBe(false); + }); + + it('favoriting an already-favorited asset is a no-op, not a 500 (E8)', async () => { + const first = await putFavorites(bob.accessToken, { ids: [assetX.id], isFavorite: true }); + expect(first.status).toBe(204); + + const second = await putFavorites(bob.accessToken, { ids: [assetX.id], isFavorite: true }); + expect(second.status).toBe(204); + + const bobInfo = await utils.getAssetInfo(bob.accessToken, assetX.id); + expect(bobInfo.isFavorite).toBe(true); + }); + + it('unfavoriting an asset the caller never favorited is a no-op, not a 404 (E9)', async () => { + const assetY = await utils.createAsset(alice.accessToken); + await utils.addSpaceAssets(alice.accessToken, spaceId, [assetY.id]); + + const { status } = await putFavorites(bob.accessToken, { ids: [assetY.id], isFavorite: false }); + expect(status).toBe(204); + const bobInfo = await utils.getAssetInfo(bob.accessToken, assetY.id); + expect(bobInfo.isFavorite).toBe(false); + }); + + it('the deprecated PUT /assets/:id isFavorite alias produces the identical result as the canonical endpoint, for the owner (E17)', async () => { + const assetZ = await utils.createAsset(alice.accessToken); + + const { status, body } = await singleUpdateAsset(alice.accessToken, assetZ.id, { isFavorite: true }); + expect(status).toBe(200); + expect(body.isFavorite).toBe(true); + + const aliceInfo = await utils.getAssetInfo(alice.accessToken, assetZ.id); + expect(aliceInfo.isFavorite).toBe(true); + }); + + it('the deprecated alias STILL rejects a space VIEWER while the canonical endpoint succeeds — the alias never widens access (E18 vs E3)', async () => { + const assetW = await utils.createAsset(alice.accessToken); + await utils.addSpaceAssets(alice.accessToken, spaceId, [assetW.id]); + + const aliasAttempt = await singleUpdateAsset(bob.accessToken, assetW.id, { isFavorite: true }); + // Bob has AssetRead via the space (Viewer) but the alias requires Permission.AssetUpdate + // (owner or space EDITOR only) — Bob is neither, so requireAccess(AssetUpdate) denies him. + expect(aliasAttempt.status).toBe(400); + expect(aliasAttempt.body.message).toMatch(/asset\.update access/); + + const canonicalAttempt = await putFavorites(bob.accessToken, { ids: [assetW.id], isFavorite: true }); + expect(canonicalAttempt.status).toBe(204); + + const bobInfo = await utils.getAssetInfo(bob.accessToken, assetW.id); + expect(bobInfo.isFavorite).toBe(true); + const aliceInfo = await utils.getAssetInfo(alice.accessToken, assetW.id); + expect(aliceInfo.isFavorite).toBe(false); + }); + + it('an empty ids array is rejected by schema validation (min(1)), not treated as a silent no-op', async () => { + const { status } = await putFavorites(alice.accessToken, { ids: [], isFavorite: true }); + expect(status).toBe(400); + }); + + it('an oversized ids array is rejected with a bounded 400, not accepted unbounded (E28)', async () => { + const ids = Array.from({ length: 1001 }, () => randomUUID()); + const { status } = await putFavorites(alice.accessToken, { ids, isFavorite: true }); + expect(status).toBe(400); + }); + + it('a nonexistent asset id is rejected, never a 500', async () => { + const { status } = await putFavorites(alice.accessToken, { ids: [randomUUID()], isFavorite: true }); + expect(status).toBe(400); + }); + + it('no request field can name another user as the favorite subject — the subject is always the caller (E4)', async () => { + const assetV = await utils.createAsset(alice.accessToken); + await utils.addSpaceAssets(alice.accessToken, spaceId, [assetV.id]); + + // Bob attempts to smuggle Alice's id in as an extra field. Whether the schema silently drops + // the unknown key or not, the write must be attributed to Bob (the caller) — never Alice. + const { status } = await putFavorites(bob.accessToken, { + ids: [assetV.id], + isFavorite: true, + userId: alice.userId, + }); + expect(status).toBe(204); + + const bobInfo = await utils.getAssetInfo(bob.accessToken, assetV.id); + expect(bobInfo.isFavorite).toBe(true); + const aliceInfo = await utils.getAssetInfo(alice.accessToken, assetV.id); + expect(aliceInfo.isFavorite).toBe(false); + }); +}); + +// #763 slice 4 — favorite rows are per (user, asset) and independent of the access-granting +// relationship (space membership, album share, partner share). Losing access must hide the +// favorite from listings and from the individual asset read (§8.1's 400-not-403 convention), +// but must NEVER delete the row: regaining access re-derives the same isFavorite=true with no +// new write. See docs/superpowers/specs/2026-07-20-per-user-favorites-design.md §5.2. +// +// Uses only its own fresh users (dave/erin/frank/grace) — the describe above is order-dependent +// on alice/bob/carol and must not be touched. +describe('favorite rows survive access loss and re-derive on read (#763 slice 4)', () => { + let dave: LoginResponseDto; // owns space2 + all assets here + let erin: LoginResponseDto; // space viewer / album recipient + let space2Id: string; + let assetY: AssetMediaResponseDto; // in space2 + let assetZ: AssetMediaResponseDto; // album-shared only + let albumId: string; + + const erinFavoriteBucketTotal = async () => { + const { body } = await request(app) + .get('/timeline/buckets?visibility=timeline&withSharedSpaces=true&isFavorite=true') + .set(asBearerAuth(erin.accessToken)); + return (body as Array<{ count: number }>).reduce((acc, b) => acc + b.count, 0); + }; + + beforeAll(async () => { + [dave, erin] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.create('fav-dave')), + utils.userSetup(admin.accessToken, createUserDto.create('fav-erin')), + ]); + const space = await utils.createSpace(dave.accessToken, { name: 'fav-lifecycle-space' }); + space2Id = space.id; + await utils.addSpaceMember(dave.accessToken, space2Id, { userId: erin.userId, role: SharedSpaceRole.Viewer }); + [assetY, assetZ] = await Promise.all([utils.createAsset(dave.accessToken), utils.createAsset(dave.accessToken)]); + await utils.addSpaceAssets(dave.accessToken, space2Id, [assetY.id]); + await request(app) + .put('/assets/favorites') + .set(asBearerAuth(erin.accessToken)) + .send({ ids: [assetY.id], isFavorite: true }); + }); + + it('member leaves the space → favorite drops out of listings; rejoining restores it without re-favoriting (E10, E11)', async () => { + expect(await erinFavoriteBucketTotal()).toBe(1); + + // Remove erin from the space — same endpoint as shared-space.e2e-spec.ts's + // "DELETE /shared-spaces/:id/members/:userId" ('should remove a member from a space'). + const removal = await request(app) + .delete(`/shared-spaces/${space2Id}/members/${erin.userId}`) + .set(asBearerAuth(dave.accessToken)); + expect(removal.status).toBe(204); + + expect(await erinFavoriteBucketTotal()).toBe(0); + // Row persisted but unreadable — the asset itself is also gone for erin: + const getAfterRemoval = await request(app).get(`/assets/${assetY.id}`).set(asBearerAuth(erin.accessToken)); + expect(getAfterRemoval.status).toBe(400); + + // Re-add erin — the favorite reappears with NO new favorite write. + await utils.addSpaceMember(dave.accessToken, space2Id, { userId: erin.userId, role: SharedSpaceRole.Viewer }); + expect(await erinFavoriteBucketTotal()).toBe(1); + const erinInfo = await utils.getAssetInfo(erin.accessToken, assetY.id); + expect(erinInfo.isFavorite).toBe(true); + }); + + it('a trashed favorited asset leaves the favorites timeline and returns on restore', async () => { + await utils.deleteAssets(dave.accessToken, [assetY.id]); // trash, not permanent + expect(await erinFavoriteBucketTotal()).toBe(0); + + // Restore from trash — same endpoint as trash.e2e-spec.ts's "POST /trash/restore/assets". + const restore = await request(app) + .post('/trash/restore/assets') + .set(asBearerAuth(dave.accessToken)) + .send({ ids: [assetY.id] }); + expect(restore.status).toBe(200); + + expect(await erinFavoriteBucketTotal()).toBe(1); + const erinInfo = await utils.getAssetInfo(erin.accessToken, assetY.id); + expect(erinInfo.isFavorite).toBe(true); + }); + + it('album unshare hides the favorite; re-share restores it (E29)', async () => { + // Share an album containing assetZ with erin, erin favorites it (AssetRead via album — the + // slice-2 access widening), then unshare and re-share. + // NOTE the pinned semantics: album-shared favorites do NOT appear in the withSharedSpaces + // favorites timeline (albums are not a timeline scope) — visibility of the favorite is + // asserted via getAssetInfo, and absence stays absent throughout. + const album = await utils.createAlbum(dave.accessToken, { + albumName: 'fav-lifecycle-album', + assetIds: [assetZ.id], + albumUsers: [{ userId: erin.userId, role: AlbumUserRole.Viewer }], + }); + albumId = album.id; + + await request(app) + .put('/assets/favorites') + .set(asBearerAuth(erin.accessToken)) + .send({ ids: [assetZ.id], isFavorite: true }); + const erinInfoBeforeRemoval = await utils.getAssetInfo(erin.accessToken, assetZ.id); + expect(erinInfoBeforeRemoval.isFavorite).toBe(true); + + // Remove erin from the album — same endpoint as album.e2e-spec.ts's "DELETE :id/user/:userId". + const removal = await request(app) + .delete(`/albums/${albumId}/user/${erin.userId}`) + .set(asBearerAuth(dave.accessToken)); + expect(removal.status).toBe(204); + + const getAfterRemoval = await request(app).get(`/assets/${assetZ.id}`).set(asBearerAuth(erin.accessToken)); + expect(getAfterRemoval.status).toBe(400); + + // Re-add erin to the album — same endpoint as album.e2e-spec.ts's "PUT :id/users". + const readd = await request(app) + .put(`/albums/${albumId}/users`) + .set(asBearerAuth(dave.accessToken)) + .send({ albumUsers: [{ userId: erin.userId, role: AlbumUserRole.Viewer }] }); + expect(readd.status).toBe(200); + + const erinInfoAfterReadd = await utils.getAssetInfo(erin.accessToken, assetZ.id); + expect(erinInfoAfterReadd.isFavorite).toBe(true); // row survived + }); + + it('partner revoke hides partner favorites; re-adding restores them (E30)', async () => { + const [frank, grace] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.create('fav-frank')), + utils.userSetup(admin.accessToken, createUserDto.create('fav-grace')), + ]); + const frankAsset = await utils.createAsset(frank.accessToken); + + const graceFavoriteBucketTotal = async () => { + const { body } = await request(app) + .get('/timeline/buckets?visibility=timeline&withPartners=true&isFavorite=true') + .set(asBearerAuth(grace.accessToken)); + return (body as Array<{ count: number }>).reduce((acc, b) => acc + b.count, 0); + }; + + // frank shares with grace (grace = recipient, inTimeline enabled — actors.ts:111-118's + // addPartner helper). + await addPartner( + { token: frank.accessToken, userId: frank.userId }, + { token: grace.accessToken, userId: grace.userId }, + ); + + await request(app) + .put('/assets/favorites') + .set(asBearerAuth(grace.accessToken)) + .send({ ids: [frankAsset.id], isFavorite: true }); + expect(await graceFavoriteBucketTotal()).toBe(1); + + // Revoke: the sharer (frank) removes the partnership addressed to the recipient (grace) — + // same endpoint as partner.e2e-spec.ts's "DELETE /partners/:id". + const revoke = await request(app).delete(`/partners/${grace.userId}`).set(asBearerAuth(frank.accessToken)); + expect(revoke.status).toBe(204); + + expect(await graceFavoriteBucketTotal()).toBe(0); + const getAfterRevoke = await request(app).get(`/assets/${frankAsset.id}`).set(asBearerAuth(grace.accessToken)); + expect(getAfterRevoke.status).toBe(400); + + // Re-add + re-enable inTimeline — no new favorite write. + await addPartner( + { token: frank.accessToken, userId: frank.userId }, + { token: grace.accessToken, userId: grace.userId }, + ); + + expect(await graceFavoriteBucketTotal()).toBe(1); + const graceInfo = await utils.getAssetInfo(grace.accessToken, frankAsset.id); + expect(graceInfo.isFavorite).toBe(true); + }); + + it('concurrent favorite + unfavorite of the same (user, asset) converges deterministically — no PK violation, no 500, no orphaned row (E27)', async () => { + // A fresh asset, untouched by the other tests in this describe. + const assetConcurrent = await utils.createAsset(dave.accessToken); + + // addAll is an INSERT ... ON CONFLICT DO NOTHING, removeAll a plain DELETE keyed on + // (userId, assetId) (asset-favorite.repository.ts) — racing PUTs must never surface a PK + // violation as a 500, on either side of the race. + for (let i = 0; i < 10; i++) { + const [favorite, unfavorite] = await Promise.all([ + putFavorites(dave.accessToken, { ids: [assetConcurrent.id], isFavorite: true }), + putFavorites(dave.accessToken, { ids: [assetConcurrent.id], isFavorite: false }), + ]); + expect(favorite.status).toBe(204); + expect(unfavorite.status).toBe(204); + } + + // Converge deterministically: an explicit final write in each direction must land, proving no + // orphaned/stuck row survived the race. + const favoriteRes = await putFavorites(dave.accessToken, { ids: [assetConcurrent.id], isFavorite: true }); + expect(favoriteRes.status).toBe(204); + let daveInfo = await utils.getAssetInfo(dave.accessToken, assetConcurrent.id); + expect(daveInfo.isFavorite).toBe(true); + + const unfavoriteRes = await putFavorites(dave.accessToken, { ids: [assetConcurrent.id], isFavorite: false }); + expect(unfavoriteRes.status).toBe(204); + daveInfo = await utils.getAssetInfo(dave.accessToken, assetConcurrent.id); + expect(daveInfo.isFavorite).toBe(false); + }); +}); + +// #763: every space role must be able to favorite a photo they do NOT own, and — the part that +// actually shipped broken — that favorite must be reachable on the /favorites page, not merely +// recorded. The describes above prove the WRITE lands (E3 covers a viewer) and read it back through +// `GET /assets/:id`, which resolves the overlay for any asset the caller can read and therefore +// stays green even when the favorites LISTING cannot see the row. +// +// pr-819-rc.1 shipped exactly that split: the heart filled and the toast said "Added to favorites", +// but /favorites rendered its empty placeholder, because the page requested the timeline without +// `withSharedSpaces`. Without that flag the server never resolves `timelineSpaceIds`, so +// `withTimeBucketAssetFilters` falls back to a hard `asset.ownerId = caller` filter and drops every +// favorite on a non-owned asset. The lifecycle describe above missed it because its helper +// hardcodes `withSharedSpaces=true` — the exact flag the page omitted. +// +// So these assert through `favoritedAssetIds`, which mirrors the /favorites page's request options +// verbatim. The final test pins the mechanism directly: drop the cross-scope flag and the +// non-owned favorite disappears. +describe('favoriting a photo you do not own, by space role (#763)', () => { + let hank: LoginResponseDto; // space OWNER — owns assetHank + let ivy: LoginResponseDto; // space EDITOR — owns assetIvy, contributed to the space + let jack: LoginResponseDto; // space VIEWER — owns nothing here + let roleSpaceId: string; + let assetHank: AssetMediaResponseDto; + let assetIvy: AssetMediaResponseDto; + + beforeAll(async () => { + [hank, ivy, jack] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.create('fav-hank')), + utils.userSetup(admin.accessToken, createUserDto.create('fav-ivy')), + utils.userSetup(admin.accessToken, createUserDto.create('fav-jack')), + ]); + + const space = await utils.createSpace(hank.accessToken, { name: 'fav-roles-space' }); + roleSpaceId = space.id; + await Promise.all([ + utils.addSpaceMember(hank.accessToken, roleSpaceId, { userId: ivy.userId, role: SharedSpaceRole.Editor }), + utils.addSpaceMember(hank.accessToken, roleSpaceId, { userId: jack.userId, role: SharedSpaceRole.Viewer }), + ]); + + [assetHank, assetIvy] = await Promise.all([ + utils.createAsset(hank.accessToken), + utils.createAsset(ivy.accessToken), + ]); + // The editor contributes their own asset, giving the space OWNER a photo they do not own. + await utils.addSpaceAssets(hank.accessToken, roleSpaceId, [assetHank.id]); + await utils.addSpaceAssets(ivy.accessToken, roleSpaceId, [assetIvy.id]); + }); + + it("a VIEWER favorites a photo they don't own and finds it on their favorites page", async () => { + const { status } = await putFavorites(jack.accessToken, { ids: [assetHank.id], isFavorite: true }); + expect(status).toBe(204); + + expect(await favoritedAssetIds(jack.accessToken)).toContain(assetHank.id); + // Per-user: the owner of the photo never sees the viewer's favorite as their own. + expect(await favoritedAssetIds(hank.accessToken)).not.toContain(assetHank.id); + }); + + it("an EDITOR favorites a photo they don't own and finds it on their favorites page", async () => { + const { status } = await putFavorites(ivy.accessToken, { ids: [assetHank.id], isFavorite: true }); + expect(status).toBe(204); + + expect(await favoritedAssetIds(ivy.accessToken)).toContain(assetHank.id); + expect(await favoritedAssetIds(hank.accessToken)).not.toContain(assetHank.id); + }); + + it("a space OWNER favorites a member's contributed photo they don't own and finds it on their favorites page", async () => { + const { status } = await putFavorites(hank.accessToken, { ids: [assetIvy.id], isFavorite: true }); + expect(status).toBe(204); + + expect(await favoritedAssetIds(hank.accessToken)).toContain(assetIvy.id); + // The contributor's own favorites are untouched by the space owner's write. + expect(await favoritedAssetIds(ivy.accessToken)).not.toContain(assetIvy.id); + }); + + // Reported against pr-819-rc.2: favoriting from inside a Space that the member has hidden from + // their own timeline gave "Added to favorites" and then never appeared under Favorites, while the + // identical action on a space that IS shown in the timeline worked. The write is the same either + // way — what differs is the READ: `withSharedSpaces` resolves its scope through + // getSpaceIdsForTimeline, which filters `showInTimeline = true`, so a hidden space contributes no + // spaceId and the caller's own overlay row becomes unreachable. + // + // "Show in timeline" is a HOME-TIMELINE browse preference, not a visibility revocation: #1041 + // already exempts Favorites (with Archive and Trash) from the personal-timeline hide for exactly + // this reason — they are curation/recovery surfaces where subtracting an asset strands it. The + // same argument applies here, and widening is safe because the favorite predicate still restricts + // to the CALLER's own asset_favorite rows and membership is re-resolved per request. + it('a favourite stays on the favorites page after hiding that space from your own timeline', async () => { + const assetHidden = await utils.createAsset(hank.accessToken); + await utils.addSpaceAssets(hank.accessToken, roleSpaceId, [assetHidden.id]); + + const setShowInTimeline = (showInTimeline: boolean) => + request(app) + .patch(`/shared-spaces/${roleSpaceId}/members/me/timeline`) + .set(asBearerAuth(jack.accessToken)) + .send({ showInTimeline }); + + const hide = await setShowInTimeline(false); + expect(hide.status).toBe(200); + + try { + const { status } = await putFavorites(jack.accessToken, { ids: [assetHidden.id], isFavorite: true }); + expect(status).toBe(204); + // The write landed — this is the half that already worked, and why the toast was honest. + const info = await utils.getAssetInfo(jack.accessToken, assetHidden.id); + expect(info.isFavorite).toBe(true); + + // ...and the favorites page must still list it. + expect(await favoritedAssetIds(jack.accessToken)).toContain(assetHidden.id); + } finally { + // In `finally` so a failure here cannot leave the space hidden and cascade into the tests + // below, which share this fixture and assume it is timeline-visible. + await setShowInTimeline(true); + } + }); + + // Reported against pr-819-rc.6: filtering by a camera value from the /favorites asset viewer + // returned 0 results. The click leaves /favorites (it has no filter panel, so it is not a filter + // target) and lands on /photos, which now carries `favorite=true` so the filter narrows the + // favourites instead of replacing them — see resolveImplicitFilterScope in web filter-target.ts. + // + // That destination query differs from the /favorites one in a load-bearing way: it states + // `userId`, the personal timeline's owner gate. The gate is OR-ed with the shared-space arm + // (asset.repository.ts), and the isFavorite carve-out above still widens the space scope to every + // space the caller belongs to — so a favourite on someone else's asset inside a space hidden from + // the caller's timeline has to come back. If either ever became an AND, the fix would silently + // return an empty page again, which is the failure this pins. + it('the /photos query the favorites filter navigates to still finds a hidden-space favourite', async () => { + const assetFiltered = await utils.createAsset(hank.accessToken); + await utils.addSpaceAssets(hank.accessToken, roleSpaceId, [assetFiltered.id]); + + const setShowInTimeline = (showInTimeline: boolean) => + request(app) + .patch(`/shared-spaces/${roleSpaceId}/members/me/timeline`) + .set(asBearerAuth(jack.accessToken)) + .send({ showInTimeline }); + + const hide = await setShowInTimeline(false); + expect(hide.status).toBe(200); + + try { + const { status } = await putFavorites(jack.accessToken, { ids: [assetFiltered.id], isFavorite: true }); + expect(status).toBe(204); + + // buildPhotosTimelineOptions + the favorite seed, as the contextual filter emits it. + const photosQuery = `userId=${jack.userId}&visibility=timeline&isFavorite=true&withStacked=true&withPartners=true&withSharedSpaces=true`; + expect(await favoritedAssetIds(jack.accessToken, photosQuery)).toContain(assetFiltered.id); + } finally { + await setShowInTimeline(true); + } + }); + + it('a non-owned favorite is only reachable while the request carries the cross-scope flag', async () => { + // jack favorited assetHank above. Owner-scoped, the row is invisible — this is precisely the + // query pr-819-rc.1's /favorites page sent, and precisely why the page looked empty. + const ownerScopedQuery = 'visibility=timeline&isFavorite=true&withStacked=true'; + expect(await favoritedAssetIds(jack.accessToken, ownerScopedQuery)).not.toContain(assetHank.id); + + // The overlay row is intact the whole time; only the scope of the read changed. + expect(await favoritedAssetIds(jack.accessToken)).toContain(assetHank.id); + const jackInfo = await utils.getAssetInfo(jack.accessToken, assetHank.id); + expect(jackInfo.isFavorite).toBe(true); + }); +}); diff --git a/e2e/src/specs/server/api/duplicate-favorite.e2e-spec.ts b/e2e/src/specs/server/api/duplicate-favorite.e2e-spec.ts new file mode 100644 index 00000000000000..810d76b007c4df --- /dev/null +++ b/e2e/src/specs/server/api/duplicate-favorite.e2e-spec.ts @@ -0,0 +1,167 @@ +import { SharedSpaceRole } from '@immich/sdk'; +import { buildSpaceContext, type SpaceContext } from 'src/actors'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +// #763 slice 7 (E21): duplicate-merge must preserve every user's favorites, not just the owner's. +// +// Duplicate *detection* is owner-scoped (`duplicate.repository.ts` filters `asset.ownerId`), so +// every asset in a duplicate group shares one owner — here, `ctx.spaceOwner`. But OTHER users can +// favorite those same assets via space access (#763 slice 2 gates favoriting on +// Permission.AssetRead, which a space viewer already has). The old code merged favorites with +// `assets.some((asset) => asset.isFavorite)` — a boolean OR over the raw, owner-only column — so it +// could only ever express the owner's own favorite and silently dropped everyone else's. +// `AssetFavoriteRepository.mergeOnto` (a per-user UNION, `INSERT ... SELECT DISTINCT ... ON +// CONFLICT DO NOTHING`) fixes that. See docs/superpowers/specs/2026-07-20-per-user-favorites-design.md +// §6 (E21) and docs/superpowers/plans/2026-07-20-per-user-favorites-slice-7.md Task 2. +// +// NOTE: this file lives alongside the legacy `e2e/src/api/specs/duplicate.e2e-spec.ts`, which is +// NOT picked up by any vitest `include` glob (dead since upstream's `refactor: e2e (#7703)` moved +// every other file's include pattern to `src/specs/server/**` and left this one behind) and whose +// assertions target a now-stale `/duplicates/resolve` response shape (`{status, results:[{status, +// reason}]}` instead of the current `BulkIdResponseDto[]`, i.e. `[{id, success, error?}]`). Reviving +// that file is a separate, pre-existing gap outside this slice's scope — flagged here rather than +// fixed, so this new coverage isn't silently lost in the same way. + +const putFavorites = (token: string, ids: string[], isFavorite: boolean) => + request(app).put('/assets/favorites').set(asBearerAuth(token)).send({ ids, isFavorite }); + +describe('/duplicates/resolve — favorite merge (#763 E21)', () => { + let ctx: SpaceContext; + + beforeAll(async () => { + await utils.resetDatabase(); + ctx = await buildSpaceContext(); + }); + + it('unions favorites from multiple source assets onto the keeper, per user', async () => { + const ownerToken = ctx.spaceOwner.token!; + const viewerToken = ctx.spaceViewer.token!; + + const [keeper, source1, source2] = await Promise.all([ + utils.createAsset(ownerToken), + utils.createAsset(ownerToken), + utils.createAsset(ownerToken), + ]); + await utils.addSpaceAssets(ownerToken, ctx.spaceId, [keeper.id, source1.id, source2.id]); + + // The owner favorites source1; the space VIEWER (never the owner) favorites source2 — + // two different users, two different sources. A single boolean OR over the owner-only + // column could never carry the viewer's half of this. + const ownerFav = await putFavorites(ownerToken, [source1.id], true); + expect(ownerFav.status).toBe(204); + const viewerFav = await putFavorites(viewerToken, [source2.id], true); + expect(viewerFav.status).toBe(204); + + const duplicateId = '00000000-0000-4000-8000-000000000210'; + await Promise.all( + [keeper, source1, source2].map((asset) => utils.setAssetDuplicateId(ownerToken, asset.id, duplicateId)), + ); + + const { status, body } = await request(app) + .post('/duplicates/resolve') + .set(asBearerAuth(ownerToken)) + .send({ groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [source1.id, source2.id] }] }); + + expect(status).toBe(200); + expect(body).toEqual([{ id: duplicateId, success: true }]); + + const keeperForOwner = await utils.getAssetInfo(ownerToken, keeper.id); + expect(keeperForOwner.isFavorite).toBe(true); + const keeperForViewer = await utils.getAssetInfo(viewerToken, keeper.id); + expect(keeperForViewer.isFavorite).toBe(true); + }); + + it('dedups when the same user favorited multiple sources — exactly one keeper row, no 500 (E8)', async () => { + const ownerToken = ctx.spaceOwner.token!; + const viewerToken = ctx.spaceViewer.token!; + + const [keeper, source1, source2] = await Promise.all([ + utils.createAsset(ownerToken), + utils.createAsset(ownerToken), + utils.createAsset(ownerToken), + ]); + await utils.addSpaceAssets(ownerToken, ctx.spaceId, [keeper.id, source1.id, source2.id]); + + // The SAME user favorited BOTH sources — mergeOnto's `onConflict do nothing` must absorb + // the resulting duplicate insert rather than a PK violation / 500. + const viewerFav = await putFavorites(viewerToken, [source1.id, source2.id], true); + expect(viewerFav.status).toBe(204); + + const duplicateId = '00000000-0000-4000-8000-000000000211'; + await Promise.all( + [keeper, source1, source2].map((asset) => utils.setAssetDuplicateId(ownerToken, asset.id, duplicateId)), + ); + + const { status, body } = await request(app) + .post('/duplicates/resolve') + .set(asBearerAuth(ownerToken)) + .send({ groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [source1.id, source2.id] }] }); + + expect(status).toBe(200); + expect(body).toEqual([{ id: duplicateId, success: true }]); + const keeperForViewer = await utils.getAssetInfo(viewerToken, keeper.id); + expect(keeperForViewer.isFavorite).toBe(true); + }); + + it('creates no favorite for the keeper when no source was favorited', async () => { + const ownerToken = ctx.spaceOwner.token!; + + const [keeper, source] = await Promise.all([utils.createAsset(ownerToken), utils.createAsset(ownerToken)]); + + const duplicateId = '00000000-0000-4000-8000-000000000212'; + await Promise.all([keeper, source].map((asset) => utils.setAssetDuplicateId(ownerToken, asset.id, duplicateId))); + + const { status, body } = await request(app) + .post('/duplicates/resolve') + .set(asBearerAuth(ownerToken)) + .send({ groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [source.id] }] }); + + expect(status).toBe(200); + expect(body).toEqual([{ id: duplicateId, success: true }]); + const keeperForOwner = await utils.getAssetInfo(ownerToken, keeper.id); + expect(keeperForOwner.isFavorite).toBe(false); + }); + + // §5.2: favorite visibility is re-derived on read, never enforced by deleting rows on access + // loss. A favorite from a user who has since lost access to the source must still transfer onto + // the keeper during the merge — proven here by revoking access, merging, then re-granting access + // and reading the row back. + it('transfers a source favorite from a user who has since lost access', async () => { + const ownerToken = ctx.spaceOwner.token!; + const viewerToken = ctx.spaceViewer.token!; + + const [keeper, source] = await Promise.all([utils.createAsset(ownerToken), utils.createAsset(ownerToken)]); + await utils.addSpaceAssets(ownerToken, ctx.spaceId, [keeper.id, source.id]); + + const viewerFav = await putFavorites(viewerToken, [source.id], true); + expect(viewerFav.status).toBe(204); + + // Revoke the viewer's space membership entirely before the merge runs. + const removed = await request(app) + .delete(`/shared-spaces/${ctx.spaceId}/members/${ctx.spaceViewer.userId}`) + .set(asBearerAuth(ownerToken)); + expect(removed.status).toBe(204); + + const duplicateId = '00000000-0000-4000-8000-000000000213'; + await Promise.all([keeper, source].map((asset) => utils.setAssetDuplicateId(ownerToken, asset.id, duplicateId))); + + const { status, body } = await request(app) + .post('/duplicates/resolve') + .set(asBearerAuth(ownerToken)) + .send({ groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [source.id] }] }); + + expect(status).toBe(200); + expect(body).toEqual([{ id: duplicateId, success: true }]); + + // Re-grant access and read it back — the row must have survived the merge even though the + // viewer could read nothing at the moment the merge ran. + await utils.addSpaceMember(ownerToken, ctx.spaceId, { + userId: ctx.spaceViewer.userId!, + role: SharedSpaceRole.Viewer, + }); + const keeperForViewer = await utils.getAssetInfo(viewerToken, keeper.id); + expect(keeperForViewer.isFavorite).toBe(true); + }); +}); diff --git a/e2e/src/specs/server/api/gallery-map.e2e-spec.ts b/e2e/src/specs/server/api/gallery-map.e2e-spec.ts index 1ee069e99f0a88..63e7f036835d60 100644 --- a/e2e/src/specs/server/api/gallery-map.e2e-spec.ts +++ b/e2e/src/specs/server/api/gallery-map.e2e-spec.ts @@ -1128,4 +1128,65 @@ describe('/gallery/map/markers', () => { expect(markerIds(body)).toEqual([favAssetId]); }); }); + + describe('cross-scope favorite markers (#763 slice 4, E23)', () => { + let spaceOwner: LoginResponseDto; + let spaceGpsAsset: AssetMediaResponseDto; + + beforeAll(async () => { + spaceOwner = await utils.userSetup(admin.accessToken, createUserDto.create('t18-space-owner')); + const filepath = join(testAssetDir, 'metadata/gps-position/thompson-springs.jpg'); + spaceGpsAsset = await utils.createAsset(spaceOwner.accessToken, { + assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, + }); + // Wait for metadata (GPS) extraction. The file's websocket is connected as `user`, so + // waitForWebsocketEvent will NOT see spaceOwner's upload — poll the post-condition instead + // (memory feedback_e2e_waitforqueuefinish_false_done). + let exifReady = false; + for (let i = 0; i < 30; i++) { + const info = await utils.getAssetInfo(spaceOwner.accessToken, spaceGpsAsset.id); + if (info.exifInfo?.latitude != null) { + exifReady = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (!exifReady) { + throw new Error('Timed out waiting for GPS metadata extraction on spaceGpsAsset'); + } + + const space = await utils.createSpace(spaceOwner.accessToken, { name: 'map-fav-space' }); + await utils.addSpaceMember(spaceOwner.accessToken, space.id, { + userId: user.userId, + role: SharedSpaceRole.Viewer, + }); + await utils.addSpaceAssets(spaceOwner.accessToken, space.id, [spaceGpsAsset.id]); + // The VIEWER favorites the non-owned space asset. + await request(app) + .put('/assets/favorites') + .set(asBearerAuth(user.accessToken)) + .send({ ids: [spaceGpsAsset.id], isFavorite: true }); + }); + + it('/map/markers?isFavorite=true&withSharedSpaces=true includes the cross-space favorite', async () => { + const { status, body } = await request(app) + .get('/map/markers?isFavorite=true&withSharedSpaces=true') + .set(asBearerAuth(user.accessToken)); + expect(status).toBe(200); + expect((body as Array<{ id: string }>).map((m) => m.id)).toContain(spaceGpsAsset.id); + }); + + it('/gallery/map/markers?isFavorite=true&withSharedSpaces=true includes the cross-space favorite', async () => { + const { status, body } = await request(app) + .get('/gallery/map/markers?isFavorite=true&withSharedSpaces=true') + .set(asBearerAuth(user.accessToken)); + expect(status).toBe(200); + expect((body as Array<{ id: string }>).map((m) => m.id)).toContain(spaceGpsAsset.id); + }); + + it("the owner's map does not show the viewer's favorite as their own (no cross-user read)", async () => { + const { body } = await request(app).get('/map/markers?isFavorite=true').set(asBearerAuth(spaceOwner.accessToken)); + expect((body as Array<{ id: string }>).map((m) => m.id)).not.toContain(spaceGpsAsset.id); + }); + }); }); diff --git a/e2e/src/specs/server/api/shared-space-album.e2e-spec.ts b/e2e/src/specs/server/api/shared-space-album.e2e-spec.ts index 63070b54e87d49..05db53c3906ac0 100644 --- a/e2e/src/specs/server/api/shared-space-album.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-space-album.e2e-spec.ts @@ -706,10 +706,18 @@ describe('rbac-3: visibility writes are restricted to owned assets', () => { expect(status).toBe(204); }); - // isFavorite is still one column on asset, so an editor's heart would flip the owner's favorite. - it('editor CANNOT favorite the victim asset → 403', async () => { + // #763: favorites are a per-user overlay (asset_favorite), so the editor's bulk isFavorite write + // succeeds but must create ONLY the editor's own row and leave the victim's (owner's) favorite + // state completely untouched. + it("editor bulk-favoriting the victim's asset creates only the EDITOR'S OWN favorite row, leaving the victim's state untouched (#763 per-user favorites)", async () => { const { status } = await bulkUpdateAssets(editor.accessToken, { ids: [victimAsset.id], isFavorite: true }); - expect(status).toBe(403); + expect(status).toBe(204); + + const editorView = await utils.getAssetInfo(editor.accessToken, victimAsset.id); + expect(editorView.isFavorite).toBe(true); + + const victimView = await utils.getAssetInfo(victim.accessToken, victimAsset.id); + expect(victimView.isFavorite).toBe(false); }); }); diff --git a/e2e/src/specs/server/api/spaces-selection-actions.e2e-spec.ts b/e2e/src/specs/server/api/spaces-selection-actions.e2e-spec.ts index ae96ae7a045ac8..2d9040d60105c5 100644 --- a/e2e/src/specs/server/api/spaces-selection-actions.e2e-spec.ts +++ b/e2e/src/specs/server/api/spaces-selection-actions.e2e-spec.ts @@ -26,11 +26,12 @@ * that space (#734 spec §2.3; access.repository.ts's `owner_member` EXISTS clause). The only * restriction is a dedicated guard for `visibility`/`livePhotoVideoId` (asset.service.ts:214-224, * "rbac-3"), whose own comment says explicitly: "other metadata (description/rating/…) stays - * editor-allowed." That guard also covers `isFavorite` and `duplicateId` since 2026-10-05: - * `isFavorite` is still one column on `asset`, so an editor's heart would flip the owner's own. - * So for spaceAssetId (owned by spaceOwner, direct-add to the space): a metadata update succeeds - * for spaceOwner AND spaceEditor, a favorite only for spaceOwner (the editor gets 403 from the - * guard); spaceViewer (role below Editor) and spaceNonMember (no membership) are rejected. + * editor-allowed." That guard also covers `duplicateId`, but NOT `isFavorite`: favorites are a + * per-user overlay (#763), so the alias writes only the caller's own row. + * So for spaceAssetId (owned by spaceOwner, direct-add to the space): a metadata update or an + * alias favorite succeeds for spaceOwner AND spaceEditor; spaceViewer (role below Editor) and + * spaceNonMember (no membership) are rejected by the alias's AssetUpdate gate. The canonical + * `PUT /assets/favorites` is gated on AssetRead instead, so a spaceViewer may favorite too. * (spaceOwner satisfies the owner-membership condition trivially here — `createSpace` auto-adds * its creator as an Owner member, so spaceAssetId's owner is always a space member in this * fixture; see access-space-edit.repository.spec.ts for the case where the owner is NOT a @@ -131,11 +132,12 @@ describe('Server/API RBAC matrix — selection-toolbar consistency (Slice 1)', ( // ───────────────────────────────────────────────────────────────────────── // 4. Favorite — PUT /assets bulk update, isFavorite (Permission.AssetUpdate) - // See file-level correction #1: the editor passes AssetUpdate, but isFavorite is owner-only. + // See file-level correction #1: the editor passes AssetUpdate, and the favorite it writes is + // the editor's own (#763), so it is not owner-gated. // ───────────────────────────────────────────────────────────────────────── describe('Favorite — PUT /assets (bulk, isFavorite)', () => { - it('Given spaceAssetId, When each actor bulk-favorites it, Then only the owner succeeds: the editor passes AssetUpdate but isFavorite is owner-only (403), and viewer/non-member fail AssetUpdate (400)', async () => { + it('Given spaceAssetId, When each actor bulk-favorites it via the alias, Then owner and editor succeed (each writes their own favorite) and viewer/non-member fail AssetUpdate (400)', async () => { await forEachActor( [ctx.spaceOwner, ctx.spaceEditor, ctx.spaceViewer, ctx.spaceNonMember], (actor) => @@ -143,7 +145,21 @@ describe('Server/API RBAC matrix — selection-toolbar consistency (Slice 1)', ( .put('/assets') .set(authHeaders(actor)) .send({ ids: [ctx.spaceAssetId], isFavorite: true }), - { spaceOwner: 204, spaceEditor: 403, spaceViewer: 400, spaceNonMember: 400 }, + { spaceOwner: 204, spaceEditor: 204, spaceViewer: 400, spaceNonMember: 400 }, + ); + }); + }); + + describe('Favorite — PUT /assets/favorites (canonical, Permission.AssetRead)', () => { + it('Given spaceAssetId, When each actor favorites it, Then every space member succeeds (viewer included) and the non-member is rejected', async () => { + await forEachActor( + [ctx.spaceOwner, ctx.spaceEditor, ctx.spaceViewer, ctx.spaceNonMember], + (actor) => + request(app) + .put('/assets/favorites') + .set(authHeaders(actor)) + .send({ ids: [ctx.spaceAssetId], isFavorite: true }), + { spaceOwner: 204, spaceEditor: 204, spaceViewer: 204, spaceNonMember: 400 }, ); }); }); diff --git a/e2e/src/specs/server/api/timeline.e2e-spec.ts b/e2e/src/specs/server/api/timeline.e2e-spec.ts index c9528db79f0745..fa180145985b7d 100644 --- a/e2e/src/specs/server/api/timeline.e2e-spec.ts +++ b/e2e/src/specs/server/api/timeline.e2e-spec.ts @@ -366,6 +366,186 @@ describe('/timeline', () => { }); }); + describe('GET /timeline — cross-scope favorites (#763 slice 4)', () => { + beforeAll(async () => { + // spaceViewer favorites the space asset they can only READ (owned by spaceOwner); + // spaceOwner favorites the partner's asset AND their own in-space asset. + await request(app) + .put('/assets/favorites') + .set(asBearerAuth(ctx.spaceViewer.token!)) + .send({ ids: [ctx.spaceAssetId], isFavorite: true }); + await request(app) + .put('/assets/favorites') + .set(asBearerAuth(ctx.spaceOwner.token!)) + .send({ ids: [ctx.partnerAssetId!, ctx.spaceAssetId], isFavorite: true }); + }); + + it('isFavorite=true + withSharedSpaces returns the NON-OWNED space favorite in the payload (E15)', async () => { + const buckets = await request(app) + .get('/timeline/buckets?visibility=timeline&withSharedSpaces=true&isFavorite=true') + .set(asBearerAuth(ctx.spaceViewer.token!)); + expect(buckets.status).toBe(200); + // spaceViewer owns nothing; their only favorite is the space asset → exactly 1. + expect(total(buckets.body)).toBe(1); + + const timeBucket = (buckets.body as Array<{ timeBucket: string }>)[0].timeBucket; + const bucket = await request(app) + .get( + `/timeline/bucket?visibility=timeline&withSharedSpaces=true&isFavorite=true&timeBucket=${encodeURIComponent(timeBucket)}`, + ) + .set(asBearerAuth(ctx.spaceViewer.token!)); + expect(bucket.status).toBe(200); + // §10.9: the non-owned favorite must actually be IN the payload, with the caller's flag. + expect(bucket.body.id).toContain(ctx.spaceAssetId); + expect(bucket.body.isFavorite[bucket.body.id.indexOf(ctx.spaceAssetId)]).toBe(true); + }); + + it('isFavorite=true + withPartners returns the partner-owned favorite (E16)', async () => { + const buckets = await request(app) + .get('/timeline/buckets?visibility=timeline&withPartners=true&isFavorite=true') + .set(asBearerAuth(ctx.spaceOwner.token!)); + expect(buckets.status).toBe(200); + // spaceOwner favorited partnerAssetId + their own spaceAssetId → 2. + expect(total(buckets.body)).toBe(2); + + const timeBucket = (buckets.body as Array<{ timeBucket: string }>)[0].timeBucket; + const bucket = await request(app) + .get( + `/timeline/bucket?visibility=timeline&withPartners=true&isFavorite=true&timeBucket=${encodeURIComponent(timeBucket)}`, + ) + .set(asBearerAuth(ctx.spaceOwner.token!)); + expect(bucket.status).toBe(200); + expect(bucket.body.id).toContain(ctx.partnerAssetId); + }); + + it('isFavorite=FALSE also composes with both scopes — the old guard tripped on false too (E16b)', async () => { + // spaceViewer: readable timeline = the 1 space asset, which they favorited → not-favorited = 0. + const spaces = await request(app) + .get('/timeline/buckets?visibility=timeline&withSharedSpaces=true&isFavorite=false') + .set(asBearerAuth(ctx.spaceViewer.token!)); + expect(spaces.status).toBe(200); + expect(total(spaces.body)).toBe(0); + + // spaceOwner: own 2 + partner 1, minus their 2 favorites → 1 (ownerAssetId). + const partners = await request(app) + .get('/timeline/buckets?visibility=timeline&withPartners=true&isFavorite=false') + .set(asBearerAuth(ctx.spaceOwner.token!)); + expect(partners.status).toBe(200); + expect(total(partners.body)).toBe(1); + }); + + it('favorites + spaces + partners simultaneously, and an own-and-in-space favorite appears ONCE', async () => { + const buckets = await request(app) + .get('/timeline/buckets?visibility=timeline&withPartners=true&withSharedSpaces=true&isFavorite=true') + .set(asBearerAuth(ctx.spaceOwner.token!)); + expect(buckets.status).toBe(200); + // spaceAssetId is owned by the caller AND in the space (two scope branches) — it must + // count once. partnerAssetId + spaceAssetId → exactly 2. + expect(total(buckets.body)).toBe(2); + + const timeBucket = (buckets.body as Array<{ timeBucket: string }>)[0].timeBucket; + const bucket = await request(app) + .get( + `/timeline/bucket?visibility=timeline&withPartners=true&withSharedSpaces=true&isFavorite=true&timeBucket=${encodeURIComponent(timeBucket)}`, + ) + .set(asBearerAuth(ctx.spaceOwner.token!)); + const ids = bucket.body.id as string[]; + expect(ids.filter((id) => id === ctx.spaceAssetId)).toHaveLength(1); + }); + + // REVERSED 2026-09-06, deliberately. This test previously asserted that showInTimeline=false + // hides space favorites "like any other space content". A tester hit that on pr-819-rc.2 and + // reported it as a bug: favoriting inside a hidden space gave "Added to favorites" and the + // photo never appeared under Favorites, with no way to reach it. + // + // Favorites are now EXEMPT from the hide, which is the same carve-out #1041 already makes for + // Favorites/Archive/Trash: those are curation and recovery surfaces, where subtracting an asset + // strands it instead of merely decluttering a browse. `showInTimeline` keeps its meaning for + // every other query — the sibling test at ~:290 still pins that, and so does a unit test on + // which resolver each browse calls. Favoriting is an explicit, per-asset act; a browse + // preference should not silently annul it. + it('showInTimeline=false does NOT hide your own favorites — they stay reachable (#763)', async () => { + // Mirror the toggle pattern of the existing 'toggling showInTimeline=false' test in this + // file (~:290) — same endpoint, same restore-afterwards discipline (try/finally). + try { + const disable = await request(app) + .patch(`/shared-spaces/${ctx.spaceId}/members/me/timeline`) + .set(asBearerAuth(ctx.spaceViewer.token!)) + .send({ showInTimeline: false }); + expect(disable.status).toBe(200); + + const { status, body } = await request(app) + .get('/timeline/buckets?visibility=timeline&withSharedSpaces=true&isFavorite=true') + .set(asBearerAuth(ctx.spaceViewer.token!)); + expect(status).toBe(200); + // Still 1 — the same count the finally block asserts once the space is shown again, which + // is the point: the toggle no longer changes what the favorites surface returns. + expect(total(body)).toBe(1); + } finally { + const restore = await request(app) + .patch(`/shared-spaces/${ctx.spaceId}/members/me/timeline`) + .set(asBearerAuth(ctx.spaceViewer.token!)) + .send({ showInTimeline: true }); + expect(restore.status).toBe(200); + + const { status, body } = await request(app) + .get('/timeline/buckets?visibility=timeline&withSharedSpaces=true&isFavorite=true') + .set(asBearerAuth(ctx.spaceViewer.token!)); + expect(status).toBe(200); + expect(total(body)).toBe(1); + } + }); + + it('withSharedSpaces + isFavorite WITHOUT explicit visibility still 400s (archive arm untouched)', async () => { + const { status } = await request(app) + .get('/timeline/buckets?withSharedSpaces=true&isFavorite=true') + .set(asBearerAuth(ctx.spaceViewer.token!)); + expect(status).toBe(400); + }); + }); + + describe('GET /timeline — favorited stack children surface standalone (#763 slice 4, E31)', () => { + let stackUser: { accessToken: string }; + let primaryId: string; + let childId: string; + + beforeAll(async () => { + const login = await utils.userSetup(ctx.admin.token!, createUserDto.create('stack-fav')); + stackUser = login; + const [a, b] = await Promise.all([utils.createAsset(login.accessToken), utils.createAsset(login.accessToken)]); + const stack = await utils.createStack(login.accessToken, [a.id, b.id]); + primaryId = stack.primaryAssetId; + childId = stack.primaryAssetId === a.id ? b.id : a.id; + // Favorite ONLY the child. + await request(app) + .put('/assets/favorites') + .set(asBearerAuth(login.accessToken)) + .send({ ids: [childId], isFavorite: true }); + }); + + it('unfiltered withStacked timeline still collapses to the primary (regression)', async () => { + const { body } = await request(app) + .get('/timeline/buckets?withStacked=true') + .set(asBearerAuth(stackUser.accessToken)); + expect(total(body)).toBe(1); + }); + + it('isFavorite=true + withStacked returns the favorited CHILD standalone (E31, §5.4)', async () => { + const buckets = await request(app) + .get('/timeline/buckets?withStacked=true&isFavorite=true') + .set(asBearerAuth(stackUser.accessToken)); + expect(buckets.status).toBe(200); + expect(total(buckets.body)).toBe(1); + + const timeBucket = (buckets.body as Array<{ timeBucket: string }>)[0].timeBucket; + const bucket = await request(app) + .get(`/timeline/bucket?withStacked=true&isFavorite=true&timeBucket=${encodeURIComponent(timeBucket)}`) + .set(asBearerAuth(stackUser.accessToken)); + expect(bucket.body.id).toContain(childId); + expect(bucket.body.id).not.toContain(primaryId); + }); + }); + describe('GET /timeline/buckets — visibility filters', () => { // Dedicated user with 4 assets in different visibility states. Using a fresh user // (not spaceOwner) keeps the asset counts deterministic and avoids polluting other diff --git a/e2e/src/specs/server/api/trash.e2e-spec.ts b/e2e/src/specs/server/api/trash.e2e-spec.ts index eaca6d0eb291e7..bdd04791030c52 100644 --- a/e2e/src/specs/server/api/trash.e2e-spec.ts +++ b/e2e/src/specs/server/api/trash.e2e-spec.ts @@ -201,6 +201,36 @@ describe('/trash', () => { expect(after.isTrashed).toBe(false); }); + // #763 slice 7 (E14): trash is a soft delete — it must never touch asset_favorite (grep + // confirms zero references in trash.service.ts / trash.repository.ts). No CASCADE fires + // until the asset is hard-deleted, so a favorite must survive a trash → restore round trip. + it('a favorited asset survives trash and restore (E14)', async () => { + const { id: assetId } = await utils.createAsset(admin.accessToken); + + const fav = await request(app) + .put('/assets/favorites') + .set(asBearerAuth(admin.accessToken)) + .send({ ids: [assetId], isFavorite: true }); + expect(fav.status).toBe(204); + const before = await utils.getAssetInfo(admin.accessToken, assetId); + expect(before.isFavorite).toBe(true); + + await utils.deleteAssets(admin.accessToken, [assetId]); + const trashed = await utils.getAssetInfo(admin.accessToken, assetId); + expect(trashed.isTrashed).toBe(true); + expect(trashed.isFavorite).toBe(true); + + const { status } = await request(app) + .post('/trash/restore/assets') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [assetId] }); + expect(status).toBe(200); + + const restored = await utils.getAssetInfo(admin.accessToken, assetId); + expect(restored.isTrashed).toBe(false); + expect(restored.isFavorite).toBe(true); + }); + it('should not restore an offline asset', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, diff --git a/e2e/src/specs/web/recently-added-filters.e2e-spec.ts b/e2e/src/specs/web/recently-added-filters.e2e-spec.ts index 757e23996ed39c..641e296bc4f00a 100644 --- a/e2e/src/specs/web/recently-added-filters.e2e-spec.ts +++ b/e2e/src/specs/web/recently-added-filters.e2e-spec.ts @@ -387,8 +387,9 @@ test.describe('Recently Added text search', () => { // outgoing DTO when the caller passes a truthy `withSharedSpaces`; when falsy, the key is // omitted entirely (not sent as `false`). Recently Added always passes `withSharedSpaces={false}` // (own + partner scope only — see the route-level spec's "does not send shared-space scope to - // SmartSearchResults"), while /photos passes `withSharedSpaces={filters.isFavorite === undefined}`, - // i.e. `true` by default. So the same query, run on both pages, must produce two different + // SmartSearchResults"), while /photos passes `withSharedSpaces={true}` unconditionally (since + // #763 made favorites per-user, a Favorites filter no longer narrows the scope there). + // So the same query, run on both pages, must produce two different // request bodies — that difference *is* the own+partner-vs-shared-spaces scope invariant, and // it's fully deterministic (it never touches embeddings or matching). test('text search stays within own + partner scope', async ({ context, page }) => { diff --git a/e2e/src/specs/web/space-viewer-favorite.e2e-spec.ts b/e2e/src/specs/web/space-viewer-favorite.e2e-spec.ts new file mode 100644 index 00000000000000..6e30f4e6e68699 --- /dev/null +++ b/e2e/src/specs/web/space-viewer-favorite.e2e-spec.ts @@ -0,0 +1,168 @@ +import { AssetMediaResponseDto, LoginResponseDto, SharedSpaceResponseDto, SharedSpaceRole } from '@immich/sdk'; +import { expect, test, type Page } from '@playwright/test'; +import { createUserDto } from 'src/fixtures'; +import { thumbnailUtils } from 'src/ui/specs/timeline/utils'; +import { utils } from 'src/utils'; + +// Web E2E for the literal #763 scenario: a space VIEWER (non-owner) favorites a photo they don't +// own, through the browser UI. Slice-5 (web Tasks 1-2, commits 6a8163181e/9a3195516e/867301fd23) +// un-gated the heart from ownership everywhere and switched the write path to +// `PUT /assets/favorites` (per-user favorites, server-side since slice 2). Before that change, +// both entry points below were hard-gated to `isOwner` and simply didn't render for a viewer. +// +// Two entry points, matching the two write paths slice 5 un-gated: +// A) the asset viewer's Favorite/Unfavorite action button (AssetViewerNavBar / asset.service.ts) +// B) the timeline multi-select bar's Favorite button (FavoriteAction.svelte) +// +// Each test builds its own space + owner-uploaded asset + viewer membership via the API so the +// two tests never depend on one another's mutated favorite state (spaces-albums-timeline.e2e-spec.ts +// convention). Role-text assertions elsewhere in this suite use `{ ignoreCase: true }` because the +// space role badge renders lowercase with CSS capitalize — noted here as this spec doesn't assert +// role text directly but reuses the same fixture idiom. + +// Navigates and waits for the initial `/timeline/buckets` fetch the destination page's Timeline +// component fires on mount, so a subsequent interaction isn't racing the load. +async function gotoAndWaitForTimeline(page: Page, url: string, readyTestId?: string) { + const bucketsResponse = page.waitForResponse((response) => response.url().includes('/timeline/buckets')); + await page.goto(url); + await bucketsResponse; + if (readyTestId) { + await page.waitForSelector(`[data-testid="${readyTestId}"]`); + } +} + +async function createSpaceWithViewerAsset( + ownerToken: string, + viewerId: string, + namePrefix: string, +): Promise<{ space: SharedSpaceResponseDto; asset: AssetMediaResponseDto }> { + const space = await utils.createSpace(ownerToken, { name: `${namePrefix} Space` }); + await utils.addSpaceMember(ownerToken, space.id, { userId: viewerId, role: SharedSpaceRole.Viewer }); + const asset = await utils.createAsset(ownerToken); + await utils.addSpaceAssets(ownerToken, space.id, [asset.id]); + return { space, asset }; +} + +test.describe('Space viewer favorites a non-owned photo (#763)', () => { + let admin: LoginResponseDto; + let owner: LoginResponseDto; + let viewer: LoginResponseDto; + + test.beforeAll(async () => { + utils.initSdk(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + + [owner, viewer] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.create('svf-owner')), + utils.userSetup(admin.accessToken, createUserDto.create('svf-viewer')), + ]); + }); + + // A) Asset viewer: open the non-owned photo from the space, favorite it via the navbar action, + // reload to prove server-side persistence (not just optimistic client state), and confirm via + // API that the owner's own favorite state was never touched (per-user write, not a shared field). + test('viewer favorites the photo from the asset viewer; state persists and the owner is unaffected', async ({ + context, + page, + }) => { + const { space, asset } = await createSpaceWithViewerAsset(owner.accessToken, viewer.userId, 'SVF-A'); + await utils.setAuthCookies(context, viewer.accessToken); + + await gotoAndWaitForTimeline(page, `/spaces/${space.id}`, 'discovery-timeline'); + await thumbnailUtils.withAssetId(page, asset.id).click(); + await page.waitForURL(`/spaces/${space.id}/photos/${asset.id}`); + await page.waitForSelector('#immich-asset-viewer'); + + const navActions = page.getByTestId('asset-viewer-navbar-actions'); + const favoriteButton = navActions.getByRole('button', { name: 'Favorite', exact: true }); + const unfavoriteButton = navActions.getByRole('button', { name: 'Unfavorite', exact: true }); + + // Pre-slice-5 this action was ownership-gated and would not render at all for a non-owner. + await expect(favoriteButton).toBeVisible(); + await expect(unfavoriteButton).toHaveCount(0); + + const favoriteResponse = page.waitForResponse( + (response) => response.request().method() === 'PUT' && response.url().includes('/assets/favorites'), + ); + await favoriteButton.click(); + await favoriteResponse; + await expect(page.getByText('Added to favorites')).toBeVisible(); + + // Reload: prove the Unfavorite state came back from the server, not just client-side optimism. + await page.reload(); + await page.waitForSelector('#immich-asset-viewer'); + await expect( + page.getByTestId('asset-viewer-navbar-actions').getByRole('button', { name: 'Unfavorite', exact: true }), + ).toBeVisible(); + await expect( + page.getByTestId('asset-viewer-navbar-actions').getByRole('button', { name: 'Favorite', exact: true }), + ).toHaveCount(0); + + // The write is scoped to the viewer: the owner's own favorite state must be untouched. + const ownerAssetInfo = await utils.getAssetInfo(owner.accessToken, asset.id); + expect(ownerAssetInfo.isFavorite).toBe(false); + }); + + // B) Timeline multi-select bar: hover-select the non-owned photo on the space Photos tab and + // favorite it from the selection bar (previously hidden entirely for a non-owned selection). + test('viewer favorites the photo via the space timeline multi-select bar', async ({ context, page }) => { + const { space, asset } = await createSpaceWithViewerAsset(owner.accessToken, viewer.userId, 'SVF-B'); + await utils.setAuthCookies(context, viewer.accessToken); + + await gotoAndWaitForTimeline(page, `/spaces/${space.id}`, 'discovery-timeline'); + + const thumb = thumbnailUtils.withAssetId(page, asset.id); + await expect(thumb).toBeVisible(); + await thumb.hover(); + await thumbnailUtils.selectButton(page, asset.id).click(); + + // Pre-slice-5 the heart was hidden from the selection bar for a non-owned selection. + const favoriteButton = page.getByRole('button', { name: 'Favorite', exact: true }); + await expect(favoriteButton).toBeVisible(); + + const favoriteResponse = page.waitForResponse( + (response) => response.request().method() === 'PUT' && response.url().includes('/assets/favorites'), + ); + await favoriteButton.click(); + await favoriteResponse; + + await expect(page.getByText('Added 1 to favorites')).toBeVisible(); + await thumbnailUtils.expectThumbnailIsFavorite(page, asset.id); + }); + + // C) The reported pr-819-rc.1 bug, end to end: favoriting a non-owned photo succeeded (toast + + // filled heart) but /favorites rendered its empty placeholder, so the feature was unusable for + // exactly the account it exists for. Tests A and B stop at the write and the heart, which is why + // they stayed green through it. + // + // The break this catches: the /favorites page requesting the timeline without `withSharedSpaces` + // (or `withPartners`). The server only resolves `timelineSpaceIds` when that flag is present; + // without it the query hard-filters `asset.ownerId = caller` and every favorite on a non-owned + // asset vanishes from the page while its `asset_favorite` row survives untouched. + test("the favorited non-owned photo appears on the viewer's /favorites page", async ({ context, page }) => { + const { space, asset } = await createSpaceWithViewerAsset(owner.accessToken, viewer.userId, 'SVF-C'); + await utils.setAuthCookies(context, viewer.accessToken); + + await gotoAndWaitForTimeline(page, `/spaces/${space.id}`, 'discovery-timeline'); + const favoriteResponse = page.waitForResponse( + (response) => response.request().method() === 'PUT' && response.url().includes('/assets/favorites'), + ); + const thumb = thumbnailUtils.withAssetId(page, asset.id); + await thumb.hover(); + await thumbnailUtils.selectButton(page, asset.id).click(); + await page.getByRole('button', { name: 'Favorite', exact: true }).click(); + await favoriteResponse; + await expect(page.getByText('Added 1 to favorites')).toBeVisible(); + + // The collection view, not the asset — this is the assertion the shipped bug failed. + await gotoAndWaitForTimeline(page, '/favorites'); + await expect(thumbnailUtils.withAssetId(page, asset.id)).toBeVisible(); + + // The owner never favorited it, so their own /favorites page must stay empty — proving the page + // is per-user and not merely showing every space asset. + await utils.setAuthCookies(context, owner.accessToken); + await gotoAndWaitForTimeline(page, '/favorites'); + await expect(thumbnailUtils.withAssetId(page, asset.id)).toHaveCount(0); + }); +}); diff --git a/e2e/src/specs/web/spaces-selection-toolbar-album.e2e-spec.ts b/e2e/src/specs/web/spaces-selection-toolbar-album.e2e-spec.ts index 9cbb5a2863bb85..bbabfebe8e3b4a 100644 --- a/e2e/src/specs/web/spaces-selection-toolbar-album.e2e-spec.ts +++ b/e2e/src/specs/web/spaces-selection-toolbar-album.e2e-spec.ts @@ -12,9 +12,10 @@ import { asBearerAuth, utils } from 'src/utils.js'; // // getSelectionCapabilities' isSpaceAlbum branch (web/src/lib/managers/selection-capabilities.ts): // - canSelectAll / canDownload: always true once a selection is active. -// - canShare / canAddToAlbum / canFavorite / canEditMetadata (incl. Delete): gated on the -// selection being entirely owned by the current viewer (`isAllUserOwned`) — NOT on -// space/album role. +// - canShare / canAddToAlbum / canEditMetadata (incl. Delete): gated on the selection being +// entirely owned by the current viewer (`isAllUserOwned`) — NOT on space/album role. +// - canFavorite: always true once a selection is active (#763) — a favorite is a per-user row in +// the `asset_favorite` overlay, so it never mutates another member's asset. // - canRemoveFromAlbum / canSetCover: gated on `canManage` (space.canWrite || album.isEditor) — // NOT on asset ownership (decision C: AlbumAssetDelete is role-gated server-side). // - canRemoveFromSpace: always false here (this is the album path, not the direct-space path). @@ -99,10 +100,11 @@ test.describe('Spaces — SelectionToolbar space-album control bar (Slice 6)', ( // Always-on, top-level. await expect(controlBar.getByRole('button', { name: 'Select all' })).toBeVisible(); - // Ownership-gated, top-level: all hidden (viewer doesn't own the asset). + // Ownership-gated, top-level: hidden (viewer doesn't own the asset). await expect(controlBar.getByRole('button', { name: 'Share' })).toHaveCount(0); await expect(controlBar.getByRole('button', { name: 'Add to album or space' })).toHaveCount(0); - await expect(controlBar.getByRole('button', { name: /favorite/i })).toHaveCount(0); + // NOT ownership-gated (#763): the favorite lands in the viewer's own overlay row. + await expect(controlBar.getByRole('button', { name: /favorite/i })).toBeVisible(); await openOverflowMenu(controlBar); @@ -117,7 +119,7 @@ test.describe('Spaces — SelectionToolbar space-album control bar (Slice 6)', ( // Case 6: a space Editor (canManage via space role alone — no album participant role at all) // selecting an asset they don't own. canManage is true (Remove-from-album / Set-cover visible) - // but isAllUserOwned is false (Share / Add-to-album / Favorite / Delete / metadata-edit hidden) + // but isAllUserOwned is false (Share / Delete / metadata-edit hidden; Favorite stays per #763) // — manager and ownership gating are independent axes, exactly as selection-capabilities.ts // encodes them for the space-album (isSpaceAlbum) branch. test('editor (canManage) selecting a not-owned album asset sees Select-all + Download + Remove-from-album + Set-cover + Share', async ({ @@ -145,7 +147,9 @@ test.describe('Spaces — SelectionToolbar space-album control bar (Slice 6)', ( await expect(controlBar.getByRole('button', { name: 'Select all' })).toBeVisible(); // Ownership-gated, top-level: hidden (editor doesn't own the asset). - await expect(controlBar.getByRole('button', { name: /favorite/i })).toHaveCount(0); + // Favorites are per-user (#763), so this one is NOT ownership-gated — it stays available on + // a non-owned asset, unlike the actions above. + await expect(controlBar.getByRole('button', { name: /favorite/i })).toBeVisible(); // Role-gated: a space Editor may publish a non-owned asset through the space (#1018), so // Share stays — the link is created against the space and warns before it is made. diff --git a/e2e/src/specs/web/spaces-selection-toolbar-person.e2e-spec.ts b/e2e/src/specs/web/spaces-selection-toolbar-person.e2e-spec.ts index bc85df4d59e3ff..a93675c1010d1f 100644 --- a/e2e/src/specs/web/spaces-selection-toolbar-person.e2e-spec.ts +++ b/e2e/src/specs/web/spaces-selection-toolbar-person.e2e-spec.ts @@ -11,10 +11,12 @@ import { utils } from 'src/utils.js'; // never render there — this page has no cover action (see the design doc's "Cover note"). // // This is a single smoke case (not the full role x ownership matrix already covered by the -// timeline spec): a space Viewer selecting a person's asset they don't own sees only the two -// always-on actions (Select-all, Download); every ownership-gated action (Share, Add-to-album, -// Favorite, edit, Delete) and the role-gated Remove-from-space are hidden, and Set-cover never -// appears at all. +// timeline spec): a space Viewer selecting a person's asset they don't own sees the two +// always-on actions (Select-all, Download) plus Favorite; every ownership-gated action (Share, +// Add-to-album, edit, Delete) and the role-gated Remove-from-space are hidden, and Set-cover never +// appears at all. Favorite is deliberately NOT ownership-gated (#763): it writes a per-user +// `asset_favorite` row rather than mutating the asset, so `canFavorite` is unconditionally true — +// even a read-only space viewer gets the heart. Same expectation as the -album/-timeline siblings. // Navigates and waits for the initial `/timeline/buckets` fetch the destination page's Timeline // component fires on mount, so a subsequent selection/assertion isn't racing the load. @@ -63,8 +65,9 @@ test.describe('Spaces — SelectionToolbar space-person control bar (Slice 5)', // Case 8: a space Viewer selecting a person's asset that belongs to another member (`owner`). // isAllUserOwned is false and space.canWrite is false (Viewer role) — every ownership-gated AND - // every role-gated action is hidden, and Set-cover never appears (no onSetCover on this page). - test("viewer selecting a space person's asset (not theirs) sees only Select-all and Download", async ({ + // every role-gated action is hidden (except Favorite, which is not on either axis — #763), and + // Set-cover never appears (no onSetCover on this page). + test("viewer selecting a space person's asset (not theirs) sees only Select-all, Favorite and Download", async ({ context, page, }) => { @@ -107,7 +110,8 @@ test.describe('Spaces — SelectionToolbar space-person control bar (Slice 5)', // Ownership-gated, top-level: all hidden (viewer doesn't own the asset). await expect(controlBar.getByRole('button', { name: 'Share' })).toHaveCount(0); await expect(controlBar.getByRole('button', { name: 'Add to album or space' })).toHaveCount(0); - await expect(controlBar.getByRole('button', { name: /favorite/i })).toHaveCount(0); + // NOT ownership-gated (#763): the favorite lands in the viewer's own overlay row. + await expect(controlBar.getByRole('button', { name: /favorite/i })).toBeVisible(); // Role-gated, top-level: hidden (Viewer has no write access to the space). await expect(controlBar.getByRole('button', { name: 'Remove from space' })).toHaveCount(0); diff --git a/e2e/src/specs/web/spaces-selection-toolbar-timeline.e2e-spec.ts b/e2e/src/specs/web/spaces-selection-toolbar-timeline.e2e-spec.ts index b9a09a7c96f9bc..30e9762781e42e 100644 --- a/e2e/src/specs/web/spaces-selection-toolbar-timeline.e2e-spec.ts +++ b/e2e/src/specs/web/spaces-selection-toolbar-timeline.e2e-spec.ts @@ -11,8 +11,10 @@ import { utils } from 'src/utils.js'; // set for a direct-space surface across the role x ownership matrix the rule engine encodes: // // - canSelectAll / canDownload: always true once a selection is active, regardless of role. -// - canFavorite / canEditMetadata (incl. Delete): gated on the selection being entirely owned -// by the current viewer (`isAllUserOwned`), NOT on space role. +// - canEditMetadata (incl. Delete): gated on the selection being entirely owned by the +// current viewer (`isAllUserOwned`), NOT on space role. +// - canFavorite: always true once a selection is active (#763). A favorite is a per-user row in +// the `asset_favorite` overlay, so favoriting never mutates another member's asset. // - canShare: gated on owning SOME of the selection — the action sends only the owned subset. // - canAddToAlbum: `isAllUserOwned` OR space Editor/Owner. A space manager may contribute // non-owned assets into an album linked to the space (#764); the picker then narrows to @@ -102,10 +104,11 @@ test.describe('Spaces — SelectionToolbar timeline control bar (Slice 4)', () = // Always-on, top-level. await expect(controlBar.getByRole('button', { name: 'Select all' })).toBeVisible(); - // Ownership-gated, top-level: all hidden (viewer doesn't own the asset). + // Ownership-gated, top-level: hidden (viewer doesn't own the asset). await expect(controlBar.getByRole('button', { name: 'Share' })).toHaveCount(0); await expect(controlBar.getByRole('button', { name: 'Add to album or space' })).toHaveCount(0); - await expect(controlBar.getByRole('button', { name: /favorite/i })).toHaveCount(0); + // NOT ownership-gated (#763): the favorite lands in the viewer's own overlay row. + await expect(controlBar.getByRole('button', { name: /favorite/i })).toBeVisible(); // Role-gated, top-level: hidden (Viewer has no write access to the space). await expect(controlBar.getByRole('button', { name: 'Remove from space' })).toHaveCount(0); @@ -149,7 +152,8 @@ test.describe('Spaces — SelectionToolbar timeline control bar (Slice 4)', () = }); // Case 3: a space Editor selecting the owner's (not their own) asset. isAllUserOwned is false - // (Share/Favorite/Delete hidden) but space.canWrite is still true, so the role-gated actions + // (Share/Delete hidden; Favorite stays visible per #763) but space.canWrite is still true, so + // the role-gated actions // stay visible: Remove-from-space, Set-cover, and Add-to-album — the last because a space // manager may contribute a non-owned asset into an album linked to this space (#764). test("editor selecting the owner's asset sees role-gated actions plus add-to-album and share", async ({ @@ -174,9 +178,10 @@ test.describe('Spaces — SelectionToolbar timeline control bar (Slice 4)', () = await expect(controlBar.getByRole('button', { name: 'Add to album or space' })).toBeVisible(); // #1018: a space Owner/Editor may also publish assets they do not own, through the space — - // the second such action after add-to-album (#764). Favorite stays ownership-gated. + // the second such action after add-to-album (#764). await expect(controlBar.getByRole('button', { name: 'Share' })).toBeVisible(); - await expect(controlBar.getByRole('button', { name: /favorite/i })).toHaveCount(0); + // Favorites are per-user (#763), so this stays available on a non-owned asset. + await expect(controlBar.getByRole('button', { name: /favorite/i })).toBeVisible(); await openOverflowMenu(controlBar); @@ -208,8 +213,8 @@ test.describe('Spaces — SelectionToolbar timeline control bar (Slice 4)', () = await expect(controlBar.getByRole('button', { name: 'Share' })).toBeVisible(); const addButton = controlBar.getByRole('button', { name: 'Add to album or space' }); await expect(addButton).toBeVisible(); - // Mixed ownership still blocks the all-asset mutations. - await expect(controlBar.getByRole('button', { name: /favorite/i })).toHaveCount(0); + // Favorite survives mixed ownership (#763) — it writes one overlay row per asset for this user. + await expect(controlBar.getByRole('button', { name: /favorite/i })).toBeVisible(); await addButton.click(); // Restricted picker: the explanatory notice is shown and neither create row is offered, diff --git a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts index 6f8cd2f1bbd376..f637ef1b885fc8 100644 --- a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts +++ b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts @@ -614,11 +614,8 @@ test.describe('Timeline', () => { const assetToFavorite = assets[0]; changes.assetArchivals.push(assetToFavorite.id); await pageUtils.openArchivePage(page); - const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => { + const favorite = pageRoutePromise(page, '**/api/assets/favorites', async (route, request) => { const requestJson = request.postDataJSON(); - if (requestJson.isFavorite === undefined) { - return await route.continue(); - } const isFavorite = requestJson.isFavorite; if (isFavorite) { changes.assetFavorites.push(...requestJson.ids); @@ -639,11 +636,8 @@ test.describe('Timeline', () => { await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id); await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover(); await thumbnailUtils.selectButton(page, assetToFavorite.id).click(); - const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => { + const unFavoriteRequest = pageRoutePromise(page, '**/api/assets/favorites', async (route, request) => { const requestJson = request.postDataJSON(); - if (requestJson.isFavorite === undefined) { - return await route.continue(); - } changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id)); await route.fulfill({ status: 204, @@ -713,11 +707,8 @@ test.describe('Timeline', () => { await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover(); await thumbnailUtils.selectButton(page, assetToFavorite.id).click(); - const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => { + const favorite = pageRoutePromise(page, '**/api/assets/favorites', async (route, request) => { const requestJson = request.postDataJSON(); - if (requestJson.isFavorite === undefined) { - return await route.continue(); - } const isFavorite = requestJson.isFavorite; if (isFavorite) { changes.assetFavorites.push(...requestJson.ids); @@ -737,11 +728,8 @@ test.describe('Timeline', () => { await thumbnailUtils.expectInViewport(page, assetToFavorite.id); await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover(); await thumbnailUtils.selectButton(page, assetToFavorite.id).click(); - const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => { + const unFavoriteRequest = pageRoutePromise(page, '**/api/assets/favorites', async (route, request) => { const requestJson = request.postDataJSON(); - if (requestJson.isFavorite === undefined) { - return await route.continue(); - } changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id)); await route.fulfill({ status: 204, @@ -807,11 +795,8 @@ test.describe('Timeline', () => { await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover(); await thumbnailUtils.selectButton(page, assetToFavorite.id).click(); - const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => { + const favorite = pageRoutePromise(page, '**/api/assets/favorites', async (route, request) => { const requestJson = request.postDataJSON(); - if (requestJson.isFavorite === undefined) { - return await route.continue(); - } const isFavorite = requestJson.isFavorite; if (isFavorite) { changes.assetFavorites.push(...requestJson.ids); @@ -834,11 +819,8 @@ test.describe('Timeline', () => { await thumbnailUtils.expectInViewport(page, assetToFavorite.id); await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover(); await thumbnailUtils.selectButton(page, assetToFavorite.id).click(); - const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => { + const unFavoriteRequest = pageRoutePromise(page, '**/api/assets/favorites', async (route, request) => { const requestJson = request.postDataJSON(); - if (requestJson.isFavorite === undefined) { - return await route.continue(); - } changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id)); await route.fulfill({ status: 204, diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 1693d7499028ad..03c0aad78e8593 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -115,6 +115,26 @@ class AssetService { await _apiRepository.unStack(stackIds); } + // #763: favorites are a per-user overlay (`asset_favorite`) that a read-only space Viewer may set + // on another member's asset, so they must NOT go through the consolidated `update` below — that + // forwards to the OWNER-ONLY `PUT /assets`. This routes to `PUT /assets/favorites` instead and + // mirrors the change into the local Drift mirror. See + // test/policy/favorite_overlay_policy_test.dart, which fails if a favorite is routed the other way. + Future updateFavorite(List remoteIds, bool isFavorite) async { + if (remoteIds.isEmpty) { + return; + } + + await _apiRepository.updateFavorite(remoteIds, isFavorite); + await _remoteRepository.updateAssets(remoteIds, isFavorite: .some(isFavorite)); + } + + // #763: DO NOT pass `isFavorite` here. It forwards to the OWNER-ONLY bulk-update endpoint + // (`AssetApiRepository.update` -> `PUT /assets`), but favorites are now a per-user overlay + // (`asset_favorite`) that a read-only space Viewer may set on another member's asset — use + // `updateFavorite` above, which routes to `PUT /assets/favorites`. The parameter survives only + // because it is the upstream Immich shape; nothing passes it, and + // test/policy/favorite_overlay_policy_test.dart fails if anything starts to. Future update( List remoteIds, { Option isFavorite = const .none(), diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 06970633197ff4..6ed4b2dd1010d4 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -435,6 +435,11 @@ class SyncStreamService { return _syncStreamRepository.updateSharedSpaceAlbumHiddenV1(data.cast()); case SyncEntityType.sharedSpaceAlbumHiddenDeleteV1: return _syncStreamRepository.deleteSharedSpaceAlbumHiddenV1(data.cast()); + // --- gallery-fork: per-user favorites sync dispatch (#763) --- + case SyncEntityType.assetFavoriteV1: + return _syncStreamRepository.updateAssetFavoritesV1(data.cast()); + case SyncEntityType.assetFavoriteDeleteV1: + return _syncStreamRepository.deleteAssetFavoritesV1(data.cast()); // Forward-compat: openapi-generator v7.24 emits SyncEntityType as a real // exhaustive Dart enum, so upstream dropped the `default:` arm here (it is // now unreachable and trips `unreachable_switch_default`). The plan's diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index 94ee630f360229..b32207c44b812a 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -56,9 +56,9 @@ class SearchApiRepository extends ApiRepository { page: Optional.present(page), size: const Optional.present(100), // Include shared-space assets so a viewer's selected facet returns results (and a - // space-person token resolves). Gated on favourite — favourites are owner-only, mirroring - // web buildPhotosTimelineOptions (`includeSharedTimelineAssets = isFavorite === undefined`). - withSharedSpaces: filter.display.isFavorite ? const Optional.absent() : const Optional.present(true), + // space-person token resolves). #763: favorites are per-user, so the server composes + // shared-space visibility with the favorite filter (slice 4) — no need to suppress it here. + withSharedSpaces: const Optional.present(true), ); return _api.searchSmart(filter.display.isUntagged ? _ExplicitNullTagIdsSmartSearchDto(dto) : dto); } @@ -91,9 +91,9 @@ class SearchApiRepository extends ApiRepository { page: Optional.present(page), size: const Optional.present(1000), // Include shared-space assets so a viewer's selected facet returns results (and a - // space-person token resolves). Gated on favourite — favourites are owner-only, mirroring - // web buildPhotosTimelineOptions (`includeSharedTimelineAssets = isFavorite === undefined`). - withSharedSpaces: filter.display.isFavorite ? const Optional.absent() : const Optional.present(true), + // space-person token resolves). #763: favorites are per-user, so the server composes + // shared-space visibility with the favorite filter (slice 4) — no need to suppress it here. + withSharedSpaces: const Optional.present(true), ); return _api.searchAssets(filter.display.isUntagged ? _ExplicitNullTagIdsMetadataSearchDto(dto) : dto); } diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 83417ed393387a..babd0684e5a577 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -45,6 +45,12 @@ class SyncApiRepository { /// list, never `_legacySpaceAlbumSyncTypes`. static const _spaceAlbumSyncTypes = [..._legacySpaceAlbumSyncTypes, SyncRequestType.sharedSpaceAlbumFoldersV1]; + /// The #763 per-user favorites request type. Introduced after M14 capability signalling + /// shipped, so — like SharedSpaceAlbumFoldersV1 in [_spaceAlbumSyncTypes] — it is only ever + /// requested behind an explicit server declaration (`supportedSyncTypes`), never on a + /// version-gate fallback. + static const _assetFavoriteSyncTypes = [SyncRequestType.assetFavoritesV1]; + Future streamChanges( Future Function(List, Function() abort, Function() reset) onData, { required SemVer serverVersion, @@ -150,6 +156,15 @@ class SyncApiRepository { ...(_spaceAlbumSyncTypes.where((type) => supportedSyncTypes.contains(type.toJson()))) else if (serverVersion > const SemVer(major: 5, minor: 0, patch: 0)) ..._legacySpaceAlbumSyncTypes, + // #763 per-user favorites stream. AssetFavoritesV1 was introduced AFTER capability + // signalling shipped (v5.7.0), so — exactly like SharedSpaceAlbumFoldersV1 above — it + // is sent ONLY behind an explicit server declaration, never on a version fallback: no + // pre-declaration server (fork 5.2.1–5.6.x) can accept it, and an unknown enum value + // 400s the WHOLE /sync/stream request. A null declaration therefore sends nothing here. + // Membership is tested with `toJson()`, matching the space-album filter above — the + // generated enum has no `.value`. + if (supportedSyncTypes != null) + ...(_assetFavoriteSyncTypes.where((type) => supportedSyncTypes.contains(type.toJson()))), ], ).toJson(), ); @@ -332,6 +347,9 @@ const _kResponseMap = { SyncEntityType.sharedSpaceAlbumHiddenV1: SyncSharedSpaceAlbumHiddenV1.fromJson, SyncEntityType.sharedSpaceAlbumHiddenBackfillV1: SyncSharedSpaceAlbumHiddenV1.fromJson, SyncEntityType.sharedSpaceAlbumHiddenDeleteV1: SyncSharedSpaceAlbumHiddenDeleteV1.fromJson, + // --- gallery-fork: per-user favorites sync (#763) --- + SyncEntityType.assetFavoriteV1: SyncAssetFavoriteV1.fromJson, + SyncEntityType.assetFavoriteDeleteV1: SyncAssetFavoriteDeleteV1.fromJson, }; class _SyncEmptyDto { diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 06399e2429d151..975b166c421ff5 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -1554,6 +1554,54 @@ class SyncStreamRepository extends DatabaseAccessor with $SyncStreamRepos } } + // --- gallery-fork: per-user favorites sync (#763) --- + // + // No new Drift table: remote_asset.isFavorite is a valid per-account store + // because every asset payload is now recipient-resolved server-side. + // Handlers flip the column by assetId: upsert -> true, tombstone -> false. + // Batch-update idiom mirrors RemoteAssetRepository.updateFavorite. + // + // Ordering note (accepted, self-healing): a favorite event for a + // not-yet-synced asset updates zero rows here and self-heals when the + // recipient-resolved asset payload arrives; a stale-ordered asset payload + // overwriting a newer favorite self-heals on the next favorite event or a + // full resync — same eventual-consistency class as other cross-entity + // fields in this sync stream. batch.update on an unmatched id is a no-op, + // not a throw, so unknown assetIds are silently skipped. + Future updateAssetFavoritesV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final favorite in data) { + batch.update( + _db.remoteAssetEntity, + const RemoteAssetEntityCompanion(isFavorite: Value(true)), + where: (row) => row.id.equals(favorite.assetId), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: updateAssetFavoritesV1', error, stack); + rethrow; + } + } + + Future deleteAssetFavoritesV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final favorite in data) { + batch.update( + _db.remoteAssetEntity, + const RemoteAssetEntityCompanion(isFavorite: Value(false)), + where: (row) => row.id.equals(favorite.assetId), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: deleteAssetFavoritesV1', error, stack); + rethrow; + } + } + Future pruneAssets() async { try { await _db.transaction(() async { diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 9bb2d628f64a44..ba572ec36890bc 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -816,21 +816,235 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor sortBy: SortAssetsBy.uploaded, ); + // #763: favorites are per-viewer, not per-owner — a viewer's favorite flag on someone else's + // asset must still surface on their Favorites page. Mirrors the server/web /favorites scope + // (timeline.service.ts getTimeBuckets with isFavorite=true, withPartners, withSharedSpaces): + // * own assets (Timeline + Archive, the page's pre-#763 owner scope); + // * timeline-enabled partners' assets (partner.inTimeline, the same set getMyPartnerIds + // {timelineEnabled: true} resolves) — Timeline visibility only, since the server rejects + // withPartners without an explicit visibility so a partner's ARCHIVED assets never leak; + // * assets reachable through EVERY space the viewer is a member of, ignoring the member's + // showInTimeline: a favorite placed inside a space the viewer hid from their timeline is + // still theirs (server getAllMemberSpaceIds, "Favorites ignores the timeline hide"). The + // album arm keeps the server's 'personal' gate — the viewer's own + // shared_space_album_hidden row — not the shared link showInTimeline flag. + // Can't reuse `_remoteQueryBuilder` (no join support) or the viewer_visibility helpers (they + // apply the timeline hide), so the scope lives in [_favoriteScopeJoins] (bucket .watch(), real + // joins for reactivity) and [_favoriteScopePredicate] (asset list .get()), kept in lockstep. TimelineQuery favorite( String userId, GroupAssetsBy groupBy, { TimelineTemporalScope temporalScope = const TimelineTemporalScope.none(), - }) => _remoteQueryBuilder( - filter: (row) => - row.deletedAt.isNull() & - row.isFavorite.equals(true) & - row.ownerId.equals(userId) & - (row.visibility.equalsValue(AssetVisibility.timeline) | row.visibility.equalsValue(AssetVisibility.archive)), - groupBy: groupBy, - temporalScope: temporalScope, + }) => ( + bucketSource: () => _watchFavoriteBucket(userId, groupBy: groupBy, temporalScope: temporalScope), + assetSource: (offset, count) => + _getFavoriteAssets(userId, offset: offset, count: count, temporalScope: temporalScope), origin: TimelineOrigin.favorite, ); + /// LEFT OUTER JOINs + WHERE term for the Favorites scope (see [favorite]). Real joins rather than + /// isInQuery so Drift's readsFrom tracks every table and the bucket stream stays reactive. + ({List joins, Expression inScope}) _favoriteScopeJoins(String userId) { + final rae = _db.remoteAssetEntity; + final assetMember = _db.alias(_db.sharedSpaceMemberEntity, 'fav_ssm_asset'); + final libraryMember = _db.alias(_db.sharedSpaceMemberEntity, 'fav_ssm_lib'); + final albumMember = _db.alias(_db.sharedSpaceMemberEntity, 'fav_ssm_album'); + + final joins = [ + leftOuterJoin( + _db.partnerEntity, + _db.partnerEntity.sharedById.equalsExp(rae.ownerId) & + _db.partnerEntity.sharedWithId.equals(userId) & + _db.partnerEntity.inTimeline.equals(true), + useColumns: false, + ), + leftOuterJoin( + _db.sharedSpaceAssetEntity, + _db.sharedSpaceAssetEntity.assetId.equalsExp(rae.id), + useColumns: false, + ), + leftOuterJoin( + assetMember, + assetMember.spaceId.equalsExp(_db.sharedSpaceAssetEntity.spaceId) & assetMember.userId.equals(userId), + useColumns: false, + ), + leftOuterJoin( + _db.sharedSpaceLibraryEntity, + _db.sharedSpaceLibraryEntity.libraryId.equalsExp(rae.libraryId), + useColumns: false, + ), + leftOuterJoin( + libraryMember, + libraryMember.spaceId.equalsExp(_db.sharedSpaceLibraryEntity.spaceId) & libraryMember.userId.equals(userId), + useColumns: false, + ), + leftOuterJoin( + _db.sharedSpaceAlbumAssetEntity, + _db.sharedSpaceAlbumAssetEntity.assetId.equalsExp(rae.id), + useColumns: false, + ), + leftOuterJoin( + _db.sharedSpaceAlbumLinkEntity, + _db.sharedSpaceAlbumLinkEntity.albumId.equalsExp(_db.sharedSpaceAlbumAssetEntity.albumId), + useColumns: false, + ), + leftOuterJoin( + albumMember, + albumMember.spaceId.equalsExp(_db.sharedSpaceAlbumLinkEntity.spaceId) & albumMember.userId.equals(userId), + useColumns: false, + ), + leftOuterJoin( + _db.sharedSpaceAlbumHiddenEntity, + _db.sharedSpaceAlbumHiddenEntity.spaceId.equalsExp(_db.sharedSpaceAlbumLinkEntity.spaceId) & + _db.sharedSpaceAlbumHiddenEntity.albumId.equalsExp(_db.sharedSpaceAlbumLinkEntity.albumId) & + _db.sharedSpaceAlbumHiddenEntity.userId.equals(userId), + useColumns: false, + ), + ]; + + final inScope = + rae.ownerId.equals(userId) | + (_db.partnerEntity.sharedById.isNotNull() & rae.visibility.equalsValue(AssetVisibility.timeline)) | + assetMember.userId.isNotNull() | + libraryMember.userId.isNotNull() | + (albumMember.userId.isNotNull() & _db.sharedSpaceAlbumHiddenEntity.albumId.isNull()); + + return (joins: joins, inScope: inScope); + } + + /// The [_favoriteScopeJoins] scope as isInQuery subqueries, for one-shot .get() asset lists + /// (no reactivity concern; isInQuery naturally deduplicates). + Expression _favoriteScopePredicate(String userId) { + final rae = _db.remoteAssetEntity; + + final ownedByPartner = + rae.ownerId.isInQuery( + _db.partnerEntity.selectOnly() + ..addColumns([_db.partnerEntity.sharedById]) + ..where(_db.partnerEntity.sharedWithId.equals(userId) & _db.partnerEntity.inTimeline.equals(true)), + ) & + rae.visibility.equalsValue(AssetVisibility.timeline); + + final inSpaceAsset = rae.id.isInQuery( + _db.sharedSpaceAssetEntity.selectOnly() + ..addColumns([_db.sharedSpaceAssetEntity.assetId]) + ..join([ + innerJoin( + _db.sharedSpaceMemberEntity, + _db.sharedSpaceMemberEntity.spaceId.equalsExp(_db.sharedSpaceAssetEntity.spaceId) & + _db.sharedSpaceMemberEntity.userId.equals(userId), + useColumns: false, + ), + ]), + ); + + final inSpaceLibrary = rae.libraryId.isInQuery( + _db.sharedSpaceLibraryEntity.selectOnly() + ..addColumns([_db.sharedSpaceLibraryEntity.libraryId]) + ..join([ + innerJoin( + _db.sharedSpaceMemberEntity, + _db.sharedSpaceMemberEntity.spaceId.equalsExp(_db.sharedSpaceLibraryEntity.spaceId) & + _db.sharedSpaceMemberEntity.userId.equals(userId), + useColumns: false, + ), + ]), + ); + + final inSpaceAlbum = rae.id.isInQuery( + _db.sharedSpaceAlbumAssetEntity.selectOnly() + ..addColumns([_db.sharedSpaceAlbumAssetEntity.assetId]) + ..join([ + innerJoin( + _db.sharedSpaceAlbumLinkEntity, + _db.sharedSpaceAlbumLinkEntity.albumId.equalsExp(_db.sharedSpaceAlbumAssetEntity.albumId), + useColumns: false, + ), + innerJoin( + _db.sharedSpaceMemberEntity, + _db.sharedSpaceMemberEntity.spaceId.equalsExp(_db.sharedSpaceAlbumLinkEntity.spaceId) & + _db.sharedSpaceMemberEntity.userId.equals(userId), + useColumns: false, + ), + leftOuterJoin( + _db.sharedSpaceAlbumHiddenEntity, + _db.sharedSpaceAlbumHiddenEntity.spaceId.equalsExp(_db.sharedSpaceAlbumLinkEntity.spaceId) & + _db.sharedSpaceAlbumHiddenEntity.albumId.equalsExp(_db.sharedSpaceAlbumLinkEntity.albumId) & + _db.sharedSpaceAlbumHiddenEntity.userId.equals(userId), + useColumns: false, + ), + ]) + ..where(_db.sharedSpaceAlbumHiddenEntity.albumId.isNull()), + ); + + return rae.ownerId.equals(userId) | ownedByPartner | inSpaceAsset | inSpaceLibrary | inSpaceAlbum; + } + + Stream> _watchFavoriteBucket( + String userId, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + TimelineTemporalScope temporalScope = const TimelineTemporalScope.none(), + }) { + final scope = _favoriteScopeJoins(userId); + final visible = + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.isFavorite.equals(true) & + (_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) | + _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.archive)) & + _remoteWithinTemporalScope(_db.remoteAssetEntity, temporalScope) & + scope.inScope; + + if (groupBy == GroupAssetsBy.none) { + final countExp = _db.remoteAssetEntity.id.count(distinct: true); + final query = _db.remoteAssetEntity.selectOnly() + ..addColumns([countExp]) + ..join(scope.joins) + ..where(visible); + + return query.map((row) => row.read(countExp) ?? 0).map(_generateBuckets).watchSingle(); + } + + final assetCountExp = _db.remoteAssetEntity.id.count(distinct: true); + final dateExp = _db.remoteAssetEntity.effectiveCreatedAt(groupBy); + + final query = _db.remoteAssetEntity.selectOnly() + ..addColumns([assetCountExp, dateExp]) + ..join(scope.joins) + ..where(visible) + ..groupBy([dateExp]) + ..orderBy([OrderingTerm.desc(dateExp)]); + + return query.map((row) { + final timeline = row.read(dateExp)!.truncateDate(groupBy); + final assetCount = row.read(assetCountExp)!; + return TimeBucket(date: timeline, assetCount: assetCount); + }).watch(); + } + + Future> _getFavoriteAssets( + String userId, { + required int offset, + required int count, + TimelineTemporalScope temporalScope = const TimelineTemporalScope.none(), + }) { + final scopePredicate = _favoriteScopePredicate(userId); + + final query = _db.remoteAssetEntity.select() + ..where( + (row) => + row.deletedAt.isNull() & + row.isFavorite.equals(true) & + (row.visibility.equalsValue(AssetVisibility.timeline) | + row.visibility.equalsValue(AssetVisibility.archive)) & + _remoteWithinTemporalScope(row, temporalScope) & + scopePredicate, + ) + ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]) + ..limit(count, offset: offset); + + return query.map((row) => row.toDto()).get(); + } + TimelineQuery trash( String userId, GroupAssetsBy groupBy, { diff --git a/mobile/lib/models/server_info/server_features.model.dart b/mobile/lib/models/server_info/server_features.model.dart index 4de7e2e3cd2df4..5731fc6cbde744 100644 --- a/mobile/lib/models/server_info/server_features.model.dart +++ b/mobile/lib/models/server_info/server_features.model.dart @@ -13,6 +13,11 @@ abstract class ServerFeatures with _$ServerFeatures { required bool passwordLogin, @Default(false) bool ocr, @Default(false) bool smartSearch, + // Fork capability signal: the sync request types the server declares it accepts + // (`GET /server/features` → `syncRequestTypes`). Null means "not declared" — a server that + // predates capability signalling (fork < 5.7.0), or features not loaded yet — and must stay + // distinct from an empty set, which would read as "declares nothing". + Set? syncRequestTypes, }) = _ServerFeatures; factory ServerFeatures.fromDto(ServerFeaturesDto dto) => ServerFeatures( @@ -22,5 +27,6 @@ abstract class ServerFeatures with _$ServerFeatures { passwordLogin: dto.passwordLogin, ocr: dto.ocr, smartSearch: dto.smartSearch, + syncRequestTypes: dto.syncRequestTypes.orElse(null)?.toSet(), ); } diff --git a/mobile/lib/presentation/actions/favorite.action.dart b/mobile/lib/presentation/actions/favorite.action.dart index 402d2f3833c722..db43a588ef0e76 100644 --- a/mobile/lib/presentation/actions/favorite.action.dart +++ b/mobile/lib/presentation/actions/favorite.action.dart @@ -9,8 +9,12 @@ import 'package:immich_mobile/utils/error_handler.dart'; typedef _State = ({bool shouldFavorite, List assetIds}); +// #763: no ownership gate — favoriting is a per-user overlay write available to anyone who can see +// the asset, so every remote asset in the selection is a candidate (NOT ownedAssetsActionProvider, +// unlike every other action here). E32: the direction must also derive from the SAME set the action +// mutates, which this provider is. final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, source) { - final assets = ref.watch(ownedAssetsActionProvider(source)); + final assets = ref.watch(assetsActionProvider(source)).remote(); if (assets.isEmpty) { return null; } @@ -18,7 +22,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final shouldFavorite = assets.favorite(isFavorite: false).isNotEmpty; final assetIds = assets.favorite(isFavorite: !shouldFavorite).map((asset) => asset.id).toList(growable: false); return (shouldFavorite: shouldFavorite, assetIds: assetIds); -}, dependencies: [ownedAssetsActionProvider]); +}, dependencies: [assetsActionProvider]); class FavoriteAction extends AssetActionBuilder { const FavoriteAction({required super.source}); @@ -52,7 +56,8 @@ class FavoriteAction extends AssetActionBuilder { final clearSelection = ref.read(clearSelectionProvider(source)); try { - await assetService.update(assetIds, isFavorite: .some(shouldFavorite)); + // #763: the per-user overlay route (PUT /assets/favorites), NOT the owner-only bulk update. + await assetService.updateFavorite(assetIds, shouldFavorite); toastService.success(message); clearSelection(); } catch (error, stack) { diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 1561cba1866fcc..c2e8731e929a51 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction; import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/option.dart'; @@ -10,12 +11,20 @@ import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:openapi/api.dart' as api show AssetVisibility; import 'package:openapi/api.dart' hide AssetVisibility; -final assetApiRepositoryProvider = Provider((ref) => AssetApiRepository(ref.watch(apiServiceProvider))); +final assetApiRepositoryProvider = Provider( + (ref) => AssetApiRepository( + ref.watch(apiServiceProvider), + () => ref.read(serverInfoProvider).serverFeatures.syncRequestTypes, + ), +); class AssetApiRepository extends ApiRepository { final ApiService _apiService; + // The sync request types the server declares (`GET /server/features`), or null when it + // declares none / they are not known yet. See [updateFavorite]. + final Set? Function() _getSupportedSyncTypes; - AssetApiRepository(this._apiService); + AssetApiRepository(this._apiService, this._getSupportedSyncTypes); AssetsApi get _api => _apiService.assetsApi; StacksApi get _stacksApi => _apiService.stacksApi; @@ -79,6 +88,12 @@ class AssetApiRepository extends ApiRepository { await _api.removeAssetEdits(assetId); } + // #763: DO NOT pass `isFavorite` here. This posts an AssetBulkUpdateDto to the OWNER-ONLY + // `PUT /assets`, but favorites are now a per-user overlay (`asset_favorite`) that a read-only + // space Viewer may set on another member's asset — use `updateFavorite` below, which routes to + // `PUT /assets/favorites` (and falls back for servers without that endpoint). The parameter survives + // only because it is the upstream Immich shape; nothing passes it, and + // test/policy/favorite_overlay_policy_test.dart fails if anything starts to. Future update( List remoteIds, { Option isFavorite = const .none(), @@ -97,6 +112,37 @@ class AssetApiRepository extends ApiRepository { ), ); } + + // #763: favorites are per-user (not owner-gated), so they route through the dedicated + // /assets/favorites endpoint rather than the owner-only bulk-update endpoint — but no RELEASED + // fork server has that endpoint (mobile and server release independently, so a newer app can + // talk to an older server). There, the request falls through to `PUT /assets/:id` (id = + // "favorites") and 400s on UUID validation, breaking favoriting entirely, owned assets + // included. The reported version cannot gate this (PR RC images stamp the bare upstream base + // version), so gate on the server's own capability declaration instead: the endpoint and the + // `AssetFavoritesV1` sync request type ship together, and `GET /server/features` → + // `syncRequestTypes` declares every request type the server accepts. Declared → the canonical + // per-user endpoint. Anything else — not declared, a server that predates capability + // signalling (null), or features not loaded yet / failed to load (null) — falls back to the + // legacy bulk-update endpoint below, which every server honours via a deprecated `isFavorite` + // alias that writes the per-user favorite for assets the caller owns (server asset.service.ts + // update/updateAll). A viewer favoriting someone else's asset on such a server gets a 403 (that + // alias requires Permission.AssetUpdate, stricter than the canonical endpoint's AssetRead): an + // accepted degraded mode, since per-user favorites don't exist on that server anyway. The + // declaration is read from the cached server features (serverInfoProvider), not fetched per tap. + // + // Upstream's action migration deleted the `updateLocation`/`updateDateTime`/`updateFavorite` + // trio in favour of the consolidated `update` above; only this one is kept, because `update` + // posts an AssetBulkUpdateDto to the OWNER-ONLY `PUT /assets`. + Future updateFavorite(List ids, bool isFavorite) async { + final supportedSyncTypes = _getSupportedSyncTypes(); + if (supportedSyncTypes != null && supportedSyncTypes.contains(SyncRequestType.assetFavoritesV1.toJson())) { + await _api.updateAssetFavorites(AssetFavoriteUpdateDto(ids: ids, isFavorite: isFavorite)); + return; + } + + await _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite))); + } } extension on StackResponseDto { diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index b3c762ca19ad5b..22b4ab0e4127af 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -2349,4 +2349,47 @@ void main() { }); }); }); + + // --- gallery-fork: per-user favorites sync (#763) --- + // + // remote_asset.isFavorite is the per-account store for this stream — no new + // Drift table, because every asset payload is now recipient-resolved + // server-side. Handlers flip the column by assetId: upsert -> true, + // tombstone -> false. Ordering note: a favorite event for a not-yet-synced + // asset updates zero rows and self-heals when the recipient-resolved asset + // payload arrives; a stale-ordered asset payload overwriting a newer + // favorite self-heals on the next favorite event or full resync — same + // eventual-consistency class as other cross-entity fields. + group('SyncStreamRepository - AssetFavorites (#763)', () { + test('updateAssetFavoritesV1 flips isFavorite to true for a known asset', () async { + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV1([_createAsset(id: 'fav-asset-1', checksum: 'cFav1', fileName: 'fav1.jpg')]); + + await sut.updateAssetFavoritesV1([SyncAssetFavoriteV1(assetId: 'fav-asset-1')]); + + final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals('fav-asset-1'))).getSingle(); + expect(row.isFavorite, isTrue); + }); + + test('deleteAssetFavoritesV1 flips isFavorite back to false for a known asset', () async { + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV1([_createAsset(id: 'fav-asset-2', checksum: 'cFav2', fileName: 'fav2.jpg')]); + await sut.updateAssetFavoritesV1([SyncAssetFavoriteV1(assetId: 'fav-asset-2')]); + + await sut.deleteAssetFavoritesV1([SyncAssetFavoriteDeleteV1(assetId: 'fav-asset-2')]); + + final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals('fav-asset-2'))).getSingle(); + expect(row.isFavorite, isFalse); + }); + + test('updateAssetFavoritesV1 for an unknown assetId is a silent no-op (self-heals)', () async { + await expectLater(sut.updateAssetFavoritesV1([SyncAssetFavoriteV1(assetId: 'not-synced-yet')]), completes); + expect(await db.remoteAssetEntity.select().get(), isEmpty); + }); + + test('deleteAssetFavoritesV1 for an unknown assetId is a silent no-op (self-heals)', () async { + await expectLater(sut.deleteAssetFavoritesV1([SyncAssetFavoriteDeleteV1(assetId: 'not-synced-yet')]), completes); + expect(await db.remoteAssetEntity.select().get(), isEmpty); + }); + }); } diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index 62342de13e05a6..1283a648e86874 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -213,6 +213,9 @@ void main() { // Folder sync handlers — M-2 gap fill. when(() => mockSyncStreamRepo.updateSharedSpaceAlbumFoldersV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteSharedSpaceAlbumFoldersV1(any())).thenAnswer(successHandler); + // Per-user favorites sync handlers (#763). + when(() => mockSyncStreamRepo.updateAssetFavoritesV1(any())).thenAnswer(successHandler); + when(() => mockSyncStreamRepo.deleteAssetFavoritesV1(any())).thenAnswer(successHandler); when(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset()).thenAnswer(successHandler); sut = SyncStreamService( @@ -739,6 +742,21 @@ void main() { verify(() => mockSyncApiRepo.ack(['sa-album-folder-delete-ack'])).called(1); }); }); + + // --- gallery-fork: per-user favorites dispatch arm tests (#763) --- + group('SyncStreamService - asset favorites dispatch arms', () { + test('assetFavoriteV1 → updateAssetFavoritesV1', () async { + await simulateEvents([SyncStreamStub.assetFavoriteV1]); + verify(() => mockSyncStreamRepo.updateAssetFavoritesV1(any())).called(1); + verify(() => mockSyncApiRepo.ack(['asset-favorite-v1-ack'])).called(1); + }); + + test('assetFavoriteDeleteV1 → deleteAssetFavoritesV1', () async { + await simulateEvents([SyncStreamStub.assetFavoriteDeleteV1]); + verify(() => mockSyncStreamRepo.deleteAssetFavoritesV1(any())).called(1); + verify(() => mockSyncApiRepo.ack(['asset-favorite-delete-ack'])).called(1); + }); + }); }); group("SyncStreamService - remote trash & restore", () { diff --git a/mobile/test/fixtures/sync_stream.stub.dart b/mobile/test/fixtures/sync_stream.stub.dart index 17fee3e23af547..6bd0cacc0ff262 100644 --- a/mobile/test/fixtures/sync_stream.stub.dart +++ b/mobile/test/fixtures/sync_stream.stub.dart @@ -584,4 +584,18 @@ abstract final class SyncStreamStub { data: SyncSharedSpaceAlbumHiddenDeleteV1(spaceId: 'space-3', albumId: 'album-stub-3', userId: 'user-1'), ack: 'sa-album-hidden-delete-ack', ); + + // --- gallery-fork: per-user favorites sync stubs (#763) --- + + static final assetFavoriteV1 = SyncEvent( + type: SyncEntityType.assetFavoriteV1, + data: SyncAssetFavoriteV1(assetId: 'asset-favorite-1'), + ack: 'asset-favorite-v1-ack', + ); + + static final assetFavoriteDeleteV1 = SyncEvent( + type: SyncEntityType.assetFavoriteDeleteV1, + data: SyncAssetFavoriteDeleteV1(assetId: 'asset-favorite-2'), + ack: 'asset-favorite-delete-ack', + ); } diff --git a/mobile/test/infrastructure/repositories/search_api_repository_test.dart b/mobile/test/infrastructure/repositories/search_api_repository_test.dart index f41a16f31b1626..7d9c1fbea844c2 100644 --- a/mobile/test/infrastructure/repositories/search_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/search_api_repository_test.dart @@ -93,8 +93,9 @@ void main() { }); // A viewer's selected facet only returns shared-space assets (and space-person tokens only - // resolve) when the search requests shared spaces — mirror web buildPhotosTimelineOptions, - // which gates shared content on `isFavorite === undefined` (favourites are owner-only). + // resolve) when the search requests shared spaces. #763: favorites are per-user, and the + // server composes shared-space visibility with the favorite filter (slice 4), so this is no + // longer gated on `isFavorite` — inverts the pre-#763 "favourites are owner-only" behavior. test('metadata search requests shared spaces when not filtering by favourite', () async { when(() => searchApi.searchAssets(any())).thenAnswer((_) async => null); await sut.search(SearchFilter.empty(), 1); @@ -102,12 +103,12 @@ void main() { expect(dto.withSharedSpaces.value, true); }); - test('metadata search omits shared spaces when filtering by favourite', () async { + test('metadata search still requests shared spaces when filtering by favourite', () async { when(() => searchApi.searchAssets(any())).thenAnswer((_) async => null); final filter = SearchFilter.empty().copyWith(display: SearchFilter.empty().display.copyWith(isFavorite: true)); await sut.search(filter, 1); final dto = verify(() => searchApi.searchAssets(captureAny())).captured.single as MetadataSearchDto; - expect(dto.withSharedSpaces.isPresent, isFalse); + expect(dto.withSharedSpaces.value, true); }); test('smart search requests shared spaces when not filtering by favourite', () async { @@ -118,7 +119,7 @@ void main() { expect(dto.withSharedSpaces.value, true); }); - test('smart search omits shared spaces when filtering by favourite', () async { + test('smart search still requests shared spaces when filtering by favourite', () async { when(() => searchApi.searchSmart(any())).thenAnswer((_) async => null); final filter = SearchFilter.empty().copyWith( context: 'beach', @@ -126,7 +127,7 @@ void main() { ); await sut.search(filter, 1); final dto = verify(() => searchApi.searchSmart(captureAny())).captured.single as SmartSearchDto; - expect(dto.withSharedSpaces.isPresent, isFalse); + expect(dto.withSharedSpaces.value, true); }); }); } diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart index 379c477c16537b..b276515835b63d 100644 --- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart @@ -347,6 +347,44 @@ void main() { }); }); + group('#763: AssetFavoritesV1 is requested ONLY behind a server declaration', () { + // AssetFavoritesV1 post-dates capability signalling (v5.7.0), so like SharedSpaceAlbumFoldersV1 + // it has no version-gate fallback: a server without a declaration (fork 5.2.1–5.6.x) does not + // know the type, and an unknown request type 400s the WHOLE /sync/stream request. + test('no declaration: EXCLUDES AssetFavoritesV1 at every version', () async { + for (final version in [ + const SemVer(major: 3, minor: 0, patch: 1), + const SemVer(major: 5, minor: 2, patch: 0), + const SemVer(major: 5, minor: 2, patch: 1), + const SemVer(major: 5, minor: 6, patch: 3), + const SemVer(major: 5, minor: 7, patch: 1), + const SemVer(major: 6, minor: 0, patch: 0), + ]) { + final types = await capturedRequestTypes(version); + expect(types, isNot(contains('AssetFavoritesV1')), reason: 'no declaration must never send it at $version'); + clearInteractions(mockHttpClient); + } + }); + + test('a declaring server INCLUDES AssetFavoritesV1 regardless of its reported version', () async { + // RC images stamp the bare upstream base version, unbranded dev servers the upstream + // version: the declaration must win. + for (final version in [const SemVer(major: 3, minor: 2, patch: 2), const SemVer(major: 5, minor: 7, patch: 1)]) { + final types = await capturedRequestTypes(version, supportedSyncTypes: {'AssetFavoritesV1', 'AssetsV1'}); + expect(types, contains('AssetFavoritesV1'), reason: 'declared capability must open the gate at $version'); + clearInteractions(mockHttpClient); + } + }); + + test('a declaring server WITHOUT AssetFavoritesV1 (released 5.7.x) EXCLUDES it', () async { + final types = await capturedRequestTypes( + const SemVer(major: 6, minor: 0, patch: 0), + supportedSyncTypes: {'AssetsV1', 'SharedSpaceAlbumFoldersV1'}, + ); + expect(types, isNot(contains('AssetFavoritesV1')), reason: 'the declaration is authoritative in both directions'); + }); + }); + test('streamChanges stops processing stream when abort is called', () async { int onDataCallCount = 0; bool abortWasCalledInCallback = false; diff --git a/mobile/test/medium/repositories/timeline_repository_test.dart b/mobile/test/medium/repositories/timeline_repository_test.dart index 54c92ccc468070..af012e4a3f2de4 100644 --- a/mobile/test/medium/repositories/timeline_repository_test.dart +++ b/mobile/test/medium/repositories/timeline_repository_test.dart @@ -3,6 +3,7 @@ // matcher (live-photos group merged in from main), so drift's must yield. import 'package:drift/drift.dart' hide isNull; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/data/db/main/table/remote/shared_space_album_hidden.drift.dart'; import 'package:immich_mobile/data/db/main/table/remote/shared_space_album_link.drift.dart'; import 'package:immich_mobile/data/db/main/table/remote/stack.drift.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -546,4 +547,205 @@ void main() { expect(buckets.fold(0, (sum, b) => sum + b.assetCount), 2); }); }); + + // #763: favorite() dropped the ownerId-only filter and mirrors the server/web /favorites scope — + // own assets, timeline-enabled partners' assets, and assets in EVERY space the viewer is a + // member of (direct, library, or album), ignoring the member's showInTimeline hide. + group('favorite() viewer visibility (#763)', () { + Future bucketTotal(TimelineQuery q) async { + final buckets = await q.bucketSource().first; + return buckets.fold(0, (sum, b) => sum + b.assetCount); + } + + test('owned favorited asset is visible; owned non-favorited asset is not', () async { + final user = await ctx.newUser(); + final favorited = await ctx.newRemoteAsset(ownerId: user.id, isFavorite: true); + await ctx.newRemoteAsset(ownerId: user.id); + + final assets = await sut.favorite(user.id, GroupAssetsBy.none).assetSource(0, 100); + final ids = assets.map((a) => (a as RemoteAsset).id).toSet(); + expect(ids, {favorited.id}); + expect(await bucketTotal(sut.favorite(user.id, GroupAssetsBy.none)), 1); + expect(await bucketTotal(sut.favorite(user.id, GroupAssetsBy.day)), 1); + }); + + test('a favorited asset owned by an unrelated user (no shared visibility) is hidden', () async { + final viewer = await ctx.newUser(); + final stranger = await ctx.newUser(); + await ctx.newRemoteAsset(ownerId: stranger.id, isFavorite: true); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets, isEmpty); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.none)), 0); + }); + + test('a favorited asset shared directly (showInTimeline=true) is visible to the viewer', () async { + final viewer = await ctx.newUser(); + final owner = await ctx.newUser(); + final asset = await ctx.newRemoteAsset(ownerId: owner.id, isFavorite: true); + final space = await ctx.newSharedSpace(createdById: owner.id); + await ctx.newSharedSpaceMember(spaceId: space.id, userId: viewer.id, showInTimeline: true); + await ctx.insertSharedSpaceAsset(spaceId: space.id, assetId: asset.id); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets.map((a) => (a as RemoteAsset).id).toSet(), {asset.id}); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.none)), 1); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.day)), 1); + }); + + test('a favorited asset in a space the viewer hid from their timeline is still visible', () async { + // Server "Favorites ignores the timeline hide": the favorites scope spans every member space + // (getAllMemberSpaceIds), not just the showInTimeline ones. + final viewer = await ctx.newUser(); + final owner = await ctx.newUser(); + final asset = await ctx.newRemoteAsset(ownerId: owner.id, isFavorite: true); + final space = await ctx.newSharedSpace(createdById: owner.id); + await ctx.newSharedSpaceMember(spaceId: space.id, userId: viewer.id, showInTimeline: false); + await ctx.insertSharedSpaceAsset(spaceId: space.id, assetId: asset.id); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets.map((a) => (a as RemoteAsset).id).toSet(), {asset.id}); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.none)), 1); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.day)), 1); + }); + + test('a favorited asset in a hidden space reached via a library or album is still visible', () async { + final viewer = await ctx.newUser(); + final owner = await ctx.newUser(); + final library = await ctx.newLibrary(ownerId: owner.id); + final viaLibrary = await ctx.newRemoteAsset(ownerId: owner.id, isFavorite: true, libraryId: library.id); + final viaAlbum = await ctx.newRemoteAsset(ownerId: owner.id, isFavorite: true); + final space = await ctx.newSharedSpace(createdById: owner.id); + final album = await ctx.newSharedSpaceAlbum(); + await ctx.newSharedSpaceMember(spaceId: space.id, userId: viewer.id, showInTimeline: false); + await ctx.insertSharedSpaceLibrary(spaceId: space.id, libraryId: library.id); + // The shared link flag governs the space's own Photos tab, not a personal surface. + await ctx.insertSharedSpaceAlbumLink(spaceId: space.id, albumId: album.id, showInTimeline: false); + await ctx.insertSharedSpaceAlbumAsset(albumId: album.id, assetId: viaAlbum.id); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets.map((a) => (a as RemoteAsset).id).toSet(), {viaLibrary.id, viaAlbum.id}); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.none)), 2); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.day)), 2); + }); + + test("a favorited asset only in a space album the viewer personally hid is excluded", () async { + // Mirrors the server album arm's 'personal' gate (shared_space_album_hidden for the viewer). + final viewer = await ctx.newUser(); + final owner = await ctx.newUser(); + final asset = await ctx.newRemoteAsset(ownerId: owner.id, isFavorite: true); + final space = await ctx.newSharedSpace(createdById: owner.id); + final album = await ctx.newSharedSpaceAlbum(); + await ctx.newSharedSpaceMember(spaceId: space.id, userId: viewer.id, showInTimeline: true); + await ctx.insertSharedSpaceAlbumLink(spaceId: space.id, albumId: album.id, showInTimeline: true); + await ctx.insertSharedSpaceAlbumAsset(albumId: album.id, assetId: asset.id); + await ctx.db + .into(ctx.db.sharedSpaceAlbumHiddenEntity) + .insert( + SharedSpaceAlbumHiddenEntityCompanion.insert(spaceId: space.id, albumId: album.id, userId: viewer.id), + ); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets, isEmpty); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.none)), 0); + }); + + test('a favorited asset in a space the viewer is NOT a member of is excluded', () async { + final viewer = await ctx.newUser(); + final owner = await ctx.newUser(); + final other = await ctx.newUser(); + final asset = await ctx.newRemoteAsset(ownerId: owner.id, isFavorite: true); + final space = await ctx.newSharedSpace(createdById: owner.id); + await ctx.newSharedSpaceMember(spaceId: space.id, userId: other.id, showInTimeline: true); + await ctx.insertSharedSpaceAsset(spaceId: space.id, assetId: asset.id); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets, isEmpty); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.none)), 0); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.day)), 0); + }); + + test("a timeline-enabled partner's favorited asset is visible", () async { + final viewer = await ctx.newUser(); + final partner = await ctx.newUser(); + final asset = await ctx.newRemoteAsset(ownerId: partner.id, isFavorite: true); + await ctx.newRemoteAsset(ownerId: partner.id); + await ctx.newPartner(sharedById: partner.id, sharedWithId: viewer.id, inTimeline: true); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets.map((a) => (a as RemoteAsset).id).toSet(), {asset.id}); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.none)), 1); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.day)), 1); + }); + + test("a partner's ARCHIVED favorited asset is excluded (server withPartners needs Timeline)", () async { + final viewer = await ctx.newUser(); + final partner = await ctx.newUser(); + await ctx.newRemoteAsset(ownerId: partner.id, isFavorite: true, visibility: AssetVisibility.archive); + await ctx.newPartner(sharedById: partner.id, sharedWithId: viewer.id, inTimeline: true); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets, isEmpty); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.none)), 0); + }); + + test('partner scope is directional and needs inTimeline (getMyPartnerIds timelineEnabled)', () async { + final viewer = await ctx.newUser(); + final notInTimeline = await ctx.newUser(); + final sharedWithThem = await ctx.newUser(); + await ctx.newRemoteAsset(ownerId: notInTimeline.id, isFavorite: true); + await ctx.newRemoteAsset(ownerId: sharedWithThem.id, isFavorite: true); + await ctx.newPartner(sharedById: notInTimeline.id, sharedWithId: viewer.id, inTimeline: false); + // Wrong direction: the VIEWER shares with this user, not the other way round. + await ctx.newPartner(sharedById: viewer.id, sharedWithId: sharedWithThem.id, inTimeline: true); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets, isEmpty); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.none)), 0); + }); + + test('a favorited asset visible via a space-linked library is visible to the viewer', () async { + final viewer = await ctx.newUser(); + final owner = await ctx.newUser(); + final library = await ctx.newLibrary(ownerId: owner.id); + final asset = await ctx.newRemoteAsset(ownerId: owner.id, isFavorite: true, libraryId: library.id); + final space = await ctx.newSharedSpace(createdById: owner.id); + await ctx.newSharedSpaceMember(spaceId: space.id, userId: viewer.id, showInTimeline: true); + await ctx.insertSharedSpaceLibrary(spaceId: space.id, libraryId: library.id); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets.map((a) => (a as RemoteAsset).id).toSet(), {asset.id}); + }); + + test('a favorited asset visible via a space-linked album (showInTimeline=true) is visible', () async { + final viewer = await ctx.newUser(); + final owner = await ctx.newUser(); + final asset = await ctx.newRemoteAsset(ownerId: owner.id, isFavorite: true); + final space = await ctx.newSharedSpace(createdById: owner.id); + final album = await ctx.newSharedSpaceAlbum(); + await ctx.newSharedSpaceMember(spaceId: space.id, userId: viewer.id, showInTimeline: true); + await ctx.insertSharedSpaceAlbumLink(spaceId: space.id, albumId: album.id, showInTimeline: true); + await ctx.insertSharedSpaceAlbumAsset(albumId: album.id, assetId: asset.id); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets.map((a) => (a as RemoteAsset).id).toSet(), {asset.id}); + }); + + test('a favorited asset visible through two spaces is counted once (assets + both bucket modes)', () async { + final viewer = await ctx.newUser(); + final owner = await ctx.newUser(); + final asset = await ctx.newRemoteAsset(ownerId: owner.id, isFavorite: true); + final s1 = await ctx.newSharedSpace(createdById: owner.id); + final s2 = await ctx.newSharedSpace(createdById: owner.id); + await ctx.newSharedSpaceMember(spaceId: s1.id, userId: viewer.id, showInTimeline: true); + await ctx.newSharedSpaceMember(spaceId: s2.id, userId: viewer.id, showInTimeline: true); + await ctx.insertSharedSpaceAsset(spaceId: s1.id, assetId: asset.id); + await ctx.insertSharedSpaceAsset(spaceId: s2.id, assetId: asset.id); + + final assets = await sut.favorite(viewer.id, GroupAssetsBy.none).assetSource(0, 100); + expect(assets.map((a) => (a as RemoteAsset).id).toList(), [asset.id]); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.none)), 1); + expect(await bucketTotal(sut.favorite(viewer.id, GroupAssetsBy.day)), 1); + }); + }); } diff --git a/mobile/test/policy/favorite_overlay_policy_test.dart b/mobile/test/policy/favorite_overlay_policy_test.dart new file mode 100644 index 00000000000000..ba8ba21f7a8134 --- /dev/null +++ b/mobile/test/policy/favorite_overlay_policy_test.dart @@ -0,0 +1,268 @@ +// Static regression gate (#763): the mobile app must not write favorites through the owner-only +// bulk asset-update endpoint. +// +// Since #763, favorites are a per-user overlay (`asset_favorite`) rather than an owner-scoped +// column on the asset. A read-only space Viewer may favorite another member's asset, so the write +// has to go through `PUT /assets/favorites` (`AssetApiRepository.updateFavorite` -> +// `updateAssetFavorites`). The consolidated update path — `AssetService.update(...)` -> +// `AssetApiRepository.update(...)` -> `AssetBulkUpdateDto` -> `PUT /assets` — is owner-gated, so +// routing a favorite through it silently restores owner-only favoriting and breaks the Viewer case. +// +// Both consolidated signatures still declare an `isFavorite` parameter: they are upstream Immich +// shapes, kept as-is so rebases stay clean. Nothing passes it today — `AssetService.update` has no +// callers at all, so the path is dead for favorites — but it is exactly the wrong thing to reach +// for when wiring the heart icon to the consolidated action, which is what this gate stops. +// +// Two frozen inventories, not flat bans: +// +// 1. The CHOKE POINT. Every possible route to owner-only favoriting has to construct an +// `AssetBulkUpdateDto` carrying `isFavorite`. Only two such sites may exist: the dead +// consolidated `update`, and the deliberate `updateFavorite` fallback for servers that do +// not declare `AssetFavoritesV1` and so lack `PUT /assets/favorites` (see that call site). +// 2. The CALLER inventory. No `.update(...)` call may pass `isFavorite:` beyond the two internal +// forwards inside `AssetService.update`'s own body. Weaker than (1) — it only sees +// receiver-qualified calls, so a bare in-class `update(...)` would slip past it — but it names +// the offending call site directly, which makes a failure faster to act on. +// +// This lives in the MOBILE suite on purpose. The server has an equivalent guard +// (`server/src/utils/favorite-grep-gate.spec.ts`), but CI gates the server unit job on `server/**` +// changes, so a mobile-only PR — precisely the shape of the regression being guarded — would never +// run it. The `mobile-unit-tests` job is gated on `mobile/**`, so this one always runs when it +// matters. +// +// Matching operates on code only: comments and string-literal contents are blanked before +// scanning, and `isFavorite:` must appear at the TOP level of an argument list, so nested +// companion/DTO constructors (`batch.update(table, Companion(isFavorite: ...))`) never match. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Blanks comments and string-literal contents, preserving length so offsets and line numbers still +/// line up with the original source. +String stripNonCode(String source) { + final out = source.split(''); + + void blank(int from, int to) { + for (var i = from; i < to && i < source.length; i++) { + if (source[i] != '\n') { + out[i] = ' '; + } + } + } + + var i = 0; + while (i < source.length) { + if (source.startsWith('//', i)) { + final end = source.indexOf('\n', i); + final stop = end == -1 ? source.length : end; + blank(i, stop); + i = stop; + continue; + } + + // Block comments nest in Dart. + if (source.startsWith('/*', i)) { + final start = i; + var depth = 0; + while (i < source.length) { + if (source.startsWith('/*', i)) { + depth++; + i += 2; + } else if (source.startsWith('*/', i)) { + depth--; + i += 2; + if (depth == 0) { + break; + } + } else { + i++; + } + } + blank(start, i); + continue; + } + + // String literal, optionally raw (`r'...'`). + var quoteStart = i; + var isRaw = false; + if (source[i] == 'r' && i + 1 < source.length && (source[i + 1] == '"' || source[i + 1] == "'")) { + isRaw = true; + quoteStart = i + 1; + } + final quoteChar = source[quoteStart]; + if (quoteChar == '"' || quoteChar == "'") { + final triple = quoteChar * 3; + final isTriple = source.startsWith(triple, quoteStart); + final delimiter = isTriple ? triple : quoteChar; + var j = quoteStart + delimiter.length; + while (j < source.length) { + if (!isRaw && source[j] == r'\') { + j += 2; + continue; + } + if (source.startsWith(delimiter, j)) { + j += delimiter.length; + break; + } + if (!isTriple && source[j] == '\n') { + break; // Unterminated single-line string — bail rather than swallow the rest of the file. + } + j++; + } + blank(quoteStart + delimiter.length, j - delimiter.length); + i = j; + continue; + } + + i++; + } + + return out.join(); +} + +/// Index of the bracket matching the one at [open], over code with strings/comments already blanked. +int matchingBracket(String code, int open) { + var depth = 0; + for (var i = open; i < code.length; i++) { + switch (code[i]) { + case '(': + case '[': + case '{': + depth++; + case ')': + case ']': + case '}': + depth--; + if (depth == 0) { + return i; + } + } + } + return -1; +} + +int _depthAt(String code, int from, int to) { + var depth = 0; + for (var i = from; i < to; i++) { + switch (code[i]) { + case '(': + case '[': + case '{': + depth++; + case ')': + case ']': + case '}': + depth--; + } + } + return depth; +} + +/// True when `isFavorite:` appears as a top-level named argument inside the brackets. +bool hasTopLevelIsFavorite(String code, int open, int close) { + for (final match in RegExp(r'isFavorite\s*:').allMatches(code, open + 1)) { + if (match.start >= close) { + return false; + } + if (_depthAt(code, open + 1, match.start) == 0) { + return true; + } + } + return false; +} + +/// Name of the class member enclosing [offset]. Matches ` (` at EXACTLY +/// two-space indentation — where class members sit in this codebase — which is enough to key an +/// inventory entry stably against unrelated line churn. `(?=\S)` is what keeps it from matching +/// deeper-indented statements such as `if (`. +final _memberDeclaration = RegExp(r'^ (?=\S)[\w<>,?\[\]$.]+(?: +[\w<>,?\[\]$.]+)*? +([\w$]+) *\(', multiLine: true); + +String enclosingMember(String code, int offset) { + var name = ''; + for (final match in _memberDeclaration.allMatches(code)) { + if (match.start >= offset) { + break; + } + name = match.group(1)!; + } + return name; +} + +bool isGenerated(String path) => + path.endsWith('.g.dart') || + path.endsWith('.drift.dart') || + path.endsWith('.steps.dart') || + path.endsWith('.freezed.dart') || + path.contains('/generated/'); + +List<({String path, String code})> loadLibSources() { + final files = Directory('lib') + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart') && !isGenerated(file.path)) + .toList(); + + // A gate that silently inspects nothing (wrong cwd, moved directory) would pass forever. + expect(files.length, greaterThan(100), reason: 'expected to scan the mobile lib/ tree'); + + return [for (final file in files) (path: file.path, code: stripNonCode(file.readAsStringSync()))] + ..sort((a, b) => a.path.compareTo(b.path)); +} + +/// Every `(` whose top-level arguments include `isFavorite:`, keyed by enclosing member. +List findIsFavoriteCallSites(RegExp callee) { + final found = []; + for (final source in loadLibSources()) { + for (final match in callee.allMatches(source.code)) { + final open = match.end - 1; + final close = matchingBracket(source.code, open); + if (close == -1 || !hasTopLevelIsFavorite(source.code, open, close)) { + continue; + } + found.add('${source.path} -> ${enclosingMember(source.code, match.start)}() -> ${match.group(1)}'); + } + } + return found..sort(); +} + +void main() { + const chokePointRemedy = + 'A new AssetBulkUpdateDto carrying isFavorite means a new route to the OWNER-ONLY bulk ' + 'endpoint PUT /assets. Since #763 favorites are a per-user overlay (asset_favorite) that a ' + "read-only space Viewer may set on another member's asset, so the write must go through " + 'PUT /assets/favorites — call AssetApiRepository.updateFavorite (which already handles the ' + 'fallback for servers without that endpoint) instead of adding another bulk-update site.'; + + const callerRemedy = + 'Something now passes isFavorite into the consolidated update path (AssetService.update -> ' + 'AssetApiRepository.update), which writes favorites through the OWNER-ONLY bulk endpoint ' + 'PUT /assets. Since #763 favorites are a per-user overlay (asset_favorite) that a read-only ' + "space Viewer may set on another member's asset: call AssetService.updateFavorite / " + 'AssetApiRepository.updateFavorite instead. If you deliberately removed the dead isFavorite ' + 'parameter from the consolidated path, update the expected list in this test to match.'; + + test('only the two sanctioned sites send isFavorite to the owner-only bulk endpoint (#763)', () { + // AssetApiRepository.update — the dead consolidated path (no caller passes isFavorite). + // AssetApiRepository.updateFavorite — deliberate fallback for servers not declaring AssetFavoritesV1. + const expected = [ + 'lib/repositories/asset_api.repository.dart -> update() -> AssetBulkUpdateDto', + 'lib/repositories/asset_api.repository.dart -> updateFavorite() -> AssetBulkUpdateDto', + ]; + + expect(findIsFavoriteCallSites(RegExp(r'\b(AssetBulkUpdateDto)\s*\(')), expected, reason: chokePointRemedy); + }); + + test('no caller passes isFavorite into the consolidated asset update path (#763)', () { + // The single entry is AssetService.update forwarding to its own API collaborator — the dead + // path itself. Any second entry is a new caller. (The sibling local-mirror forward is + // `_remoteRepository.updateAssets`, renamed upstream from `update`, so the `.update(` regex no + // longer sees it — no loss: that one writes Drift, never the owner-only endpoint.) + const expected = ['lib/domain/services/asset.service.dart -> update() -> _apiRepository.update']; + + expect( + findIsFavoriteCallSites(RegExp(r'\b([A-Za-z0-9_$]+(?:\.[A-Za-z0-9_$]+)*\.update)\s*\(')), + expected, + reason: callerRemedy, + ); + }); +} diff --git a/mobile/test/repositories/api_repository_lazy_resolution_test.dart b/mobile/test/repositories/api_repository_lazy_resolution_test.dart index 8244abe44edd20..2d0944d527466c 100644 --- a/mobile/test/repositories/api_repository_lazy_resolution_test.dart +++ b/mobile/test/repositories/api_repository_lazy_resolution_test.dart @@ -177,7 +177,7 @@ void main() { final oldApi = _MockAssetsApi(); final newApi = _MockAssetsApi(); when(() => apiService.assetsApi).thenReturn(oldApi); - final repo = AssetApiRepository(apiService); + final repo = AssetApiRepository(apiService, () => null); when(() => apiService.assetsApi).thenReturn(newApi); when(() => newApi.updateAsset(any(), any())).thenAnswer((_) => Future.error(Exception('stop'))); diff --git a/mobile/test/repositories/asset_api_repository_test.dart b/mobile/test/repositories/asset_api_repository_test.dart new file mode 100644 index 00000000000000..976fa86c3d74a8 --- /dev/null +++ b/mobile/test/repositories/asset_api_repository_test.dart @@ -0,0 +1,119 @@ +// #763: favorites are per-user, so the write path must route through the dedicated +// PUT /assets/favorites endpoint (AssetFavoriteUpdateDto), not the owner-only bulk-update +// endpoint (AssetBulkUpdateDto) other asset fields (visibility, location, dateTime) still use. +// +// No released fork server has that canonical endpoint (mobile and server release independently), +// so the write path is gated on the server's capability declaration (`GET /server/features` → +// `syncRequestTypes`): the endpoint ships together with the `AssetFavoritesV1` sync request type, +// so a declaration containing it gets the canonical endpoint, and everything else — not declared, +// or no declaration at all (pre-5.7.0 server, features not loaded) — falls back to the legacy +// bulk-update endpoint (see asset_api.repository.dart `updateFavorite` for the full rationale). +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:openapi/api.dart' as api; + +class MockAssetsApi extends Mock implements api.AssetsApi {} + +class MockApiService extends Mock implements ApiService {} + +void main() { + late MockAssetsApi mockApi; + late MockApiService mockApiService; + + AssetApiRepository buildRepository(Set? supportedSyncTypes) { + return AssetApiRepository(mockApiService, () => supportedSyncTypes); + } + + setUpAll(() { + registerFallbackValue(api.AssetFavoriteUpdateDto(ids: const [], isFavorite: false)); + registerFallbackValue(api.AssetBulkUpdateDto(ids: const [])); + }); + + setUp(() { + mockApi = MockAssetsApi(); + mockApiService = MockApiService(); + when(() => mockApiService.assetsApi).thenReturn(mockApi); + }); + + group('updateFavorite — server declares AssetFavoritesV1', () { + final newServer = {'AssetsV1', 'SharedSpaceAlbumFoldersV1', api.SyncRequestType.assetFavoritesV1.toJson()}; + + test('calls updateAssetFavorites (PUT /assets/favorites), not the bulk-update endpoint', () async { + final repository = buildRepository(newServer); + when(() => mockApi.updateAssetFavorites(any())).thenAnswer((_) async {}); + + await repository.updateFavorite(['asset-1', 'asset-2'], true); + + final dto = + verify(() => mockApi.updateAssetFavorites(captureAny())).captured.single as api.AssetFavoriteUpdateDto; + expect(dto.ids, ['asset-1', 'asset-2']); + expect(dto.isFavorite, isTrue); + verifyNever(() => mockApi.updateAssets(any())); + }); + + test('passes isFavorite: false through for unfavorite', () async { + final repository = buildRepository(newServer); + when(() => mockApi.updateAssetFavorites(any())).thenAnswer((_) async {}); + + await repository.updateFavorite(['asset-1'], false); + + final dto = + verify(() => mockApi.updateAssetFavorites(captureAny())).captured.single as api.AssetFavoriteUpdateDto; + expect(dto.ids, ['asset-1']); + expect(dto.isFavorite, isFalse); + }); + }); + + group('updateFavorite — server declares capabilities but not AssetFavoritesV1 (released 5.7.x)', () { + const oldServer = {'AssetsV1', 'SharedSpaceAlbumFoldersV1'}; + + test('falls back to updateAssets (PUT /assets), not the canonical endpoint', () async { + final repository = buildRepository(oldServer); + when(() => mockApi.updateAssets(any())).thenAnswer((_) async {}); + + await repository.updateFavorite(['asset-1', 'asset-2'], true); + + final dto = verify(() => mockApi.updateAssets(captureAny())).captured.single as api.AssetBulkUpdateDto; + expect(dto.ids, ['asset-1', 'asset-2']); + expect(dto.isFavorite.value, isTrue); + verifyNever(() => mockApi.updateAssetFavorites(any())); + }); + + test('passes isFavorite: false through for unfavorite', () async { + final repository = buildRepository(oldServer); + when(() => mockApi.updateAssets(any())).thenAnswer((_) async {}); + + await repository.updateFavorite(['asset-1'], false); + + final dto = verify(() => mockApi.updateAssets(captureAny())).captured.single as api.AssetBulkUpdateDto; + expect(dto.ids, ['asset-1']); + expect(dto.isFavorite.value, isFalse); + }); + + test('an empty declaration also falls back to updateAssets', () async { + final repository = buildRepository(const {}); + when(() => mockApi.updateAssets(any())).thenAnswer((_) async {}); + + await repository.updateFavorite(['asset-1'], true); + + verify(() => mockApi.updateAssets(any())).called(1); + verifyNever(() => mockApi.updateAssetFavorites(any())); + }); + }); + + group('updateFavorite — no declaration (pre-5.7.0 server, or features not loaded yet)', () { + test('null declaration falls back to updateAssets (PUT /assets), not the canonical endpoint', () async { + final repository = buildRepository(null); + when(() => mockApi.updateAssets(any())).thenAnswer((_) async {}); + + await repository.updateFavorite(['asset-1', 'asset-2'], true); + + final dto = verify(() => mockApi.updateAssets(captureAny())).captured.single as api.AssetBulkUpdateDto; + expect(dto.ids, ['asset-1', 'asset-2']); + expect(dto.isFavorite.value, isTrue); + verifyNever(() => mockApi.updateAssetFavorites(any())); + }); + }); +} diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index a0f752d7f106cb..9cde8a3ac3bb70 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -183,6 +183,7 @@ class ServiceMocks { void _stubAssetService() { when(asset.getAsset).thenAnswer((_) async => null); when(asset.update).thenAnswer((_) async {}); + when(asset.updateFavorite).thenAnswer((_) async {}); when(asset.stack).thenAnswer((_) async {}); when(asset.unstack).thenAnswer((_) async {}); when(asset.restoreTrash).thenAnswer((_) async {}); @@ -362,6 +363,12 @@ extension type const AssetServiceStub(MockAssetService service) implements Stub< location: any(named: 'location'), ); + // gallery-fork (#763): favorites are a per-user overlay written through their own endpoint, + // so the action calls updateFavorite rather than update. Without a descriptor here it has no + // default answer and every awaited call returns null. + Future Function() get updateFavorite => + () => service.updateFavorite(any(), any()); + Future Function() get stack => () => service.stack(any(), any()); diff --git a/mobile/test/unit/presentation/actions/favorite_action_test.dart b/mobile/test/unit/presentation/actions/favorite_action_test.dart index 4d61965b1f25dc..b3ae42eedd4db6 100644 --- a/mobile/test/unit/presentation/actions/favorite_action_test.dart +++ b/mobile/test/unit/presentation/actions/favorite_action_test.dart @@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; -import 'package:immich_mobile/utils/option.dart'; import 'package:immich_ui/immich_ui.dart'; import 'package:mocktail/mocktail.dart'; @@ -27,42 +26,79 @@ void main() { RemoteAsset owned({bool isFavorite = false}) => RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite); + RemoteAsset notOwned({bool isFavorite = false}) => RemoteAssetFactory.create(isFavorite: isFavorite); + Future pumpFavorite(WidgetTester tester, Set selection) => tester.pumpTestAction(context, const FavoriteAction(source: .timeline), overrides: context.selected(selection)); group('FavoriteAction', () { - testWidgets('favorites the eligible owned assets', (tester) async { + testWidgets('favorites the eligible assets', (tester) async { final asset = owned(); await pumpFavorite(tester, {asset}); - verify(() => assetService.update([asset.id], isFavorite: const Option.some(true))).called(1); + verify(() => assetService.updateFavorite([asset.id], true)).called(1); }); - testWidgets('unfavorite the eligible owned assets', (tester) async { + testWidgets('unfavorite the eligible assets', (tester) async { final asset = owned(isFavorite: true); await pumpFavorite(tester, {asset}); - verify(() => assetService.update([asset.id], isFavorite: const Option.some(false))).called(1); + verify(() => assetService.updateFavorite([asset.id], false)).called(1); }); - testWidgets('ignores assets owned by someone else', (tester) async { + // #763: favorites are per-user, not owner-gated — read access to a shared-space asset + // (implied by it being in the local mirror) is sufficient to favorite it. This inverts the + // pre-#763 "ignores assets owned by someone else" expectation. + testWidgets('includes assets owned by someone else (un-gated)', (tester) async { final mine = owned(); - final theirs = RemoteAssetFactory.create(); + final theirs = notOwned(); await pumpFavorite(tester, {mine, theirs}); - verify(() => assetService.update([mine.id], isFavorite: const Option.some(true))).called(1); + verify(() => assetService.updateFavorite([mine.id, theirs.id], true)).called(1); + }); + + testWidgets('is actionable for a non-owned-only selection', (tester) async { + final theirs = notOwned(); + + await pumpFavorite(tester, {theirs}); + + verify(() => assetService.updateFavorite([theirs.id], true)).called(1); + }); + + // #763 (E32): the direction must derive from the SAME candidate set the action mutates. + // Before the fix, the direction flag was computed over the raw (unfiltered) selection while the + // mutation set was owner-filtered — a non-owned asset could flip the direction while being + // excluded from the mutation, leaving an empty mutation set sent to the service. + testWidgets('E32: mixed ownership keeps direction and mutation set coherent', (tester) async { + final mineFavorited = owned(isFavorite: true); + final theirsUnfavorited = notOwned(); + + await pumpFavorite(tester, {mineFavorited, theirsUnfavorited}); + + // Mutation set: exactly the candidate not already in the target state (theirsUnfavorited). + // Pre-fix, the owner filter would have dropped it, sending an empty id list. + verify(() => assetService.updateFavorite([theirsUnfavorited.id], true)).called(1); + }); + + testWidgets('batches every eligible asset into a single call', (tester) async { + final first = owned(); + final second = owned(); + + await pumpFavorite(tester, {first, second}); + + verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1); }); - testWidgets('skips owned assets already in the target state', (tester) async { + testWidgets('skips assets already in the target state', (tester) async { final stale = owned(); final alreadyFavorite = owned(isFavorite: true); await pumpFavorite(tester, {stale, alreadyFavorite}); - verify(() => assetService.update([stale.id], isFavorite: const Option.some(true))).called(1); + verify(() => assetService.updateFavorite([stale.id], true)).called(1); }); testWidgets('shows a confirmation snackbar on success', (tester) async { @@ -79,14 +115,15 @@ void main() { expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action'); }); - testWidgets('is hidden when none of the selected assets are owned', (tester) async { + // #763: inverted from the pre-#763 "hidden when none of the selected assets are owned". + testWidgets('is visible when none of the selected assets are owned', (tester) async { await tester.pumpTestWidget( context, const ActionIconButton(action: FavoriteAction(source: .timeline)), overrides: context.selected({RemoteAssetFactory.create()}), ); - expect(find.byType(ImmichIconButton), findsNothing); + expect(find.byType(ImmichIconButton), findsOneWidget); }); }); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index a45062e58b87f1..50e43d8b0333af 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5128,6 +5128,55 @@ "x-immich-state": "Stable" } }, + "/assets/favorites": { + "put": { + "description": "Favorites are per-user. Requires only read access to the assets; a space viewer may favorite an asset they do not own. Never affects any other user.", + "operationId": "updateAssetFavorites", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFavoriteUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Set favorite state for the requesting user", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + } + ], + "x-immich-permission": "asset.read", + "x-immich-state": "Beta" + } + }, "/assets/jobs": { "post": { "description": "Run a specific job on a set of assets.", @@ -28558,7 +28607,7 @@ "type": "array" }, "isFavorite": { - "description": "Mark as favorite", + "description": "Mark as favorite. Deprecated: use PUT /assets/favorites instead (favorites are per-user).", "type": "boolean" }, "latitude": { @@ -29058,6 +29107,30 @@ ], "type": "object" }, + "AssetFavoriteUpdateDto": { + "properties": { + "ids": { + "description": "Asset IDs", + "items": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "maxItems": 1000, + "minItems": 1, + "type": "array" + }, + "isFavorite": { + "description": "Favorite state for the requesting user", + "type": "boolean" + } + }, + "required": [ + "ids", + "isFavorite" + ], + "type": "object" + }, "AssetFileResponseDto": { "properties": { "createdAt": { @@ -42632,6 +42705,34 @@ ], "type": "object" }, + "SyncAssetFavoriteDeleteV1": { + "properties": { + "assetId": { + "description": "Asset ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + "required": [ + "assetId" + ], + "type": "object" + }, + "SyncAssetFavoriteV1": { + "properties": { + "assetId": { + "description": "Asset ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + "required": [ + "assetId" + ], + "type": "object" + }, "SyncAssetMetadataDeleteV1": { "properties": { "assetId": { @@ -43185,6 +43286,8 @@ "AssetMetadataDeleteV1", "AssetOcrV1", "AssetOcrDeleteV1", + "AssetFavoriteV1", + "AssetFavoriteDeleteV1", "PartnerV1", "PartnerDeleteV1", "PartnerAssetV1", @@ -43643,6 +43746,7 @@ "AssetEditsV1", "AssetMetadataV1", "AssetOcrV1", + "AssetFavoritesV1", "AuthUsersV1", "MemoriesV1", "MemoryToAssetsV1", @@ -44941,7 +45045,7 @@ "type": "string" }, "isFavorite": { - "description": "Mark as favorite", + "description": "Mark as favorite. Deprecated: use PUT /assets/favorites instead (favorites are per-user).", "type": "boolean" }, "latitude": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 26c1a33271ef0b..5dc00878e8e79f 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1484,7 +1484,7 @@ export type AssetBulkUpdateDto = { duplicateId?: string | null; /** Asset IDs to update */ ids: string[]; - /** Mark as favorite */ + /** Mark as favorite. Deprecated: use PUT /assets/favorites instead (favorites are per-user). */ isFavorite?: boolean; /** Latitude coordinate */ latitude?: number; @@ -1544,6 +1544,12 @@ export type AssetEditableResponseDto = { /** Subset of the requested IDs the caller may edit */ editableAssetIds: string[]; }; +export type AssetFavoriteUpdateDto = { + /** Asset IDs */ + ids: string[]; + /** Favorite state for the requesting user */ + isFavorite: boolean; +}; export type AssetJobsDto = { /** Asset IDs */ assetIds: string[]; @@ -1758,7 +1764,7 @@ export type UpdateAssetDto = { dateTimeOriginal?: string; /** Asset description */ description?: string; - /** Mark as favorite */ + /** Mark as favorite. Deprecated: use PUT /assets/favorites instead (favorites are per-user). */ isFavorite?: boolean; /** Latitude coordinate */ latitude?: number; @@ -4607,6 +4613,14 @@ export type SyncAssetFaceV2 = { /** Source type */ sourceType: string; }; +export type SyncAssetFavoriteDeleteV1 = { + /** Asset ID */ + assetId: string; +}; +export type SyncAssetFavoriteV1 = { + /** Asset ID */ + assetId: string; +}; export type SyncAssetMetadataDeleteV1 = { /** Asset ID */ assetId: string; @@ -6291,6 +6305,18 @@ export function getEditableAssets({ assetEditableDto }: { body: assetEditableDto }))); } +/** + * Set favorite state for the requesting user + */ +export function updateAssetFavorites({ assetFavoriteUpdateDto }: { + assetFavoriteUpdateDto: AssetFavoriteUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/assets/favorites", oazapfts.json({ + ...opts, + method: "PUT", + body: assetFavoriteUpdateDto + }))); +} /** * Run an asset job */ @@ -11597,6 +11623,8 @@ export enum SyncEntityType { AssetMetadataDeleteV1 = "AssetMetadataDeleteV1", AssetOcrV1 = "AssetOcrV1", AssetOcrDeleteV1 = "AssetOcrDeleteV1", + AssetFavoriteV1 = "AssetFavoriteV1", + AssetFavoriteDeleteV1 = "AssetFavoriteDeleteV1", PartnerV1 = "PartnerV1", PartnerDeleteV1 = "PartnerDeleteV1", PartnerAssetV1 = "PartnerAssetV1", @@ -11703,6 +11731,7 @@ export enum SyncRequestType { AssetEditsV1 = "AssetEditsV1", AssetMetadataV1 = "AssetMetadataV1", AssetOcrV1 = "AssetOcrV1", + AssetFavoritesV1 = "AssetFavoritesV1", AuthUsersV1 = "AuthUsersV1", MemoriesV1 = "MemoriesV1", MemoryToAssetsV1 = "MemoryToAssetsV1", diff --git a/scripts/revert-to-immich.sql b/scripts/revert-to-immich.sql index 6b6d11703bd711..4478cdf332520a 100644 --- a/scripts/revert-to-immich.sql +++ b/scripts/revert-to-immich.sql @@ -95,6 +95,38 @@ DROP TRIGGER IF EXISTS "library_after_insert" ON "library"; DROP TRIGGER IF EXISTS "asset_library_delete_audit" ON "asset"; DROP TRIGGER IF EXISTS "album_soft_delete_shared_space_album" ON "album"; +-- ----------------------------------------------------------------------------- +-- 1b. Restore upstream columns Gallery removed. +-- +-- #763: per-user favorites. Gallery replaced asset."isFavorite" with the +-- asset_favorite overlay, so reverting must RESTORE the upstream column before +-- the overlay is dropped. This is the first fork change that removes upstream +-- schema, so it is the first restore step in this script. +-- LOSSY: favorites belonging to non-owners (space members who favorited someone +-- else's photo) have nowhere to live in plain Immich and are discarded. +-- +-- IF NOT EXISTS: slice 3 (1794100000000-DropAssetIsFavoriteColumn) has now dropped +-- asset."isFavorite" from the live schema, but a Gallery DB that has not yet applied that +-- migration still has the column, and a bare ADD COLUMN would fail with "column already exists" +-- against it. IF NOT EXISTS keeps the script correct in both states. +-- ----------------------------------------------------------------------------- +ALTER TABLE "asset" ADD COLUMN IF NOT EXISTS "isFavorite" boolean NOT NULL DEFAULT false; + +-- Guarded: on a Gallery DB that predates the #763 migrations entirely (e.g. the CI validation +-- image built from a pre-favorites main, or a deployment that never upgraded that far), the +-- asset_favorite overlay does not exist and the raw column still carries the favorites — there +-- is nothing to backfill and the bare UPDATE would abort the transaction. +DO $$ +BEGIN + IF to_regclass('public.asset_favorite') IS NOT NULL THEN + UPDATE "asset" + SET "isFavorite" = true + FROM asset_favorite f + WHERE f."assetId" = "asset"."id" + AND f."userId" = "asset"."ownerId"; + END IF; +END $$; + -- ----------------------------------------------------------------------------- -- 2. Drop Gallery-only tables (CASCADE). -- @@ -160,6 +192,11 @@ DROP TABLE IF EXISTS "classification_category" CASCADE; DROP TABLE IF EXISTS "storage_migration_log" CASCADE; DROP TABLE IF EXISTS "asset_duplicate_checksum" CASCADE; +-- Per-user favorites overlay (#763). asset."isFavorite" is restored and +-- backfilled from this table in step 1b above before it is dropped here. +DROP TABLE IF EXISTS "asset_favorite_audit" CASCADE; +DROP TABLE IF EXISTS "asset_favorite" CASCADE; + -- ----------------------------------------------------------------------------- -- 3. Drop Gallery-only functions. -- @@ -191,6 +228,7 @@ DROP FUNCTION IF EXISTS shared_space_album_hidden_delete_audit() CASCADE; DROP FUNCTION IF EXISTS shared_space_album_folder_delete_audit() CASCADE; DROP FUNCTION IF EXISTS album_soft_delete_shared_space_album() CASCADE; DROP FUNCTION IF EXISTS album_space_asset_delete_audit() CASCADE; +DROP FUNCTION IF EXISTS asset_favorite_delete_audit() CASCADE; -- ----------------------------------------------------------------------------- -- 4. Drop Gallery-added columns from Immich-native tables. @@ -263,6 +301,7 @@ DELETE FROM "migration_overrides" 'trigger_shared_space_album_hidden_updatedAt', 'function_album_soft_delete_shared_space_album', 'function_album_space_asset_delete_audit', + 'function_asset_favorite_delete_audit', 'index_asset_face_personId_idx', 'index_face_identity_representativeFaceId_idx', 'index_person_identityId_idx', @@ -317,7 +356,8 @@ DELETE FROM "migration_overrides" 'trigger_shared_space_person_updatedAt', 'trigger_shared_space_updatedAt', 'trigger_user_group_updatedAt', - 'trigger_shared_space_album_folder_updatedAt' + 'trigger_shared_space_album_folder_updatedAt', + 'trigger_asset_favorite_delete_audit' ); -- ----------------------------------------------------------------------------- @@ -509,6 +549,10 @@ DELETE FROM "kysely_migrations" '1793100000000-AddSharedSpaceAlbumFolderTable', '1793200000000-SharedSpaceAlbumFolderAuditTable', '1793300000000-ClearPreOptionMFaceRepairScans', + -- #763 per-user favorites. Renumbered off 1784000000000/1784100000000 during the v3.2.0 rebase: + -- 1784000000000 was already taken by FixFaceRepairScanInFlightIndexOverride above. + '1794000000000-AddAssetFavoriteTables', + '1794100000000-DropAssetIsFavoriteColumn', '1796000000000-AddAssetFaceCreatedBy', -- Build-time compatibility alias (server/bin/sync-gallery-migrations.mjs): this migration was -- renumbered off 1793000000000 when fork PR #1060 took that timestamp, but rolling RC instances @@ -586,7 +630,8 @@ BEGIN OR "name" LIKE '%AddFaceRepairDecline%' OR "name" LIKE '%AddFaceRepairLock%' OR "name" LIKE '%AddFaceRepairScanFlaggedFace%' - OR "name" LIKE '%AddFaceRepairScanInFlightIndex%'; + OR "name" LIKE '%AddFaceRepairScanInFlightIndex%' + OR "name" LIKE '%AssetFavoriteTables%'; IF fork_rows_left > 0 THEN RAISE EXCEPTION 'revert-to-immich: % Gallery row(s) still present in kysely_migrations after cleanup — aborting.', fork_rows_left; END IF; @@ -608,14 +653,15 @@ BEGIN 'shared_space_album_hidden', 'shared_space_album_hidden_audit', 'shared_space_album_asset_audit', 'shared_space_album_folder', 'shared_space_album_folder_audit', - 'album_space_asset_audit', + 'album_space_asset_audit', 'album_space_asset', 'face_identity_face', 'face_identity', 'shared_space', 'user_group_member', 'user_group', 'classification_prompt_embedding', 'classification_category', 'storage_migration_log', 'asset_duplicate_checksum', 'face_person_verdict', 'face_repair_scan', 'face_repair_decline', 'face_repair_scan_flagged_face', 'face_repair_lock', - 'pet_search' + 'pet_search', + 'asset_favorite_audit', 'asset_favorite' ); IF fork_tables_left > 0 THEN RAISE EXCEPTION 'revert-to-immich: % Gallery table(s) still present after cleanup — aborting.', fork_tables_left; diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 225692f948aec5..55cb1ff1495181 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -9,6 +9,7 @@ import { AssetCopyDto, AssetEditableDto, AssetEditableResponseDto, + AssetFavoriteUpdateDto, AssetJobsDto, AssetMetadataBulkDeleteDto, AssetMetadataBulkResponseDto, @@ -158,6 +159,23 @@ export class AssetController { return this.service.deleteBulkMetadata(auth, dto); } + // #763: this static 'favorites' path MUST be declared before @Put(':id') below — Nest matches + // routes in declaration order, so after ':id' this would 404 with a UUID-validation error + // ("favorites" is not a valid uuid) instead of reaching this handler. + @Put('favorites') + @Authenticated({ permission: Permission.AssetRead }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Set favorite state for the requesting user', + description: + 'Favorites are per-user. Requires only read access to the assets; a space viewer may favorite an asset ' + + 'they do not own. Never affects any other user.', + history: new HistoryBuilder().added('v1').beta('v1'), + }) + updateAssetFavorites(@Auth() auth: AuthDto, @Body() dto: AssetFavoriteUpdateDto): Promise { + return this.service.updateFavorites(auth, dto); + } + @Put(':id') @Authenticated({ permission: Permission.AssetUpdate }) @Endpoint({ diff --git a/server/src/database.ts b/server/src/database.ts index 37206d3cdc6ac8..f1a91a2682b958 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -432,6 +432,10 @@ export const columns = { 'asset.height', 'asset.isEdited', ], + // #763: isFavorite is intentionally NOT listed here — the search/city repository methods that + // use this list project it separately via favoriteExistsFor(eb, options.authUserId), the + // per-user asset_favorite overlay resolved for the CALLER, instead of the raw asset column + // (which was dropped in slice 3). Same pattern as syncAsset / workflowAssetV1 below. searchAsset: [ 'asset.id', 'asset.updateId', @@ -446,7 +450,6 @@ export const columns = { 'asset.fileCreatedAt', 'asset.fileModifiedAt', 'asset.isExternal', - 'asset.isFavorite', 'asset.isOffline', 'asset.isEdited', 'asset.visibility', @@ -483,10 +486,12 @@ export const columns = { 'asset.originalPath', 'asset.originalFileName', 'asset.isOffline', - 'asset.isFavorite', + // #763: isFavorite is intentionally NOT listed here — the sole consumer, workflow.repository.ts's + // getForAssetV1, projects it separately via favoriteExistsForOwner(eb), the per-user asset_favorite + // overlay resolved for the asset's OWNER (background workflow jobs have no auth user — see + // favorite.ts's doc comment for the owner-semantics rationale). 'asset.isExternal', 'asset.isEdited', - 'asset.isFavorite', ], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'], assetFilesForThumbnail: [ @@ -530,6 +535,9 @@ export const columns = { 'plugin_method.allowedHosts', 'plugin_method.uiHints', ], + // #763: isFavorite is intentionally NOT listed here — AssetSync.getUpserts (sync.repository.ts) + // projects it separately via favoriteExistsFor(eb, userId), the per-user asset_favorite overlay, + // instead of this raw asset column (same pattern as syncLibraryAsset below). syncAsset: [ 'asset.id', 'asset.ownerId', @@ -542,7 +550,6 @@ export const columns = { 'asset.localDateTime', 'asset.type', 'asset.deletedAt', - 'asset.isFavorite', 'asset.visibility', 'asset.duration', 'asset.livePhotoVideoId', @@ -615,9 +622,10 @@ export const columns = { 'asset.height', 'asset.isEdited', ], - // syncAsset minus isFavorite — LibraryAssetSync masks the favorite flag per row - // (owner keeps it, other library members see false), like the other shared-scope - // sync column sets above (issue #743 item 1). + // syncAsset minus isFavorite — LibraryAssetSync projects the favorite flag separately via + // favoriteExistsFor(eb, userId) (#763), the per-user asset_favorite overlay: each syncing user + // sees their OWN favorite state for the row, never another library member's, like the other + // shared-scope sync column sets above (issue #743 item 1). syncLibraryAsset: [ 'asset.id', 'asset.ownerId', diff --git a/server/src/dtos/asset-response.dto.spec.ts b/server/src/dtos/asset-response.dto.spec.ts new file mode 100644 index 00000000000000..0f5a83803afee9 --- /dev/null +++ b/server/src/dtos/asset-response.dto.spec.ts @@ -0,0 +1,47 @@ +import { mapAsset } from 'src/dtos/asset-response.dto.js'; +import { AssetFactory } from 'test/factories/asset.factory.js'; +import { authStub } from 'test/fixtures/auth.stub.js'; +import { getForAsset } from 'test/mappers.js'; + +describe('mapAsset — per-user favorite (#763)', () => { + it('reports true when the row carries isFavoriteForUser', () => { + const asset = getForAsset(AssetFactory.create({ ownerId: authStub.user1.user.id })); + + const result = mapAsset({ ...asset, isFavoriteForUser: true }, { auth: authStub.user1 }); + + expect(result.isFavorite).toBe(true); + }); + + it('reports false when the row carries isFavoriteForUser false', () => { + const asset = getForAsset(AssetFactory.create({ ownerId: authStub.user1.user.id })); + + const result = mapAsset({ ...asset, isFavoriteForUser: false }, { auth: authStub.user1 }); + + expect(result.isFavorite).toBe(false); + }); + + it('reports false when the row does not carry the field at all', () => { + // a query that forgot to project -> fail-safe false, never a leak + const asset = getForAsset(AssetFactory.create({ ownerId: authStub.user1.user.id })); + + const result = mapAsset(asset, { auth: authStub.user1 }); + + expect(result.isFavorite).toBe(false); + }); + + it('reports false for a shared-link call with no auth', () => { + // The mapSharedLink regression this slice exists to prevent: no auth object at all -> + // isFavoriteForUser is never projected -> must never leak a favorite to an anonymous visitor. + const asset = getForAsset(AssetFactory.create()); + + const result = mapAsset(asset, { stripMetadata: false }); + + expect(result.isFavorite).toBe(false); + }); + + // #763 slice 3: the global asset."isFavorite" column mapAsset used to guard against leaking + // (owner's raw flag reaching non-owners / anonymous callers) has been dropped outright — the + // leak this guarded against is now structurally impossible, since there is no raw column left + // to fall back to. The fail-safe-false coverage above (row without isFavoriteForUser) and the + // grep gate (favorite-grep-gate.spec.ts) cover the same intent going forward. +}); diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 787cf6954a8324..e829a2dd01ec03 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -1,4 +1,4 @@ -import { Selectable, ShallowDehydrateObject } from 'kysely'; +import { Selectable, ShallowDehydrateObject, SqlBool } from 'kysely'; import { createZodDto } from 'nestjs-zod'; import z from 'zod'; import type { MaybeDehydrated } from 'src/types.js'; @@ -143,7 +143,14 @@ export type MapAsset = { fileModifiedAt: Date; files?: ShallowDehydrateObject[]; isExternal: boolean; - isFavorite: boolean; + // #763: resolved per-user by the query via favoriteExistsFor(...).as('isFavoriteForUser'). There + // is deliberately no plain `isFavorite` field here — the global column it used to come from was + // dropped in slice 3, and reading it would leak the owner's flag to non-owners and to auth-less + // callers such as mapSharedLink. See mapAsset. + // Typed `SqlBool` (kysely's `boolean | 0 | 1`), not plain `boolean` — that's what `.select()` + // infers for a projected `favoriteExistsFor(...)` expression (used in EXISTS/WHERE position + // elsewhere in the codebase, where SqlBool is the natural fit). mapAsset coerces with `!!`. + isFavoriteForUser?: SqlBool; isOffline: boolean; visibility: AssetVisibility; libraryId: string | null; @@ -231,7 +238,12 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt fileModifiedAt: asDateTimeString(entity.fileModifiedAt), localDateTime: asDateTimeString(entity.localDateTime), updatedAt: asDateTimeString(entity.updatedAt), - isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite, + // #763: resolved per-user by the query via favoriteExistsFor(...).as('isFavoriteForUser'). The + // global asset."isFavorite" column this used to read is gone (dropped in slice 3) — reading it + // would have leaked the owner's flag to non-owners and to auth-less callers such as + // mapSharedLink. A query that does not project the field yields undefined -> false (fail-safe). + // `!!` (not `?? false`) also normalizes SqlBool's `0`/`1` to a real boolean. + isFavorite: !!entity.isFavoriteForUser, isArchived: entity.visibility === AssetVisibility.Archive, isTrashed: !!entity.deletedAt, visibility: entity.visibility, diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index a5d3863648bd29..922a8e06adbe18 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -8,7 +8,13 @@ import { IsNotSiblingOf, isoDatetimeToDate, latitudeSchema, longitudeSchema, str const UpdateAssetBaseSchema = z .object({ - isFavorite: z.boolean().optional().describe('Mark as favorite'), + // #763: deprecated alias for PUT /assets/favorites. Still routes into the SAME service method + // (AssetService.updateFavorites) as the canonical endpoint, behind this DTO's stricter + // Permission.AssetUpdate route guard — see docs/superpowers/specs/2026-07-20-per-user-favorites-design.md §8.1. + isFavorite: z + .boolean() + .optional() + .describe('Mark as favorite. Deprecated: use PUT /assets/favorites instead (favorites are per-user).'), visibility: AssetVisibilitySchema.optional(), dateTimeOriginal: z.string().optional().describe('Original date and time'), latitude: latitudeSchema.optional().describe('Latitude coordinate'), @@ -182,6 +188,17 @@ const AssetEditableResponseSchema = z }) .meta({ id: 'AssetEditableResponseDto' }); +// #763: the canonical write surface for favorites. Gated on Permission.AssetRead (own assets, +// shared-space assets at ANY role including viewer, shared-album, partner-shared) — see +// docs/superpowers/specs/2026-07-20-per-user-favorites-design.md §5.1. `ids` is bounded (E28): +// an explicit, documented limit rather than an unbounded statement. +const AssetFavoriteUpdateSchema = z + .object({ + ids: z.array(z.uuidv4()).min(1).max(1000).describe('Asset IDs'), + isFavorite: z.boolean().describe('Favorite state for the requesting user'), + }) + .meta({ id: 'AssetFavoriteUpdateDto' }); + export const mapStats = (stats: AssetStats): AssetStatsResponseDto => { return { images: stats[AssetType.Image], @@ -207,3 +224,4 @@ export class AssetCopyDto extends createZodDto(AssetCopySchema) {} export class AssetDownloadOriginalDto extends createZodDto(AssetDownloadOriginalSchema) {} export class AssetEditableDto extends createZodDto(AssetEditableSchema) {} export class AssetEditableResponseDto extends createZodDto(AssetEditableResponseSchema) {} +export class AssetFavoriteUpdateDto extends createZodDto(AssetFavoriteUpdateSchema) {} diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index dbe18c99aed83b..9ca27c37817b12 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -132,6 +132,9 @@ export function mapSharedLink( type: sharedLink.type, createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, + // #763: deliberately NOT projecting isFavoriteForUser — there is no auth object at all for an + // anonymous shared-link visitor, so `false` is correct (and is what mapAsset's fail-safe + // `?? false` already yields for a row with the field unset). assets: assets.map((asset) => { const mapped = mapAsset(asset, { stripMetadata: options.stripAssetMetadata }); if (options.redactAssetOwners) { diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 2cb6144bcbaa91..819c1cb34b8c65 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -185,6 +185,16 @@ const SyncAssetEditDeleteV1Schema = z .object({ editId: z.uuidv4().describe('Edit ID') }) .meta({ id: 'SyncAssetEditDeleteV1' }); +// #763: asset_favorite is its own synced entity (design doc §4.3) — caller-scoped (no userId in +// the payload; the stream is already filtered to the syncing user), mirroring SyncAssetEditV1. +const SyncAssetFavoriteV1Schema = z + .object({ assetId: z.uuidv4().describe('Asset ID') }) + .meta({ id: 'SyncAssetFavoriteV1' }); + +const SyncAssetFavoriteDeleteV1Schema = z + .object({ assetId: z.uuidv4().describe('Asset ID') }) + .meta({ id: 'SyncAssetFavoriteDeleteV1' }); + @ExtraModel() class SyncAssetDeleteV1 extends createZodDto(SyncAssetDeleteV1Schema) {} @ExtraModel() @@ -197,6 +207,10 @@ class SyncAssetMetadataDeleteV1 extends createZodDto(SyncAssetMetadataDeleteV1Sc export class SyncAssetEditV1 extends createZodDto(SyncAssetEditV1Schema) {} @ExtraModel() class SyncAssetEditDeleteV1 extends createZodDto(SyncAssetEditDeleteV1Schema) {} +@ExtraModel() +export class SyncAssetFavoriteV1 extends createZodDto(SyncAssetFavoriteV1Schema) {} +@ExtraModel() +class SyncAssetFavoriteDeleteV1 extends createZodDto(SyncAssetFavoriteDeleteV1Schema) {} const SyncAlbumDeleteV1Schema = z .object({ albumId: z.uuidv4().describe('Album ID') }) @@ -681,6 +695,8 @@ export type SyncItem = { [SyncEntityType.AssetOcrDeleteV1]: SyncAssetOcrDeleteV1; [SyncEntityType.AssetEditV1]: SyncAssetEditV1; [SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1; + [SyncEntityType.AssetFavoriteV1]: SyncAssetFavoriteV1; + [SyncEntityType.AssetFavoriteDeleteV1]: SyncAssetFavoriteDeleteV1; [SyncEntityType.PartnerAssetV2]: SyncAssetV2; [SyncEntityType.PartnerAssetBackfillV2]: SyncAssetV2; [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index 4ad55357bee206..1a5a1a0780d179 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1155,6 +1155,10 @@ export enum SyncRequestType { AssetEditsV1 = 'AssetEditsV1', AssetMetadataV1 = 'AssetMetadataV1', AssetOcrV1 = 'AssetOcrV1', + // #763: asset_favorite is its own synced entity (design doc §4.3) — a favorite write must + // never bump asset.updateId (that would amplify a personal write into a cross-user re-sync), + // so favorites need their own request/entity types rather than riding the asset payload. + AssetFavoritesV1 = 'AssetFavoritesV1', AuthUsersV1 = 'AuthUsersV1', MemoriesV1 = 'MemoriesV1', MemoryToAssetsV1 = 'MemoryToAssetsV1', @@ -1220,6 +1224,10 @@ export enum SyncEntityType { AssetOcrV1 = 'AssetOcrV1', AssetOcrDeleteV1 = 'AssetOcrDeleteV1', + // #763: per-user favorites overlay — see AssetFavoritesV1 above. + AssetFavoriteV1 = 'AssetFavoriteV1', + AssetFavoriteDeleteV1 = 'AssetFavoriteDeleteV1', + PartnerV1 = 'PartnerV1', PartnerDeleteV1 = 'PartnerDeleteV1', diff --git a/server/src/queries/asset.favorite.repository.sql b/server/src/queries/asset.favorite.repository.sql new file mode 100644 index 00000000000000..633ae7ce74b97b --- /dev/null +++ b/server/src/queries/asset.favorite.repository.sql @@ -0,0 +1,34 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- AssetFavoriteRepository.addAll +begin +insert into + "asset_favorite" ("userId", "assetId") +values + ($1, $2) +on conflict do nothing +returning + "assetId" +rollback + +-- AssetFavoriteRepository.removeAll +begin +delete from "asset_favorite" +where + "userId" = $1 + and "assetId" in ($2) +returning + "assetId" +commit + +-- AssetFavoriteRepository.mergeOnto +insert into + "asset_favorite" +select distinct + "asset_favorite"."userId", + $1::uuid as "assetId" +from + "asset_favorite" +where + "asset_favorite"."assetId" in ($2) +on conflict do nothing diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 25c2335939a842..d7dc2d68eb008e 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -262,11 +262,19 @@ order by select "asset"."id", "asset"."localDateTime", - "asset"."isFavorite", "asset"."type", "asset"."duration", "asset_exif"."country" as "country", "asset_exif"."city" as "city", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", extract( year from @@ -276,15 +284,15 @@ from "asset" left join "asset_exif" on "asset_exif"."assetId" = "asset"."id" where - "asset"."ownerId" = $1 - and "asset"."visibility" = $2 + "asset"."ownerId" = $2 + and "asset"."visibility" = $3 and "asset"."deletedAt" is null - and "asset"."localDateTime" <= $3 + and "asset"."localDateTime" <= $4 and extract( month from (asset."localDateTime" at time zone 'UTC') - )::int in ($4) + )::int in ($5) and exists ( select "asset_file"."assetId" @@ -292,7 +300,7 @@ where "asset_file" where "asset_file"."assetId" = "asset"."id" - and "asset_file"."type" = $5 + and "asset_file"."type" = $6 ) order by "asset"."localDateTime" asc @@ -487,13 +495,101 @@ select "asset"."id" = "tag_asset"."assetId" ) as agg ) as "tags", - to_json("asset_exif") as "exifInfo" + to_json("asset_exif") as "exifInfo", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = "asset"."ownerId" + ) as "isFavorite" from "asset" left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."id" = any ($2::uuid[]) +-- AssetRepository.getByIdsWithAllRelationsButStacks (with authUserId) +select + "asset".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_face".*, + "person" as "person" + from + "asset_face" + left join lateral ( + select + "person".* + from + "person" + where + "person"."personGroupId" = "asset_face"."personGroupId" + order by + case + when "person"."ownerId" = $1 then 0 + when "person"."ownerId" = "asset"."ownerId" then 1 + else 2 + end + limit + $2 + ) as "person" on true + where + "asset_face"."assetId" = "asset"."id" + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true + ) as agg + ) as "faces", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "tag"."id", + "tag"."value", + "tag"."createdAt", + "tag"."updatedAt", + "tag"."color", + "tag"."parentId" + from + "tag" + inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId" + where + "asset"."id" = "tag_asset"."assetId" + ) as agg + ) as "tags", + to_json("asset_exif") as "exifInfo", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = "asset"."ownerId" + ) as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $3::uuid + ) as "isFavoriteForUser" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."id" = any ($4::uuid[]) + -- AssetRepository.deleteAll delete from "asset" where @@ -585,7 +681,15 @@ select "id", "stackId", "originalPath", - "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", ( select coalesce(json_agg(agg), '[]') @@ -605,13 +709,22 @@ select from "asset" where - "id" = $1::uuid + "id" = $2::uuid limit - $2 + $3 -- AssetRepository.getById select - "asset".* + "asset".*, + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = "asset"."ownerId" + ) as "isFavorite" from "asset" where @@ -619,6 +732,34 @@ where limit $2 +-- AssetRepository.getById (with authUserId) +select + "asset".*, + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = "asset"."ownerId" + ) as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavoriteForUser" +from + "asset" +where + "asset"."id" = $2::uuid +limit + $3 + -- AssetRepository.updateAll update "asset" set @@ -821,8 +962,15 @@ with "asset"."duration", "asset"."id", "asset"."visibility", - asset."isFavorite" - and asset."ownerId" = $2 as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $2::uuid + ) as "isFavorite", asset.type = 'IMAGE' as "isImage", asset."deletedAt" is not null as "isTrashed", "asset"."livePhotoVideoId", @@ -919,8 +1067,15 @@ with "asset"."duration", "asset"."id", "asset"."visibility", - asset."isFavorite" - and asset."ownerId" = $2 as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $2::uuid + ) as "isFavorite", asset.type = 'IMAGE' as "isImage", asset."deletedAt" is not null as "isTrashed", "asset"."livePhotoVideoId", @@ -1003,8 +1158,15 @@ with "asset"."duration", "asset"."id", "asset"."visibility", - asset."isFavorite" - and asset."ownerId" = $2 as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $2::uuid + ) as "isFavorite", asset.type = 'IMAGE' as "isImage", asset."deletedAt" is not null as "isTrashed", "asset"."livePhotoVideoId", diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index cba8e1c7ad3f70..7a74ac06386288 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -299,6 +299,42 @@ where "id" = $1 and "deletedAt" is null +-- MemoryRepository.get (with viewerId) +select + "memory".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset".*, + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavoriteForUser" + from + "asset" + inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId" + where + "memory_asset"."memoriesId" = "memory"."id" + and "asset"."visibility" = 'timeline' + and "asset"."deletedAt" is null + order by + "asset"."localDateTime" asc + ) as agg + ) as "assets" +from + "memory" +where + "id" = $2 + and "deletedAt" is null + -- MemoryRepository.update update "memory" set diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 2eefd59363895f..63426b2ce3c08c 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -23,7 +23,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -37,23 +36,40 @@ select "asset"."thumbhash", "asset"."type", "asset"."width", - "asset"."height" + "asset"."height", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavoriteForUser" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where - "asset"."fileCreatedAt" >= $1 - and "asset_exif"."lensModel" = $2 - and "asset"."ownerId" = any ($3::uuid[]) - and "asset"."isFavorite" = $4 + "asset"."fileCreatedAt" >= $2 + and "asset_exif"."lensModel" = $3 + and "asset"."ownerId" = any ($4::uuid[]) + and exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $5::uuid + ) and "asset"."deletedAt" is null order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $5 -offset $6 +offset + $7 -- SearchRepository.searchMetadata (identity-filter) select @@ -70,7 +86,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -178,7 +193,15 @@ where "asset"."fileCreatedAt" >= $1 and "asset_exif"."lensModel" = $2 and "asset"."ownerId" = any ($3::uuid[]) - and "asset"."isFavorite" = $4 + and exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $4::uuid + ) and "asset"."deletedAt" is null -- SearchRepository.searchRandom @@ -196,7 +219,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -210,20 +232,37 @@ select "asset"."thumbhash", "asset"."type", "asset"."width", - "asset"."height" + "asset"."height", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavoriteForUser" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where - "asset"."fileCreatedAt" >= $1 - and "asset_exif"."lensModel" = $2 - and "asset"."ownerId" = any ($3::uuid[]) - and "asset"."isFavorite" = $4 + "asset"."fileCreatedAt" >= $2 + and "asset_exif"."lensModel" = $3 + and "asset"."ownerId" = any ($4::uuid[]) + and exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $5::uuid + ) and "asset"."deletedAt" is null order by random() limit - $5 + $6 -- SearchRepository.searchLargeAssets select @@ -240,7 +279,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -255,21 +293,38 @@ select "asset"."type", "asset"."width", "asset"."height", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavoriteForUser", to_json("asset_exif") as "exifInfo" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where - "asset"."fileCreatedAt" >= $1 - and "asset_exif"."lensModel" = $2 - and "asset"."ownerId" = any ($3::uuid[]) - and "asset"."isFavorite" = $4 + "asset"."fileCreatedAt" >= $2 + and "asset_exif"."lensModel" = $3 + and "asset"."ownerId" = any ($4::uuid[]) + and exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $5::uuid + ) and "asset"."deletedAt" is null - and "asset_exif"."fileSizeInByte" > $5 + and "asset_exif"."fileSizeInByte" > $6 order by "asset_exif"."fileSizeInByte" desc limit - $6 + $7 -- SearchRepository.searchSmart begin @@ -280,16 +335,25 @@ select from ( select - "asset".* + "asset".*, + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavoriteForUser" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "smart_search" on "asset"."id" = "smart_search"."assetId" where ( - "asset"."ownerId" = any ($1::uuid[]) + "asset"."ownerId" = any ($2::uuid[]) or ( - "asset"."visibility" in ($2, $3) + "asset"."visibility" in ($3, $4) and "asset"."deletedAt" is null and ( exists ( @@ -299,7 +363,7 @@ from "shared_space_asset" where "shared_space_asset"."assetId" = "asset"."id" - and "shared_space_asset"."spaceId" = any ($4::uuid[]) + and "shared_space_asset"."spaceId" = any ($5::uuid[]) ) or exists ( select @@ -308,7 +372,7 @@ from "shared_space_library" where "shared_space_library"."libraryId" = "asset"."libraryId" - and "shared_space_library"."spaceId" = any ($5::uuid[]) + and "shared_space_library"."spaceId" = any ($6::uuid[]) ) or ( exists ( @@ -321,8 +385,8 @@ from and "album"."deletedAt" is null where "album_asset"."assetId" = "asset"."id" - and "shared_space_album"."spaceId" = any ($6::uuid[]) - and "shared_space_album"."showInTimeline" = $7 + and "shared_space_album"."spaceId" = any ($7::uuid[]) + and "shared_space_album"."showInTimeline" = $8 ) or exists ( select @@ -335,8 +399,8 @@ from and "album"."deletedAt" is null where "album_space_asset"."assetId" = "asset"."id" - and "shared_space_album"."spaceId" = any ($8::uuid[]) - and "shared_space_album"."showInTimeline" = $9 + and "shared_space_album"."spaceId" = any ($9::uuid[]) + and "shared_space_album"."showInTimeline" = $10 ) ) ) @@ -351,25 +415,33 @@ from "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true - and "shared_space_person_face"."personId" = $10::uuid + and "shared_space_person_face"."personId" = $11::uuid + ) + and "asset"."fileCreatedAt" >= $12 + and "asset_exif"."lensModel" = $13 + and exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $14::uuid ) - and "asset"."fileCreatedAt" >= $11 - and "asset_exif"."lensModel" = $12 - and "asset"."isFavorite" = $13 and "asset"."deletedAt" is null - and (smart_search.embedding <=> $14) <= $15 + and (smart_search.embedding <=> $15) <= $16 order by - smart_search.embedding <=> $16 + smart_search.embedding <=> $17 limit - $17 + $18 ) as "candidates" order by "candidates"."fileCreatedAt" desc nulls last, "candidates"."id" limit - $18 -offset $19 +offset + $20 commit -- SearchRepository.getSmartSearchFacets @@ -991,47 +1063,6 @@ select "asset"."id" from "asset" -where - "asset"."id" in ( - select - "asset"."id" - from - "asset" - inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id" - inner join ( - select - "assetId" - from - "tag_asset" - inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant" - where - "tag_closure"."id_ancestor" = any ($1::uuid[]) - group by - "assetId" - having - count(distinct "tag_closure"."id_ancestor") >= $2 - ) as "has_tags" on "has_tags"."assetId" = "asset"."id" - where - "asset"."id" in ( - select - "candidates"."id" - from - smart_search_facet_candidates as "candidates" - ) - and "asset"."fileCreatedAt" >= $3 - and "asset"."fileCreatedAt" <= $4 - and "asset"."type" = $5 - and "asset_exif"."country" = $6 - and "asset_exif"."make" = $7 - and "asset_exif"."rating" >= $8 - ) - and "asset"."isFavorite" = $9 -limit - $10 -select - "asset"."id" -from - "asset" where "asset"."id" in ( select @@ -1482,7 +1513,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -1505,6 +1535,196 @@ from order by "asset_exif"."city" +-- SearchRepository.getAssetsByCity (with authUserId) +with recursive + "cte" as ( + ( + select + "city", + "assetId" + from + "asset_exif" + inner join "asset" on "asset"."id" = "asset_exif"."assetId" + where + ( + "asset"."ownerId" = any ($1::uuid[]) + or exists ( + select + 1 as "exists" + from + "shared_space_asset" + where + "shared_space_asset"."assetId" = "asset"."id" + and "shared_space_asset"."spaceId" = any ($2::uuid[]) + ) + or exists ( + select + 1 as "exists" + from + "shared_space_library" + where + "shared_space_library"."libraryId" = "asset"."libraryId" + and "shared_space_library"."spaceId" = any ($3::uuid[]) + ) + or ( + exists ( + select + 1 as "exists" + from + "shared_space_album" + inner join "album_asset" on "album_asset"."albumId" = "shared_space_album"."albumId" + inner join "album" on "album"."id" = "shared_space_album"."albumId" + and "album"."deletedAt" is null + where + "album_asset"."assetId" = "asset"."id" + and "shared_space_album"."spaceId" = any ($4::uuid[]) + and "shared_space_album"."showInTimeline" = $5 + ) + or exists ( + select + 1 as "exists" + from + "shared_space_album" + inner join "album_space_asset" on "album_space_asset"."albumId" = "shared_space_album"."albumId" + and "album_space_asset"."spaceId" = "shared_space_album"."spaceId" + inner join "album" on "album"."id" = "shared_space_album"."albumId" + and "album"."deletedAt" is null + where + "album_space_asset"."assetId" = "asset"."id" + and "shared_space_album"."spaceId" = any ($6::uuid[]) + and "shared_space_album"."showInTimeline" = $7 + ) + ) + ) + and "asset"."visibility" = $8 + and "asset"."type" = $9 + and "asset"."deletedAt" is null + order by + "city" + limit + $10 + ) + union all + ( + select + "l"."city", + "l"."assetId" + from + "cte" + inner join lateral ( + select + "city", + "assetId" + from + "asset_exif" + inner join "asset" on "asset"."id" = "asset_exif"."assetId" + where + ( + "asset"."ownerId" = any ($11::uuid[]) + or exists ( + select + 1 as "exists" + from + "shared_space_asset" + where + "shared_space_asset"."assetId" = "asset"."id" + and "shared_space_asset"."spaceId" = any ($12::uuid[]) + ) + or exists ( + select + 1 as "exists" + from + "shared_space_library" + where + "shared_space_library"."libraryId" = "asset"."libraryId" + and "shared_space_library"."spaceId" = any ($13::uuid[]) + ) + or ( + exists ( + select + 1 as "exists" + from + "shared_space_album" + inner join "album_asset" on "album_asset"."albumId" = "shared_space_album"."albumId" + inner join "album" on "album"."id" = "shared_space_album"."albumId" + and "album"."deletedAt" is null + where + "album_asset"."assetId" = "asset"."id" + and "shared_space_album"."spaceId" = any ($14::uuid[]) + and "shared_space_album"."showInTimeline" = $15 + ) + or exists ( + select + 1 as "exists" + from + "shared_space_album" + inner join "album_space_asset" on "album_space_asset"."albumId" = "shared_space_album"."albumId" + and "album_space_asset"."spaceId" = "shared_space_album"."spaceId" + inner join "album" on "album"."id" = "shared_space_album"."albumId" + and "album"."deletedAt" is null + where + "album_space_asset"."assetId" = "asset"."id" + and "shared_space_album"."spaceId" = any ($16::uuid[]) + and "shared_space_album"."showInTimeline" = $17 + ) + ) + ) + and "asset"."visibility" = $18 + and "asset"."type" = $19 + and "asset"."deletedAt" is null + and "asset_exif"."city" > "cte"."city" + order by + "city" + limit + $20 + ) as "l" on true + ) + ) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height", + to_jsonb("asset_exif") as "exifInfo", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $21::uuid + ) as "isFavoriteForUser" +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" + inner join "cte" on "asset"."id" = "cte"."assetId" +order by + "asset_exif"."city" + -- SearchRepository.getStates select distinct "state" @@ -1620,7 +1840,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -1668,7 +1887,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -1728,7 +1946,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -1776,7 +1993,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -1824,7 +2040,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -1872,7 +2087,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -1920,7 +2134,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -1968,7 +2181,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2023,7 +2235,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2077,7 +2288,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2139,7 +2349,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2193,7 +2402,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2249,7 +2457,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2310,7 +2517,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2364,7 +2570,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2419,7 +2624,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2470,7 +2674,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2518,7 +2721,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2569,7 +2771,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2618,7 +2819,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2666,7 +2866,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2691,7 +2890,15 @@ where or "asset"."ownerId" = $3 ) and ( - "asset"."isFavorite" = $4 + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $4::uuid + ) or exists ( select from @@ -2726,7 +2933,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2754,7 +2960,15 @@ where and "asset"."fileCreatedAt" >= $4 and ( ( - "asset"."isFavorite" = $5 + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $5::uuid + ) and "asset"."ownerId" = any ($6::uuid[]) ) or exists ( @@ -2790,7 +3004,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2814,7 +3027,15 @@ where "asset"."visibility" != $2 or "asset"."ownerId" = $3 ) - and "asset"."isFavorite" = $4 + and exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $4::uuid + ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc @@ -2838,7 +3059,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2883,7 +3103,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2907,7 +3126,15 @@ where "asset"."visibility" != $2 or "asset"."ownerId" = $3 ) - and "asset"."isFavorite" = $4 + and exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $4::uuid + ) order by random() limit @@ -2931,7 +3158,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -2984,7 +3210,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -3040,7 +3265,6 @@ select "asset"."fileCreatedAt", "asset"."fileModifiedAt", "asset"."isExternal", - "asset"."isFavorite", "asset"."isOffline", "asset"."isEdited", "asset"."visibility", @@ -3120,7 +3344,15 @@ where or "asset"."ownerId" = $3 ) and ( - "asset"."isFavorite" = $4 + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $4::uuid + ) or not exists ( select from @@ -3855,9 +4087,15 @@ where and "face_identity_face"."identityId" = $10::uuid ) ) - and "asset"."isFavorite" = $11 + and exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + ) limit - $12 + $11 select "asset"."id" from @@ -3938,7 +4176,7 @@ where and "face_identity_face"."identityId" = $10::uuid ) ) - and exists ( + and not exists ( select from "album_asset" @@ -4027,12 +4265,14 @@ where and "face_identity_face"."identityId" = $10::uuid ) ) - and not exists ( + and exists ( select + 1 as "exists" from - "album_asset" + "asset_favorite" where - "album_asset"."assetId" = "asset"."id" + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $11::uuid ) limit - $11 + $12 diff --git a/server/src/queries/shared.space.repository.sql b/server/src/queries/shared.space.repository.sql index 622aa095531457..db3af7989c6b51 100644 --- a/server/src/queries/shared.space.repository.sql +++ b/server/src/queries/shared.space.repository.sql @@ -162,6 +162,14 @@ where "userId" = $1 and "showInTimeline" = $2 +-- SharedSpaceRepository.getAllMemberSpaceIds +select + "spaceId" +from + "shared_space_member" +where + "userId" = $1 + -- SharedSpaceRepository.getHiddenAlbumIdsForUser select "albumId" diff --git a/server/src/queries/stack.repository.sql b/server/src/queries/stack.repository.sql index 88e21d653e4e3f..67fb32f884d731 100644 --- a/server/src/queries/stack.repository.sql +++ b/server/src/queries/stack.repository.sql @@ -63,6 +63,78 @@ from where "stack"."ownerId" = $1 +-- StackRepository.search (with authUserId) +select + "stack".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset".*, + to_json("exifInfo") as "exifInfo", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavoriteForUser" + from + "asset" + inner join lateral ( + select + "asset_exif"."assetId", + "asset_exif"."autoStackId", + "asset_exif"."bitsPerSample", + "asset_exif"."city", + "asset_exif"."colorspace", + "asset_exif"."country", + "asset_exif"."dateTimeOriginal", + "asset_exif"."description", + "asset_exif"."exifImageHeight", + "asset_exif"."exifImageWidth", + "asset_exif"."exposureTime", + "asset_exif"."fileSizeInByte", + "asset_exif"."fNumber", + "asset_exif"."focalLength", + "asset_exif"."fps", + "asset_exif"."iso", + "asset_exif"."latitude", + "asset_exif"."lensModel", + "asset_exif"."livePhotoCID", + "asset_exif"."longitude", + "asset_exif"."make", + "asset_exif"."model", + "asset_exif"."modifyDate", + "asset_exif"."orientation", + "asset_exif"."profileDescription", + "asset_exif"."projectionType", + "asset_exif"."rating", + "asset_exif"."state", + "asset_exif"."tags", + "asset_exif"."timeZone" + from + "asset_exif" + where + "asset_exif"."assetId" = "asset"."id" + ) as "exifInfo" on true + where + "asset"."deletedAt" is null + and "asset"."stackId" = "stack"."id" + and "asset"."visibility" in ('archive', 'timeline') + order by + "asset"."fileCreatedAt" asc + ) as agg + ) as "assets" +from + "stack" +where + "stack"."ownerId" = $2 + -- StackRepository.delete delete from "stack" where @@ -150,6 +222,97 @@ from where "id" = $1::uuid +-- StackRepository.getById (with authUserId) +select + *, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "tag"."id", + "tag"."value", + "tag"."createdAt", + "tag"."updatedAt", + "tag"."color", + "tag"."parentId" + from + "tag" + inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId" + where + "tag_asset"."assetId" = "asset"."id" + ) as agg + ) as "tags", + to_json("exifInfo") as "exifInfo", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavoriteForUser" + from + "asset" + inner join lateral ( + select + "asset_exif"."assetId", + "asset_exif"."autoStackId", + "asset_exif"."bitsPerSample", + "asset_exif"."city", + "asset_exif"."colorspace", + "asset_exif"."country", + "asset_exif"."dateTimeOriginal", + "asset_exif"."description", + "asset_exif"."exifImageHeight", + "asset_exif"."exifImageWidth", + "asset_exif"."exposureTime", + "asset_exif"."fileSizeInByte", + "asset_exif"."fNumber", + "asset_exif"."focalLength", + "asset_exif"."fps", + "asset_exif"."iso", + "asset_exif"."latitude", + "asset_exif"."lensModel", + "asset_exif"."livePhotoCID", + "asset_exif"."longitude", + "asset_exif"."make", + "asset_exif"."model", + "asset_exif"."modifyDate", + "asset_exif"."orientation", + "asset_exif"."profileDescription", + "asset_exif"."projectionType", + "asset_exif"."rating", + "asset_exif"."state", + "asset_exif"."tags", + "asset_exif"."timeZone" + from + "asset_exif" + where + "asset_exif"."assetId" = "asset"."id" + ) as "exifInfo" on true + where + "asset"."deletedAt" is null + and "asset"."stackId" = "stack"."id" + and "asset"."visibility" in ('archive', 'timeline') + order by + "asset"."fileCreatedAt" asc + ) as agg + ) as "assets" +from + "stack" +where + "id" = $2::uuid + -- StackRepository.getForAssetRemoval select "stackId" as "id", diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 67a55eff9ec933..16f43018b425cf 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -77,19 +77,24 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $1 then "asset"."isFavorite" - else $2 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "album_asset"."updateId" from "album_asset" as "album_asset" inner join "asset" on "asset"."id" = "album_asset"."assetId" where - "album_asset"."updateId" < $3 - and "album_asset"."updateId" <= $4 - and "album_asset"."updateId" > $5 - and "album_asset"."albumId" = $6 + "album_asset"."updateId" < $2 + and "album_asset"."updateId" <= $3 + and "album_asset"."updateId" > $4 + and "album_asset"."albumId" = $5 order by "album_asset"."updateId" asc @@ -114,20 +119,25 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $1 then "asset"."isFavorite" - else $2 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "asset"."updateId" from "asset" as "asset" inner join "album_asset" on "album_asset"."assetId" = "asset"."id" inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" where - "asset"."updateId" < $3 - and "asset"."updateId" > $4 - and "album_asset"."updateId" <= $5 - and "album_user"."userId" = $6 + "asset"."updateId" < $2 + and "asset"."updateId" > $3 + and "album_asset"."updateId" <= $4 + and "album_user"."userId" = $5 order by "asset"."updateId" asc @@ -153,18 +163,23 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $1 then "asset"."isFavorite" - else $2 - end as "isFavorite" + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite" from "album_asset" as "album_asset" inner join "asset" on "asset"."id" = "album_asset"."assetId" inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" where - "album_asset"."updateId" < $3 - and "album_asset"."updateId" > $4 - and "album_user"."userId" = $5 + "album_asset"."updateId" < $2 + and "album_asset"."updateId" > $3 + and "album_user"."userId" = $4 order by "album_asset"."updateId" asc @@ -423,7 +438,6 @@ select "asset"."localDateTime", "asset"."type", "asset"."deletedAt", - "asset"."isFavorite", "asset"."visibility", "asset"."duration", "asset"."livePhotoVideoId", @@ -432,13 +446,22 @@ select "asset"."width", "asset"."height", "asset"."isEdited", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "asset"."updateId" from "asset" as "asset" where - "asset"."updateId" < $1 - and "asset"."updateId" > $2 - and "ownerId" = $3 + "asset"."updateId" < $2 + and "asset"."updateId" > $3 + and "ownerId" = $4 order by "asset"."updateId" asc @@ -630,6 +653,32 @@ where order by "asset_ocr"."updateId" asc +-- SyncRepository.assetFavorite.getDeletes +select + "asset_favorite_audit"."id", + "asset_favorite_audit"."assetId" +from + "asset_favorite_audit" as "asset_favorite_audit" +where + "asset_favorite_audit"."id" < $1 + and "asset_favorite_audit"."id" > $2 + and "asset_favorite_audit"."userId" = $3 +order by + "asset_favorite_audit"."id" asc + +-- SyncRepository.assetFavorite.getUpserts +select + "asset_favorite"."assetId", + "asset_favorite"."updateId" +from + "asset_favorite" as "asset_favorite" +where + "asset_favorite"."updateId" < $1 + and "asset_favorite"."updateId" > $2 + and "asset_favorite"."userId" = $3 +order by + "asset_favorite"."updateId" asc + -- SyncRepository.authUser.getUpserts select "id", @@ -803,7 +852,15 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - $1 as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "asset"."updateId" from "asset" as "asset" @@ -856,7 +913,15 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - $1 as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "asset"."updateId" from "asset" as "asset" @@ -1312,20 +1377,25 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $1 then "asset"."isFavorite" - else $2 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "shared_space_asset"."updateId" from "shared_space_asset" as "shared_space_asset" inner join "asset" on "asset"."id" = "shared_space_asset"."assetId" where - "shared_space_asset"."updateId" < $3 - and "shared_space_asset"."updateId" <= $4 - and "shared_space_asset"."updateId" > $5 - and "shared_space_asset"."spaceId" = $6 - and "asset"."visibility" in ($7, $8) + "shared_space_asset"."updateId" < $2 + and "shared_space_asset"."updateId" <= $3 + and "shared_space_asset"."updateId" > $4 + and "shared_space_asset"."spaceId" = $5 + and "asset"."visibility" in ($6, $7) and "asset"."deletedAt" is null order by "shared_space_asset"."updateId" asc @@ -1351,33 +1421,38 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $1 then "asset"."isFavorite" - else $2 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "shared_space_asset"."updateId" from "shared_space_asset" as "shared_space_asset" inner join "asset" on "asset"."id" = "shared_space_asset"."assetId" where - "shared_space_asset"."updateId" < $3 - and "shared_space_asset"."updateId" > $4 + "shared_space_asset"."updateId" < $2 + and "shared_space_asset"."updateId" > $3 and "shared_space_asset"."spaceId" in ( select "shared_space"."id" from "shared_space" where - "shared_space"."createdById" = $5 + "shared_space"."createdById" = $4 union select "shared_space_member"."spaceId" as "id" from "shared_space_member" where - "shared_space_member"."userId" = $6 + "shared_space_member"."userId" = $5 ) - and "asset"."visibility" in ($7, $8) + and "asset"."visibility" in ($6, $7) and "asset"."deletedAt" is null order by "shared_space_asset"."updateId" asc @@ -1403,34 +1478,39 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $1 then "asset"."isFavorite" - else $2 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "asset"."updateId" from "asset" as "asset" inner join "shared_space_asset" on "shared_space_asset"."assetId" = "asset"."id" where - "asset"."updateId" < $3 - and "asset"."updateId" > $4 - and "shared_space_asset"."updateId" <= $5 + "asset"."updateId" < $2 + and "asset"."updateId" > $3 + and "shared_space_asset"."updateId" <= $4 and "shared_space_asset"."spaceId" in ( select "shared_space"."id" from "shared_space" where - "shared_space"."createdById" = $6 + "shared_space"."createdById" = $5 union select "shared_space_member"."spaceId" as "id" from "shared_space_member" where - "shared_space_member"."userId" = $7 + "shared_space_member"."userId" = $6 ) - and "asset"."visibility" in ($8, $9) + and "asset"."visibility" in ($7, $8) order by "asset"."updateId" asc @@ -1785,22 +1865,27 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $1 then "asset"."isFavorite" - else $2 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "asset"."updateId" from "asset" as "asset" where - "asset"."updateId" < $3 - and "asset"."updateId" <= $4 - and "asset"."updateId" > $5 - and "asset"."libraryId" = $6 + "asset"."updateId" < $2 + and "asset"."updateId" <= $3 + and "asset"."updateId" > $4 + and "asset"."libraryId" = $5 and ( - "asset"."ownerId" = $7 + "asset"."ownerId" = $6 or ( - "asset"."visibility" in ($8, $9) + "asset"."visibility" in ($7, $8) and "asset"."deletedAt" is null ) ) @@ -1828,16 +1913,21 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $1 then "asset"."isFavorite" - else $2 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "asset"."updateId" from "asset" as "asset" where - "asset"."updateId" < $3 - and "asset"."updateId" > $4 + "asset"."updateId" < $2 + and "asset"."updateId" > $3 and "asset"."libraryId" is not null and "asset"."libraryId" in ( select @@ -1845,7 +1935,7 @@ where from "library" where - "library"."ownerId" = $5 + "library"."ownerId" = $4 and "library"."deletedAt" is null union select @@ -1859,19 +1949,19 @@ where from "shared_space" where - "shared_space"."createdById" = $6 + "shared_space"."createdById" = $5 union select "shared_space_member"."spaceId" as "id" from "shared_space_member" where - "shared_space_member"."userId" = $7 + "shared_space_member"."userId" = $6 ) ) and ( - "asset"."ownerId" = $8 - or "asset"."visibility" in ($9, $10) + "asset"."ownerId" = $7 + or "asset"."visibility" in ($8, $9) ) order by "asset"."updateId" asc @@ -2733,22 +2823,27 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $1 then "asset"."isFavorite" - else $2 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "album_asset"."updateId" from "album_asset" inner join "asset" on "asset"."id" = "album_asset"."assetId" inner join "album" on "album"."id" = "album_asset"."albumId" where - "album_asset"."albumId" = $3 + "album_asset"."albumId" = $2 and "album"."deletedAt" is null - and "album_asset"."updateId" < $4 - and "album_asset"."updateId" <= $5 - and "album_asset"."updateId" > $6 - and "asset"."visibility" in ($7, $8) + and "album_asset"."updateId" < $3 + and "album_asset"."updateId" <= $4 + and "album_asset"."updateId" > $5 + and "asset"."visibility" in ($6, $7) and "asset"."deletedAt" is null union select @@ -2771,29 +2866,34 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $9 then "asset"."isFavorite" - else $10 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $8::uuid + ) as "isFavorite", "album_space_asset"."updateId" from "album_space_asset" inner join "asset" on "asset"."id" = "album_space_asset"."assetId" inner join "album" on "album"."id" = "album_space_asset"."albumId" where - "album_space_asset"."albumId" = $11 + "album_space_asset"."albumId" = $9 and "album"."deletedAt" is null - and "album_space_asset"."updateId" < $12 - and "album_space_asset"."updateId" <= $13 - and "album_space_asset"."updateId" > $14 - and "asset"."visibility" in ($15, $16) + and "album_space_asset"."updateId" < $10 + and "album_space_asset"."updateId" <= $11 + and "album_space_asset"."updateId" > $12 + and "asset"."visibility" in ($13, $14) and exists ( select 1 as "one" from "shared_space_album" inner join "shared_space_member" on "shared_space_member"."spaceId" = "shared_space_album"."spaceId" - and "shared_space_member"."userId" = $17::uuid + and "shared_space_member"."userId" = $15::uuid where "shared_space_album"."albumId" = "album_space_asset"."albumId" and "shared_space_album"."spaceId" = "album_space_asset"."spaceId" @@ -2823,20 +2923,25 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $1 then "asset"."isFavorite" - else $2 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "asset"."updateId" from "asset" inner join "album_asset" on "album_asset"."assetId" = "asset"."id" inner join "shared_space_album_user" on "shared_space_album_user"."albumId" = "album_asset"."albumId" where - "asset"."updateId" < $3 - and "asset"."updateId" > $4 - and "album_asset"."updateId" <= $5 - and "shared_space_album_user"."userId" = $6 + "asset"."updateId" < $2 + and "asset"."updateId" > $3 + and "album_asset"."updateId" <= $4 + and "shared_space_album_user"."userId" = $5 and "album_asset"."albumId" in ( select "shared_space_album"."albumId" as "id" @@ -2851,17 +2956,17 @@ where from "shared_space" where - "shared_space"."createdById" = $7 + "shared_space"."createdById" = $6 union select "shared_space_member"."spaceId" as "id" from "shared_space_member" where - "shared_space_member"."userId" = $8 + "shared_space_member"."userId" = $7 ) ) - and "asset"."visibility" in ($9, $10) + and "asset"."visibility" in ($8, $9) union select "asset"."id", @@ -2883,20 +2988,25 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $11 then "asset"."isFavorite" - else $12 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $10::uuid + ) as "isFavorite", "asset"."updateId" from "asset" inner join "album_space_asset" on "album_space_asset"."assetId" = "asset"."id" inner join "shared_space_album_user" on "shared_space_album_user"."albumId" = "album_space_asset"."albumId" where - "asset"."updateId" < $13 - and "asset"."updateId" > $14 - and "album_space_asset"."updateId" <= $15 - and "shared_space_album_user"."userId" = $16 + "asset"."updateId" < $11 + and "asset"."updateId" > $12 + and "album_space_asset"."updateId" <= $13 + and "shared_space_album_user"."userId" = $14 and "album_space_asset"."albumId" in ( select "shared_space_album"."albumId" as "id" @@ -2911,24 +3021,24 @@ where from "shared_space" where - "shared_space"."createdById" = $17 + "shared_space"."createdById" = $15 union select "shared_space_member"."spaceId" as "id" from "shared_space_member" where - "shared_space_member"."userId" = $18 + "shared_space_member"."userId" = $16 ) ) - and "asset"."visibility" in ($19, $20) + and "asset"."visibility" in ($17, $18) and exists ( select 1 as "one" from "shared_space_album" inner join "shared_space_member" on "shared_space_member"."spaceId" = "shared_space_album"."spaceId" - and "shared_space_member"."userId" = $21::uuid + and "shared_space_member"."userId" = $19::uuid where "shared_space_album"."albumId" = "album_space_asset"."albumId" and "shared_space_album"."spaceId" = "album_space_asset"."spaceId" @@ -2957,17 +3067,22 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $1 then "asset"."isFavorite" - else $2 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavorite", "album_asset"."updateId" from "album_asset" inner join "asset" on "asset"."id" = "album_asset"."assetId" inner join "shared_space_album_user" on "shared_space_album_user"."albumId" = "album_asset"."albumId" where - "shared_space_album_user"."userId" = $3 + "shared_space_album_user"."userId" = $2 and "album_asset"."albumId" in ( select "shared_space_album"."albumId" as "id" @@ -2982,19 +3097,19 @@ where from "shared_space" where - "shared_space"."createdById" = $4 + "shared_space"."createdById" = $3 union select "shared_space_member"."spaceId" as "id" from "shared_space_member" where - "shared_space_member"."userId" = $5 + "shared_space_member"."userId" = $4 ) ) - and "album_asset"."updateId" < $6 - and "album_asset"."updateId" > $7 - and "asset"."visibility" in ($8, $9) + and "album_asset"."updateId" < $5 + and "album_asset"."updateId" > $6 + and "asset"."visibility" in ($7, $8) and "asset"."deletedAt" is null union select @@ -3017,17 +3132,22 @@ select "asset"."width", "asset"."height", "asset"."isEdited", - case - when "asset"."ownerId" = $10 then "asset"."isFavorite" - else $11 - end as "isFavorite", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $9::uuid + ) as "isFavorite", "album_space_asset"."updateId" from "album_space_asset" inner join "asset" on "asset"."id" = "album_space_asset"."assetId" inner join "shared_space_album_user" on "shared_space_album_user"."albumId" = "album_space_asset"."albumId" where - "shared_space_album_user"."userId" = $12 + "shared_space_album_user"."userId" = $10 and "album_space_asset"."albumId" in ( select "shared_space_album"."albumId" as "id" @@ -3042,26 +3162,26 @@ where from "shared_space" where - "shared_space"."createdById" = $13 + "shared_space"."createdById" = $11 union select "shared_space_member"."spaceId" as "id" from "shared_space_member" where - "shared_space_member"."userId" = $14 + "shared_space_member"."userId" = $12 ) ) - and "album_space_asset"."updateId" < $15 - and "album_space_asset"."updateId" > $16 - and "asset"."visibility" in ($17, $18) + and "album_space_asset"."updateId" < $13 + and "album_space_asset"."updateId" > $14 + and "asset"."visibility" in ($15, $16) and exists ( select 1 as "one" from "shared_space_album" inner join "shared_space_member" on "shared_space_member"."spaceId" = "shared_space_album"."spaceId" - and "shared_space_member"."userId" = $19::uuid + and "shared_space_member"."userId" = $17::uuid where "shared_space_album"."albumId" = "album_space_asset"."albumId" and "shared_space_album"."spaceId" = "album_space_asset"."spaceId" diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql index 39954d2e886b21..d77f9efee0fbfd 100644 --- a/server/src/queries/view.repository.sql +++ b/server/src/queries/view.repository.sql @@ -89,13 +89,22 @@ order by -- ViewRepository.getAssetsByOriginalPath select "asset".*, - to_json("asset_exif") as "exifInfo" + to_json("asset_exif") as "exifInfo", + exists ( + select + 1 as "exists" + from + "asset_favorite" + where + "asset_favorite"."assetId" = "asset"."id" + and "asset_favorite"."userId" = $1::uuid + ) as "isFavoriteForUser" from "asset" left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where ( - "asset"."ownerId" = $1::uuid + "asset"."ownerId" = $2::uuid or exists ( select 1 as "exists" @@ -103,7 +112,7 @@ where "shared_space_asset" inner join "shared_space_member" on "shared_space_member"."spaceId" = "shared_space_asset"."spaceId" where - "shared_space_member"."userId" = $2::uuid + "shared_space_member"."userId" = $3::uuid and "shared_space_asset"."assetId" = "asset"."id" ) or exists ( @@ -113,7 +122,7 @@ where "shared_space_library" inner join "shared_space_member" on "shared_space_member"."spaceId" = "shared_space_library"."spaceId" where - "shared_space_member"."userId" = $3::uuid + "shared_space_member"."userId" = $4::uuid and "shared_space_library"."libraryId" = "asset"."libraryId" ) or ( @@ -127,7 +136,7 @@ where and "album"."deletedAt" is null inner join "shared_space_member" on "shared_space_member"."spaceId" = "shared_space_album"."spaceId" where - "shared_space_member"."userId" = $4::uuid + "shared_space_member"."userId" = $5::uuid and "album_asset"."assetId" = "asset"."id" and not exists ( select @@ -137,7 +146,7 @@ where where "shared_space_album_hidden"."spaceId" = "shared_space_album"."spaceId" and "shared_space_album_hidden"."albumId" = "shared_space_album"."albumId" - and "shared_space_album_hidden"."userId" = $5::uuid + and "shared_space_album_hidden"."userId" = $6::uuid ) ) or exists ( @@ -151,7 +160,7 @@ where and "album"."deletedAt" is null inner join "shared_space_member" on "shared_space_member"."spaceId" = "shared_space_album"."spaceId" where - "shared_space_member"."userId" = $6::uuid + "shared_space_member"."userId" = $7::uuid and "album_space_asset"."assetId" = "asset"."id" and not exists ( select @@ -161,17 +170,17 @@ where where "shared_space_album_hidden"."spaceId" = "shared_space_album"."spaceId" and "shared_space_album_hidden"."albumId" = "shared_space_album"."albumId" - and "shared_space_album_hidden"."userId" = $7::uuid + and "shared_space_album_hidden"."userId" = $8::uuid ) ) ) ) - and "visibility" = $8 + and "visibility" = $9 and "deletedAt" is null and "fileCreatedAt" is not null and "fileModifiedAt" is not null and "localDateTime" is not null - and "originalPath" like $9 - and "originalPath" not like $10 + and "originalPath" like $10 + and "originalPath" not like $11 order by - regexp_replace("asset"."originalPath", $11, $12) asc + regexp_replace("asset"."originalPath", $12, $13) asc diff --git a/server/src/repositories/asset-favorite.repository.ts b/server/src/repositories/asset-favorite.repository.ts new file mode 100644 index 00000000000000..ee509abc72a5be --- /dev/null +++ b/server/src/repositories/asset-favorite.repository.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely, sql } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { Chunked, DummyValue, GenerateSql } from 'src/decorators.js'; +import { DB } from 'src/schema/index.js'; +import { asUuid } from 'src/utils/database.js'; + +// Per-user favorites overlay (#763). A favorite is a fact about (user, asset) — see +// docs/superpowers/specs/2026-07-20-per-user-favorites-design.md §3. `addAll`/`removeAll` +// are the only write surface onto `asset_favorite`; the subject is always the caller-supplied +// `userId`, never resolved here — callers (the service layer) are responsible for pinning it +// to the requesting user. +@Injectable() +export class AssetFavoriteRepository { + constructor(@InjectKysely() private db: Kysely) {} + + // (E8) Re-favoriting an already-favorited asset must be a no-op, not a constraint violation. + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @Chunked({ paramIndex: 1 }) + async addAll(userId: string, assetIds: string[]): Promise { + if (assetIds.length === 0) { + return; + } + + await this.db.transaction().execute(async (tx) => { + const changed = await tx + .insertInto('asset_favorite') + .values(assetIds.map((assetId) => ({ userId, assetId }))) + .onConflict((oc) => oc.doNothing()) + .returning('assetId') + .execute(); + await this.touchOwnedAssets(tx, userId, changed); + }); + } + + // (E9) Unfavoriting a never-favorited asset must be a no-op, not an error. + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @Chunked({ paramIndex: 1 }) + async removeAll(userId: string, assetIds: string[]): Promise { + if (assetIds.length === 0) { + return; + } + + await this.db.transaction().execute(async (tx) => { + const changed = await tx + .deleteFrom('asset_favorite') + .where('userId', '=', userId) + .where('assetId', 'in', assetIds) + .returning('assetId') + .execute(); + await this.touchOwnedAssets(tx, userId, changed); + }); + } + + // Legacy-client compatibility: an app that predates AssetFavoritesV1 only learns a favorite + // change through the asset streams, i.e. through asset.updateId. Before #763 an owner's favorite + // was a column write that bumped it, so bump it again — but ONLY for assets the caller owns and + // whose favorite actually changed. That is exactly the pre-#763 re-sync footprint; a non-owner's + // favorite never touches the asset, so it still cannot amplify into other users' re-syncs. + private async touchOwnedAssets(tx: Kysely, userId: string, changed: { assetId: string }[]) { + if (changed.length === 0) { + return; + } + + await tx + .updateTable('asset') + .set({ updatedAt: sql`clock_timestamp()` }) + .where('ownerId', '=', userId) + .where( + 'id', + 'in', + changed.map(({ assetId }) => assetId), + ) + .execute(); + } + + // (E21) Duplicate-merge: a per-user UNION of every source asset's favorite rows onto the + // keeper, run BEFORE the sources are deleted (their own rows CASCADE away with them — see + // duplicate.service.ts resolveGroup). A single INSERT ... SELECT DISTINCT avoids a + // read-then-write round trip; `onConflict do nothing` handles both re-runs and a user who + // favorited more than one source asset (E8: exactly one keeper row, not a PK violation). + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @Chunked({ paramIndex: 1 }) + async mergeOnto(keeperId: string, sourceAssetIds: string[]): Promise { + if (sourceAssetIds.length === 0) { + return; + } + + await this.db + .insertInto('asset_favorite') + .expression((eb) => + eb + .selectFrom('asset_favorite') + // Explicit ::uuid cast (unlike copyAlbums' plain eb.val): DISTINCT forces Postgres to + // resolve the literal's type for the dedup comparison before the INSERT's implicit + // assignment cast would otherwise apply, and a bare text literal can't be compared + // against/assigned into a uuid column without it ("column is of type uuid but + // expression is of type text"). + .select(['asset_favorite.userId', asUuid(keeperId).as('assetId')]) + .distinct() + .where('asset_favorite.assetId', 'in', sourceAssetIds), + ) + .onConflict((oc) => oc.doNothing()) + .execute(); + } +} diff --git a/server/src/repositories/asset.repository.spec.ts b/server/src/repositories/asset.repository.spec.ts index d04854c43c4151..b3fefb34c07035 100644 --- a/server/src/repositories/asset.repository.spec.ts +++ b/server/src/repositories/asset.repository.spec.ts @@ -102,3 +102,38 @@ describe('withTimeBucketAssetFilters text filters escape LIKE wildcards', () => expect(parameters).toContain(String.raw`100\%\_done`); }); }); + +// #763 E10 — the SQL half of the access-revocation invariant. `asset_favorite` rows survive +// access loss by design (spec §5.2), so a favorite-filtered timeline is only safe because the +// favorite predicate is ANDed with an RBAC scope that is recomputed from live membership each +// request: the timelineSpaceIds arm when the caller has member-spaces, a bare +// `asset.ownerId = ...` when they no longer do. Behaviour is pinned end-to-end in +// test/medium/specs/services/favorite-access-revocation.medium.spec.ts; these compile-only +// assertions catch the same regression in the unit job, without a database. +describe('withTimeBucketAssetFilters favorite scoping (#763 E10)', () => { + const CALLER = '00000000-0000-0000-0000-000000000000'; + const SPACE = '11111111-1111-1111-1111-111111111111'; + + it('scopes a favorite-filtered timeline to owned assets when the caller has no member-spaces', () => { + const sql = compileTimeBucketFilters({ isFavorite: true, userIds: [CALLER], authUserId: CALLER }); + + // The favorite predicate is present... + expect(sql).toContain('"asset_favorite"'); + // ...but never alone: without timelineSpaceIds the only reachable rows are the caller's own, + // so a lingering overlay row on someone else's asset cannot surface it. + expect(sql).toContain('"asset"."ownerId" = any'); + expect(sql).not.toContain('"shared_space_asset"'); + }); + + it('widens to the space arm only while member-spaces are supplied', () => { + const sql = compileTimeBucketFilters({ + isFavorite: true, + userIds: [CALLER], + authUserId: CALLER, + timelineSpaceIds: [SPACE], + }); + + expect(sql).toContain('"asset_favorite"'); + expect(sql).toContain('"shared_space_asset"'); + }); +}); diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 03461224e3275d..02730560b89da6 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -63,6 +63,7 @@ import { withSmartSearch, withTags, } from 'src/utils/database.js'; +import { favoriteExistsFor, favoriteExistsForOwner } from 'src/utils/favorite.js'; import { globToPostgresRegex } from 'src/utils/misc.js'; import { type TimelineHiddenScope, @@ -181,6 +182,12 @@ export interface TimeBucketOptions extends AssetBuilderOptions { * never widens the result set, so it is resolved even when `withSharedSpaces` is off. */ visibleSpaceIds?: string[]; + /** + * #763: the caller making the request — NOT `userIds` (the timeline *target*, which differs + * from the caller on space/album browse paths). Required whenever `isFavorite` is set, so + * `withTimeBucketAssetFilters` can resolve the `asset_favorite` overlay for the right user. + */ + authUserId?: string; } export interface TimeBucketItem { @@ -552,7 +559,13 @@ export function withTimeBucketAssetFilters( .leftJoin('stack', (join) => join.onRef('stack.id', '=', 'asset.stackId').onRef('stack.primaryAssetId', '=', 'asset.id'), ) - .where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])), + // #763 §5.4 (E31): favorites are per (user, asset). Under an isFavorite:true filter every + // row is already one of the caller's favorites, and a favorited stack CHILD must surface + // standalone instead of staying collapsed behind a primary the caller may not have + // favorited — so the primary-only collapse is skipped for favorite-filtered timelines. + .$if(options.isFavorite !== true, (qb) => + qb.where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])), + ), ) // #1041 §4: the `!timelineSpaceIds` branch — userIds set, shared spaces off. E12 requires the // subtraction still applies here even with `withSharedSpaces: false`; easy to miss because this @@ -612,7 +625,13 @@ export function withTimeBucketAssetFilters( ]), ), ) - .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) + .$if(options.isFavorite !== undefined, (qb) => + qb.where((eb) => + options.isFavorite + ? favoriteExistsFor(eb, options.authUserId!) + : eb.not(favoriteExistsFor(eb, options.authUserId!)), + ), + ) .$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!)) .$if(options.isDuplicate !== undefined, (qb) => qb.where('asset.duplicateId', options.isDuplicate ? 'is not' : 'is', null), @@ -1107,18 +1126,25 @@ export class AssetRepository { ownerId: string, { months, day, favoritesOnly, type, takenBefore }: MemoryPeriodOptions, ): Promise { + // #763: favorites are a per-user overlay row, not a column on the asset. Memory generation is + // owner-scoped (`asset.ownerId = ownerId` below), so the owner's own overlay row is exactly what + // the dropped `asset.isFavorite` column used to carry here — both for the projection and for the + // `favoritesOnly` filter. `$narrowType` pins the EXISTS result to `boolean`: Kysely types it + // `SqlBool` (`boolean | number`), which does not satisfy `MemoryPeriodAsset.isFavorite`, but the + // pg driver only ever returns a boolean for it. return this.db .selectFrom('asset') .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') .select([ 'asset.id', 'asset.localDateTime', - 'asset.isFavorite', 'asset.type', 'asset.duration', 'asset_exif.country as country', 'asset_exif.city as city', ]) + .select((eb) => favoriteExistsFor(eb, ownerId).as('isFavorite')) + .$narrowType<{ isFavorite: boolean }>() .select(sql`extract(year from (asset."localDateTime" at time zone 'UTC'))::int`.as('year')) .where('asset.ownerId', '=', ownerId) .where('asset.visibility', '=', AssetVisibility.Timeline) @@ -1128,7 +1154,7 @@ export class AssetRepository { .$if(day !== undefined, (qb) => qb.where(sql`extract(day from (asset."localDateTime" at time zone 'UTC'))::int`, '=', day!), ) - .$if(favoritesOnly === true, (qb) => qb.where('asset.isFavorite', '=', true)) + .$if(favoritesOnly === true, (qb) => qb.where((eb) => favoriteExistsFor(eb, ownerId))) .$if(type !== undefined, (qb) => qb.where('asset.type', '=', type!)) .where((eb) => eb.exists( @@ -1297,23 +1323,44 @@ export class AssetRepository { .execute(); } + // #763: no isFavoriteForUser projection here — the only caller (asset.service.ts updateAll, via + // priorAssets) reads `visibility` only, to snapshot the pre-write value for the #757 transition + // helper. The rows never reach mapAsset, so there is nothing to project. @GenerateSql({ params: [[DummyValue.UUID]] }) @ChunkedArray() getByIds(ids: string[]) { return this.db.selectFrom('asset').selectAll('asset').where('asset.id', '=', anyUuid(ids)).execute(); } - @GenerateSql({ params: [[DummyValue.UUID]] }) + @GenerateSql( + { params: [[DummyValue.UUID]] }, + { name: 'with authUserId', params: [[DummyValue.UUID], DummyValue.UUID, DummyValue.UUID] }, + ) @ChunkedArray({ paramIndex: 0 }) - getByIdsWithAllRelationsButStacks(ids: string[], viewingUserId?: string) { - return this.db - .selectFrom('asset') - .selectAll('asset') - .select(withFacesAndPeople({ viewingUserId })) - .select(withTags) - .$call(withExif) - .where('asset.id', '=', anyUuid(ids)) - .execute(); + getByIdsWithAllRelationsButStacks(ids: string[], viewingUserId?: string, authUserId?: string) { + return ( + this.db + .selectFrom('asset') + .selectAll('asset') + .select(withFacesAndPeople({ viewingUserId })) + .select(withTags) + .$call(withExif) + // #763: `isFavorite` is resolved from the OWNER's overlay row (favoriteExistsForOwner) — the + // raw asset."isFavorite" column was dropped outright in slice 3, so selectAll('asset') no + // longer surfaces one at all; see the matching comment on getById below for the full + // rationale (same owner-semantics decision; job.service.ts's + // AssetEditReadyV2/AssetUploadReadyV2 payloads are this method's only direct `.isFavorite` + // reader, and always send to the asset's OWNER). + .select((eb) => favoriteExistsForOwner(eb).as('isFavorite')) + // #763: authUserId is optional and deliberately omitted by job.service.ts (background job) + // and notification.service.ts (synthetic partial auth) — see the comments at their mapAsset + // call sites. Only search.service.ts's getExploreData passes it (real caller identity). + // It is a SEPARATE parameter from viewingUserId (face/people ordering) on purpose: the two + // are the same value for a real caller, but notification.service must set only the former. + .$if(!!authUserId, (qb) => qb.select((eb) => favoriteExistsFor(eb, authUserId!).as('isFavoriteForUser'))) + .where('asset.id', '=', anyUuid(ids)) + .execute() + ); } @GenerateSql({ params: [DummyValue.UUID] }) @@ -1400,69 +1447,93 @@ export class AssetRepository { return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getForCopy(id: string) { + // #763 slice 7: `authUserId` is the ACTING user (not necessarily the asset's owner) — the + // caller decides whose favorite carries over to the copy. See asset.service.ts `copy`. + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + getForCopy(id: string, authUserId: string) { return this.db .selectFrom('asset') - .select(['id', 'stackId', 'originalPath', 'isFavorite']) + .select(['id', 'stackId', 'originalPath']) + .select((eb) => favoriteExistsFor(eb, authUserId).as('isFavorite')) .select(withFiles) .where('id', '=', asUuid(id)) .limit(1) .executeTakeFirst(); } - @GenerateSql({ params: [DummyValue.UUID] }) + @GenerateSql( + { params: [DummyValue.UUID] }, + { name: 'with authUserId', params: [DummyValue.UUID, {}, DummyValue.UUID] }, + ) getById( id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags, edits }: GetByIdsRelations = {}, + // #763: the CALLER's id — used only to project `isFavoriteForUser` (favoriteExistsFor). + // Optional and omitted by every non-mapAsset-facing caller (asset.util.ts motion lookups, + // background jobs, person.service.ts createFace, classification.service.ts), which never + // reach mapAsset and so have no per-user value to resolve. + authUserId?: string, ) { - return this.db - .selectFrom('asset') - .selectAll('asset') - .where('asset.id', '=', asUuid(id)) - .$if(!!exifInfo, withExif) - .$if(!!faces, (qb) => - qb - .select(faces?.person ? withFacesAndPeople({ viewingUserId: faces.viewingUserId! }) : withFaces) - .$narrowType<{ faces: NotNull }>(), - ) - .$if(!!library, (qb) => qb.select(withLibrary)) - .$if(!!owner, (qb) => qb.select(withOwner)) - .$if(!!smartSearch, withSmartSearch) - .$if(!!stack, (qb) => - qb - .leftJoin('stack', 'stack.id', 'asset.stackId') - .$if(!stack!.assets, (qb) => - qb.select((eb) => eb.fn.toJson(eb.table('stack')).$castTo().as('stack')), - ) - .$if(!!stack!.assets, (qb) => - qb - .leftJoinLateral( - (eb) => - eb - .selectFrom('asset as stacked') - .selectAll('stack') - .select( - sql< - ShallowDehydrateObject>[] - >`array_agg(to_json(stacked) ORDER BY stacked."fileCreatedAt" ASC)`.as('assets'), - ) - .whereRef('stacked.stackId', '=', 'stack.id') - .whereRef('stacked.id', '!=', 'stack.primaryAssetId') - .where('stacked.deletedAt', 'is', null) - .where('stacked.visibility', '=', AssetVisibility.Timeline) - .groupBy('stack.id') - .as('stacked_assets'), - (join) => join.on('stack.id', 'is not', null), - ) - .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), - ), - ) - .$if(!!files, (qb) => qb.select(withFiles)) - .$if(!!tags, (qb) => qb.select(withTags)) - .$if(!!edits, (qb) => qb.select(withEdits)) - .limit(1) - .executeTakeFirst(); + return ( + this.db + .selectFrom('asset') + .selectAll('asset') + // #763: `isFavorite` is resolved from the OWNER's overlay row (favoriteExistsForOwner), NOT + // the raw asset."isFavorite" column selectAll('asset') would otherwise surface — that column + // is a frozen legacy value the overlay write path no longer updates (dropped outright in + // slice 3). Projected unconditionally (not gated on authUserId, unlike isFavoriteForUser + // below) because every consumer that reads `.isFavorite` directly off this method's result + // (job.service.ts's AssetEditReadyV2/AssetUploadReadyV2 payloads) sends its event to the + // asset's OWNER, so owner semantics is always correct here — see favoriteExistsForOwner's doc + // comment. mapAsset-facing callers (asset.service.ts `get`/`update`) never read this field; + // they use isFavoriteForUser instead. + .select((eb) => favoriteExistsForOwner(eb).as('isFavorite')) + .where('asset.id', '=', asUuid(id)) + .$if(!!exifInfo, withExif) + .$if(!!faces, (qb) => + qb + .select(faces?.person ? withFacesAndPeople({ viewingUserId: faces.viewingUserId! }) : withFaces) + .$narrowType<{ faces: NotNull }>(), + ) + .$if(!!library, (qb) => qb.select(withLibrary)) + .$if(!!owner, (qb) => qb.select(withOwner)) + .$if(!!smartSearch, withSmartSearch) + .$if(!!stack, (qb) => + qb + .leftJoin('stack', 'stack.id', 'asset.stackId') + .$if(!stack!.assets, (qb) => + qb.select((eb) => eb.fn.toJson(eb.table('stack')).$castTo().as('stack')), + ) + .$if(!!stack!.assets, (qb) => + qb + .leftJoinLateral( + (eb) => + eb + .selectFrom('asset as stacked') + .selectAll('stack') + .select( + sql< + ShallowDehydrateObject>[] + >`array_agg(to_json(stacked) ORDER BY stacked."fileCreatedAt" ASC)`.as('assets'), + ) + .whereRef('stacked.stackId', '=', 'stack.id') + .whereRef('stacked.id', '!=', 'stack.primaryAssetId') + .where('stacked.deletedAt', 'is', null) + .where('stacked.visibility', '=', AssetVisibility.Timeline) + .groupBy('stack.id') + .as('stacked_assets'), + (join) => join.on('stack.id', 'is not', null), + ) + .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), + ), + ) + .$if(!!files, (qb) => qb.select(withFiles)) + .$if(!!tags, (qb) => qb.select(withTags)) + .$if(!!edits, (qb) => qb.select(withEdits)) + .$if(!!authUserId, (qb) => qb.select((eb) => favoriteExistsFor(eb, authUserId!).as('isFavoriteForUser'))) + .limit(1) + .executeTakeFirst() + ); } @GenerateSql({ params: [[DummyValue.UUID], {}] }) @@ -1478,7 +1549,11 @@ export class AssetRepository { await this.db.updateTable('asset').set(options).where('libraryId', '=', asUuid(libraryId)).execute(); } - async update(asset: Updateable & { id: string }) { + // #763: authUserId is the CALLER's id, used only to project `isFavoriteForUser` onto the + // returned row. Optional and omitted by the many write-only callers (thumbnail/duration/stack + // bookkeeping etc.) whose result is never fed to mapAsset — only asset.service.ts's `update` + // (the PUT /assets/:id response) passes it. + async update(asset: Updateable & { id: string }, authUserId?: string) { const value = omitBy(asset, isUndefined); delete value.id; if (!isEmpty(value)) { @@ -1489,10 +1564,20 @@ export class AssetRepository { .$call(withExif) .$call((qb) => qb.select(withFaces)) .$call((qb) => qb.select(withEdits)) + .$if(!!authUserId, (qb) => + qb.select((eb) => + // The `.with('asset', ...)` CTE above shadows the real `asset` table with the same + // name, so `eb` here is scoped to `DB & { asset: }` rather than the plain + // `ExpressionBuilder` favoriteExistsFor expects. Safe cast: at runtime + // `favoriteExistsFor` only emits `sql.ref('asset.id')`, which resolves to the CTE's + // `id` column identically to the base table (same mechanism as getTimeBucket above). + favoriteExistsFor(eb as unknown as ExpressionBuilder, authUserId!).as('isFavoriteForUser'), + ), + ) .executeTakeFirst(); } - return this.getById(asset.id, { exifInfo: true, faces: {}, edits: true }); + return this.getById(asset.id, { exifInfo: true, faces: {}, edits: true }, authUserId); } async remove(asset: { id: string }): Promise { @@ -1612,7 +1697,13 @@ export class AssetRepository { .where('ownerId', '=', asUuid(ownerId)) .$if(visibility === undefined, withDefaultVisibility) .$if(!!visibility, (qb) => qb.where('asset.visibility', '=', visibility!)) - .$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!)) + .$if(isFavorite !== undefined, (qb) => + // #763: `ownerId` doubles as the favoriting user here — the query is already scoped to + // `WHERE asset.ownerId = ownerId` below, so this only ever asks "of ownerId's own assets, + // how many has ownerId favorited", regardless of which caller (self or an admin viewing + // another user's stats, see user-admin.service.ts) triggered the request. + qb.where((eb) => (isFavorite ? favoriteExistsFor(eb, ownerId) : eb.not(favoriteExistsFor(eb, ownerId)))), + ) .$if(!!isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .where('deletedAt', isTrashed ? 'is not' : 'is', null) .executeTakeFirstOrThrow(); @@ -1802,7 +1893,15 @@ export class AssetRepository { 'asset.duration', 'asset.id', 'asset.visibility', - sql`asset."isFavorite" and asset."ownerId" = ${auth.user.id}`.as('isFavorite'), + // The `eb` here is scoped to this `.with('cte', ...)` builder (DB & { filtered: ... }), + // which Kysely's ExpressionBuilder generics don't consider assignable to the plain + // `ExpressionBuilder` favoriteExistsFor expects, even though the + // 'asset_favorite' table it queries is unaffected by the extra CTE in scope. Safe cast. + // A shared-link session's auth.user is the link OWNER, not a viewer: never project their + // private favorites to an anonymous visitor (#763). + auth.sharedLink + ? sql`false`.as('isFavorite') + : favoriteExistsFor(eb as unknown as ExpressionBuilder, auth.user.id).as('isFavorite'), sql`asset.type = 'IMAGE'`.as('isImage'), sql`asset."deletedAt" is not null`.as('isTrashed'), 'asset.livePhotoVideoId', diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 6e754189a392e5..919ead8fd9d115 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -6,6 +6,7 @@ import { ApiKeyRepository } from 'src/repositories/api-key.repository.js'; import { AppMetricsRepository } from 'src/repositories/app-metrics.repository.js'; import { AppRepository } from 'src/repositories/app.repository.js'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { AssetFileRepository } from 'src/repositories/asset-file.repository.js'; import { AssetJobRepository } from 'src/repositories/asset-job.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; @@ -74,6 +75,7 @@ export const repositories = [ AppRepository, AssetRepository, AssetEditRepository, + AssetFavoriteRepository, AssetFileRepository, AssetJobRepository, ClassificationRepository, diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 8c63916616dce8..bff81c08e56054 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -14,6 +14,7 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos import { DB } from 'src/schema/index.js'; import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table.js'; import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table.js'; +import { favoriteExistsFor } from 'src/utils/favorite.js'; import { spaceAlbumAssetExists, spaceAssetPathBranches, @@ -110,7 +111,9 @@ export class MapRepository { .$if(isArchived === false || isArchived === undefined, (qb) => qb.where('asset.visibility', '=', AssetVisibility.Timeline), ) - .$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!)) + .$if(isFavorite !== undefined, (q) => + q.where((eb) => (isFavorite ? favoriteExistsFor(eb, authUserId) : eb.not(favoriteExistsFor(eb, authUserId)))), + ) .$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!)) .$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!)) .where((eb) => { diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 1ab38abf7d9d8e..24b5d020affa1a 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -10,6 +10,7 @@ import { AssetOrderWithRandom, AssetVisibility, MemoryType } from 'src/enum.js'; import { DB } from 'src/schema/index.js'; import { MemoryTable } from 'src/schema/tables/memory.table.js'; import { asUuid } from 'src/utils/database.js'; +import { favoriteExistsFor } from 'src/utils/favorite.js'; import { type TimelineHiddenScope, hiddenFromOwnTimeline, @@ -320,6 +321,12 @@ export class MemoryRepository implements IBulkAsset { ), ), ) + // #763: userId here is the VIEWER (searchAccessible surfaces memories owned by + // others, reachable via partner/space share) — the overlay must be keyed to them, + // never to the memory's own ownerId. Gated on `.$if` (always true — userId is a + // required param) purely so Kysely infers `isFavoriteForUser` as optional, matching + // MapAsset and every other projection site instead of forcing it required here alone. + .$if(!!userId, (qb) => qb.select((eb) => favoriteExistsFor(eb, userId).as('isFavoriteForUser'))) .orderBy('asset.localDateTime', 'asc'), ).as('assets'), ) @@ -347,12 +354,19 @@ export class MemoryRepository implements IBulkAsset { // #1041: `viewerId`/`hiddenScope` are optional — `create`/`update` below return the object right // after the caller's own action and pass neither, so their SQL is unchanged. `get()` is the // read surface and is the one MemoryService resolves a scope for. - @GenerateSql({ params: [DummyValue.UUID] }) + // + // #763: `viewerId` is also the user the favorite overlay resolves for — the caller, not the + // memory owner. One id serves both, so the overlay projection keys off it rather than taking a + // second parameter that MemoryService would have to pass twice. + @GenerateSql({ params: [DummyValue.UUID] }, { name: 'with viewerId', params: [DummyValue.UUID, DummyValue.UUID] }) get(id: string, viewerId?: string, hiddenScope?: TimelineHiddenScope, visibleSpaceIds: string[] = []) { return this.getByIdBuilder(id, viewerId, hiddenScope, visibleSpaceIds).executeTakeFirst(); } - async create(memory: Insertable, assetIds: Set) { + // #763: authUserId is the CALLER's id, used only to project `isFavoriteForUser` onto each + // returned memory asset. Optional — memory.service.ts's addAssets/removeAssets call `update` + // purely for its side effect and never consume the returned assets, so they omit it. + async create(memory: Insertable, assetIds: Set, authUserId?: string) { const id = await this.db.transaction().execute(async (tx) => { const { id } = await tx.insertInto('memory').values(memory).returning('id').executeTakeFirstOrThrow(); @@ -364,13 +378,13 @@ export class MemoryRepository implements IBulkAsset { return id; }); - return this.getByIdBuilder(id).executeTakeFirstOrThrow(); + return this.getByIdBuilder(id, authUserId).executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID, { ownerId: DummyValue.UUID, isSaved: true }] }) - async update(id: string, memory: Updateable) { + async update(id: string, memory: Updateable, authUserId?: string) { await this.db.updateTable('memory').set(memory).where('id', '=', id).execute(); - return this.getByIdBuilder(id).executeTakeFirstOrThrow(); + return this.getByIdBuilder(id, authUserId).executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) @@ -481,7 +495,10 @@ export class MemoryRepository implements IBulkAsset { hiddenFromOwnTimeline(eb, hiddenScope!, { kind: 'inline', visibleSpaceIds, viewerId: viewerId! })!, ]), ), - ), + ) + // #763: `viewerId` is the CALLER — the same id #1041 threads above. Optional, so a call + // that omits it (addAssets/removeAssets discard the result) simply skips the projection. + .$if(!!viewerId, (qb) => qb.select((eb) => favoriteExistsFor(eb, viewerId!).as('isFavoriteForUser'))), ).as('assets'), ) .where('id', '=', id) diff --git a/server/src/repositories/search.repository.spec.ts b/server/src/repositories/search.repository.spec.ts index a6a2867b4135aa..1e2d6c4c5c0493 100644 --- a/server/src/repositories/search.repository.spec.ts +++ b/server/src/repositories/search.repository.spec.ts @@ -891,4 +891,35 @@ describe(SearchRepository.name, () => { expect(sql).not.toContain(ESCAPE_CLAUSE); }); }); + + // #763 E10 — sibling of the withTimeBucketAssetFilters block in asset.repository.spec.ts, for + // the search builder. `asset_favorite` rows outlive access loss by design (spec §5.2), so an + // `isFavorite` search is only safe because the favorite predicate is ANDed with a scope + // recomputed from live membership: the timelineSpaceIds arm while the caller has member-spaces, + // a bare `asset.ownerId = ...` once they do not. End-to-end coverage lives in + // test/medium/specs/services/favorite-access-revocation.medium.spec.ts. + describe('favorite scoping under access revocation (#763 E10)', () => { + const CALLER = '00000000-0000-0000-0000-000000000000'; + const SPACE = '11111111-1111-1111-1111-111111111111'; + + it('scopes a favorite search to owned assets when the caller has no member-spaces', () => { + const sql = buildAssetSearchSql({ isFavorite: true, userIds: [CALLER], authUserId: CALLER }); + + expect(sql).toContain('"asset_favorite"'); + expect(sql).toContain('"asset"."ownerId" = any'); + expect(sql).not.toContain('"shared_space_asset"'); + }); + + it('widens to the space arm only while member-spaces are supplied', () => { + const sql = buildAssetSearchSql({ + isFavorite: true, + userIds: [CALLER], + authUserId: CALLER, + timelineSpaceIds: [SPACE], + }); + + expect(sql).toContain('"asset_favorite"'); + expect(sql).toContain('"shared_space_asset"'); + }); + }); }); diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index cd711ee8d34d00..ca029bca8dfbee 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { + type ExpressionBuilder, type Kysely, type OrderByDirection, type SelectQueryBuilder, @@ -35,6 +36,7 @@ import { withExifInner, withSearchOrder, } from 'src/utils/database.js'; +import { favoriteExistsFor } from 'src/utils/favorite.js'; import { without } from 'src/utils/filter-suggestions.js'; import { type PaginationOptions, paginationHelper } from 'src/utils/pagination.js'; import { spaceAssetPathBranches, spaceVisibilityGate } from 'src/utils/shared-space-album-scope.js'; @@ -53,6 +55,14 @@ export interface SearchUserIdOptions { * userIds would widen the result set instead of narrowing it. */ ownerId?: string; + /** + * #763: the caller making the request — NOT `userIds` (the timeline/search *target*, which + * differs from the caller on space/album scoped paths — e.g. `getFilteredMapMarkers`'s + * `userIds: dto.spaceId ? undefined : [auth.user.id]` leaves `userIds` unset entirely when + * browsing a specific space). Required by `searchAssetBuilder` and the smart-facet pipeline + * whenever `isFavorite` is set, so they resolve the `asset_favorite` overlay for the right user. + */ + authUserId?: string; } export type SearchIdOptions = SearchAssetIdOptions & SearchUserIdOptions; @@ -163,6 +173,15 @@ export interface SearchSpaceOptions { spaceId?: string; spacePersonIds?: string[]; timelineSpaceIds?: string[]; + /** + * #763: the space scope the FAVOURITES probe alone runs under — every space the caller belongs + * to, not just the ones they show in their timeline. A favourite is an explicit per-asset act and + * stays reachable when its space is hidden (see SharedSpaceRepository.getAllMemberSpaceIds), so + * the "is the Favourites section worth offering" answer has to span the same set the favourites + * filter itself will. Deliberately separate from `timelineSpaceIds`, which still scopes every + * OTHER facet — widening those would leak a hidden space's cities/tags/people back into the panel. + */ + favoriteSpaceIds?: string[]; } export interface SearchOrderOptions { @@ -212,6 +231,12 @@ export interface AssetSearchScope { export interface AssetSearchBuilderV3Options { filter?: SearchFilter; + /** + * #763: the CALLER's id, used to resolve `filter.isFavorite` against the per-user + * `asset_favorite` overlay (the raw `asset.isFavorite` column is gone). Mirrors + * `AssetSearchBuilderOptions.authUserId` on the legacy path. Never client-controlled. + */ + authUserId?: string; withExif?: boolean; withFaces?: boolean; withPeople?: boolean; @@ -318,6 +343,15 @@ export interface SuggestionScopeOptions { albumId?: string; spaceId?: string; timelineSpaceIds?: string[]; + /** + * #763: the space scope the FAVOURITES probe alone runs under — every space the caller belongs + * to, not just the ones they show in their timeline. A favourite is an explicit per-asset act and + * stays reachable when its space is hidden (see SharedSpaceRepository.getAllMemberSpaceIds), so + * the "is the Favourites section worth offering" answer has to span the same set the favourites + * filter itself will. Deliberately separate from `timelineSpaceIds`, which still scopes every + * OTHER facet — widening those would leak a hidden space's cities/tags/people back into the panel. + */ + favoriteSpaceIds?: string[]; takenAfter?: Date; takenBefore?: Date; /** @@ -432,6 +466,7 @@ export class SearchRepository { withStacked: true, isFavorite: true, userIds: [DummyValue.UUID], + authUserId: DummyValue.UUID, }, ], }, @@ -452,6 +487,12 @@ export class SearchRepository { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; const items = await searchAssetBuilderLegacy(this.db, options) .select(columns.searchAsset) + // #763: project the per-user overlay onto the rows feeding mapAsset. Not folded into + // searchAssetBuilderLegacy itself — searchStatistics also builds on it with an aggregate + // countAll() and no GROUP BY, so an unconditional row-level SELECT there would break it. + .$if(!!options.authUserId, (qb) => + qb.select((eb) => favoriteExistsFor(eb, options.authUserId!).as('isFavoriteForUser')), + ) .orderBy('asset.fileCreatedAt', orderDirection) .orderBy('asset.id', orderDirection) .limit(pagination.size + 1) @@ -469,6 +510,7 @@ export class SearchRepository { lensModel: DummyValue.STRING, isFavorite: true, userIds: [DummyValue.UUID], + authUserId: DummyValue.UUID, }, ], }) @@ -488,15 +530,22 @@ export class SearchRepository { withStacked: true, isFavorite: true, userIds: [DummyValue.UUID], + authUserId: DummyValue.UUID, }, ], }) async searchRandom(size: number, options: AssetSearchOptions) { - return searchAssetBuilderLegacy(this.db, options) - .select(columns.searchAsset) - .orderBy(sql`random()`) - .limit(size) - .execute(); + return ( + searchAssetBuilderLegacy(this.db, options) + .select(columns.searchAsset) + // #763: see searchMetadata above for why this lives per-caller rather than in searchAssetBuilderLegacy. + .$if(!!options.authUserId, (qb) => + qb.select((eb) => favoriteExistsFor(eb, options.authUserId!).as('isFavoriteForUser')), + ) + .orderBy(sql`random()`) + .limit(size) + .execute() + ); } // TODO(v4): remove with the deprecated flat-field search API @@ -509,18 +558,25 @@ export class SearchRepository { withStacked: true, isFavorite: true, userIds: [DummyValue.UUID], + authUserId: DummyValue.UUID, }, ], }) searchLargeAssets(size: number, options: LargeAssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; - return searchAssetBuilderLegacy(this.db, options) - .select(columns.searchAsset) - .$call(withExifInner) - .where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0) - .orderBy('asset_exif.fileSizeInByte', orderDirection) - .limit(size) - .execute(); + return ( + searchAssetBuilderLegacy(this.db, options) + .select(columns.searchAsset) + // #763: see searchMetadata above for why this lives per-caller rather than in searchAssetBuilderLegacy. + .$if(!!options.authUserId, (qb) => + qb.select((eb) => favoriteExistsFor(eb, options.authUserId!).as('isFavoriteForUser')), + ) + .$call(withExifInner) + .where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0) + .orderBy('asset_exif.fileSizeInByte', orderDirection) + .limit(size) + .execute() + ); } // TODO(v4): remove with the deprecated flat-field search API @@ -538,6 +594,12 @@ export class SearchRepository { ratingIsMinimum: true, }) .selectAll('asset') + // #763: project the per-user overlay onto the rows feeding mapAsset via searchSmart's + // mapResponse. Flows through both the 'cte' (candidates.selectAll()) and 'simple' outer + // query paths below since both select every column baseQuery projects. + .$if(!!options.authUserId, (qb) => + qb.select((eb) => favoriteExistsFor(eb, options.authUserId!).as('isFavoriteForUser')), + ) .innerJoin('smart_search', 'asset.id', 'smart_search.assetId') .$if(!!options.forceEmptyResult, (qb) => qb.where(sql`false`)) .$if(hasDistanceThreshold, (qb) => @@ -620,6 +682,7 @@ export class SearchRepository { withStacked: true, isFavorite: true, userIds: [DummyValue.UUID], + authUserId: DummyValue.UUID, spacePersonIds: [DummyValue.UUID], timelineSpaceIds: [DummyValue.UUID, DummyValue.UUID], orderDirection: 'desc', @@ -756,6 +819,12 @@ export class SearchRepository { const appliesModel = exclude !== 'camera' && exclude !== 'cameraModel' && options.model !== undefined; const appliesRating = exclude !== 'rating' && options.rating !== undefined; const needsExifJoin = !!(appliesCountry || appliesCity || appliesMake || appliesModel || appliesRating); + // #763: same caller resolution as getSmartFacetHasFavorites — `authUserId` and `callerId` are two + // names for the viewer on this path, and `userIds[0]` is deliberately not consulted (it is not the + // caller under an album scope). Only read when `isFavorite` is set, and the assertion holds for + // every production caller: SearchService.resolveSmartSearch is the only one, and it always sets + // both alongside any client-supplied `isFavorite`. + const facetCallerId = options.authUserId ?? options.callerId; return kysely .selectFrom('asset') @@ -773,7 +842,9 @@ export class SearchRepository { ) .$if(exclude !== 'media' && !!options.type, (qb) => qb.where('asset.type', '=', options.type!)) .$if(exclude !== 'favorites' && options.isFavorite !== undefined, (qb) => - qb.where('asset.isFavorite', '=', options.isFavorite!), + qb.where((eb) => + options.isFavorite ? favoriteExistsFor(eb, facetCallerId!) : eb.not(favoriteExistsFor(eb, facetCallerId!)), + ), ) .$if(exclude !== 'albums' && !!options.isNotInAlbum && !options.albumIds?.length, (qb) => qb.where((eb) => @@ -1038,11 +1109,42 @@ export class SearchRepository { } private async getSmartFacetHasFavorites(trx: Kysely, options: SmartSearchFacetsOptions): Promise { + // #763: "is the Favourites section worth offering" is a per-CALLER question — probe the + // caller's `asset_favorite` overlay, not the dropped `asset.isFavorite` column. + // + // The caller is `authUserId`/`callerId` — two names for the same value (`auth.user.id`, set by + // SearchService.resolveSmartSearch), one from this path's search-builder options and one from + // SearchEmbeddingOptions. Deliberately NOT `userIds[0]`: unlike the browse path, where + // getUserIdsToSearch guarantees `[auth.user.id, ...partnerIds]`, `userIds` here is left unset + // entirely under an album scope (see SearchEmbeddingOptions.userIds and the `callerId` note in + // getSmartFacetPeople). Probing `userIds[0]` there would answer for whichever owner happened to + // head an owner-scoping array — another user's favourite state, surfaced in my UI. + // + // With no caller in scope there is no per-user answer, so the section is simply not offered. + // That is the fail-safe direction — it can only hide a filter, never reveal someone else's + // favourites — and it is consistent with what the section would do if shown: the `isFavorite` + // filter behind it needs a caller too. Mirrors the `.$if(!!authUserId, ...)` fail-safe the + // overlay projections use in asset.repository.ts. + const callerId = options.authUserId ?? options.callerId; + if (!callerId) { + return false; + } + const row = await trx .selectFrom('asset') .select('asset.id') - .where('asset.id', 'in', this.buildSmartFacetFilteredAssetIds(trx, options, 'favorites')) - .where('asset.isFavorite', '=', true) + // #763: same widening as getFilteredHasFavorites — the favourites facet spans every space the + // caller belongs to, while every other facet keeps the timeline-visible scope. + .where( + 'asset.id', + 'in', + this.buildSmartFacetFilteredAssetIds( + trx, + { ...options, timelineSpaceIds: options.favoriteSpaceIds ?? options.timelineSpaceIds }, + 'favorites', + ), + ) + .where((eb) => favoriteExistsFor(eb, callerId)) .limit(1) .executeTakeFirst(); return !!row; @@ -1266,8 +1368,11 @@ export class SearchRepository { .execute(); } - @GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] }) - getAssetsByCity(userIds: string[], timelineSpaceIds?: string[]) { + @GenerateSql( + { params: [[DummyValue.UUID], [DummyValue.UUID]] }, + { name: 'with authUserId', params: [[DummyValue.UUID], [DummyValue.UUID], DummyValue.UUID] }, + ) + getAssetsByCity(userIds: string[], timelineSpaceIds?: string[], authUserId?: string) { // #867: the places page is the "view all" of the Explore strip, so it carries the same scope — // own (and partner) assets, plus anything reachable through a space the viewer kept on their // timeline. Built from a detached expression builder because the recursive `cte` widens the @@ -1288,53 +1393,67 @@ export class SearchRepository { ]); }; - return this.db - .withRecursive('cte', (qb) => { - const base = qb - .selectFrom('asset_exif') - .select(['city', 'assetId']) - .innerJoin('asset', 'asset.id', 'asset_exif.assetId') - .where(viewerScope()) - .where('asset.visibility', '=', AssetVisibility.Timeline) - .where('asset.type', '=', AssetType.Image) - .where('asset.deletedAt', 'is', null) - .orderBy('city') - .limit(1); - - const recursive = qb - .selectFrom('cte') - .select(['l.city', 'l.assetId']) - .innerJoinLateral( - (qb) => - qb - .selectFrom('asset_exif') - .select(['city', 'assetId']) - .innerJoin('asset', 'asset.id', 'asset_exif.assetId') - .where(viewerScope()) - .where('asset.visibility', '=', AssetVisibility.Timeline) - .where('asset.type', '=', AssetType.Image) - .where('asset.deletedAt', 'is', null) - .whereRef('asset_exif.city', '>', 'cte.city') - .orderBy('city') - .limit(1) - .as('l'), - (join) => join.onTrue(), - ); - - return sql<{ city: string; assetId: string }>`(${base} union all ${recursive})`; - }) - .selectFrom('asset') - .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .innerJoin('cte', 'asset.id', 'cte.assetId') - .select(columns.searchAsset) - .select((eb) => - eb - .fn('to_jsonb', [eb.table('asset_exif')]) - .$castTo>>() - .as('exifInfo'), - ) - .orderBy('asset_exif.city') - .execute(); + return ( + this.db + .withRecursive('cte', (qb) => { + const base = qb + .selectFrom('asset_exif') + .select(['city', 'assetId']) + .innerJoin('asset', 'asset.id', 'asset_exif.assetId') + .where(viewerScope()) + .where('asset.visibility', '=', AssetVisibility.Timeline) + .where('asset.type', '=', AssetType.Image) + .where('asset.deletedAt', 'is', null) + .orderBy('city') + .limit(1); + + const recursive = qb + .selectFrom('cte') + .select(['l.city', 'l.assetId']) + .innerJoinLateral( + (qb) => + qb + .selectFrom('asset_exif') + .select(['city', 'assetId']) + .innerJoin('asset', 'asset.id', 'asset_exif.assetId') + .where(viewerScope()) + .where('asset.visibility', '=', AssetVisibility.Timeline) + .where('asset.type', '=', AssetType.Image) + .where('asset.deletedAt', 'is', null) + .whereRef('asset_exif.city', '>', 'cte.city') + .orderBy('city') + .limit(1) + .as('l'), + (join) => join.onTrue(), + ); + + return sql<{ city: string; assetId: string }>`(${base} union all ${recursive})`; + }) + .selectFrom('asset') + .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') + .innerJoin('cte', 'asset.id', 'cte.assetId') + .select(columns.searchAsset) + .select((eb) => + eb + .fn('to_jsonb', [eb.table('asset_exif')]) + .$castTo>>() + .as('exifInfo'), + ) + // #763: project the per-user overlay for the CALLER onto the rows feeding + // search.service.ts's getAssetsByCity -> mapAsset. The `eb` here is scoped to + // `DB & { cte: ... }` (the `withRecursive('cte', ...)` above), which Kysely's + // ExpressionBuilder generics don't consider assignable to the plain + // `ExpressionBuilder` favoriteExistsFor expects, even though the + // 'asset_favorite' table it queries is unaffected by the extra CTE in scope. Safe cast + // (same mechanism as asset.repository.ts's getTimeBucket). + .$if(!!authUserId, (qb) => + qb.select((eb) => + favoriteExistsFor(eb as unknown as ExpressionBuilder, authUserId!).as('isFavoriteForUser'), + ), + ) + .orderBy('asset_exif.city') + .execute() + ); } async upsert(assetId: string, embedding: string): Promise { @@ -1821,7 +1940,13 @@ export class SearchRepository { ) .$if(!!options.mediaType, (qb) => qb.where('asset.type', '=', options.mediaType!)) .$if(options.isFavorite !== undefined && options.isFavorite !== null, (qb) => - qb.where('asset.isFavorite', '=', options.isFavorite!), + qb.where((eb) => + // #763: buildFilteredAssetIds has no dedicated authUserId param — userIds[0] is the + // caller here (every call path resolves userIds via SearchService.getUserIdsToSearch, + // which always returns `[auth.user.id, ...partnerIds]`; unlike searchAssetBuilder's + // options.userIds, this is never left unset or reordered on a space/album path). + options.isFavorite ? favoriteExistsFor(eb, userIds[0]) : eb.not(favoriteExistsFor(eb, userIds[0])), + ), ); } @@ -2123,8 +2248,20 @@ export class SearchRepository { const row = await this.db .selectFrom('asset') .select('asset.id') - .where('asset.id', 'in', this.buildFilteredAssetIds(userIds, options)) - .where('asset.isFavorite', '=', true) + // #763: `favoriteSpaceIds` when the caller supplied it — a favourite survives hiding its + // space from the timeline, so the probe must span every membership. Falls back to the shared + // scope, which is what album/space-scoped panels (no favouriteSpaceIds) keep using. + .where( + 'asset.id', + 'in', + this.buildFilteredAssetIds(userIds, { + ...options, + timelineSpaceIds: options.favoriteSpaceIds ?? options.timelineSpaceIds, + }), + ) + // #763: per-caller overlay, not the dropped `asset.isFavorite` column. `userIds[0]` is the + // caller on this path — same reasoning as `buildFilteredAssetIds`'s isFavorite branch. + .where((eb) => favoriteExistsFor(eb, userIds[0])) .limit(1) .executeTakeFirst(); return !!row; diff --git a/server/src/repositories/shared-space.repository.ts b/server/src/repositories/shared-space.repository.ts index e584ad1216da85..3523fa38fd805e 100644 --- a/server/src/repositories/shared-space.repository.ts +++ b/server/src/repositories/shared-space.repository.ts @@ -459,6 +459,26 @@ export class SharedSpaceRepository { .execute(); } + /** + * #763: every space the caller belongs to, WITHOUT the `showInTimeline` filter its sibling + * applies. `showInTimeline` is a preference about the personal HOME TIMELINE, not a revocation of + * access — the member still sees the space, and #1041 already exempts Favorites (with Archive and + * Trash) from the personal-timeline hide because those are curation/recovery surfaces where + * subtracting an asset strands it with no way back to it in the UI. + * + * Favoriting is that same kind of explicit, per-asset act, so a favorite must stay reachable on + * /favorites even when the space it lives in is hidden from the home timeline. Reported against + * pr-819-rc.2: the heart said "Added to favorites" and the page then never listed it. + * + * Widening the scope is safe here ONLY because every caller pairs it with `isFavorite: true`, + * which narrows to the caller's OWN `asset_favorite` rows — it can never surface another member's + * content. Do not reuse it for an unfiltered browse. + */ + @GenerateSql({ params: [DummyValue.UUID] }) + getAllMemberSpaceIds(userId: string, db: Kysely | Transaction = this.db) { + return db.selectFrom('shared_space_member').where('userId', '=', userId).select('spaceId').execute(); + } + // #1041: own-row-only by construction — always a specific (spaceId, albumId, userId), never a // bulk/admin write. onConflict doNothing makes hiding an already-hidden album a no-op, not an error. async hideAlbumForUser( diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index 272c7e39bfe5a9..9d08a43f805d7b 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -8,13 +8,19 @@ import { AssetVisibility } from 'src/enum.js'; import { DB } from 'src/schema/index.js'; import { StackTable } from 'src/schema/tables/stack.table.js'; import { asUuid, withDefaultVisibility } from 'src/utils/database.js'; +import { favoriteExistsFor } from 'src/utils/favorite.js'; export interface StackSearch { ownerId: string; primaryAssetId?: string; } -const withAssets = (eb: ExpressionBuilder, withTags = false) => { +// #763: authUserId is the CALLER's id, threaded from stack.service.ts (search/create/update/ +// getById are all owner-only — see AccessRepository's StackAccess.checkOwnerAccess — so the +// caller and the stack's ownerId are always the same user; still passed explicitly rather than +// read off the entity, per the slice 1 caller-id convention). Optional and only used to project +// `isFavoriteForUser` onto each stack asset for mapAsset (via stack.dto.ts's mapStack). +const withAssets = (eb: ExpressionBuilder, withTags = false, authUserId?: string) => { return jsonArrayFrom( eb .selectFrom('asset') @@ -40,6 +46,16 @@ const withAssets = (eb: ExpressionBuilder, withTags = false) => { ), ) .select((eb) => eb.fn.toJson('exifInfo').as('exifInfo')) + // #763: the `eb` here is scoped to `DB & { exifInfo: ... }` (the innerJoinLateral above), + // which Kysely's ExpressionBuilder generics don't consider assignable to the plain + // `ExpressionBuilder` favoriteExistsFor expects, even though the + // 'asset_favorite' table it queries is unaffected by the extra lateral join in scope. Safe + // cast (same mechanism as asset.repository.ts's getTimeBucket). + .$if(!!authUserId, (qb) => + qb.select((eb) => + favoriteExistsFor(eb as unknown as ExpressionBuilder, authUserId!).as('isFavoriteForUser'), + ), + ) .where('asset.deletedAt', 'is', null) .whereRef('asset.stackId', '=', 'stack.id') .$call(withDefaultVisibility) @@ -51,18 +67,21 @@ const withAssets = (eb: ExpressionBuilder, withTags = false) => { export class StackRepository { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [{ ownerId: DummyValue.UUID }] }) - search(query: StackSearch) { + @GenerateSql( + { params: [{ ownerId: DummyValue.UUID }] }, + { name: 'with authUserId', params: [{ ownerId: DummyValue.UUID }, DummyValue.UUID] }, + ) + search(query: StackSearch, authUserId?: string) { return this.db .selectFrom('stack') .selectAll('stack') - .select(withAssets) + .select((eb) => withAssets(eb, false, authUserId)) .where('stack.ownerId', '=', query.ownerId) .$if(!!query.primaryAssetId, (eb) => eb.where('stack.primaryAssetId', '=', query.primaryAssetId!)) .execute(); } - async create(entity: Omit, 'primaryAssetId'>, assetIds: string[]) { + async create(entity: Omit, 'primaryAssetId'>, assetIds: string[], authUserId?: string) { return this.db.transaction().execute(async (tx) => { const stacks = await tx .selectFrom('stack') @@ -120,7 +139,7 @@ export class StackRepository { return tx .selectFrom('stack') .selectAll('stack') - .select(withAssets) + .select((eb) => withAssets(eb, false, authUserId)) .where('id', '=', newRecord.id) .executeTakeFirstOrThrow(); }); @@ -135,22 +154,22 @@ export class StackRepository { await this.db.deleteFrom('stack').where('id', 'in', ids).execute(); } - update(id: string, entity: Updateable) { + update(id: string, entity: Updateable, authUserId?: string) { return this.db .updateTable('stack') .set(entity) .where('id', '=', asUuid(id)) .returningAll('stack') - .returning((eb) => withAssets(eb, true)) + .returning((eb) => withAssets(eb, true, authUserId)) .executeTakeFirstOrThrow(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getById(id: string) { + @GenerateSql({ params: [DummyValue.UUID] }, { name: 'with authUserId', params: [DummyValue.UUID, DummyValue.UUID] }) + getById(id: string, authUserId?: string) { return this.db .selectFrom('stack') .selectAll() - .select((eb) => withAssets(eb, true)) + .select((eb) => withAssets(eb, true, authUserId)) .where('id', '=', asUuid(id)) .executeTakeFirst(); } diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index f0b1f1b73d68d7..b95b967d68ee4f 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -6,6 +6,7 @@ import { columns } from 'src/database.js'; import { DummyValue, GenerateSql } from 'src/decorators.js'; import { DB } from 'src/schema/index.js'; import { asUuid } from 'src/utils/database.js'; +import { favoriteExistsFor } from 'src/utils/favorite.js'; import { accessibleSpaceAlbums, accessibleSpaces, spaceVisibilityGate } from 'src/utils/shared-space-album-scope.js'; // Re-export the relocated scoping helpers so existing `sync.repository` importers @@ -62,6 +63,7 @@ export class SyncRepository { assetFace: AssetFaceSync; assetMetadata: AssetMetadataSync; assetOcr: AssetOcrSync; + assetFavorite: AssetFavoriteSync; authUser: AuthUserSync; memory: MemorySync; memoryToAsset: MemoryToAssetSync; @@ -103,6 +105,7 @@ export class SyncRepository { this.assetFace = new AssetFaceSync(this.db); this.assetMetadata = new AssetMetadataSync(this.db); this.assetOcr = new AssetOcrSync(this.db); + this.assetFavorite = new AssetFavoriteSync(this.db); this.authUser = new AuthUserSync(this.db); this.memory = new MemorySync(this.db); this.memoryToAsset = new MemoryToAssetSync(this.db); @@ -239,15 +242,7 @@ class AlbumAssetSync extends BaseSync { return this.backfillQuery('album_asset', options) .innerJoin('asset', 'asset.id', 'album_asset.assetId') .select(columns.syncAlbumAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('album_asset.updateId') .where('album_asset.albumId', '=', albumId) .stream(); @@ -259,15 +254,7 @@ class AlbumAssetSync extends BaseSync { return this.upsertQuery('asset', options) .innerJoin('album_asset', 'album_asset.assetId', 'asset.id') .select(columns.syncAlbumAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('asset.updateId') .where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send updates for assets that the client already knows about .innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId') @@ -282,15 +269,7 @@ class AlbumAssetSync extends BaseSync { .select('album_asset.updateId') .innerJoin('asset', 'asset.id', 'album_asset.assetId') .select(columns.syncAlbumAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId') .where('album_user.userId', '=', userId) .stream(); @@ -436,12 +415,18 @@ class AssetSync extends BaseSync { return this.auditCleanup('asset_audit', daysAgo); } + // #763: the syncing user IS the owner here (getDeletes/getUpserts both filter `ownerId = + // options.userId`), so favoriteExistsFor resolves the same rows the old `asset.isFavorite` + // column read — no behavior change post-backfill, just the source of truth moving to the + // per-user overlay like every other asset-payload stream in this file. @GenerateSql({ params: [dummyQueryOptions], stream: true }) getUpserts(options: SyncQueryOptions) { + const userId = options.userId; return this.upsertQuery('asset', options) .select(columns.syncAsset) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('asset.updateId') - .where('ownerId', '=', options.userId) + .where('ownerId', '=', userId) .stream(); } } @@ -666,11 +651,18 @@ class PartnerSync extends BaseSync { } class PartnerAssetsSync extends BaseSync { - @GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true }) - getBackfill(options: SyncBackfillOptions, partnerId: string) { + // #763 (E25): isFavorite now resolves the RECIPIENT's (`userId` — the syncing user, not + // `partnerId`) own overlay row instead of a hardcoded false. The hardcoded false this replaces + // was introduced by migration 1777897107000-PartnerAssetSyncReset to guard against the *owner's* + // (the partner's) favorite leaking to the recipient, back when favorite was a single boolean on + // the asset row. `favoriteExistsFor` is always scoped to the passed-in `userId`, so it can only + // ever report the recipient's own favorite of a partner asset — the owner's favorite is a + // different (userId, assetId) row in `asset_favorite` and is never read here. + @GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID, DummyValue.UUID], stream: true }) + getBackfill(options: SyncBackfillOptions, partnerId: string, userId: string) { return this.backfillQuery('asset', options) .select(columns.syncPartnerAsset) - .select(sql.val(false).as('isFavorite')) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('asset.updateId') .where('ownerId', '=', partnerId) .stream(); @@ -686,14 +678,16 @@ class PartnerAssetsSync extends BaseSync { .stream(); } + // #763 (E25): isFavorite resolves the recipient's own overlay row — see getBackfill above. @GenerateSql({ params: [dummyQueryOptions], stream: true }) getUpserts(options: SyncQueryOptions) { + const userId = options.userId; return this.upsertQuery('asset', options) .select(columns.syncPartnerAsset) - .select(sql.val(false).as('isFavorite')) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('asset.updateId') .where('ownerId', 'in', (eb) => - eb.selectFrom('partner').select(['sharedById']).where('sharedWithId', '=', options.userId), + eb.selectFrom('partner').select(['sharedById']).where('sharedWithId', '=', userId), ) .stream(); } @@ -868,6 +862,35 @@ class AssetOcrSync extends BaseSync { } } +// Per-user favorites overlay sync (#763, design doc §4.3). Structurally identical to +// AssetEditSync above: a flat, caller-scoped table (`asset_favorite.userId = options.userId`, +// no join through `asset`), getUpserts + getDeletes, no backfill loop. `asset_favorite` is its +// own synced entity — not a field riding the asset payload — specifically so that a favorite +// write never bumps `asset.updateId`, which would otherwise amplify a personal write into a +// cross-user re-sync of the underlying asset (see AssetSync.getUpserts / SharedSpaceAssetSync +// above, both of which resolve isFavorite via a read-only correlated EXISTS and never write here). +class AssetFavoriteSync extends BaseSync { + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getDeletes(options: SyncQueryOptions) { + return this.auditQuery('asset_favorite_audit', options) + .select(['asset_favorite_audit.id', 'asset_favorite_audit.assetId']) + .where('asset_favorite_audit.userId', '=', options.userId) + .stream(); + } + + cleanupAuditTable(daysAgo: number) { + return this.auditCleanup('asset_favorite_audit', daysAgo); + } + + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getUpserts(options: SyncQueryOptions) { + return this.upsertQuery('asset_favorite', options) + .select(['asset_favorite.assetId', 'asset_favorite.updateId']) + .where('asset_favorite.userId', '=', options.userId) + .stream(); + } +} + // --- gallery-fork: shared-space sync --- // `accessibleSpaces` is the source-of-truth scoping subquery used by every // shared-space sync class to test "does this user have access to this space?". @@ -994,24 +1017,16 @@ export class SharedSpaceMemberSync extends BaseSync { export class SharedSpaceAssetSync extends BaseSync { // Per-space backfill of asset rows joined through shared_space_asset. // - // isFavorite is masked to the syncing user's own rows (mirrors AlbumAssetSync): - // a space member must not learn another owner's favorite flag for an asset - // shared into the space. + // #763: isFavorite resolves the syncing user's OWN asset_favorite overlay row + // (favoriteExistsFor), never the asset owner's — a space member sees whether THEY favorited an + // asset shared into the space, not whether the owner did (mirrors AlbumAssetSync). @GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID, DummyValue.UUID], stream: true }) getBackfill(options: SyncBackfillOptions, spaceId: string, userId: string) { return ( this.backfillQuery('shared_space_asset', options) .innerJoin('asset', 'asset.id', 'shared_space_asset.assetId') .select(columns.syncSharedSpaceAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('shared_space_asset.updateId') .where('shared_space_asset.spaceId', '=', spaceId) .where((eb) => spaceVisibilityGate(eb)) @@ -1026,7 +1041,7 @@ export class SharedSpaceAssetSync extends BaseSync { // shared_space_asset row produces one event (write amplification accepted — // mobile dedups by asset id at insert time). // - // isFavorite is masked to the syncing user's own rows — see getBackfill above. + // #763: isFavorite resolves the syncing user's own overlay row — see getBackfill above. @GenerateSql({ params: [dummyQueryOptions], stream: true }) getCreates(options: SyncQueryOptions) { const userId = options.userId; @@ -1034,15 +1049,7 @@ export class SharedSpaceAssetSync extends BaseSync { this.upsertQuery('shared_space_asset', options) .innerJoin('asset', 'asset.id', 'shared_space_asset.assetId') .select(columns.syncSharedSpaceAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('shared_space_asset.updateId') .where('shared_space_asset.spaceId', 'in', (eb) => accessibleSpaces(eb, options.userId)) .where((eb) => spaceVisibilityGate(eb)) @@ -1057,22 +1064,14 @@ export class SharedSpaceAssetSync extends BaseSync { // received. Gated by `shared_space_asset.updateId <= sharedSpaceToAssetAck` to // ensure we only emit updates for join rows the client has acked. // - // isFavorite is masked to the syncing user's own rows — see getBackfill above. + // #763: isFavorite resolves the syncing user's own overlay row — see getBackfill above. @GenerateSql({ params: [dummyQueryOptions, { updateId: DummyValue.UUID }], stream: true }) getUpdates(options: SyncQueryOptions, sharedSpaceToAssetAck: SyncAck) { const userId = options.userId; return this.upsertQuery('asset', options) .innerJoin('shared_space_asset', 'shared_space_asset.assetId', 'asset.id') .select(columns.syncSharedSpaceAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('asset.updateId') .where('shared_space_asset.updateId', '<=', sharedSpaceToAssetAck.updateId) .where('shared_space_asset.spaceId', 'in', (eb) => accessibleSpaces(eb, options.userId)) @@ -1307,23 +1306,15 @@ export class LibraryAssetSync extends BaseSync { // assets. For OTHER members' assets (reached via the space-link branch of // accessibleLibraries), only Archive and Timeline are streamed. // - // L5: isFavorite is masked to the syncing user's own rows (mirrors SharedSpaceAssetSync/ - // SharedSpaceAlbumAssetSync) — a member syncing another owner's space-linked library must not learn - // the owner's true favorite flag for an asset they don't own. + // L5/#763: isFavorite resolves the syncing user's OWN asset_favorite overlay row (mirrors + // SharedSpaceAssetSync/SharedSpaceAlbumAssetSync) — a member syncing another owner's + // space-linked library sees whether THEY favorited the asset, never the owner's favorite. @GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID, DummyValue.UUID], stream: true }) getBackfill(options: SyncBackfillOptions, libraryId: string, userId: string) { return ( this.backfillQuery('asset', options) .select(columns.syncLibraryAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('asset.updateId') .where('asset.libraryId', '=', libraryId) // M-2: the non-owner (space-link) branch also excludes trashed assets; the owner keeps their own @@ -1346,21 +1337,13 @@ export class LibraryAssetSync extends BaseSync { // as `LibraryAssetCreateV1` events; the client upserts idempotently. // // M3 visibility gate: see getBackfill above. - // L5: isFavorite masking — see getBackfill above. + // L5/#763: isFavorite resolves the syncing user's own overlay row — see getBackfill above. @GenerateSql({ params: [dummyQueryOptions], stream: true }) getUpserts(options: SyncQueryOptions) { const userId = options.userId; return this.upsertQuery('asset', options) .select(columns.syncLibraryAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('asset.updateId') .where('asset.libraryId', 'is not', null) .where('asset.libraryId', 'in', (eb) => accessibleLibraries(eb, userId)) @@ -1916,9 +1899,10 @@ class SharedSpaceAlbumToAssetSync extends BaseSync { // contributionVisibleToMember). Clone of AlbumAssetSync with the album_user // scoping swapped for the grant. // -// Preserves the split getCreates/getUpdates/getBackfill pattern, the -// isFavorite masking (false for non-owners), and the albumToAssetAck coupling -// that ensures updates only fire for assets the client already knows about. +// Preserves the split getCreates/getUpdates/getBackfill pattern, the per-user isFavorite overlay +// resolution (#763: favoriteExistsFor(eb, userId) — the syncing user's own favorite, never the +// asset owner's), and the albumToAssetAck coupling that ensures updates only fire for assets the +// client already knows about. class SharedSpaceAlbumAssetSync extends BaseSync { @GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID, DummyValue.UUID], stream: true }) getBackfill(options: SyncBackfillOptions, albumId: string, userId: string) { @@ -1930,15 +1914,7 @@ class SharedSpaceAlbumAssetSync extends BaseSync { .innerJoin('asset', 'asset.id', 'album_asset.assetId') .innerJoin('album', 'album.id', 'album_asset.albumId') .select(columns.syncAlbumAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('album_asset.updateId') .where('album_asset.albumId', '=', albumId) .where('album.deletedAt', 'is', null) @@ -1955,15 +1931,7 @@ class SharedSpaceAlbumAssetSync extends BaseSync { .innerJoin('asset', 'asset.id', 'album_space_asset.assetId') .innerJoin('album', 'album.id', 'album_space_asset.albumId') .select(columns.syncAlbumAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('album_space_asset.updateId') .where('album_space_asset.albumId', '=', albumId) .where('album.deletedAt', 'is', null) @@ -1989,15 +1957,7 @@ class SharedSpaceAlbumAssetSync extends BaseSync { .innerJoin('album_asset', 'album_asset.assetId', 'asset.id') .innerJoin('shared_space_album_user', 'shared_space_album_user.albumId', 'album_asset.albumId') .select(columns.syncAlbumAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('asset.updateId') .where('asset.updateId', '<', nowId) .$if(!!ack, (qb) => qb.where('asset.updateId', '>', ack!.updateId)) @@ -2012,15 +1972,7 @@ class SharedSpaceAlbumAssetSync extends BaseSync { .innerJoin('album_space_asset', 'album_space_asset.assetId', 'asset.id') .innerJoin('shared_space_album_user', 'shared_space_album_user.albumId', 'album_space_asset.albumId') .select(columns.syncAlbumAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('asset.updateId') .where('asset.updateId', '<', nowId) .$if(!!ack, (qb) => qb.where('asset.updateId', '>', ack!.updateId)) @@ -2044,15 +1996,7 @@ class SharedSpaceAlbumAssetSync extends BaseSync { .innerJoin('asset', 'asset.id', 'album_asset.assetId') .innerJoin('shared_space_album_user', 'shared_space_album_user.albumId', 'album_asset.albumId') .select(columns.syncAlbumAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('album_asset.updateId') .where('shared_space_album_user.userId', '=', userId) .where('album_asset.albumId', 'in', (eb) => accessibleSpaceAlbums(eb, userId)) @@ -2068,15 +2012,7 @@ class SharedSpaceAlbumAssetSync extends BaseSync { .innerJoin('asset', 'asset.id', 'album_space_asset.assetId') .innerJoin('shared_space_album_user', 'shared_space_album_user.albumId', 'album_space_asset.albumId') .select(columns.syncAlbumAsset) - .select((eb) => - eb - .case() - .when('asset.ownerId', '=', userId) - .then(eb.ref('asset.isFavorite')) - .else(eb.val(false)) - .end() - .as('isFavorite'), - ) + .select((eb) => favoriteExistsFor(eb, userId).as('isFavorite')) .select('album_space_asset.updateId') .where('shared_space_album_user.userId', '=', userId) .where('album_space_asset.albumId', 'in', (eb) => accessibleSpaceAlbums(eb, userId)) diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index 706aaccf739f70..3840a7789e9538 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -4,6 +4,7 @@ import { DummyValue, GenerateSql } from 'src/decorators.js'; import { AssetVisibility } from 'src/enum.js'; import { DB } from 'src/schema/index.js'; import { asUuid, withExif } from 'src/utils/database.js'; +import { favoriteExistsFor } from 'src/utils/favorite.js'; import { type TimelineHiddenScope, hiddenFromOwnTimeline, @@ -49,23 +50,31 @@ export class ViewRepository { ) { const normalizedPath = partialPath.replaceAll(/\/$/g, ''); - return this.db - .selectFrom('asset') - .selectAll('asset') - .$call(withExif) - .where((eb) => this.ownedOrSpaceAccessible(eb, userId, hiddenScope, visibleSpaceIds)) - .where('visibility', '=', AssetVisibility.Timeline) - .where('deletedAt', 'is', null) - .where('fileCreatedAt', 'is not', null) - .where('fileModifiedAt', 'is not', null) - .where('localDateTime', 'is not', null) - .where('originalPath', 'like', `%${normalizedPath}/%`) - .where('originalPath', 'not like', `%${normalizedPath}/%/%`) - .orderBy( - (eb) => eb.fn('regexp_replace', ['asset.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]), - 'asc', - ) - .execute(); + return ( + this.db + .selectFrom('asset') + .selectAll('asset') + .$call(withExif) + // #763: userId doubles as the caller here (this repository has only one user concept — no + // owner/caller split like the shared-space browse paths), so it's also the right id to + // project the per-user isFavorite overlay for. Gated on `.$if` (always true — userId is a + // required param) purely so Kysely infers `isFavoriteForUser` as optional, matching + // MapAsset and every other projection site instead of forcing it required here alone. + .$if(!!userId, (qb) => qb.select((eb) => favoriteExistsFor(eb, userId).as('isFavoriteForUser'))) + .where((eb) => this.ownedOrSpaceAccessible(eb, userId, hiddenScope, visibleSpaceIds)) + .where('visibility', '=', AssetVisibility.Timeline) + .where('deletedAt', 'is', null) + .where('fileCreatedAt', 'is not', null) + .where('fileModifiedAt', 'is not', null) + .where('localDateTime', 'is not', null) + .where('originalPath', 'like', `%${normalizedPath}/%`) + .where('originalPath', 'not like', `%${normalizedPath}/%/%`) + .orderBy( + (eb) => eb.fn('regexp_replace', ['asset.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]), + 'asc', + ) + .execute() + ); } // The folder explorer shows folders for assets a user can actually see: their own, diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index 920e19bba2b377..02f97734fb1a0b 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -10,6 +10,7 @@ import { WorkflowLogTable } from 'src/schema/tables/workflow-log.table.js'; import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table.js'; import { WorkflowTable } from 'src/schema/tables/workflow.table.js'; import { withTags } from 'src/utils/database.js'; +import { favoriteExistsForOwner } from 'src/utils/favorite.js'; export type WorkflowStepUpsert = Omit, 'workflowId' | 'order'>; @@ -183,6 +184,11 @@ export class WorkflowRepository { .select((eb) => [ ...columns.workflowAssetV1, withTags, + // #763: the plugin-facing `isFavorite` field is the asset OWNER's favorite — the workflow + // engine always acts AS the owner (see workflow-execution.service.ts's handleAssetTrigger) + // and there is no other viewer in scope for a background job. See favorite.ts's doc + // comment on favoriteExistsForOwner for the full owner-semantics rationale. + favoriteExistsForOwner(eb).as('isFavorite'), jsonObjectFrom( eb .selectFrom('asset_exif') diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 813370b000c0e1..d554615895a103 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -882,6 +882,29 @@ export const album_space_asset_delete_audit = registerFunction({ END`, }); +// --- gallery-fork: asset_favorite delete-audit (#763) --- +// +// Tombstones every deleted per-user favorite — explicit unfavorite AND every FK cascade (asset +// delete, user delete) — driving the favorite delete sync stream. Statement-level AFTER DELETE so +// cascades are captured too; see AssetFavoriteAuditTable. +// +// Created by migration 1794000000000; registered here so `migrations:generate` / schema-check see a +// declarative counterpart, exactly like album_space_asset_delete_audit above. Without this the +// function and both migration_overrides rows exist only in the database and every instance boots +// with `Detected schema drift` (the class of bug #827 fixed). Keep byte-identical to that +// migration's DDL — migration-override-parity.spec.ts pins it. +export const asset_favorite_delete_audit = registerFunction({ + name: 'asset_favorite_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO asset_favorite_audit ("userId", "assetId") + SELECT "userId", "assetId" FROM "old"; + RETURN NULL; + END`, +}); + // --- gallery-fork: library_user create-side trigger functions --- // // See specs/2026-04-11-library-user-access-backfill-design.md for the diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index dbcf85eb9a7144..bcdc8629ae0766 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -45,6 +45,8 @@ import { AssetEditTable } from 'src/schema/tables/asset-edit.table.js'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table.js'; import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table.js'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js'; +import { AssetFavoriteAuditTable } from 'src/schema/tables/asset-favorite-audit.table.js'; +import { AssetFavoriteTable } from 'src/schema/tables/asset-favorite.table.js'; import { AssetFileTable } from 'src/schema/tables/asset-file.table.js'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table.js'; import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.table.js'; @@ -155,6 +157,8 @@ export class ImmichDatabase { AssetEditAuditTable, AssetFaceTable, AssetFaceAuditTable, + AssetFavoriteTable, + AssetFavoriteAuditTable, AssetMetadataTable, AssetMetadataAuditTable, AssetJobStatusTable, @@ -300,6 +304,8 @@ export interface DB { asset_exif: AssetExifTable; asset_face: AssetFaceTable; asset_face_audit: AssetFaceAuditTable; + asset_favorite: AssetFavoriteTable; + asset_favorite_audit: AssetFavoriteAuditTable; asset_file: AssetFileTable; asset_metadata: AssetMetadataTable; asset_metadata_audit: AssetMetadataAuditTable; diff --git a/server/src/schema/migration-override-parity.spec.ts b/server/src/schema/migration-override-parity.spec.ts index 6e2ae6135d97ec..666d5b56ec203e 100644 --- a/server/src/schema/migration-override-parity.spec.ts +++ b/server/src/schema/migration-override-parity.spec.ts @@ -31,6 +31,7 @@ vi.mock('kysely', async () => { import { album_soft_delete_shared_space_album, album_space_asset_delete_audit, + asset_favorite_delete_audit, shared_space_album_folder_delete_audit, shared_space_album_hidden_delete_audit, shared_space_member_after_insert_album, @@ -47,6 +48,7 @@ import { up as upMemberJoinGrantCreateId } from 'src/schema/migrations-gallery/1 import { up as upRepairDrift } from 'src/schema/migrations-gallery/1784800000000-RepairSharedSpaceAlbumGrantDrift.js'; import { up as upSharedSpaceAlbumHidden } from 'src/schema/migrations-gallery/1793000000000-AddSharedSpaceAlbumHidden.js'; import { up as upSharedSpaceAlbumFolderAuditTable } from 'src/schema/migrations-gallery/1793200000000-SharedSpaceAlbumFolderAuditTable.js'; +import { up as upAddAssetFavoriteTables } from 'src/schema/migrations-gallery/1794000000000-AddAssetFavoriteTables.js'; describe('1782050000000-AddAlbumSoftDeleteSharedSpaceAlbumTrigger override parity', () => { beforeEach(() => { @@ -211,6 +213,47 @@ describe('1793200000000-SharedSpaceAlbumFolderAuditTable override parity', () => }); }); +// Same shape as 1783100000000 above: this migration creates asset_favorite_delete_audit (function + +// statement AFTER DELETE trigger) and both override rows, so functions.ts / AssetFavoriteTable must +// declare them or every DB that runs it reports FunctionDrop + two OverrideDrops — and +// `migrations:generate` emits DROP TRIGGER + DROP FUNCTION, silently killing #763's favorite delete +// sync stream. +describe('1794000000000-AddAssetFavoriteTables override parity', () => { + beforeEach(() => { + capturedSql.length = 0; + }); + + it('executes and overrides the delete-audit function with DDL byte-identical to functions.ts', async () => { + await upAddAssetFavoriteTables({} as any); + + const executedFunctionDdl = findSql('the delete-audit CREATE FUNCTION', (s) => + s.startsWith('CREATE OR REPLACE FUNCTION asset_favorite_delete_audit()'), + ); + expect(executedFunctionDdl).toBe(asset_favorite_delete_audit.expression); + + const functionOverrideInsert = findSql('the delete-audit function override row', (s) => + s.includes(`VALUES ('function_asset_favorite_delete_audit'`), + ); + expect(parseOverrideValue(functionOverrideInsert).sql).toBe(asset_favorite_delete_audit.expression); + }); + + it('stores a trigger override row byte-identical to the trigger it executes', async () => { + await upAddAssetFavoriteTables({} as any); + + const executedTriggerDdl = findSql('the delete-audit CREATE TRIGGER', (s) => + s.startsWith('CREATE OR REPLACE TRIGGER "asset_favorite_delete_audit"'), + ); + const triggerOverrideInsert = findSql('the delete-audit trigger override row', (s) => + s.includes(`VALUES ('trigger_asset_favorite_delete_audit'`), + ); + + expect(parseOverrideValue(triggerOverrideInsert).sql).toBe(executedTriggerDdl); + expect(executedTriggerDdl).toContain('REFERENCING OLD TABLE AS "old"'); + expect(executedTriggerDdl).toContain('FOR EACH STATEMENT'); + expect(executedTriggerDdl).not.toContain('WHEN ('); + }); +}); + // The repair migration re-asserts all three objects for v5.2.0-rc.0 installs whose admin acted on // the (wrong-way) drift advice. Because it writes migration_overrides rows, its DDL must match // functions.ts byte-for-byte too — otherwise the repair would itself introduce fresh drift. diff --git a/server/src/schema/migrations-gallery/1794000000000-AddAssetFavoriteTables.ts b/server/src/schema/migrations-gallery/1794000000000-AddAssetFavoriteTables.ts new file mode 100644 index 00000000000000..778bd49d9e18d4 --- /dev/null +++ b/server/src/schema/migrations-gallery/1794000000000-AddAssetFavoriteTables.ts @@ -0,0 +1,87 @@ +import { Kysely, sql } from 'kysely'; + +// Per-user favorites overlay (#763). A favorite is a fact about (user, asset), never about an +// asset alone — see docs/superpowers/specs/2026-07-20-per-user-favorites-design.md §4, §4.1, §4.3. +// +// 1. `asset_favorite` — composite PK (userId, assetId), both FKs CASCADE, plus createId/updateId +// sync watermarks. No update trigger: rows are only ever inserted or deleted, never mutated in +// place (mirrors shared_space_album_user / album_space_asset). +// 2. `asset_favorite_audit` — delete-tombstone table + a statement-level AFTER DELETE trigger, +// mirroring album_space_asset_audit. Created now, in slice 0, because slice 3 (dropping +// asset."isFavorite") is irreversible and this table must exist before that point. +// 3. Backfill every existing owner favorite from asset."isFavorite" into the overlay. Does NOT +// drop asset."isFavorite" — that is slice 3. +export async function up(db: Kysely): Promise { + // --- 1. asset_favorite --- + await sql` + CREATE TABLE "asset_favorite" ( + "userId" uuid NOT NULL REFERENCES "user"(id) ON UPDATE CASCADE ON DELETE CASCADE, + "assetId" uuid NOT NULL REFERENCES "asset"(id) ON UPDATE CASCADE ON DELETE CASCADE, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "createId" uuid NOT NULL DEFAULT immich_uuid_v7(), + "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), + CONSTRAINT "asset_favorite_pkey" PRIMARY KEY ("userId", "assetId") + ); + `.execute(db); + await sql`CREATE INDEX "asset_favorite_assetId_idx" ON "asset_favorite" ("assetId");`.execute(db); + await sql`CREATE INDEX "asset_favorite_createId_idx" ON "asset_favorite" ("createId");`.execute(db); + await sql`CREATE INDEX "asset_favorite_updateId_idx" ON "asset_favorite" ("updateId");`.execute(db); + + // --- 2. asset_favorite_audit + AFTER-DELETE statement-level trigger --- + await sql` + CREATE TABLE "asset_favorite_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7() PRIMARY KEY, + "userId" uuid NOT NULL, + "assetId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp() + ); + `.execute(db); + await sql`CREATE INDEX "asset_favorite_audit_userId_id_idx" ON "asset_favorite_audit" ("userId", "id");`.execute(db); + await sql`CREATE INDEX "asset_favorite_audit_userId_idx" ON "asset_favorite_audit" ("userId");`.execute(db); + await sql`CREATE INDEX "asset_favorite_audit_assetId_idx" ON "asset_favorite_audit" ("assetId");`.execute(db); + await sql`CREATE INDEX "asset_favorite_audit_deletedAt_idx" ON "asset_favorite_audit" ("deletedAt");`.execute(db); + + await sql`CREATE OR REPLACE FUNCTION asset_favorite_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO asset_favorite_audit ("userId", "assetId") + SELECT "userId", "assetId" FROM "old"; + RETURN NULL; + END + $$;`.execute(db); + + await sql`CREATE OR REPLACE TRIGGER "asset_favorite_delete_audit" + AFTER DELETE ON "asset_favorite" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + EXECUTE FUNCTION asset_favorite_delete_audit();`.execute(db); + + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_favorite_delete_audit', '{"type":"function","name":"asset_favorite_delete_audit","sql":"CREATE OR REPLACE FUNCTION asset_favorite_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_favorite_audit (\\"userId\\", \\"assetId\\")\\n SELECT \\"userId\\", \\"assetId\\" FROM \\"old\\";\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute( + db, + ); + + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_favorite_delete_audit', '{"type":"trigger","name":"asset_favorite_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_favorite_delete_audit\\"\\n AFTER DELETE ON \\"asset_favorite\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n EXECUTE FUNCTION asset_favorite_delete_audit();"}'::jsonb);`.execute( + db, + ); + + // --- 3. Backfill existing owner favorites. Quoted camelCase identifiers are required — unquoted + // identifiers fold to lowercase and this insert would fail against the generated schema. --- + await sql` + INSERT INTO "asset_favorite" ("userId", "assetId") + SELECT "ownerId", "id" FROM "asset" WHERE "isFavorite" = true + ON CONFLICT DO NOTHING; + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DELETE FROM "migration_overrides" WHERE "name" IN ( + 'function_asset_favorite_delete_audit', + 'trigger_asset_favorite_delete_audit' + );`.execute(db); + await sql`DROP TRIGGER IF EXISTS "asset_favorite_delete_audit" ON "asset_favorite";`.execute(db); + await sql`DROP FUNCTION IF EXISTS asset_favorite_delete_audit();`.execute(db); + await sql`DROP TABLE IF EXISTS "asset_favorite_audit";`.execute(db); + await sql`DROP TABLE IF EXISTS "asset_favorite";`.execute(db); +} diff --git a/server/src/schema/migrations-gallery/1794100000000-DropAssetIsFavoriteColumn.ts b/server/src/schema/migrations-gallery/1794100000000-DropAssetIsFavoriteColumn.ts new file mode 100644 index 00000000000000..64e8c21cec8df7 --- /dev/null +++ b/server/src/schema/migrations-gallery/1794100000000-DropAssetIsFavoriteColumn.ts @@ -0,0 +1,29 @@ +import { Kysely, sql } from 'kysely'; + +// Per-user favorites overlay (#763), slice 3 — the point of no return. The legacy +// asset."isFavorite" column has been dead weight since slice 0 backfilled it into +// `asset_favorite` (1794000000000-AddAssetFavoriteTables): every write path (updateFavorites) has +// routed through the overlay since slice 1, every read path (mapAsset's isFavoriteForUser, the +// owner-scoped job.service.ts websocket payloads, the plugin-facing workflowAssetV1 projection) has +// resolved from the overlay since slice 1b/2, and the grep gate (favorite-grep-gate.spec.ts) has +// been failing any new `asset.isFavorite` read since. `asset_favorite` is the single source of +// truth going forward — this migration removes the column it replaced. +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset" DROP COLUMN "isFavorite";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset" ADD COLUMN "isFavorite" boolean NOT NULL DEFAULT false;`.execute(db); + + // Quoted camelCase identifiers are required — unquoted identifiers fold to lowercase and this + // update would fail against the generated schema. Only the OWNER's favorite backfills, matching + // the raw column's pre-#763 semantics (it could only ever be true for the asset's owner) — see + // src/utils/favorite.ts favoriteExistsForOwner's doc comment for the same reasoning. + await sql` + UPDATE "asset" + SET "isFavorite" = true + FROM "asset_favorite" f + WHERE f."assetId" = "asset"."id" + AND f."userId" = "asset"."ownerId"; + `.execute(db); +} diff --git a/server/src/schema/migrations-gallery/ORDER b/server/src/schema/migrations-gallery/ORDER index c98b46c70e61ab..860457ae436f65 100644 --- a/server/src/schema/migrations-gallery/ORDER +++ b/server/src/schema/migrations-gallery/ORDER @@ -65,4 +65,6 @@ 1793100000000-AddSharedSpaceAlbumFolderTable 1793200000000-SharedSpaceAlbumFolderAuditTable 1793300000000-ClearPreOptionMFaceRepairScans +1794000000000-AddAssetFavoriteTables +1794100000000-DropAssetIsFavoriteColumn 1796000000000-AddAssetFaceCreatedBy diff --git a/server/src/schema/revert-to-immich.spec.ts b/server/src/schema/revert-to-immich.spec.ts index f153f1829f952f..69078ca6cf357d 100644 --- a/server/src/schema/revert-to-immich.spec.ts +++ b/server/src/schema/revert-to-immich.spec.ts @@ -31,10 +31,10 @@ for (const file of migrationFiles) { } } -// Targeted regression guard (not exhaustive, matching the original scope): the -// shared_space_* / *_audit fork tables the album + library sync slices added — where the -// drift this spec guards against actually happened. -const forkTables = [...createdTables].filter((name) => name.startsWith('shared_space') || name.endsWith('_audit')); +// Every table migrations-gallery CREATEs is a fork table and needs a revert entry. +// (Was previously narrowed to shared_space* / *_audit, which silently excluded +// fork tables outside those naming conventions — e.g. asset_favorite. #763) +const forkTables = [...createdTables]; // The step-9 guard IN-list is the parenthesised block after `tablename IN (`. const guardBlock = sql.slice(sql.indexOf('AND tablename IN (')); @@ -60,4 +60,22 @@ describe('revert-to-immich.sql', () => { const missing = migrationNames.filter((name) => !deleteBlock.includes(`'${name}'`)); expect(missing).toEqual([]); }); + + it('restores asset.isFavorite before dropping asset_favorite', () => { + // "IF NOT EXISTS" is intentionally tolerated: at the time this revert path was added + // (slice 0, #763), asset."isFavorite" has NOT yet been dropped from the live schema — + // that happens in a future slice 3 — so a bare ADD COLUMN would fail with "column + // already exists" against every Gallery DB until that slice ships. + const addColumn = sql.search(/ADD COLUMN(?: IF NOT EXISTS)? "isFavorite"/); + const backfill = sql.indexOf('FROM asset_favorite'); + const dropTable = sql.indexOf('DROP TABLE IF EXISTS "asset_favorite"'); + + expect(addColumn).toBeGreaterThan(-1); + expect(backfill).toBeGreaterThan(-1); + expect(dropTable).toBeGreaterThan(-1); + // ordering is load-bearing: the backfill reads asset_favorite, so it must + // run before the table is dropped + expect(addColumn).toBeLessThan(backfill); + expect(backfill).toBeLessThan(dropTable); + }); }); diff --git a/server/src/schema/tables/asset-favorite-audit.table.ts b/server/src/schema/tables/asset-favorite-audit.table.ts new file mode 100644 index 00000000000000..a471143c7e3340 --- /dev/null +++ b/server/src/schema/tables/asset-favorite-audit.table.ts @@ -0,0 +1,25 @@ +import { Column, CreateDateColumn, Index, Table, Timestamp } from '@immich/sql-tools'; +import type { Generated } from '@immich/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators.js'; + +// Delete-audit for the asset_favorite per-user overlay (#763). Trigger-driven +// (asset_favorite_delete_audit fires AFTER DELETE ... FOR EACH STATEMENT), so it captures both an +// explicit unfavorite and every FK cascade (asset delete, user delete). `id` is a UUIDv7 in the +// same watermark domain as the other *_audit tables — see +// docs/superpowers/specs/2026-07-20-per-user-favorites-design.md §4.3 — so slice 6's sync delete +// stream can page on it like the others. +@Table('asset_favorite_audit') +@Index({ name: 'asset_favorite_audit_userId_id_idx', columns: ['userId', 'id'] }) +export class AssetFavoriteAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + userId!: string; + + @Column({ type: 'uuid', index: true }) + assetId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/asset-favorite.table.ts b/server/src/schema/tables/asset-favorite.table.ts new file mode 100644 index 00000000000000..85dae615450e8c --- /dev/null +++ b/server/src/schema/tables/asset-favorite.table.ts @@ -0,0 +1,42 @@ +import { AfterDeleteTrigger, CreateDateColumn, ForeignKeyColumn, Table, Timestamp } from '@immich/sql-tools'; +import type { Generated } from '@immich/sql-tools'; +import { CreateIdColumn, UpdateIdColumn } from 'src/decorators.js'; +import { asset_favorite_delete_audit } from 'src/schema/functions.js'; +import { AssetTable } from 'src/schema/tables/asset.table.js'; +import { UserTable } from 'src/schema/tables/user.table.js'; + +// Per-user favorites overlay (#763). A favorite is a fact about (user, asset), never about an +// asset alone — see docs/superpowers/specs/2026-07-20-per-user-favorites-design.md §3. +// Carries its own createId/updateId watermarks (+ asset_favorite_audit) because favorites are a +// separately-synced entity: a favorite write must NOT bump the owner's asset.updateId, which +// would re-sync that asset to every space member (§4.3). +// +// PK is (userId, assetId), deliberately userId-leading — the dominant query is "my favorites". +@Table('asset_favorite') +// No `when` guard: FK cascades from asset/user deletes arrive at trigger depth > 1 and MUST still be +// tombstoned, or those favorites linger on clients. Matches migration 1794000000000's DDL exactly. +@AfterDeleteTrigger({ + scope: 'statement', + function: asset_favorite_delete_audit, + referencingOldTableAs: 'old', +}) +export class AssetFavoriteTable { + // index: false — userId is the leading column of the composite PK, so its btree already serves + // "my favorites" and a separate FK index would be redundant. The migration creates no such index; + // saying so here is what keeps the declarative schema and a migrated database in agreement + // (medium test `schema-drift.spec.ts`). Same shape as asset_duplicate_checksum. + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true, index: false }) + userId!: string; + + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true, index: true }) + assetId!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @CreateIdColumn({ index: true }) + createId!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index e65cf1a89a82e7..c63444e0eee861 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -90,9 +90,6 @@ export class AssetTable { @Column({ type: 'timestamp with time zone' }) fileModifiedAt!: Timestamp; - @Column({ type: 'boolean', default: false }) - isFavorite!: Generated; - @Column({ type: 'integer', nullable: true }) duration!: number | null; diff --git a/server/src/schema/trigger-override-parity.spec.ts b/server/src/schema/trigger-override-parity.spec.ts index ab8f3cf58190a5..32c99e5b5cdd84 100644 --- a/server/src/schema/trigger-override-parity.spec.ts +++ b/server/src/schema/trigger-override-parity.spec.ts @@ -86,3 +86,17 @@ describe('shared_space_album_folder_delete_audit trigger parity (space album fol expect(overrideSql('trigger_shared_space_album_folder_updatedAt')).toBe(expected); }); }); + +describe('asset_favorite_delete_audit trigger parity (#763, migration 1794000000000)', () => { + it('generates trigger DDL byte-identical to the statement the migration executed', () => { + // Verbatim from 1794000000000-AddAssetFavoriteTables.ts. + const expected = `CREATE OR REPLACE TRIGGER "asset_favorite_delete_audit" + AFTER DELETE ON "asset_favorite" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + EXECUTE FUNCTION asset_favorite_delete_audit();`; + + expect(overrideSql('trigger_asset_favorite_delete_audit')).toBe(expected); + expect(migrationSource('1794000000000-AddAssetFavoriteTables.ts')).toContain(expected); + }); +}); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index a525ef00c4b057..4883d5a9d78d6b 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -163,7 +163,6 @@ const assetEntity = Object.freeze({ fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), - isFavorite: false, duration: null, files: [] as AssetFile[], exifInfo: { @@ -384,6 +383,32 @@ describe(AssetMediaService.name, () => { expect.any(Date), new Date(createDto.fileModifiedAt), ); + // #763 (E19): createDto.isFavorite is false — no overlay row for an unfavorited upload, and + // the create payload no longer carries a raw isFavorite column at all. + expect(mocks.assetFavorite.addAll).not.toHaveBeenCalled(); + expect(mocks.asset.create).not.toHaveBeenCalledWith(expect.objectContaining({ isFavorite: expect.anything() })); + }); + + it('should favorite the upload for the uploader when isFavorite is true (E19)', async () => { + const file = { + uuid: 'random-uuid', + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + size: 42, + }; + + mocks.asset.create.mockResolvedValue(assetEntity); + mocks.assetFavorite.addAll.mockResolvedValue(void 0); + + await expect(sut.uploadAsset(authStub.user1, { ...createDto, isFavorite: true }, file)).resolves.toEqual({ + id: 'id_1', + status: AssetMediaStatus.CREATED, + }); + + // The uploader is always the owner (E19) — the overlay row is created for auth.user.id. + expect(mocks.assetFavorite.addAll).toHaveBeenCalledWith(authStub.user1.user.id, [assetEntity.id]); }); it('should remove the asset row when post-create processing fails', async () => { diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 69b0b467c69fc1..55195f4d64ad3c 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -370,7 +370,6 @@ export class AssetMediaService extends BaseService { localDateTime: dto.fileCreatedAt, type: mimeTypes.assetType(file.originalPath), - isFavorite: dto.isFavorite, duration: dto.duration || null, visibility: dto.visibility ?? AssetVisibility.Timeline, livePhotoVideoId: dto.livePhotoVideoId, @@ -381,6 +380,18 @@ export class AssetMediaService extends BaseService { let writeBackend: StorageBackend | undefined; try { + // #763 (E19): the uploader is always the owner, so this is the uploader's own overlay + // row. Placed as the first statement in this try block — not before it — so a failure + // here (or in anything after it) hits the same catch below, which removes the just-created + // asset row. That DELETE cascades onto asset_favorite via its FK, so no partial state + // (an orphaned favorite row for a rolled-back asset) can survive an upload failure. This + // whole method is a compensating-action pattern rather than a real DB transaction already + // (S3 upload + job queueing happen alongside the writes), so joining that existing + // contract is more consistent than opening a separate transaction just for this insert. + if (dto.isFavorite) { + await this.assetFavoriteRepository.addAll(ownerId, [asset.id]); + } + if (dto.metadata?.length) { await this.assetRepository.upsertMetadata(asset.id, dto.metadata); } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index d53e0917d3b374..77be2b6f0bde7d 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -63,6 +63,12 @@ describe(AssetService.name, () => { // dedicated EXIF-restore emit (asset_exif.updateId is untouched by a visibility flip). mocks.sharedSpace.emitLibraryAssetVisibilityPurge.mockResolvedValue(void 0); mocks.sharedSpace.emitLibraryAssetVisibilityRestore.mockResolvedValue(void 0); + + // #763: update/updateAll now reroute the deprecated `isFavorite` alias through + // AssetFavoriteRepository instead of writing the asset column directly; default to a no-op so + // unrelated update/updateAll tests that merely pass isFavorite through a fixture don't break. + mocks.assetFavorite.addAll.mockResolvedValue(void 0); + mocks.assetFavorite.removeAll.mockResolvedValue(void 0); }); describe('getStatistics', () => { @@ -136,6 +142,17 @@ describe(AssetService.name, () => { ); }); + // #763: a shared-link session's auth.user is the link owner — their favorites must not leak. + it('does not project the link owner favorite for a shared link session (#763)', async () => { + const asset = AssetFactory.create(); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); + + await sut.get(authStub.adminSharedLink, asset.id); + + expect(mocks.asset.getById).toHaveBeenCalledWith(asset.id, expect.anything(), undefined); + }); + it('should strip metadata for shared link if exif is disabled', async () => { const asset = AssetFactory.from().exif({ description: 'foo' }).build(); mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); @@ -671,6 +688,71 @@ describe(AssetService.name, () => { }); }); + describe('updateFavorites', () => { + it('rejects a shared-link session outright — no repository write happens (E6, §5.1)', async () => { + const auth = AuthFactory.from().sharedLink().build(); + + await expect(sut.updateFavorites(auth, { ids: ['asset-1'], isFavorite: true })).rejects.toBeInstanceOf( + BadRequestException, + ); + + expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled(); + expect(mocks.assetFavorite.addAll).not.toHaveBeenCalled(); + expect(mocks.assetFavorite.removeAll).not.toHaveBeenCalled(); + }); + + it('requires read access to every id — denial creates no row', async () => { + const auth = AuthFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); + + await expect(sut.updateFavorites(auth, { ids: ['asset-1'], isFavorite: true })).rejects.toBeInstanceOf( + BadRequestException, + ); + + expect(mocks.assetFavorite.addAll).not.toHaveBeenCalled(); + }); + + it('calls addAll for the CALLER only when isFavorite is true', async () => { + const auth = AuthFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + + await sut.updateFavorites(auth, { ids: ['asset-1', 'asset-2'], isFavorite: true }); + + expect(mocks.assetFavorite.addAll).toHaveBeenCalledWith(auth.user.id, ['asset-1', 'asset-2']); + expect(mocks.assetFavorite.removeAll).not.toHaveBeenCalled(); + }); + + it('calls removeAll for the CALLER only when isFavorite is false', async () => { + const auth = AuthFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + + await sut.updateFavorites(auth, { ids: ['asset-1'], isFavorite: false }); + + expect(mocks.assetFavorite.removeAll).toHaveBeenCalledWith(auth.user.id, ['asset-1']); + expect(mocks.assetFavorite.addAll).not.toHaveBeenCalled(); + }); + + it('favorites an asset the caller only has space-read (not owner/editor) access to (E3)', async () => { + const auth = AuthFactory.create(); + mocks.access.asset.checkSpaceAccess.mockResolvedValue(new Set(['asset-1'])); + + await sut.updateFavorites(auth, { ids: ['asset-1'], isFavorite: true }); + + expect(mocks.assetFavorite.addAll).toHaveBeenCalledWith(auth.user.id, ['asset-1']); + }); + + it('elevated session permission does not change WHO the write is attributed to (E7)', async () => { + const auth = AuthFactory.from().session({ hasElevatedPermission: true }).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + + await sut.updateFavorites(auth, { ids: ['asset-1'], isFavorite: true }); + + // Still always the caller — elevated permission only ever widens the caller's OWN read + // access (e.g. their own Locked assets), never lets them write on someone else's behalf. + expect(mocks.assetFavorite.addAll).toHaveBeenCalledWith(auth.user.id, ['asset-1']); + }); + }); + describe('update', () => { it('should require asset write access for the id', async () => { await expect( @@ -689,7 +771,11 @@ describe(AssetService.name, () => { await sut.update(authStub.admin, asset.id, { isFavorite: true }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, isFavorite: true }); + // #763: isFavorite is stripped from the direct column write and rerouted to the caller's own + // AssetFavoriteRepository row — the update return path still threads the caller's id to + // project isFavoriteForUser from the (now up to date) asset_favorite table. + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id }, authStub.admin.user.id); + expect(mocks.assetFavorite.addAll).toHaveBeenCalledWith(authStub.admin.user.id, [asset.id]); }); it('should update the exif description', async () => { @@ -827,7 +913,11 @@ describe(AssetService.name, () => { expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, visibility: AssetVisibility.Hidden }); expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', { assetId: motionAsset.id, userId: auth.user.id }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, livePhotoVideoId: motionAsset.id }); + // #763: the update return path now threads the caller's id to project isFavoriteForUser. + expect(mocks.asset.update).toHaveBeenCalledWith( + { id: stillAsset.id, livePhotoVideoId: motionAsset.id }, + auth.user.id, + ); }); it('should throw an error if asset could not be found after update', async () => { @@ -854,10 +944,14 @@ describe(AssetService.name, () => { await sut.update(auth, asset.id, { livePhotoVideoId: null }); - expect(mocks.asset.update).toHaveBeenCalledWith({ - id: asset.id, - livePhotoVideoId: null, - }); + // #763: the update return path now threads the caller's id to project isFavoriteForUser. + expect(mocks.asset.update).toHaveBeenCalledWith( + { + id: asset.id, + livePhotoVideoId: null, + }, + auth.user.id, + ); expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, visibility: asset.visibility, @@ -892,7 +986,8 @@ describe(AssetService.name, () => { await sut.update(auth, asset.id, { livePhotoVideoId: null }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, livePhotoVideoId: null }); + // #763: the update return path now threads the caller's id to project isFavoriteForUser. + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, livePhotoVideoId: null }, auth.user.id); }); it('should update latitude and longitude', async () => { @@ -1052,17 +1147,22 @@ describe(AssetService.name, () => { expect(mocks.asset.update).not.toHaveBeenCalled(); }); - it('rejects a single-PUT favorite change on a non-owned (space-edit) asset (rbac-3)', async () => { + // #763: the alias writes only the caller's own overlay row — never the owner's — so it is not owner-gated, + // and a favorite-only write is private state, not an edit to attribute in the space feed. + it('lets a space editor favorite a non-owned asset via the single-PUT alias without logging an edit (#763)', async () => { const auth = AuthFactory.create(); const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); mocks.sharedSpace.findSpaceForAssetAndUser.mockResolvedValue(void 0 as any); mocks.asset.getById.mockResolvedValue(getForAsset(asset)); + mocks.asset.update.mockResolvedValue(getForAsset(asset)); mocks.access.asset.checkSpaceEditAccess.mockResolvedValue(new Set([asset.id])); + mocks.access.asset.checkSpaceAccess.mockResolvedValue(new Set([asset.id])); - await expect(sut.update(auth, asset.id, { isFavorite: true })).rejects.toBeInstanceOf(ForbiddenException); + await sut.update(auth, asset.id, { isFavorite: true }); - expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.assetFavorite.addAll).toHaveBeenCalledWith(auth.user.id, [asset.id]); + expect(mocks.sharedSpace.logActivity).not.toHaveBeenCalled(); }); it('allows changing visibility on an asset the caller owns even as a space editor (rbac-3)', async () => { @@ -1072,9 +1172,14 @@ describe(AssetService.name, () => { mocks.asset.getById.mockResolvedValue(getForAsset(asset)); mocks.asset.update.mockResolvedValue({ ...getForAsset(asset), visibility: AssetVisibility.Archive }); - await sut.update(AuthFactory.create({ id: asset.ownerId }), asset.id, { visibility: AssetVisibility.Archive }); + const auth = AuthFactory.create({ id: asset.ownerId }); + await sut.update(auth, asset.id, { visibility: AssetVisibility.Archive }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, visibility: AssetVisibility.Archive }); + // #763: the update return path now threads the caller's id to project isFavoriteForUser. + expect(mocks.asset.update).toHaveBeenCalledWith( + { id: asset.id, visibility: AssetVisibility.Archive }, + auth.user.id, + ); }); }); @@ -1146,30 +1251,33 @@ describe(AssetService.name, () => { expect(mocks.asset.updateAllExif).toHaveBeenCalledWith(['asset-2'], { rating: 3 }); }); - // isFavorite is still one shared column, so an editor's heart would flip the owner's own favorite; - // duplicateId is the owner's duplicate clean-up. Neither is part of the space-editor grant. - it.each([ - { field: 'isFavorite', dto: { isFavorite: true } }, - { field: 'duplicateId', dto: { duplicateId: null } }, - ])('rejects a bulk $field change that includes a non-owned (space-edit) asset (rbac-3)', async ({ dto }) => { + // duplicateId is the owner's duplicate clean-up — not part of the space-editor grant. + it('rejects a bulk duplicateId change that includes a non-owned (space-edit) asset (rbac-3)', async () => { const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.access.asset.checkSpaceEditAccess.mockResolvedValue(new Set(['asset-2'])); - await expect(sut.updateAll(auth, { ids: ['asset-1', 'asset-2'], ...dto })).rejects.toBeInstanceOf( + await expect(sut.updateAll(auth, { ids: ['asset-1', 'asset-2'], duplicateId: null })).rejects.toBeInstanceOf( ForbiddenException, ); expect(mocks.asset.updateAll).not.toHaveBeenCalled(); }); - it('allows a bulk favorite change on assets the caller owns (rbac-3)', async () => { + // #763: the deprecated isFavorite alias writes only the caller's own overlay row, so a space editor + // favoriting a member's asset is allowed — and, being private state, it is not logged as an edit. + it('lets a space editor favorite a non-owned asset via the alias without logging a cross-owner edit (#763)', async () => { const auth = AuthFactory.create(); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.asset.checkSpaceEditAccess.mockResolvedValue(new Set(['asset-2'])); + mocks.access.asset.checkSpaceAccess.mockResolvedValue(new Set(['asset-2'])); - await sut.updateAll(auth, { ids: ['asset-1', 'asset-2'], isFavorite: true }); + await sut.updateAll(auth, { ids: ['asset-2'], isFavorite: true }); - expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isFavorite: true }); + expect(mocks.assetFavorite.addAll).toHaveBeenCalledWith(auth.user.id, ['asset-2']); + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); + expect(mocks.sharedSpace.logActivity).not.toHaveBeenCalled(); + expect(mocks.sharedSpace.findSpaceForAssetAndUser).not.toHaveBeenCalled(); }); it('should not update Assets table if no relevant fields are provided', async () => { @@ -1221,11 +1329,12 @@ describe(AssetService.name, () => { latitude: 30, longitude: 50, dateTimeOriginal, - isFavorite: false, duplicateId: undefined, rating: undefined, }); - expect(mocks.asset.updateAll).toHaveBeenCalled(); + // #763: no visibility/duplicateId set → assetDto is empty → assetRepository.updateAll is + // correctly NOT called (isFavorite no longer keeps it non-empty, see the test above). + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); expect(mocks.asset.updateAllExif).toHaveBeenCalledWith(['asset-1'], { dateTimeOriginal, latitude: 30, @@ -2206,7 +2315,44 @@ describe(AssetService.name, () => { sourceAssetId: sourceId, targetAssetId: targetId, }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: targetId, isFavorite: true }); + // #763 (E20): getForCopy's isFavorite is the ACTING user's own overlay row (see + // asset.repository.ts), so the copy writes only auth.user.id's row for the target. + expect(mocks.asset.getForCopy).toHaveBeenCalledWith(sourceId, authStub.admin.user.id); + expect(mocks.asset.getForCopy).toHaveBeenCalledWith(targetId, authStub.admin.user.id); + expect(mocks.assetFavorite.addAll).toHaveBeenCalledWith(authStub.admin.user.id, [targetId]); + expect(mocks.assetFavorite.removeAll).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + }); + + it('should clear the target favorite when the acting user did not favorite the source', async () => { + // #763 (E20): favorite:true (the default) still means "copy the status", including a + // false status — mirrors the old column-overwrite semantics, just scoped to the actor. + const sourceId = newUuid(); + const targetId = newUuid(); + const sourceAsset = { + id: sourceId, + stackId: null, + isFavorite: false, + files: [], + originalPath: '/data/source.jpg', + }; + const targetAsset = { + id: targetId, + stackId: null, + isFavorite: true, + files: [], + originalPath: '/data/target.jpg', + }; + + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sourceId, targetId])); + mocks.asset.getForCopy.mockResolvedValueOnce(sourceAsset); + mocks.asset.getForCopy.mockResolvedValueOnce(targetAsset); + mocks.sharedLinkAsset.copySharedLinks.mockResolvedValue(void 0 as any); + + await sut.copy(authStub.admin, { sourceId, targetId }); + + expect(mocks.assetFavorite.removeAll).toHaveBeenCalledWith(authStub.admin.user.id, [targetId]); + expect(mocks.assetFavorite.addAll).not.toHaveBeenCalled(); }); it('should skip albums copy when albums flag is false', async () => { @@ -2289,7 +2435,8 @@ describe(AssetService.name, () => { await sut.copy(authStub.admin, { sourceId, targetId, favorite: false }); - expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isFavorite: true })); + expect(mocks.assetFavorite.addAll).not.toHaveBeenCalled(); + expect(mocks.assetFavorite.removeAll).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 0218f277d43a23..8dec4ff19f0705 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -17,6 +17,7 @@ import { AssetCopyDto, AssetEditableDto, AssetEditableResponseDto, + AssetFavoriteUpdateDto, AssetJobName, AssetJobsDto, AssetMetadataBulkDeleteDto, @@ -60,6 +61,7 @@ import { } from 'src/utils/asset.util.js'; import { isDeadlockError, retryOnDeadlock, updateLockedColumns } from 'src/utils/database.js'; import { asDateTimeString, extractTimeZone } from 'src/utils/date.js'; +import { favoriteViewerId } from 'src/utils/favorite.js'; import { batched, findOrFail } from 'src/utils/misc.js'; import { applyResolvedIdentityMetadata } from 'src/utils/person-identity.js'; import { transformOcrBoundingBox } from 'src/utils/transform.js'; @@ -87,14 +89,21 @@ export class AssetService extends BaseService { async get(auth: AuthDto, id: string, spaceId?: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); - const asset = await this.assetRepository.getById(id, { - exifInfo: true, - owner: true, - faces: { person: true, viewingUserId: auth.user.id }, - stack: { assets: true }, - edits: true, - tags: true, - }); + // #763: the isFavoriteForUser overlay is resolved for the CALLER. A shared-link session has no + // caller of its own — auth.user is the link OWNER — so favoriteViewerId returns undefined there + // and the response reports false rather than the owner's private favorites. + const asset = await this.assetRepository.getById( + id, + { + exifInfo: true, + owner: true, + faces: { person: true, viewingUserId: auth.user.id }, + stack: { assets: true }, + edits: true, + tags: true, + }, + favoriteViewerId(auth), + ); if (!asset) { throw new BadRequestException('Asset not found'); @@ -242,6 +251,34 @@ export class AssetService extends BaseService { } } + /** + * #763 canonical write surface for per-user favorites (PUT /assets/favorites), also called by + * the deprecated `isFavorite` alias on `update`/`updateAll` (§8.1) — the alias keeps its own, + * STRICTER Permission.AssetUpdate route guard, so it can only ever be narrower than this method, + * never wider. See docs/superpowers/specs/2026-07-20-per-user-favorites-design.md §5.1. + */ + async updateFavorites(auth: AuthDto, dto: AssetFavoriteUpdateDto): Promise { + // §5.1: a shared-link session's `auth.user` is the LINK OWNER's identity, not an authenticated + // caller (AuthDto.user is non-optional; sharedLink is the optional field that marks this case — + // see auth.dto.ts:16,18). Without this explicit guard, an anonymous visitor holding a share link + // would create asset_favorite rows attributed to the owner: an unauthenticated write in a real + // user's name. No route on this DTO declares `sharedLink: true`, but this guard is deliberately + // NOT relied on that route metadata alone — it must hold even if that ever changes. + if (auth.sharedLink) { + throw new BadRequestException('Shared link sessions cannot set favorites'); + } + + // The subject is ALWAYS the caller (E4/E7): nothing in AssetFavoriteUpdateDto can name another + // user, and elevated session permission (auth.session.hasElevatedPermission) never widens this — + // it only ever grants the CALLER extra read access (e.g. their own Locked assets), never lets + // them write on someone else's behalf. + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: dto.ids }); + + await (dto.isFavorite + ? this.assetFavoriteRepository.addAll(auth.user.id, dto.ids) + : this.assetFavoriteRepository.removeAll(auth.user.id, dto.ids)); + } + async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); @@ -251,18 +288,16 @@ export class AssetService extends BaseService { // not own; other metadata (description/rating/…) stays editor-allowed. Runs BEFORE the livePhotoVideoId // link/unlink side-effects and the visibility transition helper below. // - // isFavorite too: it is still one column on `asset`, so an editor's heart would flip the OWNER's own - // favorite. It stops mattering once favorites are per-user (#819), which moves them off this endpoint. - if (dto.visibility !== undefined || dto.livePhotoVideoId !== undefined || dto.isFavorite !== undefined) { + // isFavorite is NOT owner-gated (#763): it writes only the caller's own asset_favorite row via + // updateFavorites, never the owner's state. + if (dto.visibility !== undefined || dto.livePhotoVideoId !== undefined) { const ownedIds = await this.checkAccess({ auth, permission: Permission.AssetDelete, ids: [id] }); if (!ownedIds.has(id)) { - throw new ForbiddenException( - 'Visibility, favorite and live-photo linkage can only be changed on assets you own', - ); + throw new ForbiddenException('Visibility and live-photo linkage can only be changed on assets you own'); } } - const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; + const { description, dateTimeOriginal, latitude, longitude, rating, isFavorite, ...rest } = dto; const repos = { asset: this.assetRepository, event: this.eventRepository }; let previousMotion: { id: string } | null = null; @@ -285,7 +320,17 @@ export class AssetService extends BaseService { priorVisibility = priorAsset?.visibility; } - const asset = await this.assetRepository.update({ id, ...rest }); + // #763: isFavorite is the deprecated alias for PUT /assets/favorites (§8.1) — route it through + // the SAME updateFavorites logic so the alias can never be more permissive than the canonical + // endpoint. Written BEFORE the repository read below so the isFavoriteForUser it projects for + // the response reflects the caller's own just-written state, not the pre-write value. + if (isFavorite !== undefined) { + await this.updateFavorites(auth, { ids: [id], isFavorite }); + } + + // #763: auth.user.id is the CALLER — this endpoint requires Permission.AssetUpdate (never + // sharedLink: true, see asset.controller.ts), so auth.user.id is always a genuine caller here. + const asset = await this.assetRepository.update({ id, ...rest }, auth.user.id); if (previousMotion && asset) { await onAfterUnlink(repos, { @@ -314,7 +359,11 @@ export class AssetService extends BaseService { // would misclassify the owner's own lock as a cross-owner edit. A visibility write can never // legitimately be cross-owner (the guard above is total for it), so skip rather than rely on // logCrossOwnerEdit's own owner check to get this right post-write. - if (dto.visibility === undefined) { + // + // #763: a favorite-only write is the caller's private state, not an edit of the asset — logging it would + // tell the space that someone favorited a member's photo. + const { isFavorite: _isFavorite, ...edits } = dto; + if (dto.visibility === undefined && Object.values(edits).some((value) => value !== undefined)) { await this.logCrossOwnerEdit(auth, [id]); } @@ -370,19 +419,24 @@ export class AssetService extends BaseService { // AssetDelete == the pure owner arm (checkOwnerAccess, same hasElevatedPermission as the AssetUpdate gate's // isOwner sub-check); a library-backed asset owned by another user is correctly NOT returned as owned. // - // isFavorite and duplicateId take the same guard. isFavorite is still one shared column (see update()), - // and duplicateId is the owner's own duplicate clean-up: an editor must not pull a member's asset into or - // out of a duplicate group. Neither is part of the space-editor grant. - if (visibility !== undefined || isFavorite !== undefined || duplicateId !== undefined) { + // duplicateId takes the same guard: it is the owner's own duplicate clean-up, and an editor must not pull a + // member's asset into or out of a duplicate group. isFavorite is NOT owner-gated (#763): it writes only the + // caller's own asset_favorite row via updateFavorites, never the owner's state. + if (visibility !== undefined || duplicateId !== undefined) { const ownedIds = await this.checkAccess({ auth, permission: Permission.AssetDelete, ids }); if (ownedIds.size !== new Set(ids).size) { - throw new ForbiddenException( - 'Visibility, favorite and duplicate grouping can only be changed on assets you own', - ); + throw new ForbiddenException('Visibility and duplicate grouping can only be changed on assets you own'); } } - const assetDto = omitBy({ isFavorite, visibility, duplicateId }, isUndefined); + // #763: isFavorite is the deprecated alias for PUT /assets/favorites (§8.1) — it no longer goes + // into the plain column write (assetDto below). Routed through the SAME updateFavorites logic + // as the canonical endpoint, so the alias can never be more permissive. + if (isFavorite !== undefined) { + await this.updateFavorites(auth, { ids, isFavorite }); + } + + const assetDto = omitBy({ visibility, duplicateId }, isUndefined); // When latitude/longitude are updated in bulk, reverse-geocode once so country/state/city // stay in sync across all selected assets. See updateExif() for the rationale. @@ -442,7 +496,10 @@ export class AssetService extends BaseService { // requires the caller to own every id when visibility is set, so a visibility write can never // legitimately be cross-owner, and logCrossOwnerEdit's own owner check would misclassify a // just-Locked asset (checkOwnerAccess reads it back as not-owned for a non-elevated session). - if (visibility === undefined) { + // + // #763: a favorite-only write is the caller's private state, not an edit — see update(). + const { ids: _ids, isFavorite: _isFavorite, ...edits } = dto; + if (visibility === undefined && Object.values(edits).some((value) => value !== undefined)) { await this.logCrossOwnerEdit(auth, ids); } } @@ -527,8 +584,10 @@ export class AssetService extends BaseService { }: AssetCopyDto, ) { await this.requireAccess({ auth, permission: Permission.AssetCopy, ids: [sourceId, targetId] }); - const sourceAsset = await this.assetRepository.getForCopy(sourceId); - const targetAsset = await this.assetRepository.getForCopy(targetId); + // #763 (E20): `getForCopy`'s `isFavorite` is the ACTING user's overlay row, not the owner's — + // see asset.repository.ts. Copy carries only that, never every user who favorited the source. + const sourceAsset = await this.assetRepository.getForCopy(sourceId, auth.user.id); + const targetAsset = await this.assetRepository.getForCopy(targetId, auth.user.id); if (!sourceAsset || !targetAsset) { throw new BadRequestException('Both assets must exist'); @@ -551,7 +610,14 @@ export class AssetService extends BaseService { } if (favorite) { - await this.assetRepository.update({ id: targetId, isFavorite: sourceAsset.isFavorite }); + // Semantic sharpening (#763, E20), not a translation of the old column-update: the old + // code copied the source asset's GLOBAL isFavorite column; this copies the ACTING user's + // own favorite of the source onto the target, mirroring the old "set to whatever the + // source's value is" overwrite behaviour but scoped to auth.user.id only. Another user who + // also favorited the source is untouched — they never had a say in this copy. + await (sourceAsset.isFavorite + ? this.assetFavoriteRepository.addAll(auth.user.id, [targetId]) + : this.assetFavoriteRepository.removeAll(auth.user.id, [targetId])); } if (sidecar) { diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index b125149b3d150a..0339b3eede5d26 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -21,6 +21,7 @@ import { AlbumRepository } from 'src/repositories/album.repository.js'; import { ApiKeyRepository } from 'src/repositories/api-key.repository.js'; import { AppRepository } from 'src/repositories/app.repository.js'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { AssetFileRepository } from 'src/repositories/asset-file.repository.js'; import { AssetJobRepository } from 'src/repositories/asset-job.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; @@ -109,6 +110,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ AppRepository, AssetRepository, AssetEditRepository, + AssetFavoriteRepository, AssetFileRepository, AssetJobRepository, ClusterGroupRepository, @@ -182,6 +184,7 @@ export class BaseService { protected appRepository: AppRepository, protected assetRepository: AssetRepository, protected assetEditRepository: AssetEditRepository, + protected assetFavoriteRepository: AssetFavoriteRepository, protected assetFileRepository: AssetFileRepository, protected assetJobRepository: AssetJobRepository, protected clusterGroupRepository: ClusterGroupRepository, @@ -276,6 +279,7 @@ export class BaseService { ctx.appRepository, ctx.assetRepository, ctx.assetEditRepository, + ctx.assetFavoriteRepository, ctx.assetFileRepository, ctx.assetJobRepository, ctx.clusterGroupRepository, diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 21f11676624ced..94505b54b265bc 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -57,6 +57,10 @@ describe(DuplicateService.name, () => { // Default to "no editable spaces" so the new merge branch is a no-op for // existing tests. Tests that exercise the merge override per case. mocks.sharedSpace.getEditableByAssetIds.mockResolvedValue(new Set()); + // #763 (E21): default no-op so existing tests that hit the metadata-merge branch + // (idsToKeep.length === 1 && idsToTrash.length > 0) don't need to know about the favorite + // union unless they're specifically asserting on it. + mocks.assetFavorite.mergeOnto.mockResolvedValue(undefined as any); }); it('should work', () => { @@ -449,7 +453,7 @@ describe(DuplicateService.name, () => { }); it('should not merge metadata when multiple assets are kept', async () => { - const asset1 = AssetFactory.create({ isFavorite: true }); + const asset1 = AssetFactory.create(); const asset2 = AssetFactory.create(); mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); mocks.duplicateRepository.get.mockResolvedValue({ diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 502639cef54eef..4714acbd62c32f 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -13,7 +13,6 @@ import { batched, isDuplicateDetectionEnabled } from 'src/utils/misc.js'; type ResolveRequest = { assetUpdate: { - isFavorite?: boolean; visibility?: AssetVisibility; }; @@ -71,6 +70,10 @@ export class DuplicateService extends BaseService { const duplicates = await this.duplicateRepository.getAll(auth.user.id); return duplicates.map(({ duplicateId, assets }) => { + // #763: deliberately NOT projecting isFavoriteForUser — duplicateRepository.getAll doesn't + // project the overlay yet, so this stays `false` for now. Slice 7 covers merging favorite + // state across duplicate resolution (resolveGroup, below); wiring the overlay into this + // listing view remains open and is not part of that slice. const mappedAssets = assets.map((asset) => mapAsset(asset, { auth })); return { duplicateId, @@ -200,6 +203,15 @@ export class DuplicateService extends BaseService { assetAlbumMap, ); + // #763 (E21): per-user union of every trashed source's favorite rows onto the keeper, run + // BEFORE idsToTrash are marked deleted below. Duplicate detection is owner-scoped + // (all group assets share an owner), but OTHER users can have favorited these assets via + // space access — the old `assets.some((asset) => asset.isFavorite)` boolean-OR could only + // ever express the owner's favorite and silently dropped everyone else's. `onConflict do + // nothing` in mergeOnto absorbs both re-runs and a user who favorited more than one source + // (E8: exactly one keeper row, never a PK violation). + await this.assetFavoriteRepository.mergeOnto(idsToKeep[0], idsToTrash); + if (mergedAlbumIds.length > 0) { const allowedAlbumIds = await this.checkAccess({ auth, @@ -301,8 +313,6 @@ export class DuplicateService extends BaseService { exifUpdate: {}, }; - response.assetUpdate.isFavorite = assets.some((asset) => asset.isFavorite); - // R1: `assets` always comes from duplicateRepository.get, which applies withDefaultVisibility // (Archive+Timeline only) — a duplicate group/keeper can never contain a Locked or Hidden asset, so // there is no branch for either here. Follow-up (out of scope for this cleanup): route the diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index f89cccff54b199..c0a3525bb4ecbf 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -194,7 +194,11 @@ export class JobService extends BaseService { duration: asset.duration, type: asset.type, deletedAt: asset.deletedAt, - isFavorite: asset.isFavorite, + // #763: OWNER-scoped — assetRepository.getById/getByIdsWithAllRelationsButStacks + // project `isFavorite` from favoriteExistsForOwner (SqlBool), not a plain boolean; + // both events here are always sent to the asset's owner, so this IS the correct + // value, just needing the same `!!` normalization mapAsset applies elsewhere. + isFavorite: !!asset.isFavorite, visibility: asset.visibility, livePhotoVideoId: asset.livePhotoVideoId, stackId: asset.stackId, @@ -243,6 +247,9 @@ export class JobService extends BaseService { !asset.isExternal && (asset.visibility === AssetVisibility.Timeline || asset.visibility === AssetVisibility.Archive) ) { + // #763: deliberately NOT projecting isFavoriteForUser — this is a background job with no + // auth (the asset came from getByIdsWithAllRelationsButStacks called with no authUserId + // above), so `false` is correct and matches pre-#763 behavior for this synthetic path. this.websocketRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset)); if (asset.exifInfo) { const exif = asset.exifInfo; @@ -261,7 +268,11 @@ export class JobService extends BaseService { duration: asset.duration, type: asset.type, deletedAt: asset.deletedAt, - isFavorite: asset.isFavorite, + // #763: OWNER-scoped — assetRepository.getById/getByIdsWithAllRelationsButStacks + // project `isFavorite` from favoriteExistsForOwner (SqlBool), not a plain boolean; + // both events here are always sent to the asset's owner, so this IS the correct + // value, just needing the same `!!` normalization mapAsset applies elsewhere. + isFavorite: !!asset.isFavorite, visibility: asset.visibility, livePhotoVideoId: asset.livePhotoVideoId, stackId: asset.stackId, diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index fca9c41787ab20..d88f4671779109 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -153,19 +153,21 @@ describe(MapService.name, () => { ); }); - it('should not resolve space IDs when isFavorite=true', async () => { + it('resolves space IDs even when isFavorite=true (#763 slice 4)', async () => { const auth = AuthFactory.create(); + const spaceId = '00000000-0000-4000-8000-000000000004'; mocks.partner.getAll.mockResolvedValue([]); + mocks.sharedSpace.getSpaceIdsForTimeline.mockResolvedValue([{ spaceId }]); mocks.map.getMapMarkers.mockResolvedValue([]); await sut.getMapMarkers(auth, { withSharedSpaces: true, isFavorite: true }); - expect(mocks.sharedSpace.getSpaceIdsForTimeline).not.toHaveBeenCalled(); + expect(mocks.sharedSpace.getSpaceIdsForTimeline).toHaveBeenCalledWith(auth.user.id); expect(mocks.map.getMapMarkers).toHaveBeenCalledWith( auth.user.id, [auth.user.id], expect.anything(), - expect.not.objectContaining({ timelineSpaceIds: expect.anything() }), + expect.objectContaining({ timelineSpaceIds: [spaceId], isFavorite: true }), ); }); diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index fa43704887d434..f48402b418ecff 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -23,7 +23,7 @@ export class MapService extends BaseService { fileCreatedAfter: options.fileCreatedAfter, }; - if ((options.withSharedSpaces || options.withSharedAlbums) && options.isFavorite !== true) { + if (options.withSharedSpaces || options.withSharedAlbums) { const spaceRows = await this.sharedSpaceRepository.getSpaceIdsForTimeline(auth.user.id); if (spaceRows.length > 0) { searchOptions.timelineSpaceIds = spaceRows.map((row) => row.spaceId); diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index ab3bcf148d9a51..867ef47a602658 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -1623,6 +1623,7 @@ describe(MemoryService.name, () => { }), ).resolves.toMatchObject({ assets: [] }); + // #763: create threads the caller's id to project isFavoriteForUser. expect(mocks.memory.create).toHaveBeenCalledWith( { type: memory.type, @@ -1632,6 +1633,7 @@ describe(MemoryService.name, () => { isSaved: memory.isSaved, }, new Set(), + userId, ); }); @@ -1652,9 +1654,11 @@ describe(MemoryService.name, () => { }), ).resolves.toBeDefined(); + // #763: create threads the caller's id to project isFavoriteForUser. expect(mocks.memory.create).toHaveBeenCalledWith( expect.objectContaining({ ownerId: userId }), new Set([assetId]), + userId, ); }); @@ -1691,6 +1695,7 @@ describe(MemoryService.name, () => { seenAt, }); + // #763: create threads the caller's id to project isFavoriteForUser. expect(mocks.memory.create).toHaveBeenCalledWith( expect.objectContaining({ ownerId: userId, @@ -1700,6 +1705,7 @@ describe(MemoryService.name, () => { seenAt, }), new Set(), + userId, ); }); }); @@ -1715,37 +1721,47 @@ describe(MemoryService.name, () => { it('should update a memory', async () => { const memory = MemoryFactory.create(); + const auth = factory.auth(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.memory.update.mockResolvedValue(getForMemory(memory)); - await expect(sut.update(factory.auth(), memory.id, { isSaved: true })).resolves.toBeDefined(); + await expect(sut.update(auth, memory.id, { isSaved: true })).resolves.toBeDefined(); - expect(mocks.memory.update).toHaveBeenCalledWith(memory.id, expect.objectContaining({ isSaved: true })); + // #763: update threads the caller's id to project isFavoriteForUser. + expect(mocks.memory.update).toHaveBeenCalledWith( + memory.id, + expect.objectContaining({ isSaved: true }), + auth.user.id, + ); }); it('should update a memory with seenAt', async () => { const memory = MemoryFactory.create(); const seenAt = new Date(); + const auth = factory.auth(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.memory.update.mockResolvedValue(memory as any); - await sut.update(factory.auth(), memory.id, { seenAt }); + await sut.update(auth, memory.id, { seenAt }); - expect(mocks.memory.update).toHaveBeenCalledWith(memory.id, expect.objectContaining({ seenAt })); + // #763: update threads the caller's id to project isFavoriteForUser. + expect(mocks.memory.update).toHaveBeenCalledWith(memory.id, expect.objectContaining({ seenAt }), auth.user.id); }); it('should update a memory with memoryAt', async () => { const memory = MemoryFactory.create(); const memoryAt = new Date(); + const auth = factory.auth(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.memory.update.mockResolvedValue(memory as any); - await sut.update(factory.auth(), memory.id, { memoryAt }); + await sut.update(auth, memory.id, { memoryAt }); - expect(mocks.memory.update).toHaveBeenCalledWith(memory.id, expect.objectContaining({ memoryAt })); + // #763: update threads the caller's id to project isFavoriteForUser. + expect(mocks.memory.update).toHaveBeenCalledWith(memory.id, expect.objectContaining({ memoryAt }), auth.user.id); }); }); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index e85251aee63cf2..8f16f69c9b78a9 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -546,6 +546,7 @@ export class MemoryService extends BaseService { seenAt: dto.seenAt, }, allowedAssetIds, + auth.user.id, ); return mapMemory(memory, auth); @@ -554,11 +555,15 @@ export class MemoryService extends BaseService { async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise { await this.requireAccess({ auth, permission: Permission.MemoryUpdate, ids: [id] }); - const memory = await this.memoryRepository.update(id, { - isSaved: dto.isSaved, - memoryAt: dto.memoryAt, - seenAt: dto.seenAt, - }); + const memory = await this.memoryRepository.update( + id, + { + isSaved: dto.isSaved, + memoryAt: dto.memoryAt, + seenAt: dto.seenAt, + }, + auth.user.id, + ); return mapMemory(memory, auth); } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 0c1b337924fddf..ddf22cd3815fad 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -173,6 +173,10 @@ export class NotificationService extends BaseService { const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId], userId); if (asset) { + // #763: deliberately NOT projecting isFavoriteForUser — `getByIdsWithAllRelationsButStacks` + // above is called with no authUserId, and this synthetic `auth` (a partial cast, not a real + // AuthDto) exists only for other mapAsset options, not for the overlay. `false` is correct + // and matches pre-#763 behavior for this background-notification path. this.websocketRepository.clientSend( 'on_asset_update', userId, diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index abbb4f6c862ff4..34dde89b4dc062 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -22,6 +22,10 @@ describe(SearchService.name, () => { beforeEach(() => { ({ sut, mocks } = newTestService(SearchService)); mocks.partner.getAll.mockResolvedValue([]); + // #763: the favourites facet resolves its own wider space scope. Default it to "no memberships" + // so the many tests that do not care about spaces keep their previous behaviour; the tests that + // do assert on it override this. + mocks.sharedSpace.getAllMemberSpaceIds.mockResolvedValue([]); (mocks.faceIdentity as any).resolveScopedPersonTokens ??= vitest.fn(); (mocks.faceIdentity as any).getAccessiblePersonFilterSuggestions ??= vitest.fn(); (mocks.faceIdentity as any).searchAccessiblePeople ??= vitest.fn(); @@ -912,6 +916,7 @@ describe(SearchService.name, () => { userIds: [authStub.user1.user.id], viewingUserId: authStub.user1.user.id, callerId: authStub.user1.user.id, + authUserId: authStub.user1.user.id, maxDistance: 0, visibility: 'not-locked', }, @@ -1568,6 +1573,29 @@ describe(SearchService.name, () => { }); describe('searchMetadata', () => { + // #763: auth.user is the link owner — neither project nor filter by their favorites. + it('does not resolve favorites for a shared link session (#763)', async () => { + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-1'])); + mocks.sharedSpace.getSpaceIdsForTimeline.mockResolvedValue([]); + mocks.search.searchMetadata.mockResolvedValue({ hasNextPage: false, items: [] }); + + await sut.searchMetadata(authStub.adminSharedLink, { albumIds: ['album-1'], size: 250 }); + + expect(mocks.search.searchMetadata).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ authUserId: undefined }), + ); + }); + + it('rejects a favorite filter for a shared link session (#763)', async () => { + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-1'])); + + await expect( + sut.searchMetadata(authStub.adminSharedLink, { albumIds: ['album-1'], isFavorite: true, size: 250 }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.search.searchMetadata).not.toHaveBeenCalled(); + }); + it('should search metadata with default pagination', async () => { mocks.search.searchMetadata.mockResolvedValue({ hasNextPage: false, items: [] }); @@ -2063,7 +2091,13 @@ describe(SearchService.name, () => { const result = await sut.getAssetsByCity(authStub.user1); - expect(mocks.search.getAssetsByCity).toHaveBeenCalledWith([authStub.user1.user.id], undefined); + // #763: getAssetsByCity also threads the caller's id to project isFavoriteForUser; the + // undefined is #867's timelineSpaceIds, which this stub has none of. + expect(mocks.search.getAssetsByCity).toHaveBeenCalledWith( + [authStub.user1.user.id], + undefined, + authStub.user1.user.id, + ); expect(result).toHaveLength(1); }); @@ -2076,7 +2110,12 @@ describe(SearchService.name, () => { await sut.getAssetsByCity(authStub.user1); expect(mocks.sharedSpace.getSpaceIdsForTimeline).toHaveBeenCalledWith(authStub.user1.user.id); - expect(mocks.search.getAssetsByCity).toHaveBeenCalledWith([authStub.user1.user.id], [spaceId]); + // #763 threads the caller as a third argument so the row can project isFavoriteForUser. + expect(mocks.search.getAssetsByCity).toHaveBeenCalledWith( + [authStub.user1.user.id], + [spaceId], + authStub.user1.user.id, + ); }); }); @@ -2094,6 +2133,37 @@ describe(SearchService.name, () => { hasAssetsNotInAlbum: false, }; + // #763: the Favourites section is offered based on a WIDER space scope than every other facet. + // A favourite survives hiding its space from the timeline, so withholding the section for one + // would hide a filter that does have results. The other facets keep the timeline-visible scope — + // widening those would pull a hidden space's cities/tags/people back into the panel. + it('scopes the favourites probe to every membership while other facets stay timeline-visible (#763)', async () => { + const auth = AuthFactory.create(); + const visibleSpaceId = newUuid(); + const hiddenSpaceId = newUuid(); + mocks.partner.getAll.mockResolvedValue([]); + mocks.sharedSpace.getSpaceIdsForTimeline.mockResolvedValue([{ spaceId: visibleSpaceId }]); + mocks.sharedSpace.getAllMemberSpaceIds.mockResolvedValue([ + { spaceId: visibleSpaceId }, + { spaceId: hiddenSpaceId }, + ]); + mocks.search.getFilterSuggestions.mockResolvedValue(emptyResult); + (mocks.faceIdentity as any).getAccessiblePersonFilterSuggestions.mockResolvedValue({ + people: [], + hasUnnamedPeople: false, + }); + + await sut.getFilterSuggestions(auth, { withSharedSpaces: true }); + + expect(mocks.search.getFilterSuggestions).toHaveBeenCalledWith( + [auth.user.id], + expect.objectContaining({ + timelineSpaceIds: [visibleSpaceId], + favoriteSpaceIds: [visibleSpaceId, hiddenSpaceId], + }), + ); + }); + it('should return filter suggestions', async () => { const auth = AuthFactory.create(); mocks.partner.getAll.mockResolvedValue([]); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 7e8ec64e971615..b507ad7fa612e8 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -33,6 +33,7 @@ import { AssetSearchScope } from 'src/repositories/search.repository.js'; import { BaseService } from 'src/services/base.service.js'; import { requireElevatedPermission } from 'src/utils/access.js'; import { getMyPartnerIds } from 'src/utils/asset.util.js'; +import { favoriteViewerId, rejectSharedLinkFavoriteFilter } from 'src/utils/favorite.js'; import { isSmartSearchEnabled } from 'src/utils/misc.js'; import { decodeSearchCursor, encodeSearchCursor } from 'src/utils/search-cursor.js'; import { applyLockedVisibilityPolicy, collectFilterIds } from 'src/utils/search-filter.js'; @@ -53,7 +54,12 @@ type ResolvedSmartSearch = { // boundary and `albumSharedSpaceScope` re-gates — see resolveSmartSearch. userIds?: string[]; callerId?: string; + authUserId: string; timelineSpaceIds?: string[]; + // #763: the widened space scope the hasFavorites facet runs under — see the note where it is + // resolved in resolveSmartSearch. Declared here so it survives the trip to the repository + // instead of being dropped as an excess property. + favoriteSpaceIds?: string[]; maxDistance?: number; orderDirection?: SmartSearchDto['order']; visibility?: AssetVisibility | 'not-locked'; @@ -144,6 +150,7 @@ export class SearchService extends BaseService { const cityAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks( cities.items.map(({ data }) => data), auth.user.id, + auth.user.id, ); const cityItems = cityAssets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) })); @@ -151,6 +158,7 @@ export class SearchService extends BaseService { const recentAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks( recents.items.map((item) => item.data), auth.user.id, + auth.user.id, ); const recentItems = recentAssets.map((asset) => ({ value: asset.createdAt.toISOString(), @@ -224,9 +232,13 @@ export class SearchService extends BaseService { userIds = await this.getUserIdsToSearch(auth, dto.visibility); } + rejectSharedLinkFavoriteFilter(auth, dto.isFavorite); + const page = dto.page ?? 1; const size = dto.size; - const timelineSpaceIds = await this.getTimelineSpaceIds(auth, dto.withSharedSpaces || !!dto.albumIds?.length); + const timelineSpaceIds = await this.getTimelineSpaceIds(auth, dto.withSharedSpaces || !!dto.albumIds?.length, { + favoriteScoped: dto.isFavorite === true, + }); const resolvedDto = await this.resolveScopedPersonFilters(auth, { ...dto, timelineSpaceIds }); const { hasNextPage, items } = await this.searchRepository.searchMetadata( { page, size }, @@ -236,6 +248,7 @@ export class SearchService extends BaseService { visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'), userIds, viewingUserId: auth.user.id, + authUserId: favoriteViewerId(auth), orderDirection: dto.order ?? AssetOrder.Desc, }, ); @@ -265,7 +278,9 @@ export class SearchService extends BaseService { if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } - const timelineSpaceIds = await this.getTimelineSpaceIds(auth, dto.withSharedSpaces || !!dto.albumIds?.length); + const timelineSpaceIds = await this.getTimelineSpaceIds(auth, dto.withSharedSpaces || !!dto.albumIds?.length, { + favoriteScoped: dto.isFavorite === true, + }); const resolvedDto = await this.resolveScopedPersonFilters(auth, { ...dto, timelineSpaceIds }); return await this.searchRepository.searchStatistics({ @@ -273,6 +288,7 @@ export class SearchService extends BaseService { visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'), userIds, viewingUserId: auth.user.id, + authUserId: auth.user.id, }); } @@ -299,10 +315,13 @@ export class SearchService extends BaseService { } const userIds = await this.getUserIdsToSearch(auth, dto.visibility); - const timelineSpaceIds = await this.getTimelineSpaceIds(auth, dto.withSharedSpaces || !!dto.albumIds?.length); + const timelineSpaceIds = await this.getTimelineSpaceIds(auth, dto.withSharedSpaces || !!dto.albumIds?.length, { + favoriteScoped: dto.isFavorite === true, + }); const resolvedDto = await this.resolveScopedPersonFilters(auth, { ...dto, timelineSpaceIds }); const items = await this.searchRepository.searchRandom(dto.size, { ...resolvedDto, + authUserId: auth.user.id, visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'), userIds, viewingUserId: auth.user.id, @@ -330,13 +349,16 @@ export class SearchService extends BaseService { } const userIds = await this.getUserIdsToSearch(auth, dto.visibility); - const timelineSpaceIds = await this.getTimelineSpaceIds(auth, dto.withSharedSpaces || !!dto.albumIds?.length); + const timelineSpaceIds = await this.getTimelineSpaceIds(auth, dto.withSharedSpaces || !!dto.albumIds?.length, { + favoriteScoped: dto.isFavorite === true, + }); const resolvedDto = await this.resolveScopedPersonFilters(auth, { ...dto, timelineSpaceIds }); const items = await this.searchRepository.searchLargeAssets(dto.size, { ...resolvedDto, visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'), userIds, viewingUserId: auth.user.id, + authUserId: auth.user.id, }); return items.map((item) => mapAsset(item, { auth })); } @@ -400,8 +422,8 @@ export class SearchService extends BaseService { async getAssetsByCity(auth: AuthDto): Promise { const userIds = await this.getUserIdsToSearch(auth); const timelineSpaceIds = await this.getTimelineSpaceIds(auth, true); - const assets = await this.searchRepository.getAssetsByCity(userIds, timelineSpaceIds); - return assets.map((asset) => mapAsset(asset)); + const assets = await this.searchRepository.getAssetsByCity(userIds, timelineSpaceIds, auth.user.id); + return assets.map((asset) => mapAsset(asset, { auth })); } async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto) { @@ -491,16 +513,34 @@ export class SearchService extends BaseService { const userIds = await this.getUserIdsToSearch(auth); let timelineSpaceIds: string[] | undefined; + // #763: a SECOND list, for the hasFavorites probe only. Every other facet stays on + // `timelineSpaceIds` — widening those would pull a hidden space's cities/tags/people back into + // the panel — but "is the Favourites section worth offering" has to span the same spaces the + // favourites filter itself now searches, or the section is withheld for favourites the user can + // still reach. Only resolved for the withSharedSpaces panel; an album/space-scoped panel has a + // single explicit scope and no timeline hide to undo. + let favoriteSpaceIds: string[] | undefined; if (dto.withSharedSpaces || dto.albumId) { const spaceRows = await this.sharedSpaceRepository.getSpaceIdsForTimeline(auth.user.id); if (spaceRows.length > 0) { timelineSpaceIds = spaceRows.map((row) => row.spaceId); } } + if (dto.withSharedSpaces) { + const memberRows = await this.sharedSpaceRepository.getAllMemberSpaceIds(auth.user.id); + if (memberRows.length > 0) { + favoriteSpaceIds = memberRows.map((row) => row.spaceId); + } + } // See getSearchSuggestions above — same not-locked/elevated resolution (LOW #7). const visibility = auth.session?.hasElevatedPermission ? undefined : 'not-locked'; - const resolvedDto = await this.resolveScopedPersonFilters(auth, { ...dto, timelineSpaceIds, visibility }); + const resolvedDto = await this.resolveScopedPersonFilters(auth, { + ...dto, + timelineSpaceIds, + favoriteSpaceIds, + visibility, + }); return await this.searchRepository.getFilterSuggestions(userIds, resolvedDto); } @@ -725,11 +765,26 @@ export class SearchService extends BaseService { throw new BadRequestException('Either `query` or `queryAssetId` must be set'); } + // #763: a favourite-filtered smart search spans every space the caller belongs to, and the + // hasFavorites facet needs that same set even when the search itself is not favourite-filtered + // (the facet deliberately ignores its own isFavorite filter). One membership lookup serves both. + const favoriteFiltered = 'isFavorite' in dto && dto.isFavorite === true; + const memberSpaceRows = + dto.withSharedSpaces || favoriteFiltered + ? await this.sharedSpaceRepository.getAllMemberSpaceIds(auth.user.id) + : []; + const memberSpaceIds = memberSpaceRows.map((row) => row.spaceId); + const favoriteSpaceIds = dto.withSharedSpaces && memberSpaceIds.length > 0 ? memberSpaceIds : undefined; + let timelineSpaceIds: string[] | undefined; if (dto.withSharedSpaces || !!('albumIds' in dto && dto.albumIds?.length)) { - const spaceRows = await this.sharedSpaceRepository.getSpaceIdsForTimeline(auth.user.id); - if (spaceRows.length > 0) { - timelineSpaceIds = spaceRows.map((row) => row.spaceId); + if (favoriteFiltered) { + timelineSpaceIds = memberSpaceIds.length > 0 ? memberSpaceIds : undefined; + } else { + const spaceRows = await this.sharedSpaceRepository.getSpaceIdsForTimeline(auth.user.id); + if (spaceRows.length > 0) { + timelineSpaceIds = spaceRows.map((row) => row.spaceId); + } } } @@ -741,6 +796,7 @@ export class SearchService extends BaseService { const resolvedOptions = await this.resolveScopedPersonFilters(auth, { ...dto, timelineSpaceIds, + favoriteSpaceIds, // Undefined under an album scope — the AlbumRead check above is the boundary there, and // `albumSharedSpaceScope` in the query builder is what re-gates visibility and trash. userIds: albumScoped ? undefined : await this.getUserIdsToSearch(auth, visibility), @@ -748,6 +804,8 @@ export class SearchService extends BaseService { // owner-scoped. See SearchEmbeddingOptions.callerId. callerId: auth.user.id, viewingUserId: auth.user.id, + // #763: the favorite overlay is always resolved for the caller, on every scope. + authUserId: auth.user.id, embedding, maxDistance: machineLearning.clip.maxDistance, visibility: resolvedVisibility, @@ -778,12 +836,26 @@ export class SearchService extends BaseService { return [auth.user.id, ...partnerIds]; } - private async getTimelineSpaceIds(auth: AuthDto, withSharedSpaces?: boolean): Promise { + /** + * #763: `favoriteScoped` resolves EVERY space the caller belongs to instead of only the ones they + * show in their timeline. Hiding a space from the home timeline is a browse preference, not a + * revocation — a favourite the caller placed inside it stays theirs and must stay findable, which + * is the same carve-out the timeline service makes for `isFavorite: true`. Safe only because a + * favourite-filtered query narrows to the caller's own `asset_favorite` rows; never pass it for + * an unfiltered browse. + */ + private async getTimelineSpaceIds( + auth: AuthDto, + withSharedSpaces?: boolean, + { favoriteScoped = false }: { favoriteScoped?: boolean } = {}, + ): Promise { if (!withSharedSpaces) { return; } - const spaceRows = await this.sharedSpaceRepository.getSpaceIdsForTimeline(auth.user.id); + const spaceRows = favoriteScoped + ? await this.sharedSpaceRepository.getAllMemberSpaceIds(auth.user.id) + : await this.sharedSpaceRepository.getSpaceIdsForTimeline(auth.user.id); return spaceRows.length > 0 ? spaceRows.map((row) => row.spaceId) : undefined; } diff --git a/server/src/services/shared-space.service.spec.ts b/server/src/services/shared-space.service.spec.ts index 1d0d3ff0b75ac1..e0d57e4afd75f0 100644 --- a/server/src/services/shared-space.service.spec.ts +++ b/server/src/services/shared-space.service.spec.ts @@ -13515,13 +13515,21 @@ describe(SharedSpaceService.name, () => { ); }); - it('should not resolve timelineSpaceIds when isFavorite=true', async () => { + it('resolves timelineSpaceIds even when isFavorite=true (#763 slice 4)', async () => { const auth = factory.auth(); + const spaceId = newUuid(); + mocks.sharedSpace.getSpaceIdsForTimeline.mockResolvedValue([{ spaceId }]); mocks.sharedSpace.getFilteredMapMarkers.mockResolvedValue([]); await sut.getFilteredMapMarkers(auth, { withSharedSpaces: true, isFavorite: true }); - expect(mocks.sharedSpace.getSpaceIdsForTimeline).not.toHaveBeenCalled(); + expect(mocks.sharedSpace.getSpaceIdsForTimeline).toHaveBeenCalledWith(auth.user.id); + expect(mocks.sharedSpace.getFilteredMapMarkers).toHaveBeenCalledWith( + expect.objectContaining({ + timelineSpaceIds: [spaceId], + isFavorite: true, + }), + ); }); // Regression (Task 11 / D-silent-empty): the SAME bug 00a7fd6bac fixed on the albumId arm of @@ -13563,19 +13571,19 @@ describe(SharedSpaceService.name, () => { isFavorite: true, spacePersonIds: ['space-person-1'], forceEmptyResult: false, - // ...but the QUERY still must NOT widen to shared-space assets — see below. - timelineSpaceIds: undefined, + // #763: the query widens too — see below. + timelineSpaceIds: [spaceId], }), ); }); - // The other half of the same fix: a favourite is the ASSET OWNER'S flag, so widening a - // favorites query to shared-space assets would pin other members' favourites on the caller's - // favourites map (timeline.service.ts:158-168 rejects the same combination outright rather than - // answer it). timelineSpaceIds is therefore computed for the person-token consumer but NOT - // forwarded to the marker query, which stays owner-scoped (searchAssetBuilder database.ts:688 - // only widens when timelineSpaceIds AND userIds are both set). - it('does not widen a favorites query to shared-space assets even when a scoped token forced timelineSpaceIds to be resolved', async () => { + // #763 inverted the other half of this fix. A favourite used to be the ASSET OWNER'S flag, so + // widening a favorites query to shared-space assets would have pinned other members' favourites + // on the caller's map — timelineSpaceIds was computed for the person-token consumer but withheld + // from the marker query. The overlay makes the favourite predicate per-CALLER (asset_favorite + // resolved for authUserId), so a space asset only pins when *I* favourited it, and withholding + // the scope now just hides my own favourites inside my spaces. + it("widens a favorites query to shared-space assets — the overlay predicate is the caller's (#763)", async () => { const auth = factory.auth(); const spaceId = newUuid(); mocks.sharedSpace.getSpaceIdsForTimeline.mockResolvedValue([{ spaceId }]); @@ -13595,11 +13603,16 @@ describe(SharedSpaceService.name, () => { const args = mocks.sharedSpace.getFilteredMapMarkers.mock.calls[0][0]; expect(args.userIds).toEqual([auth.user.id]); - expect(args.timelineSpaceIds).toBeUndefined(); + expect(args.timelineSpaceIds).toEqual([spaceId]); + expect(args.authUserId).toBe(auth.user.id); }); - it('does not resolve timelineSpaceIds for a favorites query whose person filter carries no scoped token', async () => { + // #763: a favorites query resolves timelineSpaceIds like any other withSharedSpaces query now — + // it is the widening consumer in its own right, not just the person-token one. Only the scoped + // TOKEN resolution stays conditional on a scoped token actually being present. + it('resolves timelineSpaceIds for a favorites query whose person filter carries no scoped token', async () => { const auth = factory.auth(); + mocks.sharedSpace.getSpaceIdsForTimeline.mockResolvedValue([]); mocks.sharedSpace.getFilteredMapMarkers.mockResolvedValue([]); await sut.getFilteredMapMarkers(auth, { @@ -13608,7 +13621,7 @@ describe(SharedSpaceService.name, () => { personIds: ['ffffffff-ffff-4fff-bfff-ffffffffffff'], }); - expect(mocks.sharedSpace.getSpaceIdsForTimeline).not.toHaveBeenCalled(); + expect(mocks.sharedSpace.getSpaceIdsForTimeline).toHaveBeenCalledWith(auth.user.id); expect(mocks.faceIdentity.resolveScopedPersonTokens).not.toHaveBeenCalled(); }); diff --git a/server/src/services/shared-space.service.ts b/server/src/services/shared-space.service.ts index 1f5044afecd523..72d693e1002228 100644 --- a/server/src/services/shared-space.service.ts +++ b/server/src/services/shared-space.service.ts @@ -1659,7 +1659,12 @@ export class SharedSpaceService extends BaseService { // out of their timeline (#656). (It matches the grid on VISIBILITY too — see the `visibility` // option below.) On the album path timelineSpaceIds is therefore inert for the gate (albumAccessIsBoundary // skips albumSharedSpaceScope, and with userIds unset the widening arm cannot fire either). - const spaceScopeWidensToTimelineSpaces = !dto.spaceId && dto.withSharedSpaces === true && dto.isFavorite !== true; + // #763: `isFavorite` used to be carved out here, because favourites lived on `asset.isFavorite` + // (the OWNER's flag) and widening to timeline spaces would have surfaced other members' assets + // under the caller's own favourite filter. The overlay makes the filter per-user — searchAssetBuilder + // resolves it from `asset_favorite` for `authUserId` — so a member's favourite inside a space is + // theirs, and the widening is correct rather than a leak. + const spaceScopeWidensToTimelineSpaces = !dto.spaceId && dto.withSharedSpaces === true; // CONSUMER 2 — scoped person-token resolution. resolveScopedMapPersonFilters forwards // timelineSpaceIds to faceIdentityRepository.resolveScopedPersonTokens to resolve @@ -1699,6 +1704,9 @@ export class SharedSpaceService extends BaseService { // albumSharedSpaceScope's shared-space re-gate (database.ts:713): album membership is already the // boundary here, matching the grid and the old map endpoint — see the comment above. userIds: dto.spaceId || dto.albumId ? undefined : [auth.user.id], + // #763: the favourite overlay resolves against the CALLER, which userIds no longer names on + // the space/album arms above. + authUserId: auth.user.id, spaceId: dto.spaceId, albumAccessIsBoundary: !!dto.albumId, // Consumer 1 only (see above): a favorites query resolved timelineSpaceIds purely to make its diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index 4ff21f8210940b..027125e6f64eaf 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -30,10 +30,14 @@ describe(StackService.name, () => { mocks.stack.search.mockResolvedValue([getForStack(stack)]); await sut.search(auth, { primaryAssetId: asset.id }); - expect(mocks.stack.search).toHaveBeenCalledWith({ - ownerId: auth.user.id, - primaryAssetId: asset.id, - }); + // #763: search now threads the caller's id to project isFavoriteForUser. + expect(mocks.stack.search).toHaveBeenCalledWith( + { + ownerId: auth.user.id, + primaryAssetId: asset.id, + }, + auth.user.id, + ); }); }); @@ -128,7 +132,8 @@ describe(StackService.name, () => { await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + // #763: get threads the caller's id to project isFavoriteForUser. + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id', authStub.admin.user.id); }); it('should throw "Asset stack not found" when getById returns null', async () => { @@ -156,7 +161,8 @@ describe(StackService.name, () => { assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })], }); expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id); + // #763: get threads the caller's id to project isFavoriteForUser. + expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id, auth.user.id); }); }); @@ -172,10 +178,12 @@ describe(StackService.name, () => { it('should fail if stack could not be found', async () => { mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); mocks.stack.getById.mockResolvedValue(void 0); + const auth = AuthFactory.create(); - await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.update(auth, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + // #763: findOrFail/getById threads the caller's id to project isFavoriteForUser. + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id', auth.user.id); expect(mocks.stack.update).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled(); }); @@ -194,7 +202,8 @@ describe(StackService.name, () => { BadRequestException, ); - expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id); + // #763: findOrFail/getById threads the caller's id to project isFavoriteForUser. + expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id, auth.user.id); expect(mocks.stack.update).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled(); }); @@ -213,11 +222,16 @@ describe(StackService.name, () => { await sut.update(auth, stack.id, { primaryAssetId: asset.id }); - expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id); - expect(mocks.stack.update).toHaveBeenCalledWith(stack.id, { - id: stack.id, - primaryAssetId: asset.id, - }); + // #763: getById/update both thread the caller's id to project isFavoriteForUser. + expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id, auth.user.id); + expect(mocks.stack.update).toHaveBeenCalledWith( + stack.id, + { + id: stack.id, + primaryAssetId: asset.id, + }, + auth.user.id, + ); expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', { stackId: stack.id, userId: auth.user.id, diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts index 55c17da3ef6122..513b1b154c8049 100644 --- a/server/src/services/stack.service.ts +++ b/server/src/services/stack.service.ts @@ -10,10 +10,13 @@ import { UUIDAssetIDParamDto } from 'src/validation.js'; @Injectable() export class StackService extends BaseService { async search(auth: AuthDto, dto: StackSearchDto): Promise { - const stacks = await this.stackRepository.search({ - ownerId: auth.user.id, - primaryAssetId: dto.primaryAssetId, - }); + const stacks = await this.stackRepository.search( + { + ownerId: auth.user.id, + primaryAssetId: dto.primaryAssetId, + }, + auth.user.id, + ); return stacks.map((stack) => mapStack(stack, { auth })); } @@ -32,7 +35,7 @@ export class StackService extends BaseService { throw new ForbiddenException('Stacks can only be created from assets you own'); } - const stack = await this.stackRepository.create({ ownerId: auth.user.id }, dto.assetIds); + const stack = await this.stackRepository.create({ ownerId: auth.user.id }, dto.assetIds, auth.user.id); await this.eventRepository.emit('StackCreate', { stackId: stack.id, userId: auth.user.id }); @@ -41,18 +44,22 @@ export class StackService extends BaseService { async get(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.StackRead, ids: [id] }); - const stack = await this.findOrFail(id); + const stack = await this.findOrFail(id, auth.user.id); return mapStack(stack, { auth }); } async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise { await this.requireAccess({ auth, permission: Permission.StackUpdate, ids: [id] }); - const stack = await this.findOrFail(id); + const stack = await this.findOrFail(id, auth.user.id); if (dto.primaryAssetId && stack.assets.every(({ id }) => id !== dto.primaryAssetId)) { throw new BadRequestException('Primary asset must be in the stack'); } - const updatedStack = await this.stackRepository.update(id, { id, primaryAssetId: dto.primaryAssetId }); + const updatedStack = await this.stackRepository.update( + id, + { id, primaryAssetId: dto.primaryAssetId }, + auth.user.id, + ); await this.eventRepository.emit('StackUpdate', { stackId: id, userId: auth.user.id }); @@ -89,7 +96,8 @@ export class StackService extends BaseService { await this.eventRepository.emit('StackUpdate', { stackId, userId: auth.user.id }); } - private findOrFail(id: string) { - return findOrFail(() => this.stackRepository.getById(id), 'Asset stack'); + // #763: authUserId is threaded into getById so mapStack's assets carry isFavoriteForUser. + private findOrFail(id: string, authUserId?: string) { + return findOrFail(() => this.stackRepository.getById(id, authUserId), 'Asset stack'); } } diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index ab0658137b200c..bb052f62c38e40 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -163,6 +163,7 @@ const setupSyncMocks = (mocks: ServiceMocks) => { assetFace: makeSub(), assetMetadata: makeSub(), assetOcr: makeSub(), + assetFavorite: makeSub(), authUser: makeSub(), memory: makeSub(), memoryToAsset: makeSub(), @@ -1232,6 +1233,7 @@ describe(SyncService.name, () => { expect(syncSubs.partnerAsset.getBackfill).toHaveBeenCalledWith( expect.objectContaining({ afterUpdateId: partialExtraId }), partnerId, + authStub.user1.user.id, ); }); }); @@ -1294,6 +1296,7 @@ describe(SyncService.name, () => { expect(syncSubs.assetFace.cleanupAuditTable).toHaveBeenCalledWith(31); expect(syncSubs.assetMetadata.cleanupAuditTable).toHaveBeenCalledWith(31); expect(syncSubs.assetEdit.cleanupAuditTable).toHaveBeenCalledWith(31); + expect(syncSubs.assetFavorite.cleanupAuditTable).toHaveBeenCalledWith(31); expect(syncSubs.memory.cleanupAuditTable).toHaveBeenCalledWith(31); expect(syncSubs.memoryToAsset.cleanupAuditTable).toHaveBeenCalledWith(31); expect(syncSubs.partner.cleanupAuditTable).toHaveBeenCalledWith(31); diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 2b649e8c9a62f7..aa4038272f0166 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; -import { Insertable } from 'kysely'; +import { Insertable, SqlBool } from 'kysely'; import { DateTime, Duration } from 'luxon'; import { Writable } from 'node:stream'; import type { SyncAck } from 'src/types.js'; @@ -24,19 +24,26 @@ import { ClientDisconnectedError, waitForDrain } from 'src/utils/response.js'; import { SerializeOptions, fromAck, serialize, toAck } from 'src/utils/sync.js'; type CheckpointMap = Partial>; -type AssetLike = Omit & { +// #763: isFavorite is SqlBool (boolean | 0 | 1), not boolean, because every asset-payload stream +// now projects it via favoriteExistsFor's eb.exists(...) (sync.repository.ts), whose driver-level +// boolean representation Kysely can't narrow further than SqlBool — same reason +// asset-response.dto.ts's MapAsset.isFavoriteForUser is typed SqlBool. Coerced to a strict +// boolean below, in mapSyncAssetV2, mirroring that DTO's `!!entity.isFavoriteForUser`. +type AssetLike = Omit & { checksum: Buffer; thumbhash: Buffer | null; + isFavorite: SqlBool; }; const COMPLETE_ID = 'complete'; const MAX_DAYS = 30; const MAX_DURATION = Duration.fromObject({ days: MAX_DAYS }); -const mapSyncAssetV2 = ({ checksum, thumbhash, ...data }: AssetLike): SyncAssetV2 => ({ +const mapSyncAssetV2 = ({ checksum, thumbhash, isFavorite, ...data }: AssetLike): SyncAssetV2 => ({ ...data, checksum: hexOrBufferToBase64(checksum), thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, + isFavorite: !!isFavorite, }); const mapSyncAssetV1 = (data: AssetLike): SyncAssetV1 => { @@ -133,6 +140,11 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.SharedSpaceAlbumToAssetsV1, SyncRequestType.SharedSpaceAlbumAssetsV1, SyncRequestType.SharedSpaceAlbumAssetExifsV1, + // #763: favorites reference assets a viewer does not own, so this overlay stream sits after + // EVERY asset-delivering stream (own, partner, album, library, shared-space, space-album) — + // same delivering-stream-first pattern as PartnerAssetExifsV1. It stays BEFORE the memory types + // so main's "memories stream LAST" invariant below (guarded by sync.service.spec.ts) still holds. + SyncRequestType.AssetFavoritesV1, // Memories stream LAST, after every type that can deliver an asset row. // // Mobile's `memory_asset_entity` has real foreign keys (`assetId` → `remote_asset_entity.id`, @@ -267,6 +279,7 @@ export class SyncService extends BaseService { [SyncRequestType.AssetsV2]: () => this.syncAssetsV2(options, response, checkpointMap), [SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap), [SyncRequestType.AssetEditsV1]: () => this.syncAssetEditsV1(options, response, checkpointMap), + [SyncRequestType.AssetFavoritesV1]: () => this.syncAssetFavoritesV1(options, response, checkpointMap), [SyncRequestType.PartnerAssetsV2]: () => this.syncPartnerAssetsV2(options, response, checkpointMap, session.id), [SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth), [SyncRequestType.PartnerAssetExifsV1]: () => @@ -344,6 +357,7 @@ export class SyncService extends BaseService { await this.syncRepository.assetFace.cleanupAuditTable(pruneThreshold); await this.syncRepository.assetMetadata.cleanupAuditTable(pruneThreshold); await this.syncRepository.assetEdit.cleanupAuditTable(pruneThreshold); + await this.syncRepository.assetFavorite.cleanupAuditTable(pruneThreshold); await this.syncRepository.memory.cleanupAuditTable(pruneThreshold); await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold); await this.syncRepository.partner.cleanupAuditTable(pruneThreshold); @@ -478,6 +492,7 @@ export class SyncService extends BaseService { const backfill = this.syncRepository.partnerAsset.getBackfill( { ...options, afterUpdateId: startId, beforeUpdateId: endId }, partner.sharedById, + options.userId, ); for await (const { updateId, ...data } of backfill) { @@ -527,6 +542,24 @@ export class SyncService extends BaseService { } } + // #763: asset_favorite is its own synced entity (design doc §4.3) — mirrors syncAssetEditsV1 + // (deletes before upserts) so a favorite/unfavorite converges the same way an edit does. + private async syncAssetFavoritesV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + const deleteType = SyncEntityType.AssetFavoriteDeleteV1; + const deletes = this.syncRepository.assetFavorite.getDeletes({ ...options, ack: checkpointMap[deleteType] }); + + for await (const { id, ...data } of deletes) { + await send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.AssetFavoriteV1; + const upserts = this.syncRepository.assetFavorite.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + + for await (const { updateId, ...data } of upserts) { + await send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async syncPartnerAssetExifsV1( options: SyncQueryOptions, response: Writable, diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 8108c9a9add8d1..ed662092cefe70 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -303,13 +303,54 @@ describe(TimelineService.name, () => { ).rejects.toThrow(BadRequestException); }); - it('should throw when combined with isFavorite', async () => { + // #763: the favourite-filtered browse resolves its space scope from EVERY membership, not + // just the timeline-visible ones — a favourite placed inside a space the caller hid from + // their home timeline must still reach /favorites. Asserting which resolver is called (and + // that the timeline-filtered one is not) is the whole point: both return `{ spaceId }[]`, so + // a test that only checked `timelineSpaceIds` would pass against either. + it('resolves ALL member spaces — not just timeline-visible ones — when isFavorite is set (#763)', async () => { + mocks.sharedSpace.getAllMemberSpaceIds.mockResolvedValue([{ spaceId: 'space-1' }]); + mocks.asset.getTimeBuckets.mockResolvedValue([]); + await expect( sut.getTimeBuckets(authStub.admin, { withSharedSpaces: true, + visibility: AssetVisibility.Timeline, isFavorite: true, }), - ).rejects.toThrow(BadRequestException); + ).resolves.toEqual([]); + + expect(mocks.sharedSpace.getAllMemberSpaceIds).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.sharedSpace.getSpaceIdsForTimeline).not.toHaveBeenCalled(); + expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith( + expect.objectContaining({ + isFavorite: true, + timelineSpaceIds: ['space-1'], + authUserId: authStub.admin.user.id, + }), + authStub.admin, + ); + }); + + // The other direction, and the reason the widening above is scoped to `isFavorite: true`: + // an ordinary browse must keep honouring "don't show this space in my timeline". + it('keeps the timeline-visible-only scope for a browse that is not favourite-filtered (#763)', async () => { + mocks.sharedSpace.getSpaceIdsForTimeline.mockResolvedValue([{ spaceId: 'space-visible' }]); + mocks.asset.getTimeBuckets.mockResolvedValue([]); + + await expect( + sut.getTimeBuckets(authStub.admin, { + withSharedSpaces: true, + visibility: AssetVisibility.Timeline, + }), + ).resolves.toEqual([]); + + expect(mocks.sharedSpace.getSpaceIdsForTimeline).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.sharedSpace.getAllMemberSpaceIds).not.toHaveBeenCalled(); + expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith( + expect.objectContaining({ timelineSpaceIds: ['space-visible'] }), + authStub.admin, + ); }); it('should throw when combined with isTrashed', async () => { @@ -387,6 +428,18 @@ describe(TimelineService.name, () => { mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([albumId])); }); + // #763: auth.user is the link owner, so a favorite filter would list THEIR favorites. + it.each([true, false])('rejects isFavorite=%s for a shared link session (#763)', async (isFavorite) => { + await expect(sut.getTimeBuckets(sharedLinkAuth(spaceId), { albumId, isFavorite })).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect( + sut.getTimeBucket(sharedLinkAuth(spaceId), { albumId, isFavorite, timeBucket: '2024-01-01' }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.asset.getTimeBuckets).not.toHaveBeenCalled(); + expect(mocks.asset.getTimeBucket).not.toHaveBeenCalled(); + }); + it('resolves the contributed arm from the space the link was made from', async () => { mocks.sharedSpace.getMemberSpaceIdsLinkingAlbum.mockResolvedValue([spaceId]); mocks.asset.getTimeBuckets.mockResolvedValue([]); @@ -598,26 +651,28 @@ describe(TimelineService.name, () => { ).rejects.toThrow(BadRequestException); }); - it('should throw an error if withParners is true and isFavorite is either true or false', async () => { - await expect( - sut.getTimeBucket(authStub.admin, { - timeBucket: '2024-01-01', - isFavorite: true, - bucketSize: TimeBucketSize.Day, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrow(BadRequestException); + it('passes isFavorite (true and false) through when combined with withPartners (#763 slice 4)', async () => { + mocks.asset.getTimeBucket.mockResolvedValue({ assets: '[]' }); + mocks.partner.getAll.mockResolvedValue([]); - await expect( - sut.getTimeBucket(authStub.admin, { - timeBucket: '2024-01-01', - isFavorite: false, - bucketSize: TimeBucketSize.Day, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrow(BadRequestException); + for (const isFavorite of [true, false]) { + await expect( + sut.getTimeBucket(authStub.admin, { + timeBucket: '2024-01-01', + visibility: AssetVisibility.Timeline, + bucketSize: TimeBucketSize.Day, + withPartners: true, + userId: authStub.admin.user.id, + isFavorite, + }), + ).resolves.toBeDefined(); + } + + expect(mocks.asset.getTimeBucket).toHaveBeenLastCalledWith( + '2024-01-01', + expect.objectContaining({ isFavorite: false, authUserId: authStub.admin.user.id }), + authStub.admin, + ); }); it('should throw an error if withParners is true and isTrash is true', async () => { diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index b97c0570004816..f6b1197a30498f 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -13,6 +13,7 @@ import { BaseService } from 'src/services/base.service.js'; import { requireElevatedPermission } from 'src/utils/access.js'; import { getAlbumSpaceIds } from 'src/utils/album-space-ids.js'; import { getMyPartnerIds } from 'src/utils/asset.util.js'; +import { rejectSharedLinkFavoriteFilter } from 'src/utils/favorite.js'; import { timelineHiddenScopeIsEmpty } from 'src/utils/shared-space-album-scope.js'; import { normalizeTimeBucketForBucketSize } from 'src/utils/timeline-bucket.js'; @@ -87,7 +88,16 @@ export class TimelineService extends BaseService { } if (dto.withSharedSpaces) { - const spaceRows = await this.sharedSpaceRepository.getSpaceIdsForTimeline(auth.user.id); + // #763: an `isFavorite: true` browse is scoped by MY favourites, so it spans every space I + // belong to — including ones I've hidden from my home timeline. `showInTimeline` is a + // preference about this timeline, not about whether my own explicit favourite still counts; + // resolving it here is what made a favourite placed inside a hidden space vanish from + // /favorites despite the success toast. Safe because the favorite predicate narrows to the + // caller's own overlay rows — see getAllMemberSpaceIds. Every other browse keeps the hide. + const spaceRows = + dto.isFavorite === true + ? await this.sharedSpaceRepository.getAllMemberSpaceIds(auth.user.id) + : await this.sharedSpaceRepository.getSpaceIdsForTimeline(auth.user.id); if (spaceRows.length > 0) { timelineSpaceIds = spaceRows.map((row) => row.spaceId); } @@ -144,6 +154,12 @@ export class TimelineService extends BaseService { callerId, hiddenScope, visibleSpaceIds, + // #763: the caller, threaded separately from `userIds` (the timeline *target*, which is not + // necessarily the caller on space/album browse paths) so the isFavorite overlay predicate in + // withTimeBucketAssetFilters resolves against the right user. Distinct from `callerId` above: + // that one is set only on the own-timeline path and drives #1041's hidden-scope subtraction, + // while this is always the authenticated user because the overlay is always per-caller. + authUserId: auth.user.id, }; } @@ -238,28 +254,28 @@ export class TimelineService extends BaseService { if (auth.sharedLink && !auth.sharedLink.showExif) { dto.withCoordinates = false; } + rejectSharedLinkFavoriteFilter(auth, dto.isFavorite); if (dto.withPartners) { const isRequestedLocked = dto.visibility === AssetVisibility.Locked; const isRequestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined; - const isRequestedFavorite = dto.isFavorite === true || dto.isFavorite === false; const isRequestedTrash = dto.isTrashed === true; - if (isRequestedLocked || isRequestedArchived || isRequestedFavorite || isRequestedTrash) { + // #763 slice 4: isFavorite is deliberately no longer rejected here — favorites are a + // per-user overlay (asset_favorite) resolved for the CALLER, so they compose with + // cross-user scopes. Archive/trash/locked stay rejected: owner-private states. + if (isRequestedLocked || isRequestedArchived || isRequestedTrash) { throw new BadRequestException( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', + 'withPartners is only supported for non-archived, non-trashed, non-locked assets', ); } } if (dto.withSharedSpaces) { const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined; - const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; const requestedTrash = dto.isTrashed === true; - if (requestedArchived || requestedFavorite || requestedTrash) { - throw new BadRequestException( - 'withSharedSpaces is only supported for non-archived, non-trashed, non-favorited assets', - ); + if (requestedArchived || requestedTrash) { + throw new BadRequestException('withSharedSpaces is only supported for non-archived, non-trashed assets'); } } } diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts index 6eed0498329e3a..55c2de50b38054 100644 --- a/server/src/services/workflow-execution.service.ts +++ b/server/src/services/workflow-execution.service.ts @@ -353,6 +353,12 @@ export class WorkflowExecutionService extends BaseService { return { read: async () => { const asset = await this.workflowRepository.getForAssetV1(assetId); + // #763: workflowAssetV1's `isFavorite` (database.ts / workflow.repository.ts's + // getForAssetV1) is resolved via favoriteExistsForOwner — the ASSET OWNER's + // favorite, not any particular viewer's. The workflow engine always acts AS the + // asset owner (authUserId below), which matches this field's pre-#763 semantics + // (the old raw `asset.isFavorite` column could only ever be true for the owner), so + // the query result needs no further per-user resolution here. return { data: { asset } as any, authUserId: asset.ownerId, diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 474008eaf0a536..342ec9e6f5317f 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -49,6 +49,7 @@ import { } from 'src/repositories/search.repository.js'; import { DB } from 'src/schema/index.js'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table.js'; +import { favoriteExistsFor } from 'src/utils/favorite.js'; import { fromChecksum } from 'src/utils/request.js'; import { spaceAssetPathBranches, spaceVisibilityGate } from 'src/utils/shared-space-album-scope.js'; import { dateTruncUnitForTimeBucketSize } from 'src/utils/timeline-bucket.js'; @@ -1028,7 +1029,13 @@ export function searchAssetBuilderLegacy(kysely: Kysely, options: AssetSearc .where(() => sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(options.ocr!).join(' ')})`), ) .$if(!!options.type, (qb) => qb.where('asset.type', '=', options.type!)) - .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) + .$if(options.isFavorite !== undefined, (qb) => + qb.where((eb) => + options.isFavorite + ? favoriteExistsFor(eb, options.authUserId!) + : eb.not(favoriteExistsFor(eb, options.authUserId!)), + ), + ) .$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!)) .$if(options.isEncoded !== undefined, (qb) => qb.where((eb) => { @@ -1247,14 +1254,21 @@ function existsPredicates( // predicates are collected as expressions rather than chained `where` calls so the same // helpers can build each `or` branch, which must compose into eb.and/eb.or -function branchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch) { +function branchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch, authUserId?: string) { const { encodedVideoPath } = branch; return [ ...comparisonPredicates(eb, 'asset.id', branch.id), ...comparisonPredicates(eb, 'asset.libraryId', branch.libraryId), ...comparisonPredicates(eb, 'asset.type', branch.type), ...comparisonPredicates(eb, 'asset.visibility', branch.visibility), - ...(branch.isFavorite ? [eb('asset.isFavorite', '=', branch.isFavorite.eq)] : []), + // #763: favorites are a per-user overlay (asset_favorite) — the raw `asset.isFavorite` column + // this used to compare against was dropped in slice 3. Resolved for the CALLER, mirroring + // searchAssetBuilderLegacy's isFavorite arm above. V3 is dormant in the fork (see the + // coexistence spec), so `authUserId` has no live caller yet; whoever wires V3 to an endpoint + // must thread it, exactly as the legacy path already does. + ...(branch.isFavorite + ? [branch.isFavorite.eq ? favoriteExistsFor(eb, authUserId!) : eb.not(favoriteExistsFor(eb, authUserId!))] + : []), ...(branch.isOffline ? [eb('asset.isOffline', '=', branch.isOffline.eq)] : []), ...(branch.isMotion ? [eb('asset.livePhotoVideoId', branch.isMotion.eq ? 'is not' : 'is', null)] : []), ...existsPredicates(eb, branch.isEncoded, () => encodedVideoFiles(eb)), @@ -1338,13 +1352,13 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild ) .$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null)) .where((eb) => { - const predicates = branchPredicates(eb, filter); + const predicates = branchPredicates(eb, filter, options.authUserId); if (branches.length > 0) { predicates.push( eb.or( branches.map((branch) => eb.and([ - ...branchPredicates(eb, branch), + ...branchPredicates(eb, branch, options.authUserId), ...(scopePerBranch && !isAlbumConfined(branch) ? [ownershipPredicate(eb)] : []), ]), ), @@ -1531,6 +1545,7 @@ export const searchMetadataV3Examples: GenerateSqlQueries[] = [ params: [ { take: 100 }, { + authUserId: DummyValue.UUID, filter: { or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }], }, @@ -1543,6 +1558,7 @@ export const searchMetadataV3Examples: GenerateSqlQueries[] = [ params: [ { take: 100 }, { + authUserId: DummyValue.UUID, filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE }, or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }], @@ -1553,7 +1569,14 @@ export const searchMetadataV3Examples: GenerateSqlQueries[] = [ }, { name: 'cursor-offset', - params: [{ take: 100, skip: 100 }, { filter: { isFavorite: { eq: true } } }, scopeExample], + // #763: `authUserId` is required by any example whose filter uses `isFavorite` -- the predicate + // resolves against the per-user `asset_favorite` overlay, so omitting it binds `undefined` and + // sync-sql logs `UNDEFINED_VALUE` instead of failing, which is easy to miss. + params: [ + { take: 100, skip: 100 }, + { authUserId: DummyValue.UUID, filter: { isFavorite: { eq: true } } }, + scopeExample, + ], }, ]; @@ -1561,7 +1584,7 @@ export const searchRandomV3Examples: GenerateSqlQueries[] = [ { name: 'baseline', params: [100, {}, scopeExample] }, { name: 'with-filter', - params: [100, { filter: { isFavorite: { eq: true } } }, scopeExample], + params: [100, { authUserId: DummyValue.UUID, filter: { isFavorite: { eq: true } } }, scopeExample], }, ]; @@ -1605,6 +1628,7 @@ export const searchStatisticsV3Examples: GenerateSqlQueries[] = [ name: 'with-or', params: [ { + authUserId: DummyValue.UUID, filter: { or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }], }, diff --git a/server/src/utils/favorite-grep-gate.spec.ts b/server/src/utils/favorite-grep-gate.spec.ts new file mode 100644 index 00000000000000..8674d2f7754375 --- /dev/null +++ b/server/src/utils/favorite-grep-gate.spec.ts @@ -0,0 +1,106 @@ +// Static regression guard (#763, slice 3 pre-drop): no code under src/ may read the legacy raw +// `asset.isFavorite` column anymore. Favorites are a per-user overlay (`asset_favorite`, +// resolved via `favoriteExistsFor` / `favoriteExistsForOwner` in favorite.ts) — this guard exists +// so a future query can't reintroduce the old ownership-masking column read (which could only +// ever be true for the asset's owner) once the column itself is dropped from the schema in a +// later task of this slice. +// +// Matching is deliberately scoped to the ways a raw SQL column reference can appear as TEXT in +// TypeScript source — always as a STRING (Kysely column selectors are plain strings; a raw +// Postgres-quoted identifier only ever shows up embedded in a string/template literal). It does +// NOT match a bare, unquoted `asset.isFavorite` (no adjacent quote/backtick at all), because that +// form is indistinguishable by text alone from ordinary JS property access on a query-result +// object. CAVEAT (slice-3 review finding): being un-matchable does NOT make such reads correct — +// `job.service.ts`'s AssetEditReadyV2/AssetUploadReadyV2 payload mappings read `asset.isFavorite` +// off `getById`/`getByIdsWithAllRelationsButStacks` results fetched WITHOUT `authUserId`, i.e. +// `selectAll('asset')`'s RAW column, which the overlay write path no longer updated — a +// staleness bug FIXED in slice 3 task 2 (owner-overlay projection in those repository methods, +// see job-favorite-payload medium spec). Separately, `person.isFavorite` (a different table) and +// a bare `isFavorite:` object key never match — the patterns all require the literal `asset.` +// prefix. +import { readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// vitest runs with cwd = server/ (matches revert-to-immich.spec.ts / shared-space-album-scope +// guard style) — resolve paths from there, not __dirname / import.meta. +const SERVER_ROOT = process.cwd(); +const SRC_DIR = join(SERVER_ROOT, 'src'); +// Hardcoded (not __filename) — this test runs under vite-node/swc, where CJS-style file globals +// aren't guaranteed; the path is also already covered by the general `.spec.ts` exclusion below, +// so this is belt-and-suspenders self-exclusion per the brief. +const THIS_FILE = 'src/utils/favorite-grep-gate.spec.ts'; + +// Raw asset-column-reference forms, as they'd appear as literal source text: +// 'asset.isFavorite' — a plain TS string, e.g. a Kysely `.select([...])` column entry +// "asset.isFavorite" — same, double-quoted +// asset."isFavorite" — a raw Postgres double-quoted identifier, embedded in a string/template +// All three require the literal `asset.` prefix and at least one adjacent quote character, so +// unquoted JS property access (`asset.isFavorite` with no quotes at all) never matches. +const RAW_COLUMN_PATTERNS = [/'asset\.isFavorite'/, /"asset\.isFavorite"/, /asset\."isFavorite"/]; + +function collectTsFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...collectTsFiles(full)); + continue; + } + if (entry.isFile() && entry.name.endsWith('.ts')) { + files.push(full); + } + } + return files; +} + +function isExcluded(relPath: string): boolean { + if (relPath === THIS_FILE) { + return true; + } + if (relPath.endsWith('.spec.ts')) { + return true; + } + // Excludes both src/schema/migrations/ (upstream) and src/schema/migrations-gallery/ (fork) — + // historical/generated migration bodies are allowed to reference the pre-drop column shape. + if (/\/schema\/migrations[^/]*\//.test(relPath)) { + return true; + } + return false; +} + +// Comment-stripping is intentionally simple and line-based: drop any line whose TRIMMED form +// starts with `//` or `*` (covers `//` line comments and the body/close of `/* ... */` and JSDoc +// blocks). It doesn't handle inline trailing `//` comments or comments embedded mid-expression — +// none of the raw-column forms this guard targets are ever written that way. Keeps the original +// 1-based line number alongside each surviving line so failure output points at the real line. +function nonCommentLines(content: string): Array<{ lineNumber: number; line: string }> { + return content + .split('\n') + .map((line, i) => ({ lineNumber: i + 1, line })) + .filter(({ line }) => { + const trimmed = line.trim(); + return !trimmed.startsWith('//') && !trimmed.startsWith('*'); + }); +} + +describe('favorite grep gate (#763)', () => { + it('never reads the raw asset.isFavorite column outside comments', () => { + const offenders: string[] = []; + + for (const absPath of collectTsFiles(SRC_DIR)) { + const relPath = relative(SERVER_ROOT, absPath).replaceAll('\\', '/'); + if (isExcluded(relPath)) { + continue; + } + + for (const { lineNumber, line } of nonCommentLines(readFileSync(absPath, 'utf8'))) { + if (RAW_COLUMN_PATTERNS.some((pattern) => pattern.test(line))) { + offenders.push(`${relPath}:${lineNumber}: ${line.trim()}`); + } + } + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/server/src/utils/favorite.ts b/server/src/utils/favorite.ts new file mode 100644 index 00000000000000..6010aba00ba77f --- /dev/null +++ b/server/src/utils/favorite.ts @@ -0,0 +1,102 @@ +// Per-user favorites overlay (#763). Correlated EXISTS against the `asset_favorite` table +// (slice 0), replacing the old ownership-masking form `asset."isFavorite" and asset."ownerId" = +// :me`, which could only ever be true for the asset's owner. See +// docs/superpowers/specs/2026-07-20-per-user-favorites-design.md §5.2. +// +// Follows the correlated-EXISTS idiom established in src/utils/shared-space-album-scope.ts +// (see `spaceDirectAssetExists` / `spaceAlbumAssetExists`): an `ExpressionBuilder` +// so the helper composes into any query regardless of what's already joined, and `eb.exists(...)` +// over a subquery projecting a literal `1`. +// +// `assetIdRef` is a plain `string` (not a typed `ReferenceExpression` like +// `correlateAssetId` in shared-space-album-scope.ts) so it keeps working when the asset table is +// aliased or the correlation target is a CTE column not present in the static `DB` schema (e.g. +// `getTimeBucket`'s `.with('asset', ...)` / `.with('cte', ...)` chain) — `sql.ref` interpolates it +// as a raw identifier instead of being resolved against the schema at compile time. +import { BadRequestException } from '@nestjs/common'; +import { AliasableExpression, ExpressionBuilder, SqlBool, sql } from 'kysely'; +import { AuthDto } from 'src/dtos/auth.dto.js'; +import { DB } from 'src/schema/index.js'; +import { asUuid } from 'src/utils/database.js'; + +/** + * Per-user favorites (#763). Correlated EXISTS against the `asset_favorite` overlay: true iff + * `userId` has favorited the asset referenced by `assetIdRef`. + * + * Usable in SELECT position (projects a boolean column, e.g. `.select((eb) => + * [favoriteExistsFor(eb, userId).as('isFavorite')])`) and in WHERE position (filters to the + * user's favorites, e.g. `.where((eb) => favoriteExistsFor(eb, userId))`). Negate with + * `eb.not(favoriteExistsFor(eb, userId))` for "not favorited by me". + * + * Return type is `AliasableExpression` rather than the plain `Expression` used + * by the analogous helpers in shared-space-album-scope.ts — those are only ever used in WHERE + * position, but this helper must also work in SELECT position, which needs the `.as(...)` method + * that only `AliasableExpression` (what `eb.exists()` actually returns) provides. It's still + * assignable everywhere a plain `Expression` is expected (e.g. into `eb.not(...)`), since + * `AliasableExpression` extends `Expression`. + */ +export function favoriteExistsFor( + eb: ExpressionBuilder, + userId: string, + assetIdRef: string = 'asset.id', +): AliasableExpression { + return eb.exists( + eb + .selectFrom('asset_favorite') + .select(eb.lit(1).as('exists')) + .whereRef('asset_favorite.assetId', '=', sql.ref(assetIdRef)) + .where('asset_favorite.userId', '=', asUuid(userId)), + ); +} + +/** + * Per-user favorites (#763), OWNER-scoped variant: correlated EXISTS against the `asset_favorite` + * overlay, true iff the asset's OWNER — not any particular viewer — has favorited the asset + * referenced by `assetIdRef`. Unlike `favoriteExistsFor`, which binds a caller-supplied `userId` + * parameter, this helper takes no user at all: `ownerIdRef` is correlated via `sql.ref` against a + * column on the query's own row (defaulting to `asset.ownerId`), exactly like `assetIdRef`. + * + * Owner-semantics decision: this exists for contexts with no authenticated viewer to resolve a + * per-user value for — currently the plugin-facing `workflowAssetV1` projection + * (`workflow.repository.ts` `getForAssetV1`), consumed by background workflow jobs, which have no + * auth user. The workflow engine always acts AS the asset owner (see + * `workflow-execution.service.ts`'s `handleAssetTrigger`), so projecting the OWNER's favorite onto + * the plugin-facing `isFavorite` field reproduces the exact pre-#763 semantic of the raw + * `asset."isFavorite"` column it replaces — that column could only ever be true for the owner, + * since favoriting was owner-only before this feature. Resolving any OTHER viewer's favorite here + * would be meaningless (no viewer is in scope) and resolving nobody's would silently regress + * plugin steps like `assetFavorite` (`inverse: true`) back to always reading a stale `false`. + * + * Same shape as `favoriteExistsFor` — see its doc comment for the `AliasableExpression` + * return type rationale and the `assetIdRef: string` (rather than a typed `ReferenceExpression`) + * rationale; both apply identically to `ownerIdRef` here. + */ +export function favoriteExistsForOwner( + eb: ExpressionBuilder, + assetIdRef: string = 'asset.id', + ownerIdRef: string = 'asset.ownerId', +): AliasableExpression { + return eb.exists( + eb + .selectFrom('asset_favorite') + .select(eb.lit(1).as('exists')) + .whereRef('asset_favorite.assetId', '=', sql.ref(assetIdRef)) + .whereRef('asset_favorite.userId', '=', sql.ref(ownerIdRef)), + ); +} + +/** + * The user whose favorites a response should reflect, or undefined when there is none. A + * shared-link session's `auth.user` is the link OWNER (validateSharedLinkKey/Slug), not the + * anonymous visitor — projecting their overlay would show a visitor which of the album's + * photos (including other contributors' photos) the owner favorited. Undefined makes every + * `$if(!!authUserId, …)` projection skip, so mapAsset reports false. + */ +export const favoriteViewerId = (auth: AuthDto): string | undefined => (auth.sharedLink ? undefined : auth.user.id); + +/** Filtering by favorite state would leak the link owner's favorites just like projecting it. */ +export const rejectSharedLinkFavoriteFilter = (auth: AuthDto, isFavorite: boolean | undefined | null) => { + if (auth.sharedLink && isFavorite !== undefined && isFavorite !== null) { + throw new BadRequestException('Shared link sessions cannot filter by favorite'); + } +}; diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 4210bcafde7479..8909bd8e89d6bf 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -59,7 +59,6 @@ export class AssetFactory { fileCreatedAt: new Date(now++), fileModifiedAt: new Date(now++), isExternal: false, - isFavorite: false, isOffline: false, libraryId: null, livePhotoVideoId: null, diff --git a/server/test/mappers.ts b/server/test/mappers.ts index c5c0d76921696c..32a960a409cebc 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -110,6 +110,11 @@ export const getForActivity = (activity: Selectable & { user: Ret export const getForAsset = (asset: ReturnType) => { return { ...asset, + // #763: assetRepository.getById/getByIdsWithAllRelationsButStacks now always project + // `isFavorite` from favoriteExistsForOwner (the raw column this used to come from was + // dropped in slice 3) — mock this shape's baseline the same way: no favorite by default. + // Override per-test (`{ ...getForAsset(asset), isFavorite: true }`) when a case needs it. + isFavorite: false, faces: asset.faces.map((face) => ({ ...getDehydrated(face), person: face.person ? getDehydrated(face.person) : null, diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 48295ecda55558..1555aa9cccc546 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -27,6 +27,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository.js'; import { AlbumRepository } from 'src/repositories/album.repository.js'; import { ApiKeyRepository } from 'src/repositories/api-key.repository.js'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { AssetFileRepository } from 'src/repositories/asset-file.repository.js'; import { AssetJobRepository } from 'src/repositories/asset-job.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; @@ -74,6 +75,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository.js'; import { UserRepository } from 'src/repositories/user.repository.js'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository.js'; import { ViewRepository } from 'src/repositories/view-repository.js'; +import { WebsocketRepository } from 'src/repositories/websocket.repository.js'; import { WorkflowRepository } from 'src/repositories/workflow.repository.js'; import { DB } from 'src/schema/index.js'; import { AlbumTable } from 'src/schema/tables/album.table.js'; @@ -217,9 +219,23 @@ export class MediumTestContext = return { stack: { ...stack, primaryAssetId: assetIds[0] }, result }; } - async newAsset(dto: Partial> = {}) { - const asset = mediumFactory.assetInsert(dto); + /** + * #763: `asset.isFavorite` is gone — a favorite is an `asset_favorite` row per (user, asset). + * Fixtures (upstream's and the fork's) still say `isFavorite: true` to mean "the OWNER has + * favorited this", so translate it here rather than rewriting every call site. Keeping the + * knob absorbs the idiom for tests that arrive from upstream later, too. + */ + async newAsset(dto: Partial> & { isFavorite?: boolean } = {}) { + const { isFavorite, ...assetDto } = dto; + const asset = mediumFactory.assetInsert(assetDto); const result = await this.get(AssetRepository).create(asset); + if (isFavorite) { + await this.database + .insertInto('asset_favorite') + .values({ userId: asset.ownerId, assetId: asset.id }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } return { asset, result }; } @@ -627,6 +643,7 @@ const newRealRepository = (key: T, db: Kysely case ApiKeyRepository: case AssetRepository: case AssetEditRepository: + case AssetFavoriteRepository: case AssetFileRepository: case AssetJobRepository: case ClusterGroupRepository: @@ -801,6 +818,13 @@ const newMockRepository = (key: ClassConstructor) => { return automock(StorageRepository, { args: [{ setContext: () => {} }] }); } + // #763: job.service.spec's websocket-payload staleness test (job.service-favorite-payload.spec.ts) + // needs to assert on WebsocketRepository.clientSend calls from a real JobService constructed via + // newMediumService — not previously wired here since no medium test had exercised that path. + case WebsocketRepository: { + return automock(WebsocketRepository, { args: [undefined, { setContext: () => {} }], strict: false }); + } + default: { throw new Error(`Invalid repository key: ${key}`); } @@ -817,7 +841,6 @@ const assetInsert = (asset: Partial> = {}) => { type: AssetType.Image, originalPath: '/path/to/something.jpg', ownerId: 'not-a-valid-uuid', - isFavorite: false, fileCreatedAt: now, fileModifiedAt: now, localDateTime: now, diff --git a/server/test/medium/specs/repositories/asset-favorite.repository.spec.ts b/server/test/medium/specs/repositories/asset-favorite.repository.spec.ts new file mode 100644 index 00000000000000..735aedf1294909 --- /dev/null +++ b/server/test/medium/specs/repositories/asset-favorite.repository.spec.ts @@ -0,0 +1,482 @@ +import { Kysely } from 'kysely'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; +import { LoggingRepository } from 'src/repositories/logging.repository.js'; +import { DB } from 'src/schema/index.js'; +import { BaseService } from 'src/services/base.service.js'; +import { favoriteExistsFor } from 'src/utils/favorite.js'; +import { newMediumService } from 'test/medium.factory.js'; +import { getKyselyDB } from 'test/utils.js'; + +// #763 slice 0 laid the schema foundation (`asset_favorite` / `asset_favorite_audit`), exercised +// below directly via the Kysely DB handle. Slice 2 (this file's `AssetFavoriteRepository` describe +// block, see docs/superpowers/plans/2026-07-20-per-user-favorites-slice-2.md Task 1) adds the +// repository that Task 2's write path calls. + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { ctx } = newMediumService(BaseService, { + database: db || defaultDatabase, + real: [], + mock: [LoggingRepository], + }); + return { ctx }; +}; + +const setupRepo = (db?: Kysely) => { + const { ctx } = newMediumService(BaseService, { + database: db || defaultDatabase, + real: [AssetFavoriteRepository], + mock: [LoggingRepository], + }); + return { ctx, sut: ctx.get(AssetFavoriteRepository) }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe('asset_favorite schema', () => { + it('persists a row and reads it back by (userId, assetId)', async () => { + const { ctx } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: asset.id }).execute(); + + const rows = await ctx.database + .selectFrom('asset_favorite') + .selectAll() + .where('userId', '=', user.id) + .where('assetId', '=', asset.id) + .execute(); + + expect(rows).toHaveLength(1); + expect(rows[0].createId).toEqual(expect.any(String)); + expect(rows[0].updateId).toEqual(expect.any(String)); + }); + + it('lets two users favorite the same asset independently', async () => { + const { ctx } = setup(); + const { user: userA } = await ctx.newUser(); + const { user: userB } = await ctx.newUser(); + const { user: owner } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + + await ctx.database + .insertInto('asset_favorite') + .values([ + { userId: userA.id, assetId: asset.id }, + { userId: userB.id, assetId: asset.id }, + ]) + .execute(); + + const rows = await ctx.database.selectFrom('asset_favorite').selectAll().where('assetId', '=', asset.id).execute(); + + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.userId).toSorted()).toEqual([userA.id, userB.id].toSorted()); + }); + + it('rejects a duplicate (userId, assetId) via the composite PK', async () => { + const { ctx } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: asset.id }).execute(); + + await expect( + ctx.database + .insertInto('asset_favorite') + .values({ userId: user.id, assetId: asset.id }) + .onConflict((oc) => oc.columns(['userId', 'assetId']).doNothing()) + .execute(), + ).resolves.not.toThrow(); + + const rows = await ctx.database + .selectFrom('asset_favorite') + .selectAll() + .where('userId', '=', user.id) + .where('assetId', '=', asset.id) + .execute(); + + expect(rows).toHaveLength(1); + }); + + it('cascade-deletes rows for every user when the asset is deleted', async () => { + const { ctx } = setup(); + const { user: userA } = await ctx.newUser(); + const { user: userB } = await ctx.newUser(); + const { user: owner } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + + await ctx.database + .insertInto('asset_favorite') + .values([ + { userId: userA.id, assetId: asset.id }, + { userId: userB.id, assetId: asset.id }, + ]) + .execute(); + + await ctx.database.deleteFrom('asset').where('id', '=', asset.id).execute(); + + const rows = await ctx.database.selectFrom('asset_favorite').selectAll().where('assetId', '=', asset.id).execute(); + + expect(rows).toHaveLength(0); + }); + + it("cascade-deletes a user's rows but leaves other users' rows for the same asset", async () => { + const { ctx } = setup(); + const { user: userA } = await ctx.newUser(); + const { user: userB } = await ctx.newUser(); + const { user: owner } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + + await ctx.database + .insertInto('asset_favorite') + .values([ + { userId: userA.id, assetId: asset.id }, + { userId: userB.id, assetId: asset.id }, + ]) + .execute(); + + await ctx.database.deleteFrom('user').where('id', '=', userA.id).execute(); + + const rows = await ctx.database.selectFrom('asset_favorite').selectAll().where('assetId', '=', asset.id).execute(); + + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(userB.id); + }); + + it('writes an audit tombstone when a favorite row is deleted', async () => { + const { ctx } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: asset.id }).execute(); + await ctx.database + .deleteFrom('asset_favorite') + .where('userId', '=', user.id) + .where('assetId', '=', asset.id) + .execute(); + + const auditRows = await ctx.database + .selectFrom('asset_favorite_audit') + .selectAll() + .where('userId', '=', user.id) + .where('assetId', '=', asset.id) + .execute(); + + expect(auditRows).toHaveLength(1); + expect(auditRows[0]).toMatchObject({ userId: user.id, assetId: asset.id }); + expect(auditRows[0].deletedAt).toBeTruthy(); + }); + + it('emits one audit row per row on a multi-row delete', async () => { + const { ctx } = setup(); + const { user: userA } = await ctx.newUser(); + const { user: userB } = await ctx.newUser(); + const { user: owner } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + + await ctx.database + .insertInto('asset_favorite') + .values([ + { userId: userA.id, assetId: asset.id }, + { userId: userB.id, assetId: asset.id }, + ]) + .execute(); + + // Statement-level trigger check: both rows deleted in ONE DELETE statement. A trigger + // mistakenly declared FOR EACH STATEMENT-without-transition-table (or FOR EACH ROW misused) + // could record only once; this asserts one audit row per deleted row. + await ctx.database.deleteFrom('asset_favorite').where('assetId', '=', asset.id).execute(); + + const auditRows = await ctx.database + .selectFrom('asset_favorite_audit') + .selectAll() + .where('assetId', '=', asset.id) + .execute(); + + expect(auditRows).toHaveLength(2); + expect(auditRows.map((row) => row.userId).toSorted()).toEqual([userA.id, userB.id].toSorted()); + }); + + // #763 slice 3: the two "backfills from asset.isFavorite" tests that lived here exercised + // 1794000000000-AddAssetFavoriteTables's up() backfill SQL against a live DB — that only made + // sense while asset."isFavorite" still existed (slices 0-2). Slice 3 + // (1794100000000-DropAssetIsFavoriteColumn) has now dropped the column outright, so there is no + // live schema left to run that historical backfill query against; the migration file itself + // (and its own historical correctness) is unaffected and untouched. +}); + +describe('favoriteExistsFor', () => { + it('is true only for the user who favorited the asset', async () => { + const { ctx } = setup(); + const { user: userA } = await ctx.newUser(); + const { user: userB } = await ctx.newUser(); + const { user: owner } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + + await ctx.database.insertInto('asset_favorite').values({ userId: userA.id, assetId: asset.id }).execute(); + + const rowForUserA = await ctx.database + .selectFrom('asset') + .select((eb) => ['asset.id', favoriteExistsFor(eb, userA.id).as('fav')]) + .where('asset.id', '=', asset.id) + .executeTakeFirstOrThrow(); + + const rowForUserB = await ctx.database + .selectFrom('asset') + .select((eb) => ['asset.id', favoriteExistsFor(eb, userB.id).as('fav')]) + .where('asset.id', '=', asset.id) + .executeTakeFirstOrThrow(); + + expect(rowForUserA.fav).toBe(true); + expect(rowForUserB.fav).toBe(false); + }); + + it('is false for an asset with no favorite rows at all', async () => { + const { ctx } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const row = await ctx.database + .selectFrom('asset') + .select((eb) => ['asset.id', favoriteExistsFor(eb, user.id).as('fav')]) + .where('asset.id', '=', asset.id) + .executeTakeFirstOrThrow(); + + expect(row.fav).toBe(false); + }); + + it('usable as a WHERE predicate to filter to a user favorites', async () => { + const { ctx } = setup(); + const { user: userA } = await ctx.newUser(); + const { user: owner } = await ctx.newUser(); + const { asset: assetX } = await ctx.newAsset({ ownerId: owner.id }); + const { asset: assetY } = await ctx.newAsset({ ownerId: owner.id }); + + await ctx.database.insertInto('asset_favorite').values({ userId: userA.id, assetId: assetX.id }).execute(); + + const rows = await ctx.database + .selectFrom('asset') + .select('asset.id') + .where('asset.id', 'in', [assetX.id, assetY.id]) + .where((eb) => favoriteExistsFor(eb, userA.id)) + .execute(); + + expect(rows).toEqual([{ id: assetX.id }]); + }); +}); + +describe('AssetFavoriteRepository', () => { + it('addAll creates one row per asset for the given user', async () => { + const { ctx, sut } = setupRepo(); + const { user } = await ctx.newUser(); + const { asset: assetA } = await ctx.newAsset({ ownerId: user.id }); + const { asset: assetB } = await ctx.newAsset({ ownerId: user.id }); + + await sut.addAll(user.id, [assetA.id, assetB.id]); + + const rows = await ctx.database + .selectFrom('asset_favorite') + .select(['userId', 'assetId']) + .where('userId', '=', user.id) + .execute(); + + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.assetId).toSorted()).toEqual([assetA.id, assetB.id].toSorted()); + }); + + it('addAll is idempotent — re-adding an existing favorite is a no-op, not a 500', async () => { + const { ctx, sut } = setupRepo(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await sut.addAll(user.id, [asset.id]); + + await expect(sut.addAll(user.id, [asset.id])).resolves.not.toThrow(); + + const rows = await ctx.database + .selectFrom('asset_favorite') + .selectAll() + .where('userId', '=', user.id) + .where('assetId', '=', asset.id) + .execute(); + + expect(rows).toHaveLength(1); + }); + + it('removeAll deletes only the given users rows', async () => { + const { ctx, sut } = setupRepo(); + const { user: userA } = await ctx.newUser(); + const { user: userB } = await ctx.newUser(); + const { user: owner } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + + await sut.addAll(userA.id, [asset.id]); + await sut.addAll(userB.id, [asset.id]); + + await sut.removeAll(userA.id, [asset.id]); + + const rows = await ctx.database.selectFrom('asset_favorite').selectAll().where('assetId', '=', asset.id).execute(); + + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(userB.id); + }); + + it('removeAll on a never-favorited asset is a no-op', async () => { + const { ctx, sut } = setupRepo(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await expect(sut.removeAll(user.id, [asset.id])).resolves.not.toThrow(); + + const rows = await ctx.database.selectFrom('asset_favorite').selectAll().where('assetId', '=', asset.id).execute(); + + expect(rows).toHaveLength(0); + }); + + it('addAll with an empty id list does nothing and does not throw', async () => { + const { ctx, sut } = setupRepo(); + const { user } = await ctx.newUser(); + + await expect(sut.addAll(user.id, [])).resolves.not.toThrow(); + + const rows = await ctx.database.selectFrom('asset_favorite').selectAll().where('userId', '=', user.id).execute(); + + expect(rows).toHaveLength(0); + }); + + // #763 slice 7 (E21): duplicate-merge calls mergeOnto BEFORE the source assets are deleted — + // their own asset_favorite rows would otherwise CASCADE away with them. Duplicate detection is + // owner-scoped (all sources share an owner), but other users can have favorited a source via + // space access, which the old boolean-OR on the raw column could not express. + describe('mergeOnto', () => { + it('unions favorites from multiple source assets onto the keeper, per user', async () => { + const { ctx, sut } = setupRepo(); + const { user: owner } = await ctx.newUser(); + const { user: userA } = await ctx.newUser(); + const { user: userB } = await ctx.newUser(); + const { asset: keeper } = await ctx.newAsset({ ownerId: owner.id }); + const { asset: source1 } = await ctx.newAsset({ ownerId: owner.id }); + const { asset: source2 } = await ctx.newAsset({ ownerId: owner.id }); + + // userA favorited source1, userB favorited source2 — two different users, two different + // sources. The old global-column OR could only ever carry the owner's single boolean. + await sut.addAll(userA.id, [source1.id]); + await sut.addAll(userB.id, [source2.id]); + + await sut.mergeOnto(keeper.id, [source1.id, source2.id]); + + const rows = await ctx.database + .selectFrom('asset_favorite') + .select('userId') + .where('assetId', '=', keeper.id) + .execute(); + + expect(rows.map((row) => row.userId).toSorted()).toEqual([userA.id, userB.id].toSorted()); + }); + + it('dedups when the same user favorited multiple sources — exactly one keeper row (E8)', async () => { + const { ctx, sut } = setupRepo(); + const { user: owner } = await ctx.newUser(); + const { user: userA } = await ctx.newUser(); + const { asset: keeper } = await ctx.newAsset({ ownerId: owner.id }); + const { asset: source1 } = await ctx.newAsset({ ownerId: owner.id }); + const { asset: source2 } = await ctx.newAsset({ ownerId: owner.id }); + + await sut.addAll(userA.id, [source1.id, source2.id]); + + await expect(sut.mergeOnto(keeper.id, [source1.id, source2.id])).resolves.not.toThrow(); + + const rows = await ctx.database + .selectFrom('asset_favorite') + .select('userId') + .where('assetId', '=', keeper.id) + .execute(); + + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(userA.id); + }); + + it('does not conflict with a favorite the keeper already carries', async () => { + const { ctx, sut } = setupRepo(); + const { user: owner } = await ctx.newUser(); + const { user: userA } = await ctx.newUser(); + const { asset: keeper } = await ctx.newAsset({ ownerId: owner.id }); + const { asset: source } = await ctx.newAsset({ ownerId: owner.id }); + + // userA already favorited the keeper directly, AND favorited the source being merged in. + await sut.addAll(userA.id, [keeper.id, source.id]); + + await expect(sut.mergeOnto(keeper.id, [source.id])).resolves.not.toThrow(); + + const rows = await ctx.database + .selectFrom('asset_favorite') + .select('userId') + .where('assetId', '=', keeper.id) + .execute(); + + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(userA.id); + }); + + it('creates no rows when no source was favorited', async () => { + const { ctx, sut } = setupRepo(); + const { user: owner } = await ctx.newUser(); + const { asset: keeper } = await ctx.newAsset({ ownerId: owner.id }); + const { asset: source } = await ctx.newAsset({ ownerId: owner.id }); + + await sut.mergeOnto(keeper.id, [source.id]); + + const rows = await ctx.database + .selectFrom('asset_favorite') + .selectAll() + .where('assetId', '=', keeper.id) + .execute(); + + expect(rows).toHaveLength(0); + }); + + it('with an empty source list does nothing and does not throw', async () => { + const { ctx, sut } = setupRepo(); + const { user: owner } = await ctx.newUser(); + const { asset: keeper } = await ctx.newAsset({ ownerId: owner.id }); + + await expect(sut.mergeOnto(keeper.id, [])).resolves.not.toThrow(); + + const rows = await ctx.database + .selectFrom('asset_favorite') + .selectAll() + .where('assetId', '=', keeper.id) + .execute(); + + expect(rows).toHaveLength(0); + }); + + // §5.2: visibility is re-derived on read, never enforced by deleting rows on access loss — + // mergeOnto has no notion of "current access" at all, it only reads existing asset_favorite + // rows. A favorite from a user who has since lost access to the source still transfers here; + // whether it's visible to that user afterwards is entirely a read-path/access-filter concern. + it('transfers a source favorite regardless of the favoriting users current access', async () => { + const { ctx, sut } = setupRepo(); + const { user: owner } = await ctx.newUser(); + const { user: formerMember } = await ctx.newUser(); + const { asset: keeper } = await ctx.newAsset({ ownerId: owner.id }); + const { asset: source } = await ctx.newAsset({ ownerId: owner.id }); + + await sut.addAll(formerMember.id, [source.id]); + + await sut.mergeOnto(keeper.id, [source.id]); + + const rows = await ctx.database + .selectFrom('asset_favorite') + .select('userId') + .where('assetId', '=', keeper.id) + .execute(); + + expect(rows.map((row) => row.userId)).toEqual([formerMember.id]); + }); + }); +}); diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts index cc6470a94a58d1..6161974d98da9d 100644 --- a/server/test/medium/specs/repositories/asset.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -286,9 +286,11 @@ describe(AssetRepository.name, () => { const { user } = await ctx.newUser(); const matching = await createTimelineAsset(ctx, user.id, new Date('2024-02-01T12:00:00.000Z'), { - isFavorite: true, thumbhash: Buffer.from('favorite'), }); + // #763: isFavorite now filters against the asset_favorite overlay for the caller + // (authUserId), not the legacy asset.isFavorite column. + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: matching.id }).execute(); await ctx.newExif({ assetId: matching.id, latitude: 52.5, @@ -300,9 +302,7 @@ describe(AssetRepository.name, () => { rating: 4, }); - const decoy = await createTimelineAsset(ctx, user.id, new Date('2024-02-02T12:00:00.000Z'), { - isFavorite: false, - }); + const decoy = await createTimelineAsset(ctx, user.id, new Date('2024-02-02T12:00:00.000Z')); await ctx.newExif({ assetId: decoy.id, latitude: 48.8, @@ -317,6 +317,7 @@ describe(AssetRepository.name, () => { const result = await sut.getTimeBuckets( { userIds: [user.id], + authUserId: user.id, visibility: AssetVisibility.Timeline, bucketSize: TimeBucketSize.Year, isFavorite: true, @@ -1805,7 +1806,7 @@ describe(AssetRepository.name, () => { expect(JSON.parse(bucket.assets).id).toEqual([match.id]); }); - it('projects owner-scoped isFavorite, isImage and ratio for matched assets', async () => { + it('projects isImage and ratio for matched assets', async () => { const { ctx, sut } = setup(); const { user } = await ctx.newUser(); const auth = factory.auth({ user: { id: user.id } }); @@ -1813,7 +1814,6 @@ describe(AssetRepository.name, () => { const { asset } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Image, - isFavorite: true, fileCreatedAt: new Date('2026-03-08T12:00:00.000Z'), localDateTime: new Date('2026-03-08T12:00:00.000Z'), }); @@ -1827,7 +1827,64 @@ describe(AssetRepository.name, () => { const parsed = JSON.parse(bucket.assets); expect(parsed.id).toEqual([asset.id]); expect(parsed.isImage).toEqual([true]); - expect(parsed.isFavorite).toEqual([true]); + }); + + it('reports isFavorite from the asset_favorite overlay for the requesting user (#763, E24)', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const { asset } = await ctx.newAsset({ + ownerId: user.id, + type: AssetType.Image, + fileCreatedAt: new Date('2026-03-08T12:00:00.000Z'), + localDateTime: new Date('2026-03-08T12:00:00.000Z'), + }); + await ctx.newExif({ assetId: asset.id, timeZone: 'UTC' }); + + // Insert the overlay row directly (bypassing the write path, which is unchanged in this + // slice) so the read is exercised against the overlay in isolation from asset.isFavorite, + // which is never set here and stays at its default `false`. + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: asset.id }).execute(); + + const withFavorite = await sut.getTimeBucket( + '2026-03-01', + { userIds: [user.id], visibility: AssetVisibility.Timeline }, + auth, + ); + expect(JSON.parse(withFavorite.assets).isFavorite).toEqual([true]); + + // Flip via the overlay only, and confirm the projection follows it (not a stale column read). + await ctx.database + .deleteFrom('asset_favorite') + .where('userId', '=', user.id) + .where('assetId', '=', asset.id) + .execute(); + + const withoutFavorite = await sut.getTimeBucket( + '2026-03-01', + { userIds: [user.id], visibility: AssetVisibility.Timeline }, + auth, + ); + expect(JSON.parse(withoutFavorite.assets).isFavorite).toEqual([false]); + }); + }); + + describe('getStatistics', () => { + it('counts only the callers own asset_favorite overlay rows (#763, E22)', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + + const { asset: assetX } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Timeline }); + await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Timeline }); + + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: assetX.id }).execute(); + + const stats = await sut.getStatistics(user.id, { isFavorite: true }); + expect(stats[AssetType.Image]).toEqual(1); + + const nonFavoriteStats = await sut.getStatistics(user.id, { isFavorite: false }); + expect(nonFavoriteStats[AssetType.Image]).toEqual(1); }); }); @@ -2790,4 +2847,87 @@ describe(AssetRepository.name, () => { await expect(sut.getExternalAssetIds(user.id)).resolves.toEqual(new Set()); }); }); + + // #763 slice 1b Task 2 — mapAsset now reads `isFavoriteForUser` exclusively (slice 1b Task 1). + // These queries must project the per-user asset_favorite overlay onto the returned row, keyed + // to the CALLER (authUserId), not to the asset owner. + describe('getById (#763)', () => { + it('projects isFavoriteForUser for the requesting user, independent of the owner', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + + await ctx.database.insertInto('asset_favorite').values({ userId: viewer.id, assetId: asset.id }).execute(); + + const asOwner = await sut.getById(asset.id, {}, owner.id); + expect(asOwner?.isFavoriteForUser).toBe(false); + + const asViewer = await sut.getById(asset.id, {}, viewer.id); + expect(asViewer?.isFavoriteForUser).toBe(true); + }); + + it('leaves isFavoriteForUser unset when no authUserId is supplied (fail-safe)', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: asset.id }).execute(); + + const result = await sut.getById(asset.id); + expect(result?.isFavoriteForUser).toBeUndefined(); + }); + }); + + describe('getByIdsWithAllRelationsButStacks (#763)', () => { + it('projects isFavoriteForUser for the requesting user, independent of the owner', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + + await ctx.database.insertInto('asset_favorite').values({ userId: viewer.id, assetId: asset.id }).execute(); + + const [asOwner] = await sut.getByIdsWithAllRelationsButStacks([asset.id], owner.id, owner.id); + expect(asOwner.isFavoriteForUser).toBe(false); + + const [asViewer] = await sut.getByIdsWithAllRelationsButStacks([asset.id], viewer.id, viewer.id); + expect(asViewer.isFavoriteForUser).toBe(true); + }); + + it('leaves isFavoriteForUser unset without an authUserId, matching background-job callers', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: asset.id }).execute(); + + const [result] = await sut.getByIdsWithAllRelationsButStacks([asset.id]); + expect(result.isFavoriteForUser).toBeUndefined(); + }); + }); + + describe('update (#763)', () => { + it('projects isFavoriteForUser for the supplied authUserId after writing a column', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id, isOffline: false }); + await ctx.database.insertInto('asset_favorite').values({ userId: viewer.id, assetId: asset.id }).execute(); + + const asOwner = await sut.update({ id: asset.id, isOffline: false }, owner.id); + expect(asOwner?.isFavoriteForUser).toBe(false); + + const asViewer = await sut.update({ id: asset.id, isOffline: false }, viewer.id); + expect(asViewer?.isFavoriteForUser).toBe(true); + }); + + it('projects isFavoriteForUser via the getById fallback when there is nothing to set', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: asset.id }).execute(); + + const result = await sut.update({ id: asset.id }, user.id); + expect(result?.isFavoriteForUser).toBe(true); + }); + }); }); diff --git a/server/test/medium/specs/repositories/favorite-cross-scope.spec.ts b/server/test/medium/specs/repositories/favorite-cross-scope.spec.ts new file mode 100644 index 00000000000000..1c18dacc603c97 --- /dev/null +++ b/server/test/medium/specs/repositories/favorite-cross-scope.spec.ts @@ -0,0 +1,160 @@ +import { Kysely } from 'kysely'; +import { AssetVisibility, TimeBucketSize } from 'src/enum.js'; +import { AssetRepository } from 'src/repositories/asset.repository.js'; +import { LoggingRepository } from 'src/repositories/logging.repository.js'; +import { DB } from 'src/schema/index.js'; +import { BaseService } from 'src/services/base.service.js'; +import { mediumFactory, newMediumService } from 'test/medium.factory.js'; +import { factory } from 'test/small.factory.js'; +import { getKyselyDB } from 'test/utils.js'; + +// #763 slice 4 (design §10.10): a favorite-filtered timeline first page that crosses TEN shared +// spaces the caller belongs to but owns none of — the branch in withTimeBucketAssetFilters +// (asset.repository.ts) that ORs `asset.ownerId = caller` against the timelineSpaceIds +// spaceAssetPathBranches, then narrows to the caller's own asset_favorite overlay. A regression +// tripwire, not a strict SLA — see memory `project_people_page_slow_accessible_faces` for the +// JIT-class regression this shape exists to catch: a fine plan at small scale that falls off a +// cliff once the corpus crosses a cost threshold no earlier, smaller medium test exercised. + +interface TimeBucketAssets { + id: string[]; +} + +const SPACE_COUNT = 10; +const ASSETS_PER_OWNER = 1000; +const TOTAL_ASSETS = SPACE_COUNT * ASSETS_PER_OWNER; +const CHUNK_SIZE = 1000; +const PERF_CEILING_MS = 5000; + +const chunk = (items: T[], size: number): T[][] => { + const chunks: T[][] = []; + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + return chunks; +}; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { ctx } = newMediumService(BaseService, { + database: db || defaultDatabase, + real: [], + mock: [LoggingRepository], + }); + return { ctx, sut: ctx.get(AssetRepository) }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}, 30_000); + +describe('favorite cross-scope timeline performance (#763 slice 4)', () => { + it('returns a favorite-filtered, 10-space cross-scope first page within the perf budget', async () => { + const { ctx, sut } = setup(); + const database = ctx.database; + + const { user: caller } = await ctx.newUser(); + + // 10 spaces, each owned by a different user — the caller is a member (showInTimeline, + // the factory default) of every one but owns zero assets, so the measured query must take + // the cross-owner spaceAssetPathBranches arm for every row it returns. + const spaceIds: string[] = []; + const assetRows: ReturnType[] = []; + + for (let s = 0; s < SPACE_COUNT; s++) { + const { user: owner } = await ctx.newUser(); + const { space } = await ctx.newSharedSpace({ createdById: owner.id }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: caller.id }); + spaceIds.push(space.id); + + for (let a = 0; a < ASSETS_PER_OWNER; a++) { + const index = s * ASSETS_PER_OWNER + a; + const takenAt = new Date(Date.now() - index * 60 * 60 * 1000); + assetRows.push( + mediumFactory.assetInsert({ + ownerId: owner.id, + originalFileName: `perf-${index}.jpg`, + fileCreatedAt: takenAt, + fileModifiedAt: takenAt, + localDateTime: takenAt, + }), + ); + } + } + + // Bulk-insert everything in 1,000-row chunks — never row-by-row for 10k+ rows. + for (const assetChunk of chunk(assetRows, CHUNK_SIZE)) { + await database.insertInto('asset').values(assetChunk).execute(); + } + + // getTimeBucket's projection stage inner-joins asset_exif — a real asset always has one, so + // the perf shape needs it too (otherwise the join silently drops every seeded row). + const exifRows = assetRows.map((asset) => ({ assetId: asset.id })); + for (const exifChunk of chunk(exifRows, CHUNK_SIZE)) { + await database.insertInto('asset_exif').values(exifChunk).execute(); + } + + const spaceAssetRows = assetRows.map((asset, index) => ({ + spaceId: spaceIds[Math.floor(index / ASSETS_PER_OWNER)], + assetId: asset.id, + addedById: null, + })); + for (const spaceAssetChunk of chunk(spaceAssetRows, CHUNK_SIZE)) { + await database.insertInto('shared_space_asset').values(spaceAssetChunk).execute(); + } + + // The caller favorites every seeded asset — none of which they own. + const favoriteRows = assetRows.map((asset) => ({ userId: caller.id, assetId: asset.id })); + for (const favoriteChunk of chunk(favoriteRows, CHUNK_SIZE)) { + await database.insertInto('asset_favorite').values(favoriteChunk).execute(); + } + + // Mirrors timeline.service.ts buildTimeBucketOptions output for a favorite-filtered, + // withSharedSpaces timeline request. + const options = { + isFavorite: true, + timelineSpaceIds: spaceIds, + userIds: [caller.id], + authUserId: caller.id, + visibility: AssetVisibility.Timeline, + withStacked: true, + bucketSize: TimeBucketSize.Month, + }; + + const auth = factory.auth({ user: { id: caller.id } }); + + // Warm-up: untimed, so the measured calls below aren't paying for cold caches / plan + // compilation. + await sut.getTimeBuckets(options, auth); + + const bucketsStart = performance.now(); + const buckets = await sut.getTimeBuckets(options, auth); + const bucketsMs = performance.now() - bucketsStart; + + // Sanity: the seeded favorites must actually be what's being measured — a query that + // silently returned nothing would still be "fast". + const totalFavorites = buckets.reduce((sum, bucket) => sum + Number(bucket.count), 0); + expect(totalFavorites).toBe(TOTAL_ASSETS); + expect(buckets.length).toBeGreaterThan(0); + + const firstBucket = buckets[0].timeBucket; + + const bucketStart = performance.now(); + const page = await sut.getTimeBucket(firstBucket, options, auth); + const bucketMs = performance.now() - bucketStart; + + const parsed = JSON.parse(page.assets) as TimeBucketAssets; + expect(parsed.id.length).toBeGreaterThan(0); + + // This line's numbers go into the PR description — keep it grep-able. + console.log( + `[perf #763 slice-4] buckets=${bucketsMs.toFixed(1)}ms firstBucket=${bucketMs.toFixed(1)}ms (10 spaces / 10k favorites)`, + ); + + // Generous — expected ~tens of ms. This tripwire is meant to catch the People-page-JIT + // class of regression, not CI jitter. + expect(bucketsMs).toBeLessThan(PERF_CEILING_MS); + expect(bucketMs).toBeLessThan(PERF_CEILING_MS); + }, 120_000); +}); diff --git a/server/test/medium/specs/repositories/map.repository.spec.ts b/server/test/medium/specs/repositories/map.repository.spec.ts index 8a08d0b44633a4..80b97ed44501c9 100644 --- a/server/test/medium/specs/repositories/map.repository.spec.ts +++ b/server/test/medium/specs/repositories/map.repository.spec.ts @@ -292,6 +292,25 @@ describe(MapRepository.name, () => { expect(results.find((r) => r.id === asset.id)).toBeUndefined(); }); + + // #763 slice 1 Task 3 — isFavorite must resolve against the per-user asset_favorite overlay, + // not the ownership-masked asset.isFavorite column. asset.isFavorite is left at its default + // (false) on both assets; only a direct asset_favorite insert marks the caller's favorite. + it('filters isFavorite from the callers overlay, not asset.isFavorite (#763)', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset: favorited } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Timeline }); + const { asset: notFavorited } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Timeline }); + await withGps(ctx, favorited.id, 10, 10); + await withGps(ctx, notFavorited.id, 20, 20); + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: favorited.id }).execute(); + + const favoriteResults = await sut.getMapMarkers(user.id, [user.id], [], { isFavorite: true }); + expect(favoriteResults.map((r) => r.id)).toEqual([favorited.id]); + + const nonFavoriteResults = await sut.getMapMarkers(user.id, [user.id], [], { isFavorite: false }); + expect(nonFavoriteResults.map((r) => r.id)).toEqual([notFavorited.id]); + }); }); describe('getAlbumMapMarkers', () => { diff --git a/server/test/medium/specs/repositories/memory.repository.spec.ts b/server/test/medium/specs/repositories/memory.repository.spec.ts index beb56986b324cb..8033ca9cf08acc 100644 --- a/server/test/medium/specs/repositories/memory.repository.spec.ts +++ b/server/test/medium/specs/repositories/memory.repository.spec.ts @@ -396,4 +396,51 @@ describe(MemoryRepository.name, () => { await expect(sut.getOldestMemoryDate()).resolves.toEqual(new Date('2025-12-25T00:00:00Z')); }); }); + + // #763 slice 1b Task 2 — mapMemory feeds mapAsset with each memory's `assets` array, so the + // overlay must be projected onto those rows keyed to the CALLER (the viewer), not the memory + // owner — memories are frequently viewed by someone other than their owner (partner/space share). + describe('searchAccessible (#763)', () => { + it('projects isFavoriteForUser for the requesting user, independent of the memory owner', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const { space } = await ctx.newSharedSpace({ createdById: owner.id }); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id, addedById: owner.id }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: viewer.id }); + const { memory } = await ctx.newMemory({ ownerId: owner.id }); + await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id }); + + await ctx.database.insertInto('asset_favorite').values({ userId: viewer.id, assetId: asset.id }).execute(); + + const [asOwner] = await sut.searchAccessible(owner.id, {}); + expect(asOwner.assets[0].isFavoriteForUser).toBe(false); + + const [asViewer] = await sut.searchAccessible(viewer.id, {}); + expect(asViewer.assets[0].isFavoriteForUser).toBe(true); + }); + }); + + describe('get (#763)', () => { + it('projects isFavoriteForUser for the supplied authUserId', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + const { memory } = await ctx.newMemory({ ownerId: owner.id }); + await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id }); + + await ctx.database.insertInto('asset_favorite').values({ userId: viewer.id, assetId: asset.id }).execute(); + + const asOwner = await sut.get(memory.id, owner.id); + expect(asOwner?.assets[0].isFavoriteForUser).toBe(false); + + const asViewer = await sut.get(memory.id, viewer.id); + expect(asViewer?.assets[0].isFavoriteForUser).toBe(true); + + const withoutAuthUserId = await sut.get(memory.id); + expect(withoutAuthUserId?.assets[0].isFavoriteForUser).toBeUndefined(); + }); + }); }); diff --git a/server/test/medium/specs/repositories/search.repository.spec.ts b/server/test/medium/specs/repositories/search.repository.spec.ts index bd537cd54adebc..32463cc7b797a1 100644 --- a/server/test/medium/specs/repositories/search.repository.spec.ts +++ b/server/test/medium/specs/repositories/search.repository.spec.ts @@ -63,7 +63,6 @@ describe(SearchRepository.name, () => { const { asset: february } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Video, - isFavorite: true, fileCreatedAt: new Date('2024-02-20T10:00:00.000Z'), localDateTime: new Date('2024-02-20T10:00:00.000Z'), }); @@ -496,6 +495,10 @@ describe(SearchRepository.name, () => { const result = await sut.getSmartSearchFacets({ userIds: [user.id], + // #763: hasFavorites is per-caller, so the caller has to be named. Without it the facet + // fail-safes to false (pinned by the sibling test below) rather than answering for whoever + // happens to head `userIds`. + callerId: user.id, embedding: matchingEmbedding, isFavorite: false, }); @@ -647,11 +650,170 @@ describe(SearchRepository.name, () => { const { asset: plain } = await ctx.newAsset({ ownerId: user.id }); await addEmbedding(defaultDatabase, plain.id); - const result = await sut.getSmartSearchFacets({ userIds: [user.id], embedding: matchingEmbedding }); + // #763: `callerId` is what makes hasFavorites answerable — see the sibling test below, which + // pins that omitting it yields false rather than another user's favourite state. + const result = await sut.getSmartSearchFacets({ + userIds: [user.id], + callerId: user.id, + embedding: matchingEmbedding, + }); expect(result.total).toBe(2); expect(result.hasFavorites).toBe(true); }); + + // #763: the same fixture as above, minus the caller. `hasFavorites` is a per-user question, so + // with no caller identity in the options there is no answer to give and the facet must stay + // false — the section is not offered rather than being answered for the wrong user. + // + // Seeding a real favourite is what makes this a pin rather than a tautology: the assertion can + // only pass because the caller is absent, not because the scope is empty. It also guards the + // reverse regression — resolving the caller from `userIds[0]` would report `true` here, which + // is another user's favourite state leaking into this viewer's facets. + it('reports hasFavorites false on the smart path when no caller identity is supplied (#763)', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset: favourite } = await ctx.newAsset({ ownerId: user.id }); + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: favourite.id }).execute(); + await addEmbedding(defaultDatabase, favourite.id); + + const result = await sut.getSmartSearchFacets({ userIds: [user.id], embedding: matchingEmbedding }); + + expect(result.total).toBe(1); + expect(result.hasFavorites).toBe(false); + }); + }); + + // #763 slice 1 Task 3 — isFavorite must resolve against the per-user asset_favorite overlay, not + // the (now-dropped, slice 3) ownership-masked asset.isFavorite column. Only a direct + // asset_favorite insert marks the caller's favorite. + describe('isFavorite overlay (#763)', () => { + it('searchMetadata filters isFavorite from the callers overlay, not asset.isFavorite', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset: favorited } = await ctx.newAsset({ ownerId: user.id }); + const { asset: notFavorited } = await ctx.newAsset({ ownerId: user.id }); + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: favorited.id }).execute(); + + const { items: favoriteItems } = await sut.searchMetadata( + { page: 1, size: 100 }, + { userIds: [user.id], authUserId: user.id, isFavorite: true }, + ); + expect(favoriteItems.map((item) => item.id)).toEqual([favorited.id]); + + const { items: nonFavoriteItems } = await sut.searchMetadata( + { page: 1, size: 100 }, + { userIds: [user.id], authUserId: user.id, isFavorite: false }, + ); + expect(nonFavoriteItems.map((item) => item.id)).toEqual([notFavorited.id]); + }); + + it('getSmartSearchFacets scopes isFavorite totals to the callers overlay', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset: favorited } = await ctx.newAsset({ ownerId: user.id }); + const { asset: notFavorited } = await ctx.newAsset({ ownerId: user.id }); + await addEmbedding(ctx.database, favorited.id); + await addEmbedding(ctx.database, notFavorited.id); + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: favorited.id }).execute(); + + const result = await sut.getSmartSearchFacets({ + embedding: matchingEmbedding, + userIds: [user.id], + authUserId: user.id, + maxDistance: 0.01, + isFavorite: true, + }); + + expect(result.total).toBe(1); + }); + + it('getFilterSuggestions (buildFilteredAssetIds) scopes isFavorite to the callers overlay', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset: favorited } = await ctx.newAsset({ ownerId: user.id }); + const { asset: notFavorited } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: favorited.id, country: 'Germany' }); + await ctx.newExif({ assetId: notFavorited.id, country: 'France' }); + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: favorited.id }).execute(); + + const result = await sut.getFilterSuggestions([user.id], { isFavorite: true }); + + expect(result.countries).toEqual(['Germany']); + }); + }); + + // #763 slice 1b Task 2 — mapAsset now reads `isFavoriteForUser` exclusively (slice 1b Task 1), + // so every row-returning search path must project the per-user asset_favorite overlay keyed to + // the CALLER (authUserId) — never to userIds[0] or the asset owner (slice 1 proved that wrong + // for the space-browse path, where userIds can be undefined entirely). + describe('isFavoriteForUser projection (#763 slice 1b)', () => { + it('searchMetadata projects isFavoriteForUser from authUserId, not from userIds[0] or the owner', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + await ctx.database.insertInto('asset_favorite').values({ userId: viewer.id, assetId: asset.id }).execute(); + + const { items: asOwner } = await sut.searchMetadata( + { page: 1, size: 100 }, + { userIds: [owner.id], authUserId: owner.id }, + ); + expect(asOwner.find((item) => item.id === asset.id)?.isFavoriteForUser).toBe(false); + + const { items: asViewer } = await sut.searchMetadata( + { page: 1, size: 100 }, + { userIds: [owner.id], authUserId: viewer.id }, + ); + expect(asViewer.find((item) => item.id === asset.id)?.isFavoriteForUser).toBe(true); + }); + + it('searchRandom projects isFavoriteForUser for the requesting user', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + await ctx.database.insertInto('asset_favorite').values({ userId: viewer.id, assetId: asset.id }).execute(); + + const asViewer = await sut.searchRandom(100, { userIds: [owner.id], authUserId: viewer.id }); + expect(asViewer.find((item) => item.id === asset.id)?.isFavoriteForUser).toBe(true); + + const asOwner = await sut.searchRandom(100, { userIds: [owner.id], authUserId: owner.id }); + expect(asOwner.find((item) => item.id === asset.id)?.isFavoriteForUser).toBe(false); + }); + + it('searchLargeAssets projects isFavoriteForUser for the requesting user', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: 5000 }); + await ctx.database.insertInto('asset_favorite').values({ userId: viewer.id, assetId: asset.id }).execute(); + + const asViewer = await sut.searchLargeAssets(100, { userIds: [owner.id], authUserId: viewer.id }); + expect(asViewer.find((item) => item.id === asset.id)?.isFavoriteForUser).toBe(true); + + const asOwner = await sut.searchLargeAssets(100, { userIds: [owner.id], authUserId: owner.id }); + expect(asOwner.find((item) => item.id === asset.id)?.isFavoriteForUser).toBe(false); + }); + + it('getAssetsByCity projects isFavoriteForUser for the requesting user', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id, type: AssetType.Image }); + await ctx.newExif({ assetId: asset.id, city: 'Berlin' }); + await ctx.database.insertInto('asset_favorite').values({ userId: viewer.id, assetId: asset.id }).execute(); + + const asViewer = await sut.getAssetsByCity([owner.id], undefined, viewer.id); + expect(asViewer.find((item) => item.id === asset.id)?.isFavoriteForUser).toBe(true); + + const asOwner = await sut.getAssetsByCity([owner.id], undefined, owner.id); + expect(asOwner.find((item) => item.id === asset.id)?.isFavoriteForUser).toBe(false); + + const withoutAuthUserId = await sut.getAssetsByCity([owner.id]); + expect(withoutAuthUserId.find((item) => item.id === asset.id)?.isFavoriteForUser).toBeUndefined(); + }); }); describe('getFilterSuggestions', () => { @@ -818,11 +980,40 @@ describe(SearchRepository.name, () => { expect(result.hasFavorites).toBe(false); }); + // #763: the probe accepts a SEPARATE, wider space scope so the Favourites section is still + // offered for a favourite whose space the caller hid from their timeline. `timelineSpaceIds` + // stays narrow here — that is the point: the two lists must be honoured independently, and a + // single-scope implementation cannot pass both this and the test below it. + it('reports hasFavorites from favoriteSpaceIds even when the space is outside timelineSpaceIds (#763)', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: member } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + await ctx.database.insertInto('asset_favorite').values({ userId: member.id, assetId: asset.id }).execute(); + + const { space } = await ctx.newSharedSpace({ createdById: owner.id }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: owner.id, role: 'owner' }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: member.id, role: 'viewer' }); + await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id, addedById: owner.id }); + + // The space is hidden from the member's timeline, so it is absent from timelineSpaceIds... + const hidden = await sut.getFilterSuggestions([member.id], {}); + expect(hidden.hasFavorites).toBe(false); + + // ...but the favourites probe runs over the memberships instead, and still finds it. + const widened = await sut.getFilterSuggestions([member.id], { favoriteSpaceIds: [space.id] }); + expect(widened.hasFavorites).toBe(true); + }); + it('sees a shared-space favourite only with timelineSpaceIds (#910)', async () => { const { ctx, sut } = setup(); const { user: owner } = await ctx.newUser(); const { user: member } = await ctx.newUser(); - const { asset } = await ctx.newAsset({ ownerId: owner.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + // #763: favourites are per (user, asset), so the MEMBER — the caller in both assertions + // below — has to be the one who favourited it. Keying this to the owner instead would make + // the test assert that one member's favourite shows up in another's facets. + await ctx.database.insertInto('asset_favorite').values({ userId: member.id, assetId: asset.id }).execute(); // A distinctive make so the second assertion can identify THIS asset specifically — // a `toBe(true)` non-emptiness check alone would also pass for an over-broad scope // that leaked in unrelated assets. @@ -1011,12 +1202,16 @@ describe(SearchRepository.name, () => { const { ctx, sut } = setup(); const { user } = await ctx.newUser(); - const { asset: r5 } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + const { asset: r5 } = await ctx.newAsset({ ownerId: user.id }); await ctx.newExif({ assetId: r5.id, make: 'Canon', model: 'Canon EOS R5' }); const { asset: sevenD } = await ctx.newAsset({ ownerId: user.id }); await ctx.newExif({ assetId: sevenD.id, make: 'Canon', model: 'Canon EOS 7D' }); + // #763: the dropped asset."isFavorite" column is replaced by the per-user overlay. The + // narrowing predicate resolves it for userIds[0], so favorite it as `user`. + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: r5.id }).execute(); + const models = await sut.getCameraModels([user.id], { make: 'Canon', isFavorite: true }); expect(models).toEqual(['Canon EOS R5']); @@ -1242,12 +1437,16 @@ describe(SearchRepository.name, () => { const { ctx, sut } = setup(); const { user } = await ctx.newUser(); - const { asset: canonAsset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + const { asset: canonAsset } = await ctx.newAsset({ ownerId: user.id }); await ctx.newExif({ assetId: canonAsset.id, make: 'Canon', rating: 5 }); const { asset: nikonAsset } = await ctx.newAsset({ ownerId: user.id }); await ctx.newExif({ assetId: nikonAsset.id, make: 'Nikon', rating: 2 }); + // #763: the dropped asset."isFavorite" column is replaced by the per-user overlay. The + // narrowing predicate resolves it for userIds[0], so favorite it as `user`. + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: canonAsset.id }).execute(); + const makes = await sut.getCameraMakes([user.id], { rating: 4, isFavorite: true }); expect(makes).toEqual(['Canon']); diff --git a/server/test/medium/specs/repositories/stack.repository.spec.ts b/server/test/medium/specs/repositories/stack.repository.spec.ts index 7748ea314ca936..422f7b2ea7bd1e 100644 --- a/server/test/medium/specs/repositories/stack.repository.spec.ts +++ b/server/test/medium/specs/repositories/stack.repository.spec.ts @@ -159,4 +159,33 @@ describe(StackRepository.name, () => { expect(sorted(result)).toEqual(sorted([primary.id, hidden.id])); }); }); + + // #763 slice 1b Task 2 — stack.dto.ts's mapStack feeds each stack asset through mapAsset, which + // now reads `isFavoriteForUser` exclusively (slice 1b Task 1). getById (and search/create/update, + // sharing the withAssets helper) must project the overlay keyed to the CALLER. + describe('getById (#763)', () => { + it('projects isFavoriteForUser onto stack.assets for the requesting user, independent of the owner', async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const { asset: primary } = await ctx.newAsset({ ownerId: owner.id }); + const { asset: child } = await ctx.newAsset({ ownerId: owner.id }); + // withAssets inner-joins asset_exif (via innerJoinLateral) — every asset needs a row there + // to be included at all. + await ctx.newExif({ assetId: primary.id, make: 'Test' }); + await ctx.newExif({ assetId: child.id, make: 'Test' }); + const { stack } = await ctx.newStack({ ownerId: owner.id }, [primary.id, child.id]); + + await ctx.database.insertInto('asset_favorite').values({ userId: viewer.id, assetId: child.id }).execute(); + + const asOwner = await sut.getById(stack.id, owner.id); + expect(asOwner?.assets.find((a) => a.id === child.id)?.isFavoriteForUser).toBe(false); + + const asViewer = await sut.getById(stack.id, viewer.id); + expect(asViewer?.assets.find((a) => a.id === child.id)?.isFavoriteForUser).toBe(true); + + const withoutAuthUserId = await sut.getById(stack.id); + expect(withoutAuthUserId?.assets.find((a) => a.id === child.id)?.isFavoriteForUser).toBeUndefined(); + }); + }); }); diff --git a/server/test/medium/specs/repositories/view-repository.spec.ts b/server/test/medium/specs/repositories/view-repository.spec.ts index 97f15aab9eb833..958c7ef661528f 100644 --- a/server/test/medium/specs/repositories/view-repository.spec.ts +++ b/server/test/medium/specs/repositories/view-repository.spec.ts @@ -361,5 +361,21 @@ describe(ViewRepository.name, () => { expect(result.map((a) => a.id)).toContain(asset.id); }); + + // #763 slice 1b Task 2, E1 — mapAsset feeds isFavorite from `isFavoriteForUser` exclusively; + // the folder-contents query must project the per-user overlay, not the asset owner's flag. + it('E1 — a space member sees their own favorite here, independent of the owner (#763)', async () => { + const { ctx, sut } = setup(); + const path = '/archive/contents/favorite-e1'; + const { owner, member, asset } = await seedDirectSpaceAsset(ctx, SharedSpaceRole.Viewer, `${path}/IMG.jpg`); + + await ctx.database.insertInto('asset_favorite').values({ userId: member.id, assetId: asset.id }).execute(); + + const asMember = await sut.getAssetsByOriginalPath(member.id, path); + expect(asMember).toEqual([expect.objectContaining({ id: asset.id, isFavoriteForUser: true })]); + + const asOwner = await sut.getAssetsByOriginalPath(owner.id, path); + expect(asOwner).toEqual([expect.objectContaining({ id: asset.id, isFavoriteForUser: false })]); + }); }); }); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 232f101a7482ce..5445d7fb3311fa 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -6,6 +6,7 @@ import { AssetFileType, AssetMetadataKey, AssetStatus, AssetVisibility, JobName, import { AccessRepository } from 'src/repositories/access.repository.js'; import { AlbumRepository } from 'src/repositories/album.repository.js'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { AssetJobRepository } from 'src/repositories/asset-job.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; import { EventRepository } from 'src/repositories/event.repository.js'; @@ -34,6 +35,7 @@ const setup = (db?: Kysely) => { real: [ AssetRepository, AssetEditRepository, + AssetFavoriteRepository, AssetJobRepository, AlbumRepository, AccessRepository, @@ -99,6 +101,29 @@ describe(AssetService.name, () => { }); }); + // #763 slice 1b Task 2 — mapAsset now reads `isFavoriteForUser` exclusively (slice 1b Task 1); + // `get` must thread the CALLER's id (auth.user.id) into the repository projection, not the + // asset owner's. + describe('get (#763)', () => { + it('E1 — a space member sees their own favorite, independent of the owner', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: member } = await ctx.newUser(); + const { space } = await ctx.newSharedSpace({ createdById: owner.id }); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id, addedById: owner.id }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: member.id, role: 'viewer' }); + + await ctx.database.insertInto('asset_favorite').values({ userId: member.id, assetId: asset.id }).execute(); + + const memberAuth = factory.auth({ user: { id: member.id } }); + await expect(sut.get(memberAuth, asset.id)).resolves.toMatchObject({ isFavorite: true }); + + const ownerAuth = factory.auth({ user: { id: owner.id } }); + await expect(sut.get(ownerAuth, asset.id)).resolves.toMatchObject({ isFavorite: false }); + }); + }); + describe('copy', () => { it('should copy albums', async () => { const { sut, ctx } = setup(); @@ -216,13 +241,18 @@ describe(AssetService.name, () => { expect(stack!.assets.length).toEqual(3); }); - it('should copy favorite status', async () => { + // #763 slice 7 (E20): copy carries the ACTING user's own overlay row for the source, not the + // raw (now-legacy) asset.isFavorite column — `ctx.newAsset({ isFavorite: true })` only seeds + // that column for backfill-style fixtures, so the acting user must ALSO hold a real + // asset_favorite row on the source for it to transfer. + it('should copy favorite status for the acting user', async () => { const { sut, ctx } = setup(); - const assetRepo = ctx.get(AssetRepository); + const assetFavoriteRepo = ctx.get(AssetFavoriteRepository); const { user } = await ctx.newUser(); - const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id }); const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id }); + await assetFavoriteRepo.addAll(user.id, [oldAsset.id]); await ctx.newExif({ assetId: oldAsset.id, description: 'foo' }); await ctx.newExif({ assetId: newAsset.id, description: 'bar' }); @@ -230,7 +260,35 @@ describe(AssetService.name, () => { const auth = factory.auth({ user: { id: user.id } }); await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id }); - await expect(assetRepo.getById(newAsset.id)).resolves.toEqual(expect.objectContaining({ isFavorite: true })); + await expect(sut.get(auth, newAsset.id)).resolves.toEqual(expect.objectContaining({ isFavorite: true })); + }); + + it("should not copy another user's favorite of the source (E20)", async () => { + const { sut, ctx } = setup(); + const assetFavoriteRepo = ctx.get(AssetFavoriteRepository); + + const { user: owner } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { space } = await ctx.newSharedSpace({ createdById: owner.id }); + const { asset: oldAsset } = await ctx.newAsset({ ownerId: owner.id }); + const { asset: newAsset } = await ctx.newAsset({ ownerId: owner.id }); + await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: oldAsset.id, addedById: owner.id }); + await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: newAsset.id, addedById: owner.id }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: otherUser.id, role: 'viewer' }); + + // otherUser favorited the source; owner (the ACTOR performing the copy) did not. + await assetFavoriteRepo.addAll(otherUser.id, [oldAsset.id]); + + await ctx.newExif({ assetId: oldAsset.id, description: 'foo' }); + await ctx.newExif({ assetId: newAsset.id, description: 'bar' }); + + const ownerAuth = factory.auth({ user: { id: owner.id } }); + await sut.copy(ownerAuth, { sourceId: oldAsset.id, targetId: newAsset.id }); + + await expect(sut.get(ownerAuth, newAsset.id)).resolves.toEqual(expect.objectContaining({ isFavorite: false })); + + const otherAuth = factory.auth({ user: { id: otherUser.id } }); + await expect(sut.get(otherAuth, newAsset.id)).resolves.toEqual(expect.objectContaining({ isFavorite: false })); }); it('should copy sidecar file', async () => { @@ -396,6 +454,22 @@ describe(AssetService.name, () => { ); }); + // #763 slice 1b Task 2 — the update return path feeds mapAsset, which now reads + // `isFavoriteForUser` exclusively; `update` must thread auth.user.id into the repository + // projection so the response reflects the CALLER's own overlay, not the owner's. + it('projects isFavoriteForUser for the requesting user (#763)', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: asset.id }).execute(); + + await expect(sut.update(auth, asset.id, { description: 'updated' })).resolves.toMatchObject({ + isFavorite: true, + }); + }); + it('should automatically lock lockable columns', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queue.mockResolvedValue(); diff --git a/server/test/medium/specs/services/duplicate.service.spec.ts b/server/test/medium/specs/services/duplicate.service.spec.ts index d573745b09a937..ca44bcdf7aa3e2 100644 --- a/server/test/medium/specs/services/duplicate.service.spec.ts +++ b/server/test/medium/specs/services/duplicate.service.spec.ts @@ -3,6 +3,7 @@ import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.respons import { AssetStatus, AssetVisibility } from 'src/enum.js'; import { AccessRepository } from 'src/repositories/access.repository.js'; import { AlbumRepository } from 'src/repositories/album.repository.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; import { ConfigRepository } from 'src/repositories/config.repository.js'; import { DuplicateRepository } from 'src/repositories/duplicate.repository.js'; @@ -28,6 +29,11 @@ const setup = (db?: Kysely) => { AccessRepository, AlbumRepository, AssetRepository, + // gallery-fork (#763): per-user favorites live in their own overlay table, so the keeper's + // merged favorite is written by AssetFavoriteRepository.mergeOnto rather than an + // asset."isFavorite" column. Real rather than mocked: 'should merge isFavorite into the + // keeper' asserts the merged row is readable back through AssetRepository.getById. + AssetFavoriteRepository, ConfigRepository, DuplicateRepository, // gallery-fork: resolveGroup carries the keeper's shared-space membership (#317), so it diff --git a/server/test/medium/specs/services/favorite-access-revocation.medium.spec.ts b/server/test/medium/specs/services/favorite-access-revocation.medium.spec.ts new file mode 100644 index 00000000000000..15f00164004c00 --- /dev/null +++ b/server/test/medium/specs/services/favorite-access-revocation.medium.spec.ts @@ -0,0 +1,249 @@ +// #763 design §5.2 + edge case E10: favorite rows are NEVER cleaned up when a user loses access. +// `asset_favorite` is an overlay keyed by (userId, assetId) with no membership awareness of its +// own, so the ONLY thing keeping a departed member's favorite out of their favorites surfaces is +// the read path re-deriving visibility from LIVE membership on every request. That makes the +// invariant a pure query-scoping property — which is why this lives at the medium level (real +// Postgres) rather than in a service unit test: mocking `getSpaceIdsForTimeline` would assert the +// mock, not the SQL. +// +// The chain under test, driven through the REAL services so both halves run for real: +// 1. `SharedSpaceRepository.getSpaceIdsForTimeline(userId)` selects `shared_space_member` rows +// for `userId` with `showInTimeline = true` — CURRENT membership, resolved per request. +// 2. `timeline.service.ts` `buildTimeBucketOptions` / `search.service.ts` `getTimelineSpaceIds` +// set `timelineSpaceIds` from that, and ONLY when the result is non-empty. +// 3. `withTimeBucketAssetFilters` (asset.repository.ts) and `searchAssetBuilderLegacy` +// (utils/database.ts) each apply the per-user favorite predicate +// `favoriteExistsFor(eb, authUserId)` as a SEPARATE, ANDed `where` alongside the RBAC scope: +// the `timelineSpaceIds` arm when spaces are in play, otherwise a bare +// `asset.ownerId = anyUuid(userIds)`. +// So a revoked member's lingering overlay row can never surface the asset: it is excluded by the +// scope before the favorite predicate is reached. +// +// Both surfaces are covered because they are different query builders that each carry their own +// copy of the scope/favorite pairing, and a regression in one would not show up in the other: +// - timeline / time-bucket path — `AssetRepository.getTimeBuckets` + `getTimeBucket`, what the +// web Favorites route and the Photos timeline favorites filter actually call. +// - search path — `SearchRepository.searchMetadata` -> `searchAssetBuilderLegacy`, what the +// `/search` favorites filter and "add all to collection" call. +// +// E10 is also covered end-to-end by e2e/src/specs/server/api/asset-favorite.e2e-spec.ts, but only +// via bucket COUNTS on the timeline path, with no control asset — an accidentally +// returns-nothing query would still read as green there. Every assertion below pairs the +// must-disappear asset with a must-remain one in the SAME response. +import { Kysely } from 'kysely'; +import { AuthDto } from 'src/dtos/auth.dto.js'; +import { AssetVisibility, SharedSpaceRole } from 'src/enum.js'; +import { AccessRepository } from 'src/repositories/access.repository.js'; +import { AssetRepository } from 'src/repositories/asset.repository.js'; +import { DatabaseRepository } from 'src/repositories/database.repository.js'; +import { FaceIdentityRepository } from 'src/repositories/face-identity.repository.js'; +import { LoggingRepository } from 'src/repositories/logging.repository.js'; +import { PartnerRepository } from 'src/repositories/partner.repository.js'; +import { PersonRepository } from 'src/repositories/person.repository.js'; +import { SearchRepository } from 'src/repositories/search.repository.js'; +import { SharedSpaceRepository } from 'src/repositories/shared-space.repository.js'; +import { TagRepository } from 'src/repositories/tag.repository.js'; +import { DB } from 'src/schema/index.js'; +import { SearchService } from 'src/services/search.service.js'; +import { TimelineService } from 'src/services/timeline.service.js'; +import { newMediumService } from 'test/medium.factory.js'; +import { factory } from 'test/small.factory.js'; +import { getKyselyDB } from 'test/utils.js'; + +let defaultDatabase: Kysely; + +// Every seeded asset lands in this month bucket so a single getTimeBucket call sees them all. +const BUCKET = '2024-05-01'; + +// `withSharedSpaces` is what makes the space arm resolve at all, and timeBucketChecks rejects it +// unless an explicit non-archive visibility is supplied. +const FAVORITE_FILTER = { + isFavorite: true, + withSharedSpaces: true, + visibility: AssetVisibility.Timeline, +} as const; + +const setup = (db?: Kysely) => { + const database = db || defaultDatabase; + const timeline = newMediumService(TimelineService, { + database, + real: [AssetRepository, AccessRepository, PartnerRepository, SharedSpaceRepository], + mock: [LoggingRepository], + }); + const search = newMediumService(SearchService, { + database, + real: [ + AccessRepository, + AssetRepository, + DatabaseRepository, + FaceIdentityRepository, + PartnerRepository, + PersonRepository, + SearchRepository, + SharedSpaceRepository, + TagRepository, + ], + mock: [LoggingRepository], + }); + + // Both services share one database, so either ctx seeds for both. + return { + ctx: timeline.ctx, + timeline: timeline.sut, + search: search.sut, + spaces: timeline.ctx.get(SharedSpaceRepository), + }; +}; + +type Ctx = ReturnType['ctx']; + +// getTimeBucket's projection stage inner-joins asset_exif, so a seeded asset without an exif row +// is silently dropped from the bucket — which would make the absence assertions meaningless. +const seedAsset = async (ctx: Ctx, ownerId: string, localDateTime: Date) => { + const { asset } = await ctx.newAsset({ ownerId, fileCreatedAt: localDateTime, localDateTime }); + await ctx.newExif({ assetId: asset.id, make: 'Canon', timeZone: 'UTC' }); + return asset; +}; + +const favorite = (ctx: Ctx, userId: string, assetId: string) => + ctx.database.insertInto('asset_favorite').values({ userId, assetId }).execute(); + +const revokeMembership = (ctx: Ctx, spaceId: string, userId: string) => + ctx.database.deleteFrom('shared_space_member').where('spaceId', '=', spaceId).where('userId', '=', userId).execute(); + +const favoriteBucketTotal = async (sut: TimelineService, auth: AuthDto) => { + // Spread per call: timeBucketChecks mutates the dto (`dto.userId ||= auth.user.id`). + const buckets = await sut.getTimeBuckets(auth, { ...FAVORITE_FILTER }); + return buckets.reduce((sum, bucket) => sum + Number(bucket.count), 0); +}; + +const favoriteBucketAssets = (sut: TimelineService, auth: AuthDto) => + sut.getTimeBucket(auth, { ...FAVORITE_FILTER, timeBucket: BUCKET }); + +const favoriteSearchIds = async (sut: SearchService, auth: AuthDto) => { + const response = await sut.searchMetadata(auth, { size: 250, ...FAVORITE_FILTER }); + return response.assets.items.map((item) => item.id); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}, 30_000); + +describe('favorites and access revocation (#763 E10)', () => { + it('hides a favorite on a space asset once the member leaves, while keeping their own favorites', async () => { + const { ctx, timeline, search, spaces } = setup(); + + const { user: owner } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const viewerAuth = factory.auth({ user: { id: viewer.id, email: viewer.email } }); + + const { space } = await ctx.newSharedSpace({ createdById: owner.id }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: owner.id, role: SharedSpaceRole.Owner }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: viewer.id, role: SharedSpaceRole.Viewer }); + + // The owner's asset, reachable by the viewer ONLY through space membership. + const ownerAsset = await seedAsset(ctx, owner.id, new Date('2024-05-10T12:00:00Z')); + await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: ownerAsset.id, addedById: owner.id }); + + // The control: the viewer's own asset, reachable regardless of any membership. Present in + // every response below, so none of the absence assertions can pass on an empty result. + const ownAsset = await seedAsset(ctx, viewer.id, new Date('2024-05-20T12:00:00Z')); + + // #763: canFavorite is deliberately NOT ownership-gated — a read-only Viewer may favorite + // another member's asset. Inserted directly; the write path is covered by + // asset-favorite.repository.spec.ts and the e2e suite. + await favorite(ctx, viewer.id, ownerAsset.id); + await favorite(ctx, viewer.id, ownAsset.id); + + // --- while the membership is live ------------------------------------------------------- + expect(await spaces.getSpaceIdsForTimeline(viewer.id)).toEqual([{ spaceId: space.id }]); + expect(await favoriteBucketTotal(timeline, viewerAuth)).toBe(2); + + const bucketBefore = await favoriteBucketAssets(timeline, viewerAuth); + expect(bucketBefore).toContain(ownerAsset.id); + expect(bucketBefore).toContain(ownAsset.id); + + const searchBefore = await favoriteSearchIds(search, viewerAuth); + expect(searchBefore.toSorted()).toEqual([ownerAsset.id, ownAsset.id].toSorted()); + + // --- membership revoked ----------------------------------------------------------------- + await revokeMembership(ctx, space.id, viewer.id); + + // The mechanism: membership is re-resolved per request, so the space drops out of + // `timelineSpaceIds` entirely and the queries fall back to `asset.ownerId = viewer`. + expect(await spaces.getSpaceIdsForTimeline(viewer.id)).toEqual([]); + + expect(await favoriteBucketTotal(timeline, viewerAuth)).toBe(1); + + const bucketAfter = await favoriteBucketAssets(timeline, viewerAuth); + expect(bucketAfter).not.toContain(ownerAsset.id); + expect(bucketAfter).toContain(ownAsset.id); + + expect(await favoriteSearchIds(search, viewerAuth)).toEqual([ownAsset.id]); + + // §5.2: the overlay row is deliberately NOT cleaned up — visibility is re-derived, so + // rejoining restores the favorite without re-favoriting. And the asset is still in the + // space, which pins the drop-out on the membership loss rather than on the asset leaving. + const rows = await ctx.database + .selectFrom('asset_favorite') + .selectAll() + .where('userId', '=', viewer.id) + .where('assetId', '=', ownerAsset.id) + .execute(); + expect(rows).toHaveLength(1); + + const spaceAssets = await ctx.database + .selectFrom('shared_space_asset') + .selectAll() + .where('spaceId', '=', space.id) + .where('assetId', '=', ownerAsset.id) + .execute(); + expect(spaceAssets).toHaveLength(1); + }); + + // Distinct code path from the test above: with another live membership the caller still has a + // non-empty `timelineSpaceIds`, so the queries stay on the SPACE arm rather than falling back + // to the bare ownerId predicate. The revoked space must simply be absent from the id list that + // arm is scoped by — a narrowing, not a disabled branch. + it('keeps the space arm active for remaining memberships and still excludes the revoked space', async () => { + const { ctx, timeline, search, spaces } = setup(); + + const { user: ownerA } = await ctx.newUser(); + const { user: ownerB } = await ctx.newUser(); + const { user: viewer } = await ctx.newUser(); + const viewerAuth = factory.auth({ user: { id: viewer.id, email: viewer.email } }); + + const { space: revokedSpace } = await ctx.newSharedSpace({ createdById: ownerA.id }); + await ctx.newSharedSpaceMember({ spaceId: revokedSpace.id, userId: ownerA.id, role: SharedSpaceRole.Owner }); + await ctx.newSharedSpaceMember({ spaceId: revokedSpace.id, userId: viewer.id, role: SharedSpaceRole.Viewer }); + + const { space: keptSpace } = await ctx.newSharedSpace({ createdById: ownerB.id }); + await ctx.newSharedSpaceMember({ spaceId: keptSpace.id, userId: ownerB.id, role: SharedSpaceRole.Owner }); + await ctx.newSharedSpaceMember({ spaceId: keptSpace.id, userId: viewer.id, role: SharedSpaceRole.Viewer }); + + const revokedAsset = await seedAsset(ctx, ownerA.id, new Date('2024-05-11T12:00:00Z')); + await ctx.newSharedSpaceAsset({ spaceId: revokedSpace.id, assetId: revokedAsset.id, addedById: ownerA.id }); + + // The control lives in the OTHER space and is owned by neither the viewer nor ownerA, so it + // can only be reached through the space arm — if that arm were disabled instead of narrowed, + // this asset would vanish too and the test would fail loudly. + const keptAsset = await seedAsset(ctx, ownerB.id, new Date('2024-05-21T12:00:00Z')); + await ctx.newSharedSpaceAsset({ spaceId: keptSpace.id, assetId: keptAsset.id, addedById: ownerB.id }); + + await favorite(ctx, viewer.id, revokedAsset.id); + await favorite(ctx, viewer.id, keptAsset.id); + + expect(await favoriteBucketTotal(timeline, viewerAuth)).toBe(2); + + await revokeMembership(ctx, revokedSpace.id, viewer.id); + + expect(await spaces.getSpaceIdsForTimeline(viewer.id)).toEqual([{ spaceId: keptSpace.id }]); + expect(await favoriteBucketTotal(timeline, viewerAuth)).toBe(1); + + const bucketAfter = await favoriteBucketAssets(timeline, viewerAuth); + expect(bucketAfter).not.toContain(revokedAsset.id); + expect(bucketAfter).toContain(keptAsset.id); + + expect(await favoriteSearchIds(search, viewerAuth)).toEqual([keptAsset.id]); + }); +}); diff --git a/server/test/medium/specs/services/job-favorite-payload.spec.ts b/server/test/medium/specs/services/job-favorite-payload.spec.ts new file mode 100644 index 00000000000000..3b0dc30712a5a3 --- /dev/null +++ b/server/test/medium/specs/services/job-favorite-payload.spec.ts @@ -0,0 +1,91 @@ +import { Kysely } from 'kysely'; +import { JobName, JobStatus, QueueName } from 'src/enum.js'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js'; +import { AssetRepository } from 'src/repositories/asset.repository.js'; +import { EventRepository } from 'src/repositories/event.repository.js'; +import { JobRepository } from 'src/repositories/job.repository.js'; +import { LoggingRepository } from 'src/repositories/logging.repository.js'; +import { WebsocketRepository } from 'src/repositories/websocket.repository.js'; +import { DB } from 'src/schema/index.js'; +import { JobService } from 'src/services/job.service.js'; +import { newMediumService } from 'test/medium.factory.js'; +import { getKyselyDB } from 'test/utils.js'; + +// #763 (Task 1 review finding): job.service.ts's AssetEditReadyV2 / AssetUploadReadyV2 websocket +// payloads are built by hand (not via mapAsset) from `assetRepository.getById` / +// `getByIdsWithAllRelationsButStacks` called with NO authUserId, and mapped `isFavorite: +// asset.isFavorite` off the raw `selectAll('asset')` row. The overlay write path (updateFavorites, +// slice 1) never touches that raw column, so these two events went stale the moment #763 shipped — +// this is a LIVE staleness bug, not incidental tsc fallout from the slice-3 column drop. +// +// These events are always sent to the asset's OWNER (`this.websocketRepository.clientSend(event, +// asset.ownerId, ...)`), so the correct semantics are the OWNER's favorite state — exactly what +// `favoriteExistsForOwner` (src/utils/favorite.ts) resolves. This spec seeds the OWNER's favorite +// via the `asset_favorite` overlay ONLY (never the raw column — the app no longer writes it) and +// asserts the emitted payload reflects it, then asserts a NON-owner's favorite does not leak onto +// the owner-scoped payload. +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(JobService, { + database: db || defaultDatabase, + real: [AssetRepository, AssetEditRepository], + mock: [EventRepository, JobRepository, LoggingRepository, WebsocketRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}, 30_000); + +describe('JobService AssetEditReadyV2 favorite staleness (#763)', () => { + it('resolves isFavorite from the OWNER overlay row, not the raw asset column', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).run.mockResolvedValue(JobStatus.Success); + ctx.getMock(EventRepository).emit.mockResolvedValue(undefined as never); + + const { user: owner } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + + // Seed the OWNER's favorite via the overlay ONLY — mirrors how the app writes favorites + // post-#763 (updateFavorites routes through AssetFavoriteRepository, never asset.isFavorite). + // mediumFactory.assetInsert still defaults the raw column to false, so this row diverges from + // the raw column exactly the way a real post-#763 favorite does. + await ctx.database.insertInto('asset_favorite').values({ userId: owner.id, assetId: asset.id }).execute(); + + await sut.onJobRun(QueueName.ThumbnailGeneration, { + name: JobName.AssetEditThumbnailGeneration, + data: { id: asset.id }, + }); + + expect(ctx.getMock(WebsocketRepository).clientSend).toHaveBeenCalledWith( + 'AssetEditReadyV2', + owner.id, + expect.objectContaining({ asset: expect.objectContaining({ isFavorite: true }) }), + ); + }); + + it('does not leak a non-owner favorite onto the owner-scoped payload', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).run.mockResolvedValue(JobStatus.Success); + ctx.getMock(EventRepository).emit.mockResolvedValue(undefined as never); + + const { user: owner } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + + // Only a non-owner favorited the asset — the owner-scoped websocket payload must stay false. + await ctx.database.insertInto('asset_favorite').values({ userId: other.id, assetId: asset.id }).execute(); + + await sut.onJobRun(QueueName.ThumbnailGeneration, { + name: JobName.AssetEditThumbnailGeneration, + data: { id: asset.id }, + }); + + expect(ctx.getMock(WebsocketRepository).clientSend).toHaveBeenCalledWith( + 'AssetEditReadyV2', + owner.id, + expect.objectContaining({ asset: expect.objectContaining({ isFavorite: false }) }), + ); + }); +}); diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index a46e13f969719c..8e458286e078b9 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -413,6 +413,32 @@ describe(MemoryService.name, () => { }), ]); }); + + // #763 slice 1b Task 2 — mapMemory feeds mapAsset, which now reads `isFavoriteForUser` + // exclusively; searchAccessible must project the overlay for the VIEWER, not the memory owner. + it('E1 — a space member sees their own favorite on a shared memory asset, independent of the owner', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: member } = await ctx.newUser(); + const { space } = await ctx.newSharedSpace({ createdById: owner.id }); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + const { memory } = await ctx.newMemory({ ownerId: owner.id }); + await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id }); + await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id, addedById: owner.id }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: member.id }); + + await ctx.database.insertInto('asset_favorite').values({ userId: member.id, assetId: asset.id }).execute(); + + const memberResult = await sut.search(factory.auth({ user: member }), {}); + expect(memberResult.flatMap((m) => m.assets)).toEqual([ + expect.objectContaining({ id: asset.id, isFavorite: true }), + ]); + + const ownerResult = await sut.search(factory.auth({ user: owner }), {}); + expect(ownerResult.flatMap((m) => m.assets)).toEqual([ + expect.objectContaining({ id: asset.id, isFavorite: false }), + ]); + }); }); describe('onMemoryCreate', () => { diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index 5d841bc2ad8a3d..01f48851c64856 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -400,6 +400,31 @@ describe(SearchService.name, () => { }); }); + // #763 slice 1b Task 2 — mapAsset now reads `isFavoriteForUser` exclusively; searchMetadata must + // project the per-user overlay for the CALLER, not the asset owner. + describe('isFavoriteForUser projection (#763)', () => { + it('E1 — a space member sees their own favorite in searchMetadata, independent of the owner', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: member } = await ctx.newUser(); + const { space } = await ctx.newSharedSpace({ createdById: owner.id }); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id, addedById: owner.id }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: owner.id, role: 'owner' }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: member.id, role: 'viewer' }); + + await ctx.database.insertInto('asset_favorite').values({ userId: member.id, assetId: asset.id }).execute(); + + const memberAuth = factory.auth({ user: { id: member.id } }); + const memberResult = await sut.searchMetadata(memberAuth, { size: 250, spaceId: space.id }); + expect(memberResult.assets.items).toEqual([expect.objectContaining({ id: asset.id, isFavorite: true })]); + + const ownerAuth = factory.auth({ user: { id: owner.id } }); + const ownerResult = await sut.searchMetadata(ownerAuth, { size: 250, spaceId: space.id }); + expect(ownerResult.assets.items).toEqual([expect.objectContaining({ id: asset.id, isFavorite: false })]); + }); + }); + describe('library-linked space assets', () => { it('should include library-linked assets in searchMetadata when filtering by spaceId', async () => { const { sut, ctx } = setup(); diff --git a/server/test/medium/specs/services/shared-link.service.spec.ts b/server/test/medium/specs/services/shared-link.service.spec.ts index 165d3b2a9e1081..ec3bcf8ae2193f 100644 --- a/server/test/medium/specs/services/shared-link.service.spec.ts +++ b/server/test/medium/specs/services/shared-link.service.spec.ts @@ -95,6 +95,33 @@ describe(SharedLinkService.name, () => { }); }); + // #763 slice 1b — mapSharedLink calls mapAsset with NO auth at all (see shared-link.dto.ts), so + // `isFavorite` must stay `false` for an anonymous visitor even when the link owner has favorited + // the asset. This is the exact regression this slice exists to prevent (see slice 1b plan + // "Why this is its own slice"). + it('never exposes isFavorite to an anonymous shared-link visitor, even when the owner favorited the asset (#763)', async () => { + const { sut, ctx } = setup(); + + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, make: 'Canon' }); + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: asset.id }).execute(); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + allowUpload: false, + type: SharedLinkType.Individual, + assetIds: [asset.id], + }); + + await expect(sut.getMine({ user, sharedLink }, [])).resolves.toMatchObject({ + assets: [expect.objectContaining({ id: asset.id, isFavorite: false })], + }); + }); + describe('getAll', () => { it('should return all shared links even when they share the same createdAt', async () => { const { sut, ctx } = setup(); diff --git a/server/test/medium/specs/services/timeline.service.spec.ts b/server/test/medium/specs/services/timeline.service.spec.ts index 00d23dc4a78be7..0dd83520df510c 100644 --- a/server/test/medium/specs/services/timeline.service.spec.ts +++ b/server/test/medium/specs/services/timeline.service.spec.ts @@ -81,10 +81,21 @@ const createPhotosAlbumFixture = async (ctx: ReturnType['ctx']) => const { user: carol } = await ctx.newUser(); // in NO space of the viewer's const myAsset = await createTimelineAsset(ctx, viewer.id, SPACE_DATE); - const myFavoriteAsset = await createTimelineAsset(ctx, viewer.id, SPACE_DATE, { isFavorite: true }); + const myFavoriteAsset = await createTimelineAsset(ctx, viewer.id, SPACE_DATE); // Album-only assets of another user: the co-member contributions the D3 bug leaked. const carolAsset = await createTimelineAsset(ctx, carol.id, SPACE_DATE); - const carolFavoriteAsset = await createTimelineAsset(ctx, carol.id, SPACE_DATE, { isFavorite: true }); + const carolFavoriteAsset = await createTimelineAsset(ctx, carol.id, SPACE_DATE); + + // #763: a favourite is an `asset_favorite` row per USER, not `asset.isFavorite` on the asset. + // Carol favourites hers, the viewer favourites theirs — which is exactly what the assertion + // below distinguishes. + await ctx.database + .insertInto('asset_favorite') + .values([ + { userId: viewer.id, assetId: myFavoriteAsset.id }, + { userId: carol.id, assetId: carolFavoriteAsset.id }, + ]) + .execute(); const { album } = await ctx.newAlbum({ ownerId: viewer.id }, [ myAsset.id, @@ -210,12 +221,11 @@ describe(TimelineService.name, () => { const { user } = await ctx.newUser(); const auth = factory.auth({ user }); - await createTimelineAsset(ctx, user.id, new Date('2024-04-02T12:00:00.000Z'), { - isFavorite: true, - }); - await createTimelineAsset(ctx, user.id, new Date('2024-04-03T12:00:00.000Z'), { - isFavorite: false, - }); + const favorited = await createTimelineAsset(ctx, user.id, new Date('2024-04-02T12:00:00.000Z')); + await createTimelineAsset(ctx, user.id, new Date('2024-04-03T12:00:00.000Z')); + // #763: isFavorite now filters against the asset_favorite overlay for the caller, not the + // legacy asset.isFavorite column. + await ctx.database.insertInto('asset_favorite').values({ userId: user.id, assetId: favorited.id }).execute(); await expect(sut.getTimeBuckets(auth, { bucketSize: TimeBucketSize.Month, isFavorite: true })).resolves.toEqual([ expect.objectContaining({ count: 1 }), @@ -258,7 +268,9 @@ describe(TimelineService.name, () => { const { user } = await ctx.newUser(); const auth = factory.auth({ user }); - await createTimelineAsset(ctx, user.id, new Date('2024-05-01T12:00:00.000Z'), { isFavorite: false }); + // Not favorited: no asset_favorite overlay row seeded (there is no raw column to default + // false anymore — dropped in slice 3). + await createTimelineAsset(ctx, user.id, new Date('2024-05-01T12:00:00.000Z')); await expect(sut.getTimeBuckets(auth, { bucketSize: TimeBucketSize.Year, isFavorite: true })).resolves.toEqual( [], @@ -292,31 +304,31 @@ describe(TimelineService.name, () => { }); await expect(response1).rejects.toBeInstanceOf(BadRequestException); await expect(response1).rejects.toThrow( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', + 'withPartners is only supported for non-archived, non-trashed, non-locked assets', ); const response2 = sut.getTimeBuckets(auth, { bucketSize, withPartners: true }); await expect(response2).rejects.toBeInstanceOf(BadRequestException); await expect(response2).rejects.toThrow( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', + 'withPartners is only supported for non-archived, non-trashed, non-locked assets', ); }, ); - it('should return error if time bucket is requested with partners asset and favorite', async () => { + // #763 slice 4: isFavorite no longer trips this guard — it composes with withPartners (the + // caller-scoped asset_favorite overlay resolves independently of the cross-user union). An + // explicit non-archived visibility is required so the request doesn't fall into the separate + // undefined-visibility-defaults-to-archived rejection exercised above. + it('resolves time buckets when partners is combined with isFavorite (true or false) (#763 slice 4)', async () => { const { sut } = setup(); const auth = factory.auth(); - const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false }); - await expect(response1).rejects.toBeInstanceOf(BadRequestException); - await expect(response1).rejects.toThrow( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', - ); - const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true }); - await expect(response2).rejects.toBeInstanceOf(BadRequestException); - await expect(response2).rejects.toThrow( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', - ); + await expect( + sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false, visibility: AssetVisibility.Timeline }), + ).resolves.toEqual([]); + await expect( + sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true, visibility: AssetVisibility.Timeline }), + ).resolves.toEqual([]); }); it.each([TimeBucketSize.Year, TimeBucketSize.Month, TimeBucketSize.Day])( @@ -327,7 +339,7 @@ describe(TimelineService.name, () => { const response = sut.getTimeBuckets(auth, { bucketSize, withPartners: true, isTrashed: true }); await expect(response).rejects.toBeInstanceOf(BadRequestException); await expect(response).rejects.toThrow( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', + 'withPartners is only supported for non-archived, non-trashed, non-locked assets', ); }, ); @@ -470,7 +482,6 @@ describe(TimelineService.name, () => { ownerId: user.id, fileCreatedAt: new Date('1970-02-12'), localDateTime: new Date('1970-02-12'), - isFavorite: true, }); await ctx.newExif({ assetId: result.asset.id, make: 'Canon' }); return result; @@ -481,13 +492,23 @@ describe(TimelineService.name, () => { ownerId: user.id, fileCreatedAt: new Date('1970-02-13'), localDateTime: new Date('1970-02-13'), - isFavorite: true, }); await ctx.newExif({ assetId: result.asset.id, make: 'Canon' }); return result; }), ]); + // #763: each owner has favorited only their own asset in the asset_favorite overlay — the + // legacy asset.isFavorite column is never set here, so this is exercised purely against the + // overlay, same as production once the (currently untouched) write path records a favorite. + await ctx.database + .insertInto('asset_favorite') + .values([ + { userId: asset1.ownerId, assetId: asset1.id }, + { userId: asset2.ownerId, assetId: asset2.id }, + ]) + .execute(); + await Promise.all([ ctx.newPartner({ sharedById: asset1.ownerId, sharedWithId: asset2.ownerId }), ctx.newPartner({ sharedById: asset2.ownerId, sharedWithId: asset1.ownerId }), @@ -871,9 +892,9 @@ describe(TimelineService.name, () => { const { viewer, album, myFavoriteAsset, carolFavoriteAsset } = await createPhotosAlbumFixture(ctx); const auth = factory.auth({ user: viewer }); - // `isFavorite` is the asset OWNER's flag, and /photos drops withPartners/withSharedSpaces for - // a favourites query (the server 400s that combination anyway) — so here `userId` is the ONLY - // thing standing between me and a co-member's favourites. + // #763: `isFavorite` resolves the per-user `asset_favorite` overlay for the CALLER, so a + // co-member's favourite on their own asset is not mine. `userId` still scopes the album + // query to my own timeline; the overlay is what makes the chip mean "mine". const ids = await photosBucketAssetIds(sut, auth, { userId: viewer.id, albumId: album.id, diff --git a/server/test/medium/specs/sync/shared-space-album-asset-sync.spec.ts b/server/test/medium/specs/sync/shared-space-album-asset-sync.spec.ts index f0079db028c11c..64365752ae0aca 100644 --- a/server/test/medium/specs/sync/shared-space-album-asset-sync.spec.ts +++ b/server/test/medium/specs/sync/shared-space-album-asset-sync.spec.ts @@ -1,5 +1,6 @@ import { Kysely } from 'kysely'; import { SharedSpaceRole, SyncEntityType } from 'src/enum.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { SyncRepository } from 'src/repositories/sync.repository.js'; import { DB } from 'src/schema/index.js'; import { SyncTestContext } from 'test/medium.factory.js'; @@ -14,7 +15,7 @@ const drain = async (stream: AsyncIterable) => { // - getBackfill: per-album backfill of full asset rows // - getCreates: new album_asset join rows via grant // - getUpdates: asset metadata changes gated by albumToAssetAck coupling -// - isFavorite masking: false for non-owner members +// - isFavorite: recipient's own asset_favorite overlay row (#763), never the owner's let defaultDatabase: Kysely; @@ -67,29 +68,46 @@ describe('SharedSpaceAlbumAssetSync.getBackfill', () => { expect(result).toHaveLength(0); }); - it('masks isFavorite to false for non-owners', async () => { - const { ctx, db, sut } = setup(); + it("does not leak the owner's favorite to a non-owner member's backfill (#763)", async () => { + const { ctx, sut } = setup(); const { user: owner } = await ctx.newUser(); const { user: member } = await ctx.newUser(); const { album } = await ctx.newAlbum({ ownerId: owner.id }); const { asset } = await ctx.newAsset({ ownerId: owner.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); - // Set isFavorite to true on the asset (owner's perspective) - await db.updateTable('asset').set({ isFavorite: true }).where('id', '=', asset.id).execute(); + // Owner favorites the asset — this must never leak to another member's stream. + await ctx.get(AssetFavoriteRepository).addAll(owner.id, [asset.id]); - // Backfill as member (non-owner) — should see isFavorite=false + // Backfill as member (non-owner, never favorited) — should see isFavorite=false. const stream = sut.getBackfill({ nowId: NOW_ID, beforeUpdateId: BEFORE_UPDATE_ID }, album.id, member.id); const result: any[] = await Array.fromAsync(stream); const row = result.find((r: any) => r.id === asset.id); expect(row).toBeDefined(); expect(row.isFavorite).toBe(false); - // Backfill as owner — should see true isFavorite + // Backfill as owner — resolves the owner's own favorite, true. const ownerStream = sut.getBackfill({ nowId: NOW_ID, beforeUpdateId: BEFORE_UPDATE_ID }, album.id, owner.id); const ownerResult: any[] = await Array.fromAsync(ownerStream); expect(ownerResult.find((r: any) => r.id === asset.id)?.isFavorite).toBe(true); }); + + it("syncs the recipient's own favorite of a foreign-owned asset on backfill (#763, E25)", async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: member } = await ctx.newUser(); + const { album } = await ctx.newAlbum({ ownerId: owner.id }); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + + // Member favorites an asset they don't own — their own stream must reflect it as true, not + // the old hardcoded/masked false. + await ctx.get(AssetFavoriteRepository).addAll(member.id, [asset.id]); + + const stream = sut.getBackfill({ nowId: NOW_ID, beforeUpdateId: BEFORE_UPDATE_ID }, album.id, member.id); + const result: any[] = await Array.fromAsync(stream); + expect(result.find((r: any) => r.id === asset.id)?.isFavorite).toBe(true); + }); }); describe('SharedSpaceAlbumAssetSync.getCreates', () => { @@ -150,8 +168,8 @@ describe('SharedSpaceAlbumAssetSync.getCreates', () => { expect(result.map((r: any) => r.id)).not.toContain(asset.id); }); - it('masks isFavorite to false for non-owner members in getCreates', async () => { - const { ctx, db, sut } = setup(); + it("does not leak the owner's favorite to a non-owner member in getCreates (#763)", async () => { + const { ctx, sut } = setup(); const { user: owner } = await ctx.newUser(); const { user: member } = await ctx.newUser(); const { album } = await ctx.newAlbum({ ownerId: owner.id }); @@ -160,13 +178,31 @@ describe('SharedSpaceAlbumAssetSync.getCreates', () => { const { space } = await ctx.newSharedSpace({ createdById: owner.id }); await ctx.newSharedSpaceMember({ spaceId: space.id, userId: member.id, role: SharedSpaceRole.Editor }); await ctx.newSharedSpaceAlbum({ spaceId: space.id, albumId: album.id }); - await db.updateTable('asset').set({ isFavorite: true }).where('id', '=', asset.id).execute(); + await ctx.get(AssetFavoriteRepository).addAll(owner.id, [asset.id]); const stream = sut.getCreates({ nowId: NOW_ID, userId: member.id }); const result: any[] = await Array.fromAsync(stream); const row = result.find((r: any) => r.id === asset.id); expect(row?.isFavorite).toBe(false); }); + + it("syncs the recipient's own favorite of a foreign-owned asset in getCreates (#763, E25)", async () => { + const { ctx, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: member } = await ctx.newUser(); + const { album } = await ctx.newAlbum({ ownerId: owner.id }); + const { asset } = await ctx.newAsset({ ownerId: owner.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + const { space } = await ctx.newSharedSpace({ createdById: owner.id }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: member.id, role: SharedSpaceRole.Editor }); + await ctx.newSharedSpaceAlbum({ spaceId: space.id, albumId: album.id }); + await ctx.get(AssetFavoriteRepository).addAll(member.id, [asset.id]); + + const stream = sut.getCreates({ nowId: NOW_ID, userId: member.id }); + const result: any[] = await Array.fromAsync(stream); + const row = result.find((r: any) => r.id === asset.id); + expect(row?.isFavorite).toBe(true); + }); }); describe('SharedSpaceAlbumAssetSync.getUpdates', () => { diff --git a/server/test/medium/specs/sync/sync-album-asset.spec.ts b/server/test/medium/specs/sync/sync-album-asset.spec.ts index 782b16818c3e36..de3f199c1e185b 100644 --- a/server/test/medium/specs/sync/sync-album-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset.spec.ts @@ -1,5 +1,6 @@ import { Kysely } from 'kysely'; import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; import { DB } from 'src/schema/index.js'; import { SyncTestContext } from 'test/medium.factory.js'; @@ -75,7 +76,9 @@ describe(SyncRequestType.AlbumAssetsV2, () => { fileCreatedAt: asset.fileCreatedAt, fileModifiedAt: asset.fileModifiedAt, createdAt: asset.createdAt, - isFavorite: asset.isFavorite, + // #763: no asset_favorite overlay row seeded for this asset -> resolves to false. The + // raw asset."isFavorite" column this used to read from is gone (dropped in slice 3). + isFavorite: false, localDateTime: asset.localDateTime, type: asset.type, visibility: asset.visibility, @@ -303,13 +306,15 @@ describe(SyncRequestType.AlbumAssetsV2, () => { ]); }); - it('should hide isFavorite for album assets owned by another user', async () => { + it("does not leak another user's favorite for album assets owned by that user (#763)", async () => { const { auth, ctx } = await setup(); const { user: user2 } = await ctx.newUser(); - const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); const { album } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Viewer }); + // Owner favorites the asset — this must never leak to the viewer's stream. + await ctx.get(AssetFavoriteRepository).addAll(user2.id, [asset.id]); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]); expect(response).toEqual([ @@ -323,13 +328,35 @@ describe(SyncRequestType.AlbumAssetsV2, () => { ]); }); + it("syncs the recipient's own favorite for an album asset owned by another user (#763, E25)", async () => { + const { auth, ctx } = await setup(); + const { user: user2 } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const { album } = await ctx.newAlbum({ ownerId: user2.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Viewer }); + // The viewer favorites an asset they don't own — their own stream must reflect it as true. + await ctx.get(AssetFavoriteRepository).addAll(auth.user.id, [asset.id]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]); + expect(response).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ id: asset.id, isFavorite: true }), + type: SyncEntityType.AlbumAssetCreateV2, + }), + ]), + ); + }); + it('should sync isFavorite for album assets owned by the requesting user', async () => { const { auth, ctx } = await setup(); const { user: user2 } = await ctx.newUser(); - const { asset } = await ctx.newAsset({ ownerId: auth.user.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); const { album } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Viewer }); + await ctx.get(AssetFavoriteRepository).addAll(auth.user.id, [asset.id]); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]); expect(response).toEqual( diff --git a/server/test/medium/specs/sync/sync-asset-favorite.spec.ts b/server/test/medium/specs/sync/sync-asset-favorite.spec.ts new file mode 100644 index 00000000000000..4fb61043555f69 --- /dev/null +++ b/server/test/medium/specs/sync/sync-asset-favorite.spec.ts @@ -0,0 +1,261 @@ +import { Kysely } from 'kysely'; +import { SyncEntityType, SyncRequestType } from 'src/enum.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; +import { DB } from 'src/schema/index.js'; +import { SyncTestContext } from 'test/medium.factory.js'; +import { factory } from 'test/small.factory.js'; +import { getKyselyDB } from 'test/utils.js'; + +// #763 slice 6 (design doc §4.3): asset_favorite is its own synced entity, because a non-owner's +// favorite write must never bump asset.updateId — that would amplify a personal write into a +// cross-user re-sync of the underlying asset. Modeled directly on sync-asset-edit.spec.ts / AssetEditSync: +// flat caller-scoped table, getUpserts + getDeletes, no backfill loop. + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncRequestType.AssetFavoritesV1, () => { + it('should sync an existing favorite on initial sync', async () => { + const { auth, user, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const favoriteRepo = ctx.get(AssetFavoriteRepository); + await favoriteRepo.addAll(user.id, [asset.id]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFavoritesV1]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { assetId: asset.id }, + type: SyncEntityType.AssetFavoriteV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFavoritesV1]); + }); + + it('should sync a new favorite created after the checkpoint', async () => { + const { auth, user, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const favoriteRepo = ctx.get(AssetFavoriteRepository); + + // Establish a checkpoint with nothing favorited yet. + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFavoritesV1]); + + await favoriteRepo.addAll(user.id, [asset.id]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFavoritesV1]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { assetId: asset.id }, + type: SyncEntityType.AssetFavoriteV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFavoritesV1]); + }); + + it('should sync an unfavorite as a tombstone delete', async () => { + const { auth, user, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const favoriteRepo = ctx.get(AssetFavoriteRepository); + await favoriteRepo.addAll(user.id, [asset.id]); + + const response1 = await ctx.syncStream(auth, [SyncRequestType.AssetFavoritesV1]); + await ctx.syncAckAll(auth, response1); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFavoritesV1]); + + await favoriteRepo.removeAll(user.id, [asset.id]); + + const response2 = await ctx.syncStream(auth, [SyncRequestType.AssetFavoritesV1]); + expect(response2).toEqual([ + { + ack: expect.any(String), + data: { assetId: asset.id }, + type: SyncEntityType.AssetFavoriteDeleteV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response2); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFavoritesV1]); + }); + + // §3 core invariant: "No cross-user read. A favorite row for user B never surfaces in user + // A's response payload ... sync stream." Both users favorite the SAME asset — the classic + // trap here is forgetting `.where('asset_favorite.userId', '=', options.userId)`, which would + // let A's stream also pick up B's row on that assetId (same assetId, so it wouldn't fail on a + // simple "wrong id" assertion; it fails on emitting two events instead of one). + it('should isolate favorites per user on the same asset, in both directions (§3)', async () => { + const { auth: authA, user: userA, ctx } = await setup(); + const { user: userB } = await ctx.newUser(); + const { session: sessionB } = await ctx.newSession({ userId: userB.id }); + const authB = factory.auth({ session: sessionB, user: userB }); + + const { asset } = await ctx.newAsset({ ownerId: userA.id }); + const favoriteRepo = ctx.get(AssetFavoriteRepository); + + await favoriteRepo.addAll(userA.id, [asset.id]); + await favoriteRepo.addAll(userB.id, [asset.id]); + + const responseA = await ctx.syncStream(authA, [SyncRequestType.AssetFavoritesV1]); + expect(responseA).toEqual([ + { ack: expect.any(String), data: { assetId: asset.id }, type: SyncEntityType.AssetFavoriteV1 }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + const responseB = await ctx.syncStream(authB, [SyncRequestType.AssetFavoritesV1]); + expect(responseB).toEqual([ + { ack: expect.any(String), data: { assetId: asset.id }, type: SyncEntityType.AssetFavoriteV1 }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(authA, responseA); + await ctx.syncAckAll(authB, responseB); + await ctx.assertSyncIsComplete(authA, [SyncRequestType.AssetFavoritesV1]); + await ctx.assertSyncIsComplete(authB, [SyncRequestType.AssetFavoritesV1]); + + // Reverse direction: unfavoriting user B's row must not surface as a delete in user A's + // stream — isolation holds for the tombstone side too, not just the upsert side. + await favoriteRepo.removeAll(userB.id, [asset.id]); + await ctx.assertSyncIsComplete(authA, [SyncRequestType.AssetFavoritesV1]); + + const responseB2 = await ctx.syncStream(authB, [SyncRequestType.AssetFavoritesV1]); + expect(responseB2).toEqual([ + { ack: expect.any(String), data: { assetId: asset.id }, type: SyncEntityType.AssetFavoriteDeleteV1 }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + }); + + // E26: mobile unfavorite-while-offline-then-resync must converge to the user's actual intent, + // with no duplicate or stale terminal state left over from the intermediate toggle. + it('should converge after favorite -> unfavorite -> favorite across checkpoints (E26)', async () => { + const { auth, user, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const favoriteRepo = ctx.get(AssetFavoriteRepository); + + await favoriteRepo.addAll(user.id, [asset.id]); + const response1 = await ctx.syncStream(auth, [SyncRequestType.AssetFavoritesV1]); + expect(response1).toEqual([ + { ack: expect.any(String), data: { assetId: asset.id }, type: SyncEntityType.AssetFavoriteV1 }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.syncAckAll(auth, response1); + + await favoriteRepo.removeAll(user.id, [asset.id]); + const response2 = await ctx.syncStream(auth, [SyncRequestType.AssetFavoritesV1]); + expect(response2).toEqual([ + { ack: expect.any(String), data: { assetId: asset.id }, type: SyncEntityType.AssetFavoriteDeleteV1 }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.syncAckAll(auth, response2); + + await favoriteRepo.addAll(user.id, [asset.id]); + const response3 = await ctx.syncStream(auth, [SyncRequestType.AssetFavoritesV1]); + expect(response3).toEqual([ + { ack: expect.any(String), data: { assetId: asset.id }, type: SyncEntityType.AssetFavoriteV1 }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.syncAckAll(auth, response3); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFavoritesV1]); + + // A full resync from empty must converge to the same terminal state. The delete-audit + // tombstone for the intermediate unfavorite is still inside the (uncleaned) audit table, so + // a full resync legitimately replays it — same as every other delete-then-upsert entity in + // this file (e.g. AssetOcrSync's upsert-deletes-then-inserts, sync-asset-ocr.spec.ts + // "should update asset OCR"). Convergence means: applied in the order sent (deletes before + // upserts, per syncAssetFavoritesV1), the LAST event for this assetId is the upsert — the + // client ends up favorited, not stuck on a stale delete. + const fullResync = await ctx.syncStream(auth, [SyncRequestType.AssetFavoritesV1], true); + const events = fullResync.filter( + (event): event is { type: SyncEntityType; data: { assetId: string } } => + (event.type === SyncEntityType.AssetFavoriteV1 || event.type === SyncEntityType.AssetFavoriteDeleteV1) && + event.data.assetId === asset.id, + ); + expect(events.length).toBeGreaterThan(0); + expect(events.at(-1)).toMatchObject({ type: SyncEntityType.AssetFavoriteV1 }); + }); +}); + +// §4.3: a NON-OWNER's favorite write must not amplify onto the AssetsV2 stream — if it bumped +// asset.updateId, every device that already has the asset would re-sync it just because +// someone else favorited it. An OWNER's favorite does bump it, exactly as the pre-#763 column +// write did, so apps that predate AssetFavoritesV1 still propagate it to the owner's other devices. +const getUpdateId = (ctx: SyncTestContext, assetId: string) => + ctx.database.selectFrom('asset').select('updateId').where('id', '=', assetId).executeTakeFirstOrThrow(); + +describe('§4.3 cross-stream amplification', () => { + it('does not re-emit the asset on AssetsV2 when a non-owner favorites it', async () => { + const { auth, user, ctx } = await setup(); + const { user: other } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const favoriteRepo = ctx.get(AssetFavoriteRepository); + + const assetsResponse = await ctx.syncStream(auth, [SyncRequestType.AssetsV2]); + await ctx.syncAckAll(auth, assetsResponse); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV2]); + + const before = await getUpdateId(ctx, asset.id); + await favoriteRepo.addAll(other.id, [asset.id]); + await favoriteRepo.removeAll(other.id, [asset.id]); + const after = await getUpdateId(ctx, asset.id); + + expect(after.updateId).toBe(before.updateId); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV2]); + }); + + it('re-emits the asset on AssetsV2 with the new state when the owner favorites or unfavorites it', async () => { + const { auth, user, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const favoriteRepo = ctx.get(AssetFavoriteRepository); + + await ctx.syncAckAll(auth, await ctx.syncStream(auth, [SyncRequestType.AssetsV2])); + + await favoriteRepo.addAll(user.id, [asset.id]); + const favorited = await ctx.syncStream(auth, [SyncRequestType.AssetsV2]); + expect(favorited).toEqual([ + expect.objectContaining({ + type: SyncEntityType.AssetV2, + data: expect.objectContaining({ id: asset.id, isFavorite: true }), + }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.syncAckAll(auth, favorited); + + await favoriteRepo.removeAll(user.id, [asset.id]); + const unfavorited = await ctx.syncStream(auth, [SyncRequestType.AssetsV2]); + expect(unfavorited).toEqual([ + expect.objectContaining({ + type: SyncEntityType.AssetV2, + data: expect.objectContaining({ id: asset.id, isFavorite: false }), + }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + }); + + it('does not bump the asset when the favorite state did not change', async () => { + const { user, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const favoriteRepo = ctx.get(AssetFavoriteRepository); + + await favoriteRepo.addAll(user.id, [asset.id]); + const before = await getUpdateId(ctx, asset.id); + await favoriteRepo.addAll(user.id, [asset.id]); + const after = await getUpdateId(ctx, asset.id); + + expect(after.updateId).toBe(before.updateId); + }); +}); diff --git a/server/test/medium/specs/sync/sync-asset.spec.ts b/server/test/medium/specs/sync/sync-asset.spec.ts index 4ded43372d9f00..aa6eadddc62a4b 100644 --- a/server/test/medium/specs/sync/sync-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-asset.spec.ts @@ -56,7 +56,9 @@ describe(SyncEntityType.AssetV2, () => { fileCreatedAt: asset.fileCreatedAt, fileModifiedAt: asset.fileModifiedAt, createdAt: asset.createdAt, - isFavorite: asset.isFavorite, + // #763: no asset_favorite overlay row seeded for this asset -> resolves to false. The + // raw asset."isFavorite" column this used to read from is gone (dropped in slice 3). + isFavorite: false, localDateTime: asset.localDateTime, type: asset.type, visibility: asset.visibility, diff --git a/server/test/medium/specs/sync/sync-library-asset.spec.ts b/server/test/medium/specs/sync/sync-library-asset.spec.ts index f656c7f45e7506..78366fb8f1c9b1 100644 --- a/server/test/medium/specs/sync/sync-library-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-library-asset.spec.ts @@ -1,5 +1,6 @@ import { Kysely } from 'kysely'; import { AssetVisibility, SharedSpaceRole, SyncEntityType, SyncRequestType } from 'src/enum.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { DB } from 'src/schema/index.js'; import { SyncTestContext } from 'test/medium.factory.js'; import { getKyselyDB } from 'test/utils.js'; @@ -79,13 +80,22 @@ describe(SyncRequestType.LibraryAssetsV1, () => { // LibraryAssetCreateV1 and the client applies them idempotently. const { auth, ctx } = await setup(); const { library } = await ctx.newLibrary({ ownerId: auth.user.id }); - const { asset } = await ctx.newAsset({ ownerId: auth.user.id, libraryId: library.id, isFavorite: false }); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id, libraryId: library.id }); const initial = await ctx.syncStream(auth, [SyncRequestType.LibraryAssetsV1]); await ctx.syncAckAll(auth, initial); await ctx.assertSyncIsComplete(auth, [SyncRequestType.LibraryAssetsV1]); - await defaultDatabase.updateTable('asset').set({ isFavorite: true }).where('id', '=', asset.id).execute(); + // #763: favoriting alone does not bump asset.updateId (asset_favorite is a separate overlay + // table — see design doc §4.3), so the unrelated column update below stands in for "some + // metadata changed" to trigger the re-emit; the actual isFavorite value comes from the + // overlay insert. asset."isFavorite" no longer exists (dropped in slice 3), hence originalFileName. + await defaultDatabase + .updateTable('asset') + .set({ originalFileName: 'renamed-for-resync.jpg' }) + .where('id', '=', asset.id) + .execute(); + await ctx.get(AssetFavoriteRepository).addAll(auth.user.id, [asset.id]); const next = await ctx.syncStream(auth, [SyncRequestType.LibraryAssetsV1]); const assetEvents = next.filter((r) => isAssetEvent(r)); @@ -96,13 +106,14 @@ describe(SyncRequestType.LibraryAssetsV1, () => { }); }); - it('masks isFavorite for a foreign-owned asset in a shared library', async () => { - // The owner's favorite flag must not leak to other library members — same - // masking rule as SharedSpaceAssetSync / AlbumAssetSync (issue #743 item 1). + it("does not leak the owner's favorite for a foreign-owned asset in a shared library (#763)", async () => { + // The owner's favorite must not leak to other library members — same + // per-user overlay rule as SharedSpaceAssetSync / AlbumAssetSync (issue #743 item 1). const { auth, ctx } = await setup(); const { user: owner } = await ctx.newUser(); const { library } = await ctx.newLibrary({ ownerId: owner.id }); - const { asset } = await ctx.newAsset({ ownerId: owner.id, libraryId: library.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: owner.id, libraryId: library.id }); + await ctx.get(AssetFavoriteRepository).addAll(owner.id, [asset.id]); const { space } = await ctx.newSharedSpace({ createdById: owner.id }); await ctx.newSharedSpaceMember({ spaceId: space.id, userId: owner.id, role: SharedSpaceRole.Owner }); @@ -118,7 +129,29 @@ describe(SyncRequestType.LibraryAssetsV1, () => { }); }); - it('masks isFavorite on the backfill path for a foreign-owned asset in a late-linked library', async () => { + it("syncs the recipient's own favorite for a foreign-owned asset in a shared library (#763, E25)", async () => { + const { auth, ctx } = await setup(); + const { user: owner } = await ctx.newUser(); + const { library } = await ctx.newLibrary({ ownerId: owner.id }); + const { asset } = await ctx.newAsset({ ownerId: owner.id, libraryId: library.id }); + // The member favorites an asset they don't own — their own stream must reflect it as true. + await ctx.get(AssetFavoriteRepository).addAll(auth.user.id, [asset.id]); + + const { space } = await ctx.newSharedSpace({ createdById: owner.id }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: owner.id, role: SharedSpaceRole.Owner }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: auth.user.id, role: SharedSpaceRole.Editor }); + await ctx.newSharedSpaceLibrary({ spaceId: space.id, libraryId: library.id, addedById: owner.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.LibraryAssetsV1]); + const assetEvents = response.filter((r) => isAssetEvent(r)); + expect(assetEvents).toHaveLength(1); + expect((assetEvents[0] as { data: { id: string; isFavorite: boolean } }).data).toMatchObject({ + id: asset.id, + isFavorite: true, + }); + }); + + it("does not leak the owner's favorite on the backfill path for a foreign-owned asset in a late-linked library (#763)", async () => { const { auth, ctx } = await setup(); const { user: owner } = await ctx.newUser(); const { space } = await ctx.newSharedSpace({ createdById: owner.id }); @@ -131,7 +164,8 @@ describe(SyncRequestType.LibraryAssetsV1, () => { // Link a new library with a foreign favorite asset — it flows via getBackfill. const { library } = await ctx.newLibrary({ ownerId: owner.id }); - const { asset } = await ctx.newAsset({ ownerId: owner.id, libraryId: library.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: owner.id, libraryId: library.id }); + await ctx.get(AssetFavoriteRepository).addAll(owner.id, [asset.id]); await ctx.newSharedSpaceLibrary({ spaceId: space.id, libraryId: library.id, addedById: owner.id }); const response = await ctx.syncStream(auth, syncTypes); @@ -143,6 +177,32 @@ describe(SyncRequestType.LibraryAssetsV1, () => { }); }); + it("syncs the recipient's own favorite on the backfill path for a foreign-owned asset in a late-linked library (#763, E25)", async () => { + const { auth, ctx } = await setup(); + const { user: owner } = await ctx.newUser(); + const { space } = await ctx.newSharedSpace({ createdById: owner.id }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: owner.id, role: SharedSpaceRole.Owner }); + await ctx.newSharedSpaceMember({ spaceId: space.id, userId: auth.user.id, role: SharedSpaceRole.Editor }); + + const syncTypes = [SyncRequestType.LibrariesV1, SyncRequestType.LibraryAssetsV1]; + const initial = await ctx.syncStream(auth, syncTypes); + await ctx.syncAckAll(auth, initial); + + // Link a new library with an asset the RECIPIENT favorites before it backfills. + const { library } = await ctx.newLibrary({ ownerId: owner.id }); + const { asset } = await ctx.newAsset({ ownerId: owner.id, libraryId: library.id }); + await ctx.get(AssetFavoriteRepository).addAll(auth.user.id, [asset.id]); + await ctx.newSharedSpaceLibrary({ spaceId: space.id, libraryId: library.id, addedById: owner.id }); + + const response = await ctx.syncStream(auth, syncTypes); + const assetEvents = response.filter((r) => isAssetEvent(r)); + expect(assetEvents).toHaveLength(1); + expect((assetEvents[0] as { data: { id: string; isFavorite: boolean } }).data).toMatchObject({ + id: asset.id, + isFavorite: true, + }); + }); + it('emits a LibraryAssetDeleteV1 event when a library asset is deleted', async () => { const { auth, ctx } = await setup(); const { library } = await ctx.newLibrary({ ownerId: auth.user.id }); @@ -254,7 +314,11 @@ describe(SyncRequestType.LibraryAssetsV1, () => { await defaultDatabase.updateTable('library').set({ deletedAt: new Date() }).where('id', '=', library.id).execute(); // Mutate the asset so it would be streamed if the library gate was broken. - await defaultDatabase.updateTable('asset').set({ isFavorite: true }).where('id', '=', asset.id).execute(); + await defaultDatabase + .updateTable('asset') + .set({ originalFileName: 'renamed-for-resync.jpg' }) + .where('id', '=', asset.id) + .execute(); const after = await ctx.syncStream(auth, [SyncRequestType.LibraryAssetsV1]); const afterEvents = after.filter((r) => isAssetEvent(r)); @@ -289,15 +353,22 @@ describe(SyncRequestType.LibraryAssetsV1, () => { await ctx.assertSyncIsComplete(auth, [SyncRequestType.LibraryAssetsV1]); await defaultDatabase.updateTable('library').set({ deletedAt: new Date() }).where('id', '=', library.id).execute(); - await defaultDatabase.updateTable('asset').set({ isFavorite: true }).where('id', '=', asset.id).execute(); + // Owner favorites the asset (via the overlay) AND a genuine metadata change bumps + // asset.updateId so the row re-flows — even so, the owner's favorite must not leak to auth. + await ctx.get(AssetFavoriteRepository).addAll(owner.id, [asset.id]); + await defaultDatabase + .updateTable('asset') + .set({ originalFileName: 'renamed-for-resync.jpg' }) + .where('id', '=', asset.id) + .execute(); const after = await ctx.syncStream(auth, [SyncRequestType.LibraryAssetsV1]); const events = after.filter((r) => isAssetEvent(r)); expect(events).toHaveLength(1); const data = (events[0] as { data: { id: string; isFavorite: boolean } }).data; expect(data.id).toBe(asset.id); - // L5: `auth` is a space member, not the asset owner — the owner's true isFavorite flag must not - // leak to a non-owning library sync arm reader (mirrors the direct/album arms' masking). + // L5/#763: `auth` is a space member, not the asset owner — isFavorite resolves auth's OWN + // overlay row, so the owner's favorite must not leak (mirrors the direct/album arms). expect(data.isFavorite).toBe(false); }); diff --git a/server/test/medium/specs/sync/sync-partner-asset.spec.ts b/server/test/medium/specs/sync/sync-partner-asset.spec.ts index 0f01d99dedcf14..cabbddd850f039 100644 --- a/server/test/medium/specs/sync/sync-partner-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-partner-asset.spec.ts @@ -1,5 +1,6 @@ import { Kysely } from 'kysely'; import { SyncEntityType, SyncRequestType } from 'src/enum.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; import { PartnerRepository } from 'src/repositories/partner.repository.js'; import { UserRepository } from 'src/repositories/user.repository.js'; @@ -341,11 +342,13 @@ describe(SyncRequestType.PartnerAssetsV2, () => { await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]); }); - it('should hide isFavorite for partner assets', async () => { + it("does not leak the partner's own favorite for partner assets (#763)", async () => { const { auth, ctx } = await setup(); const { user: user2 } = await ctx.newUser(); - const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); + // The partner (owner) favorites their own asset — must never leak to the recipient's stream. + await ctx.get(AssetFavoriteRepository).addAll(user2.id, [asset.id]); const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]); expect(response).toEqual([ @@ -357,4 +360,58 @@ describe(SyncRequestType.PartnerAssetsV2, () => { expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); }); + + it("syncs the recipient's own favorite for a partner asset (#763, E25 — was hardcoded false)", async () => { + const { auth, ctx } = await setup(); + const { user: user2 } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); + // The recipient favorites a partner asset they don't own — their own stream must reflect it. + await ctx.get(AssetFavoriteRepository).addAll(auth.user.id, [asset.id]); + + const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ id: asset.id, isFavorite: true }), + type: SyncEntityType.PartnerAssetV2, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + }); + + it("syncs the recipient's own favorite for a partner asset on the backfill path (#763, E25)", async () => { + // Exercises PartnerAssetsSync.getBackfill specifically (getBackfill and getUpserts are + // separate CASE-WHEN/overlay call sites in sync.repository.ts). Mirrors "should backfill + // partner assets when a partner shared their library with you" above: the asset must exist + // BEFORE the checkpoint-establishing initial sync so the later partner share routes it + // through the backfill loop rather than the regular upsert stream. + const { auth, ctx } = await setup(); + const { user: user2 } = await ctx.newUser(); + const { user: user3 } = await ctx.newUser(); + const { asset: backfillAsset } = await ctx.newAsset({ ownerId: user3.id }); + await wait(2); + const { asset: initialAsset } = await ctx.newAsset({ ownerId: user2.id }); + await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); + + const setupResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]); + expect(setupResponse).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ id: initialAsset.id }), + type: SyncEntityType.PartnerAssetV2, + }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.syncAckAll(auth, setupResponse); + + await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id }); + // The recipient favorites the historical partner asset before it backfills. + await ctx.get(AssetFavoriteRepository).addAll(auth.user.id, [backfillAsset.id]); + + const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]); + const backfillEvents = response.filter((r: { type: string }) => r.type === SyncEntityType.PartnerAssetBackfillV2); + const backfillRow = backfillEvents.find((r: { data: { id: string } }) => r.data.id === backfillAsset.id) as + { data: { id: string; isFavorite: boolean } } | undefined; + expect(backfillRow?.data.isFavorite).toBe(true); + }); }); diff --git a/server/test/medium/specs/sync/sync-shared-space-asset.spec.ts b/server/test/medium/specs/sync/sync-shared-space-asset.spec.ts index 264cc800d14254..e40317b2958add 100644 --- a/server/test/medium/specs/sync/sync-shared-space-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-shared-space-asset.spec.ts @@ -1,5 +1,6 @@ import { Kysely } from 'kysely'; import { SharedSpaceRole, SyncEntityType, SyncRequestType } from 'src/enum.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { DB } from 'src/schema/index.js'; import { SyncTestContext } from 'test/medium.factory.js'; import { getKyselyDB, wait } from 'test/utils.js'; @@ -164,14 +165,23 @@ describe(SyncRequestType.SharedSpaceAssetsV1, () => { userId: auth.user.id, role: SharedSpaceRole.Owner, }); - const { asset } = await ctx.newAsset({ ownerId: auth.user.id, isFavorite: false }); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id }); const initial = await ctx.syncStream(auth, [SyncRequestType.SharedSpaceAssetsV1]); await ctx.syncAckAll(auth, initial); await ctx.assertSyncIsComplete(auth, [SyncRequestType.SharedSpaceAssetsV1]); - await defaultDatabase.updateTable('asset').set({ isFavorite: true }).where('id', '=', asset.id).execute(); + // #763: favoriting alone does not bump asset.updateId (asset_favorite is a separate overlay + // table — see design doc §4.3), so the unrelated column update below stands in for "some + // metadata changed" to trigger the re-emit; the actual isFavorite value comes from the + // overlay insert. asset."isFavorite" no longer exists (dropped in slice 3), hence originalFileName. + await defaultDatabase + .updateTable('asset') + .set({ originalFileName: 'renamed-for-resync.jpg' }) + .where('id', '=', asset.id) + .execute(); + await ctx.get(AssetFavoriteRepository).addAll(auth.user.id, [asset.id]); const next = await ctx.syncStream(auth, [SyncRequestType.SharedSpaceAssetsV1]); const updateEvents = next.filter((r: { type: string }) => r.type === SyncEntityType.SharedSpaceAssetUpdateV1); @@ -182,7 +192,7 @@ describe(SyncRequestType.SharedSpaceAssetsV1, () => { }); }); - it('masks isFavorite for a foreign-owned asset shared into the space', async () => { + it("does not leak the peer's favorite for a foreign-owned asset shared into the space (#763)", async () => { const { auth, ctx } = await setup(); const { user: peer } = await ctx.newUser(); const { space } = await ctx.newSharedSpace({ createdById: peer.id }); @@ -196,8 +206,10 @@ describe(SyncRequestType.SharedSpaceAssetsV1, () => { userId: auth.user.id, role: SharedSpaceRole.Editor, }); - const { asset } = await ctx.newAsset({ ownerId: peer.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: peer.id }); await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id }); + // The peer (owner) favorites their own asset — must never leak to auth's stream. + await ctx.get(AssetFavoriteRepository).addAll(peer.id, [asset.id]); const response = await ctx.syncStream(auth, [SyncRequestType.SharedSpaceAssetsV1]); const assetEvents = response.filter( @@ -211,6 +223,37 @@ describe(SyncRequestType.SharedSpaceAssetsV1, () => { }); }); + it("syncs the recipient's own favorite for a foreign-owned asset shared into the space (#763, E25)", async () => { + const { auth, ctx } = await setup(); + const { user: peer } = await ctx.newUser(); + const { space } = await ctx.newSharedSpace({ createdById: peer.id }); + await ctx.newSharedSpaceMember({ + spaceId: space.id, + userId: peer.id, + role: SharedSpaceRole.Owner, + }); + await ctx.newSharedSpaceMember({ + spaceId: space.id, + userId: auth.user.id, + role: SharedSpaceRole.Editor, + }); + const { asset } = await ctx.newAsset({ ownerId: peer.id }); + await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id }); + // auth (not the owner) favorites the asset — their own stream must reflect it as true. + await ctx.get(AssetFavoriteRepository).addAll(auth.user.id, [asset.id]); + + const response = await ctx.syncStream(auth, [SyncRequestType.SharedSpaceAssetsV1]); + const assetEvents = response.filter( + (r: { type: string }) => + r.type === SyncEntityType.SharedSpaceAssetCreateV1 || r.type === SyncEntityType.SharedSpaceAssetUpdateV1, + ); + expect(assetEvents).toHaveLength(1); + expect((assetEvents[0] as { data: { id: string; isFavorite: boolean } }).data).toMatchObject({ + id: asset.id, + isFavorite: true, + }); + }); + it('syncs true isFavorite for an asset owned by the requesting user', async () => { const { auth, ctx } = await setup(); const { space } = await ctx.newSharedSpace({ createdById: auth.user.id }); @@ -219,8 +262,9 @@ describe(SyncRequestType.SharedSpaceAssetsV1, () => { userId: auth.user.id, role: SharedSpaceRole.Owner, }); - const { asset } = await ctx.newAsset({ ownerId: auth.user.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id }); + await ctx.get(AssetFavoriteRepository).addAll(auth.user.id, [asset.id]); const response = await ctx.syncStream(auth, [SyncRequestType.SharedSpaceAssetsV1]); const assetEvents = response.filter( @@ -234,7 +278,7 @@ describe(SyncRequestType.SharedSpaceAssetsV1, () => { }); }); - it('masks isFavorite on the update path for a foreign-owned asset', async () => { + it("does not leak the peer's favorite on the update path for a foreign-owned asset (#763)", async () => { const { auth, ctx } = await setup(); const { user: peer } = await ctx.newUser(); const { space } = await ctx.newSharedSpace({ createdById: peer.id }); @@ -248,14 +292,22 @@ describe(SyncRequestType.SharedSpaceAssetsV1, () => { userId: auth.user.id, role: SharedSpaceRole.Editor, }); - const { asset } = await ctx.newAsset({ ownerId: peer.id, isFavorite: false }); + const { asset } = await ctx.newAsset({ ownerId: peer.id }); await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id }); const initial = await ctx.syncStream(auth, [SyncRequestType.SharedSpaceAssetsV1]); await ctx.syncAckAll(auth, initial); await ctx.assertSyncIsComplete(auth, [SyncRequestType.SharedSpaceAssetsV1]); - await defaultDatabase.updateTable('asset').set({ isFavorite: true }).where('id', '=', asset.id).execute(); + // The peer favorites the asset (via the overlay) AND a genuine metadata change bumps + // asset.updateId so the row re-flows through the update path — even so, auth's stream must + // never see the peer's favorite. + await ctx.get(AssetFavoriteRepository).addAll(peer.id, [asset.id]); + await defaultDatabase + .updateTable('asset') + .set({ originalFileName: 'renamed-for-resync.jpg' }) + .where('id', '=', asset.id) + .execute(); const next = await ctx.syncStream(auth, [SyncRequestType.SharedSpaceAssetsV1]); const updateEvents = next.filter((r: { type: string }) => r.type === SyncEntityType.SharedSpaceAssetUpdateV1); @@ -265,4 +317,43 @@ describe(SyncRequestType.SharedSpaceAssetsV1, () => { isFavorite: false, }); }); + + it("syncs the recipient's own favorite on the update path for a foreign-owned asset (#763, E25)", async () => { + const { auth, ctx } = await setup(); + const { user: peer } = await ctx.newUser(); + const { space } = await ctx.newSharedSpace({ createdById: peer.id }); + await ctx.newSharedSpaceMember({ + spaceId: space.id, + userId: peer.id, + role: SharedSpaceRole.Owner, + }); + await ctx.newSharedSpaceMember({ + spaceId: space.id, + userId: auth.user.id, + role: SharedSpaceRole.Editor, + }); + const { asset } = await ctx.newAsset({ ownerId: peer.id }); + await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id }); + + const initial = await ctx.syncStream(auth, [SyncRequestType.SharedSpaceAssetsV1]); + await ctx.syncAckAll(auth, initial); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.SharedSpaceAssetsV1]); + + // auth favorites the foreign-owned asset AND a genuine metadata change bumps asset.updateId + // so the row re-flows through the update path with auth's own favorite state. + await ctx.get(AssetFavoriteRepository).addAll(auth.user.id, [asset.id]); + await defaultDatabase + .updateTable('asset') + .set({ originalFileName: 'renamed-for-update-path' }) + .where('id', '=', asset.id) + .execute(); + + const next = await ctx.syncStream(auth, [SyncRequestType.SharedSpaceAssetsV1]); + const updateEvents = next.filter((r: { type: string }) => r.type === SyncEntityType.SharedSpaceAssetUpdateV1); + expect(updateEvents).toHaveLength(1); + expect((updateEvents[0] as { data: { id: string; isFavorite: boolean } }).data).toMatchObject({ + id: asset.id, + isFavorite: true, + }); + }); }); diff --git a/server/test/medium/specs/sync/sync-shared-space-visibility-gate.spec.ts b/server/test/medium/specs/sync/sync-shared-space-visibility-gate.spec.ts index d184d7413d4a41..6f7aea7b031fe0 100644 --- a/server/test/medium/specs/sync/sync-shared-space-visibility-gate.spec.ts +++ b/server/test/medium/specs/sync/sync-shared-space-visibility-gate.spec.ts @@ -18,6 +18,7 @@ import { Kysely } from 'kysely'; import { AssetVisibility, SharedSpaceRole, SyncEntityType, SyncRequestType } from 'src/enum.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { DB } from 'src/schema/index.js'; import { SyncTestContext } from 'test/medium.factory.js'; import { getKyselyDB } from 'test/utils.js'; @@ -134,8 +135,8 @@ describe('SharedSpaceAssetSync — visibility gate', () => { expect(emittedIds).not.toContain(asset.id); }); - it('masks isFavorite for a Hidden-to-Timeline asset with a foreign owner (unchanged behaviour)', async () => { - // Regression guard: isFavorite masking must be unaffected by the visibility gate. + it("does not leak the peer's favorite for a Hidden-to-Timeline asset with a foreign owner (unchanged behaviour, #763)", async () => { + // Regression guard: isFavorite overlay resolution must be unaffected by the visibility gate. const { auth, ctx } = await setup(); const { user: peer } = await ctx.newUser(); const { space } = await ctx.newSharedSpace({ createdById: peer.id }); @@ -145,9 +146,9 @@ describe('SharedSpaceAssetSync — visibility gate', () => { const { asset } = await ctx.newAsset({ ownerId: peer.id, visibility: AssetVisibility.Timeline, - isFavorite: true, }); await ctx.newSharedSpaceAsset({ spaceId: space.id, assetId: asset.id }); + await ctx.get(AssetFavoriteRepository).addAll(peer.id, [asset.id]); const response = await ctx.syncStream(auth, [SyncRequestType.SharedSpaceAssetsV1]); const assetEvents = response.filter((r) => isSharedSpaceAssetEvent(r)); diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts index fd508b12d5bb3e..ee0b72f22530bf 100644 --- a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -5,6 +5,7 @@ import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto.js'; import { AssetType, AssetVisibility, LogLevel } from 'src/enum.js'; import { AccessRepository } from 'src/repositories/access.repository.js'; import { AlbumRepository } from 'src/repositories/album.repository.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; import { ConfigRepository } from 'src/repositories/config.repository.js'; import { CryptoRepository } from 'src/repositories/crypto.repository.js'; @@ -33,6 +34,11 @@ class WorkflowTestContext extends MediumTestContext { await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); - await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); + // #763: favorites are a per-user overlay (asset_favorite), not the legacy asset.isFavorite + // column — the workflow's synthesized auth acts AS the asset owner, so isFavoriteForUser + // resolved for that same owner is the correct place to assert the write landed. + await expect(ctx.get(AssetRepository).getById(asset.id, {}, user.id)).resolves.toMatchObject({ + isFavoriteForUser: true, + }); }); it('should unfavorite an asset', async () => { const { user } = await ctx.newUser(); - const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + // #763: seed the per-user overlay row directly — ctx.newAsset's isFavorite option only sets + // the legacy column, which the read path no longer consults. + await ctx.get(AssetFavoriteRepository).addAll(user.id, [asset.id]); const workflow = await createWorkflow({ ownerId: user.id, @@ -238,14 +252,41 @@ describe('core plugin', () => { await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); - await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false }); + await expect(ctx.get(AssetRepository).getById(asset.id, {}, user.id)).resolves.toMatchObject({ + isFavoriteForUser: false, + }); + }); + + // #763 slice 3 pre-drop: workflow.repository.ts's getForAssetV1 (the workflowAssetV1 + // projection consumed by handleAssetTrigger's read()) must resolve the plugin-facing + // `isFavorite` field via favoriteExistsForOwner directly, not the legacy raw column — this + // asserts the PROJECTION itself, independent of the assetFavorite plugin action's write path + // covered above. + it('projects the ASSET OWNER favorite onto workflowAssetV1, not another user favorite', async () => { + const { user: owner } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const { asset: ownerFavorited } = await ctx.newAsset({ ownerId: owner.id }); + const { asset: notOwnerFavorited } = await ctx.newAsset({ ownerId: owner.id }); + + // Owner favorites via the per-user overlay only — the legacy asset.isFavorite column is + // never touched. + await ctx.get(AssetFavoriteRepository).addAll(owner.id, [ownerFavorited.id]); + // A DIFFERENT user favorites the other asset — its owner never favorited it. + await ctx.get(AssetFavoriteRepository).addAll(other.id, [notOwnerFavorited.id]); + + await expect(ctx.get(WorkflowRepository).getForAssetV1(ownerFavorited.id)).resolves.toMatchObject({ + isFavorite: true, + }); + await expect(ctx.get(WorkflowRepository).getForAssetV1(notOwnerFavorited.id)).resolves.toMatchObject({ + isFavorite: false, + }); }); }); describe('assetAddToAlbums', () => { it('should create an album by name', async () => { const { user } = await ctx.newUser(); - const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); const workflow = await createWorkflow({ ownerId: user.id, @@ -269,7 +310,7 @@ describe('core plugin', () => { it('should not use the name when there is an albumId', async () => { const { user } = await ctx.newUser(); - const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); const { album } = await ctx.newAlbum({ ownerId: user.id }); const workflow = await createWorkflow({ @@ -291,7 +332,7 @@ describe('core plugin', () => { it('should add an asset to an album', async () => { const { user } = await ctx.newUser(); - const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); const { album } = await ctx.newAlbum({ ownerId: user.id }); const workflow = await createWorkflow({ @@ -308,7 +349,7 @@ describe('core plugin', () => { it('should add an asset to multiple albums', async () => { const { user } = await ctx.newUser(); const [{ asset }, { album: album1 }, { album: album2 }] = await Promise.all([ - ctx.newAsset({ ownerId: user.id, isFavorite: true }), + ctx.newAsset({ ownerId: user.id }), ctx.newAlbum({ ownerId: user.id }), ctx.newAlbum({ ownerId: user.id }), ]); @@ -328,7 +369,7 @@ describe('core plugin', () => { it('should require album access', async () => { const { user: user1 } = await ctx.newUser(); const { user: user2 } = await ctx.newUser(); - const { asset } = await ctx.newAsset({ ownerId: user1.id, isFavorite: true }); + const { asset } = await ctx.newAsset({ ownerId: user1.id }); const { album } = await ctx.newAlbum({ ownerId: user2.id }); const workflow = await createWorkflow({ @@ -364,7 +405,10 @@ describe('core plugin', () => { }); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); - await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); + // #763: per-user overlay — the workflow acts as the asset owner. + await expect(ctx.get(AssetRepository).getById(asset.id, {}, user.id)).resolves.toMatchObject({ + isFavoriteForUser: true, + }); }); it('should not favorite asset outside a given radius', async () => { @@ -387,7 +431,10 @@ describe('core plugin', () => { }); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); - await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false }); + // #763: per-user overlay — the workflow acts as the asset owner. + await expect(ctx.get(AssetRepository).getById(asset.id, {}, user.id)).resolves.toMatchObject({ + isFavoriteForUser: false, + }); }); it('should favorite asset by location name', async () => { @@ -410,7 +457,10 @@ describe('core plugin', () => { }); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); - await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); + // #763: per-user overlay — the workflow acts as the asset owner. + await expect(ctx.get(AssetRepository).getById(asset.id, {}, user.id)).resolves.toMatchObject({ + isFavoriteForUser: true, + }); }); }); @@ -529,7 +579,10 @@ describe('core plugin', () => { }); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); - await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); + // #763: per-user overlay — the workflow acts as the asset owner. + await expect(ctx.get(AssetRepository).getById(asset.id, {}, user.id)).resolves.toMatchObject({ + isFavoriteForUser: true, + }); }); }); diff --git a/server/test/utils.ts b/server/test/utils.ts index 22330cb3d697b3..d4235975018aab 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -26,6 +26,7 @@ import { AlbumRepository } from 'src/repositories/album.repository.js'; import { ApiKeyRepository } from 'src/repositories/api-key.repository.js'; import { AppRepository } from 'src/repositories/app.repository.js'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository.js'; +import { AssetFavoriteRepository } from 'src/repositories/asset-favorite.repository.js'; import { AssetFileRepository } from 'src/repositories/asset-file.repository.js'; import { AssetJobRepository } from 'src/repositories/asset-job.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; @@ -249,6 +250,7 @@ export type ServiceOverrides = { app: AppRepository; asset: AssetRepository; assetEdit: AssetEditRepository; + assetFavorite: AssetFavoriteRepository; assetFile: AssetFileRepository; assetJob: AssetJobRepository; clusterGroup: ClusterGroupRepository; @@ -349,6 +351,7 @@ export const getMocks = () => { albumUser: automock(AlbumUserRepository), asset: newAssetRepositoryMock(), assetEdit: automock(AssetEditRepository), + assetFavorite: automock(AssetFavoriteRepository), assetFile: automock(AssetFileRepository), assetJob: automock(AssetJobRepository), clusterGroup: automock(ClusterGroupRepository), @@ -434,6 +437,7 @@ export const newTestService = ( overrides.app || (mocks.app as As), overrides.asset || (mocks.asset as As), overrides.assetEdit || (mocks.assetEdit as As), + overrides.assetFavorite || (mocks.assetFavorite as As), overrides.assetFile || (mocks.assetFile as As), overrides.assetJob || (mocks.assetJob as As), overrides.clusterGroup || (mocks.clusterGroup as As), diff --git a/specs/2026-07-20-per-user-favorites-design.md b/specs/2026-07-20-per-user-favorites-design.md new file mode 100644 index 00000000000000..67927686d3a94e --- /dev/null +++ b/specs/2026-07-20-per-user-favorites-design.md @@ -0,0 +1,1044 @@ +# Per-user favorites (`asset_favorite` overlay) — design + +## 0. Meta + +| Field | Value | +| ---------- | -------------------------------------------------------------------------------------- | +| Date | 2026-07-20 | +| Issue | [#763](https://github.com/open-noodle/gallery/issues/763) | +| Status | Approved — ready for `/impl-loop` | +| Branch | `worktree-fix-763-space-member-favorite` | +| Scope | server + web + mobile, landing atomically | +| Supersedes | Known gap 4 in `docs/plans/shared-spaces-permission-matrix.md:178` (favorite arm only) | + +Method: **TDD, red first**, on every slice. Each slice states its BDD scenarios, the failing +test that proves the gap, the fix, and an explicit edge-case list that must have assertions. +No slice is done until its negatives are asserted, not just its happy path. + +--- + +## 1. Problem + +In a Shared Space, the favorite (heart) action is absent from the photo action menu for anyone +who is not the asset's owner. The reporter's framing — "favoriting is a personal, per-user +curation action" — describes behavior the data model does not implement. + +Three facts define the problem: + +1. **Favorite is global asset state.** `isFavorite` is a single boolean column on the `asset` + row (`server/src/schema/tables/asset.table.ts:93-94`), present since + `1744910873969-InitialMigration.ts:120`. There is no per-user favorite table anywhere in + `server/src/schema/tables/`. + +2. **The web UI gates strictly on ownership.** `web/src/lib/services/asset.service.ts:115` + computes `isOwner = authUser.id === asset.ownerId`; the Favorite and Unfavorite actions gate + on it at `:170-176` and `:178-184`. The gate covers both the rendered button and the `f` + shortcut (both are `ActionItem.$if` from `@immich/ui`). The multi-select bar does the same via + `asset-multi-select-manager.svelte.ts:23-25,30-31`, which is why space pages hide the heart + entirely once a selection includes a photo you don't own + (`routes/(user)/spaces/[spaceId]/…/+page.svelte:883-888`). + +3. **The server is looser than the UI, in the wrong direction.** `Permission.AssetUpdate` unions + in `checkSpaceEditAccess` (`server/src/utils/access.ts:155-159`), and the field-level owner + guard in `asset.service.ts:224-237,324-340` covers only `visibility` and `livePhotoVideoId` — + `isFavorite` passes through unchecked (`:341`). A space **editor or owner** can therefore + `PUT /assets/:id {isFavorite:true}` on another member's photo today and flip the real owner's + flag. The web UI never issues that call, so it is reachable only via API/mobile, but the authz + layer permits it and `e2e/src/specs/server/api/shared-space-album.e2e-spec.ts:705` asserts it + as intended behavior. + +So the bug report and the latent authz bug are the same bug seen from two sides: favorite is +modelled as owned, global state, and every surface has patched around that inconsistently. + +### 1.1 What is already correct + +The fork has **already implemented the per-user read semantics** against the global column, by +masking on ownership: + +- `server/src/dtos/asset-response.dto.ts:228` — + `isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite` +- `server/src/repositories/asset.repository.ts:1432` — + ``sql`asset."isFavorite" and asset."ownerId" = ${auth.user.id}`.as('isFavorite')`` +- `sync.repository.ts` — `CASE WHEN ownerId = $1 …` on every cross-user stream + (`:240,260,283` album; `:998,1029,1059` shared space; `:1310,1347` library; + `:1827,1852,1886,1909,1941,1965` shared-space album), and hardcoded `false` for partners + (`:661,:681`, with `schema/migrations/1777897107000-PartnerAssetSyncReset.ts` cleaning up a + server ≤2.x leak). + +**Consequence for the design:** `AssetResponseDto.isFavorite` already means "favorited by you". +The API contract does not change. This work replaces each ownership-masking expression with a +join against an overlay table, and makes the write path per-user. It is a substitution, not new +plumbing. + +**Consequence for review:** claims elsewhere that the thumbnail heart badge leaks the owner's +favorite state to space members are **false** — `Thumbnail.svelte:337` renders +`asset.isFavorite`, which `asset-response.dto.ts:228` has already zeroed for non-owners. + +### 1.2 The guard that encodes the bug + +`server/src/services/timeline.service.ts` throws `BadRequestException` when `isFavorite` is +combined with `withPartners` or `withSharedSpaces` — the `isFavorite` checks are at `:180,192`, +the `throw`s at `:183-185,195-197`. Note the predicate is +`dto.isFavorite === true || dto.isFavorite === false`, so it trips on **`false` as well as +`true`**; both arms change in slice 4. Web mirrors it in ~12 places as +`withSharedSpaces: filters.isFavorite === undefined` (heaviest: +`routes/(user)/photos/[[assetId=id]]/+page.svelte`). `shared-space.service.ts:1040` skips +space-ID resolution when `isFavorite === true`; `map.service.ts:21,26` does the same. + +That guard exists **only** because the column is global — favorites could not be meaningfully +combined with a cross-user scope. Removing it is what actually delivers #763: favorites that +work across spaces. + +--- + +## 2. Goals & non-goals + +### Goals + +- Favoriting is personal for **every** user, including the asset owner. Your favorite is + invisible to, and independent of, everyone else's. +- Any user who can **read** an asset can favorite it — space viewers included. +- Favorites compose with cross-user scopes (spaces, partners) instead of being mutually exclusive. +- A space editor can no longer alter the owner's favorite state, by construction. +- `AssetResponseDto` keeps its current shape; existing API consumers keep working. +- Server, web and mobile stay consistent at every commit. + +### Non-goals + +- **Archive (`visibility`) and rating (`asset_exif.rating`)** have the identical owner-gated, + global-state problem (same known-gap line, `shared-spaces-permission-matrix.md:178`). This + design deliberately covers **favorite only** and documents the overlay as the reusable pattern + (§11.1). Archive additionally interacts with trash and the visibility enum in ways favorite + does not. +- `person.isFavorite` is a **separate feature** on the person table. Untouched. Roughly half of + all repo-wide `isFavorite` matches are person favorites or mobile's device-local backup mirror + (`local_asset.entity.dart:16-17,44`, `trashed_local_asset.entity.dart:25,46`) — neither is in + scope, and neither should be counted when sizing a slice. +- No favorites UI redesign. Same heart, same `/favorites` route, same filter chips. +- No shared/collaborative "space favorites" concept. + +--- + +## 3. The core invariant + +> A favorite is a fact about **(user, asset)**, never about an asset alone. Reading it requires +> the reader's identity; writing it requires only that the writer can read the asset; and no +> write by user A can ever change what user B sees. + +Two corollaries the slices must assert as negatives: + +- **No cross-user write.** There is no code path by which any role — space owner, space editor, + album owner, partner, or system admin — mutates another user's favorite. Admin elevation + (`hasElevatedPermission`) does not grant it. +- **No cross-user read.** A favorite row for user B never surfaces in user A's response payload, + timeline bucket, sync stream, statistic, or map marker. + +--- + +## 4. Data model + +New fork table, structurally a copy of the established per-user overlay precedent +`server/src/schema/tables/shared-space-person-alias.table.ts` (composite `(entityId, userId)` PK +letting each member hold their own value for a shared entity without touching the shared row): + +```ts +@Table("asset_favorite") +export class AssetFavoriteTable { + @ForeignKeyColumn(() => UserTable, { onDelete: "CASCADE", primary: true }) + userId!: string; + + @ForeignKeyColumn(() => AssetTable, { onDelete: "CASCADE", primary: true }) + assetId!: string; + + @CreateDateColumn() + createdAt!: Generated; + + // Sync watermarks — see §4.3. Favorites are their own synced entity, not a field + // on the asset row, so they need their own createId/updateId cursors. + @CreateIdColumn({ index: true }) + createId!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} +``` + +**PK order is `(userId, assetId)`, deliberately.** The dominant query is "my favorites" +(`/favorites` route, `isFavorite: true` filters, statistics), which is `userId`-leading. The +`assetId` foreign key carries its own index for the per-asset existence check used by the read +path. + +**Both FKs are `ON DELETE CASCADE`**: deleting a user or an asset removes the rows. There is no +`addedById`-style nullable actor column — the `userId` _is_ the actor, so `SET NULL` would be +meaningless. + +`createdAt` is not read by any slice in this design. It is included because favorite-ordering +("recently favorited") is a plausible near-term ask and adding a timestamp column later to a +large table is more expensive than carrying it from the start. Slices must not build behavior on it. + +### 4.3 Sync watermarks and the audit table — RESOLVED: both are required + +An earlier draft of this section assumed favorites were single-user state that mobile could +reconcile without a tombstone stream, and deferred the question to a slice-0 proof. **That proof +was run during slice-0 planning and the assumption is refuted.** The resolution is recorded here +because it changes the table definition above. + +**The mechanism.** Sync streams page on `updateId`, a UUIDv7 watermark column — see +`server/src/repositories/sync.repository.ts:136-143`: + +```ts +const updateIdRef = ref(`${t}.updateId`); + .where(updateIdRef, '<', nowId) + .where(updateIdRef, '<=', beforeUpdateId) + .$if(!!afterUpdateId, (qb) => qb.where(updateIdRef, '>', afterUpdateId!)) +``` + +An asset is re-emitted to a device only when `asset.updateId` advances. If a favorite write +touches only `asset_favorite`, the asset's watermark never moves, the incremental stream never +re-emits that asset, and **the favorite never reaches the device**. A full resync masks this +entirely, so it would not surface in a naive slice-6 test. + +**The two candidate fixes, and why one loses.** + +1. _Favorite writes bump `asset.updateId`._ Rejected. A viewer favoriting would mutate the + **owner's** asset row, re-syncing that asset to every member of the space — cross-user write + amplification triggered by a personal action, and in tension with §3's invariant that no write + by user A changes what user B sees. +2. _`asset_favorite` becomes its own synced entity._ **Chosen.** It carries `createId` / `updateId` + watermarks and an `asset_favorite_audit` tombstone table for the delete stream, exactly + mirroring `album_space_asset` + `album_space_asset_audit`. + +**Revised during the 2026-10 review — owner writes also bump `asset.updateId`.** Apps that predate +`AssetFavoritesV1` only see favorite changes through the asset streams. Before #763 an owner's +favorite was a column write that bumped the watermark, so those apps propagated it to the owner's +other devices; option 2 alone silently broke that. `AssetFavoriteRepository.addAll`/`removeAll` +therefore bump `asset.updatedAt` (and so `updateId`) for rows whose favorite state actually changed +**and** whose owner is the writer. That is exactly the pre-#763 re-sync footprint, so option 1's +objection — a viewer's personal write re-syncing the owner's asset to every space member — still +holds and still does not happen: a non-owner's favorite never touches the asset row. + +**Consequences.** `asset_favorite_audit` is created in **slice 0**, alongside the main table and +its delete trigger — never later, because slice 3 is irreversible. Slice 6 consumes these streams +rather than introducing them. Both tables need DROP lines, step-8 migration entries and step-9 +guard entries in `revert-to-immich.sql` (§4.2), and `asset_favorite_audit` ends with `_audit`, so +it _is_ caught by the existing `revert-to-immich.spec.ts` filter — but `asset_favorite` still is +not, so the filter widening in §4.2 remains required. + +**Note on the API-shape claim (§1.1).** `AssetResponseDto.isFavorite` still does not change shape. +Favorites being a separately-synced entity is a **sync-transport** decision, not a REST-payload +one; the REST asset response continues to carry a resolved per-user boolean. + +### 4.1 Migration + +Fork migration in `server/src/schema/migrations-gallery/`, round timestamp per the house +convention: + +1. `CREATE TABLE asset_favorite …` +2. Backfill: + `INSERT INTO asset_favorite ("userId", "assetId") SELECT "ownerId", id FROM asset WHERE "isFavorite" = true` + — camelCase identifiers **must** be double-quoted or Postgres folds them to lowercase and the + insert fails against the generated schema. +3. Drop `asset."isFavorite"` — **in slice 3, not slice 0** (see §9 ordering rationale). + +The backfill assigns each existing favorite to the **owner**, which is exactly what the current +masking semantics already imply: nobody but the owner could ever observe it. + +Must apply cleanly on a database that already has every #752 migration, via the unordered-migration +path (`allowUnorderedMigrations: true`, `DatabaseRepository.createMigrator()`). + +### 4.2 Revert-to-immich — a new step class + +`scripts/revert-to-immich.sql` today **only ever DROPs fork tables** — `grep "ADD COLUMN"` +returns nothing across the file. Dropping `asset.isFavorite` makes this the **first fork change +that removes upstream schema**, so the revert path must now _restore_ it: + +1. `ALTER TABLE asset ADD COLUMN "isFavorite" boolean NOT NULL DEFAULT false` +2. Backfill from the overlay, **owner rows only**: + `UPDATE asset SET "isFavorite" = true FROM asset_favorite f WHERE f."assetId" = asset.id AND f."userId" = asset."ownerId"` +3. `DROP TABLE IF EXISTS "asset_favorite" CASCADE;` + +Non-owner favorite rows are **intentionally discarded** on revert — plain Immich has nowhere to +put them. This is lossy and must be stated in the script's comments and in the switch-back guide +under `docs/docs/guides/`. + +Ordering matters: the `ADD COLUMN` + backfill must run **before** the `DROP TABLE`, which sits in +a block (`revert-to-immich.sql:107-145`) that currently assumes drop-only semantics. + +**`revert-to-immich.spec.ts` will _not_ catch a missing DROP line for this table — read this +before assuming it does.** The spec derives `createdTables` from what `migrations-gallery/` +actually `CREATE`s, but then narrows it (`server/src/schema/revert-to-immich.spec.ts:38`): + +```ts +const forkTables = [...createdTables].filter((name) => name.startsWith("shared_space") || name.endsWith("_audit")); +``` + +`asset_favorite` matches **neither** predicate, so it is silently excluded from both the +drop-every-fork-table test and the step-9 guard-list test. Left as-is, this table would ship with +**zero** revert coverage — exactly the drift the file's own header comment (`:20-23`) says it was +rewritten to prevent. + +Slice 0 must therefore **widen that filter** so non-`shared_space` fork tables are covered, not +merely add a DROP line under the existing one. + +The genuine red available at slice 0 is the **third** test — every `migrations-gallery` migration +must appear in the step-8 `kysely_migrations` DELETE block — which our new migration files do trip. +The spec must additionally be extended to assert the column-restore steps exist and are correctly +ordered, since its current guards understand only DROP lines. CI gate: +`.github/workflows/gallery-revert-to-immich-validation.yml`. + +--- + +## 5. Access & visibility model + +### 5.1 Who may favorite + +Gate on the existing **`Permission.AssetRead`**. That already resolves own assets, shared-space +assets at _any_ role including viewer, shared-album assets, and partner-shared assets. No new +authz concept is introduced. + +This is a deliberate widening relative to today's `Permission.AssetUpdate` +(`utils/access.ts:155-159`), and it is the crux of #763: the reporter's family members are space +**viewers**, and viewers currently receive 403 on any asset write. + +Space roles are `owner` / `editor` / `viewer` (`server/src/enum.ts:73-77`); there is no +space-level "admin". The issue's "Space Admin" is the space **owner**, and its "regular member" +is a viewer or editor. Slices must use the real role names. + +**Shared-link sessions may never favorite — and this requires an explicit guard.** It does _not_ +fall out for free. `server/src/dtos/auth.dto.ts:16,18` declares `user: AuthUser` as +**non-optional** and `sharedLink?: AuthSharedLink` as the optional field, so a shared-link request +carries the **link owner's** identity. Without an explicit `auth.sharedLink` rejection on the +canonical endpoint, an anonymous visitor holding a share link would create `asset_favorite` rows +**attributed to the link's owner** — an unauthenticated write in a real user's name. + +The endpoint must reject any request where `auth.sharedLink` is set, and E6 must assert it. + +**Nor may they read the owner's favorites.** The same `auth.user` identity means every read path +that resolves the overlay "for the caller" would resolve it for the link **owner** — showing an +anonymous visitor which of the album's photos (other contributors' included) the owner favorited. +Reads that accept shared links (asset detail, timeline buckets, metadata search) therefore project +no favorite for a shared-link session (`favoriteViewerId`), and reject an `isFavorite` filter +(`rejectSharedLinkFavoriteFilter`) — see `server/src/utils/favorite.ts`. + +### 5.2 Losing access + +**Favorite rows are never cleaned up on access loss. Visibility is re-derived on read.** + +When a user leaves a space, an album is unshared, partner sharing is revoked, or an asset is +removed from a space, the user's `asset_favorite` rows for now-unreadable assets **remain in the +table** and are filtered out by the read path's access check. Rejoining restores them. + +Rationale, and why the alternative was rejected: + +- **Fail-safe direction.** With re-derivation, a forgotten cleanup hook is harmless — the read + filter still hides the asset. With hard deletion, a forgotten hook is a **privacy leak**. There + are many revocation sites (space member removal, asset removed from space, album unshare, + partner unshare), and the fork has prior evidence of this being fragile: + `cleanupDepartingMemberFaces` from #752 shipped with zero test coverage. +- **Matches the documented house principle.** The header comment on + `server/src/schema/tables/album-space-asset.table.ts` states exactly this: keep cross-owner + state in a side table whose visibility is re-derived from live membership rather than mutated. +- **Preserves curation** across a leave/rejoin cycle. + +**The cost, stated honestly:** the favorites read path must now apply an access filter. Today +`/favorites` is implicitly safe because favorites are owner-only, so no filter exists. Slice 1 +adds one, and slice 1's edge cases must assert it — an unfiltered favorites query would surface +assets the user can no longer read, which is precisely the leak this design claims to prevent. + +**Mechanically, the safety today comes from owner-scoping, and that is also the second blocker.** +`server/src/services/timeline.service.ts:164` sets `dto.userId = dto.userId || auth.user.id`, and +`server/src/utils/database.ts:880` applies a bare `qb.where('asset.isFavorite', '=', …)` with no +owner or space awareness of its own. So the query is restricted to assets the caller **owns**. +That is why removing the `BadRequestException` guard alone (§1.2) is **not sufficient** — the +owner-scoping would still filter every non-owned favorite out, and the slice would pass its own +tests while delivering nothing. Slice 4 must replace owner-scoping with readable-asset scoping, +not merely delete the guard. + +### 5.3 Storage growth + +Rows for permanently-lost access accumulate. This is accepted: the row is three columns, the +volume is bounded by user curation activity, and a periodic reaper can be added later without a +schema change. **No reaper in this design.** If one is added, it must delete only rows whose +asset is unreadable by that user _and_ has been so for a defined grace period — never on the +revocation event itself, which would reintroduce the leave/rejoin data loss. + +--- + +### 5.4 Stacked assets — favorites are per-photo + +**Decision (approved 2026-07-20): a favorite applies to the exact asset favorited, never to its +stack.** Favoriting a stack **child** favorites that child only; favoriting the **primary** +favorites the primary only. A favorited child appears in `/favorites` as a standalone asset even +though it is normally hidden behind its stack primary elsewhere in the timeline. + +Rationale: it is the only option that needs no special-casing, and it follows directly from §3 — +a favorite is a fact about `(user, asset)`. The alternatives (redirect the favorite to the stack +primary; or render the stack in Favorites with the favorited child inside it) both introduce a +second notion of what a favorite attaches to, which is the ambiguity this whole design exists to +remove. + +Accepted cost: a user can see a photo in Favorites that appears nowhere else in their timeline, +which can read as a bug. It is rare, and it is literally what the user asked for by favoriting +that photo. Do **not** add stack-collapsing logic to the favorites view to hide it. + +Why it becomes reachable now: today only owners favorite, and owners can restack and manage their +own assets. Once space viewers can favorite, users who can neither see nor manage the stack can +reach this state. + +## 6. Lifecycle & edge cases — assertion source for the slices + +Every row here must have an explicit assertion in the slice named. This table is the contract; +a slice is not done while a row in it is unasserted. + +| # | Scenario | Expected | Slice | +| ---- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----- | +| E1 | Two users favorite the same asset | Two independent rows; each sees only their own `isFavorite: true` | 1, 2 | +| E2 | Owner unfavorites; member had favorited | Member's row untouched; member still sees `true` | 2 | +| E3 | Space **viewer** favorites a space asset | 200; row created; owner's view unchanged | 2 | +| E4 | Space **editor** attempts to set another member's favorite | Impossible — no endpoint accepts a target user | 2 | +| E5 | Non-member attempts to favorite a space asset | 403; no row created | 2 | +| E6 | Shared-link session attempts to favorite | Rejected by an **explicit** `auth.sharedLink` guard; no row created for the link owner | 2 | +| E7 | System admin (`hasElevatedPermission`) favorites another user's asset | Creates the **admin's own** row only; target user unaffected | 2 | +| E8 | Favorite twice (idempotency) | Second call is a no-op, not a 500 (`onConflict do nothing`) | 2 | +| E9 | Unfavorite something never favorited | No-op, 200, not 404 | 2 | +| E10 | Member favorites, then leaves the space | Row persists; asset absent from their `/favorites` and from `isFavorite: true` filters | 4 | +| E11 | …then rejoins the space | Asset reappears in their favorites | 4 | +| E12 | Asset deleted | Rows CASCADE for all users | 0 | +| E13 | User deleted | Their rows CASCADE; other users' rows for the same assets survive | 0 | +| E14 | Asset trashed then restored | Favorite survives (trash never touched favorite before and must not now) | 7 | +| E15 | `isFavorite: true` + `withSharedSpaces` | Works, returns cross-space favorites — no longer a 400 | 4 | +| E16 | `isFavorite: true` + `withPartners` | Works — no longer a 400 | 4 | +| E16b | `isFavorite: **false**` + either cross-user scope | Also works — the guard trips on `false` too today, so both arms must be asserted | 4 | +| E17 | Deprecated `UpdateAssetDto.isFavorite` from an owner | Writes the caller's own row; identical result to the canonical endpoint | 2 | +| E18 | Deprecated alias from a space **viewer** | Still 403 — the alias is strictly narrower and must never widen access | 2 | +| E19 | Upload with `isFavorite: true` | Creates the uploader's row (uploader is always the owner) | 7 | +| E20 | Asset copy with `favorite: true` | Copies the **acting user's** row only, not every user's | 7 | +| E21 | Duplicate merge | Union of all source assets' rows onto the keeper, **per user**, before sources CASCADE | 7 | +| E22 | Statistics `isFavorite: true` | Counts only the caller's rows | 1 | +| E23 | Map markers `isFavorite: true` | Only caller's favorites; cross-space markers now included | 4 | +| E24 | Timeline bucket parallel array | `isFavorite[]` aligns 1:1 with the bucket's asset order for the caller | 1 | +| E25 | Mobile sync, non-owned space asset | Stream carries the **recipient's** favorite, not a masked `false` | 6 | +| E26 | Mobile unfavorite while offline, then sync | Converges to the user's intent; no cross-user write | 6 | +| E27 | Concurrent favorite + unfavorite of the same `(user, asset)` | Converges deterministically; no PK violation, no 500, no orphaned row | 2 | +| E28 | Bulk request with an oversized `ids` array | Bounded — documented limit enforced with a 400, not an unbounded statement | 2 | +| E29 | Album **unshared** after the user favorited an asset in it | Same as E10: row persists, asset drops out of their favorites | 4 | +| E30 | Partner sharing **revoked** after favoriting | Same as E10 | 4 | +| E31 | Stacked assets (`/favorites` sends `withStacked: true`) | **Per-photo** (§5.4): favoriting a stack child favorites only that asset, and it appears standalone in Favorites | 4 | +| E32 | Mobile toggle direction on a mixed-ownership selection | Direction derives from the **same** set that is mutated (see slice 6) | 6 | + +--- + +## 7. Surfaces + +Sizing from the call-site sweep. Person-favorite and mobile device-local-mirror sites are +excluded throughout. + +| Surface | Sites | Nature | +| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Server repo reads | ~24 expressions | Substitute masking → EXISTS join | +| Server SQL snapshots | 4 files, ~21 blocks | Regenerate: `asset.repository.sql` (4), `search.repository.sql` (5), `sync.repository.sql` (12) | +| Server writes | **5** | `asset.service.ts:313,341,501`; `asset-media.service.ts:352`; `duplicate.service.ts:305` | +| Search / filters | ~12 | Mostly boolean passthrough; real work at `search.repository.ts:655,1413-1414`, `utils/database.ts:880` | +| Timeline | ~7 | `asset.repository.ts:447,1432,1494`; `time-bucket.dto.ts:29,131`; `timeline.service.ts:180,192` | +| Stats | 3 | `asset.repository.ts:1255,1265`; `UserUsageStatistic.svelte:54` | +| Memories | **0** | No favorite weighting exists anywhere. Confirmed. | +| Mobile sync | 22 server expr / 12 snapshots + ~15 Dart | `sync.repository.ts`; `sync_stream.repository.dart:236,277` | +| Mobile UI | ~35 | `favorite.action.dart:9-37` (note `:22` owner filter), action buttons, `drift_favorite.page.dart`, filters, map | +| Web | ~85 non-test | `asset.service.ts:173,181,432-448`; `FavoriteAction.svelte`; `/favorites` route; ~40 filter-plumbing passthrough | +| API DTOs | ~16 hand-written + 69 generated | `asset-response.dto.ts:96,140,228`; `asset.dto.ts:11,82,162`; regenerate both SDKs | +| CLI | **0** | Confirmed no favorite handling. | +| Tests | ~42 e2e + ~90 unit | See §9 per slice | + +--- + +## 8. Decisions taken + +Recorded so `/impl-loop` does not relitigate them. + +| Decision | Choice | Rejected alternative & why | +| ------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Semantics | Per-user overlay for **everyone**, owner included | Space-scoped-only overlay: two code paths for one user-visible concept, and "my favorites" would have to union both | +| `asset.isFavorite` column | Backfill, then **drop** — overlay is sole source of truth | Dual-write cache: two sources of truth that drift silently | +| Who may favorite | Anything readable (`Permission.AssetRead`) | Own+space only: unexplainable to users ("why can I heart this space photo but not this album photo?") | +| Access revocation | Keep rows, re-derive on read | Hard delete: every missed hook is a leak, and rejoining loses curation | +| Platforms | Server + web + mobile atomically | Staged: a window where the sync stream and server disagree about what `isFavorite` means | +| Write API | Dedicated endpoint **+ deprecated alias** | Alias-free: breaks third-party API consumers. Guard-relaxation-only: one missed field is privilege escalation | +| Sibling actions | Favorite only; pattern documented (§11.1) | All three: archive drags in trash/visibility semantics | + +### 8.1 On the deprecated alias + +The alias (`UpdateAssetDto.isFavorite`, `AssetBulkUpdateDto`) is **not** a second authorization +path. It routes into the _same_ service method as the canonical endpoint, behind the _stricter_ +`Permission.AssetUpdate` route guard. It is therefore structurally incapable of being more +permissive than the canonical endpoint — a second door to one room, not a second room. E18 +asserts this. + +Removal is out of scope; it needs a deprecation window announced to API consumers. + +--- + +## 9. Slice overview + +| # | Slice | Layers | Depends on | Self-contained? | +| --- | -------------------------------------------------------------- | ---------------------- | ----------- | -------------------- | +| 0 | `asset_favorite` schema, migration, backfill, revert-to-immich | server medium | — | foundation | +| 1 | Read path: masking → EXISTS join, + favorites access filter | server medium + unit | 0 | foundation | +| 2 | Write path: canonical endpoint, deprecated alias, authz | server e2e + unit | 1 | ✅ (API) | +| 3 | Drop `asset.isFavorite`; regenerate SQL snapshots | server medium | 1, 2, **7** | irreversible gate | +| 4 | Cross-scope favorites: guard **and** owner-scoping removal | server e2e + web | 3 | ✅ | +| 5 | Web: un-gate the heart in viewer + multi-select | web unit + Playwright | 2 | ✅ — **closes #763** | +| 6 | Mobile: sync stream, Drift, un-gate the action | server medium + mobile | 3 | ✅ | +| 7 | Secondary writes: upload, copy, duplicate-merge, trash | server e2e + unit | 2 | ✅ | + +Ordering: **0 → 1 → 1b → 2 → 7 → 4 → 5 → 6 → 3**. Each `/impl-loop` run does one slice, TDD. + +**Slice 3 is LAST.** It was originally placed mid-sequence on the reasoning that dropping the +column as soon as nothing depends on it converts silent-staleness risk into a loud error. That +reasoning is sound, but the premise was wrong: things still depend on it much later than assumed. +The readiness grep after slice 7 found the column still referenced by + +- `sync.repository.ts` — 15 `eb.ref('asset.isFavorite')` sites (slice 6 converts these) +- `database.ts:451,508` — the `columns.asset` / `columns.syncAsset` constants +- `job.service.ts:162,232` and `workflow-execution.service.ts:331` — the plugin-facing + `workflowAssetV1` projection + +so slice 3 must follow slice 6. Its gate is not "the tests pass" but the readiness grep returning +**nothing outside DTOs, schema definitions, person favorites and migration history**. + +**Correction (2026-07-20): slice 7 must precede slice 3.** The original ordering put the +secondary writes after the column drop. That is wrong and would have broken the build at the one +irreversible step. Three sites still write or read the raw column and would fail the moment it is +dropped: + +- `asset-media.service.ts:352` — upload writes `isFavorite: dto.isFavorite` into the asset create +- `asset.service.ts:557` — copy writes `{ id: targetId, isFavorite: sourceAsset.isFavorite }` +- `duplicate.service.ts:308` — merge reads `assets.some((asset) => asset.isFavorite)` + +The symptom was already visible before the reorder: after slice 1b made statistics read the +overlay while upload still wrote the column, `GET /assets/statistics?isFavorite` returned zero for +freshly-uploaded favorited assets — three failing e2e assertions in `asset.e2e-spec.ts`. Those +failures are the read/write asymmetry, and slice 7 closes it. **Do not run slice 3 until slice 7 +is green.** + +**All eight slices land as a single PR.** §8 records the decision that server, web and mobile ship +atomically, so there is no window where the sync stream and the server disagree about what +`isFavorite` means. "Self-contained" in the table above means the slice is a coherent, separately +reviewable unit whose tests pass on their own — **not** that it may be merged alone. In +particular, shipping slice 5 by itself would un-gate the web heart against a server that still +owner-scopes favorites (§5.2), and shipping 0–5 without 6 would leave mobile reading a column that +slice 3 deleted. + +**Why slice 3 (the drop) sits mid-sequence, not last.** Slices 0–2 leave the column present but +unread and unwritten — a dead column. Deferring the drop past 4–7 would mean every later slice +must reason about whether the dead column matters, and would leave a long window where a +rebase-imported upstream query could silently start reading stale data with no test failing. +Dropping it as soon as nothing depends on it converts that silent-staleness risk into a loud +compile/query error. Slice 3 is the point of no return; 0–2 are reversible. + +--- + +## Slice 0 — Schema foundation: `asset_favorite` + +**Delivers.** The storage substrate, its cascade guarantees, the backfill, and a correct revert +path. No user-facing behavior (the one non-vertical slice besides 1); fully covered by medium +tests against the live schema. + +**Behavior (BDD)** + +- _Given_ a user and an asset, _When_ an `asset_favorite` row is inserted, _Then_ it persists and + is readable by `(userId, assetId)`. +- _Given_ two users and one asset, _When_ both insert rows, _Then_ both persist independently. +- _Given_ an `asset_favorite` row, _When_ its **asset** is deleted, _Then_ the row is + cascade-deleted; _When_ its **user** is deleted, _Then_ the row is cascade-deleted and other + users' rows for that asset survive. +- _Given_ a database with `asset."isFavorite" = true` rows, _When_ the migration runs, _Then_ each + produces exactly one `asset_favorite` row keyed to that asset's **owner**. +- _Given_ the revert script runs, _When_ it completes, _Then_ `asset."isFavorite"` exists again and + is `true` exactly for assets the **owner** had favorited. + +**TDD — red first** + +- **Medium** (`server/test/medium/specs/…`): create the table via migration on the testcontainers + DB; assert insert/read, composite-PK uniqueness, and each FK cascade. Red because the table + does not exist. +- **Migration test**: seed `asset` rows with `isFavorite` true/false across two owners, run the + migration, assert the backfilled row set exactly. +- **Unit** (`server/src/schema/revert-to-immich.spec.ts`): the available red is the **step-8 + `kysely_migrations` DELETE block** test, which the new `migrations-gallery` migration trips + immediately. The drop-every-fork-table test will **not** go red — `asset_favorite` is filtered + out at `:38` (§4.2) — so this slice must first **widen that filter** to cover non-`shared_space` + fork tables, which makes the DROP-line test go red, and only then add the DROP line. Extend the + spec to assert the `ADD COLUMN` + backfill + `DROP TABLE` steps exist **and are correctly + ordered**; its current guards understand only DROP lines. +- **Medium (audit-table decision, §4)**: assert a client can reconcile favorite **deletes** from + the user's own full row set with no tombstone stream. If this cannot be satisfied, + `asset_favorite_audit` is created **in this slice** — never later, since slice 3 is irreversible. + +**Fix (green).** + +- `server/src/schema/tables/asset-favorite.table.ts` per §4. +- Migration in `migrations-gallery/` with a round timestamp: create + backfill. **Does not drop + the column** — that is slice 3. +- `scripts/revert-to-immich.sql`: the three ordered steps from §4.2, with a comment stating the + non-owner-row loss, **plus** `asset_favorite` in the step-9 `fork_tables_left` guard list and + the new migration name in the step-8 DELETE block. +- `revert-to-immich.spec.ts:38` — widen the `forkTables` filter so fork tables outside the + `shared_space*` / `*_audit` naming conventions are covered. +- Register the repository in `test/medium.factory.ts`'s `newRealRepository` switch, and in + `BaseService` at **all three sites** — import, constructor, and the `static create()` positional + list. Missing the third shifts every later repository and fails only in medium tests. +- Document the lossy revert in the switch-back guide under `docs/docs/guides/`. + +**Edge cases (explicit assertions)** + +- Duplicate `(userId, assetId)` insert → PK conflict handled, not a 500. (E8 substrate) +- Asset delete cascades for **all** users, not just the owner. (E12) +- User delete leaves other users' rows for the same asset intact. (E13) +- Migration is idempotent-safe on a DB with zero favorites (empty backfill, no error). +- Migration applies cleanly on a DB that already has every #752 migration (unordered path). +- Revert on a DB with **only** non-owner favorites → column added, all `false`, no crash. + +**Green.** Medium schema suite green; `revert-to-immich.spec.ts` green; revert-to-immich CI +workflow green; `make sql` diff limited to the new table; server `check` clean. + +--- + +## Slice 1 — Read path: masking → overlay join + +**Delivers.** Every read of "is this favorited" resolves against `asset_favorite` for the +requesting user, and the favorites read path gains the access filter §5.2 requires. + +**This slice is _not_ a pure refactor, despite mostly looking like one.** The substitution of +masking expressions for overlay joins is behaviour-preserving — the backfill guarantees owner +parity, and that parity is the regression suite. But adding the readable-asset filter is a real +behaviour change (E10), and it is the one part of this slice that can leak if it is wrong. Treat +the substitution as refactor-with-parity-tests and the access filter as new behaviour with its own +red-first test. + +**Behavior (BDD)** + +- _Given_ a user who favorited an asset, _When_ they fetch it, _Then_ `isFavorite: true`. +- _Given_ user A favorited an asset and user B did not, _When_ B fetches it, _Then_ + `isFavorite: false` — regardless of who owns it. (E1) +- _Given_ a user favorited a space asset and then lost access, _When_ they list favorites, + _Then_ the asset is absent, _And_ the row still exists in the table. (E10) +- _Given_ a timeline bucket request, _When_ it returns, _Then_ `isFavorite[]` aligns 1:1 with the + bucket's asset order for the caller. (E24) +- _Given_ `getAssetStatistics({ isFavorite: true })`, _When_ it runs, _Then_ it counts only the + caller's rows. (E22) + +**TDD — red first** + +- **Medium**: seed two users favoriting overlapping assets via `asset_favorite` directly; assert + each read surface returns the correct per-user value. Red because reads still consult the + column, which the direct inserts never set. +- **Unit** (`asset-response.dto` mapper): assert the mapper reads the joined value, not + `ownerId` equality. +- **Medium (the leak test)**: favorite a space asset, revoke space membership, assert the asset + is absent from the favorites listing **and** the row is still present. Red because no access + filter exists today. + +**Fix (green).** Substitute at each site: + +- `dtos/asset-response.dto.ts:228` — read the joined value. +- `repositories/asset.repository.ts:1432` (masked select), `:1494` (`array_agg`), `:447` + (`withTimeBucketAssetFilters` WHERE), `:1061` (`getForCopy`), `:1265` (`getStatistics`). +- `search.repository.ts:655,1413-1414`, `:324,359,377,397,507`; `utils/database.ts:880` + (drives 4 snapshots **and** `shared-space.repository.ts:1534`); `map.repository.ts:21,95,109`. +- `database.ts:249,451,454,509,582` column-list constants — note `:582` already comments + "syncAsset minus isFavorite". +- Add the readable-asset filter to the favorites read path per §5.2. +- Leave `sync.repository.ts` for slice 6 and writes for slice 2; the column stays present. + +**Edge cases** + +- Asset the user cannot read at all → never appears, even with a row. (E10) +- Owner's own favorites unchanged vs. pre-migration (parity regression suite). +- Bucket `isFavorite[]` alignment holds when the caller favorited a strict subset. (E24) +- Empty favorites → empty result, not an error. +- `isFavorite: false` filter means "not favorited **by me**", including assets others favorited. +- Statistics never count another user's rows. (E22) + +**Green.** Medium + unit suites green; existing e2e still green (owner parity); server `check` +and `prettier --check` clean on all modified files. + +--- + +## Slice 2 — Write path: canonical endpoint + deprecated alias + +**Delivers.** The #763 capability at the API layer: any user who can read an asset can favorite +it for themselves, and no user can alter anyone else's favorite. + +**Behavior (BDD)** — space S; Alice owns asset X in S; Bob is a **viewer** of S; Carol is a non-member. + +- _Given_ Bob, _When_ he `PUT /assets/favorites {ids:[X], isFavorite:true}`, _Then_ 200, _And_ Bob + sees `isFavorite: true`, _And_ Alice's view of X is unchanged. (E3) +- _Given_ Alice had favorited X, _When_ she unfavorites it, _Then_ Bob still sees `true`. (E2) +- _Given_ Carol, _When_ she calls the endpoint for X, _Then_ 403 and no row. (E5) +- _Given_ a shared-link session, _When_ it calls the endpoint, _Then_ rejected, no row. (E6) +- _Given_ an admin with elevated permission, _When_ they favorite X, _Then_ only the **admin's** + row is created. (E7) +- _Given_ Alice, _When_ she uses the deprecated `PUT /assets/:id {isFavorite:true}`, _Then_ + identical result to the canonical endpoint. (E17) +- _Given_ Bob (viewer), _When_ he uses the deprecated alias, _Then_ **still 403** — the alias never + widens access. (E18) + +**TDD — red first** + +- **E2E** (`e2e/src/specs/server/api/asset.e2e-spec.ts` + a new favorites spec): the full matrix + above. Red because the endpoint does not exist and viewers currently 403 on any asset write. +- **Unit** (`asset.service.spec.ts`): the service method writes/deletes rows for + `auth.user.id` only, and accepts no caller-supplied target user. +- **Invert the bug-encoding test.** `e2e/src/specs/server/api/shared-space-album.e2e-spec.ts:705` + currently asserts an editor can bulk-favorite another member's asset. Rewrite it to assert the + editor's write creates the **editor's own** row and leaves the owner's state untouched. This + inversion is a required deliverable, not incidental churn. + +**Fix (green).** + +- Canonical route `PUT /assets/favorites` `{ ids: string[], isFavorite: boolean }`, guarded by + `Permission.AssetRead`. +- Service method: `onConflict do nothing` insert / delete-by-key, scoped to `auth.user.id`. +- Wire `UpdateAssetDto.isFavorite` and `AssetBulkUpdateDto` (`asset.service.ts:313,341`) into the + same method; remove `isFavorite` from the `_.omitBy` column write at `:341`. +- Mark the DTO field `@deprecated` with a pointer to the canonical route. +- Regenerate OpenAPI spec + **both** SDKs (`make open-api` — TS-only regeneration leaves the Dart + client stale and fails CI). + +**Edge cases** + +- Idempotent favorite / no-op unfavorite. (E8, E9) +- Bulk request mixing readable and unreadable ids → per-id outcome, no partial-success lie + (mirrors the #764 toast lesson). +- Empty `ids` → 400 or clean no-op, explicitly asserted either way. +- Oversized `ids` array → bounded by a documented limit, rejected with 400 rather than issuing an + unbounded statement. (E28) +- Concurrent favorite + unfavorite of the same `(user, asset)` → converges deterministically; the + `onConflict do nothing` insert and the delete-by-key must not race into a 500 or an orphan. (E27) +- Nonexistent asset id → 400/404, never a 500. +- No request shape permits naming another user as the favorite subject. (E4) +- Elevated permission does not widen the subject. (E7) +- A request carrying `auth.sharedLink` is rejected before any row is written. (E6, §5.1) + +**Green.** New + inverted e2e green; `asset.service.spec.ts` green; SDKs regenerated and +committed; server `check` + `prettier --check` clean. + +--- + +## Slice 3 — Drop `asset.isFavorite` + +**Delivers.** Single source of truth. The point of no return. + +**Behavior (BDD)** + +- _Given_ a user who had favorited assets before the column was dropped, _When_ they fetch those + assets, list `/favorites`, request a timeline bucket, and read their favorite statistic, _Then_ + every result is byte-identical to the pre-drop response. +- _Given_ a second user who favorited a subset of the same assets, _When_ both users read, _Then_ + each still sees only their own favorites — the drop changes storage, never semantics. +- _Given_ the column is gone, _When_ the server boots and runs its migrations, _Then_ startup + succeeds and no query references the removed column. + +**TDD — red first** + +- **Grep gate** (a real assertion, not a manual step): a unit test asserting zero references to + `asset."isFavorite"` / `asset.isFavorite` remain in `server/src/`, excluding the migration + history and person favorites. Red until every site is converted. +- **Medium**: full favorite suite from slices 1–2 re-run against the post-drop schema. + +**Fix (green).** + +- Migration in `migrations-gallery/`: `ALTER TABLE asset DROP COLUMN "isFavorite"`. +- Remove the field from `asset.table.ts:93-94`. +- Regenerate the 4 SQL snapshot files (~21 blocks) against a scratch migrated DB. + **Never run `make sql` without a running DB — it deletes all query files.** + +**Edge cases** + +- Snapshot regeneration diff contains **only** favorite-related changes — a wider diff means a + stale or wrong DB. +- `sync.repository.sql` blocks still compile with the column gone (slice 6 changes semantics; this + slice must not break the build in between). +- Drop migration applies on a DB where the slice-0 backfill already ran. +- Revert-to-immich still round-trips post-drop (re-run slice 0's revert assertions). + +**Green.** Grep gate green; all prior favorite suites green; `make sql` diff scoped; +revert-to-immich CI green. + +--- + +## Slice 4 — Cross-scope favorites + +**Delivers.** Favorites that compose with spaces and partners — the behavioral payoff of the +whole design. + +**Behavior (BDD)** + +- _Given_ a user with favorites across their own library and two spaces, _When_ they request + `isFavorite: true` with `withSharedSpaces: true`, _Then_ 200 with the union — not a 400, **and + the non-owned favorites are actually present in the result**. (E15) +- _Given_ the same with `withPartners: true`, _Then_ 200 with partner-owned favorites present. (E16) +- _Given_ `isFavorite: false` with either cross-user scope, _Then_ 200 — the current guard trips on + `false` as well as `true`, so both arms change. (E16b) +- _Given_ a user who favorited a space asset then left, _When_ they list favorites, _Then_ absent; + _When_ they rejoin, _Then_ present again. (E10, E11) +- _Given_ an album is unshared, or partner sharing revoked, after favoriting, _Then_ the same + drop-out behaviour as leaving a space. (E29, E30) +- _Given_ map markers filtered by favorite, _When_ requested, _Then_ cross-space markers included. (E23) + +**TDD — red first** + +- **E2E**: the combinations above. Two distinct reds, and the second is the one that matters — + first the 400 from the guard, then, once the guard is gone, an **empty/short result** because + the query is still owner-scoped. A test that only asserts "not a 400" would pass against a + feature that returns nothing; every scenario must assert the non-owned favorite is **in** the + payload. +- **Unit** (`timeline.service.spec.ts`): the 5 existing tests asserting the rejection must be + **inverted** — they currently encode the limitation. +- **Web unit**: filter-options specs asserting favorites no longer suppress `withSharedSpaces`. + +**Fix (green).** Two independent blockers — removing only the first yields a green-but-inert slice: + +1. Delete the `BadRequestException` guards in `timeline.service.ts` (the `isFavorite` checks are + at `:180,192`; the `throw`s at `:183-185,195-197`). Both trip on `isFavorite: false` too. +2. **Replace owner-scoping with readable-asset scoping** (§5.2). `timeline.service.ts:164` sets + `dto.userId = dto.userId || auth.user.id`, and `utils/database.ts:880` applies a bare + `qb.where('asset.isFavorite', '=', …)`. Together these restrict results to assets the caller + **owns**, which would filter out every cross-space favorite even with the guard gone. + +- Remove the ~12 web mirrors (`withSharedSpaces: filters.isFavorite === undefined`), heaviest in + `routes/(user)/photos/[[assetId=id]]/+page.svelte`. +- Remove the space-resolution skip at `shared-space.service.ts:1040` and the `map.service.ts:21,26` + equivalent. + +**Edge cases** + +- Favorites + space + partner **simultaneously**. +- Favorite in a space with `showInTimeline: false` → respects the existing timeline preference. +- Favorited asset in a space the user owns _and_ one they're a viewer of → appears once, not twice. +- Trashed favorited asset stays out of the favorites timeline. +- `/favorites` route unchanged for a user with no spaces (regression). +- Album unshare (E29) and partner revoke (E30) drop favorites out, exactly as leaving a space does. +- **Stacks**: `/favorites` sends `withStacked: true`. Favoriting a stack **child** vs. the + **primary** must have one defined behaviour — pick it, state it, assert it. Today the question + cannot arise because only owners favorite; it becomes reachable here. (E31) +- **Performance**: `utils/database.ts:880` goes from a bare indexed column predicate to a join, at + the same time as the result set widens across spaces. Assert the §10 budget on a seeded + many-spaces dataset — this is the shape of regression that produced the People-page incident. + +**Green.** E2E green; inverted `timeline.service.spec.ts` green; web unit green; web +`check:typescript` + `check:svelte` + `pnpm lint` clean. + +--- + +## Slice 5 — Web: un-gate the heart — closes #763 + +**Delivers.** The literal user-visible fix from the issue: a space viewer opens a photo they +don't own and the heart is there. + +**Behavior (BDD)** + +- _Given_ Bob is a space **viewer** viewing Alice's photo, _When_ he opens the action menu, + _Then_ Favorite is present, _And_ pressing `f` toggles it. +- _Given_ Bob favorited it, _When_ he reopens the menu, _Then_ **Unfavorite** is shown. +- _Given_ Bob multi-selects photos including ones he doesn't own, _When_ the action bar renders, + _Then_ the heart is present and applies to the **whole** selection. +- _Given_ a shared-link viewer, _When_ the menu renders, _Then_ **no** favorite action. + +**TDD — red first** + +- **Web unit** (`web/src/lib/services/asset.service.spec.ts`): assert the Favorite/Unfavorite + `$if` no longer consults `isOwner`. Red because of `asset.service.ts:170-184`. +- **Web unit** (`asset-multi-select-manager.svelte.spec.ts`, `selection-command-handlers.spec.ts`): + assert the favorite command targets **all** selected assets, not `ownedAssets`. +- **Playwright**: a space viewer favorites a non-owned photo and the heart persists across reload. + +**Fix (green).** + +- Drop `isOwner` from the two `$if` gates (`asset.service.ts:173,181`); keep it for genuinely + owner-only actions (delete, archive, edit, stack, visibility). +- `FavoriteAction.svelte:30` — target the full selection, not + `assetMultiSelectManager.ownedAssets`. +- `asset-multi-select-manager.svelte.ts:23-25,30-31` — stop using `isAllUserOwned` to gate favorite. +- Un-gate the space-page heart (`spaces/[spaceId]/…/+page.svelte:883-888` and the + people variant `:720-725`). +- Point web writes at the canonical endpoint (`asset.service.ts:432-448`). +- Update `docs/plans/shared-spaces-permission-matrix.md:70` (Favorite row → Yes/Yes/Yes) and amend + known-gap 4 at `:178` to cover only archive/edit/rating. + +**Edge cases** + +- Owner's experience unchanged (regression). +- Shared-link viewer: no action, and the `f` shortcut is inert. +- Mixed selection (owned + non-owned) → single toggle applies to all. +- Toggling from the space **people** page as well as the space timeline. +- Optimistic UI reverts correctly on server error. +- Heart badge reflects the **viewer's** state on a non-owned thumbnail. + +**Green.** Web unit + Playwright green; `check:typescript` + `check:svelte` + `pnpm lint` clean +from `web/`. **#763 is closable at the end of this slice.** + +--- + +## Slice 6 — Mobile: sync, Drift, un-gate + +**Delivers.** Mobile parity — the recipient's own favorite flows through the sync stream and the +mobile heart works on non-owned space assets. + +**Behavior (BDD)** + +- _Given_ Bob favorited Alice's space asset, _When_ his device syncs, _Then_ the local row carries + `isFavorite: true` — not the masked `false` the stream sends today. (E25) +- _Given_ Bob unfavorites on mobile, _When_ it syncs, _Then_ his row is removed and Alice is + unaffected. +- _Given_ Bob toggles offline, _When_ connectivity returns, _Then_ state converges with no + cross-user write. (E26) +- _Given_ a non-owned asset, _When_ Bob opens the viewer, _Then_ the heart is present and enabled. + +**TDD — red first** + +- **Medium** (server): assert each sync stream emits the **recipient's** favorite. Red because of + the `CASE WHEN ownerId = $1` masking and the partner hardcoded `false`. +- **Flutter** (`flutter test`): `favorite.action.dart` no longer filters on + `asset.ownerId == scope.authUser.id` (`:22`); the repository writes via the canonical endpoint. +- **Flutter (toggle-direction bug, E32)**: with a mixed-ownership selection, assert the toggle + direction matches the set actually mutated. Red today — `favorite.action.dart:11` computes + `shouldFavorite = assets.any((asset) => !asset.isFavorite)` over the **unfiltered** selection + while `:22` filters to owned assets, so a non-owned asset can already flip the direction while + being excluded from the mutation. Latent today; load-bearing once `:22` is un-gated. + +**Fix (green).** + +- Convert the 22 `sync.repository.ts` expressions to overlay joins: album (`:240,260,283`), shared + space (`:998,1029,1059`), library (`:1310,1347`), shared-space album + (`:1827,1852,1886,1909,1941,1965`), partner (`:661,681` — drop the hardcoded `false`), own + (`:478`). Update the explanatory comments at `:985,1017,1048,1298,1337,1810`, which currently + document the masking rationale. +- Regenerate the 12 `sync.repository.sql` snapshot blocks. +- Re-confirm the §4 no-audit-table decision against the real stream (the decision itself, and any + `asset_favorite_audit` table, belongs to **slice 0** — it must not be introduced here, after the + irreversible slice 3). +- Dart: `favorite.action.dart:11` — derive `shouldFavorite` from the same filtered set that gets + mutated, not the raw selection (E32); `:22` un-gate; `remote_asset.repository.dart:119-124` and + `asset_api.repository.dart:45-46` to the canonical endpoint; verify + `timeline.repository.dart` favorite queries and `merged_asset.drift`. +- Leave `local_asset` / `trashed_local_asset` favorite mirrors alone — device-local backup state. + +**Edge cases** + +- Partner assets now carry real favorites — confirm no regression of the + `1777897107000-PartnerAssetSyncReset` leak (that reset existed because favorites _should not_ + have crossed; now they legitimately do, for the recipient's own rows only). +- Full resync from empty produces the same state as incremental. +- Two devices, same user → converge. +- Asset leaves a space → local row removal on next sync. +- Local-only asset (not yet uploaded) favorite still works. + +**Green.** Server medium + regenerated snapshots green; `dart analyze --fatal-infos lib test` +**and** `dart format --set-exit-if-changed` clean (CI runs both; local `flutter analyze lib` +misses test lints); `flutter test` green on Flutter 3.41.7. + +--- + +## Slice 7 — Secondary write paths + +**Delivers.** Upload, copy, duplicate-merge and trash behave correctly under per-user favorites. + +**Behavior (BDD)** + +- _Given_ an upload with `isFavorite: true`, _When_ it completes, _Then_ the **uploader's** row + exists. (E19) +- _Given_ an asset copy with `favorite: true`, _When_ it completes, _Then_ the **acting user's** + row is copied — not every user's. (E20) +- _Given_ a duplicate merge where two users favorited different source assets, _When_ it + completes, _Then_ the keeper carries **both** users' rows. (E21) +- _Given_ a favorited asset is trashed then restored, _Then_ the favorite survives. (E14) + +**TDD — red first** + +- **E2E** (`asset-copy.e2e-spec.ts:164-203`, `duplicate.e2e-spec.ts`): red because these still + assert global-column semantics. +- **Unit** (`asset.service.spec.ts`, `duplicate.service.spec.ts`, `asset-media.service.spec.ts`). + +**Fix (green).** + +- `asset-media.service.ts:352` — write the uploader's overlay row instead of the create column. +- `asset.service.ts:501` — copy the acting user's row; keep `dto.favorite` default `true` + (`asset.dto.ts:162`). +- `duplicate.service.ts:305` — replace the boolean OR with a per-user union of source rows onto the + keeper, **before** the sources are deleted and CASCADE removes them. +- Confirm trash/restore still never touches favorites (currently zero hits in `trash.service.ts` / + `trash.repository.ts` — assert this stays true). + +**Edge cases** + +- Merge where **no** source was favorited → no rows created. +- Merge where the same user favorited multiple sources → exactly one keeper row (dedup). (E8) +- Copy by a non-owner who can read the source. +- Upload with `isFavorite: false` → no row. +- Merge sources with favorites from a user who has since lost access → row still transfers + (visibility is a read-time concern, per §5.2). + +**Green.** E2E + unit green; server `check` + `prettier --check` clean. + +--- + +## 10. Global acceptance criteria + +1. Every row of §6 has a passing assertion in its named slice. +2. `Permission.AssetRead` is sufficient to favorite; `Permission.AssetUpdate` is not required. +3. No code path lets any actor write another user's favorite. Asserted, not assumed. (E4, E7) +4. No response, bucket, sync stream, statistic or map marker exposes another user's favorite. +5. `asset."isFavorite"` does not exist post-slice-3; the grep gate enforces it. +6. `revert-to-immich.sql` restores the column with owner rows and passes its CI workflow, **and** + `asset_favorite` is actually covered by `revert-to-immich.spec.ts` (its filter widened — §4.2). +7. `AssetResponseDto` shape is unchanged; both SDKs regenerated via `make open-api`. +8. **A shared-link request can never write a favorite row.** Explicitly guarded, explicitly + asserted. (§5.1, E6) +9. **Cross-scope favorites actually return non-owned assets** — not merely "no longer a 400". + Owner-scoping replaced by readable-asset scoping. (§5.2, slice 4) +10. **Performance budget**: on a seeded dataset with a user in ≥10 spaces and ≥10k favorites, + `/favorites` first-page latency stays within the pre-change budget. Record the measurement in + the PR. The People-page incident came from exactly this shape of change. +11. Full local gate green: `make lint-all`, `make check-all`, server + web unit, server medium, + e2e API + web, `dart analyze --fatal-infos lib test`, `dart format --set-exit-if-changed`, + `flutter test`. +12. `docs/plans/shared-spaces-permission-matrix.md` updated at both `:70` and `:178`. +13. #763 verified manually on a real space with a **viewer**-role member. +14. All eight slices land as **one PR** (§8, §9). + +**Verification discipline:** run the full gate yourself at the end. Subagent "green" reports have +repeatedly missed what integrated CI catches. `pnpm test -- --run ` silently drops the path +filter; `make check-server` does not exist. `prettier --check` and eslint are separate CI gates — +eslint green does not imply prettier green. + +--- + +## 11. Out of scope + +- **Archive** (`visibility`) and **rating** (`asset_exif.rating`) — same owner-gated global-state + problem, same known-gap line. Deferred (§11.1). +- Removing the deprecated `UpdateAssetDto.isFavorite` alias — needs a consumer deprecation window. +- A reaper for favorite rows on permanently-lost access (§5.3). +- `person.isFavorite` — separate feature, untouched. +- Favorite-ordering UI, despite `createdAt` being carried. +- Shared/collaborative "space favorites". +- Favorite counts or activity notifications. + +### 11.1 The reusable pattern + +For a future archive/rating slice, the pattern this design establishes: + +1. Overlay table `(userId, entityId)` with composite PK, both FKs `CASCADE`, modelled on + `shared_space_person_alias`. +2. Backfill the existing global column to the **owner's** rows; drop the column once nothing + reads it. +3. Replace ownership-masking expressions with overlay joins — in this codebase the masking + already exists, so this is substitution rather than new plumbing. +4. Gate writes on `Permission.AssetRead` via a dedicated endpoint; keep the old DTO field as a + strictly-narrower deprecated alias into the same service method. +5. Never clean up rows on access loss; re-derive visibility on read. +6. Remove any `X × cross-user-scope` rejection guard the global column forced into existence. + +Archive carries one extra hazard favorite does not: `visibility` is an enum entangled with trash +and the sync streams, so it cannot be modelled as row-presence/absence the way a boolean can. diff --git a/specs/shared-spaces-permission-matrix.md b/specs/shared-spaces-permission-matrix.md index 1031ff7db39e65..63628aaa2e7fe7 100644 --- a/specs/shared-spaces-permission-matrix.md +++ b/specs/shared-spaces-permission-matrix.md @@ -67,7 +67,7 @@ Note: PR #243 adds `findSpaceForAssetAndUser` which auto-resolves space context | **Download** | Yes | Yes | Yes | Yes | | **Download original** | Yes | Yes | Yes | Yes | | **Share** | Yes | No\* | No\* | No\* | -| **Favorite/unfavorite** | Yes | No | No | No | +| **Favorite/unfavorite** | Yes | Yes | Yes | No | | **Edit (crop/rotate)** | Yes | No | No | No | | **Add to album** | Yes | Yes | Yes | Yes | | **Slideshow** | Yes | Yes | Yes | Yes | @@ -175,5 +175,5 @@ Note: PR #243 adds `findSpaceForAssetAndUser` which auto-resolves space context 1. **Tag editing for space members** — `TagAsset` permission is owner-only. Space editors cannot add/remove tags on space assets. Requires extending the tag permission model. 2. **Photos page people filter** — Uses `getAllPeople()` which returns only the user's own people. Space people not included. Needs cross-space person dedup logic. 3. **`$preferences.tags.enabled`** — Space members must enable tags in their personal settings to see them. No automatic bypass for space context. -4. **Nav bar actions for editors** — Several actions (favorite, archive, edit/crop/rotate, rating) are gated by `isOwner` in the frontend even though space editors have `AssetUpdate` permission on the server. The Share button is visible to all but fails server-side for non-owners. +4. **Nav bar actions for editors** — Several actions (archive, edit/crop/rotate, rating) are gated by `isOwner` in the frontend even though space editors have `AssetUpdate` permission on the server. (Favorite was removed from this list by #763: it is now a per-user `asset_favorite` overlay, available to every member who can read the asset.) The Share button is visible to all but fails server-side for non-owners. 5. **No shared links from space context** — Space members cannot create shared links for space assets. Only the asset owner and partners can share via `AssetShare`. diff --git a/web/src/lib/components/timeline/SelectionToolbar.spec.ts b/web/src/lib/components/timeline/SelectionToolbar.spec.ts index 46a62f7bf928f5..b106c777e97ef4 100644 --- a/web/src/lib/components/timeline/SelectionToolbar.spec.ts +++ b/web/src/lib/components/timeline/SelectionToolbar.spec.ts @@ -170,8 +170,9 @@ describe('SelectionToolbar', () => { expect(screen.queryByLabelText('share')).not.toBeInTheDocument(); expect(screen.queryByLabelText('add_to_album_or_space')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('to_favorite')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('remove_from_favorites')).not.toBeInTheDocument(); + // Shown even for a viewer on someone else's asset: a favorite is the viewer's own + // `asset_favorite` overlay row (#763), so it is not owner-gated. + expect(screen.getByLabelText('to_favorite')).toBeInTheDocument(); expect(screen.queryByRole('menuitem', { name: 'delete' })).not.toBeInTheDocument(); expect(screen.queryByLabelText('remove_from_space')).not.toBeInTheDocument(); expect(screen.queryByRole('menuitem', { name: 'set_as_space_cover' })).not.toBeInTheDocument(); @@ -240,7 +241,8 @@ describe('SelectionToolbar', () => { expect(screen.queryByLabelText('remove_from_space')).not.toBeInTheDocument(); // Share is present since #1018 — the space Owner/Editor role carries it, like add-to-album. expect(screen.getByLabelText('share')).toBeInTheDocument(); - expect(screen.queryByLabelText('to_favorite')).not.toBeInTheDocument(); + // Favorites are a per-user overlay row (#763), so they are never owner-gated. + expect(screen.getByLabelText('to_favorite')).toBeInTheDocument(); expect(screen.queryByRole('menuitem', { name: 'delete' })).not.toBeInTheDocument(); }); @@ -257,8 +259,9 @@ describe('SelectionToolbar', () => { expect(screen.getByLabelText('share')).toBeInTheDocument(); expect(screen.getByLabelText('add_to_album_or_space')).toBeInTheDocument(); - // Still hidden: these mutate every selected asset and the server refuses the non-owned ones. - expect(screen.queryByLabelText('to_favorite')).not.toBeInTheDocument(); + // Shown regardless of ownership: a favorite is the viewer's own `asset_favorite` row (#763). + expect(screen.getByLabelText('to_favorite')).toBeInTheDocument(); + // Still hidden: this mutates every selected asset and the server refuses the non-owned ones. expect(screen.queryByRole('menuitem', { name: 'delete' })).not.toBeInTheDocument(); }); diff --git a/web/src/lib/components/timeline/actions/FavoriteAction.svelte b/web/src/lib/components/timeline/actions/FavoriteAction.svelte index 0cc861020d4ce7..5bf4dc8a031fd1 100644 --- a/web/src/lib/components/timeline/actions/FavoriteAction.svelte +++ b/web/src/lib/components/timeline/actions/FavoriteAction.svelte @@ -3,7 +3,7 @@ import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte'; import type { OnFavorite } from '$lib/utils/actions'; import { handleError } from '$lib/utils/handle-error'; - import { updateAssets } from '@immich/sdk'; + import { updateAssetFavorites } from '@immich/sdk'; import { IconButton, toastManager } from '@immich/ui'; import { mdiHeartMinusOutline, mdiHeartOutline, mdiTimerSand } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -26,12 +26,12 @@ loading = true; try { - const assets = assetMultiSelectManager.ownedAssets.filter((asset) => asset.isFavorite !== isFavorite); + const assets = assetMultiSelectManager.assets.filter((asset) => asset.isFavorite !== isFavorite); const ids = assets.map(({ id }) => id); if (ids.length > 0) { - await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } }); + await updateAssetFavorites({ assetFavoriteUpdateDto: { ids, isFavorite } }); } for (const asset of assets) { diff --git a/web/src/lib/managers/command-items.spec.ts b/web/src/lib/managers/command-items.spec.ts index 68636595844d3a..7918037b851a56 100644 --- a/web/src/lib/managers/command-items.spec.ts +++ b/web/src/lib/managers/command-items.spec.ts @@ -302,10 +302,11 @@ describe('selection-context commands', () => { expect(item.isAvailable!(makeCtx(null))).toBe(false); }); - it('favorite hides when ownership is mixed, missing onFavorite, or already all favorite', () => { + it('favorite is available regardless of ownership (per-user, #763); hides on missing onFavorite or already all favorite', () => { const item = cmd('cmd:selection_favorite'); expect(item.isAvailable!(makeCtx(makeSelection()))).toBe(true); - expect(item.isAvailable!(makeCtx(makeSelection({ isAllUserOwned: false })))).toBe(false); + // #763: favorites are per-user, so a mixed-ownership selection no longer hides the command. + expect(item.isAvailable!(makeCtx(makeSelection({ isAllUserOwned: false })))).toBe(true); expect(item.isAvailable!(makeCtx(makeSelection({ onFavorite: undefined })))).toBe(false); expect(item.isAvailable!(makeCtx(makeSelection({ isAllFavorite: true })))).toBe(false); }); diff --git a/web/src/lib/managers/selection-capabilities.spec.ts b/web/src/lib/managers/selection-capabilities.spec.ts index 5e3e3f881fce74..bf96d01bd6416c 100644 --- a/web/src/lib/managers/selection-capabilities.spec.ts +++ b/web/src/lib/managers/selection-capabilities.spec.ts @@ -110,7 +110,7 @@ const ALL_FALSE: SelectionCapabilities = { // --------------------------------------------------------------------------- describe('getSelectionCapabilities — space timeline (direct space)', () => { - it("E1: Given a space viewer selecting another member's asset, When capabilities resolve, Then only select-all and download are allowed", () => { + it("E1: Given a space viewer selecting another member's asset, When capabilities resolve, Then only select-all, download and favorite are allowed", () => { const ctx = makeCtx({ space: makeSpace({ canWrite: false }), selection: makeSelection({ isAllUserOwned: false, selectedAssetIds: ['asset-1'] }), @@ -120,6 +120,8 @@ describe('getSelectionCapabilities — space timeline (direct space)', () => { ...ALL_FALSE, canSelectAll: true, canDownload: true, + // Favorites are a per-user overlay row (#763), so they are never owner-gated. + canFavorite: true, }); }); @@ -164,7 +166,8 @@ describe('getSelectionCapabilities — space timeline (direct space)', () => { canShare: true, shareScopedToSpace: true, canAddToAlbum: true, - canFavorite: false, + // Favorites are a per-user overlay row (#763), so they are never owner-gated. + canFavorite: true, canEditMetadata: false, canSetVisibility: false, canTag: false, @@ -189,7 +192,8 @@ describe('getSelectionCapabilities — space timeline (direct space)', () => { expect(caps.shareScopedToSpace).toBe(true); expect(caps.canAddToAlbum).toBe(true); expect(caps.addToAlbumRestrictedToSpace).toBe(true); - expect(caps.canFavorite).toBe(false); + // Favorites are a per-user overlay row (#763), so they are never owner-gated. + expect(caps.canFavorite).toBe(true); expect(caps.canEditMetadata).toBe(false); expect(caps.canTag).toBe(false); expect(caps.canDelete).toBe(false); @@ -202,8 +206,9 @@ describe('getSelectionCapabilities — space timeline (direct space)', () => { expect(caps.canShare).toBe(true); expect(caps.canAddToAlbum).toBe(true); expect(caps.addToAlbumRestrictedToSpace).toBe(true); + // Allowed regardless of ownership: a favorite is the viewer's own `asset_favorite` row (#763). + expect(caps.canFavorite).toBe(true); // Still denied: these mutate every selected asset, and the server refuses the non-owned ones. - expect(caps.canFavorite).toBe(false); expect(caps.canEditMetadata).toBe(false); expect(caps.canDelete).toBe(false); }); @@ -290,7 +295,8 @@ describe('getSelectionCapabilities — cross-cutting edge cases', () => { // No space to contribute through, so the non-owned assets have nowhere to land. expect(caps.canAddToAlbum).toBe(false); expect(caps.addToAlbumRestrictedToSpace).toBe(false); - expect(caps.canFavorite).toBe(false); + // Favorites are a per-user overlay row (#763), so they are never owner-gated. + expect(caps.canFavorite).toBe(true); expect(caps.canEditMetadata).toBe(false); expect(caps.canTag).toBe(false); expect(caps.canDelete).toBe(false); diff --git a/web/src/lib/managers/selection-capabilities.ts b/web/src/lib/managers/selection-capabilities.ts index 5202067d01a66f..46f9eec9abf4be 100644 --- a/web/src/lib/managers/selection-capabilities.ts +++ b/web/src/lib/managers/selection-capabilities.ts @@ -123,7 +123,11 @@ export function getSelectionCapabilities(ctx: CommandContext, tagsEnabled: boole shareScopedToSpace: isSpaceEditor && !sel.isAllUserOwned, canAddToAlbum: sel.isAllUserOwned || isSpaceEditor, addToAlbumRestrictedToSpace: !sel.isAllUserOwned && isSpaceEditor, - canFavorite: sel.isAllUserOwned, + // Not `isAllUserOwned` (#763): a favorite is a per-user row in the `asset_favorite` overlay, + // not a flag on the asset, so favoriting never mutates someone else's asset. Any viewer who + // can see the selection may favorite it — including a shared-space member favoriting another + // member's photo. Mirrors `canFavoriteSelected` in selection-command-handlers.ts. + canFavorite: true, canEditMetadata: hasEditable, // Archive / SetVisibility are NOT metadata edits: rbac-3 restricts visibility changes to // owned assets, so they must not ride on canEditMetadata the way they used to. diff --git a/web/src/lib/managers/selection-command-handlers.spec.ts b/web/src/lib/managers/selection-command-handlers.spec.ts index 37bdabf78f9019..1ca9bd58961171 100644 --- a/web/src/lib/managers/selection-command-handlers.spec.ts +++ b/web/src/lib/managers/selection-command-handlers.spec.ts @@ -1,4 +1,10 @@ -import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets, updateAssets } from '@immich/sdk'; +import { + AssetVisibility, + deleteAssets as deleteBulk, + restoreAssets, + updateAssetFavorites, + updateAssets, +} from '@immich/sdk'; import { modalManager, toastManager } from '@immich/ui'; import { get } from 'svelte/store'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -37,6 +43,7 @@ vi.mock('@immich/ui', async (orig) => { vi.mock('@immich/sdk', async (orig) => ({ ...(await orig()), updateAssets: vi.fn().mockResolvedValue(undefined), + updateAssetFavorites: vi.fn().mockResolvedValue(undefined), deleteAssets: vi.fn().mockResolvedValue(undefined), restoreAssets: vi.fn().mockResolvedValue(undefined), })); @@ -123,6 +130,7 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(modalManager.show).mockResolvedValue(true as never); vi.mocked(updateAssets).mockResolvedValue(undefined as never); + vi.mocked(updateAssetFavorites).mockResolvedValue(undefined as never); vi.mocked(deleteBulk).mockResolvedValue(undefined as never); vi.mocked(restoreAssets).mockResolvedValue(undefined as never); featureFlagsManager.value.trash = true; @@ -194,15 +202,22 @@ describe('selection command availability', () => { expect(canAddSelectedToSpace(makeCtx(null))).toBe(false); }); - it('canFavoriteSelected requires selection, all-owned, onFavorite, and at least one non-favorite asset', () => { + it('canFavoriteSelected requires selection, onFavorite, and at least one non-favorite asset (ownership-agnostic, #763)', () => { const asset = makeAsset({ isFavorite: false }); expect(canFavoriteSelected(makeCtx(makeSelection({ assets: [asset] })))).toBe(true); expect(canFavoriteSelected(makeCtx(null))).toBe(false); - expect(canFavoriteSelected(makeCtx(makeSelection({ isAllUserOwned: false })))).toBe(false); expect(canFavoriteSelected(makeCtx(makeSelection({ onFavorite: undefined })))).toBe(false); expect(canFavoriteSelected(makeCtx(makeSelection({ assets: [makeAsset({ isFavorite: true })] })))).toBe(false); }); + it('canFavoriteSelected returns true for a mixed-ownership selection — favorites are per-user, not gated by ownership (#763)', () => { + const owned = makeAsset({ id: 'owned-1', ownerId: 'u-me', isFavorite: false }); + const notOwned = makeAsset({ id: 'not-owned-1', ownerId: 'u-other', isFavorite: false }); + const selection = makeSelection({ assets: [owned, notOwned], ownedAssets: [owned], isAllUserOwned: false }); + + expect(canFavoriteSelected(makeCtx(selection))).toBe(true); + }); + it('canArchiveSelected requires selection, all-owned, onArchive, and at least one non-archived asset', () => { const asset = makeAsset({ visibility: AssetVisibility.Timeline }); expect(canArchiveSelected(makeCtx(makeSelection({ assets: [asset] })))).toBe(true); @@ -316,35 +331,72 @@ describe('add selected to current space', () => { }); describe('favorite selected', () => { - it('favorites only non-favorite owned assets, mutates refs, notifies, toasts, and clears after success', async () => { - const favorite = makeAsset({ id: 'favorite', isFavorite: true }); - const target = makeAsset({ id: 'target', isFavorite: false }); - const selection = makeSelection({ assets: [favorite, target] }); + it('favorites only non-favorite assets across the FULL selection (owned + non-owned), mutates refs, notifies, toasts, and clears after success (#763)', async () => { + const favorite = makeAsset({ id: 'favorite', ownerId: 'u-me', isFavorite: true }); + const ownedTarget = makeAsset({ id: 'owned-target', ownerId: 'u-me', isFavorite: false }); + const notOwnedTarget = makeAsset({ id: 'not-owned-target', ownerId: 'u-other', isFavorite: false }); + const selection = makeSelection({ + assets: [favorite, ownedTarget, notOwnedTarget], + ownedAssets: [favorite, ownedTarget], + isAllUserOwned: false, + }); await handleFavoriteSelected(makeCtx(selection)); - expect(updateAssets).toHaveBeenCalledWith({ assetBulkUpdateDto: { ids: ['target'], isFavorite: true } }); - expect(target.isFavorite).toBe(true); + expect(updateAssetFavorites).toHaveBeenCalledWith({ + assetFavoriteUpdateDto: { ids: ['owned-target', 'not-owned-target'], isFavorite: true }, + }); + expect(updateAssets).not.toHaveBeenCalled(); + expect(ownedTarget.isFavorite).toBe(true); + expect(notOwnedTarget.isFavorite).toBe(true); expect(favorite.isFavorite).toBe(true); - expect(selection.onFavorite).toHaveBeenCalledWith(['target'], true); + expect(selection.onFavorite).toHaveBeenCalledWith(['owned-target', 'not-owned-target'], true); expect(toastManager.primary).toHaveBeenCalledOnce(); expect(selection.clearSelection).toHaveBeenCalledOnce(); }); + it('direction/mutation coherence: mutates exactly the non-owned asset when it is the only unfavorited one in the selection (web analogue of spec E32, #763)', async () => { + const ownedFavorited = makeAsset({ id: 'owned-favorited', ownerId: 'u-me', isFavorite: true }); + const notOwnedTarget = makeAsset({ id: 'not-owned-target', ownerId: 'u-other', isFavorite: false }); + const selection = makeSelection({ + assets: [ownedFavorited, notOwnedTarget], + ownedAssets: [ownedFavorited], + isAllUserOwned: false, + }); + + await handleFavoriteSelected(makeCtx(selection)); + + // Under the old `ownedAssets` filter this id list would have been empty. + expect(updateAssetFavorites).toHaveBeenCalledWith({ + assetFavoriteUpdateDto: { ids: ['not-owned-target'], isFavorite: true }, + }); + expect(notOwnedTarget.isFavorite).toBe(true); + expect(ownedFavorited.isFavorite).toBe(true); + expect(selection.onFavorite).toHaveBeenCalledWith(['not-owned-target'], true); + expect(selection.clearSelection).toHaveBeenCalledOnce(); + }); + it('no-ops when the filtered id list is empty', async () => { const selection = makeSelection({ assets: [makeAsset({ isFavorite: true })] }); await handleFavoriteSelected(makeCtx(selection)); - expect(updateAssets).not.toHaveBeenCalled(); + expect(updateAssetFavorites).not.toHaveBeenCalled(); expect(selection.onFavorite).not.toHaveBeenCalled(); expect(toastManager.primary).not.toHaveBeenCalled(); expect(selection.clearSelection).not.toHaveBeenCalled(); }); + it('no-ops when there is no selection (regression)', async () => { + await handleFavoriteSelected(makeCtx(null)); + + expect(updateAssetFavorites).not.toHaveBeenCalled(); + expect(toastManager.primary).not.toHaveBeenCalled(); + }); + it('calls handleError, does not notify, and does not clear on failure', async () => { const error = new Error('favorite failed'); - vi.mocked(updateAssets).mockRejectedValueOnce(error); + vi.mocked(updateAssetFavorites).mockRejectedValueOnce(error); const selection = makeSelection(); await handleFavoriteSelected(makeCtx(selection)); @@ -539,6 +591,7 @@ describe('null selection handlers', () => { expect(modalManager.show).not.toHaveBeenCalled(); expect(updateAssets).not.toHaveBeenCalled(); + expect(updateAssetFavorites).not.toHaveBeenCalled(); expect(deleteBulk).not.toHaveBeenCalled(); expect(restoreAssets).not.toHaveBeenCalled(); expect(toastManager.primary).not.toHaveBeenCalled(); @@ -554,6 +607,7 @@ describe('null selection handlers', () => { expect(modalManager.show).not.toHaveBeenCalled(); expect(updateAssets).not.toHaveBeenCalled(); + expect(updateAssetFavorites).not.toHaveBeenCalled(); expect(deleteBulk).not.toHaveBeenCalled(); expect(restoreAssets).not.toHaveBeenCalled(); expect(toastManager.primary).not.toHaveBeenCalled(); diff --git a/web/src/lib/managers/selection-command-handlers.ts b/web/src/lib/managers/selection-command-handlers.ts index 191896dfc06fd2..7bc2bb874e8868 100644 --- a/web/src/lib/managers/selection-command-handlers.ts +++ b/web/src/lib/managers/selection-command-handlers.ts @@ -1,4 +1,10 @@ -import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets, updateAssets } from '@immich/sdk'; +import { + AssetVisibility, + deleteAssets as deleteBulk, + restoreAssets, + updateAssetFavorites, + updateAssets, +} from '@immich/sdk'; import { modalManager, toastManager } from '@immich/ui'; import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; @@ -37,9 +43,7 @@ export const canAddSelectedToSpace = (ctx: CommandContext) => { export const canFavoriteSelected = (ctx: CommandContext) => { const selection = getSelection(ctx); - return ( - selection !== null && selection.isAllUserOwned && selection.onFavorite !== undefined && !selection.isAllFavorite - ); + return selection !== null && selection.onFavorite !== undefined && !selection.isAllFavorite; }; export const canArchiveSelected = (ctx: CommandContext) => { @@ -87,11 +91,12 @@ export async function handleAddSelectedToCurrentSpace(ctx?: CommandContext) { export async function handleFavoriteSelected(ctx?: CommandContext) { const selection = getSelection(ctx); - if (!selection || !selection.isAllUserOwned || !selection.onFavorite) { + if (!selection || !selection.onFavorite) { return; } - const assets = selection.ownedAssets.filter((asset) => !asset.isFavorite); + // #763: favorites are per-user — the whole selection is favoritable, not just owned assets. + const assets = selection.assets.filter((asset) => !asset.isFavorite); const ids = assets.map((asset) => asset.id); if (ids.length === 0) { return; @@ -99,7 +104,7 @@ export async function handleFavoriteSelected(ctx?: CommandContext) { const $t = get(t); try { - await updateAssets({ assetBulkUpdateDto: { ids, isFavorite: true } }); + await updateAssetFavorites({ assetFavoriteUpdateDto: { ids, isFavorite: true } }); for (const asset of assets) { asset.isFavorite = true; } diff --git a/web/src/lib/services/asset.service.spec.ts b/web/src/lib/services/asset.service.spec.ts index a1894be889b6a3..0f039356b6326f 100644 --- a/web/src/lib/services/asset.service.spec.ts +++ b/web/src/lib/services/asset.service.spec.ts @@ -1,7 +1,15 @@ -import { AssetEditAction, getAssetInfo, type AssetEditActionItemDto, type AssetResponseDto } from '@immich/sdk'; +import { + AssetEditAction, + getAssetInfo, + updateAsset, + updateAssetFavorites, + type AssetEditActionItemDto, + type AssetResponseDto, +} from '@immich/sdk'; import { modalManager, toastManager } from '@immich/ui'; import { vitest } from 'vitest'; import { authManager } from '$lib/managers/auth-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; import AssetAddToCollectionModal from '$lib/modals/AssetAddToCollectionModal.svelte'; import { getAssetActions, @@ -24,6 +32,7 @@ const { downloadUrlMock } = vitest.hoisted(() => ({ vitest.mock('@immich/ui', () => ({ toastManager: { primary: vitest.fn(), + danger: vitest.fn(), }, modalManager: { show: vitest.fn() }, })); @@ -42,6 +51,8 @@ vitest.mock('@immich/sdk', async () => { return { ...originalModule, getAssetInfo: vitest.fn(), + updateAsset: vitest.fn(), + updateAssetFavorites: vitest.fn(), }; }); @@ -350,3 +361,138 @@ describe('add to album/space entry points', () => { }); }); }); + +describe('favorite actions — per-user, un-gated from ownership (#763 slice 5)', () => { + let emitSpy: ReturnType; + + beforeEach(() => { + authManager.reset(); + setSharedLink(undefined); + emitSpy = vitest.spyOn(eventManager, 'emit'); + }); + + afterEach(() => { + emitSpy.mockRestore(); + authManager.reset(); + setSharedLink(undefined); + }); + + it('shows Favorite to an authenticated NON-OWNER when not yet favorited by them', () => { + const ownerId = 'owner'; + const user = userAdminFactory.build({ id: 'non-owner' }); + authManager.setPreferences(preferencesFactory.build()); + authManager.setUser(user); + const asset = assetFactory.build({ ownerId, isFavorite: false }); + + const assetActions = getAssetActions(() => '', asset); + + expect(assetActions.Favorite.$if?.()).toBe(true); + expect(assetActions.Unfavorite.$if?.()).toBe(false); + }); + + it('shows Unfavorite to a non-owner who HAS favorited (viewer state, not owner state)', () => { + const ownerId = 'owner'; + const user = userAdminFactory.build({ id: 'non-owner' }); + authManager.setPreferences(preferencesFactory.build()); + authManager.setUser(user); + const asset = assetFactory.build({ ownerId, isFavorite: true }); + + const assetActions = getAssetActions(() => '', asset); + + expect(assetActions.Unfavorite.$if?.()).toBe(true); + expect(assetActions.Favorite.$if?.()).toBe(false); + }); + + it('owner behavior unchanged (regression)', () => { + const ownerId = 'owner'; + const user = userAdminFactory.build({ id: ownerId }); + authManager.setPreferences(preferencesFactory.build()); + authManager.setUser(user); + + const notFavorited = assetFactory.build({ ownerId, isFavorite: false }); + const notFavoritedActions = getAssetActions(() => '', notFavorited); + expect(notFavoritedActions.Favorite.$if?.()).toBe(true); + expect(notFavoritedActions.Unfavorite.$if?.()).toBe(false); + + const favorited = assetFactory.build({ ownerId, isFavorite: true }); + const favoritedActions = getAssetActions(() => '', favorited); + expect(favoritedActions.Favorite.$if?.()).toBe(false); + expect(favoritedActions.Unfavorite.$if?.()).toBe(true); + }); + + it('shared-link session: neither action available (and thus the f shortcut is inert)', () => { + const user = userAdminFactory.build(); + authManager.setPreferences(preferencesFactory.build()); + authManager.setUser(user); + setSharedLink(sharedLinkFactory.build()); + + const notFavorited = assetFactory.build({ ownerId: user.id, isFavorite: false }); + const notFavoritedActions = getAssetActions(() => '', notFavorited); + expect(notFavoritedActions.Favorite.$if?.()).toBe(false); + expect(notFavoritedActions.Unfavorite.$if?.()).toBe(false); + + const favorited = assetFactory.build({ ownerId: user.id, isFavorite: true }); + const favoritedActions = getAssetActions(() => '', favorited); + expect(favoritedActions.Favorite.$if?.()).toBe(false); + expect(favoritedActions.Unfavorite.$if?.()).toBe(false); + }); + + it('handleFavorite calls the canonical endpoint and emits a flipped AssetUpdate', async () => { + const $t = vitest.fn().mockReturnValue('formatter'); + vitest.mocked(getFormatter).mockResolvedValue($t); + vitest.mocked(updateAssetFavorites).mockResolvedValue(undefined as never); + + const ownerId = 'owner'; + const user = userAdminFactory.build({ id: 'non-owner' }); + authManager.setPreferences(preferencesFactory.build()); + authManager.setUser(user); + const asset = assetFactory.build({ id: 'asset-1', ownerId, isFavorite: false }); + + const assetActions = getAssetActions(() => '', asset); + await assetActions.Favorite.onAction(assetActions.Favorite); + + expect(updateAssetFavorites).toHaveBeenCalledWith({ + assetFavoriteUpdateDto: { ids: [asset.id], isFavorite: true }, + }); + expect(emitSpy).toHaveBeenCalledWith('AssetUpdate', expect.objectContaining({ id: asset.id, isFavorite: true })); + expect(updateAsset).not.toHaveBeenCalled(); + }); + + it('handleUnfavorite mirrors with isFavorite: false', async () => { + const $t = vitest.fn().mockReturnValue('formatter'); + vitest.mocked(getFormatter).mockResolvedValue($t); + vitest.mocked(updateAssetFavorites).mockResolvedValue(undefined as never); + + const ownerId = 'owner'; + const user = userAdminFactory.build({ id: 'non-owner' }); + authManager.setPreferences(preferencesFactory.build()); + authManager.setUser(user); + const asset = assetFactory.build({ id: 'asset-1', ownerId, isFavorite: true }); + + const assetActions = getAssetActions(() => '', asset); + await assetActions.Unfavorite.onAction(assetActions.Unfavorite); + + expect(updateAssetFavorites).toHaveBeenCalledWith({ + assetFavoriteUpdateDto: { ids: [asset.id], isFavorite: false }, + }); + expect(emitSpy).toHaveBeenCalledWith('AssetUpdate', expect.objectContaining({ id: asset.id, isFavorite: false })); + expect(updateAsset).not.toHaveBeenCalled(); + }); + + it('on endpoint error, no AssetUpdate is emitted (state untouched)', async () => { + const $t = vitest.fn().mockReturnValue('formatter'); + vitest.mocked(getFormatter).mockResolvedValue($t); + vitest.mocked(updateAssetFavorites).mockRejectedValue(new Error('network error')); + + const ownerId = 'owner'; + const user = userAdminFactory.build({ id: 'non-owner' }); + authManager.setPreferences(preferencesFactory.build()); + authManager.setUser(user); + const asset = assetFactory.build({ id: 'asset-1', ownerId, isFavorite: false }); + + const assetActions = getAssetActions(() => '', asset); + await assetActions.Favorite.onAction(assetActions.Favorite); + + expect(emitSpy).not.toHaveBeenCalledWith('AssetUpdate', expect.anything()); + }); +}); diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index 7eed278612af72..290fc22ef1fe87 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -9,7 +9,7 @@ import { getAssetInfo, removeAssetEdits, runAssetJobs, - updateAsset, + updateAssetFavorites, type AssetEditActionItemDto, type AssetJobsDto, type AssetResponseDto, @@ -216,7 +216,10 @@ export const getAssetActions = ( const Favorite: ActionItem = { title: $t('to_favorite'), icon: mdiHeartOutline, - $if: () => isOwner && !asset.isFavorite, + // #763: favoriting is per-user — any authenticated reader may favorite. Shared-link + // sessions are explicitly excluded: auth.user there is the LINK OWNER, and an anonymous + // visitor must never write favorites in the owner's name (spec §5.1). + $if: () => !!authUser && !sharedLink && !asset.isFavorite, onAction: () => handleFavorite(asset), shortcuts: [{ key: 'f' }], }; @@ -224,7 +227,7 @@ export const getAssetActions = ( const Unfavorite: ActionItem = { title: $t('unfavorite'), icon: mdiHeart, - $if: () => isOwner && asset.isFavorite, + $if: () => !!authUser && !sharedLink && asset.isFavorite, onAction: () => handleUnfavorite(asset), shortcuts: [{ key: 'f' }], }; @@ -479,9 +482,9 @@ const handleFavorite = async (asset: AssetResponseDto) => { const $t = await getFormatter(); try { - const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: true } }); + await updateAssetFavorites({ assetFavoriteUpdateDto: { ids: [asset.id], isFavorite: true } }); toastManager.primary($t('added_to_favorites')); - eventManager.emit('AssetUpdate', response); + eventManager.emit('AssetUpdate', { ...asset, isFavorite: true }); } catch (error) { handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } })); } @@ -491,9 +494,9 @@ const handleUnfavorite = async (asset: AssetResponseDto) => { const $t = await getFormatter(); try { - const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: false } }); + await updateAssetFavorites({ assetFavoriteUpdateDto: { ids: [asset.id], isFavorite: false } }); toastManager.primary($t('removed_from_favorites')); - eventManager.emit('AssetUpdate', response); + eventManager.emit('AssetUpdate', { ...asset, isFavorite: false }); } catch (error) { handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } })); } diff --git a/web/src/lib/utils/__tests__/album-filter-options.spec.ts b/web/src/lib/utils/__tests__/album-filter-options.spec.ts index 641a60d96ba112..214e8e1ac5b53a 100644 --- a/web/src/lib/utils/__tests__/album-filter-options.spec.ts +++ b/web/src/lib/utils/__tests__/album-filter-options.spec.ts @@ -166,7 +166,7 @@ describe('buildAlbumAssetPickerOptions', () => { ); }); - it('maps favorites and omits partners for album asset picker options', () => { + it('maps favorites and keeps partner scope active for album asset picker options (#763 slice 4)', () => { const filters = { ...createFilterState(), isFavorite: true }; const options = buildAlbumAssetPickerOptions('album-1', filters); @@ -174,9 +174,9 @@ describe('buildAlbumAssetPickerOptions', () => { expect.objectContaining({ timelineAlbumId: 'album-1', isFavorite: true, + withPartners: true, }), ); - expect(options).not.toHaveProperty('withPartners'); }); // Pinned per the #767c Step-3 decision: buildAlbumAssetPickerOptions deliberately does NOT diff --git a/web/src/lib/utils/__tests__/filter-target.spec.ts b/web/src/lib/utils/__tests__/filter-target.spec.ts index 2322223eba8f47..c75c0c2dad28c0 100644 --- a/web/src/lib/utils/__tests__/filter-target.spec.ts +++ b/web/src/lib/utils/__tests__/filter-target.spec.ts @@ -175,10 +175,38 @@ describe('buildContextualFilterUrl', () => { // E3 — fallback it('E3: falls back to /photos from a non-filterable surface', () => { - const url = buildContextualFilterUrl(u('/favorites/a1'), { make: 'Apple' }); + const url = buildContextualFilterUrl(u('/search/photos/a1'), { make: 'Apple' }); expect(url).toContain('/photos'); expect(url).toContain('make=Apple'); + // A surface with no scope of its own carries nothing but the patch. + expect(url).not.toContain('favorite='); + }); + + // #763 — /favorites has no filter panel, so it is not a filter TARGET (E3 above), but it is still + // an implicit filter: everything on it is there because I favourited it. Dropping that on the way + // to /photos silently rewrites "this camera, among my favourites" into "this camera, anywhere in + // my timeline" — and for the favourite that only /favorites can reach (one placed inside a Space + // I have hidden from my timeline, which the isFavorite carve-out in timeline.service.ts exists to + // keep reachable) the widened query matches NOTHING. The report was a camera filter that returned + // 0 results for a photo the user was looking at. + it('#763: carries the Favorites scope over when filtering from /favorites', () => { + const url = buildContextualFilterUrl(u('/favorites/photos/a1'), { make: 'Canon', model: 'Canon EOS R6' }); + + expect(url).toContain('/photos'); + expect(url).toContain('favorite=true'); + expect(url).toContain('make=Canon'); + expect(url).toContain('model=Canon+EOS+R6'); + }); + + // The counterpart: "search everywhere" means everywhere. It is the one affordance whose whole + // point is leaving the current scope behind, so it must NOT pick the favorites seed up. + it('#763: does not carry the Favorites scope into a global "search everywhere" filter', () => { + const url = buildContextualFilterUrl(u('/favorites/photos/a1'), { make: 'Canon' }, { global: true }); + + expect(url).toContain('/photos'); + expect(url).toContain('make=Canon'); + expect(url).not.toContain('favorite='); }); it('keeps the map on the map, preserving its spaceId', () => { @@ -282,11 +310,21 @@ describe('applyContextualFilter', () => { // E3 — a non-filterable surface (resolveFilterTarget returns null) must not throw; it falls back // to /photos. it('does not throw from a non-filterable surface, falling back to /photos', () => { - mockPage.reset('https://g.test/favorites/asset-1'); + mockPage.reset('https://g.test/search/asset-1'); expect(() => applyContextualFilter({ make: 'Apple' })).not.toThrow(); expect(gotoMock).toHaveBeenCalledWith('/photos?make=Apple'); }); + + // #763 — the wiring for the seed, end to end: this is what the camera row's own button does when + // the asset viewer is open on /favorites. + it('#763: navigates to the favorites-scoped /photos URL from /favorites', () => { + mockPage.reset('https://g.test/favorites/photos/asset-1'); + + applyContextualFilter({ make: 'Canon' }); + + expect(gotoMock).toHaveBeenCalledWith('/photos?make=Canon&favorite=true'); + }); }); // R8 — the single most dangerous patch in the slice. The Space timeline sends FilterState.personIds diff --git a/web/src/lib/utils/__tests__/photos-filter-options.spec.ts b/web/src/lib/utils/__tests__/photos-filter-options.spec.ts index 64091bb85a1c8e..5d452f161fc5ee 100644 --- a/web/src/lib/utils/__tests__/photos-filter-options.spec.ts +++ b/web/src/lib/utils/__tests__/photos-filter-options.spec.ts @@ -144,13 +144,13 @@ describe('buildPhotosTimelineOptions', () => { expect(options.withSharedSpaces).toBe(true); }); - it('should include isFavorite and omit shared timeline inclusions when favorites is selected', () => { + it('should include isFavorite and keep partner/shared-space scope active when favorites is selected (#763 slice 4)', () => { const filters = { ...createFilterState(), isFavorite: true }; const options = buildPhotosTimelineOptions(filters, MY_USER_ID); expect(options.isFavorite).toBe(true); - expect(options).not.toHaveProperty('withPartners'); - expect(options).not.toHaveProperty('withSharedSpaces'); + expect(options.withPartners).toBe(true); + expect(options.withSharedSpaces).toBe(true); }); it('should include has-no-album when selected', () => { @@ -276,15 +276,19 @@ describe('buildPhotosTimelineOptions', () => { expect(options.userId).toBe(MY_USER_ID); }); - it('keeps the owner gate under the favorites chip, where the shared widenings are dropped', () => { + it('keeps the owner gate under the favorites chip (#763: the widenings now stay too)', () => { const options = buildPhotosTimelineOptions( { ...createFilterState(), isFavorite: true, albumId: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa' }, MY_USER_ID, ); + // The owner gate is the point of this test and is unchanged. expect(options.userId).toBe(MY_USER_ID); - expect(options).not.toHaveProperty('withPartners'); - expect(options).not.toHaveProperty('withSharedSpaces'); + // #763: the widenings used to be dropped here, because `isFavorite` was the asset OWNER's + // flag and the server rejected the combination. It is a per-user overlay row now, so the + // chip narrows to MY favourites and partner/space content stays legitimately in scope. + expect(options.withPartners).toBe(true); + expect(options.withSharedSpaces).toBe(true); }); }); }); diff --git a/web/src/lib/utils/__tests__/recently-added-filter-options.spec.ts b/web/src/lib/utils/__tests__/recently-added-filter-options.spec.ts index 15033c439bb8ff..e1ba20bbd560f6 100644 --- a/web/src/lib/utils/__tests__/recently-added-filter-options.spec.ts +++ b/web/src/lib/utils/__tests__/recently-added-filter-options.spec.ts @@ -80,10 +80,13 @@ describe('buildRecentlyAddedTimelineOptions', () => { ); }); - it('drops partner assets under a favorites filter (favorites are personal)', () => { + it('keeps partner assets under a favorites filter (favorites are per-user, #763)', () => { + // Pre-#763 a favorite was the owner's flag on the asset, so a partner's favorites were + // meaningless to the viewer and the partner scope was dropped. Favorites are now per-user + // (asset_favorite overlay), so the viewer's own favorites on partner assets must stay in scope. expect( - buildRecentlyAddedTimelineOptions({ ...createFilterState(), isFavorite: true }, MY_USER_ID), - ).not.toHaveProperty('withPartners'); + buildRecentlyAddedTimelineOptions({ ...createFilterState(), isFavorite: true }, MY_USER_ID).withPartners, + ).toBe(true); }); it('maps sortOrder to order without touching orderBy', () => { diff --git a/web/src/lib/utils/album-filter-options.ts b/web/src/lib/utils/album-filter-options.ts index 948a22fbc2c9a2..668e8db6f323c2 100644 --- a/web/src/lib/utils/album-filter-options.ts +++ b/web/src/lib/utils/album-filter-options.ts @@ -83,7 +83,7 @@ export function buildAlbumAssetPickerOptions(albumId: string, filters: FilterSta return applyCommonFilterFields( { visibility: AssetVisibility.Timeline, - ...(filters.isFavorite === undefined && { withPartners: true }), + withPartners: true, timelineAlbumId: albumId, }, filters, diff --git a/web/src/lib/utils/filter-target.ts b/web/src/lib/utils/filter-target.ts index f7757c88921503..95f28572c7883c 100644 --- a/web/src/lib/utils/filter-target.ts +++ b/web/src/lib/utils/filter-target.ts @@ -45,6 +45,32 @@ export function resolveFilterTarget(url: URL): FilterTarget | null { return null; } +/** + * The filter a surface applies to ITSELF, for a surface that has no filter panel to express it in. + * + * `/favorites` is not a filter TARGET — it has no panel and no filter params, so `resolveFilterTarget` + * returns null for it and a contextual filter falls back to `/photos` (E3). But it is very much a + * filter: every asset on it is there because the viewer favourited it. Landing on a bare + * `/photos?make=…` therefore does not narrow what the user was looking at, it REPLACES it — "this + * camera, among my favourites" silently becomes "this camera, anywhere in my timeline". + * + * That is not merely a wider result set. #763's carve-out (timeline.service.ts: an `isFavorite` + * browse spans every space I belong to, including ones I have hidden from my home timeline) means + * /favorites can legitimately show an asset that /photos MUST NOT — so the widened query returns + * nothing at all, under the unfiltered "upload your first photo" placeholder. Seeding `favorite=true` + * keeps the destination a strict narrowing of the surface the click started from, and `/photos` can + * both express it (the Favorites filter section) and undo it (the chip's ×). + * + * Deliberately NOT a general "implicit scope" registry: `/people/:id` and `/tags/:id` are implicit + * filters in the same sense, but neither is broken today the way favorites is, and inventing seeds + * for them here would be speculative. Add one when a surface actually needs it. + */ +function resolveImplicitFilterScope(url: URL): Partial | undefined { + const root = url.pathname.split('/').find(Boolean); + + return root === 'favorites' ? { isFavorite: true } : undefined; +} + /** * Merge one metadata patch into the current URL's filters and return the URL to navigate to. * @@ -64,8 +90,16 @@ export function buildContextualFilterUrl(url: URL, patch: Partial, // filters: "search everywhere for THIS camera" is a new search, not the old one plus a camera. // It also avoids dragging a Space's `space-person:` scoped tokens onto /photos, where a // scoped token matches nothing. + // + // The fallback's one exception is the surface's OWN implicit filter (`implicitScope` below) — + // clean of the previous context, but not of what the surface itself meant. const carryOver = target !== null; + // `global` leaves every scope behind — including the surface's own implicit one (see + // resolveImplicitFilterScope). "Search everywhere" that quietly stayed inside my favourites would + // be the same lie in the other direction. + const implicitScope = opts?.global ? undefined : resolveImplicitFilterScope(url); + const params = new URLSearchParams(carryOver ? url.searchParams : undefined); // `at` is a one-shot grid scroll target left behind by closing the asset viewer. It must not @@ -75,7 +109,7 @@ export function buildContextualFilterUrl(url: URL, patch: Partial, const current: FilterState = { ...createFilterState(), - ...(carryOver && decodeFilterParams(url)), + ...(carryOver ? decodeFilterParams(url) : implicitScope), ...patch, }; @@ -193,9 +227,10 @@ export function rememberContextualPersonName(destination: string, personId: stri * would land on the GLOBAL map carrying the album's filters but NOT its album scope — silently * widening "this album" to "the whole library". Callers must not render the pin when this is null. * - * A non-filterable surface (`resolveFilterTarget` → null, e.g. /search) carries nothing over, exactly - * like `buildContextualFilterUrl`'s fallback: there is no scope to preserve, so there is none to lie - * about either. + * A non-filterable surface (`resolveFilterTarget` → null, e.g. /search) carries nothing over: there + * is no scope to preserve, so there is none to lie about either. Note this does NOT pick up + * `resolveImplicitFilterScope` the way `buildContextualFilterUrl` does — the pin says "on the FULL + * map", so widening is what it promises, and unlike a filter it cannot come back empty. */ export function buildContextualMapUrl(url: URL, point?: { lat: number; lng: number; zoom?: number }): string | null { const target = resolveFilterTarget(url); diff --git a/web/src/lib/utils/photos-filter-options.spec.ts b/web/src/lib/utils/photos-filter-options.spec.ts new file mode 100644 index 00000000000000..5ec4b967d8c21d --- /dev/null +++ b/web/src/lib/utils/photos-filter-options.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { createFilterState } from '$lib/components/filter-panel/filter-panel'; +import { buildPhotosTimelineOptions } from '$lib/utils/photos-filter-options'; + +const MY_USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +describe('photos timeline options — favorites compose with cross-user scopes (#763 slice 4)', () => { + it('an active favorite filter no longer suppresses partner/space scope', () => { + const options = buildPhotosTimelineOptions({ ...createFilterState(), isFavorite: true }, MY_USER_ID); + expect(options.isFavorite).toBe(true); + expect(options.withSharedSpaces).toBe(true); + expect(options.withPartners).toBe(true); + }); + + it('isFavorite: false also keeps both scopes (the old mirror keyed on undefined)', () => { + const options = buildPhotosTimelineOptions({ ...createFilterState(), isFavorite: false }, MY_USER_ID); + expect(options.isFavorite).toBe(false); + expect(options.withSharedSpaces).toBe(true); + expect(options.withPartners).toBe(true); + }); + + it('no favorite filter: scopes unchanged (regression)', () => { + const options = buildPhotosTimelineOptions(createFilterState(), MY_USER_ID); + expect(options.isFavorite).toBeUndefined(); + expect(options.withSharedSpaces).toBe(true); + expect(options.withPartners).toBe(true); + }); +}); diff --git a/web/src/lib/utils/photos-filter-options.ts b/web/src/lib/utils/photos-filter-options.ts index 4c14b5b5806a22..9c12d9bfd9a604 100644 --- a/web/src/lib/utils/photos-filter-options.ts +++ b/web/src/lib/utils/photos-filter-options.ts @@ -26,16 +26,22 @@ export type PhotosPersonFilterReference = { * must see the owner's assets (medium test E22). `/photos` is the opposite surface, so it has to * state its scope itself: without `userId`, an album chip would collapse the personal timeline to * "everything in that album", and `?albumId=A&ownerId=` would list a co-member's assets - * — and the Favorites chip the album OWNER's favourites (`isFavorite` is the owner's flag) — on MY - * timeline. The server query already ANDs the two gates; it just has to be told about both. + * on MY timeline. The server query already ANDs the two gates; it just has to be told about both. + * + * (#763: the Favorites chip is no longer part of that hazard — a favourite is a per-user overlay + * row resolved for the CALLER, not the asset owner's flag, so it narrows to MY favourites whoever + * owns the asset.) */ export function buildPhotosTimelineOptions(filters: FilterState, userId: string): Record { - const includeSharedTimelineAssets = filters.isFavorite === undefined; const base: Record = { userId, visibility: AssetVisibility.Timeline, withStacked: true, - ...(includeSharedTimelineAssets && { withPartners: true, withSharedSpaces: true }), + // #763 slice 4: favorites no longer suppress cross-user scopes — the server guard is gone + // and the favorite predicate is per-user, so partner/space content stays in scope while + // filtering by favorite. + withPartners: true, + withSharedSpaces: true, }; if (filters.personIds.length > 0) { diff --git a/web/src/lib/utils/recently-added-filter-options.ts b/web/src/lib/utils/recently-added-filter-options.ts index 638e9927524287..0198171c5ac5f0 100644 --- a/web/src/lib/utils/recently-added-filter-options.ts +++ b/web/src/lib/utils/recently-added-filter-options.ts @@ -19,8 +19,9 @@ export function shouldShowRecentlyAddedCount(count: number, hasActiveFilters: bo * * Reuses Photos' predicate mapping, then applies the two invariants that define this view: * 1. never surface shared-space assets — `withSharedSpaces` is stripped in every case, so the - * view stays own + partner (and own-only under a Favorites filter, which Photos treats as - * a personal flag); + * view stays own + partner. That includes under a Favorites filter: since #763 favorites are + * per-user (an `asset_favorite` overlay rather than the owner's flag on the asset), so the + * viewer's favorites on partner assets are theirs and stay in scope; * 2. always order and day-group by *added* date. * * Note the date filter still filters *taken* date (there is no created-at range predicate); diff --git a/web/src/lib/utils/space-filter-options.spec.ts b/web/src/lib/utils/space-filter-options.spec.ts index c672a1f7a07c77..f64e26e4045500 100644 --- a/web/src/lib/utils/space-filter-options.spec.ts +++ b/web/src/lib/utils/space-filter-options.spec.ts @@ -1,7 +1,7 @@ import { AssetOrder } from '@immich/sdk'; import { describe, expect, it } from 'vitest'; import { createFilterState } from '$lib/components/filter-panel/filter-panel'; -import { buildAlbumTimelineOptions } from '$lib/utils/album-filter-options'; +import { buildAlbumAssetPickerOptions, buildAlbumTimelineOptions } from '$lib/utils/album-filter-options'; import { buildSpaceTimelineOptions } from '$lib/utils/space-filter-options'; describe('space vs album timeline options — stack collapse (#751)', () => { @@ -18,3 +18,12 @@ describe('space vs album timeline options — stack collapse (#751)', () => { expect(options.withStacked).toBeUndefined(); }); }); + +describe('album asset picker options — favorites compose with cross-user scopes (#763 slice 4)', () => { + it('an active favorite filter no longer suppresses partner scope', () => { + const options = buildAlbumAssetPickerOptions('album-1', { ...createFilterState(), isFavorite: true }); + + expect(options.isFavorite).toBe(true); + expect(options.withPartners).toBe(true); + }); +}); diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 7bcea46dccc5a1..6b6d331cf8a382 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1052,10 +1052,10 @@ : undefined} /> - {#if assetMultiSelectManager.isAllUserOwned} - - {/if} + + {#if assetMultiSelectManager.isAllUserOwned} diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/mock-timeline.test-wrapper.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/mock-timeline.test-wrapper.svelte index 9418f76ccefd6f..39f8b3521ab155 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/mock-timeline.test-wrapper.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/mock-timeline.test-wrapper.svelte @@ -15,6 +15,7 @@ grouping?: TimelineGrouping; onGroupingChange?: (grouping: TimelineGrouping) => void; children?: Snippet; + empty?: Snippet; } let { @@ -22,6 +23,7 @@ options = {}, album, children, + empty: emptySnippet, onTimelineBucketActivate, temporalAnchor, onTemporalAnchorResolved, @@ -31,13 +33,23 @@ const mountId = nextTimelineMountId(); + /** + * Opt-in empty rendering, keyed on the same `__timelineStubAssetCount` global the sibling + * bindable-timeline stub uses. Gated on the global being EXPLICITLY 0 rather than on this mock's + * own `empty` computation, so every spec that does not set it keeps rendering exactly what it + * rendered before this snippet existed. + */ + const emptyStubGlobals = globalThis as typeof globalThis & { __timelineStubAssetCount?: number }; + const renderEmptySnippet = $derived(emptyStubGlobals.__timelineStubAssetCount === 0); + $effect(() => { if (album?.id === 'without-bound-timeline-manager') { return; } const tagIds = Array.isArray(options.tagIds) ? options.tagIds : []; - const empty = tagIds.includes('tag-no-match') || album?.assetCount === 0; + const empty = + tagIds.includes('tag-no-match') || album?.assetCount === 0 || emptyStubGlobals.__timelineStubAssetCount === 0; const monthsOnly = album?.id === 'timeline-months-only'; const nextTimelineManager = { @@ -92,4 +104,7 @@
{timelineManager?.assetCount ?? 0}
+{#if renderEmptySnippet} + {@render emptySnippet?.()} +{/if} {@render children?.()} diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/page.route.spec.ts b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/page.route.spec.ts index e8a97079aac8f9..e639ead14d0212 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/page.route.spec.ts +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/page.route.spec.ts @@ -294,7 +294,8 @@ describe('album detail filter panel route', () => { expect(screen.getByTestId('active-chip')).toHaveTextContent('favorites'); expect(screen.getByTestId('timeline-options').textContent).toContain('"timelineAlbumId":"'); expect(screen.getByTestId('timeline-options').textContent).toContain('"isFavorite":true'); - expect(screen.getByTestId('timeline-options').textContent).not.toContain('"withPartners":true'); + // #763 slice 4: favorites no longer suppress partner scope in picker mode. + expect(screen.getByTestId('timeline-options').textContent).toContain('"withPartners":true'); }); it('keeps timelineAlbumId in picker options after filters change and shows filtered empty state', async () => { diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index becc833bdcf7ad..15928fbba86aa8 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -26,6 +26,7 @@ import { getAssetBulkActions } from '$lib/services/asset.service'; import { getTimelineBucketZoomTarget, type ActivatableTimelineBucket } from '$lib/utils/timeline-zoom-navigation'; import { getTimelineTopVisibleAnchor } from '$lib/managers/timeline-manager/timeline-anchor'; + import { AssetVisibility } from '@immich/sdk'; import { ActionButton, CommandPaletteDefaultProvider, modalManager } from '@immich/ui'; import { mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -49,7 +50,26 @@ }; let timelineGrouping = $state('day'); let temporalAnchor = $state(); - const baseTimelineOptions = { isFavorite: true, withStacked: true }; + // #763: favorites are a per-user overlay, so this page is cross-scope like the Photos timeline + // favorites filter (see buildPhotosTimelineOptions). Without these, a favorite a member placed on + // another member's Space asset — which the overlay explicitly permits — would be unreachable here. + // Access is still enforced per request: the server recomputes timelineSpaceIds from current + // membership, so losing Space access drops the asset even though the overlay row survives. + // + // `visibility` is required, not incidental: timeline.service.ts rejects withPartners/ + // withSharedSpaces outright (400) when visibility is undefined, because undefined resolves to + // withDefaultVisibility = Archive+Timeline and would expose other users' ARCHIVED assets. An + // earlier attempt at this fix added the two flags alone and 400'd every request on this page, + // which is why it was reverted. Matching buildPhotosTimelineOptions keeps the two favorite + // surfaces consistent; the trade-off is that an archived favorite of your own no longer appears + // here, exactly as it already does not under the /photos favorites chip. + const baseTimelineOptions = { + isFavorite: true, + withStacked: true, + withPartners: true, + withSharedSpaces: true, + visibility: AssetVisibility.Timeline, + }; const options = $derived({ ...baseTimelineOptions, grouping: timelineGrouping, diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/favorites-page.spec.ts b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/favorites-page.spec.ts index 903161cfee4594..276f7f8ea4b829 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/favorites-page.spec.ts +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/favorites-page.spec.ts @@ -151,6 +151,51 @@ describe('Favorites page timeline grouping', () => { expect(screen.getByTestId('timeline-options')).toHaveTextContent('"grouping":"day"'); }); + // #763: a favorite is a per-user `asset_favorite` row, so this page must be cross-scope like the + // Photos timeline's favorites chip (buildPhotosTimelineOptions). The server only resolves + // `timelineSpaceIds` when the request carries `withSharedSpaces`; without it, the timeline query + // falls back to a hard `asset.ownerId = caller` filter, so a favorite the caller placed on another + // member's Space asset — which the overlay explicitly permits, and which the heart reports as + // favorited — is silently unreachable here and the page renders its empty placeholder. + // + // The break this catches: dropping either flag from `baseTimelineOptions`. That regression shipped + // once already (fixed in abbbde288e8, reverted by fd0c6b38aa1 along with this coverage), and the + // API-level e2e cannot catch it because its helper hardcodes `withSharedSpaces=true` — the very + // flag the page omitted. Asserted on the serialised options because the timeline is stubbed here. + it('requests favorites across shared spaces and partners, not just owned assets', async () => { + renderPage(); + + const options = await screen.findByTestId('timeline-options'); + expect(options).toHaveTextContent('"withSharedSpaces":true'); + expect(options).toHaveTextContent('"withPartners":true'); + }); + + // The cross-scope flags are only half the contract: timeline.service.ts rejects both of them with + // a 400 unless `visibility` is set, because an undefined visibility resolves to Archive+Timeline + // and would expose other users' archived assets. Sending the flags without it makes every request + // on this page fail — the page renders empty either way, so only this assertion separates "asks + // for the right scope" from "asks for a scope the server will refuse". + it('pins the visibility the cross-scope flags require, so the request is not rejected', async () => { + renderPage(); + + expect(await screen.findByTestId('timeline-options')).toHaveTextContent('"visibility":"timeline"'); + }); + + // Guards the composition rather than the constant: `options` is $derived by spreading + // baseTimelineOptions, so a refactor that rebuilds it per grouping could drop the flags on every + // surface except the initial render. Activating a year bucket regroups to month. + it('keeps the cross-scope flags after a bucket change regroups the timeline', async () => { + renderPage(); + + await fireEvent.click(await screen.findByTestId('activate-year-bucket')); + + await waitFor(() => { + expect(screen.getByTestId('timeline-options')).toHaveTextContent('"grouping":"month"'); + expect(screen.getByTestId('timeline-options')).toHaveTextContent('"withSharedSpaces":true'); + expect(screen.getByTestId('timeline-options')).toHaveTextContent('"withPartners":true'); + }); + }); + it('year and month buckets keep favorite options without temporal chips', async () => { renderPage(); diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte index 0c75a61cf21bb5..3a3ce007c760bb 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte @@ -199,12 +199,12 @@ - {#if assetMultiSelectManager.isAllUserOwned} - timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} - /> + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} + /> + {#if assetMultiSelectManager.isAllUserOwned} {#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 3a9bdf8ab29aa1..6a2eefb2cc1713 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -204,7 +204,7 @@ isInAlbum: nextFilters.isInAlbum === true ? true : undefined, takenAfter: context?.takenAfter, takenBefore: context?.takenBefore, - ...(nextFilters.isFavorite === undefined && { withSharedSpaces: true }), + withSharedSpaces: true, }); const mappedPeople = response.people.map((p) => ({ id: getPhotosPersonFilterId(p), @@ -237,7 +237,7 @@ return undefined; } - const withSharedSpaces = nextFilters.isFavorite === undefined; + const withSharedSpaces = true; const key = buildSmartSearchFacetKey({ query, filters: nextFilters, withSharedSpaces, language: $lang }); if (smartFacets && smartFacetKey === key) { return smartFacets; @@ -289,14 +289,14 @@ $type: SearchSuggestionType.City, country, ...context, - ...(context?.isFavorite === undefined && { withSharedSpaces: true }), + withSharedSpaces: true, }), cameraModels: (make, context) => getSearchSuggestions({ $type: SearchSuggestionType.CameraModel, make, ...context, - ...(context?.isFavorite === undefined && { withSharedSpaces: true }), + withSharedSpaces: true, }), }; @@ -341,7 +341,7 @@ smartSearchFacetsDto: buildSmartSearchFacetsParams({ query, filters: { ...filters, country }, - withSharedSpaces: filters.isFavorite === undefined, + withSharedSpaces: true, language: $lang, }), }); @@ -359,7 +359,7 @@ smartSearchFacetsDto: buildSmartSearchFacetsParams({ query, filters: { ...filters, make }, - withSharedSpaces: filters.isFavorite === undefined, + withSharedSpaces: true, language: $lang, }), }); @@ -382,7 +382,7 @@ const terms: SearchTerms = { ...filterStateToSearchTerms(filters), visibility: AssetVisibility.Timeline }; if (query) { terms.query = query; - } else if (filters.isFavorite === undefined) { + } else { terms.withSharedSpaces = true; } void modalManager.show(SearchAddAllToCollectionModal, { @@ -668,7 +668,7 @@ {filters} language={$lang} isShared={false} - withSharedSpaces={filters.isFavorite === undefined} + withSharedSpaces={true} total={smartFacetTotal} /> {:else} @@ -704,11 +704,21 @@ {/if} {#snippet empty()} - openFileUploadDialog()} - class="mx-auto mt-10" - /> + + {#if hasActiveFilters} + + {:else} + openFileUploadDialog()} + class="mx-auto mt-10" + /> + {/if} {/snippet} {/key} @@ -735,9 +745,9 @@ {/if} - {#if assetMultiSelectManager.isAllUserOwned} - + + {#if assetMultiSelectManager.isAllUserOwned} {#if !showSearchResults && (assetMultiSelectManager.assets.length > 1 || isAssetStackSelected)} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/photos-page.spec.ts b/web/src/routes/(user)/photos/[[assetId=id]]/photos-page.spec.ts index 7062de2c166686..19c2836b41ab34 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/photos-page.spec.ts +++ b/web/src/routes/(user)/photos/[[assetId=id]]/photos-page.spec.ts @@ -96,8 +96,10 @@ vi.mock('$lib/components/shared-components/context-menu/ButtonContextMenu.svelte return { default: MockComponent }; }); +// Surfaces `text`/`onClick` instead of swallowing them: which empty state renders is the assertion +// in the "#763" tests below, and a noop mock cannot tell them apart. vi.mock('$lib/components/shared-components/EmptyPlaceholder.svelte', async () => { - const { default: MockComponent } = await import('@test-data/mocks/noop-component.svelte'); + const { default: MockComponent } = await import('@test-data/mocks/empty-placeholder.stub.svelte'); return { default: MockComponent }; }); @@ -490,7 +492,7 @@ describe('Photos page search URL state', () => { }); }); - it('narrows non-search dependent suggestions to favorites without shared spaces when selected', async () => { + it('narrows non-search dependent suggestions to favorites while keeping shared spaces in scope (#763 slice 4)', async () => { mockPage.url = new URL('https://gallery.test/photos'); renderPage(); @@ -508,7 +510,7 @@ describe('Photos page search URL state', () => { }); for (const [request] of sdkMock.getSearchSuggestions.mock.calls) { - expect(request).not.toHaveProperty('withSharedSpaces'); + expect(request).toHaveProperty('withSharedSpaces', true); } }); @@ -581,7 +583,7 @@ describe('Photos page search URL state', () => { expect(screen.getByTestId('smart-search-results')).toHaveAttribute('data-language', 'de'); }); - it('narrows search results and smart facets to favorites without shared spaces when selected', async () => { + it('narrows search results and smart facets to favorites while keeping shared spaces in scope (#763 slice 4)', async () => { renderPage(); await vi.waitFor(() => expect(sdkMock.searchSmartFacets).toHaveBeenCalledTimes(1)); @@ -589,12 +591,12 @@ describe('Photos page search URL state', () => { await vi.waitFor(() => expect(sdkMock.searchSmartFacets).toHaveBeenCalledTimes(2)); expect(screen.getByTestId('smart-search-results')).toHaveAttribute('data-is-favorite', 'true'); - expect(screen.getByTestId('smart-search-results')).toHaveAttribute('data-with-shared-spaces', 'false'); + expect(screen.getByTestId('smart-search-results')).toHaveAttribute('data-with-shared-spaces', 'true'); expect(sdkMock.searchSmartFacets.mock.calls[1][0].smartSearchFacetsDto).toMatchObject({ query: 'nature', isFavorite: true, + withSharedSpaces: true, }); - expect(sdkMock.searchSmartFacets.mock.calls[1][0].smartSearchFacetsDto).not.toHaveProperty('withSharedSpaces'); }); it('uses smart facet timeBuckets in search mode', async () => { @@ -1413,3 +1415,63 @@ describe('Photos page — SpaceAddAssets timeline reload (#1041)', () => { expect(screen.getByTestId('timeline-mount-id').textContent).toBe(before); }); }); + +// #763 — the second half of the bug report: a camera filter that matched nothing rendered the +// EMPTY-LIBRARY placeholder ("Click to upload your first photo"), which reads as "you have no +// photos at all" rather than "this filter matched none of them". The user had the photo open in +// the viewer one click earlier, so the page was telling them something they could see was false. +describe('Photos page — empty state under active filters (#763)', () => { + const timelineStubGlobals = globalThis as typeof globalThis & { __timelineStubAssetCount?: number }; + + beforeEach(() => { + vi.resetAllMocks(); + lang.set('en'); + mockAssetMultiSelectManager.selectionActive = false; + mockAssetMultiSelectManager.assets = []; + mockMemoryManager.memories = []; + mockAuthManager.preferences.memories.enabled = false; + mockRegisterSearchablePageFilters.mockReturnValue(vi.fn()); + sessionStorage.clear(); + resetTimelineMountSeq(); + sdkMock.getFilterSuggestions.mockResolvedValue({ + people: [], + countries: [], + cameraMakes: [], + tags: [], + ratings: [], + mediaTypes: [], + hasUnnamedPeople: false, + hasFavorites: false, + hasAssetsInAlbum: false, + hasAssetsNotInAlbum: false, + }); + timelineStubGlobals.__timelineStubAssetCount = 0; + }); + + afterEach(() => { + timelineStubGlobals.__timelineStubAssetCount = undefined; + }); + + it('says there are no results when filters are active, and offers no upload affordance', async () => { + mockPage.reset('https://gallery.test/photos?make=Canon&model=Canon+EOS+R6', { + routeId: '/(user)/photos/[[assetId=id]]', + }); + + renderPage(); + + const placeholder = await screen.findByTestId('empty-placeholder'); + expect(screen.getByTestId('empty-placeholder-text')).toHaveTextContent('no_results'); + // "Click to upload your first photo" is a call to action; a filter that matched nothing is not. + expect(placeholder).toHaveAttribute('data-clickable', 'false'); + }); + + it('still invites the first upload when the timeline is empty with no filters', async () => { + mockPage.reset('https://gallery.test/photos', { routeId: '/(user)/photos/[[assetId=id]]' }); + + renderPage(); + + const placeholder = await screen.findByTestId('empty-placeholder'); + expect(screen.getByTestId('empty-placeholder-text')).toHaveTextContent('no_assets_message'); + expect(placeholder).toHaveAttribute('data-clickable', 'true'); + }); +}); diff --git a/web/src/routes/(user)/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte index 425216be44d325..8d31e889af1d46 100644 --- a/web/src/routes/(user)/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/recently-added/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -571,9 +571,9 @@ {/if} - {#if assetMultiSelectManager.isAllUserOwned} - + + {#if assetMultiSelectManager.isAllUserOwned} {#if !showSearchResults && (assetMultiSelectManager.assets.length > 1 || isAssetStackSelected)} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6293c4e6a39834..debf1c2b31a964 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -424,9 +424,9 @@ onclick={handleSelectAll} /> - {#if assetMultiSelectManager.isAllUserOwned} - + + {#if assetMultiSelectManager.isAllUserOwned} diff --git a/web/src/routes/(user)/spaces/[spaceId]/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/space-album-detail-page.spec.ts b/web/src/routes/(user)/spaces/[spaceId]/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/space-album-detail-page.spec.ts index 8416570a74304c..f4578e13a8283b 100644 --- a/web/src/routes/(user)/spaces/[spaceId]/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/space-album-detail-page.spec.ts +++ b/web/src/routes/(user)/spaces/[spaceId]/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/space-album-detail-page.spec.ts @@ -1112,8 +1112,11 @@ describe('Space album detail page', () => { // reasoning) instead of the stripped Download+Remove-only bar it originally shipped with. // `canRemoveFromAlbum` stays `canManage`-gated (ownership grants nothing — decision C), while // the metadata-edit affordances (Archive/ChangeDate/ChangeLocation/Delete/…) and Share/ - // Add-to-album/Favorite are gated independently on `isAllUserOwned` — the same two + // Add-to-album are gated independently on `isAllUserOwned` — the same two // independent axes the merged direct-space timeline and SelectionToolbar.spec.ts encode. + // Favorite is NOT on either axis (#763): it writes a per-user `asset_favorite` row rather than + // mutating the asset, so `canFavorite` is unconditionally true and the heart shows for every + // selection any viewer can see. describe('Slice 6: full album-equivalent toolbar (reversal of the stripped bar)', () => { it('manager + all-owned selection: Download + RemoveFromAlbum + metadata-edit + Set-cover all present', async () => { mockAssetMultiSelectManager.selectionActive = true; @@ -1135,7 +1138,7 @@ describe('Space album detail page', () => { expect(screen.getByRole('menuitem', { name: 'Set as album cover' })).toBeInTheDocument(); }); - it('manager + NOT-owned selection: RemoveFromAlbum + Set-cover + Add-to-album + Share present (canManage/space-role-gated), metadata-edit + Delete absent (ownership-gated)', async () => { + it('manager + NOT-owned selection: RemoveFromAlbum + Set-cover + Add-to-album + Share (canManage/space-role-gated) + Favorite (per-user) present, metadata-edit + Delete absent (ownership-gated)', async () => { mockAssetMultiSelectManager.selectionActive = true; mockAssetMultiSelectManager.isAllUserOwned = false; mockAssetMultiSelectManager.assets = [{ id: 'asset-1' }]; @@ -1147,7 +1150,9 @@ describe('Space album detail page', () => { // A space Editor may contribute non-owned assets into an album linked to this space // (#764), so the "+" stays — it opens the picker restricted to that space's albums. expect(screen.getByRole('button', { name: 'Add to album or space' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /favorite/i })).not.toBeInTheDocument(); + // #763: favoriting writes a per-user `asset_favorite` row, so it is not ownership-gated — + // the heart stays for a non-owned selection. + expect(screen.getByRole('button', { name: /favorite/i })).toBeInTheDocument(); await openOverflowMenu(); expect(screen.getByTestId('download-action')).toBeInTheDocument(); @@ -1193,7 +1198,7 @@ describe('Space album detail page', () => { expect(screen.queryByRole('menuitem', { name: 'Set as album cover' })).not.toBeInTheDocument(); }); - it('non-manager (viewer) + NOT-owned selection: only Select-all and Download are shown', async () => { + it('non-manager (viewer) + NOT-owned selection: only Select-all, Favorite and Download are shown', async () => { mockAssetMultiSelectManager.selectionActive = true; mockAssetMultiSelectManager.isAllUserOwned = false; mockAssetMultiSelectManager.assets = [{ id: 'asset-1' }]; @@ -1212,7 +1217,9 @@ describe('Space album detail page', () => { expect(screen.getByRole('button', { name: 'Select all' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Add to album or space' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /favorite/i })).not.toBeInTheDocument(); + // #763: favoriting is a per-user overlay write available to anyone who can see the asset, + // so even a read-only space viewer gets the heart on a non-owned selection. + expect(screen.getByRole('button', { name: /favorite/i })).toBeInTheDocument(); await openOverflowMenu(); expect(screen.getByTestId('download-action')).toBeInTheDocument(); diff --git a/web/src/test-data/mocks/empty-placeholder.stub.svelte b/web/src/test-data/mocks/empty-placeholder.stub.svelte new file mode 100644 index 00000000000000..2872eda557d8b7 --- /dev/null +++ b/web/src/test-data/mocks/empty-placeholder.stub.svelte @@ -0,0 +1,24 @@ + + + +
+ {#if title} + {title} + {/if} + {text} +