Skip to content

Latest commit

 

History

History
252 lines (221 loc) · 18.9 KB

flow.md

File metadata and controls

252 lines (221 loc) · 18.9 KB

Progress Flow

The progress feature tracks a per-child star count and unlocked milestones, earned as a side effect of completing tasks in the Calendars feature. A guardian configures the ordered list of goal posts (threshold, icon, optional label) a child progresses through — see Configurable goal posts for progress — and a per-child reward catalog the child spends stars on. The child asks for a reward and a guardian approves or declines; only an approval spends stars, and lifetime stars (which drive the goal posts) never drop — see Reward redemption for progress. The only way a ChildProgress aggregate changes from stars is an internal call from SetTaskCompletionHandler, made after that handler's own event has already been appended; goal posts, rewards and requests change directly, via the endpoints below.

sequenceDiagram
    actor Child
    actor Guardian
    participant App as Client app
    participant API as Buddy API
    participant Calendars as Calendars feature
    participant Progress as Progress feature
    participant Store as Progress event store

    Child->>App: Mark a task complete
    App->>API: PATCH /calendars/{calendarId}/items/{itemId}[/subtasks/{subtaskId}]/completion
    API->>Calendars: SetTaskCompletion command
    Calendars->>Calendars: Append TaskCompletionChanged
    alt Item has an assignee
        Calendars->>Progress: RecordStarChange (internal, via IMessageBus)
        Progress->>Store: Read child's stream, rehydrate ChildProgress
        Store-->>Progress: Existing events, or none
        Progress->>Store: Append StarAwarded/StarRevoked, maybe MilestoneUnlocked
        Progress-->>Calendars: Success, or a swallowed failure
    end
    Calendars-->>API: Updated CalendarItem
    API-->>App: 200 OK

    Child->>App: View their own progress
    App->>API: GET /progress/me
    API->>Progress: GetMyProgress query
    Progress->>Store: Read caller's stream and rehydrate
    Store-->>Progress: ChildProgress, or none
    Progress-->>API: ProgressSummary
    API-->>App: 200 OK

    Guardian->>App: View a child's progress
    App->>API: GET /progress/children/{childId}
    API->>Progress: GetChildProgress query
    Progress->>Progress: Check self or active guardian
    Progress->>Store: Read child's stream and rehydrate
    Store-->>Progress: ChildProgress, or none
    Progress-->>API: ProgressSummary
    API-->>App: 200 OK

    Guardian->>App: Configure a child's goal posts
    App->>API: PUT /progress/children/{childId}/goals
    API->>Progress: ConfigureGoalPosts command
    Progress->>Progress: Check active guardian (Manage tier)
    Progress->>Store: Read child's stream, rehydrate ChildProgress
    Store-->>Progress: Existing events, or none
    Progress->>Store: Append GoalPostsConfigured (creating the stream first, if needed)
    Progress-->>API: ProgressSummary
    API-->>App: 200 OK
Loading

Reward redemption

