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 2b4571031..fd39cdc8c 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 9bcaa1443..d4f125005 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/seeding/BoardCardKindConstraintRelaxer.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/seeding/BoardCardKindConstraintRelaxer.kt new file mode 100644 index 000000000..0e5463c84 --- /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 4eef74a9a..ec6a61968 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BoardService.kt @@ -28,7 +28,6 @@ import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardM import com.sprintstart.sprintstartbackend.onboarding.model.response.board.BoardMomentResponse 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 @@ -39,10 +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.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 @@ -136,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, @@ -143,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 } + }, ) } @@ -246,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]. * @@ -591,12 +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.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. @@ -756,26 +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 path card's content, from the same timeline the PM dashboard reads. * @@ -831,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), ) /** @@ -978,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 000000000..e6d798270 --- /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/BuddyActionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyActionService.kt index 4be2048e1..d2dd950e8 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 e11b661e2..d7a4784c7 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,11 @@ 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. 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/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 000000000..4683dce86 --- /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 063c8aac2..acac00498 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 @@ -674,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 @@ -768,6 +814,45 @@ 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() + 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 +990,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 a5b1049e5..e7b29c016 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 d64111ce6..6d16e43e6 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 539eee4f8..2fc3de191 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,21 @@ 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) + + // `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 fun `replaces an existing goal rather than adding a second one`() { stageUser() @@ -127,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()) } } }