Skip to content

Measurements and progress photos create a dated client timeline for coach review, comparison, and feedback.

This feature captures body metrics and standardized progress photo sets from client mobile, stores media through signed uploads, and exposes a relationship-scoped timeline to coach web and coach mobile. It is the operational source for client progress history, while ProgressAnalytics consumes the same records for charts, trend summaries, and weekly recaps.

Tags: NestJS · MinIO Signed Uploads · Drizzle + PostgreSQL · Offline Mobile Queue

V1 baseline decisions

progress tracking uses one active or onboarding coach-client relationship per client. The default catalog includes body weight, circumference, body fat percentage, lean mass, and coach-defined custom fields, but most fields are optional by coach package. Standard photo angles are front, back, and side; coaches can add custom angles, and clients must submit the complete requested set. Client archive/removal actions are not available in Public V1. Progress photos are retained for 30 days after relationship end, then purged or anonymized unless account deletion policy requires a shorter path.

Offline Sync Protocol

Progress entries and media assets own captured-date, unit, and upload validation. Offline draft queues, upload-session retry, receipts, canonical id mapping, conflict handling, and local media pruning follow the Offline Sync Protocol.

Measurement And Photo Timeline Flows

Coaches configure what to collect, clients submit dated entries, and coaches review or comment without losing the historical record.

Coach Configures Progress Tracking

  1. Coach opens a client's progress settings from the profile or timeline.
  2. Coach selects measurement definitions from the default catalog, marks which fields are required or optional for the package, selects front/back/side photo angles plus any custom extra angles, sets cadence, unit preference, and due-day reminders.
  3. API creates or replaces the active MeasurementPlan for the coach-client relationship.
  4. System generates upcoming client tasks and reminder jobs based on cadence and timezone.
  5. Coach can pause tracking without deleting historical entries.

Client Submits Measurements

  1. Client opens the progress task from Today, Progress, or a push/deep link.
  2. Client enters required values such as weight, waist, hip, chest, body fat percentage, lean mass, or custom coach-defined fields, with optional fields shown but not blocking submission.
  3. Mobile validates numeric range, unit, required fields, and captured date before syncing.
  4. API stores canonical values plus the client's display unit and source metadata.
  5. Entry appears on the client and coach timeline as SUBMITTED until reviewed or acknowledged.

Client Uploads Progress Photos

  1. Client starts a photo set for a dated check-in and captures every requested angle: front, back, side, and any coach-defined custom angles.
  2. Mobile compresses images to the V1 baseline upload profile and queues uploads when offline.
  3. API returns signed upload URLs for each expected asset and records angle, content type, byte limit, checksum, and target variant metadata.
  4. After upload, media jobs strip unsafe metadata, scan files, normalize orientation, create display variants and thumbnails, and mark assets READY.
  5. Photo set becomes visible only after every requested asset is uploaded, processed, and submitted; partial sets cannot satisfy the task.

Coach Reviews Timeline

  1. Coach opens the timeline from client profile, inbox, check-in, or progress chart.
  2. Timeline merges measurement entries, photo sets, coach reviews, check-ins, goals, and major milestones by captured date.
  3. Coach compares two photo dates side-by-side and inspects numeric changes over selected periods.
  4. Coach adds feedback, writes comparison comments, requests a correction, or marks an entry reviewed.
  5. Review events feed client notifications, weekly recaps, and ProgressAnalytics read models.

Captured progress date

A progress entry is dated by the client-captured progress date, not by upload time. Upload and processing timestamps still matter for audit, ordering conflicts, and retry behavior.

Domain Model

Measurement and photo records are client progress evidence scoped to a single active coach-client relationship, with media files handled by the shared storage layer.

