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 @@ -2,12 +2,21 @@ package com.sprintstart.sprintstartbackend.onboarding.repository

import com.sprintstart.sprintstartbackend.onboarding.model.entity.OnboardingPath
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.transaction.annotation.Transactional
import java.util.Optional
import java.util.UUID

interface OnboardingPathRepository : JpaRepository<OnboardingPath, UUID> {
fun findOnboardingPathByUserId(userId: UUID): Optional<OnboardingPath>

/**
* Deletes a user's path with everything under it.
*
* Transactional here rather than left to each caller: a derived delete throws
* `TransactionRequiredException` when it runs with no transaction, and the services that call it
* are not themselves transactional.
*/
@Transactional
fun deleteByUserId(userId: UUID)

fun existsByUserId(userId: UUID): Boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.sprintstart.sprintstartbackend.onboarding.service

import com.sprintstart.sprintstartbackend.onboarding.model.entity.StarterWorkTaskProposal
import com.sprintstart.sprintstartbackend.onboarding.repository.StarterWorkTaskProposalRepository
import com.sprintstart.sprintstartbackend.user.external.ProjectMember
import com.sprintstart.sprintstartbackend.user.external.ProjectMembershipApi
import com.sprintstart.sprintstartbackend.user.external.UserApi
import org.springframework.stereotype.Component
import java.util.UUID

/** An element of somebody's path that belongs to a member of the turn's project. */
data class ScopedElement(
val element: PathElement,
val owner: ProjectMember,
)

/**
* Which onboarding content a project's manager may touch: the paths of the project's members.
*
* Paths belong to people, not projects — a path has one owner and no project — so an element is in
* scope exactly when its owner is a member of the turn's project. Every content tool asks this, the
* reads and the actions alike, and asks it again when a stored proposal is confirmed, so what a
* manager is shown and what they may change cannot disagree.
*
* The other half of the same fact is that a member of several projects has *one* path. Changing it
* changes it for the other projects' managers too; [alsoOn] is what lets a preview say so.
*/
@Component
class ContentScope(
private val pathElements: PathElements,
private val projectMembershipApi: ProjectMembershipApi,
private val userApi: UserApi,
private val starterWorkTaskProposalRepository: StarterWorkTaskProposalRepository,
private val starterWorkScope: StarterWorkScope,
) {
/**
* The [kind] element [id] names, if it sits on the path of a member of [projectId].
*
* Null for everything else — no such element, or one on somebody else's path — and deliberately
* not distinguishable, so the model cannot use a refusal to probe for ids elsewhere.
*/
fun element(kind: PathElementKind, id: UUID?, projectId: UUID): ScopedElement? {
val found = id?.let { pathElements.find(kind, it) } ?: return null
val owner = member(found.ownerId, projectId) ?: return null
return ScopedElement(found, owner)
}

/** The refusal for a stored proposal whose target is gone or out of scope since, or null when it is fine. */
fun missing(kind: PathElementKind, id: UUID?, projectId: UUID): String? =
goneSince(kind).takeIf { element(kind, id, projectId) == null }

/**
* The starter-work task [taskId] names, if its repository is linked to [projectId].
*
* An orientation packet belongs to a task and a project together, but the services behind it only
* check that the task exists. The task's source is what ties it to a project, so that is what is
* asked here — the same question the starter-work area asks of the pool.
*/
fun proposal(taskId: UUID?, projectId: UUID): StarterWorkTaskProposal? =
taskId
?.let { starterWorkTaskProposalRepository.findById(it).orElse(null) }
?.takeIf { starterWorkScope.covers(it.sourceId, projectId) }

/** Everyone on [projectId]. */
fun members(projectId: UUID): List<ProjectMember> = projectMembershipApi.getProjectMembers(projectId)

/** The member [memberId] names on [projectId], or null. */
fun member(memberId: UUID?, projectId: UUID): ProjectMember? =
memberId?.let { id -> projectMembershipApi.getProjectMembers(projectId).firstOrNull { it.userId == id } }

/** The names of the other projects [userId] is on, sorted; empty when they are on this one only. */
fun alsoOn(userId: UUID, projectId: UUID): List<String> =
userApi
.getUsersByIds(listOf(userId))
.firstOrNull()
?.projects
.orEmpty()
.filter { it.projectId != projectId }
.map { it.name }
.sorted()

/**
* The sentence a preview adds when the path it changes also belongs to other projects, or empty.
*
* Said whatever the change is: nothing about a person's path is project-scoped, so there is no
* change that stays here.
*/
fun sharedNote(owner: ProjectMember, projectId: UUID): String {
val others = alsoOn(owner.userId, projectId)
return if (others.isEmpty()) {
""
} else {
"${owner.displayName} is also on ${others.joinToString(", ")}, and has one onboarding path for " +
"all of their projects — so this changes it there too."
}
}
}

internal const val NOT_A_MEMBER_HERE =
"That person is not on this project. Call find_member for the people who are, and pass the member_id " +
"it gives."

internal const val TASK_NOT_HERE =
"That task is not from a repository linked to this project. Call list_starter_work_pool for the tasks " +
"that are, and pass the task_id it gives."

internal const val LEFT_SINCE_HERE = "That person is no longer on this project, so nothing was changed."

/** Whether anything sits after this element among its siblings, so deleting it moves something up. */
internal fun PathElement.hasLaterSiblings(): Boolean = position != null && position < siblings - 1

/** What to tell the model when an id is not in scope, per kind. */
internal fun notInScope(kind: PathElementKind): String {
val where = when (kind) {
PathElementKind.SKIP -> "list_pending_skips"
PathElementKind.FEEDBACK -> "list_feedback"
else -> "get_member_path for somebody who is"
}
return "That ${kind.noun} is not on the onboarding path of anybody on this project. Call $where, and pass " +
"an id from it."
}

/** Why a stored proposal's target is no longer there: gone, or no longer on a member's path. */
internal fun goneSince(kind: PathElementKind): String =
"That ${kind.noun} is gone, or no longer on the path of anybody on this project, so nothing was changed."
Loading
Loading