Scope goals to coaching relationships with many-to-many session linking - #242
Merged
Conversation
Goals are now owned by coaching relationships instead of being tied to a
single coaching session. Adds coaching_relationship_id (NOT NULL with
backfill from existing session links), renames coaching_session_id to
created_in_session_id, and introduces a coaching_sessions_goals join
table for the many-to-many relationship between sessions and goals.
New endpoints: DELETE /goals/{id}, POST/DELETE /coaching_session_goals,
GET /coaching_sessions/{id}/goals, GET /goals/{id}/sessions. Protect
middleware now authorizes via coaching_relationship_id directly. Email
helper updated to display up to 3 active goal titles per session.
GET /coaching_sessions/{id}/goals now returns full goal models
instead of join table records. This avoids requiring the frontend
to cross-reference goal IDs from the join table with a separate
goals fetch.
When a goal is created with a non-null created_in_session_id, automatically insert a coaching_sessions_goals row to link it to that session. This restores the previous single-call behavior where creating a goal from a session context associated it with that session. Extracted into link_to_originating_session() with CHANGEME markers for removal when the carry-forward workflow replaces auto-linking.
- Fix N+1 query in get_active_goal_titles_for_session by using find_goals_by_session_id (single JOIN) instead of per-link find_by_id - Add Model::is_active() on goals entity for reusable active status check - Add Model::includes_user() on coaching_relationships entity for reusable membership check with tests for both methods - Add protect::goals::by_id middleware for path-based goal authorization - Add protect::goals::by_session_id middleware for session-based auth - Wire protect middleware on goal CRUD and session-goal routes - Add AuthenticatedUser extractor to goal delete handler - Clarify migration comments on column nullability
batch_load_goals queries via created_in_session_id which depends on PR2's auto-linking. PR3 must refactor this to use the join table.
Relocate the 3-active-goal-per-relationship constraint from the domain layer into entity_api, centralizing it closer to the data operations so any code path that modifies goal status enforces the limit automatically. Key changes: - Enhance ValidationError to carry message + structured details - Add check_active_goal_limit in entity_api::goal, wired into create/update/update_status - Replace ActiveGoalLimitReached with generic EntityErrorKind::Conflict flowing through the existing error chain to 409 responses - Eliminate redundant find_by_id queries on update paths - Add coding standards guidance on error variant reuse
Clarifies that the method checks for InProgress status only, not NotStarted. Aligns the method name with the enum variant it checks, removing ambiguity about what "active" means.
…code - Wrap goal delete_by_id in a transaction to eliminate TOCTOU race - Revert ActionEmailContext.goal to &str to avoid unnecessary allocation - Remove unused coaching_session_goal::find_by_id
… goal list Replace hardcoded `.take(3)` in email goal formatting with the `max_in_progress_goals()` accessor. Add `find_in_progress_goals_by_coaching_session_id` to entity_api and domain layers so filtering and limiting happen at the data access layer. Format goal titles as an HTML ordered list for proper email rendering.
rnambaale
approved these changes
Mar 13, 2026
calebbourg
approved these changes
Mar 13, 2026
Contributor
🧹 PR Preview Environment Cleaned Up!📊 Cleanup Summary
📝 Details
🔐 Security & Provenance
💡 Layer Caching Strategy
Cleaned up: 2026-03-13T17:01:55.165Z |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Evolves goals from single-session-scoped entities (1:1 with coaching_sessions) to relationship-scoped entities with a many-to-many link to sessions via a join table. This is the second PR in the goals feature series (PR2).
Changes
coaching_relationship_id(NOT NULL, backfilled from existing session→relationship links), renamescoaching_session_id→created_in_session_id, adds optionaltarget_datecolumn, createscoaching_sessions_goalsjoin table with CASCADE FKs and unique constraintcoaching_sessions_goalsjoin table from existingcreated_in_session_idlinksgoalsentity with new fields/relations, newcoaching_sessions_goalsjoin table entity, added reverse relations oncoaching_sessionsandcoaching_relationshipscoaching_session_goalmodule with create/delete/find operations for the join table, switched toEntity::delete_by_idfor delete operationscoaching_relationship_iddirectly (eliminates session→relationship lookup hop), addeddeletewith SSE event publishing, join table management hidden as implementation detail insidedomain::goal(following theactions_userpattern)InProgress) goals per coaching relationship at theentity_apilayer, checked on create and status transitions. Uses the genericValidationErrorvariant (with message + structureddetailspayload) rather than a goal-specific error type. Mapped throughEntityErrorKind::Conflictto 409 responses with active goal summaries for frontend swap dialogdomain/src/emails.rsto look up goals viadomain::goal::find_goals_by_coaching_session_id. Newget_active_goal_titles_for_coaching_sessionhelper fetches goals linked to a session through the join table, filters to active statuses, takes up to 3, and joins their titles for the email templateDELETE /goals/:idendpoint, nested join table endpoints under/coaching_sessions/:coaching_session_id/goals(POST create link, GET list goals, DELETE unlink),GET /goals/:goal_id/sessionsfor reverse lookup, protect middleware authorizes viacoaching_relationship_iddirectlyBreaking API Changes
POST /goalsbody:coaching_session_idPOST /goalsbody:coaching_relationship_idGET /goals?coaching_session_id=UUIDGET /goals?coaching_relationship_id=UUIDGET /users/:id/goals?coaching_session_id=UUIDGET /users/:id/goals?coaching_relationship_id=UUIDcoaching_session_idcoaching_relationship_id,created_in_session_id,target_datePOST /coaching_session_goalsbody:{coaching_session_id, goal_id}POST /coaching_sessions/:coaching_session_id/goalsbody:{goal_id}DELETE /coaching_session_goals/:idDELETE /coaching_sessions/:coaching_session_id/goals/:idGET /coaching_sessions/:session_id/goalsGET /coaching_sessions/:coaching_session_id/goalsTesting Strategy
created_in_session_idfrom join table before schema migration drops itcargo clippyandcargo fmtpass cleanConcerns
coaching_session_idpointing to a session with acoaching_relationship_id— if any orphaned goals exist, the schema migration will fail on the NOT NULL constraint