Aggregates & Entities

  • MeasurementDefinition: system or workspace catalog item defining key, label, kind, canonical unit, supported units, range validation, and visibility. V1 baseline system defaults include weight, waist, hip, chest, arm, thigh, body fat percentage, lean mass, progress note, and coach custom numeric/text fields.
  • MeasurementPlan: relationship-scoped active tracking plan with cadence, required definitions, required photo angles, reminders, and pause state.
  • MeasurementPlanItem: required or optional measurement rule for a plan, including display order and min/max validation overrides.
  • MeasurementEntry: dated submission for one client and relationship, containing many measurement values and optional link to a task/check-in.
  • MeasurementValue: value object row for one definition, preserving raw submitted unit and normalized canonical value.
  • ProgressPhotoSet: dated photo submission containing angle-specific photo assets.
  • ProgressPhotoAsset: metadata for each uploaded object, including angle, storage key, processing status, thumbnail key, checksum, and dimensions.
  • ProgressEntryReview: coach feedback, comparison comment, review state, correction request, or internal-only note linked to a measurement entry or photo set.
  • ProgressTimelineEvent: read model item used to merge measurements, photos, check-ins, workouts, goals, and coach reviews.

Value Objects & Rules

  • MeasurementKind: BODY_WEIGHT, CIRCUMFERENCE, BODY_COMPOSITION, PERFORMANCE, CUSTOM.
  • MeasurementUnit: canonical unit plus display unit, with conversion rules at the application boundary.
  • PhotoAngle: FRONT, BACK, SIDE, CUSTOM, with optional coach label for custom angles.
  • ProgressEntryStatus: DRAFT, SUBMITTED, PROCESSING_MEDIA, READY_FOR_REVIEW, REVIEWED, CORRECTION_REQUESTED, ARCHIVED.
  • EntrySource: CLIENT_MOBILE, COACH_WEB, COACH_MOBILE, WEARABLE_IMPORT, MIGRATION.
  • A submitted entry cannot be edited in place after coach review; create a correction version and preserve the original.
  • One active measurement plan per relationship; old plans are retired and remain linked to historical tasks.
  • A client can have only one active or onboarding CoachClientRelationship in Public V1; progress history is not shared across concurrent coaches because concurrent coaches are not supported.
  • Photo set submission is all-or-nothing for the plan's selected angles. If a coach requests front, back, side, and one custom angle, all four assets must be ready before the set can be submitted.
  • Client-facing archive or removal requests are not available in Public V1. Corrections and privacy deletion workflows are separate server-controlled paths.
  • Do not display a photo asset until scan, metadata stripping, thumbnail generation, and permission checks have passed.
ModelOwned ByImportant MethodsEmits
MeasurementPlanCoachClientManagementcreateForRelationship(), replaceItems(), pause(), resume(), retire()MeasurementPlanConfigured, MeasurementPlanPaused
MeasurementEntryCoachClientManagementcreateDraft(), submit(), requestCorrection(), suppressForRetention()MeasurementEntrySubmitted, MeasurementCorrectionRequested
ProgressPhotoSetCoachClientManagement + Media StorageinitUpload(), attachAsset(), markAssetReady(), submit()ProgressPhotoUploadStarted, ProgressPhotoSetSubmitted
ProgressEntryReviewCoachClientManagementmarkReviewed(), addFeedback(), addComparisonComment(), requestCorrection(), hideFromClient()ProgressEntryReviewed, ClientProgressFeedbackSent

Table Structure

Store measurements as queryable normalized values. Store photos in object storage and keep permissioned metadata in PostgreSQL.

DDD boundary rule

progress evidence stores relationship, workspace, task, and user values as IDs. Measurement and photo aggregates may use those IDs for scoping and authorization checks, but should validate them through relationship/task/user policies or read models instead of importing aggregate roots from other bounded contexts.

V1 baseline Measurement Catalog

  • Default enabled fields: body weight, waist, hip, chest, arm, thigh, body fat percentage, lean mass, and progress note.
  • Coach-selectable optional fields: neck, shoulders, calf, resting heart rate, body water percentage, visceral fat rating, and custom numeric/text fields.
  • Body fat, lean mass, and custom fields are included in the first release, but they are optional unless the coach package explicitly requires them.
  • System defaults use stable keys so charts, comparisons, and analytics can query them; coach custom fields stay workspace-scoped and package-selected.

