Body Metrics — Behavior
State machine
Body metrics are single-shot records, not stateful. The lifecycle is:
create → (optional patch) → (optional soft-delete)No publish, no review, no audit phases. Each row is a snapshot at recorded_at.
Invariants
| Invariant | Enforcement |
|---|---|
One row per (membershipId, metricType, recordedAt, customLabel) — duplicates of “weight on 2026-05-28” collapse | DB unique index body_metrics_unique_idx using COALESCE(custom_label, '') |
metric_type='custom' requires a custom_label (semantic — service-layer enforcement TBD) | Service layer / DTO |
unit matches the metric type (weight → kg/lb; body_fat → %) | DTO validator + enum constraints |
| Member can see only their own metrics; staff can see members in their org | requireAccess helper in BodyMetricsService |
Golden paths
Member self-reports weight
- Member opens the mobile body-metrics screen.
- Picks “Weight”, enters value, optional
recorded_at(defaults to today). POST /organizations/:orgId/body-metrics/self→ row inserted withrecorded_by=user.id.- UI optimistically updates the latest card and trend chart.
Coach records body fat on member’s profile
- Coach navigates to the member detail page → Metrics tab.
- Clicks ”+ Add measurement”, picks
body_fat, enters value. POST /organizations/:orgId/members/:membershipId/metricswithrecorded_by=coach.user.id.
Member edits an old entry
- Open the entry in the trend chart drill-down.
PATCH /members/:membershipId/metrics/:idwith new value.
Soft delete
DELETEsetsdeleted_at; all reads filter ondeleted_at IS NULL.
Edge cases
| Scenario | Behavior |
|---|---|
| Two members of the same org weigh in on the same day | Two rows, distinct membership_ids — unique constraint scopes to membership |
| Member adds “weight” twice on the same day | Second one trips the unique index — 409 or 500; UI should PATCH instead |
| Unit changed between measurements (kg → lb) | Allowed at the row level; chart UI must convert client-side |
custom metric with empty custom_label | DB unique treats it as ”; collides with another empty custom on the same day. Should be DTO-blocked |
| Coach views a member they’re not assigned to | requireAccess only checks org membership and isStaffRole — any staff can view any member’s metrics |
Side effects
| Action | Writes |
|---|---|
| Create | body_metrics insert |
| Update | body_metrics update |
| Soft delete | body_metrics update (deleted_at) |
| Settings update | body_metric_settings upsert (one row per membership, enabled_metrics JSONB array) |
No events emitted.
Permissions
| Action | Auth |
|---|---|
| Self-report | Active org membership; recorded_by pinned to caller |
| Staff record on member | Staff role + same-org as the target membership |
| List / view | Self or staff |
| Update / delete | Self or staff |