sequenceDiagram
    actor Child
    actor Guardian
    participant API as Buddy API
    participant Progress as Progress feature
    participant Store as Progress event store

    Guardian->>API: PUT /progress/children/{childId}/rewards
    API->>Progress: ConfigureRewards (Manage)
    Progress->>Store: Append RewardsConfigured (full replace, stable reward ids)

    Child->>API: POST /progress/me/reward-requests { rewardId }
    API->>Progress: RequestReward (the caller's own stream only)
    Progress->>Store: Read and rehydrate ChildProgress
    alt cost <= spendable stars, under 10 pending
        Progress->>Store: Append RewardRequested (reserves the cost)
        Progress-->>API: 200 ProgressSummary
    else
        Progress-->>API: 409 insufficient_stars / too_many_pending_requests
    end

    Guardian->>API: POST /progress/children/{childId}/reward-requests/{requestId}/approve
    API->>Progress: ApproveRewardRequest (Manage)
    Progress->>Store: Read and rehydrate ChildProgress
    alt still pending and cost <= TotalStars - SpentStars
        Progress->>Store: Append RewardRequestApproved (SpentStars += cost)
    else
        Progress-->>API: 409 insufficient_stars / reward_request_resolved
    end
Loading

Declining (/decline, guardian) and cancelling (/progress/me/reward-requests/{requestId}/cancel, the child) follow the approve shape without the star check, and release the reservation.

Endpoints

Method Route Behavior
GET /progress/me Returns the caller's own star count, unlocked milestones, and resolved goal-post info. Self-only: it always resolves the child from the caller's own claims.
GET /progress/children/{childId} Returns one named child's star count, unlocked milestones, and resolved goal-post info. Read-only guardian-facing view; see "Authorization model" below.
PUT /progress/children/{childId}/goals Guardian-only. Replaces the child's full ordered list of goal posts (Threshold, Icon, optional Label). Full-replace, not a partial update; see "Authorization model" below.
PUT /progress/children/{childId}/rewards Guardian-only. Replaces the child's reward catalog (Name, Icon, Cost, and the Id of a reward being kept). An unknown Id is 400.
POST /progress/me/reward-requests The caller asks for one of their own rewards. 409 insufficient_stars past the spendable balance, 409 too_many_pending_requests at 10 pending, 404 for a reward not in the catalog.
POST /progress/me/reward-requests/{requestId}/cancel The caller withdraws their own pending request.
POST /progress/children/{childId}/reward-requests/{requestId}/approve Guardian-only. Spends the request's cost; 409 insufficient_stars if revoked stars left too few.
POST /progress/children/{childId}/reward-requests/{requestId}/decline Guardian-only. Releases the reservation.

Every route returns the updated ProgressSummary, which also carries SpendableStars, SpentStars, the catalog and the reward requests (pending first, then the 20 most recently resolved). Repeating the same resolution is an idempotent 200; resolving an already-resolved request differently is 409 reward_request_resolved.

RecordStarChange (see "Core lifecycle") is still an internal command, not an HTTP endpoint — a star is never awarded or revoked by a client calling Progress directly.

Validation

Every rule the endpoints enforce, validator rules first, then the handler's own checks. A 400 validation_error carries the message in the ErrorEnvelope's details under the key in the Field column, "" for a check that isn't tied to one field (see HTTP status codes). A body that can't be bound is rejected before these rules run, with the same envelope.

Endpoint Field Rule Code Status
ConfigureGoalPosts goalPosts Required: at least one goal post. validation_error 400
ConfigureGoalPosts goalPosts At most 20 goal posts. validation_error 400
ConfigureGoalPosts goalPosts Thresholds strictly increasing, in list order (no duplicates). validation_error 400
ConfigureGoalPosts goalPosts[n].threshold Greater than 0. validation_error 400
ConfigureGoalPosts goalPosts[n].icon Required: not empty after trimming. At most 32 characters after trimming. validation_error 400
ConfigureGoalPosts goalPosts[n].label At most 100 characters after trimming. Optional (null or blank means none). validation_error 400
ConfigureGoalPosts "" No null entries in goalPosts (checked by the endpoint, before the validator). validation_error 400
ConfigureRewards rewards At most 30 rewards. An empty list is allowed (removes every reward). validation_error 400
ConfigureRewards rewards Each Id at most once (rows without an Id are new rewards). validation_error 400
ConfigureRewards rewards[n].name Required: not empty after trimming. At most 100 characters after trimming. validation_error 400
ConfigureRewards rewards[n].icon Required: not empty after trimming. At most 32 characters after trimming. validation_error 400
ConfigureRewards rewards[n].cost Between 1 and 10,000 inclusive. validation_error 400
ConfigureRewards "" No null entries in rewards (checked by the endpoint, before the validator). validation_error 400
ConfigureRewards rewards[n].id A kept reward's Id must be in the child's current catalog. Checked after authorization; the first unknown one is reported. validation_error 400
RequestReward — No rules beyond binding the body (rewardId). A reward not in the caller's catalog is 404. validation_error 400
RequestReward — At most 10 pending requests per child. Checked before the star balance. too_many_pending_requests 409
RequestReward — The reward's cost must not exceed the spendable stars (lifetime stars minus spent stars minus pending reservations). insufficient_stars 409
CancelRewardRequest, ApproveRewardRequest, DeclineRewardRequest — The request must still be pending; repeating the same resolution is an idempotent 200, a different one is rejected. reward_request_resolved 409
ApproveRewardRequest — The request's cost must not exceed lifetime stars minus spent stars (a revoked star may have eaten into the reservation). insufficient_stars 409

Core lifecycle

RecordStarChange is not an HTTP endpoint. It is an internal command, invoked via IMessageBus.InvokeAsync from SetTaskCompletionHandler in the Calendars feature, after TaskCompletionChanged has already been appended to the calendar item's own stream, and only when the item has an assignee (item.AssignedTo). The call is wrapped in a try/catch that deliberately swallows a failure: the task completion itself has already succeeded and is not rolled back, so a failed star update just leaves the child's count stale until the next successful, idempotent completion change catches it up. This mirrors the two-write, not-a-transaction shape the GuardianLink decision already established for cross-feature calls elsewhere in this codebase.

ChildProgress is an event-sourced aggregate with one stream per child, keyed by ProgressId.Value == ChildId.Value. Unlike most aggregates in this codebase, no index document maps a domain ID to a child — the relationship is already 1:1, so the child's own UserId is reused directly as the stream ID. The stream is created lazily on the first star (or the first goal-post configuration, if that happens before any star does), not when the child account is provisioned. It contains:

  • ProgressStarted, appended once, establishing the aggregate and its child.
  • StarAwarded, appended on a false→true completion transition, keyed by an OccurrenceKey(ItemId, OccurrenceDate, Target), where Target is a CompletionTarget: the whole task, or one subtask.
  • StarRevoked, appended on a true→false transition for the same key, mirroring TaskCompletionChanged's own Before/After toggle semantics rather than a separate "undo" event.
  • MilestoneUnlocked, appended when TotalStars newly reaches a threshold resolved by GoalPostResolver — the child's own configured goal posts if any exist, extrapolated indefinitely past the last one, or else the original fixed [5, 10, 25, 50, 100] scale — checked at write time.
  • GoalPostsConfigured, appended by the guardian-only ConfigureGoalPosts command, carrying the complete replacement list of goal posts (full replace, not a partial update — see configurable-goal-posts.md).
  • RewardsConfigured, the guardian's full-replace reward catalog.
  • RewardRequested, the child's request, with the reward's name, icon and cost copied at request time; pending requests reserve their cost.
  • RewardRequestApproved (adds the cost to SpentStars), RewardRequestDeclined and RewardRequestCancelled, each resolving one pending request.

AwardedOccurrences is a sparse set of OccurrenceKey (item, occurrence date, and completion target), so a plain task and each independently-completable subtask of a template-scheduled task earn and revoke their own star. Re-awarding an already-awarded key, or re-revoking a key that isn't currently awarded, is a no-op: RecordStarChangeHandler compares command.IsCompleted against whether the occurrence is already in AwardedOccurrences before appending anything, the same before/after guard SetTaskCompletionHandler already applies to TaskCompletionChanged.

Authorization model

All endpoints require an authenticated caller. ProgressAuthorization resolves a two-tier access level, mirroring MedicineAuthorization's Mark/Manage split:

  • View — the child identified by childId (or, for /progress/me, the caller themself) can always view their own progress; an active guardian of that child can also view it.
  • Manage — only an active guardian can configure goal posts and rewards and approve or decline requests. A child attempting any of these on themself has View but not Manage, so the endpoint returns 403 Forbidden rather than 404.
  • Self — requesting and cancelling go through /progress/me, so they only ever reach the caller's own stream; no childId is accepted.

A caller with no relationship to the child at all (neither View nor Manage) receives 404 Not Found, collapsing "no such child" and "not your child" the same way other domains in this codebase already do. A child or guardian with no progress stream yet (nothing completed, no goal posts configured) receives 200 OK with zero stars and no milestones, never 404 Not Found — an empty progress history is not an error.

Key event types

  • ProgressStarted — starts a child's stream on their first completion or first goal-post configuration.
  • StarAwarded — one star for one (ItemId, OccurrenceDate, Target).
  • StarRevoked — removes the star for that same key.
  • MilestoneUnlocked — records a goal-post threshold newly crossed.
  • GoalPostsConfigured — guardian-authored, full-replace list of goal posts (Threshold, Icon, optional Label).
  • RewardsConfigured — guardian-authored, full-replace reward catalog.
  • RewardRequested / RewardRequestApproved / RewardRequestDeclined / RewardRequestCancelled — a reward request and how it was resolved.

See Gamified progress for children's tasks for the design rationale behind the dedicated aggregate, the explicit synchronous call instead of a projection, and the 1:1 stream-ID shortcut, and Configurable goal posts for progress for the guardian-write goal-post design covered above, and Reward redemption for progress for the reward catalog and requests. Dose gamification, manual star adjustment, and sibling comparisons remain out of scope.