Photo Upload Profile

  • Client compresses before upload to JPEG or WebP, strips EXIF, normalizes orientation, uses 1600px max long edge, and targets 80 quality.
  • Hard max upload size is 3 MB per compressed photo; API rejects unsupported content types, uncompressed originals, and assets above the byte limit.
  • Server keeps the compressed 1600px canonical asset plus variants: 960px comparison preview, 640px detail preview, and 320px timeline thumbnail.
  • Signed read URLs are generated per variant; mobile and web should request thumbnails for timelines and 960px variants for comparison by default.
TablePurposeImportant ColumnsConstraints & Indexes
measurement_definitionsCatalog of measurement fields available to plans and entries.id, workspace_id, key, label, kind, canonical_unit, supported_units jsonb, validation_rules jsonb, default_catalog, coach_selectable, status, created_at, archived_atUnique active (workspace_id, key); allow workspace_id null for system defaults; index (kind, status).
measurement_plansActive or historical relationship-scoped progress tracking requirements.id, relationship_id, cadence, due_day, reminder_time_local, required_photo_angles jsonb, custom_photo_angles jsonb, status, configured_by_user_id, configured_at, paused_at, retired_atUnique active relationship_id where retired_at is null; index (status, due_day) for reminder generation.
measurement_plan_itemsDefinitions required or optional under a measurement plan.id, plan_id, definition_id, required, display_order, custom_label, validation_override jsonb, created_atUnique (plan_id, definition_id); order index on (plan_id, display_order).
measurement_entriesDated submission container for one set of measurement values.id, relationship_id, client_user_id, captured_on, source, status, submitted_by_user_id, submitted_at, version, supersedes_entry_id, task_id, note, archived_atIndex (relationship_id, captured_on desc); index (client_user_id, captured_on desc); prevent duplicate active client-submitted entry per relationship/date unless versioning.
measurement_valuesIndividual metric values attached to a measurement entry.id, entry_id, definition_id, raw_value, raw_unit, canonical_value, canonical_unit, confidence, created_atUnique (entry_id, definition_id); index (definition_id, canonical_value) for trend and outlier queries.
progress_photo_setsDated group of progress photos for one relationship and capture date.id, relationship_id, client_user_id, captured_on, status, expected_angles jsonb, submitted_by_user_id, submitted_at, version, supersedes_set_id, task_id, note, archived_atIndex (relationship_id, captured_on desc); one active submitted set per relationship/date/version policy; submit requires ready assets for every expected_angles entry.
progress_photo_assetsMetadata and processing state for each photo object in a set.id, photo_set_id, angle, custom_angle_label, storage_bucket, storage_key, variant_keys jsonb, thumbnail_key, content_type, byte_size, checksum_sha256, width, height, processing_profile, processing_status, uploaded_at, ready_at, deleted_atUnique active (photo_set_id, angle, custom_angle_label); index (processing_status, uploaded_at) for media jobs.
progress_entry_reviewsCoach review, feedback, and correction state for measurement entries or photo sets.id, relationship_id, target_type, target_id, review_status, feedback_visibility, feedback_body, comparison_comment_body, timeline_visible, reviewed_by_user_id, reviewed_at, created_atIndex (relationship_id, created_at desc); index (target_type, target_id); internal-only feedback excluded from client reads.
progress_timeline_eventsMaterialized read model for timeline rendering across progress evidence and reviews.id, relationship_id, event_type, event_id, occurred_on, summary jsonb, visibility, created_at, invalidated_atUnique active (event_type, event_id); index (relationship_id, occurred_on desc); rebuildable from source tables.

Lifecycle Policy

Progress evidence is sensitive, longitudinal client data. Preserve review history while supporting corrections, retention cleanup, and privacy purge.

