Skip to content
Open
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 @@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.util.UUID

/**
Expand Down Expand Up @@ -87,11 +88,15 @@ class OnboardingPathController(
* This removes the hierarchy root at depth 0. Any nested descendants below that
* root are deleted according to the persistence rules of the underlying model.
*
* PM and admin only: a member deleting their path and building a new one would be the rebuild
* that is the project manager's call (see [ProjectOnboardingPathController.personalizePathForUser]).
*
* @param jwt Authenticated JWT used to resolve the current user.
*/
@Operation(
summary = "Delete current user's onboarding path",
description = "Deletes the onboarding path at hierarchy depth 0 for the authenticated user.",
description = "Deletes the onboarding path at hierarchy depth 0 for the authenticated user. " +
"PM and admin only: members cannot discard their own path.",
)
@ApiResponses(
value = [
Expand All @@ -103,7 +108,7 @@ class OnboardingPathController(
)
@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping("/me/path")
@PreAuthorize("hasRole('USER')")
@PreAuthorize("hasAnyRole('PM', 'ADMIN')")
fun deletePathForMe(
@Parameter(hidden = true)
@AuthenticationPrincipal jwt: Jwt,
Expand Down Expand Up @@ -193,6 +198,7 @@ class OnboardingPathController(
class ProjectOnboardingPathController(
private val onboardingPersonalizationService: OnboardingPersonalizationService,
private val onboardingGenerationRegistry: OnboardingGenerationRegistry,
private val onboardingPathService: OnboardingPathService,
private val userApi: UserApi,
) {
/**
Expand Down Expand Up @@ -245,14 +251,19 @@ class ProjectOnboardingPathController(
* template. The service rejects a project the user is not assigned to. Any existing path is
* replaced. A project must have exactly one active blueprint.
*
* A member builds their *first* path here (or retries one whose every phase failed); rebuilding
* a path with phases in it is the project manager's call ([personalizePathForUser]), because it
* throws away the member's progress. The project's manager and admins may still replace their
* own path from here.
*
* The generation runs detached from this request (see [OnboardingGenerationRegistry]): closing
* the stream does not cancel it, and a request while one is running watches that one instead of
* starting another.
*
* @param projectId The project whose active blueprint seeds the path.
* @return A stream of progress events ending in the new path plus a `done` event.
* @throws ResponseStatusException `403` when the user is not assigned to the project,
* `404` when the user does not exist.
* @throws ResponseStatusException `403` when the user is not assigned to the project, or already
* has a path and does not manage the project; `404` when the user does not exist.
*/
@Operation(
summary = "Create onboarding path from blueprint",
Expand All @@ -269,7 +280,8 @@ class ProjectOnboardingPathController(
ApiResponse(
responseCode = "403",
description = "Insufficient role to create an onboarding path, " +
"or the authenticated user is not assigned to the given project",
"the authenticated user is not assigned to the given project, " +
"or the user already has a path and does not manage the project",
),
ApiResponse(responseCode = "404", description = "No user found for the authenticated user"),
],
Expand All @@ -283,6 +295,64 @@ class ProjectOnboardingPathController(
@Parameter(hidden = true)
@AuthenticationPrincipal jwt: Jwt,
): Flow<OnboardingSseEvent> {
// Watching a running generation stays open to everyone -- including one a PM started for this
// member. Only starting a new one over an existing path is the manager's call.
val startsNewRun = onboardingGenerationRegistry.status(jwt.subject) == null
if (startsNewRun &&
onboardingPathService.hasBuiltPathForMe(jwt.subject) &&
!userApi.canManageProject(jwt.subject, projectId)
) {
throw ResponseStatusException(
HttpStatus.FORBIDDEN,
"Only the project manager can rebuild an existing onboarding path",
)
}
return onboardingGenerationRegistry.startOrAttach(jwt.subject, projectId)
}

/**
* Rebuilds a member's onboarding path from [projectId]'s active blueprint, on the project
* manager's behalf.
*
* The same generation as [personalizePath], run for the member: it replaces their path (and
* with it their progress), and the member's own onboarding page attaches to it like to one they
* started. Closing this stream does not cancel it.
*
* @param projectId The project whose active blueprint seeds the path.
* @param userId The member whose path is rebuilt.
* @return A stream of progress events ending in the new path plus a `done` event.
* @throws ResponseStatusException `403` when the member is not assigned to the project, `404`
* when the member does not exist.
*/
@Operation(
summary = "Rebuild a member's onboarding path",
description = "Rebuilds the member's onboarding path from the project's active blueprint, " +
"replacing the path they have. For the project's manager and admins. Failures after the " +
"stream has opened are reported as an `error` event inside the `200` stream.",
)
@ApiResponses(
value = [
ApiResponse(responseCode = "200", description = "SSE stream of personalization events"),
ApiResponse(responseCode = "401", description = "Authentication required"),
ApiResponse(
responseCode = "403",
description = "Caller does not manage the project, or the member is not assigned to it",
),
ApiResponse(responseCode = "404", description = "No user found with the given ID"),
],
)
@ResponseStatus(HttpStatus.OK)
@PostMapping("/users/{userId}/path/personalize", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
@PreAuthorize("@projectAuth.canManageProject(authentication, #projectId)")
fun personalizePathForUser(
@Parameter(description = "UUID of the project whose active blueprint seeds the path")
@PathVariable projectId: UUID,
@Parameter(description = "UUID of the member whose path is rebuilt")
@PathVariable userId: UUID,
): Flow<OnboardingSseEvent> {
val authId = userApi.getAuthIdByUserId(userId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "No user found with id: $userId")
}
return onboardingGenerationRegistry.startOrAttach(authId, projectId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,23 @@ class OnboardingPathService(
)
}

/**
* Whether the authenticated user has an onboarding path with anything in it. An unknown user
* has none; neither does one whose every phase failed to generate -- an empty path holds no
* progress, so building it again takes nothing away.
*
* @param authId External authentication identifier.
* @return `true` when the user's path has at least one phase.
*/
@Transactional(readOnly = true)
@Tracked("Checking whether the user has a built onboarding path")
fun hasBuiltPathForMe(authId: String): Boolean =
userApi
.getUserIdByAuthId(authId)
.flatMap { userId -> onboardingPathRepository.findByUserId(userId) }
.map { path -> path.phases.isNotEmpty() }
.orElse(false)

/**
* Deletes the onboarding path owned by the authenticated user.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ interface UserApi {
*/
fun getUserIdByAuthId(authId: String): Optional<UUID>

/**
* Resolves the Keycloak authentication subject for an internal SprintStart user ID -- the
* reverse of [getUserIdByAuthId], for acting on somebody else's behalf (a PM rebuilding a
* member's onboarding path).
*
* @param userId Internal SprintStart user identifier.
* @return The matching auth ID when present.
*/
fun getAuthIdByUserId(userId: UUID): Optional<String>

fun getUserByAuthId(authId: String): UserDto

fun searchUsers(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ class UserApiService(
return userRepository.findIdByAuthId(authId)
}

/**
* Resolves the external authentication identifier for an internal user ID.
*
* @param userId Internal user identifier.
* @return The matching auth ID when present.
*/
@Transactional(readOnly = true)
@Tracked("Resolving auth ID by user ID")
override fun getAuthIdByUserId(userId: UUID): Optional<String> {
return userRepository.findAuthIdById(userId)
}

/**
* Retrieves a user by their external authentication identifier.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.sprintstart.sprintstartbackend.onboarding.service.OnboardingPathServi
import com.sprintstart.sprintstartbackend.onboarding.service.OnboardingPersonalizationService
import com.sprintstart.sprintstartbackend.user.external.UserApi
import com.sprintstart.sprintstartbackend.user.external.UserOnboardingProfile
import com.sprintstart.sprintstartbackend.user.external.security.ProjectAuthorization
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
Expand Down Expand Up @@ -56,6 +57,9 @@ class OnboardingPathControllerTest(
@MockkBean
private lateinit var jwtDecoder: JwtDecoder

@MockkBean(name = "projectAuth")
private lateinit var projectAuthorization: ProjectAuthorization

private val pathId = UUID.randomUUID()
private val userId = UUID.randomUUID()
private val projectId = UUID.randomUUID()
Expand All @@ -82,6 +86,7 @@ class OnboardingPathControllerTest(
private val userJwt = jwtWithSubject(authId, "USER")
private val adminJwt = jwtWithSubject(adminAuthId, "USER", "ADMIN")
private val noUserRoleJwt = jwtWithSubject(authId, "NONE")
private val pmJwt = jwtWithSubject(authId, "USER", "PM")

// ========================== /me endpoints ==========================

Expand Down Expand Up @@ -147,7 +152,7 @@ class OnboardingPathControllerTest(
mockMvc
.perform(
delete("/api/v1/onboarding/me/path")
.with(userJwt),
.with(pmJwt),
).andExpect(status().isNoContent)

verify(exactly = 1) {
Expand All @@ -171,6 +176,17 @@ class OnboardingPathControllerTest(
).andExpect(status().isForbidden)
}

@Test
fun `deleteOnboardingPathForMe is refused to a plain member`() {
mockMvc
.perform(
delete("/api/v1/onboarding/me/path")
.with(userJwt),
).andExpect(status().isForbidden)

verify(exactly = 0) { onboardingPathService.deleteOnboardingPathForMe(any()) }
}

@Test
fun `deleteOnboardingPathForMe should return 404 when not found`() {
every { onboardingPathService.deleteOnboardingPathForMe(authId) } throws
Expand All @@ -179,7 +195,7 @@ class OnboardingPathControllerTest(
mockMvc
.perform(
delete("/api/v1/onboarding/me/path")
.with(userJwt),
.with(pmJwt),
).andExpect(status().isNotFound)

verify(exactly = 1) {
Expand All @@ -191,6 +207,8 @@ class OnboardingPathControllerTest(

@Test
fun `personalizePath passes the selected project path variable to the generation registry`() {
every { onboardingGenerationRegistry.status(authId) } returns null
every { onboardingPathService.hasBuiltPathForMe(authId) } returns false
every { onboardingGenerationRegistry.startOrAttach(authId, projectId) } throws
ResponseStatusException(HttpStatus.BAD_REQUEST, "rejected")

Expand All @@ -205,6 +223,107 @@ class OnboardingPathControllerTest(
}
}

@Test
fun `personalizePath refuses a member rebuilding a path they already have`() {
every { onboardingGenerationRegistry.status(authId) } returns null
every { onboardingPathService.hasBuiltPathForMe(authId) } returns true
every { userApi.canManageProject(authId, projectId) } returns false

mockMvc
.perform(
post("/api/v1/projects/$projectId/onboarding/me/path/personalize")
.with(userJwt),
).andExpect(status().isForbidden)

verify(exactly = 0) { onboardingGenerationRegistry.startOrAttach(any(), any()) }
}

@Test
fun `personalizePath lets the project's manager rebuild their own path`() {
every { onboardingGenerationRegistry.status(authId) } returns null
every { onboardingPathService.hasBuiltPathForMe(authId) } returns true
every { userApi.canManageProject(authId, projectId) } returns true
every { onboardingGenerationRegistry.startOrAttach(authId, projectId) } throws
ResponseStatusException(HttpStatus.BAD_REQUEST, "reached")

mockMvc
.perform(
post("/api/v1/projects/$projectId/onboarding/me/path/personalize")
.with(userJwt),
).andExpect(status().isBadRequest)

verify(exactly = 1) { onboardingGenerationRegistry.startOrAttach(authId, projectId) }
}

@Test
fun `personalizePath still attaches a member to a running rebuild over their path`() {
every { onboardingGenerationRegistry.status(authId) } returns
OnboardingGenerationRegistry.GenerationRun(projectId = projectId, startedAt = Instant.now())
every { onboardingGenerationRegistry.startOrAttach(authId, projectId) } throws
ResponseStatusException(HttpStatus.BAD_REQUEST, "reached")

mockMvc
.perform(
post("/api/v1/projects/$projectId/onboarding/me/path/personalize")
.with(userJwt),
).andExpect(status().isBadRequest)

verify(exactly = 0) { onboardingPathService.hasBuiltPathForMe(any()) }
verify(exactly = 1) { onboardingGenerationRegistry.startOrAttach(authId, projectId) }
}

// ========================== PM rebuild of a member's path ==========================

@Test
fun `personalizePathForUser starts the generation under the member's auth id`() {
val memberAuthId = "member-auth-id"
every { projectAuthorization.canManageProject(any(), projectId) } returns true
every { userApi.getAuthIdByUserId(userId) } returns Optional.of(memberAuthId)
every { onboardingGenerationRegistry.startOrAttach(memberAuthId, projectId) } throws
ResponseStatusException(HttpStatus.BAD_REQUEST, "reached")

mockMvc
.perform(
post("/api/v1/projects/$projectId/onboarding/users/$userId/path/personalize")
.with(adminJwt),
).andExpect(status().isBadRequest)

verify(exactly = 1) { onboardingGenerationRegistry.startOrAttach(memberAuthId, projectId) }
}

@Test
fun `personalizePathForUser is refused to anyone who does not manage the project`() {
every { projectAuthorization.canManageProject(any(), projectId) } returns false

mockMvc
.perform(
post("/api/v1/projects/$projectId/onboarding/users/$userId/path/personalize")
.with(userJwt),
).andExpect(status().isForbidden)

verify(exactly = 0) { onboardingGenerationRegistry.startOrAttach(any(), any()) }
}

@Test
fun `personalizePathForUser returns 404 for an unknown member`() {
every { projectAuthorization.canManageProject(any(), projectId) } returns true
every { userApi.getAuthIdByUserId(userId) } returns Optional.empty()

mockMvc
.perform(
post("/api/v1/projects/$projectId/onboarding/users/$userId/path/personalize")
.with(adminJwt),
).andExpect(status().isNotFound)
}

@Test
fun `personalizePathForUser should return 401 when not authenticated`() {
mockMvc
.perform(
post("/api/v1/projects/$projectId/onboarding/users/$userId/path/personalize"),
).andExpect(status().isUnauthorized)
}

@Test
fun `getGenerationStatus reports a running generation and the project's blueprint`() {
val startedAt = Instant.parse("2026-09-15T10:00:00Z")
Expand Down
Loading
Loading