Skip to content

Scope goals to coaching relationships with many-to-many session linking - #242

Merged
jhodapp merged 15 commits into
mainfrom
goals-pr2-relationship-scoping
Mar 13, 2026
Merged

Scope goals to coaching relationships with many-to-many session linking#242
jhodapp merged 15 commits into
mainfrom
goals-pr2-relationship-scoping

Conversation

@jhodapp

@jhodapp jhodapp commented Mar 9, 2026

Copy link
Copy Markdown
Member

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).

⚠️ COORDINATED DEPLOY — This PR contains breaking API changes. The following companion frontend PR is required and both must deploy together.

Changes

  • Migration (schema): Adds coaching_relationship_id (NOT NULL, backfilled from existing session→relationship links), renames coaching_session_idcreated_in_session_id, adds optional target_date column, creates coaching_sessions_goals join table with CASCADE FKs and unique constraint
  • Migration (data): Separate data migration backfills coaching_sessions_goals join table from existing created_in_session_id links
  • Entity layer: Updated goals entity with new fields/relations, new coaching_sessions_goals join table entity, added reverse relations on coaching_sessions and coaching_relationships
  • Entity API: Updated goal CRUD for new fields, coaching_session_goal module with create/delete/find operations for the join table, switched to Entity::delete_by_id for delete operations
  • Domain layer: Simplified goal mutations to read coaching_relationship_id directly (eliminates session→relationship lookup hop), added delete with SSE event publishing, join table management hidden as implementation detail inside domain::goal (following the actions_user pattern)
  • Active goal limit: Enforces a maximum of 3 active (InProgress) goals per coaching relationship at the entity_api layer, checked on create and status transitions. Uses the generic ValidationError variant (with message + structured details payload) rather than a goal-specific error type. Mapped through EntityErrorKind::Conflict to 409 responses with active goal summaries for frontend swap dialog
  • Email helper: Updated domain/src/emails.rs to look up goals via domain::goal::find_goals_by_coaching_session_id. New get_active_goal_titles_for_coaching_session helper 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 template
  • Web layer: New DELETE /goals/:id endpoint, nested join table endpoints under /coaching_sessions/:coaching_session_id/goals (POST create link, GET list goals, DELETE unlink), GET /goals/:goal_id/sessions for reverse lookup, protect middleware authorizes via coaching_relationship_id directly
  • Coding standards: Added "Error Variant Reuse" guidance — prefer generic, reusable error variants with context fields over one-off variants per validation rule

Breaking API Changes

Before After
POST /goals body: coaching_session_id POST /goals body: coaching_relationship_id
GET /goals?coaching_session_id=UUID GET /goals?coaching_relationship_id=UUID
GET /users/:id/goals?coaching_session_id=UUID GET /users/:id/goals?coaching_relationship_id=UUID
Goal response: coaching_session_id Goal response: coaching_relationship_id, created_in_session_id, target_date
POST /coaching_session_goals body: {coaching_session_id, goal_id} POST /coaching_sessions/:coaching_session_id/goals body: {goal_id}
DELETE /coaching_session_goals/:id DELETE /coaching_sessions/:coaching_session_id/goals/:id
GET /coaching_sessions/:session_id/goals GET /coaching_sessions/:coaching_session_id/goals

Testing Strategy

  • All unit tests pass across entity_api (mock DB), domain, and web crates
  • Active goal limit validated at entity_api layer: rejects at limit, allows under limit, allows non-InProgress status at limit, rejects status transitions to InProgress at limit, allows InProgress→InProgress no-op transitions
  • Domain integration tests verify event publishing on create/update_status success
  • Migration tested locally: schema and data migrations run cleanly on fresh DB rebuild
  • Migration rollback verified: data migration recovers created_in_session_id from join table before schema migration drops it
  • cargo clippy and cargo fmt pass clean

Concerns

  • This is a coordinated deploy with the frontend — goals UI is broken until the frontend companion PR updates API calls to use new endpoints
  • Migration backfill assumes all existing goals have a valid coaching_session_id pointing to a session with a coaching_relationship_id — if any orphaned goals exist, the schema migration will fail on the NOT NULL constraint

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.
@jhodapp jhodapp self-assigned this Mar 9, 2026
@jhodapp jhodapp added the enhancement Improves existing functionality or feature label Mar 9, 2026
@jhodapp jhodapp moved this to 🏗 In progress in Refactor Coaching Platform Mar 9, 2026
@jhodapp jhodapp added this to the 1.0.0-beta3 milestone Mar 9, 2026
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.
@jhodapp
jhodapp marked this pull request as ready for review March 10, 2026 00:57
@jhodapp jhodapp moved this from 🏗 In progress to Review in Refactor Coaching Platform Mar 10, 2026
- 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.
@jhodapp
jhodapp merged commit 8e41f1b into main Mar 13, 2026
9 of 11 checks passed
@github-project-automation github-project-automation Bot moved this from Review to ✅ Done in Refactor Coaching Platform Mar 13, 2026
@github-actions

github-actions Bot commented Mar 13, 2026

Copy link
Copy Markdown
Contributor

🧹 PR Preview Environment Cleaned Up!

📊 Cleanup Summary

Resource Status
Containers ✅ Stopped and removed
PR-Specific Images (RPi5) ✅ Removed
Database Volume (RPi5) ✅ Removed
Network ✅ Removed
Compose File ✅ Deleted
Environment File ✅ Deleted
main-arm64 Image ✅ Built and pushed
PR Image (GHCR) ✅ Deleted

📝 Details

🔐 Security & Provenance

  • Image Tag: ghcr.io/refactor-group/refactor-platform-rs:main-arm64
  • Digest: sha256:18fe1ef57505...
  • Attestation: View provenance
  • Built from: main branch @ 8e41f1bd4ec9a3db29a1b86422bc6dc90546d521
  • Registry: ghcr.io

💡 Layer Caching Strategy

  • main-arm64 image now available for faster PR builds
  • Future PR builds will use main-arm64 layers as cache
  • Reduces build times and GHCR image accumulation
  • Single source of truth: main-arm64 image

Cleaned up: 2026-03-13T17:01:55.165Z
Workflow: cleanup-pr-preview.yml

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improves existing functionality or feature

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

3 participants