Entry Lifecycle

  • MeasurementPlan changes create a new active plan and retire the previous plan; historical entries keep their original plan/task context.
  • Measurement entries and photo sets can start as DRAFT on mobile, but the server primarily stores submitted or processing records.
  • Corrections create a new version linked by supersedes_entry_id or supersedes_set_id; reviewed originals remain read-only.
  • Client-facing archive and removal-request actions are not available in Public V1. Clients can submit corrections only when the coach requests them.
  • Server-side privacy, account deletion, or retention workflows may suppress entries with archived_at and hide them from timelines unless privileged history is requested.
  • Timeline events are derived and rebuildable; never treat them as the legal source of measurement or photo truth.

Photo And Retention Lifecycle

  • Photo assets are private by default and require signed read URLs generated at request time.
  • Failed uploads expire through a cleanup job that deletes unused objects and marks assets EXPIRED.
  • Rejected, archived, or superseded photos keep metadata for audit but hide object reads from normal clients.
  • After a relationship ends, progress photo objects, thumbnails, and variants remain read-only for authorized coaches for 30 days, then are purged or anonymized from normal access.
  • Account deletion and privacy export flows must include measurements, photo metadata, and eligible photo objects.
  • Hard purge deletes object storage keys, thumbnails, derived variants, and database rows according to retention policy.

Progress photo access

Progress photos should never be sent through generic message attachment URLs. Use a purpose-specific photo asset policy so progress visibility, retention, exports, and timeline comparisons stay controllable.

API Contracts

Coach APIs are relationship-scoped. Client APIs return only the active client's own progress requirements and submissions.

EndpointUse CaseRequestResponse
GET /api/v1/coach/clients/{relationshipId}/progress/timelineCoach loads dated progress timeline and review state.Coach bearer token, date range, filters such as measurements, photos, reviews, cursor pagination.Timeline events, measurement summaries, photo thumbnails as signed read descriptors, review state, and next cursor.
PUT /api/v1/coach/clients/{relationshipId}/measurement-planCoach configures required measurements, photo angles, cadence, and reminders.cadence, dueDay, reminderTimeLocal, measurementItems, photoAngles, timezone.Active plan, retired plan reference, generated task/reminder summary, and client-visible requirements.
GET /api/v1/client/progress/requirementsClient mobile loads active progress task requirements.Client bearer token. V1 baseline resolves the single active or onboarding relationship; any supplied relationship id must match that relationship.Measurement plan, required fields, unit preferences, photo angles, due state, and latest submitted dates.
POST /api/v1/client/progress/measurement-entriesClient submits a dated measurement entry.relationshipId, capturedOn, values, note, taskId, idempotency key.Created entry, normalized values, validation warnings, updated timeline event, and review status.
POST /api/v1/client/progress/photo-sets/init-uploadClient creates a photo set draft and obtains signed upload URLs.relationshipId, capturedOn, complete requested asset list with angle, optional custom label, contentType, compressed byteSize, dimensions, and checksum.Photo set id, asset ids, signed upload URLs, required headers, expiration timestamp, 3 MB max byte rule, 1600px long-edge target, and expected server variants.
POST /api/v1/client/progress/photo-sets/{photoSetId}/submitClient marks uploaded photo set ready for server processing and review.Asset ids, checksums, optional note, idempotency key.Photo set status, processing state per asset, timeline placeholder, and retryable errors if any requested assets are missing or not ready.
POST /api/v1/coach/clients/{relationshipId}/progress/reviewsCoach reviews an entry or requests correction.targetType, targetId, reviewStatus, feedbackVisibility, feedbackBody, optional comparisonCommentBody, optional follow-up task.Review record, updated target status, client notification side effect, and timeline event when feedback or comparison comment is client-visible.
GET /api/v1/coach/clients/{relationshipId}/progress/photo-sets/{photoSetId}/compareCoach or client loads two signed photo sets for visual comparison.Date or photo set ids, angle filters, target surface for image size.Signed read descriptors for approved compressed variants, thumbnails, dimensions, capture dates, and missing angle notices for historical sets.

