Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -199,6 +201,34 @@ data class BoardSuggestedTaskResponse(
val reasons: List<String>,
)

/**
* 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<BoardPoolTaskResponse>,
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<String>,
/** 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -136,14 +135,21 @@ 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,
projectId = projectId,
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 }
},
)
}

Expand Down Expand Up @@ -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].
*
Expand Down Expand Up @@ -591,12 +624,17 @@ class BoardService(
diagram: BoardDiagram?,
arrivalSteps: List<ResolvedArrivalStep>,
pathSteps: Map<UUID, ResolvedPathStep>,
matches: () -> List<RankedStarterWorkTaskResponse>,
): 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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -831,13 +849,17 @@ class BoardService(
diagram: BoardDiagram? = null,
arrivalSteps: List<ResolvedArrivalStep> = emptyList(),
pathSteps: Map<UUID, ResolvedPathStep> = emptyMap(),
// A single card rendered on its own ranks for itself; a whole board shares one ranking.
matches: () -> List<RankedStarterWorkTaskResponse> = {
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),
)

/**
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RankedStarterWorkTaskResponse>): 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<RankedStarterWorkTaskResponse>, 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,
)
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -447,19 +445,16 @@ class BuddyActionService(
}

private fun claimGoal(
userId: UUID,
authId: String,
projectId: UUID,
taskId: UUID?,
): BuddyActionResponse {
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. " +
Expand Down
Loading
Loading