Skip to content

feat(spaces): in-space album management + shared-space-album scope helper#752

Open
Deeds67 wants to merge 256 commits into
mainfrom
space-albums-onto-main
Open

feat(spaces): in-space album management + shared-space-album scope helper#752
Deeds67 wants to merge 256 commits into
mainfrom
space-albums-onto-main

Conversation

@Deeds67

@Deeds67 Deeds67 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

In-space album management (search / create / sort / group / list-covers)

Brings the full main albums-page toolbar into the shared-space Albums tab, and consolidates shared-space-album scoping behind a single shared helper.

Web — space Albums tab

  • Search albums by name/description (null-safe), with a distinct "no matching albums" state.
  • Sort — Title / Item count / Date created·modified / Most-recent·Oldest photo (null-date albums sort last).
  • Group by — None / Year / Linked by (space member who linked it) / Owner, with expand/collapse-all and per-group collapse; "Unassigned" bucket for unknown linkers.
  • Cover ↔ List view toggle (list rows route to the space album).
  • Create album (editor+) creates → auto-links → opens it; Link album for existing albums; viewers are read-only but keep search/sort/group/view.
  • Fork-isolated: reuses AlbumCover, sortAlbums, and the album mapper; the toolbar/list/table live in fork-only components with a space-scoped view-settings store (isolated from the global /albums settings). The /albums Create button moves to the right so both pages match.

Server

  • Enriches SharedSpaceLinkedAlbumDto to the AlbumResponseDto shape (+ showInTimeline / addedById / linkedAt) and removes the per-album N+1 (bulk getMetadataForIds + reused mapAlbum).
  • New shared-space-album-scope helper routed through ~9 repositories (access / asset / search / timeline / map / memory / view / person-face / face-identity), de-duplicating inline space-scoping SQL — proven equivalent by medium characterization tests.
  • Closes 4 space-album soft-delete face-retention holes.

Testing

TDD throughout: server unit + real-DB medium (incl. the scope-equivalence characterization spec), full web component suite, and Playwright e2e journeys (search/sort/group/view/create/link/viewer gating). Regenerated SQL / OpenAPI / Dart clients.

Deeds67 added 30 commits July 3, 2026 17:16
Add addAlbum / removeAlbum / getLinkedAlbums / getSpacesLinkedToAlbum /
hasAlbumLink / setAlbumShowInTimeline to SharedSpaceRepository, mirroring
the existing library link CRUD block. Covered by a new medium test
(shared-space-album.spec.ts) that exercises all six methods against a
real Postgres testcontainer — 5/5 green.
Add a shared_space_album branch to AssetAccess.checkSpaceAccess and
checkSpaceAccessForSpace, joining through album_asset so that space
members can read assets contained in albums linked to their space.

Adds the authoritative permission-matrix medium spec for Phase 1,
covering Grid 1 (full AssetRead chain for all actor roles) and Grid 6
(multi-path read: direct-asset vs album-link, unlink behaviour across
two spaces sharing the same album).
Add AlbumAccess.checkSpaceLinkedAlbumAccess which returns album ids the
user can write because they are an Owner or Editor of a space that links
the album. Wire it into Permission.AlbumAssetCreate and
Permission.AlbumAssetDelete in access.ts (union after owner + shared-album
paths). Add mock entry for the new method. Add Grid 2 (WRITE) cells and
a write-wins cell to the matrix spec.
Implement SharedSpaceService.linkAlbum: requireRole(Editor+), requireAccess(AlbumUpdate), addAlbum (idempotent), queue SharedSpaceAlbumFaceSync only on new link with faceRecognitionEnabled. No isAdmin gate (diverges from linkLibrary). Add Grid 3 permission-matrix medium tests (5 cases, isolated space/albums to avoid world mutation) and 8 unit tests mirroring linkLibrary patterns.
- Add getAlbumAssetIdsWithoutOtherSpacePath to SharedSpaceRepository:
  returns asset IDs that have no other path into the space (not
  direct-added, not in another linked album, not via a linked library)