Key Code Snippets

These snippets sketch the planned NestJS, Drizzle, and policy boundaries. Media object bytes live outside PostgreSQL; metadata and permissions stay inside the app database.

Drizzle schema shape

apps/api/src/coach-client-management/db/progress.schema.ts

ts
export const measurementEntries = pgTable("measurement_entries", {
  id: uuid("id").primaryKey().defaultRandom(),
  // External identity references. Do not import/navigate relationship or user aggregates here.
  relationshipId: uuid("relationship_id").notNull(),
  clientUserId: uuid("client_user_id").notNull(),
  capturedOn: date("captured_on").notNull(),
  source: text("source").notNull(),
  status: text("status").notNull().default("SUBMITTED"),
  submittedByUserId: uuid("submitted_by_user_id").notNull(),
  submittedAt: timestamp("submitted_at", { withTimezone: true }).notNull().defaultNow(),
  version: integer("version").notNull().default(1),
  supersedesEntryId: uuid("supersedes_entry_id"),
  taskId: uuid("task_id"),
  note: text("note"),
  archivedAt: timestamp("archived_at", { withTimezone: true }),
}, (table) => ({
  relationshipCapturedIdx: index("measurement_entries_relationship_captured_idx")
    .on(table.relationshipId, table.capturedOn),
  clientCapturedIdx: index("measurement_entries_client_captured_idx")
    .on(table.clientUserId, table.capturedOn),
}));

export const measurementValues = pgTable("measurement_values", {
  id: uuid("id").primaryKey().defaultRandom(),
  entryId: uuid("entry_id").notNull().references(() => measurementEntries.id),
  definitionId: uuid("definition_id").notNull().references(() => measurementDefinitions.id),
  rawValue: numeric("raw_value").notNull(),
  rawUnit: text("raw_unit").notNull(),
  canonicalValue: numeric("canonical_value").notNull(),
  canonicalUnit: text("canonical_unit").notNull(),
  confidence: numeric("confidence"),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
  entryDefinitionUnique: uniqueIndex("measurement_values_entry_definition_uq")
    .on(table.entryId, table.definitionId),
}));

export const progressPhotoAssets = pgTable("progress_photo_assets", {
  id: uuid("id").primaryKey().defaultRandom(),
  photoSetId: uuid("photo_set_id").notNull().references(() => progressPhotoSets.id),
  angle: text("angle").notNull(),
  customAngleLabel: text("custom_angle_label"),
  storageBucket: text("storage_bucket").notNull(),
  storageKey: text("storage_key").notNull(),
  variantKeys: jsonb("variant_keys").$type<Record<"preview960" | "preview640" | "thumb320", string>>(),
  thumbnailKey: text("thumbnail_key"),
  contentType: text("content_type").notNull(),
  byteSize: integer("byte_size").notNull(),
  checksumSha256: text("checksum_sha256").notNull(),
  width: integer("width"),
  height: integer("height"),
  processingProfile: text("processing_profile").notNull().default("PROGRESS_PHOTO_V1 baseline_1600"),
  processingStatus: text("processing_status").notNull().default("WAITING_UPLOAD"),
  uploadedAt: timestamp("uploaded_at", { withTimezone: true }),
  readyAt: timestamp("ready_at", { withTimezone: true }),
  deletedAt: timestamp("deleted_at", { withTimezone: true }),
}, (table) => ({
  processingIdx: index("progress_photo_assets_processing_idx")
    .on(table.processingStatus, table.uploadedAt),
}));

Submit measurements use case

apps/api/src/coach-client-management/use-cases/submit-measurement-entry.ts

ts
@Injectable()
export class SubmitMeasurementEntryUseCase {
  constructor(
    private readonly relationships: CoachClientRelationshipRepository,
    private readonly plans: MeasurementPlanRepository,
    private readonly entries: MeasurementEntryRepository,
    private readonly definitions: MeasurementDefinitionRepository,
    private readonly unitConverter: MeasurementUnitConverter,
    private readonly policy: ProgressPolicy,
    private readonly tx: TransactionRunner,
    private readonly clock: Clock,
  ) {}

