Skip to content

fix(adhoc): assignee photos never reached the server - #1177

Merged
renemadsen merged 4 commits into
masterfrom
fix/adhoc-assignee-photo-update-and-stale-drawer
Sep 4, 2026
Merged

fix(adhoc): assignee photos never reached the server#1177
renemadsen merged 4 commits into
masterfrom
fix/adhoc-assignee-photo-update-and-stale-drawer

Conversation

@renemadsen

Copy link
Copy Markdown
Member

The bug

Pictures taken by the worker a task is assigned to never appeared on web — only the creator's original pictures did.

They never reached the server at all. RequireCreator rejected every UpdateTask from an assigned-but-not-creating worker, including the no-op the mobile client sends when that worker adds a photo. The mobile client classifies PermissionDenied as permanent and parks the outbox row, and its per-task head-of-line rule then blocks everything queued behind it for that task — the photo upload itself, and that worker's later comments, permanently. Nothing in the app can move a row out of failed, so it never recovered.

The irony: SavePhoto is already CanSee-gated, so the assignee was always authorised to upload. They just never got there, because the no-op update queued in front of it was rejected.

What this changes

1. UpdateTask accepts and ignores a CanSee-eligible non-creator (BackendConfigurationAdhocService.cs). No field is applied and PhotoIds is deliberately not passed to ReconcilePhotosAsync — that reconciles by omission, so honouring a stale client's list would wipe the creator's photos. A non-creator who fails CanSee is still rejected exactly as before.

Ignore semantics, not a field diff, for two reasons: the client builds its request from a local cache that may legitimately lag a concurrent edit, so a diff would reject exactly the offline saves this exists to accept; and the request has no change on the wire to detect anyway (the new photo is still pendingUpload and is filtered out before sending).

Security: the new gate is a strict subset of what RequireCreator already rejected, and its only non-throw exit is a read-only MapToModelAsync. It can only ever convert a reject into a read, never into a write. The web dashboard is statically excluded — AdhocController passes DashboardHasFullAccess = true as a const, so it never enters the branch.

2. The Overblik drawer refetches the task before opening (adhoc-container.component.ts). It was opened with the row cached from the last index call and submits that snapshot's photoIds back on save, where ReconcilePhotosAsync soft-deletes by omission — so saving any edit silently wiped photos added on mobile since page load. Mirrors the existing AdhocHistoryComponent.onRowClick pattern. onCopyTask is untouched: its result is already a fresh server response.

Known limitation, deliberately not fixed here

The refetch narrows the data-loss window (from "since the last index page load", potentially hours since Overblik does not poll, to "since the drawer opened") — it does not close it. visiblePhotos reads the drawer-open snapshot, so a photo uploaded from a phone while the drawer sits open is still soft-deleted on save. The mobile creator has the same shape.

The durable fix is to stop reconciling by omission — send removedPhotoIds (the drawer already tracks them) or version-gate the update. That is a design decision and is left for a follow-up ticket.

Testing

  • Adhoc integration suite: 153 passed, 0 failed (151 baseline + 2 new).
  • Angular backend-configuration-pn: 28 suites, 417 tests, all passed.
  • New coverage includes the actual reported production scenario (a web-created task with CreatedByWorkerId = 0 and an assigned worker) and Everyone-rule tasks, which are the widest new acceptance surface. Both assert the persisted DB row, not just the returned model.
  • The pre-existing UpdateTask_Throws_WhenCallerIsNotCreator is kept unmodified and still passes — it now pins "a non-creator who cannot see the task is still rejected".
  • A reviewer mutation-tested the Angular spec by reverting the fix: 3 of 4 tests failed, so it genuinely pins the behaviour.

Merge order

Merge and release this before the flutter-adhoc counterpart. The mobile PR replays previously-parked updates; against an unfixed server those are denied again and re-park, wasting the recovery on every affected device.

Design: docs/superpowers/specs/2026-09-04-assignee-photos-not-visible-on-web-design.md in the flutter-adhoc repo (§2 and §4).

🤖 Generated with Claude Code

renemadsen and others added 4 commits September 4, 2026 08:08
RequireCreator rejected every UpdateTask from an assigned-but-not-creating
worker, including the no-op the mobile client sends when that worker adds a
photo. The client treats PermissionDenied as permanent, parks the outbox row,
and its per-task head-of-line rule then blocks the photo upload queued behind
it forever - so assignee photos never reached the server at all, and the
worker's later comments on that task were silently dropped too.

A CanSee-eligible non-creator is now accepted and ignored: no field is
applied and PhotoIds is not reconciled (it soft-deletes by omission, so a
stale client could otherwise wipe the creator's photos). Ignore rather than
diff, because the client builds its request from a cache that may lag a
concurrent edit and a diff would reject exactly the offline saves this
exists to accept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPgYKU2f7RQ7HNmCZqF43g
The drawer was opened with the row object cached from the last index call
and submits that snapshot's photoIds back on save, where
ReconcilePhotosAsync soft-deletes by omission - so saving any edit silently
wiped every photo added on mobile since the page was loaded. Latent while
assignee photos never arrived; live as soon as they do.

Fetches by id first, mirroring AdhocHistoryComponent.onRowClick. onCopyTask
is untouched: its result is already the copy endpoint's fresh response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPgYKU2f7RQ7HNmCZqF43g
getTaskSub$ was shared by both onViewTask and onEditTask. View row A, then
Edit row B before A's HTTP call resolves, and B's assignment drops the
reference to A's Subscription. AutoUnsubscribe only walks fields still
referenced on the instance, so navigating away before A resolves leaves
A's request in flight; its callback then fires after destruction and pops
the drawer on an unrelated page.

Split into viewTaskSub$ and editTaskSub$, one per call site, so
AutoUnsubscribe cancels both on destroy with no further wiring. Not a
subscription leak - getTask() is a plain apiBaseService.get() that
self-completes after one emission - just a dropped reference defeating the
existing cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPgYKU2f7RQ7HNmCZqF43g
…pt-and-ignore path

- Add the two unpinned UpdateTask acceptance cases: a web-created task
  (CreatedByWorkerId == 0) edited by its assigned gRPC worker, and a
  non-assigned worker with property access on an Everyone-rule task.
- Reword the Overblik drawer refetch comment: it narrows the photo
  data-loss window, it does not close it, since visiblePhotos still
  reads the drawer-open-time task snapshot.
- Log the accept-and-ignore outcome so it's distinguishable from a real
  update if the mobile UI ever widens what it lets an assignee send.
- Note that RequireCreator's worker-0 guard is now only reached for
  web-created tasks, and rename callerAssignedWorkerIds (holds the
  task's, not the caller's, assignees) to taskAssignedWorkerIds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPgYKU2f7RQ7HNmCZqF43g
@renemadsen
renemadsen merged commit 81b3b69 into master Sep 4, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant