From 6a5e545f083cbcf88cb3fce1eedbfae18c094d05 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sat, 26 Sep 2026 17:42:35 +0200 Subject: [PATCH 1/2] feat(board): task pool card, and pin the current task on every claim Adds TASK_POOL, a baseline board card carrying the whole live starter-work pool ranked for the hire (same ranking and reasons as GET /me/matches), so a task can be browsed and grabbed without going through the buddy. Claiming now pins the CURRENT_TASK card inside UserGoalService.claimForMe instead of only in the buddy's claim_goal action. That card is mentor-placed, so a hire grabbing by hand via POST /me/goal would otherwise have a task and no card showing it. V20 drops the Hibernate-generated board_cards_kind_check: ddl-auto update never widens it, and a baseline kind the column rejects would fail every board read on an existing database. Co-Authored-By: Claude Opus 5.5 --- .../external/enums/BoardCardKind.kt | 10 ++++ .../model/response/board/BoardResponses.kt | 30 ++++++++++ .../onboarding/service/BoardService.kt | 29 +++++++++ .../onboarding/service/BuddyActionService.kt | 11 +--- .../onboarding/service/UserGoalService.kt | 6 ++ .../V20__relax_board_card_kind_check.sql | 6 ++ .../onboarding/service/BoardServiceTest.kt | 59 +++++++++++++++++-- .../service/BuddyActionServiceTest.kt | 9 +-- .../service/BuddyBoardWriteActionsTest.kt | 1 - .../onboarding/service/UserGoalServiceTest.kt | 16 +++++ 10 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 src/main/resources/db/migration/V20__relax_board_card_kind_check.sql diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt index 2b457103..fd39cdc8 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/external/enums/BoardCardKind.kt @@ -73,6 +73,16 @@ enum class BoardCardKind( */ SUGGESTED_TASKS(Placement.MENTOR), + /** + * The whole live starter-work pool, ranked for this hire, to browse and grab from by hand. + * + * Baseline, unlike [SUGGESTED_TASKS]: picking your own task is not something the mentor should + * have to decide to offer. The buddy stays the way to get help choosing; this is the way to + * choose without asking anybody. Same ranking and same reasons as `GET /me/matches`, so the card + * and the conversation cannot disagree about which task fits best. + */ + TASK_POOL(Placement.BASELINE), + /** * What the hire has shown they can do, and what they are still short of. * diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/board/BoardResponses.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/board/BoardResponses.kt index 9bcaa144..d4f12500 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/board/BoardResponses.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/response/board/BoardResponses.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo import com.sprintstart.sprintstartbackend.onboarding.external.enums.BoardCardKind import com.sprintstart.sprintstartbackend.onboarding.external.enums.BoardCardOwner import com.sprintstart.sprintstartbackend.onboarding.external.enums.StepStatus +import com.sprintstart.sprintstartbackend.onboarding.external.enums.TaskType import com.sprintstart.sprintstartbackend.onboarding.model.response.arrival.ArrivalStepResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.resource.GetOnboardingResourcesResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTasksResponse @@ -64,6 +65,7 @@ data class BoardCardResponse( JsonSubTypes.Type(value = OpenPullRequestsContent::class, name = "OPEN_PULL_REQUESTS"), JsonSubTypes.Type(value = CurrentTaskContent::class, name = "CURRENT_TASK"), JsonSubTypes.Type(value = SuggestedTasksContent::class, name = "SUGGESTED_TASKS"), + JsonSubTypes.Type(value = TaskPoolContent::class, name = "TASK_POOL"), JsonSubTypes.Type(value = CompetencyProgressContent::class, name = "COMPETENCY_PROGRESS"), JsonSubTypes.Type(value = MemoryRecapContent::class, name = "MEMORY_RECAP"), JsonSubTypes.Type(value = DiagramContent::class, name = "DIAGRAM"), @@ -199,6 +201,34 @@ data class BoardSuggestedTaskResponse( val reasons: List, ) +/** + * Every live task the hire may grab, best fit first. + * + * [currentTaskId] is the task they are on, if any, so the card can mark it rather than offering to + * grab what is already theirs. The order is the ranking; the client filters, never re-sorts. + */ +data class TaskPoolContent( + override val kind: BoardCardKind = BoardCardKind.TASK_POOL, + val tasks: List, + val currentTaskId: UUID?, +) : BoardCardContent + +/** One task in the pool, with what the hire needs to choose it — and no score. */ +data class BoardPoolTaskResponse( + val taskId: UUID, + val title: String, + val summary: String?, + /** Why this is a reasonable first task, in the words of whoever put it in the pool. */ + val rationale: String?, + val url: String?, + val taskType: TaskType, + val reasons: List, + /** Among the top of the ranking and matched on at least one signal. */ + val bestFit: Boolean, + /** Three-valued like the pool's own field: only `true` means somebody is on it. */ + val sourceHasAssignee: Boolean?, +) + /** Something the hire wrote down. The one card whose text the board did not read from anywhere. */ data class NoteContent( override val kind: BoardCardKind = BoardCardKind.NOTE, diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt index 4eef74a9..3d7fa3a2 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt @@ -26,6 +26,7 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardC import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardCompetencyResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardMomentKey import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardMomentResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardPoolTaskResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardPullRequestResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardSuggestedTaskResponse @@ -40,6 +41,7 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.board.OpenPu import com.sprintstart.sprintstartbackend.onboarding.model.response.board.PathStepContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.PathToFirstContributionContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.SuggestedTasksContent +import com.sprintstart.sprintstartbackend.onboarding.model.response.board.TaskPoolContent import com.sprintstart.sprintstartbackend.onboarding.model.response.competency.MyCompetencyResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.metrics.HireTimelineResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.resource.GetOnboardingResourcesResponse @@ -597,6 +599,7 @@ class BoardService( BoardCardKind.OPEN_PULL_REQUESTS -> openPullRequestsContent(member, projectId) BoardCardKind.CURRENT_TASK -> currentTaskContent(member.userId, projectId) BoardCardKind.SUGGESTED_TASKS -> suggestedTasksContent(member.userId, projectId) + BoardCardKind.TASK_POOL -> taskPoolContent(member.userId, projectId) BoardCardKind.COMPETENCY_PROGRESS -> competencyProgressContent(member.userId) BoardCardKind.MEMORY_RECAP -> memoryRecapContent(member.userId) // The one card served from a cache: its content costs a model call. @@ -776,6 +779,32 @@ class BoardService( }, ) + /** + * The whole pool, ranked, for the hire to pick from themselves. + * + * Uncapped on purpose — the point of the card is that nothing is hidden behind "ask your buddy". + * The client scrolls and filters. + */ + private fun taskPoolContent(userId: UUID, projectId: UUID): TaskPoolContent = + TaskPoolContent( + tasks = starterWorkTaskProposalService + .matchForUserId(userId, projectId) + .mapIndexed { index, match -> + BoardPoolTaskResponse( + taskId = match.task.id, + title = match.task.title, + summary = match.task.summary, + rationale = match.task.rationale, + url = match.task.sourceUrl, + taskType = match.taskType, + reasons = match.reasons, + bestFit = index < MAX_SUGGESTED_TASKS && match.score > 0, + sourceHasAssignee = match.task.sourceHasAssignee, + ) + }, + currentTaskId = currentTaskReader.currentTaskFor(userId, projectId)?.id, + ) + /** * The path card's content, from the same timeline the PM dashboard reads. * diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt index 4be2048e..d2dd950e 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt @@ -1,6 +1,5 @@ package com.sprintstart.sprintstartbackend.onboarding.service -import com.sprintstart.sprintstartbackend.onboarding.external.enums.BoardCardKind import com.sprintstart.sprintstartbackend.onboarding.external.enums.BuddyActionType import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProficiencyLevel import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto @@ -52,7 +51,6 @@ class BuddyActionService( private val userGoalService: UserGoalService, private val userApi: UserApi, private val attestationService: AttestationService, - private val boardService: BoardService, private val competencyPlacementService: CompetencyPlacementService, private val boardWrites: BuddyBoardWriteActions, ) { @@ -364,7 +362,7 @@ class BuddyActionService( BuddyActionType.CLAIM_TASK_ZERO -> claimTaskZero(resolved.userId, resolved.projectId) BuddyActionType.FLAG_TO_PM -> flagToPm(authId, resolved.projectId, request.question) BuddyActionType.CLAIM_GOAL -> - claimGoal(resolved.userId, authId, resolved.projectId, request.taskId) + claimGoal(authId, resolved.projectId, request.taskId) BuddyActionType.REQUEST_ATTESTATION -> requestAttestation(resolved, request.title, request.attesterId) BuddyActionType.PLACE_CHECKLIST, @@ -447,7 +445,6 @@ class BuddyActionService( } private fun claimGoal( - userId: UUID, authId: String, projectId: UUID, taskId: UUID?, @@ -455,11 +452,9 @@ class BuddyActionService( if (taskId == null) { return BuddyActionResponse(ok = false, message = "No task was proposed to claim.") } + // Claiming also pins the current-task card (see `UserGoalService.claimForMe`), so the + // message below can promise it is on the board. val goal = userGoalService.claimForMe(authId, projectId, taskId) - // Pin the task the moment it becomes theirs, rather than hoping the mentor thinks to. This - // conversation is gone by the next visit; the board is what carries "this is what you are - // working on" across the gap, and the one instant we know for certain it is true is now. - boardService.place(userId, projectId, BoardCardKind.CURRENT_TASK) return BuddyActionResponse( ok = true, message = "You're now working toward “${goal.title}” — I'll shape your next steps around it. " + diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalService.kt index e11b661e..6b9fe671 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalService.kt @@ -1,5 +1,6 @@ package com.sprintstart.sprintstartbackend.onboarding.service +import com.sprintstart.sprintstartbackend.onboarding.external.enums.BoardCardKind import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProposalStatus import com.sprintstart.sprintstartbackend.onboarding.model.entity.UserGoal import com.sprintstart.sprintstartbackend.onboarding.model.response.goal.GoalView @@ -30,6 +31,7 @@ class UserGoalService( private val starterWorkTaskProposalRepository: StarterWorkTaskProposalRepository, private val starterWorkPoolReconciler: StarterWorkPoolReconciler, private val userApi: UserApi, + private val boardService: BoardService, ) { /** * Claims a live starter-work task as this hire's goal for [projectId], replacing any goal they @@ -66,6 +68,10 @@ class UserGoalService( sourceProposalId = proposal.id, ) userGoalRepository.save(goal) + // Pinned the moment it becomes theirs, whichever way it was grabbed — from the buddy's + // confirm or by hand from the pool card. The current-task card is mentor-placed, so without + // this a hire who grabbed by hand would have a task and no card saying so. + boardService.place(userId, projectId, BoardCardKind.CURRENT_TASK) return GoalView( proposalId = proposal.id, diff --git a/src/main/resources/db/migration/V20__relax_board_card_kind_check.sql b/src/main/resources/db/migration/V20__relax_board_card_kind_check.sql new file mode 100644 index 00000000..5a7f2868 --- /dev/null +++ b/src/main/resources/db/migration/V20__relax_board_card_kind_check.sql @@ -0,0 +1,6 @@ +-- board_cards is created by Hibernate (ddl-auto: update), which writes a CHECK constraint listing +-- every BoardCardKind at creation time and never widens it afterwards. A new kind — TASK_POOL here, +-- a baseline card inserted on every board read — would then fail on any database created before +-- it, and take the whole board down with it. The enum is the catalog; the column does not need to +-- repeat it. Safe on a fresh database, where the table does not exist yet. +ALTER TABLE IF EXISTS board_cards DROP CONSTRAINT IF EXISTS board_cards_kind_check; diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt index 063c8aac..f0ac3bbd 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt @@ -36,6 +36,7 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.board.OpenPu import com.sprintstart.sprintstartbackend.onboarding.model.response.board.PathStepContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.PathToFirstContributionContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.SuggestedTasksContent +import com.sprintstart.sprintstartbackend.onboarding.model.response.board.TaskPoolContent import com.sprintstart.sprintstartbackend.onboarding.model.response.competency.MyCompetencyResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.metrics.HireTimelineResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.RankedStarterWorkTaskResponse @@ -217,13 +218,17 @@ class BoardServiceTest { } @Test - fun `an engineering hire gets the path card and the open pull request card`() { + fun `an engineering hire gets the path, open pull request and task pool cards`() { noBoardYet() val kinds = service.getBoard(hireId, projectId)?.cards?.map { it.kind } assertEquals( - listOf(BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, BoardCardKind.OPEN_PULL_REQUESTS), + listOf( + BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, + BoardCardKind.OPEN_PULL_REQUESTS, + BoardCardKind.TASK_POOL, + ), kinds, ) } @@ -335,7 +340,11 @@ class BoardServiceTest { // Underneath, their own arrangement is untouched -- the pin is a sort on read, never a // write to `position`, which is what makes overriding it acceptable rather than destructive. assertEquals( - listOf(BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, BoardCardKind.OPEN_PULL_REQUESTS), + listOf( + BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, + BoardCardKind.OPEN_PULL_REQUESTS, + BoardCardKind.TASK_POOL, + ), kinds.drop(1), ) } @@ -363,6 +372,7 @@ class BoardServiceTest { BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, BoardCardKind.OPEN_PULL_REQUESTS, BoardCardKind.CURRENT_TASK, + BoardCardKind.TASK_POOL, ), kinds, ) @@ -418,6 +428,7 @@ class BoardServiceTest { BoardCardKind.SUGGESTED_TASKS, BoardCardKind.COMPETENCY_PROGRESS, BoardCardKind.MEMORY_RECAP, + BoardCardKind.TASK_POOL, ) every { boardCardRepository.findAllByBoardId(board.id) } returns kindsOnBoard .mapIndexed { index, kind -> card(board, kind, position = index) } @@ -459,6 +470,12 @@ class BoardServiceTest { owner = BoardCardOwner.AI, position = 1, ), + BoardCard( + boardId = board.id, + kind = BoardCardKind.TASK_POOL, + owner = BoardCardOwner.AI, + position = 2, + ), ) service.getBoard(hireId, projectId) @@ -483,7 +500,7 @@ class BoardServiceTest { // The dismissed row is what makes the removal stick: the path card is added because it is // missing, the pull-request card is not re-added because the hire said no to it. - assertEquals(listOf(BoardCardKind.PATH_TO_FIRST_CONTRIBUTION), kinds) + assertEquals(listOf(BoardCardKind.PATH_TO_FIRST_CONTRIBUTION, BoardCardKind.TASK_POOL), kinds) } @Test @@ -501,8 +518,10 @@ class BoardServiceTest { val cards = service.getBoard(hireId, projectId)?.cards.orEmpty() // Ensuring a card exists must never reshuffle a board the hire has arranged. - assertEquals(BoardCardKind.OPEN_PULL_REQUESTS, cards.last().kind) - assertEquals(8, cards.last().position) + assertEquals( + listOf(BoardCardKind.OPEN_PULL_REQUESTS to 8, BoardCardKind.TASK_POOL to 9), + cards.drop(1).map { it.kind to it.position }, + ) } @Test @@ -768,6 +787,28 @@ class BoardServiceTest { assertEquals(listOf("You have worked in this repository before"), tasks.first().reasons) } + @Test + fun `the task pool card lists the whole pool in rank order and marks the current task`() { + val board = existingBoard() + every { boardCardRepository.findAllByBoardId(board.id) } returns listOf( + card(board, BoardCardKind.TASK_POOL), + ) + val pool = (1..5).map { ranked("Task $it", listOf("reason $it")) } + every { starterWorkTaskProposalService.matchForUserId(hireId, projectId) } returns pool + every { currentTaskReader.currentTaskFor(hireId, projectId) } returns StarterWorkTaskProposal( + id = pool[3].task.id, + sourceId = "src-Task 4", + title = "Task 4", + ) + + val content = service.taskPoolCard() + + // Uncapped, unlike the suggestions card: nothing is hidden behind "ask your buddy". + assertEquals(pool.map { it.task.title }, content.tasks.map { it.title }) + assertEquals(listOf(true, true, true, false, false), content.tasks.map { it.bestFit }) + assertEquals(pool[3].task.id, content.currentTaskId) + } + // ---- what the mentor's other cards say (slice 3) ---- @Test @@ -905,6 +946,12 @@ class BoardServiceTest { .first { it.kind == BoardCardKind.SUGGESTED_TASKS } .content as SuggestedTasksContent + private fun BoardService.taskPoolCard(): TaskPoolContent = + getBoard(hireId, projectId)!! + .cards + .first { it.kind == BoardCardKind.TASK_POOL } + .content as TaskPoolContent + private fun BoardService.pathCard(): PathToFirstContributionContent = getBoard(hireId, projectId)!! .cards diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt index a5b1049e..e7b29c01 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionServiceTest.kt @@ -1,6 +1,5 @@ package com.sprintstart.sprintstartbackend.onboarding.service -import com.sprintstart.sprintstartbackend.onboarding.external.enums.BoardCardKind import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProficiencyLevel import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProposalStatus import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolCallDto @@ -47,7 +46,6 @@ class BuddyActionServiceTest { userGoalService, userApi, attestationService, - boardService, competencyPlacementService, BuddyBoardWriteActions(boardService), ) @@ -557,7 +555,7 @@ class BuddyActionServiceTest { } @Test - fun `claiming a goal pins it to the board, so the next visit still knows about it`() = runTest { + fun `claiming a goal tells the hire it is on their board`() = runTest { asHire() onOneProject() val taskId = UUID.randomUUID() @@ -570,9 +568,8 @@ class BuddyActionServiceTest { val result = service.perform(BuddyActionRequest(action = "claim_goal", taskId = taskId), jwt) - // This conversation is gone by the next visit; the one instant we know for certain the - // task is theirs is now, so the card is placed then rather than when the mentor thinks of it. - verify { boardService.place(userId, projectId, BoardCardKind.CURRENT_TASK) } + // The pinning itself happens in `UserGoalService.claimForMe` (tested there); the buddy + // only has to tell the hire where to find it. assertThat(result.message).contains("board") } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardWriteActionsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardWriteActionsTest.kt index d64111ce..6d16e43e 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardWriteActionsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyBoardWriteActionsTest.kt @@ -44,7 +44,6 @@ class BuddyBoardWriteActionsTest { mockk(relaxed = true), userApi, mockk(relaxed = true), - boardService, mockk(relaxed = true), boardWrites, ) diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalServiceTest.kt index 539eee4f..61bdec5e 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalServiceTest.kt @@ -1,5 +1,6 @@ package com.sprintstart.sprintstartbackend.onboarding.service +import com.sprintstart.sprintstartbackend.onboarding.external.enums.BoardCardKind import com.sprintstart.sprintstartbackend.onboarding.external.enums.ProposalStatus import com.sprintstart.sprintstartbackend.onboarding.model.entity.StarterWorkTaskProposal import com.sprintstart.sprintstartbackend.onboarding.model.entity.UserGoal @@ -30,6 +31,7 @@ class UserGoalServiceTest { private val userGoalRepository: UserGoalRepository = mockk(relaxed = true) private val starterWorkTaskProposalRepository: StarterWorkTaskProposalRepository = mockk() private val userApi: UserApi = mockk() + private val boardService: BoardService = mockk(relaxed = true) // Claiming reconciles the one row against its source first. Default to "nothing changed"; the // tests that care about the closed case override it. @@ -41,6 +43,7 @@ class UserGoalServiceTest { starterWorkTaskProposalRepository, starterWorkPoolReconciler, userApi, + boardService, ) private val userId: UUID = UUID.randomUUID() @@ -94,6 +97,19 @@ class UserGoalServiceTest { assertEquals(proposal.id, saved.captured.sourceProposalId) } + @Test + fun `pins the current-task card, however the task was grabbed`() { + stageUser() + val proposal = approvedProposal() + every { starterWorkTaskProposalRepository.findById(proposal.id) } returns Optional.of(proposal) + every { userGoalRepository.findByUserIdAndProjectId(userId, projectId) } returns null + every { userGoalRepository.save(any()) } answers { firstArg() } + + service.claimForMe(authId, projectId, proposal.id) + + verify { boardService.place(userId, projectId, BoardCardKind.CURRENT_TASK) } + } + @Test fun `replaces an existing goal rather than adding a second one`() { stageUser() From d2532dfb2b0437b556a91cfa8cf9bb4c6975188e Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Tue, 29 Sep 2026 17:06:32 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(board):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20constraint=20drop=20that=20actually=20runs,=20revive,=20one?= =?UTF-8?q?=20ranking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace V20 with BoardCardKindConstraintRelaxer, an ApplicationRunner that drops board_cards_kind_check idempotently on every startup. Flyway was removed (2982075c), so nothing ever executed the SQL file: every existing database would have rejected TASK_POOL on the first board read. Running on every start also covers databases created later, whose fresh constraint would break on the next new kind. Failures are logged, not thrown. Verified on Postgres in a rolled-back transaction. - Claiming now revives a dismissed CURRENT_TASK card (BoardService.placeOrRevive) instead of silently leaving it gone. The mentor's place() still respects the dismissal. - Rank the pool once per board read and share it between the suggestions and the pool card (lazy, so boards with neither pay nothing). The two card builders move to BoardTaskCards, which also keeps BoardService under detekt's LargeClass limit and documents why "Good next tasks" takes the top three regardless of score. - Tests: relaxer (SQL, failure tolerated), revive vs. place, a 409 claim pins nothing, one ranking per board read. Co-Authored-By: Claude Opus 5.5 --- .../seeding/BoardCardKindConstraintRelaxer.kt | 46 ++++++++ .../onboarding/service/BoardService.kt | 104 ++++++++---------- .../onboarding/service/BoardTaskCards.kt | 65 +++++++++++ .../onboarding/service/UserGoalService.kt | 5 +- .../V20__relax_board_card_kind_check.sql | 6 - .../BoardCardKindConstraintRelaxerTest.kt | 35 ++++++ .../onboarding/service/BoardServiceTest.kt | 44 ++++++++ .../onboarding/service/UserGoalServiceTest.kt | 6 +- 8 files changed, 245 insertions(+), 66 deletions(-) create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/seeding/BoardCardKindConstraintRelaxer.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardTaskCards.kt delete mode 100644 src/main/resources/db/migration/V20__relax_board_card_kind_check.sql create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/seeding/BoardCardKindConstraintRelaxerTest.kt diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/seeding/BoardCardKindConstraintRelaxer.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/seeding/BoardCardKindConstraintRelaxer.kt new file mode 100644 index 00000000..0e5463c8 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/seeding/BoardCardKindConstraintRelaxer.kt @@ -0,0 +1,46 @@ +package com.sprintstart.sprintstartbackend.onboarding.seeding + +import org.slf4j.LoggerFactory +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.dao.DataAccessException +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Component + +/** + * Drops the `CHECK` constraint Hibernate writes on `board_cards.kind`, on every startup. + * + * `board_cards` is created by `ddl-auto: update`, which lists every `BoardCardKind` in a check + * constraint when it creates the table and never widens it afterwards. A kind added later is then + * rejected on any database older than the kind — and for a *baseline* kind such as `TASK_POOL`, + * which is inserted on every board read, that is every board read failing. The enum is the + * catalog; the column does not need to repeat it. + * + * Done here rather than as a migration file because nothing in this service runs migrations: + * Flyway was removed, and the files under `db/migration` are applied by hand if at all. Running on + * every start also covers the database created tomorrow, which gets a fresh constraint listing + * today's kinds and would break again on the next one. Idempotent, and a no-op where the table or + * the constraint does not exist (H2 in tests names its checks differently and is left alone). + * + * A failure is logged rather than thrown: an app that refuses to start is worse than a board that + * cannot hold a kind it has never held before. + */ +@Component +class BoardCardKindConstraintRelaxer( + private val jdbcTemplate: JdbcTemplate, +) : ApplicationRunner { + private val logger = LoggerFactory.getLogger(javaClass) + + override fun run(args: ApplicationArguments) { + try { + jdbcTemplate.execute(DROP_KIND_CHECK) + } catch (e: DataAccessException) { + logger.warn("Could not drop board_cards_kind_check; new board card kinds may be rejected: {}", e.message) + } + } + + companion object { + const val DROP_KIND_CHECK = + "ALTER TABLE IF EXISTS board_cards DROP CONSTRAINT IF EXISTS board_cards_kind_check" + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt index 3d7fa3a2..ec6a6196 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt @@ -26,10 +26,8 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardC import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardCompetencyResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardMomentKey import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardMomentResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardPoolTaskResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardPullRequestResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardResponse -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardSuggestedTaskResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.ChecklistContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.ChecklistItemResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.board.CompetencyProgressContent @@ -40,11 +38,10 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.board.NoteCo import com.sprintstart.sprintstartbackend.onboarding.model.response.board.OpenPullRequestsContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.PathStepContent import com.sprintstart.sprintstartbackend.onboarding.model.response.board.PathToFirstContributionContent -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.SuggestedTasksContent -import com.sprintstart.sprintstartbackend.onboarding.model.response.board.TaskPoolContent import com.sprintstart.sprintstartbackend.onboarding.model.response.competency.MyCompetencyResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.metrics.HireTimelineResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.resource.GetOnboardingResourcesResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.RankedStarterWorkTaskResponse import com.sprintstart.sprintstartbackend.onboarding.model.response.task.GetOnboardingTasksResponse import com.sprintstart.sprintstartbackend.onboarding.repository.BoardCardRepository import com.sprintstart.sprintstartbackend.onboarding.repository.BoardDiagramRepository @@ -138,6 +135,11 @@ class BoardService( } else { emptyMap() } + // One ranking per read, shared by the suggestions card and the pool card: it is a pass over + // the whole live pool plus a responsiveness read, and the pool card is baseline, so every + // board would otherwise pay for it twice. Sharing it also means the two cannot disagree. + // Lazy, so a board with neither card does not pay for it at all. + val matches by lazy { starterWorkTaskProposalService.matchForUserId(userId, projectId) } return BoardResponse( boardId = board.id, @@ -145,7 +147,9 @@ class BoardService( cards = cards .filter { it.state == BoardCardState.ACTIVE } .sortedWith(attentionOrder(arrivalSteps, onATask)) - .map { it.toResponse(member, projectId, timeline, diagrams[it.id], arrivalSteps, pathSteps) }, + .map { + it.toResponse(member, projectId, timeline, diagrams[it.id], arrivalSteps, pathSteps) { matches } + }, ) } @@ -248,6 +252,33 @@ class BoardService( return PlacementOutcome.PLACED } + /** + * Places a card of [kind], or brings it back if the hire dismissed it before. + * + * The one exception to dismissal being sticky, and it is only for the hire's own act: when they + * grab a task, "this is what I'm working on" is exactly what the current-task card says, so a + * card they dismissed back when it had nothing on it has to return. The mentor never gets this — + * [place] stays the only thing it can call. + * + * Only for kinds without a subject, where one row per kind is the whole identity. + */ + @Transactional + fun placeOrRevive(userId: UUID, projectId: UUID, kind: BoardCardKind): PlacementOutcome { + require(!kind.takesSubject) { "$kind is identified by its subject; revive it through place()" } + val outcome = place(userId, projectId, kind) + if (outcome != PlacementOutcome.DISMISSED_BY_HIRE) return outcome + + val board = boardRepository.findByUserIdAndProjectId(userId, projectId) ?: return outcome + val card = boardCardRepository + .findAllByBoardId(board.id) + .firstOrNull { it.kind == kind && it.state == BoardCardState.DISMISSED } + ?: return outcome + card.state = BoardCardState.ACTIVE + card.placedAt = Instant.now() + boardCardRepository.save(card) + return PlacementOutcome.PLACED + } + /** * Whether this row is the same card as one of [kind] with [subject]. * @@ -593,13 +624,17 @@ class BoardService( diagram: BoardDiagram?, arrivalSteps: List, pathSteps: Map, + matches: () -> List, ): BoardCardContent = when (card.kind) { BoardCardKind.PATH_TO_FIRST_CONTRIBUTION -> pathContent(member, timeline) BoardCardKind.ARRIVAL_STEPS -> arrivalStepsContent(arrivalSteps) BoardCardKind.OPEN_PULL_REQUESTS -> openPullRequestsContent(member, projectId) BoardCardKind.CURRENT_TASK -> currentTaskContent(member.userId, projectId) - BoardCardKind.SUGGESTED_TASKS -> suggestedTasksContent(member.userId, projectId) - BoardCardKind.TASK_POOL -> taskPoolContent(member.userId, projectId) + BoardCardKind.SUGGESTED_TASKS -> BoardTaskCards.suggested(matches()) + BoardCardKind.TASK_POOL -> BoardTaskCards.pool( + matches(), + currentTaskId = currentTaskReader.currentTaskFor(member.userId, projectId)?.id, + ) BoardCardKind.COMPETENCY_PROGRESS -> competencyProgressContent(member.userId) BoardCardKind.MEMORY_RECAP -> memoryRecapContent(member.userId) // The one card served from a cache: its content costs a model call. @@ -759,52 +794,6 @@ class BoardService( ) } - /** - * Good next tasks, ranked. Carries the reasons and never the score. - * - * Same read and same cap as the buddy's `get_suggested_tasks` tool. - */ - private fun suggestedTasksContent(userId: UUID, projectId: UUID): SuggestedTasksContent = - SuggestedTasksContent( - tasks = starterWorkTaskProposalService - .matchForUserId(userId, projectId) - .take(MAX_SUGGESTED_TASKS) - .map { match -> - BoardSuggestedTaskResponse( - taskId = match.task.id, - title = match.task.title, - url = match.task.sourceUrl, - reasons = match.reasons, - ) - }, - ) - - /** - * The whole pool, ranked, for the hire to pick from themselves. - * - * Uncapped on purpose — the point of the card is that nothing is hidden behind "ask your buddy". - * The client scrolls and filters. - */ - private fun taskPoolContent(userId: UUID, projectId: UUID): TaskPoolContent = - TaskPoolContent( - tasks = starterWorkTaskProposalService - .matchForUserId(userId, projectId) - .mapIndexed { index, match -> - BoardPoolTaskResponse( - taskId = match.task.id, - title = match.task.title, - summary = match.task.summary, - rationale = match.task.rationale, - url = match.task.sourceUrl, - taskType = match.taskType, - reasons = match.reasons, - bestFit = index < MAX_SUGGESTED_TASKS && match.score > 0, - sourceHasAssignee = match.task.sourceHasAssignee, - ) - }, - currentTaskId = currentTaskReader.currentTaskFor(userId, projectId)?.id, - ) - /** * The path card's content, from the same timeline the PM dashboard reads. * @@ -860,13 +849,17 @@ class BoardService( diagram: BoardDiagram? = null, arrivalSteps: List = emptyList(), pathSteps: Map = emptyMap(), + // A single card rendered on its own ranks for itself; a whole board shares one ranking. + matches: () -> List = { + starterWorkTaskProposalService.matchForUserId(member.userId, projectId) + }, ) = BoardCardResponse( id = id, kind = kind, owner = owner, position = position, placedAt = placedAt, - content = hydrate(this, member, projectId, timeline, diagram, arrivalSteps, pathSteps), + content = hydrate(this, member, projectId, timeline, diagram, arrivalSteps, pathSteps, matches), ) /** @@ -1007,9 +1000,6 @@ class BoardService( } private companion object { - /** Matches the buddy tool's cap, so the card and the conversation list the same tasks. */ - const val MAX_SUGGESTED_TASKS = 3 - /** * Long enough for any real question, short enough that a rambling one cannot become a card * title nobody can read. Matches the cap the AI service applies to the same string. diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardTaskCards.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardTaskCards.kt new file mode 100644 index 00000000..e6d79827 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardTaskCards.kt @@ -0,0 +1,65 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardPoolTaskResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardSuggestedTaskResponse +import com.sprintstart.sprintstartbackend.onboarding.model.response.board.SuggestedTasksContent +import com.sprintstart.sprintstartbackend.onboarding.model.response.board.TaskPoolContent +import com.sprintstart.sprintstartbackend.onboarding.model.response.starterwork.RankedStarterWorkTaskResponse +import java.util.UUID + +/** + * The two board cards built from the ranked starter-work pool: good next tasks, and the whole pool. + * + * Pure projections of one ranking, which [BoardService] reads once per board and hands to both, so + * the two cards cannot disagree about order or reasons. Neither carries the score. + */ +internal object BoardTaskCards { + /** Matches the buddy tool's cap, so the card and the conversation list the same tasks. */ + const val MAX_SUGGESTED_TASKS = 3 + + /** + * Good next tasks: the top three, whatever they score. + * + * The pool's `bestFit` also asks for a score above zero, and that difference is deliberate. This + * card answers "what should I pick up" and must not come up empty while the pool holds work; + * `bestFit` answers "does this fit me", and a zero score is not a fit. So a task can be + * suggested here without being marked there. + */ + fun suggested(matches: List): SuggestedTasksContent = + SuggestedTasksContent( + tasks = matches + .take(MAX_SUGGESTED_TASKS) + .map { match -> + BoardSuggestedTaskResponse( + taskId = match.task.id, + title = match.task.title, + url = match.task.sourceUrl, + reasons = match.reasons, + ) + }, + ) + + /** + * The whole pool, for the hire to pick from themselves. + * + * Uncapped on purpose — the point of the card is that nothing is hidden behind "ask your buddy". + * The client scrolls and filters. + */ + fun pool(matches: List, currentTaskId: UUID?): TaskPoolContent = + TaskPoolContent( + tasks = matches.mapIndexed { index, match -> + BoardPoolTaskResponse( + taskId = match.task.id, + title = match.task.title, + summary = match.task.summary, + rationale = match.task.rationale, + url = match.task.sourceUrl, + taskType = match.taskType, + reasons = match.reasons, + bestFit = index < MAX_SUGGESTED_TASKS && match.score > 0, + sourceHasAssignee = match.task.sourceHasAssignee, + ) + }, + currentTaskId = currentTaskId, + ) +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalService.kt index 6b9fe671..d7a4784c 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalService.kt @@ -70,8 +70,9 @@ class UserGoalService( userGoalRepository.save(goal) // Pinned the moment it becomes theirs, whichever way it was grabbed — from the buddy's // confirm or by hand from the pool card. The current-task card is mentor-placed, so without - // this a hire who grabbed by hand would have a task and no card saying so. - boardService.place(userId, projectId, BoardCardKind.CURRENT_TASK) + // this a hire who grabbed by hand would have a task and no card saying so. Revived if they + // dismissed it before: grabbing a task is them saying this is what they are working on. + boardService.placeOrRevive(userId, projectId, BoardCardKind.CURRENT_TASK) return GoalView( proposalId = proposal.id, diff --git a/src/main/resources/db/migration/V20__relax_board_card_kind_check.sql b/src/main/resources/db/migration/V20__relax_board_card_kind_check.sql deleted file mode 100644 index 5a7f2868..00000000 --- a/src/main/resources/db/migration/V20__relax_board_card_kind_check.sql +++ /dev/null @@ -1,6 +0,0 @@ --- board_cards is created by Hibernate (ddl-auto: update), which writes a CHECK constraint listing --- every BoardCardKind at creation time and never widens it afterwards. A new kind — TASK_POOL here, --- a baseline card inserted on every board read — would then fail on any database created before --- it, and take the whole board down with it. The enum is the catalog; the column does not need to --- repeat it. Safe on a fresh database, where the table does not exist yet. -ALTER TABLE IF EXISTS board_cards DROP CONSTRAINT IF EXISTS board_cards_kind_check; diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/seeding/BoardCardKindConstraintRelaxerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/seeding/BoardCardKindConstraintRelaxerTest.kt new file mode 100644 index 00000000..4683dce8 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/seeding/BoardCardKindConstraintRelaxerTest.kt @@ -0,0 +1,35 @@ +package com.sprintstart.sprintstartbackend.onboarding.seeding + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.springframework.boot.DefaultApplicationArguments +import org.springframework.dao.DataAccessResourceFailureException +import org.springframework.jdbc.core.JdbcTemplate + +class BoardCardKindConstraintRelaxerTest { + private val jdbcTemplate: JdbcTemplate = mockk(relaxed = true) + private val relaxer = BoardCardKindConstraintRelaxer(jdbcTemplate) + + @Test + fun `drops the kind check idempotently on startup`() { + relaxer.run(DefaultApplicationArguments()) + + // IF EXISTS twice: a fresh database without the table, and one whose constraint is already + // gone, must both start cleanly. + verify { + jdbcTemplate.execute( + "ALTER TABLE IF EXISTS board_cards DROP CONSTRAINT IF EXISTS board_cards_kind_check", + ) + } + } + + @Test + fun `a database that refuses does not stop the app from starting`() { + every { jdbcTemplate.execute(any()) } throws DataAccessResourceFailureException("no") + + assertDoesNotThrow { relaxer.run(DefaultApplicationArguments()) } + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt index f0ac3bbd..acac0049 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardServiceTest.kt @@ -693,6 +693,33 @@ class BoardServiceTest { ) } + @Test + fun `a dismissed current-task card comes back when the hire grabs a task`() { + val board = existingBoard() + val dismissed = card(board, BoardCardKind.CURRENT_TASK, state = BoardCardState.DISMISSED) + every { boardCardRepository.findAllByBoardId(board.id) } returns listOf(dismissed) + + val outcome = service.placeOrRevive(hireId, projectId, BoardCardKind.CURRENT_TASK) + + // Grabbing is the hire saying "this is what I'm working on", which is what the card says. + assertEquals(BoardService.PlacementOutcome.PLACED, outcome) + assertEquals(BoardCardState.ACTIVE, dismissed.state) + verify { boardCardRepository.save(dismissed) } + } + + @Test + fun `the mentor's place still leaves a dismissed card alone`() { + val board = existingBoard() + val dismissed = card(board, BoardCardKind.CURRENT_TASK, state = BoardCardState.DISMISSED) + every { boardCardRepository.findAllByBoardId(board.id) } returns listOf(dismissed) + + assertEquals( + BoardService.PlacementOutcome.DISMISSED_BY_HIRE, + service.place(hireId, projectId, BoardCardKind.CURRENT_TASK), + ) + assertEquals(BoardCardState.DISMISSED, dismissed.state) + } + // ---- the hire removes (slice 1) ---- @Test @@ -787,6 +814,23 @@ class BoardServiceTest { assertEquals(listOf("You have worked in this repository before"), tasks.first().reasons) } + @Test + fun `the suggestions and the pool share one ranking per board read`() { + val board = existingBoard() + every { boardCardRepository.findAllByBoardId(board.id) } returns listOf( + card(board, BoardCardKind.SUGGESTED_TASKS, position = 0), + card(board, BoardCardKind.TASK_POOL, position = 1), + ) + every { starterWorkTaskProposalService.matchForUserId(hireId, projectId) } returns listOf( + ranked("Fix a typo", listOf("You have worked in this repository before")), + ) + + service.getBoard(hireId, projectId) + + // A pass over the whole live pool plus a responsiveness read — once, not once per card. + verify(exactly = 1) { starterWorkTaskProposalService.matchForUserId(hireId, projectId) } + } + @Test fun `the task pool card lists the whole pool in rank order and marks the current task`() { val board = existingBoard() diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalServiceTest.kt index 61bdec5e..2fc3de19 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/UserGoalServiceTest.kt @@ -107,7 +107,9 @@ class UserGoalServiceTest { service.claimForMe(authId, projectId, proposal.id) - verify { boardService.place(userId, projectId, BoardCardKind.CURRENT_TASK) } + // `placeOrRevive`, not `place`: a card the hire dismissed back when it had nothing on it + // has to come back once they grab something. + verify { boardService.placeOrRevive(userId, projectId, BoardCardKind.CURRENT_TASK) } } @Test @@ -143,6 +145,8 @@ class UserGoalServiceTest { // A PM curates which tasks exist; a hire may pick from that set, not extend it. assertEquals(HttpStatus.CONFLICT, exception.statusCode) verify(exactly = 0) { userGoalRepository.save(any()) } + // Nothing was claimed, so nothing is pinned — the order inside claimForMe matters. + verify(exactly = 0) { boardService.placeOrRevive(any(), any(), any()) } } }