  async execute(input: SubmitMeasurementEntryInput, actor: Actor): Promise<MeasurementEntryDto> {
    return this.tx.run(async () => {
      const relationship = await this.relationships.lockById(input.relationshipId);
      await this.policy.assertCanSubmitProgress(actor, relationship);

      const plan = await this.plans.findActiveByRelationship(relationship.id);
      const definitions = await this.definitions.findByIds(input.values.map((value) => value.definitionId));

      const values = input.values.map((value) => {
        const definition = definitions.require(value.definitionId);
        plan.assertAllowsDefinition(definition.id);
        return MeasurementValue.create({
          definitionId: definition.id,
          rawValue: value.value,
          rawUnit: value.unit,
          canonical: this.unitConverter.toCanonical(definition, value.value, value.unit),
        });
      });

      const entry = MeasurementEntry.submit({
        relationshipId: relationship.id,
        clientUserId: relationship.clientUserId,
        capturedOn: input.capturedOn,
        source: actor.progressSource(),
        submittedByUserId: actor.userId,
        values,
        note: input.note,
        taskId: input.taskId,
        now: this.clock.now(),
      });

      plan.assertRequiredValuesPresent(entry);
      await this.entries.save(entry);
      await this.entries.saveEventsToOutbox(entry);
      return MeasurementEntryDto.from(entry);
    });
  }
}

Photo upload initialization

apps/api/src/coach-client-management/use-cases/init-progress-photo-upload.ts

ts
@Injectable()
export class InitProgressPhotoUploadUseCase {
  constructor(
    private readonly relationships: CoachClientRelationshipRepository,
    private readonly photoSets: ProgressPhotoSetRepository,
    private readonly plans: MeasurementPlanRepository,
    private readonly media: MediaStorageGateway,
    private readonly policy: ProgressPolicy,
    private readonly tx: TransactionRunner,
  ) {}

  async execute(input: InitProgressPhotoUploadInput, actor: Actor): Promise<PhotoUploadSessionDto> {
    return this.tx.run(async () => {
      const relationship = await this.relationships.lockById(input.relationshipId);
      await this.policy.assertCanSubmitProgress(actor, relationship);

      const plan = await this.plans.findActiveByRelationship(relationship.id);
      plan.assertCompletePhotoSet(input.assets.map((asset) => ({
        angle: asset.angle,
        customAngleLabel: asset.customAngleLabel,
      })));

      const photoSet = ProgressPhotoSet.createDraft({
        relationshipId: relationship.id,
        clientUserId: relationship.clientUserId,
        capturedOn: input.capturedOn,
        submittedByUserId: actor.userId,
      });

      const uploadTargets = await Promise.all(input.assets.map(async (asset) => {
        photoSet.addExpectedAsset(asset);
        return this.media.createSignedUpload({
          purpose: "PROGRESS_PHOTO",
          processingProfile: "PROGRESS_PHOTO_V1 baseline_1600",
          contentType: asset.contentType,
          byteSize: asset.byteSize,
          maxByteSize: 3_000_000,
          maxLongEdgePx: 1600,
          checksumSha256: asset.checksumSha256,
          ownerUserId: relationship.clientUserId,
        });
      }));

      await this.photoSets.save(photoSet);
      return PhotoUploadSessionDto.from(photoSet, uploadTargets);
    });
  }
}

Progress read policy

apps/api/src/coach-client-management/policies/progress.policy.ts

ts
export class ProgressPolicy {
  assertCanReadTimeline(actor: Actor, relationship: CoachClientRelationship): void {
    if (actor.isClient(relationship.clientUserId)) return;
    actor.requireRole("COACH");
    actor.requireWorkspaceMembership(relationship.workspaceId, ["OWNER", "COACH"]);
    actor.requirePermission("client.progress.read");
    relationship.assertVisibleToCoach(actor.userId);
  }