- Add unlinkAlbum to SharedSpaceService: requires Editor role, fetches
  orphaned asset IDs, removes the album link, then removes stale
  shared_space_person_face rows and deletes orphaned space persons

- Extend shared-space-album.repository.spec with two
  getAlbumAssetIdsWithoutOtherSpacePath tests (direct+another-album
  paths excluded; library path excluded)

- Add Grid 4 auth matrix to shared-space-album-permissions.service.spec:
  owner+editor ALLOW, viewer+nonMember DENY

- New shared-space-album.service.spec: face-retention test asserting
  album-only asset face is removed on unlink while directly-added asset
  face is retained; orphaned-person deletion test
- SharedSpaceService.updateAlbumLink: requires Editor role, delegates to
  sharedSpaceRepository.setAlbumShowInTimeline
- SharedSpaceService.getLinkedAlbums: requires membership, maps
  getLinkedAlbums rows to SharedSpaceLinkedAlbumDto with assetCount via
  new sharedSpaceRepository.getAlbumAssetCount (count album_asset rows)
- Grid 4b permission matrix tests (toggle: owner/editor ALLOW,
  viewer/nonMember DENY) with persistence assertion via getLinkedAlbums
- getLinkedAlbums medium tests: correct DTO mapping, assetCount, empty
  case, non-member rejection
- Unit tests: updateAlbumLink requiresEditor gate + setAlbumShowInTimeline
  call; getLinkedAlbums requiresMembership gate + row→DTO mapping
- Add AssetRepository.getByAlbumIdWithFaces (mirrors getByLibraryIdWithFaces, joins album_asset)
- Add SharedSpaceService.handleSharedSpaceAlbumFaceSync handler (@onjob FacialRecognition queue): mirrors handleSharedSpaceLibraryFaceSync - batched album asset iteration, hasAlbumLink re-check per batch, processSpaceFaceMatch per asset, queueSpaceIdentityReconciliation on match, dedup queue on completion
- Extend SharedSpaceRepository.isAssetInSpace with album path (shared_space_album → album_asset → asset union) so processSpaceFaceMatch recognises album-linked assets as in-space
- Extend SharedSpaceRepository.getSpaceAssetAdder with album path for correct addedById attribution
- Add getByAlbumIdWithFaces to asset repository mock
- 4 medium handler tests (RED→GREEN) + 6 unit tests in service.spec.ts
Adds 7 medium tests to shared-space-album.service.spec.ts covering:
- asset removal from album revokes space access
- direct-add fallback preserves access after album removal
- hard album delete cascades shared_space_album link + revokes access
- soft-deleted asset excluded from space access and timeline
- live-photo motion part reachable via album branch
- locked (AssetVisibility.Locked) asset pinned behavior (visibility not filtered)
- empty album link is a no-op (assetCount 0, timeline unchanged)
…y guard

Add Grid 5 (AlbumDelete — album owner only, space link does not grant),
Grid 7 medium layer (DB assertion that linking writes no album_user rows
for space members), and a cross-layer set-equality guard that pins
checkSpaceAccess output against an independently-derived expected set
(seeded paths, not the predicate) to catch future predicate drift.
Regenerated OpenAPI spec (sync-open-api), TypeScript SDK (oazapfts), and
Dart client (openapi-generator) to reflect the 4 new shared-space album
endpoints (GET/PUT/PATCH/DELETE /shared-spaces/{id}/albums[/{albumId}]),
new DTOs (SharedSpaceAlbumLinkUpdateDto, SharedSpaceLinkedAlbumDto), new
permissions (sharedSpaceAlbum.create/update/delete), and new job name
(SharedSpaceAlbumFaceSync).

SQL docs deferred to CI (mise sql requires a live DB).
- SpaceLinkedAlbums component (space-linked-albums.svelte): loads linked
  albums via getSharedSpaceAlbums, renders list with per-album thumbnail,
  name, and asset count; canManage gate controls unlink button, timeline
  Switch toggle, and album picker (calls getAllAlbums filtered to
  owner/editor roles, excluding already-linked)
