Skip to content

fix(people): make "Fix incorrect match" work in shared spaces (#765) - #811

Open
Deeds67 wants to merge 22 commits into
mainfrom
worktree-fix-765-space-editor-face-reassign
Open

fix(people): make "Fix incorrect match" work in shared spaces (#765)#811
Deeds67 wants to merge 22 commits into
mainfrom
worktree-fix-765-space-editor-face-reassign

Conversation

@Deeds67

@Deeds67 Deeds67 commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Fixes #765.

A non-admin Editor in a Shared Space selects misassigned photos, runs Fix incorrect match, reassigns them — the flow completes with no error, and the photos immediately reappear under the original person.

Root cause

The issue title points at permissions, but the reported symptom is a different defect. There are two, and both independently block that user.

1. Stale space projection — this is the silent no-op

reassignFaces / reassignFacesById wrote asset_face.personId and face_identity_face, but every space-scoped person view reads the materialized shared_space_person_face projection (e.g. shared-space.repository.ts:2588), which was never refreshed.

So the write landed, nothing threw, and the UI re-read a stale row — the correction looked like it had no effect. The recognition path already queues SharedSpaceFaceMatch for exactly this reason (person.service.ts:1035,1134); the reassign paths queued nothing.

The stale assignment is now evicted synchronously, so the face leaves the wrong person immediately, and the match job is queued to re-project it under the corrected person.

Two subtleties that make this safe:

  • Eviction is gated on the job's own isAssetInSpace guard. getSpaceIdsForAsset is broader — it ignores deletedAt / isOffline / visibility. Evicting on it alone would remove a face for a trashed/offline/hidden asset that the job then refuses to re-add, deleting it from the space entirely — worse than the original bug.
  • The refresh must run after replaceFaceIdentity. The match job resolves the target space person from face_identity_face; evicting before the identity swap would re-add the old person and silently restore this bug. Pinned by a test and a comment.

The vacated space person is recounted and reaped, mirroring processSpaceFaceMatch.

2. Owner-only permissions

Both gates were owner-scoped — PersonUpdatecheckOwnerAccess and PersonCreatecheckFaceOwnerAccess (access.ts:328,315) — so an Editor correcting a face on another member's asset was rejected outright.

Reassign now mirrors the existing updateRepresentativeFace pattern: owner fast path, then checkSharedSpaceEditAccess for the target person, and Permission.AssetUpdate (already owner-or-space-Editor) for the face's asset. Viewers hold PersonRead only and stay denied.

Resolving the face before its access check meant an unknown id would surface as a Kysely error, so NoResultError is mapped back to a 400 — indistinguishable from an unauthorized face — while real database failures still propagate.

Tests

TDD throughout: every test was watched fail first, for the right reason.

17 unit tests (person.service.spec.ts) covering per-space evict+queue, evict-before-queue ordering, duplicate-space dedup, no-space assets, the bulk path, the isAssetInSpace guard, recount/reap, identity-before-evict ordering, Editor allowed / Viewer denied on both target and face, unknown face id, and DB errors not being masked.

4 medium tests against a real Postgres (people-identity-rbac.spec.ts) asserting actual projection rows: the row exists under the wrong identity, is gone before any job drain, and lands under the correct identity after. Plus Editor-allowed, Viewer-denied, and no-space.

The negative tests pass pre-fix by design — they exist to catch an over-permissive fix.

Red-validated: reverting the refresh calls fails the medium test with the stale row still under the wrong person.

server unit          5111 passed, 0 failed
medium (real DB)     82 + 21 passed
tsc / eslint / prettier   clean

No client change needed — eviction is synchronous, so the existing refresh shows the corrected state right away.

@Deeds67 Deeds67 added the changelog:fix Bug fix for changelog label Jul 19, 2026
@Deeds67
Deeds67 force-pushed the worktree-fix-765-space-editor-face-reassign branch from 2eb1fcc to 2a7231c Compare July 21, 2026 20:24
@Deeds67
Deeds67 force-pushed the worktree-fix-765-space-editor-face-reassign branch from 4946b15 to 65d1506 Compare July 30, 2026 07:22
Deeds67 added 21 commits July 30, 2026 10:21
Reassigning a face appeared to succeed but the photo reappeared under the
original person. Two independent defects, both blocking a non-admin Editor.

Stale space projection. reassignFaces/reassignFacesById wrote asset_face
.personId and face_identity_face, but every space-scoped person view reads
the materialized shared_space_person_face projection, which was never
refreshed. The write landed, nothing errored, and the UI re-read the stale
row — so the correction looked like a no-op. The recognition path already
queues SharedSpaceFaceMatch for exactly this reason; the reassign paths
queued nothing.

Now the stale assignment is evicted synchronously, so the face leaves the
wrong person immediately, and the match job is queued to re-project it under
the corrected one. Eviction is gated on the job's own isAssetInSpace guard:
getSpaceIdsForAsset is broader (it ignores deletedAt/isOffline/visibility),
so evicting on it alone would drop faces the job then refuses to re-add. The
vacated space person is recounted and reaped, mirroring processSpaceFaceMatch.

Owner-only permissions. Both gates were owner-scoped — PersonUpdate on the
target and PersonCreate on the face — so an Editor correcting a face on
another member's asset was rejected outright. Reassign now mirrors
updateRepresentativeFace: owner fast path, then checkSharedSpaceEditAccess
for the target person, and Permission.AssetUpdate (already owner-or-space-
Editor) for the face's asset. Viewers hold PersonRead only and stay denied.

Resolving the face before its access check means an unknown id would have
surfaced as a Kysely error; NoResultError is mapped back to a 400 so it stays
indistinguishable from an unauthorized face, while real database failures
still propagate.

Ordering is load-bearing: the match job resolves the target space person from
face_identity_face, so the refresh must run after replaceFaceIdentity or the
job would re-add the old person and silently restore this bug.
…sForSpacePersonAssets (#765)

Review findings on the Slice 1 projection query:
- IMPORTANT: bring the query to guard parity with the sibling
  getSpaceRepresentativeFaceForUpdate/getSpaceRepresentativeFaces —
  add asset.isOffline, asset.visibility (visibleSpaceAssetVisibilities),
  and the direct/library/album space-scope existence OR-group (derived by
  joining shared_space_person for spaceId). Without this a stale
  shared_space_person_face row could make an offline / non-space-visible /
  removed-from-space asset's face resolvable and reassignable by an editor.
- MINOR: add a test proving the personId equality filter itself (a face
  projected onto a sibling shared_space_person in the same space must not
  leak into the source person's results), not just that the inner join is
  required.
- MINOR: bind the empty-assetIds early return to the same inferred return
  type as the query branch via an explicit method return-type annotation,
  removing the hand-duplicated inline type.

Regenerated src/queries/shared.space.repository.sql accordingly.
…trap guard (#765)

Review hardening on Slice 2's reassignSpaceFacesToTarget: pin the
identity-relink call args so a regression to the face's OLD person (or
wrong source) can't slip through green tests, switch the space-person
resolution branch onto its type discriminant instead of an id's
truthiness so a falsy resolved identity throws instead of silently
falling through to the #765 duplicate-space-person trap, and give
newly created persons a feature face at creation (matching the
create({ faceAssetId }) convention elsewhere). Strengthens the
ordering/refresh/empty-list/batch-hoist tests to actually detect
phase-batching, single-refresh-per-batch, and per-face
ensureSpacePersonIdentity regressions.
reassignSpaceFacesToTarget set faceAssetId on both create() calls but
never queued PersonGenerateThumbnail, unlike every other faceAssetId-
at-creation site in the codebase. Newly created persons rendered
avatar-less on the People page since thumbnailPath stayed ''.

Also: distinct owners in the batch-hoist test so it can't be satisfied
by resolving the identity inside the per-owner branch; a message
matcher on the falsy-identity guard's rejects.toThrow(); and
cached !== undefined instead of truthiness for the per-owner cache
lookup.
- Separate the misassigned asset's owner from the space creator (4th
  fixture user) so AC1's/AC2's ownership assertions can't pass
  vacuously when production misattributes to the space owner instead
  of the asset owner.
- Add pre-condition assertions before AC1 and the two-space reassign
  calls so the post-call empty-projection checks can't pass vacuously.
- Add a positive control on AC1's getSpacePersonFaces re-read.
- Switch AC2's owner-aligned person lookup to .execute() + length
  check so a wrongly-minted duplicate can't be masked non-
  deterministically.
- Assert AC2's synchronous eviction, symmetric with AC1/two-space.
- Pin the reap test's rejection to the "Person not found" message.
- Add Vitest assertion messages to the #765-critical expectations.
…ating #765 (#765)

Slice 4 review findings: getAllPeople was requesting withSharedSpaces
unconditionally, so a normal owned-person source also saw shared-space
candidates whose id would be sent to the personal reassignFaces branch
on selection — the same id-mismatch #765 fixed, in reverse. Scope the
flag to space-scoped sources only.

Also gate onConfirm() on reassigned > 0 in both handlers: a zero-result
reassign was still firing the caller's optimistic asset removal, so the
UI showed a danger toast and emptied the grid of photos that never
moved. Throw-path behaviour (onConfirm still fires after a caught
error) is unchanged.

Plus the review's minor fixes: give reassignInSpace's spaceRef
parameter instead of `!`-asserting the derived value inside it, fix
two fixtures that couldn't distinguish primaryProfile.id from
person.id, and tighten several test assertions (missing
reassignFaces-not-called checks, an untested handleReassign
zero-result branch, and toast-content assertions instead of
toast-was-called-at-all).
Four targeted fixes on the "Fix incorrect match" flow, from the whole-branch
review:

- The local `target` type in UnmergeFaceSelector contradicted its own generated
  DTO: string literals cannot satisfy the `Type3`/`Type4` string-enum members
  `SharedSpacePersonReassignDto['target']` uses. CI was blind to it
  (`check-svelte` runs with `--no-tsconfig`, `tsc` never reads `.svelte`), and
  runtime was unaffected since enums erase to the same strings — but the file
  disagreed with the API it calls. Now typed as the DTO's own member and
  constructed from the generated enums, like `scoped-person-ref.ts` already does.

- A thrown reassign still fired `onConfirm()`, which drives the caller's
  optimistic `timelineManager.removeAssets` — so a failure showed a danger toast
  AND vanished the photos until refresh: #765's symptom relocated. Slice 4 fixed
  this for the zero-result case only. Both catch blocks now clear `shouldConfirm`.

- `assetIds` is capped at 100 server-side while "Select all" sits on the same
  toolbar unbounded, so a >100 selection 400'd outright. The space-path helper
  now chunks into batches of <=100, issued sequentially with the server-reported
  counts summed; any chunk throwing fails the whole operation.

- "Fix incorrect match" was not gated by `canEditSpacePerson`, unlike the
  rename/birthday/merge actions, so a space viewer was offered an action that
  403s. Server-side enforcement was already correct; this is the UI affordance.
…765)

The space reassign path skipped both feature-photo repairs the global path
performs (PersonService.reassignFaces / reassignFacesById):

  (a) when the moved face WAS the source person's `faceAssetId`, that person's
      owner — often a different space member — kept seeing a face that now
      belongs to someone else as their person's avatar;
  (b) an existing-but-empty target (resolved via `getPersonByIdentity`, or a
      global `person` target) gained the face with no feature photo at all.

Nothing repairs a stale `person.faceAssetId` in the background, so both were
permanent. `reassignSpaceFacesToTarget` now collects the affected person ids
during the loop — reading the source person BEFORE `reassignFace` rewrites
`asset_face.personId`, and remembering the answer per person rather than merely
noting the lookup, so a feature face that is the batch's second face still
counts — and applies the repairs after the loop, mirroring the global path's
`createNewFeaturePhoto` (re-point + queue PersonGenerateThumbnail; a person with
no eligible face left keeps its stale pointer, same as the global path). This is
what the previously-unread `personId` on the projection query is for.

Space-side sibling: `removePersonFaceAssignmentsForSpaceFace` did not clear a
`shared_space_person.representativeFaceId` pointing at the evicted face, and
neither repair helper covers it (`repairInvalidRepresentativeFaces` only inspects
manual picks, `repairOrphanedRepresentativeFaces` only fills NULLs) — so the
source space person's avatar could keep showing the face that just left it. It
now re-picks after the delete, degrading a no-longer-valid manual pick to 'auto'
exactly as `repairInvalidRepresentativeFaces` does.
Two defects made "Fix incorrect match" unusable in a shared space even after
the endpoint itself worked:

Avatars: FaceThumbnail resolved every candidate through the owner-only
`GET /people/{id}/thumbnail`. A space person has no row in the owner-only
person table, so that endpoint 400s for it and the whole picker rendered as
"Error loading". Route through getGlobalPersonThumbnailUrl, which picks the
membership-gated `GET /shared-spaces/{spaceId}/people/{id}/thumbnail` for a
space profile and falls through to the owner-only URL for personal people.

Candidates: the picker loaded getAllPeople({ withSharedSpaces: true }), which
spans every space the viewer belongs to and collapses each identity to a
single primary profile. That offered targets the endpoint can only reject with
"Target person not found in this space", and hid the in-space profile of any
identity whose primary profile lives in another space. Ask the space for its
own people instead (getSpacePeople), shaping each into the PersonResponseDto
the picker renders with a space-scoped primaryProfile.

The personal (owned-person) path is unchanged and still sees own people only.
…mes (#765)

GET /api/people/{id} used the requested profile id only as a lookup key for the
identity, then re-picked a profile from scratch via primary_rn. For an identity
with profiles in two spaces the caller can see, that meant /people/{fv-profile}
could resolve to the Photography Club profile — and since rename, birthday,
merge and reassign all take their space from primaryProfile, every write on that
page targeted a space the caller never asked for. It was also unstable:
updatedAt DESC is one of the tiebreakers, so a rename or a projection refresh
could move the addressed space under the user, and the final tiebreak is a raw
UUID comparison.

Thread the requested profile into hydrateAccessiblePeople and rank it directly
below the caller's own person. Ordering the caller's own person first is
deliberate: an owner who deep-links a space profile of an identity they also
hold personally keeps their own page, which shows their whole library rather
than one space's slice and is the only one they can always edit.

The list paths pass no requested profile, so their ranking is unchanged — the
new branch only refines a tiebreak that was previously arbitrary.

This does not change that the person page still loads photos identity-wide
while writes stay space-scoped; reconciling those is a separate change.
@Deeds67
Deeds67 force-pushed the worktree-fix-765-space-editor-face-reassign branch from 65d1506 to c73cb82 Compare July 30, 2026 08:24
#765 replaced two owner-only upstream gates on reassignFaces /
reassignFacesById with fork helpers:

  Permission.PersonUpdate (person.checkOwnerAccess)
    -> requireReassignTargetAccess: owner, else person.checkSharedSpaceEditAccess
  Permission.PersonCreate (person.checkFaceOwnerAccess)
    -> requireReassignFaceAccess:  face owner, else Permission.AssetUpdate

Both are supersets of what they replaced: each ORs in exactly one extra
predicate, and both of those require a shared_space_member row with role
owner|editor. Record that as tests rather than as a reading of the diff.

Unit (person.service.spec.ts), against the mocked access repository: both
entry points refuse the any-role PersonRead grant on the target person and
the any-role space / partner / album grants on the face's asset, refuse an
admin at each gate, and reject a denied target before the face row is read
at all. Two bulk-path rows pin that the face gate runs once per face and
that a refusal aborts the batch (non-atomic, as upstream).

Medium (people-identity-rbac.spec.ts), against the real access repository so
the role predicates in checkSharedSpaceEditAccess / checkSpaceEditAccess are
actually exercised: space Owner and Editor may reassign a face on another
member's asset; Viewer, non-member, admin non-member and an unrelated user
may not, and nothing moves; an Editor still cannot touch a face whose asset
is outside any space they edit, including one in a different space they are
not a member of. Every allow row uses an actor who owns neither the target
person nor the asset, so none of them can pass through an owner fast path.

One row is characterisation rather than a requirement: the two gates are
evaluated independently, so an Editor of two spaces can move a face from one
onto a person they only reach through the other. The picker's same-space
scoping is client-side; making it a server invariant would now be a visible
change.

Also gates the deep-link resolution the branch added: a requested space
profile is only resolved for a caller with a membership in that profile's
space, at both the repository and the service edge.
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.

[bug] "Fix incorrect match" has no effect for non admin Editor in a Space, misassigned photos reappear immediately

1 participant