diff --git a/__tests__/components/ui/coaching-session-selector.test.tsx b/__tests__/components/ui/coaching-session-selector.test.tsx index 5f3e2ae2..76c9bce5 100644 --- a/__tests__/components/ui/coaching-session-selector.test.tsx +++ b/__tests__/components/ui/coaching-session-selector.test.tsx @@ -28,7 +28,7 @@ vi.mock('@/lib/hooks/use-current-coaching-session', () => ({ })) vi.mock('@/lib/api/goals', () => ({ - useGoalBySession: vi.fn(() => ({ + useGoalByRelationship: vi.fn(() => ({ goal: { title: 'Test Goal' }, isLoading: false, isError: false, diff --git a/__tests__/lib/api/goals.test.ts b/__tests__/lib/api/goals.test.ts new file mode 100644 index 00000000..8753e56d --- /dev/null +++ b/__tests__/lib/api/goals.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { GoalApi, useGoalList, useGoalsBySession } from '@/lib/api/goals' +import { EntityApi } from '@/lib/api/entity-api' +import { renderHook } from '@testing-library/react' +import { TestProviders } from '@/test-utils/providers' + +// Mock EntityApi +vi.mock('@/lib/api/entity-api', () => ({ + EntityApi: { + listFn: vi.fn(), + listNestedFn: vi.fn(), + getFn: vi.fn(), + createFn: vi.fn(), + updateFn: vi.fn(), + deleteFn: vi.fn(), + useEntityList: vi.fn(), + useEntityMutation: vi.fn(), + }, +})) + +// Mock site config to provide a proper base URL +vi.mock('@/site.config', () => ({ + siteConfig: { + env: { + backendServiceURL: 'http://localhost:3000', + }, + }, +})) + +describe('GoalApi.list', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sends coaching_relationship_id as query param', async () => { + vi.mocked(EntityApi.listFn).mockResolvedValue([]) + + await GoalApi.list('rel-123') + + expect(EntityApi.listFn).toHaveBeenCalledWith('http://localhost:3000/goals', { + params: { + coaching_relationship_id: 'rel-123', + }, + }) + }) + + it('does not send coaching_session_id', async () => { + vi.mocked(EntityApi.listFn).mockResolvedValue([]) + + await GoalApi.list('rel-123') + + const callArgs = vi.mocked(EntityApi.listFn).mock.calls[0] + expect(callArgs[1].params).not.toHaveProperty('coaching_session_id') + }) + + it('includes sort params when provided', async () => { + vi.mocked(EntityApi.listFn).mockResolvedValue([]) + + await GoalApi.list('rel-123', 'title', 'asc') + + expect(EntityApi.listFn).toHaveBeenCalledWith('http://localhost:3000/goals', { + params: { + coaching_relationship_id: 'rel-123', + sort_by: 'title', + sort_order: 'asc', + }, + }) + }) + + it('omits sort params when not provided', async () => { + vi.mocked(EntityApi.listFn).mockResolvedValue([]) + + await GoalApi.list('rel-123') + + const callArgs = vi.mocked(EntityApi.listFn).mock.calls[0] + expect(callArgs[1].params).not.toHaveProperty('sort_by') + expect(callArgs[1].params).not.toHaveProperty('sort_order') + }) +}) + +describe('useGoalList hook', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('passes coaching_relationship_id to EntityApi.useEntityList', () => { + const mockReturn = { + entities: [], + isLoading: false, + isError: false, + refresh: vi.fn(), + } + + vi.mocked(EntityApi.useEntityList).mockReturnValue(mockReturn) + + renderHook( + () => useGoalList('rel-456'), + { wrapper: TestProviders } + ) + + expect(EntityApi.useEntityList).toHaveBeenCalledWith( + 'http://localhost:3000/goals', + expect.any(Function), + 'rel-456' + ) + }) + + it('passes null to EntityApi.useEntityList when relationship ID is null', () => { + const mockReturn = { + entities: [], + isLoading: false, + isError: false, + refresh: vi.fn(), + } + + vi.mocked(EntityApi.useEntityList).mockReturnValue(mockReturn) + + renderHook( + () => useGoalList(null), + { wrapper: TestProviders } + ) + + expect(EntityApi.useEntityList).toHaveBeenCalledWith( + 'http://localhost:3000/goals', + expect.any(Function), + null + ) + }) +}) + +describe('GoalApi.listNested', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetches goals nested under the coaching session endpoint', async () => { + vi.mocked(EntityApi.listNestedFn).mockResolvedValue([]) + + await GoalApi.listNested('session-123') + + expect(EntityApi.listNestedFn).toHaveBeenCalledWith( + 'http://localhost:3000/coaching_sessions', + 'session-123', + 'goals', + {} + ) + }) +}) + +describe('useGoalsBySession hook', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('passes session-scoped URL to EntityApi.useEntityList', () => { + const mockReturn = { + entities: [], + isLoading: false, + isError: false, + refresh: vi.fn(), + } + + vi.mocked(EntityApi.useEntityList).mockReturnValue(mockReturn) + + renderHook( + () => useGoalsBySession('session-789'), + { wrapper: TestProviders } + ) + + expect(EntityApi.useEntityList).toHaveBeenCalledWith( + 'http://localhost:3000/coaching_sessions/session-789/goals', + expect.any(Function), + 'session-789' + ) + }) + + it('returns goals array from entities', () => { + const mockGoals = [ + { id: 'goal-1', title: 'Test Goal' }, + { id: 'goal-2', title: 'Another Goal' }, + ] + + vi.mocked(EntityApi.useEntityList).mockReturnValue({ + entities: mockGoals as any, + isLoading: false, + isError: false, + refresh: vi.fn(), + }) + + const { result } = renderHook( + () => useGoalsBySession('session-789'), + { wrapper: TestProviders } + ) + + expect(result.current.goals).toEqual(mockGoals) + expect(result.current.isLoading).toBe(false) + }) +}) diff --git a/__tests__/test-utils.ts b/__tests__/test-utils.ts index 980e94d0..f432c7c7 100644 --- a/__tests__/test-utils.ts +++ b/__tests__/test-utils.ts @@ -1,6 +1,8 @@ import { DateTime } from "ts-luxon"; import { CoachingSession } from "@/types/coaching-session"; import { CoachingRelationshipWithUserNames } from "@/types/coaching_relationship"; +import { Goal } from "@/types/goal"; +import { ItemStatus } from "@/types/general"; import { Organization } from "@/types/organization"; import { User } from "@/types/user"; import { OAuthConnection } from "@/types/oauth-connection"; @@ -84,6 +86,25 @@ export function createSessionAt(minutesFromNow: number): CoachingSession { }); } +export function createMockGoal(overrides?: Partial): Goal { + const now = DateTime.now(); + return { + id: "goal-1", + coaching_relationship_id: "rel-1", + created_in_session_id: "session-1", + user_id: "user-1", + title: "Improve communication", + body: "Work on active listening", + status: ItemStatus.NotStarted, + status_changed_at: now, + completed_at: now, + target_date: null, + created_at: now, + updated_at: now, + ...overrides, + }; +} + export function createMockGoogleOAuthConnectionState( overrides?: Partial ): OAuthConnection { diff --git a/__tests__/types/goal.test.ts b/__tests__/types/goal.test.ts new file mode 100644 index 00000000..98720cbe --- /dev/null +++ b/__tests__/types/goal.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from 'vitest' +import { DateTime } from 'ts-luxon' +import { ItemStatus } from '@/types/general' +import { + isGoal, + isGoalArray, + parseGoal, + defaultGoal, + defaultGoals, + getGoalById, + goalToString, + goalsToString, +} from '@/types/goal' +import type { Goal } from '@/types/goal' + +/** Factory for creating test Goal data matching the PR2 schema */ +function makeGoalData(overrides?: Partial>): Record { + return { + id: 'goal-1', + coaching_relationship_id: 'rel-1', + created_in_session_id: 'session-1', + user_id: 'user-1', + title: 'Improve communication', + body: 'Work on active listening', + status: 'NotStarted', + status_changed_at: '2026-03-01T00:00:00Z', + completed_at: '2026-03-10T00:00:00Z', + target_date: '2026-06-15', + created_at: '2026-03-01T00:00:00Z', + updated_at: '2026-03-01T00:00:00Z', + ...overrides, + } +} + +describe('isGoal', () => { + it('returns true for a valid goal with all required fields', () => { + expect(isGoal(makeGoalData())).toBe(true) + }) + + it('returns true when created_in_session_id is null', () => { + expect(isGoal(makeGoalData({ created_in_session_id: null }))).toBe(true) + }) + + it('returns true when target_date is null', () => { + expect(isGoal(makeGoalData({ target_date: null }))).toBe(true) + }) + + it('returns false for null', () => { + expect(isGoal(null)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(isGoal(undefined)).toBe(false) + }) + + it('returns false for a non-object', () => { + expect(isGoal('not a goal')).toBe(false) + }) + + it('returns false when id is missing', () => { + const { id: _, ...rest } = makeGoalData() + expect(isGoal(rest)).toBe(false) + }) + + it('returns false when coaching_relationship_id is missing', () => { + const { coaching_relationship_id: _, ...rest } = makeGoalData() + expect(isGoal(rest)).toBe(false) + }) + + it('returns false when user_id is missing', () => { + const { user_id: _, ...rest } = makeGoalData() + expect(isGoal(rest)).toBe(false) + }) + + it('does not require the old coaching_session_id field', () => { + // PR2 removed coaching_session_id — goals should validate without it + const data = makeGoalData() + expect(data).not.toHaveProperty('coaching_session_id') + expect(isGoal(data)).toBe(true) + }) +}) + +describe('isGoalArray', () => { + it('returns true for an array of valid goals', () => { + expect(isGoalArray([makeGoalData(), makeGoalData({ id: 'goal-2' })])).toBe(true) + }) + + it('returns true for an empty array', () => { + expect(isGoalArray([])).toBe(true) + }) + + it('returns false for a non-array', () => { + expect(isGoalArray(makeGoalData())).toBe(false) + }) +}) + +describe('parseGoal', () => { + it('parses valid goal data into a Goal object', () => { + const data = makeGoalData() + const goal = parseGoal(data) + + expect(goal.id).toBe('goal-1') + expect(goal.coaching_relationship_id).toBe('rel-1') + expect(goal.created_in_session_id).toBe('session-1') + expect(goal.user_id).toBe('user-1') + expect(goal.title).toBe('Improve communication') + expect(goal.body).toBe('Work on active listening') + expect(goal.status).toBe('NotStarted') + expect(goal.target_date).toBe('2026-06-15') + }) + + it('parses goal with null created_in_session_id', () => { + const goal = parseGoal(makeGoalData({ created_in_session_id: null })) + expect(goal.created_in_session_id).toBeNull() + }) + + it('parses goal with null target_date', () => { + const goal = parseGoal(makeGoalData({ target_date: null })) + expect(goal.target_date).toBeNull() + }) + + it('throws on invalid data', () => { + expect(() => parseGoal({ id: 123 })).toThrow() + }) +}) + +describe('defaultGoal', () => { + it('returns a goal with the PR2 field shape', () => { + const goal = defaultGoal() + + expect(goal).toHaveProperty('id') + expect(goal).toHaveProperty('coaching_relationship_id') + expect(goal).toHaveProperty('created_in_session_id') + expect(goal).toHaveProperty('target_date') + expect(goal).toHaveProperty('user_id') + expect(goal).toHaveProperty('title') + expect(goal).toHaveProperty('body') + expect(goal).toHaveProperty('status') + }) + + it('does not have the old coaching_session_id field', () => { + const goal = defaultGoal() + expect(goal).not.toHaveProperty('coaching_session_id') + }) + + it('has null for nullable fields', () => { + const goal = defaultGoal() + expect(goal.created_in_session_id).toBeNull() + expect(goal.target_date).toBeNull() + }) + + it('has NotStarted status', () => { + const goal = defaultGoal() + expect(goal.status).toBe(ItemStatus.NotStarted) + }) +}) + +describe('defaultGoals', () => { + it('returns an array with one default goal', () => { + const goals = defaultGoals() + expect(goals).toHaveLength(1) + expect(goals[0]).toHaveProperty('coaching_relationship_id') + }) +}) + +describe('getGoalById', () => { + it('returns the matching goal', () => { + const now = DateTime.now() + const goals: Goal[] = [ + { ...defaultGoal(), id: 'a', coaching_relationship_id: 'rel-1', created_at: now, updated_at: now }, + { ...defaultGoal(), id: 'b', coaching_relationship_id: 'rel-1', created_at: now, updated_at: now }, + ] + expect(getGoalById('b', goals).id).toBe('b') + }) + + it('returns a default goal when not found', () => { + const goal = getGoalById('nonexistent', []) + expect(goal.id).toBe('') + expect(goal).toHaveProperty('coaching_relationship_id') + }) +}) + +describe('goalToString / goalsToString', () => { + it('serializes a goal to JSON', () => { + const goal = defaultGoal() + const json = goalToString(goal) + expect(json).toContain('coaching_relationship_id') + expect(json).not.toContain('coaching_session_id') + }) + + it('serializes undefined to "undefined"', () => { + expect(goalToString(undefined)).toBe(undefined) + }) + + it('serializes a goal array to JSON', () => { + const json = goalsToString([defaultGoal()]) + expect(json).toContain('coaching_relationship_id') + }) +}) diff --git a/src/components/ui/coaching-session-selector.tsx b/src/components/ui/coaching-session-selector.tsx index 12fb8170..ed7d644d 100644 --- a/src/components/ui/coaching-session-selector.tsx +++ b/src/components/ui/coaching-session-selector.tsx @@ -17,7 +17,8 @@ import { useEnrichedCoachingSessionsForUser, CoachingSessionInclude, } from "@/lib/api/coaching-sessions"; -import { useGoalBySession } from "@/lib/api/goals"; +import { useGoalByRelationship } from "@/lib/api/goals"; +import { DEFAULT_GOAL_TITLE, goalTitle } from "@/types/goal"; import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session"; import { DateTime } from "ts-luxon"; import type { EnrichedCoachingSession } from "@/types/coaching-session"; @@ -143,7 +144,7 @@ function SessionItem({

- {session.goal?.title || "No goal set"} + {session.goal ? goalTitle(session.goal) : DEFAULT_GOAL_TITLE}

{formatDateInUserTimezone( @@ -172,7 +173,7 @@ export default function CoachingSessionSelector({ const { userSession } = useAuthStore((state) => state); const { goal, isLoading: isLoadingGoal } = - useGoalBySession(currentCoachingSessionId || ""); + useGoalByRelationship(relationshipId); const handleSetCoachingSession = (coachingSessionId: Id) => { // Navigate to the coaching session page @@ -199,7 +200,7 @@ export default function CoachingSessionSelector({ Loading goal... ) : ( - goal?.title || "No goal set" + goalTitle(goal) )} diff --git a/src/components/ui/coaching-session.tsx b/src/components/ui/coaching-session.tsx index 0a1dc8f2..05e8fcc5 100644 --- a/src/components/ui/coaching-session.tsx +++ b/src/components/ui/coaching-session.tsx @@ -4,7 +4,8 @@ import React from "react"; import { Card, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import Link from "next/link"; -import { useGoalBySession } from "@/lib/api/goals"; +import { useGoalsBySession } from "@/lib/api/goals"; +import { DEFAULT_GOAL_TITLE, goalTitle } from "@/types/goal"; import { Id } from "@/types/general"; import { DropdownMenu, @@ -101,19 +102,21 @@ const SessionGoal: React.FC = ({ coachingSessionId, }) => { const { - goal, - isLoading: isLoadingGoal, - isError: isErrorGoal, - } = useGoalBySession(coachingSessionId); + goals, + isLoading: isLoadingGoals, + isError: isErrorGoals, + } = useGoalsBySession(coachingSessionId); let titleText: string; - if (isLoadingGoal) { + if (isLoadingGoals) { titleText = "Loading..."; - } else if (isErrorGoal) { + } else if (isErrorGoals) { titleText = "Error loading goal"; } else { - titleText = goal?.title || "No goal set"; + titleText = goals.length > 0 + ? goalTitle(goals[0]) + : DEFAULT_GOAL_TITLE; } return

{titleText}
; diff --git a/src/components/ui/coaching-sessions/goal-container.tsx b/src/components/ui/coaching-sessions/goal-container.tsx index 51609d36..ab1faf6b 100644 --- a/src/components/ui/coaching-sessions/goal-container.tsx +++ b/src/components/ui/coaching-sessions/goal-container.tsx @@ -6,46 +6,60 @@ import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { GoalComponent } from "./goal"; import { - useGoalBySession, + useGoalsBySession, useGoalMutation, } from "@/lib/api/goals"; -import { Goal } from "@/types/goal"; +import { + defaultGoal, + Goal, + extractActiveGoalLimitError, +} from "@/types/goal"; +import { Id } from "@/types/general"; import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session"; +import { useCurrentCoachingRelationship } from "@/lib/hooks/use-current-coaching-relationship"; +import { toast } from "@/components/ui/use-toast"; -const GoalContainer: React.FC = () => { - const [isOpen, setIsOpen] = useState(false); +interface GoalContainerInnerProps { + coachingSessionId: Id; + coachingRelationshipId: Id; +} - // Get coaching session ID from URL - const { currentCoachingSessionId } = useCurrentCoachingSession(); +const GoalContainerInner: React.FC = ({ + coachingSessionId, + coachingRelationshipId, +}) => { + const [isOpen, setIsOpen] = useState(false); - const { goal, refresh } = - useGoalBySession(currentCoachingSessionId || ""); + const { goals, refresh } = + useGoalsBySession(coachingSessionId); + const goal = goals.length > 0 ? goals[0] : defaultGoal(); const { create: createGoal, update: updateGoal } = useGoalMutation(); - const handleGoalChange = async (newGoal: Goal) => { + const handleGoalChange = async (currentGoal: Goal, newGoal: Goal) => { try { - if (currentCoachingSessionId) { - if (goal.id) { - await updateGoal( - goal.id, - newGoal - ); - } else if (!goal.id) { - newGoal.coaching_session_id = currentCoachingSessionId; - await createGoal(newGoal); - - // Manually trigger a local refresh of the cached Goal data such that - // any other local code using the KeyedMutator will also update with this new data. - refresh(); - } + if (currentGoal.id) { + await updateGoal(currentGoal.id, newGoal); } else { - console.error( - "Could not update or create a Goal since coachingSessionId or userId are not set." - ); + newGoal.coaching_relationship_id = coachingRelationshipId; + newGoal.created_in_session_id = coachingSessionId; + await createGoal(newGoal); + + // Manually trigger a local refresh of the cached Goal data such that + // any other local code using the KeyedMutator will also update with this new data. + refresh(); } } catch (err) { - console.error("Failed to update or create Goal: " + err); + const limitInfo = extractActiveGoalLimitError(err); + if (limitInfo) { + toast({ + variant: "destructive", + title: "Goal limit reached", + description: `You already have ${limitInfo.maxActiveGoals} goals in progress for this coaching relationship. Please complete or change the status of one before starting another.`, + }); + } else { + console.error("Failed to update or create Goal: " + err); + } } }; @@ -60,7 +74,7 @@ const GoalContainer: React.FC = () => { setIsOpen(open)} - onGoalChange={(g: Goal) => handleGoalChange(g)} + onGoalChange={(g: Goal) => handleGoalChange(goal, g)} >
@@ -89,4 +103,21 @@ const GoalContainer: React.FC = () => { ); }; +const GoalContainer: React.FC = () => { + const { currentCoachingSessionId } = useCurrentCoachingSession(); + const { currentCoachingRelationshipId } = useCurrentCoachingRelationship(); + + // Guard: only render when both session and relationship IDs are available + if (!currentCoachingSessionId || !currentCoachingRelationshipId) { + return null; + } + + return ( + + ); +}; + export { GoalContainer }; diff --git a/src/components/ui/coaching-sessions/goal.tsx b/src/components/ui/coaching-sessions/goal.tsx index 53e64374..113d93f6 100644 --- a/src/components/ui/coaching-sessions/goal.tsx +++ b/src/components/ui/coaching-sessions/goal.tsx @@ -14,6 +14,7 @@ import { cn } from "@/components/lib/utils"; import { defaultGoal, Goal, + goalTitle, } from "@/types/goal"; const GoalComponent: React.FC<{ @@ -77,7 +78,7 @@ const GoalComponent: React.FC<{ onClick={(e) => e.stopPropagation()} > Goal: - {goal.title} + {goalTitle(goal)}
)} diff --git a/src/components/ui/dashboard/today-session-card.tsx b/src/components/ui/dashboard/today-session-card.tsx index df924853..def4d4bb 100644 --- a/src/components/ui/dashboard/today-session-card.tsx +++ b/src/components/ui/dashboard/today-session-card.tsx @@ -13,6 +13,7 @@ import { copyCoachingSessionLinkWithToast } from "@/components/ui/share-session- import { cn } from "@/components/lib/utils"; import { PulsingDot } from "@/components/ui/pulsing-dot"; import { SessionUrgency } from "@/types/session-display"; +import { DEFAULT_GOAL_TITLE, goalTitle } from "@/types/goal"; import { RelationshipRole } from "@/types/relationship-role"; import type { AssignedActionWithContext } from "@/types/assigned-actions"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; @@ -209,7 +210,7 @@ export function TodaySessionCard({ timezone ); - const goalText = session.goal?.title || "No goal set"; + const goalText = session.goal ? goalTitle(session.goal) : DEFAULT_GOAL_TITLE; const organizationName = session.organization?.name || "Unknown organization"; /** diff --git a/src/components/ui/join-session-popover.tsx b/src/components/ui/join-session-popover.tsx index 0d287f29..5b9854ee 100644 --- a/src/components/ui/join-session-popover.tsx +++ b/src/components/ui/join-session-popover.tsx @@ -17,6 +17,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { DEFAULT_GOAL_TITLE, goalTitle } from "@/types/goal"; import { Select, SelectContent, @@ -364,7 +365,7 @@ function SessionGroup({ className="flex flex-col items-start w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground" > - {session.goal?.title || "No goal set"} + {session.goal ? goalTitle(session.goal) : DEFAULT_GOAL_TITLE} {formatDateInUserTimezone(session.date, timezone)} diff --git a/src/lib/api/goals.ts b/src/lib/api/goals.ts index a9367453..2eda5bf0 100644 --- a/src/lib/api/goals.ts +++ b/src/lib/api/goals.ts @@ -10,6 +10,7 @@ import { ApiSortOrder, GoalSortField } from "@/types/sorting"; import { EntityApi } from "./entity-api"; const GOALS_BASEURL: string = `${siteConfig.env.backendServiceURL}/goals`; +const COACHING_SESSIONS_BASEURL: string = `${siteConfig.env.backendServiceURL}/coaching_sessions`; /** * API client for goal-related operations. @@ -19,20 +20,20 @@ const GOALS_BASEURL: string = `${siteConfig.env.backendServiceURL}/goals`; */ export const GoalApi = { /* - * Fetches a list of goals associated with a specific coaching session. + * Fetches a list of goals associated with a specific coaching relationship. * - * @param coachingSessionId The ID of the coaching session whose goals should be retrieved + * @param coachingRelationshipId The ID of the coaching relationship whose goals should be retrieved * @param sortBy Optional field to sort by. * @param sortOrder Optional sort order. * @returns Promise resolving to an array of Goal objects */ list: async ( - coachingSessionId: Id, + coachingRelationshipId: Id, sortBy?: GoalSortField, sortOrder?: ApiSortOrder ): Promise => { const params: Record = { - coaching_session_id: coachingSessionId, + coaching_relationship_id: coachingRelationshipId, }; if (sortBy) { @@ -45,6 +46,22 @@ export const GoalApi = { return EntityApi.listFn(GOALS_BASEURL, { params }); }, + /** + * Fetches goals nested under a coaching session via the join table. + * Uses GET /coaching_sessions/{session_id}/goals which returns full Goal models. + * + * @param coachingSessionId The ID of the parent coaching session + * @returns Promise resolving to an array of Goal objects linked to the session + */ + listNested: async (coachingSessionId: Id): Promise => { + return EntityApi.listNestedFn( + COACHING_SESSIONS_BASEURL, + coachingSessionId, + 'goals', + {} + ); + }, + /** * Fetches a single goal by its ID. * @@ -116,12 +133,12 @@ export const GoalApi = { }; /** - * A custom React hook that fetches a list of goals for a specific coaching session. + * A custom React hook that fetches a list of goals for a specific coaching relationship. * * This hook uses SWR to efficiently fetch, cache, and revalidate goal data. * It automatically refreshes data when the component mounts. * - * @param coachingSessionId The ID of the coaching session whose goals should be fetched + * @param coachingRelationshipId The ID of the coaching relationship whose goals should be fetched * @returns An object containing: * * * goals: Array of Goal objects (empty array if data is not yet loaded) @@ -129,12 +146,13 @@ export const GoalApi = { * * isError: Error object if the fetch operation failed, undefined otherwise * * refresh: Function to manually trigger a refresh of the data */ -export const useGoalList = (coachingSessionId: Id) => { +export const useGoalList = (coachingRelationshipId: Id | null) => { const { entities, isLoading, isError, refresh } = EntityApi.useEntityList( GOALS_BASEURL, - () => GoalApi.list(coachingSessionId), - coachingSessionId + // SWR skips this fetcher when params are falsy (null key = no fetch) + () => GoalApi.list(coachingRelationshipId!), + coachingRelationshipId ); return { @@ -177,10 +195,10 @@ export const useGoal = (id: Id) => { }; /** - * A custom React hook that fetches a single goal by coaching session ID. + * A custom React hook that fetches the first goal for a coaching relationship. * This hook uses SWR to efficiently fetch and cache goal data. * - * @param coachingSessionId The coaching session ID of the goal to fetch. + * @param coachingRelationshipId The coaching relationship ID whose first goal should be fetched. * @returns An object containing: * * * goal: The fetched Goal object, or a default goal if not yet loaded @@ -188,9 +206,9 @@ export const useGoal = (id: Id) => { * * isError: Error object if the fetch operation failed, undefined otherwise * * refresh: Function to manually trigger a refresh of the data */ -export const useGoalBySession = (coachingSessionId: Id) => { +export const useGoalByRelationship = (coachingRelationshipId: Id | null) => { const { goals, isLoading, isError, refresh } = - useGoalList(coachingSessionId); + useGoalList(coachingRelationshipId); return { goal: goals.length @@ -202,6 +220,41 @@ export const useGoalBySession = (coachingSessionId: Id) => { }; }; +/** + * A custom React hook that fetches goals linked to a specific coaching session + * via the coaching_sessions_goals join table. + * + * Uses GET /coaching_sessions/{session_id}/goals which returns full Goal models. + * + * @param coachingSessionId The coaching session ID whose linked goals should be fetched. + * @returns An object containing: + * + * * goals: Array of Goal objects linked to the session (empty array if not loaded) + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useGoalsBySession = (coachingSessionId: Id | null) => { + const url = coachingSessionId + ? `${COACHING_SESSIONS_BASEURL}/${coachingSessionId}/goals` + : COACHING_SESSIONS_BASEURL; + + const { entities, isLoading, isError, refresh } = + EntityApi.useEntityList( + url, + // SWR skips this fetcher when params are falsy (null key = no fetch) + () => GoalApi.listNested(coachingSessionId!), + coachingSessionId + ); + + return { + goals: entities, + isLoading, + isError, + refresh, + }; +}; + /** * Hook for goal mutations. * Provides methods to create, update, and delete goals. diff --git a/src/lib/hooks/use-sse-cache-invalidation.ts b/src/lib/hooks/use-sse-cache-invalidation.ts index 466ae42d..c10a4e8c 100644 --- a/src/lib/hooks/use-sse-cache-invalidation.ts +++ b/src/lib/hooks/use-sse-cache-invalidation.ts @@ -36,6 +36,23 @@ export function useSSECacheInvalidation(eventSource: EventSource | null) { console.log(`[SSE] Revalidated ${endpointPath} cache after ${eventName}`); }, [mutate, baseUrl]); + /** + * Invalidates only the session-scoped goal caches (e.g. /coaching_sessions/{id}/goals) + * without touching other coaching_sessions caches (session list, enriched sessions, etc.). + */ + const invalidateSessionGoals = useCallback((eventName: string) => { + const sessionGoalsPattern = `${baseUrl}/coaching_sessions/`; + mutate( + (key) => { + const url = typeof key === 'string' ? key : Array.isArray(key) ? key[0] : null; + return typeof url === 'string' && url.startsWith(sessionGoalsPattern) && url.endsWith('/goals'); + }, + undefined, + { revalidate: true } + ); + console.log(`[SSE] Revalidated session-scoped goal caches after ${eventName}`); + }, [mutate, baseUrl]); + // ACTION EVENTS - Invalidate only /actions endpoint useSSEEventHandler(eventSource, 'action_created', () => { invalidateEndpoint('/actions', 'action_created'); @@ -62,16 +79,19 @@ export function useSSECacheInvalidation(eventSource: EventSource | null) { invalidateEndpoint('/agreements', 'agreement_deleted'); }); - // GOAL EVENTS - Invalidate only /goals endpoint + // GOAL EVENTS - Invalidate /goals and session-scoped goal caches (join table) useSSEEventHandler(eventSource, 'goal_created', () => { invalidateEndpoint('/goals', 'goal_created'); + invalidateSessionGoals('goal_created'); }); useSSEEventHandler(eventSource, 'goal_updated', () => { invalidateEndpoint('/goals', 'goal_updated'); + invalidateSessionGoals('goal_updated'); }); useSSEEventHandler(eventSource, 'goal_deleted', () => { invalidateEndpoint('/goals', 'goal_deleted'); + invalidateSessionGoals('goal_deleted'); }); } diff --git a/src/lib/utils/session.ts b/src/lib/utils/session.ts index 1fe5567c..6f5534fe 100644 --- a/src/lib/utils/session.ts +++ b/src/lib/utils/session.ts @@ -12,6 +12,7 @@ import { getUserRoleInRelationship, } from "@/lib/utils/relationship"; import { getBrowserTimezone } from "@/lib/timezone-utils"; +import { goalTitle } from "@/types/goal"; /** * Session Utility Functions @@ -224,7 +225,7 @@ export function enrichSessionForDisplay( return { id: session.id, - goalTitle: goal?.title || "Coaching Session", + goalTitle: goal ? goalTitle(goal, "Coaching Session") : "Coaching Session", participantName: getOtherParticipantName(relationship, user), userRole: getUserRoleInRelationship(relationship, user), dateTime: formatSessionDateTime(session.date, timezone), diff --git a/src/types/goal.ts b/src/types/goal.ts index b6de1cc0..21be9082 100644 --- a/src/types/goal.ts +++ b/src/types/goal.ts @@ -1,34 +1,100 @@ import { DateTime } from "ts-luxon"; -import { Id, ItemStatus } from "@/types/general"; +import { Id, ItemStatus, EntityApiError } from "@/types/general"; + +// ─── Active Goal Limit (409 Conflict) ─────────────────────────────── +// "Active" means InProgress ONLY — NotStarted does not count. +// See ActiveGoalLimitError contract v4 on the coordination board. + +/** Default limit used when no 409 response has been received yet. */ +const DEFAULT_MAX_ACTIVE_GOALS = 3; + +/** Summary of an InProgress goal returned in the 409 response body. */ +export interface ActiveGoalSummary { + id: Id; + title: string; +} + +/** Parsed result from a 409 active-goal-limit error. */ +export interface ActiveGoalLimitInfo { + maxActiveGoals: number; + activeGoals: ActiveGoalSummary[]; +} + +/** Shape of the 409 response body when the active-goal limit is exceeded. */ +export interface ActiveGoalLimitErrorData { + status_code: 409; + error: "active_goal_limit_reached"; + message: string; + max_active_goals: number; + active_goals: ActiveGoalSummary[]; +} + +/** + * Extracts active-goal-limit info from an EntityApiError if it represents + * a 409 active-goal-limit error. Returns the limit and active goals on match, + * or null if the error is something else. + */ +export function extractActiveGoalLimitError( + err: unknown +): ActiveGoalLimitInfo | null { + if ( + !(err instanceof EntityApiError) || + err.status !== 409 + ) { + return null; + } + + const data = err.data; + if ( + data && + typeof data === "object" && + data.error === "active_goal_limit_reached" && + Array.isArray(data.active_goals) && + typeof data.max_active_goals === "number" + ) { + return { + maxActiveGoals: data.max_active_goals, + activeGoals: data.active_goals as ActiveGoalSummary[], + }; + } + + return null; +} + +export { DEFAULT_MAX_ACTIVE_GOALS }; // This must always reflect the Rust struct on the backend // entity::goals::Model export interface Goal { id: Id; - coaching_session_id: Id; + coaching_relationship_id: Id; + created_in_session_id: Id | null; user_id: Id; title: string; body: string; status: ItemStatus; status_changed_at: DateTime; completed_at: DateTime; + target_date: string | null; created_at: DateTime; updated_at: DateTime; } export function parseGoal(data: any): Goal { if (!isGoal(data)) { - throw new Error("Invalid CoachingSession data"); + throw new Error("Invalid Goal data"); } return { id: data.id, - coaching_session_id: data.coaching_session_id, + coaching_relationship_id: data.coaching_relationship_id, + created_in_session_id: data.created_in_session_id ?? null, user_id: data.user_id, title: data.title, body: data.body, status: data.status, status_changed_at: data.status_changed_at, completed_at: data.completed_at, + target_date: data.target_date ?? null, created_at: data.created_at, updated_at: data.updated_at, }; @@ -41,16 +107,18 @@ export function isGoal(value: unknown): value is Goal { const object = value as Record; return ( - (typeof object.id === "string" && - typeof object.coaching_session_id === "string" && - typeof object.user_id === "string" && - typeof object.status === "string" && - typeof object.created_at === "string" && - typeof object.updated_at === "string") || - typeof object.title === "string" || - typeof object.body === "string" || - typeof object.status_changed_at === "string" || - typeof object.completed_at === "string" + typeof object.id === "string" && + typeof object.coaching_relationship_id === "string" && + (object.created_in_session_id === null || typeof object.created_in_session_id === "string") && + typeof object.user_id === "string" && + typeof object.title === "string" && + typeof object.body === "string" && + typeof object.status === "string" && + (object.target_date === null || typeof object.target_date === "string") && + typeof object.status_changed_at === "string" && + typeof object.completed_at === "string" && + typeof object.created_at === "string" && + typeof object.updated_at === "string" ); } @@ -72,13 +140,15 @@ export function defaultGoal(): Goal { const now = DateTime.now(); return { id: "", - coaching_session_id: "", + coaching_relationship_id: "", + created_in_session_id: null, user_id: "", title: "", body: "", status: ItemStatus.NotStarted, status_changed_at: now, completed_at: now, + target_date: null, created_at: now, updated_at: now, }; @@ -88,6 +158,19 @@ export function defaultGoals(): Goal[] { return [defaultGoal()]; } +export const DEFAULT_GOAL_TITLE = "No goal set"; + +/** + * Returns the goal's title if non-empty, otherwise a default fallback. + * Handles empty string titles consistently across the UI. + */ +export function goalTitle( + goal: Pick, + fallback: string = DEFAULT_GOAL_TITLE +): string { + return goal.title || fallback; +} + export function goalToString( goal: Goal | undefined ): string {