- SpaceLinkedAlbumsModal: thin modal wrapper mirroring SpaceLinkedLibrariesModal
- Wire into +page.svelte: import + handleLinkAlbums handler + "Linked Albums"
  MenuOption gated on isEditor (matches Link Libraries gate)
- 12 i18n keys in en.json (prettier-sorted)
- space-linked-albums.spec.ts: 14 tests covering render, canManage=true
  (unlink/toggle/picker/linkAlbum calls), and canManage=false (read-only)
Adds the `shared_space_album` leg (gated on `showInTimeline = true`) to
the two `searchAssetBuilder` blocks in `database.ts` (spaceId path and
timelineSpaceIds path) and to all three space-legs in `map.repository.ts`
(`getAlbumMapMarkers` timelineSpaceIds scope, `getMapMarkers` albumScope,
and `getMapMarkers` direct timelineSpaceIds union), mirroring the pattern
already used by the timeline builders.

Adds `newSharedSpaceAlbum` factory helper to `medium.factory.ts`.
Tests (TDD, RED→GREEN): search service (+2 cases: showInTimeline=true
found / showInTimeline=false excluded) and map repository (+2 cases: GPS
asset in linked album shown / excluded when showInTimeline=false). All
19 search-service and 13 map-repository medium tests pass.

Regenerates SQL docs for `map.repository.sql` and `search.repository.sql`.
Wrap the thumbnail+name section of each linked-album row in an
<a href> using Route.viewAlbum({ id }) so all members can click through
to browse the album. The manage controls (timeline Switch + unlink
Button) are sibling elements outside the anchor so they remain
independently clickable without triggering navigation.

Add a test asserting the link element carries the correct /albums/<id>
href.
…lbums

Add checkSpaceLinkedAlbumReadAccess (no role filter) to AlbumAccess and
wire it into the AlbumRead and AlbumDownload auth branches in access.ts,
so any space member (Owner/Editor/Viewer) can open a linked album entity
and download from it without needing an album_user row.
Adds /spaces/:spaceId/albums: SpaceAlbumCard grid with link/unlink/toggle-timeline handlers relocated from space-linked-albums.svelte. Editors/Owners get the Link-album inline picker; Viewers see the grid read-only. Mirrors People sub-page shell (UserPageLayout, load pattern, isEditor derived).
Route /spaces/:spaceId/albums/:albumId=id opens a linked album inside
the space chrome. The +page.ts verifies the album appears in
getSharedSpaceAlbums before calling getAlbumInfo — an unlinked album
owned by the user redirects to /spaces/:id/albums instead. The
+page.svelte renders the album Timeline (enableRouting=false, options
via buildAlbumTimelineOptions + grouping:month) with a canManage-gated
"Add photos" button (data-testid=add-photos-button) for Task 7 to wire.
…T19)

- Add 7 e2e cases to shared-space-album.e2e-spec.ts proving the
  checkSpaceLinkedAlbumReadAccess grant:
  · space viewer GET /albums/:id linked album → 200
  · non-member GET /albums/:id → 400
  · space member GET /albums/:id for non-linked album → 400 (boundary)
  · space viewer GET /timeline/buckets?albumId → 200
  · space viewer GET /timeline/bucket?albumId → 200 (content path)
  · non-member GET /timeline/buckets?albumId → 400
  · re-assert: editor PUT /albums/:id/assets still → 200

- Regenerate server/src/queries/access.repository.sql against the
  running immich_postgres (all fork tables present). Diff is scoped to
  12 additions: new checkSpaceLinkedAlbumReadAccess block only.
  No lines dropped from any existing query.

e2e deferred to CI (e2e compose stack not running locally).
Deeds67 added 26 commits July 6, 2026 01:24
…slice 1)