  assertCanSubmitProgress(actor: Actor, relationship: CoachClientRelationship): void {
    if (actor.isClient(relationship.clientUserId)) return;
    actor.requireRole("COACH");
    actor.requirePermission("client.progress.write_on_behalf");
    relationship.assertActiveOrOnboarding();
  }

  assertCanReviewProgress(actor: Actor, relationship: CoachClientRelationship): void {
    actor.requireRole("COACH");
    actor.requireWorkspaceMembership(relationship.workspaceId, ["OWNER", "COACH"]);
    actor.requirePermission("client.progress.review");
    relationship.assertActiveOrOnboarding();
  }

  filterTimelineForClient(timeline: ProgressTimelineDto): ProgressTimelineDto {
    return timeline.withoutInternalReviews().withoutArchivedEvents().withoutUnsignedMedia();
  }
}

Events & Background Jobs

Progress records feed reminders, coach inbox tasks, media processing, analytics rollups, and weekly recap generation.

Domain / Integration Events

  • MeasurementPlanConfigured: update client requirements, reminder schedules, and task generation.
  • MeasurementEntrySubmitted: create timeline event, notify coach if review is required, update chart read models.
  • ProgressPhotoUploadStarted: track pending media and signed URL expiration.
  • ProgressPhotoAssetReady: update photo set completeness, variant keys, and thumbnail availability.
  • ProgressPhotoSetSubmitted: create timeline event and coach review task.
  • ProgressEntryReviewed: notify client when feedback or comparison comment is client-visible and update timeline.
  • MeasurementCorrectionRequested: create client follow-up task and mark target as needing correction.

Jobs

  • Generate recurring measurement/photo tasks from active measurement plans.
  • Send reminders before and after due dates, respecting client timezone and notification preferences.
  • Process photo uploads: validate checksum, strip metadata, scan, normalize orientation, enforce 1600px compressed canonical profile, create 960px, 640px, and 320px variants, and mark assets ready.
  • Expire unused signed upload sessions and delete orphaned object keys.
  • Rebuild timeline events after corrections, client-visible review comments, retention cleanup, or import backfills.
  • Roll up measurement trends for ProgressAnalytics and weekly recap drafts.
  • Purge or anonymize progress photo objects and variants after the 30-day ended-relationship retention window.
  • Purge suppressed photos and measurements during account deletion/privacy workflows.

Permissions & Access Rules

Measurements and body photos are highly sensitive. Access must be relationship-scoped, purpose-specific, and careful about cached media.

  • Clients can create and read their own progress submissions while the relationship is active or onboarding. They cannot archive reviewed entries or request removal in Public V1.
  • V1 baseline allows one active or onboarding CoachClientRelationship per client, so progress photos and measurement history are not shared across concurrent coaches.
  • Coaches can read progress only through an active, onboarding, or 30-day retained historical relationship in their workspace.
  • Coach write-on-behalf requires client.progress.write_on_behalf and must attribute the submitting user.
  • Coach reviews require client.progress.review; internal review notes are never returned to client endpoints, but client-visible feedback and comparison comments can appear in the progress timeline.
  • Signed photo read URLs must be short-lived, generated per request, and scoped to the requested asset size or variant.
  • Ended relationships are read-only for 30 days, then hidden from normal coach access after retention cleanup.
  • Offline mobile caches must not persist signed URLs longer than their expiration and should store photos only in the app's protected storage while queued.

Web And Mobile Client Behavior

The same progress state appears differently by surface: dense coach review, quick mobile coaching, and low-friction client capture.

Coach Web

  • Client progress tab shows measurement charts, photo timeline, review queue, plan settings, and comparison tools.
  • Timeline filters by measurements, photos, coach feedback, check-ins, goals, workouts, and date range.
  • Photo comparison supports two dates, matching angles, missing-angle notices, and signed compressed variant reads.
  • Coach can configure plan cadence, required and optional measurements, front/back/side photo angles, custom extra angles, and reminder copy.
  • Coach can review, comment, request correction, add timeline-visible comparison comments, or link progress evidence to a check-in recap.

