critical priority medium complexity backend pending backend specialist Tier 1

Acceptance Criteria

The saveContact method accepts the full form data and a reference to the current contact state (or the original contact snapshot)
A diff is computed by comparing form data against the original contact field-by-field; only fields whose values have changed are included in the update payload
If the diff is empty (no changes), the method emits ContactEditSuccess with the unchanged contact immediately — no network call is made
The diff payload is submitted to the repository using PATCH semantics (partial update), not a full PUT replacement
ContactEditSaving state is emitted immediately when saveContact is called
On a successful repository response, ContactEditSuccess is emitted carrying the server-confirmed Contact object
If ContactFormValidator returns validation errors, ContactEditError(validationFailed) is emitted with per-field errors before any network call is made
On a network error, ContactEditError(networkFailure) is emitted after exhausting retries (consistent with ContactDetailService retry policy)
On a 403, ContactEditError(permissionDenied) is emitted immediately without retry
Type coercion is handled correctly: string form values are converted to the correct Dart types before diff comparison (e.g., date strings to DateTime, number strings to int)

Technical Requirements

frameworks
Flutter
BLoC (flutter_bloc)
Riverpod (flutter_riverpod)
apis
Supabase REST API (PATCH /contacts/{id})
data models
Contact
ContactFormValidator
performance requirements
Diff computation must be O(n) in the number of contact fields — no nested loops or full serialization round-trips
Empty diff detection must prevent unnecessary network calls entirely
security requirements
Diff must never include fields the current user does not have write permission for — validate writable fields against the org config or role permissions before building the diff
Partial update payload must be sent over HTTPS with the authenticated Supabase session token
Encrypted fields must not be included in the diff unless the user has explicitly provided a new value via the secure input widget

Execution Context

Execution Tier
Tier 1

Tier 1 - 540 tasks

Can start after Tier 0 completes

Implementation Notes

Implement the diff as a pure function computeDiff(Contact original, Map formData) → Map that can be tested in isolation. Use field-level comparison by iterating over formData keys and checking equality against the corresponding field on the original Contact using toJson(). Handle type normalization before comparison — convert formData values to the same type as the Contact model field (e.g., parse date strings). The repository's patch method should accept Map and construct the Supabase .update(payload).eq('id', id) call.

Do not pass the contactId as part of the diff payload. Reuse the same retryAsync utility from task-004 for wrapping the repository call. After a successful PATCH, use the repository's returned data (Supabase returns the updated row) to construct the ContactEditSuccess state — do not reconstruct it from local form data, as server-side triggers may modify additional fields.

Testing Requirements

Unit tests using flutter_test with mocked repository and validator: (1) Verify that when only 'phone' changes, the PATCH payload contains only {phone: newValue} and not other fields. (2) Verify that when form data equals original contact data, no network call is made and ContactEditSuccess is emitted directly. (3) Verify ContactFormValidator is called before any network call and that validation errors short-circuit to ContactEditError(validationFailed) with per-field errors. (4) Verify ContactEditSaving is emitted before the repository call resolves.

(5) Verify ContactEditSuccess carries the server-returned Contact, not the locally-computed one. (6) Verify type coercion: a date string '1990-05-15' is correctly compared against a DateTime field and included in diff only when changed. (7) Verify permissionDenied and networkFailure error mappings.

Component
Contact Edit Service
service medium
Epic Risks (2)
medium impact medium prob technical

Parallel fetching of profile, activity history, and assignment status from contact-detail-service may produce race conditions where partial state is emitted to the UI before all fetches complete, resulting in flickering or incorrect loading indicators.

Mitigation & Contingency

Mitigation: Use Future.wait or a single composed BLoC event that only emits a loaded state once all three futures resolve. Define a strict state machine: initial → loading → loaded/error with no intermediate partial-loaded states emitted to the UI.

Contingency: If parallelism proves unreliable in testing, fall back to sequential fetching with a combined loading indicator. The 500ms target may need to be renegotiated with stakeholders if sequential fetching exceeds it on slow connections.

high impact low prob integration

The partial-field update pattern in contact-edit-service assumes the contact record has not changed between when the edit screen was loaded and when the save is submitted. Concurrent edits by another coordinator could cause the earlier editor's save to silently overwrite the later one.

Mitigation & Contingency

Mitigation: Include an updated_at timestamp in the PATCH request and configure Supabase to reject updates where the server-side timestamp differs from the client's version. Return a 409-equivalent error that the service maps to a user-readable conflict message.

Contingency: If optimistic locking is too complex for initial delivery, implement a simple 'reload and retry' flow: on save error, reload the contact detail and prompt the coordinator to re-apply their changes manually.