Pins the timeline album leg + showInTimeline gate (asset.repository, previously
uncovered) and the current pre-fix A1 soft-delete-hole behavior at the four sites
that omit album.deletedAt (isFaceInSpace, getSpaceAssetAdder,
getAlbumAssetIdsWithoutOtherSpacePath, getAssetIdsWithoutOtherSpacePath). Slices
2-14 must keep these green; Slice 15 flips the pre-fix assertions.

Spec: data/sa-abstraction-spec-t8/report.md
New fork-only module src/utils/shared-space-album-scope.ts encodes the linked-album
access path (shared_space_album -> album(deletedAt) -> album_asset) and the A1
invariant exactly once, as composable Kysely EXISTS helpers:
spaceAlbumAssetExists (the workhorse album arm, positive or negated), plus
spaceDirectAssetExists / spaceLibraryAssetExists / spaceAssetPathBranches for the
clean asset-outer sites. Parameterized by scope (spaceId / spaceIds / member /
correlated ref), requireShowInTimeline, requireAlbumNotDeleted (A1), excludeAlbumId.

DB-backed tests cover every path combination + edge case (soft-deleted album,
showInTimeline off, multi-space dedup, member vs non-member, excludeAlbumId,
non-asset correlation). No call sites migrated yet.

Spec: data/sa-abstraction-spec-t8/report.md
…ice 3)

spaceAlbumAssetExistsSql emits the linked-album EXISTS arm for sql``-authored
queries (face-identity.repository.ts). A DB-backed equivalence test proves it
returns the identical asset set as the Kysely spaceAlbumAssetExists over the same
data + scope, with A1 on and off — the wire that keeps the two authoring styles
from drifting.

Spec: data/sa-abstraction-spec-t8/report.md
Scans all 12 scoping files and asserts every shared_space_library access-scope arm
has a nearby shared_space_album arm or fork album-scope helper call — the backstop
for the recurring 'library present, album forgotten' defect class, incl. future
upstream-rebase additions. Benign refs (CRUD, column lists, library-only sync) are
pattern-filtered; genuine album-free sites are named in ALLOWLIST with reasons.

The guard already surfaced two PRE-EXISTING missing-album gaps the review's grep
missed (findSpaceForAssetAndUser, getPersonalThumbnailForSpacePerson — both
union/or direct+library, no album arm). Allowlisted + flagged for follow-up; NOT
fixed here (unplanned behavior change).

Spec: data/sa-abstraction-spec-t8/report.md
…e module (slice 5)

Move the two scoping subqueries out of the upstream sync.repository.ts into the
fork-owned scope module (reducing fork footprint in an upstream file) and
re-export them from sync for its existing callers. No external importers; all 359
sync medium tests unchanged. Behavior identical.

Spec: data/sa-abstraction-spec-t8/report.md
… (slice 6)

Replace the two hand-cloned 3-path OR clusters in searchAssetBuilder (spaceId and
timelineSpaceIds forms) with spaceAssetPathBranches. ~44 inline lines -> 2 helper
spreads. Behavior identical (repositories suite 521 tests + timeline
characterization green).

Spec: data/sa-abstraction-spec-t8/report.md
…lice 7)

Replace the four hand-cloned 3-path OR clusters in withTimeBucketAssetFilters and
getTimeBucket (AR-1..AR-4) with spaceAssetPathBranches (requireShowInTimeline).
Behavior identical (asset.repository 46 + timeline characterization 8 green).

Spec: data/sa-abstraction-spec-t8/report.md
…elper (slice 8)

Replace the 5 hand-cloned album/3-path clusters in getAccessibleTags and
applySuggestionScope (SR-1..SR-5, no showInTimeline) with spaceAssetPathBranches.
Behavior identical (search.repository 13 green).

Spec: data/sa-abstraction-spec-t8/report.md
MR-1 (albumScope 3-arm push) via spaceAssetPathBranches; MR-2 keeps its
AND(visibility=Timeline, ...) wrapper and routes the album leg through
spaceAlbumAssetExists. Behavior identical (map.repository 13 green).