Coach Mobile

  • Progress review card appears in the coach inbox and client profile summary.
  • Support quick swipe between latest photo angles and recent numeric changes.
  • Allow concise feedback, correction request, and reviewed state without exposing full desktop configuration complexity.
  • Cache timeline summaries only; fetch signed media URLs on demand.

Client Mobile

  • Progress task is optimized for fast entry: date, required fields, optional coach-selected fields, unit picker, optional note, and complete photo angle checklist.
  • Support offline drafts and upload queue with retry, idempotency keys, and visible processing state.
  • Use camera/gallery permissions only at capture time and explain missing permission recovery through native permission flows.
  • Show submitted history, coach feedback, timeline-visible comparison comments, correction requests, and date-based photo comparison where permitted.
  • Do not show archive or removal-request actions for reviewed progress entries in Public V1.
  • Respect locale and unit preferences while sending canonical-compatible payloads to the API.

Test Scenarios & V1 Baseline Decisions

Tests should cover unit normalization, complete photo sets, media lifecycle, permission boundaries, offline retry, retention cleanup, and timeline ordering by captured date.

Required Tests

  • Domain: measurement plan requires configured definitions and photo angles before task satisfaction.
  • Domain: body fat percentage, lean mass, and coach custom fields can be included in a plan as optional or required items.
  • Domain: photo set submission fails unless every requested standard and custom angle is uploaded and ready.
  • Domain: submitted measurement values are normalized to canonical units and preserve raw display units.
  • Domain: reviewed measurement entries and photo sets cannot be edited in place; corrections create new versions.
  • API integration: client archive and removal-request actions are unavailable for reviewed entries.
  • API integration: idempotent measurement submission does not duplicate active entries for the same client/date/key.
  • API integration: signed upload session rejects unsupported content type, photos over 3 MB after compression, images over the 1600px long-edge profile, and checksum mismatch.
  • Media job: uploaded photo is scanned, metadata-stripped, orientation-normalized, resized into 960px, 640px, and 320px variants, and marked ready.
  • Timeline: events sort by captured_on and use upload time only as a tie breaker.
  • Timeline: client-visible coach comparison comments appear as progress timeline events.
  • Permission: coach from another workspace cannot read timeline events or signed photo URLs.
  • Permission: client endpoint excludes internal coach review notes and archived assets.
  • Permission: accepting or creating progress for a second active coach relationship fails in Public V1.
  • Retention: ended relationships keep progress photos read-only for 30 days, then purge or anonymize objects and variants from normal access.
  • Mobile E2E: offline measurement and photo draft syncs after reconnect and shows processing state.
  • Analytics contract: measurement and photo events publish enough data for charts and weekly recaps without exposing private media URLs.

Resolved V1 Baseline Decisions

  • Measurement catalog: V1 baseline includes body weight, key circumferences, body fat percentage, lean mass, progress note, and custom fields. Body fat, lean mass, and custom fields are coach-optional unless a package requires them.
  • Photo angles: standard angles are front, back, and side. Coaches can add custom angles, and clients cannot submit partial sets.
  • Client removal controls: clients cannot archive reviewed entries or request coach/admin removal in Public V1.
  • Photo retention: progress photo objects and variants remain available read-only for 30 days after relationship end, then are purged or anonymized from normal access.
  • Coach comments: client-visible comparison comments can become progress timeline events.
  • Multiple coaches: not supported in Public V1; progress history belongs to the single active or onboarding relationship.
  • Image handling: mobile compresses to JPEG/WebP, strips EXIF, uses 1600px max long edge at about 80 quality, and API enforces a 3 MB hard max. Server stores 960px, 640px, and 320px variants for comparison, detail, and timeline views.

CoachMe internal planning documentation.