Spec: data/sa-abstraction-spec-t8/report.md
…(slice 10)

view-repository.ownedOrSpaceAccessible: full 3-path via spaceAssetPathBranches
(member scope). memory.accessibleSearchBuilder: album arm via spaceAlbumAssetExists
(direct/library kept inline — library carries a bespoke isOffline filter).
tag.repository (tag_asset-walk correlation) and download.repository (UNION-of-rows)
are left inline as structural outliers — both keep album.deletedAt and are covered
by the co-occurrence guard. Behavior identical (view 26 + memory 20 green).

Spec: data/sa-abstraction-spec-t8/report.md
… 11)

PersonAccess.checkSharedSpaceAccess (AC-5) 3-path OR -> spaceAssetPathBranches
(member scope). checkSpaceLinkedAlbumAccess/ReadAccess (album-id JOIN, the
canonical album-access derivation) and checkSpaceAccess/checkSpaceAccessForSpace
(UNION-of-rows) are left inline as distinct query shapes (not clones); both keep
album.deletedAt. checkSpaceEditAccess album-arm gap remains allowlisted (pre-existing).
Behavior identical (RBAC suites 85 green).

Spec: data/sa-abstraction-spec-t8/report.md
…ent (slice 12)

Replace the 6 byte-identical inline EXISTS album clones (getAccessiblePeople*
statistics/counts/page, hydrateAccessiblePeople) with spaceAlbumAssetExistsSql.
The 2 UNION-arm sites (face-match backfill targets) keep their bespoke inline SQL
(shared_space_library/album adjacent, guard-covered). Behavior identical
(face-identity 114 + face-matching 34 green; raw≡Kysely equivalence pinned).

Spec: data/sa-abstraction-spec-t8/report.md
…elper (slice 13)

Consolidate the 5 correlated-EXISTS album clones in the person/face paths
(getSpaceRepresentativeFace(s), getIdentityEvidenceForSpacePerson,
isSpacePersonRepresentativeFaceValid, getFirstValidRepresentativeFaceForPerson)
via spaceAlbumAssetExists. getPersonsBySpaceId's arm uses an aliased-CTE eb
(spf2/af2) not assignable to the helper's type, so it stays inline. The UNION-row
(getAssetCount/getRecentAssets/getNewAssetCount/isAssetInSpace/isFaceInSpace/
getSpaceIdsForAsset), raw-stats and adder-attribution forms remain inline
(structural outliers, fork-only, guard-covered). Behavior identical (148 green).

Spec: data/sa-abstraction-spec-t8/report.md
…e 15)

THE ONE INTENTIONAL BEHAVIOR CHANGE in this branch — reviewable/revertable on its own.

Four sites resolved a linked-album access path WITHOUT the album.deletedAt IS NULL
guard (A1), so an asset reachable only via a SOFT-DELETED album was still treated
as in-space — leaking/retaining its faces and mis-attributing its adder:
  - getAlbumAssetIdsWithoutOtherSpacePath (branch 2)  [review finding #5]
  - getAssetIdsWithoutOtherSpacePath (branch 2)        [review finding #5]
  - isFaceInSpace (album leg)                          [newly found by this work]
  - getSpaceAssetAdder (album leg)                     [newly found by this work]

Each album leg now joins album with deletedAt IS NULL, matching isAssetInSpace and
the shared album-scope helper's default. RED-first: the 4 characterization
assertions were flipped to the correct post-fix expectation, failed on the prior
commit, and pass now (211 shared-space tests green).

Spec: data/sa-abstraction-spec-t8/report.md
…ame)

Prettier-format 4 space-album-scope files and switch the guard's server-root
resolution from __dirname (banned by unicorn/prefer-module) / import.meta.dirname
(invalid under CJS) to process.cwd() (matches face-identity-query-shape.spec.ts).
No behavior change. Local: format --check clean, eslint --max-warnings 0 clean,
tsc 0 errors, 4752 unit tests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant