From 505b5a69b458cd4c49fadea21faee78e8c6c24d0 Mon Sep 17 00:00:00 2001 From: BabuPlk Date: Wed, 30 Sep 2026 14:20:50 +0000 Subject: [PATCH 1/2] Persist the dashboard layout server-side (#213) Adds GET/PUT/DELETE /api/v1/users/me/dashboard/layout: one arrangement per user, stored as JSON with the client's layout version. A missing layout or one written under another version answers with the default instead of a 404. The caller is always resolved from the token, and a user's layout is removed together with the user. The per-board arrangement already lives in /me/board/structure. --- .../controller/DashboardLayoutController.kt | 131 ++++++++++++++ .../user/model/entity/DashboardLayout.kt | 45 +++++ .../entity/DashboardLayoutItemPayload.kt | 36 ++++ .../dashboard/SaveDashboardLayoutRequest.kt | 22 +++ .../dashboard/DashboardLayoutResponse.kt | 17 ++ .../repository/DashboardLayoutRepository.kt | 41 +++++ .../user/service/DashboardLayoutService.kt | 117 +++++++++++++ .../user/service/UserService.kt | 3 + .../migration/V21__add_dashboard_layouts.sql | 27 +++ .../user/UserServiceTest.kt | 4 + .../DashboardLayoutControllerTest.kt | 164 ++++++++++++++++++ .../service/DashboardLayoutServiceTest.kt | 156 +++++++++++++++++ 12 files changed, 763 insertions(+) create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutController.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayout.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayoutItemPayload.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/SaveDashboardLayoutRequest.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/response/dashboard/DashboardLayoutResponse.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/user/repository/DashboardLayoutRepository.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutService.kt create mode 100644 src/main/resources/db/migration/V21__add_dashboard_layouts.sql create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutControllerTest.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutServiceTest.kt diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutController.kt new file mode 100644 index 000000000..11e50363f --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutController.kt @@ -0,0 +1,131 @@ +package com.sprintstart.sprintstartbackend.user.controller + +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.SaveDashboardLayoutRequest +import com.sprintstart.sprintstartbackend.user.model.response.dashboard.DashboardLayoutResponse +import com.sprintstart.sprintstartbackend.user.service.DashboardLayoutService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +/** + * How the signed-in user has arranged their dashboard. + * + * Self-serve only: the user is always resolved from the token and no endpoint takes a user id, so + * there is no "read somebody else's layout" to get the authorisation wrong on. + * + * The per-board arrangement (areas, folds, sizes, pins on `/board`) is a separate document per + * board — see `BoardStructureController`. This one is the user-level half: one dashboard per person. + */ +@RestController +@RequestMapping("/api/v1/users") +@Tag( + name = "Current User - Dashboard layout", + description = "Which widgets you placed on your dashboard, in which order and at which size", +) +class DashboardLayoutController( + private val dashboardLayoutService: DashboardLayoutService, +) { + /** + * Returns the caller's dashboard arrangement for the given client layout version. + * + * No arrangement, or one written under another version, answers 200 with no items and a null + * `updatedAt`, which tells the client to show its default. + * + * @param jwt The authenticated JWT containing the caller subject. + * @param version The layout version the client understands. + * @return The stored arrangement, or the empty one. + */ + @Operation( + summary = "How I have arranged my dashboard", + description = "Returns the widgets placed on the caller's dashboard, in order, with their sizes.\n\n" + + "`version` is the layout version the client understands. A layout stored under any " + + "other version, or no layout at all, answers with no items and a null `updatedAt` " + + "rather than a 404: it means \"show the default\".", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Arrangement returned, possibly empty"), + ApiResponse(responseCode = "401", description = "Authentication required"), + ApiResponse(responseCode = "404", description = "User not found"), + ], + ) + @ResponseStatus(HttpStatus.OK) + @GetMapping("/me/dashboard/layout") + @PreAuthorize("hasAnyRole('USER', 'PM', 'HR', 'ADMIN')") + fun getMyLayout( + @Parameter(hidden = true) + @AuthenticationPrincipal jwt: Jwt, + @RequestParam version: Int, + ): DashboardLayoutResponse = dashboardLayoutService.read(jwt.subject, version) + + /** + * Replaces the caller's dashboard arrangement. + * + * @param jwt The authenticated JWT containing the caller subject. + * @param request The whole arrangement and the version it was written under. + * @return The stored arrangement with its new `updatedAt`. + */ + @Operation( + summary = "Arrange my dashboard", + description = "Replaces the whole arrangement. Send all of it rather than the part that " + + "changed. Widget ids and sizes are the client's vocabulary and are stored as given; " + + "the client drops what it no longer knows when it reads them back.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Arrangement stored"), + ApiResponse(responseCode = "400", description = "Invalid arrangement"), + ApiResponse(responseCode = "401", description = "Authentication required"), + ApiResponse(responseCode = "404", description = "User not found"), + ], + ) + @ResponseStatus(HttpStatus.OK) + @PutMapping("/me/dashboard/layout") + @PreAuthorize("hasAnyRole('USER', 'PM', 'HR', 'ADMIN')") + fun saveMyLayout( + @Parameter(hidden = true) + @AuthenticationPrincipal jwt: Jwt, + @RequestBody @Valid request: SaveDashboardLayoutRequest, + ): DashboardLayoutResponse = dashboardLayoutService.write(jwt.subject, request) + + /** + * Forgets the caller's dashboard arrangement, so the next read answers with the default. + * + * @param jwt The authenticated JWT containing the caller subject. + */ + @Operation( + summary = "Reset my dashboard", + description = "Removes the stored arrangement. Succeeds whether or not one was stored.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "Arrangement removed"), + ApiResponse(responseCode = "401", description = "Authentication required"), + ApiResponse(responseCode = "404", description = "User not found"), + ], + ) + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/me/dashboard/layout") + @PreAuthorize("hasAnyRole('USER', 'PM', 'HR', 'ADMIN')") + fun resetMyLayout( + @Parameter(hidden = true) + @AuthenticationPrincipal jwt: Jwt, + ) { + dashboardLayoutService.clear(jwt.subject) + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayout.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayout.kt new file mode 100644 index 000000000..353d9985b --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayout.kt @@ -0,0 +1,45 @@ +package com.sprintstart.sprintstartbackend.user.model.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.Instant +import java.util.UUID + +/** + * How one user has arranged their dashboard. + * + * One row per user, created on first write: a user who has not arranged anything gets the default + * dashboard, and an empty row saying so would tell nobody anything. + * + * Keyed by user and not by project, because the dashboard is one per person — a hire on two + * projects has two board arrangements (see `board_structures`) and one dashboard. The widgets + * themselves decide per project what they show. + * + * The arrangement is JSON in [payload], read and written whole, the same bargain the board's + * arrangement makes. [version] is the client's layout version it was written under; see + * `DashboardLayoutService` for what happens when it no longer matches. + */ +@Entity +@Table( + name = "dashboard_layouts", + uniqueConstraints = [ + UniqueConstraint(name = "uq_dashboard_layouts_user", columnNames = ["user_id"]), + ], +) +class DashboardLayout( + @Id + val id: UUID = UUID.randomUUID(), + @Column(name = "user_id", nullable = false) + val userId: UUID, + @Column(nullable = false) + var version: Int, + @Column(columnDefinition = "TEXT", nullable = false) + var payload: String, + @Column(name = "created_at", nullable = false) + val createdAt: Instant = Instant.now(), + @Column(name = "updated_at", nullable = false) + var updatedAt: Instant = Instant.now(), +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayoutItemPayload.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayoutItemPayload.kt new file mode 100644 index 000000000..6c87b354c --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayoutItemPayload.kt @@ -0,0 +1,36 @@ +package com.sprintstart.sprintstartbackend.user.model.entity + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import kotlinx.serialization.Serializable + +/** + * How much of a dashboard arrangement the server will take. + * + * A dashboard holds around a dozen widgets; the numbers are far above anything a person arranges + * by hand and low enough that one client cannot decide how large a stored row gets. + */ +object DashboardLayoutLimits { + /** Placed widgets. The catalog has about a dozen. */ + const val ITEMS = 100 + + /** A widget id or size name: short strings the client defines. */ + const val SHORT_TEXT = 100 +} + +/** + * One placed widget: which one, and how large. Order in the list is the reading order on screen. + * + * Both fields are strings the client owns. The catalog of widgets and the size vocabulary live in + * the frontend and change there; checking them here would mean every new widget needs a backend + * release. The client already drops ids and sizes it does not know when it reads a layout back. + */ +@Serializable +data class DashboardLayoutItemPayload( + @field:NotBlank + @field:Size(max = DashboardLayoutLimits.SHORT_TEXT) + val id: String, + @field:NotBlank + @field:Size(max = DashboardLayoutLimits.SHORT_TEXT) + val size: String, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/SaveDashboardLayoutRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/SaveDashboardLayoutRequest.kt new file mode 100644 index 000000000..a727726ce --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/SaveDashboardLayoutRequest.kt @@ -0,0 +1,22 @@ +package com.sprintstart.sprintstartbackend.user.model.request.dashboard + +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutItemPayload +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutLimits +import jakarta.validation.Valid +import jakarta.validation.constraints.Positive +import jakarta.validation.constraints.Size + +/** + * The whole dashboard arrangement, replacing whatever was stored. + * + * Sent whole rather than as a patch: the arrangement is small and the client holds all of it. + * [version] is the client's layout version (`LAYOUT_VERSION` in the frontend) the items were + * written under. + */ +data class SaveDashboardLayoutRequest( + @field:Positive + val version: Int, + @field:Size(max = DashboardLayoutLimits.ITEMS) + @field:Valid + val items: List, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/response/dashboard/DashboardLayoutResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/response/dashboard/DashboardLayoutResponse.kt new file mode 100644 index 000000000..a2bee2524 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/response/dashboard/DashboardLayoutResponse.kt @@ -0,0 +1,17 @@ +package com.sprintstart.sprintstartbackend.user.model.response.dashboard + +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutItemPayload +import java.time.Instant + +/** + * How this user has arranged their dashboard. + * + * [updatedAt] is null when there is no arrangement to use — none stored, or one stored under a + * different version — which means "show the default". An empty [items] with a non-null + * [updatedAt] is a different fact: somebody who took every widget off on purpose. + */ +data class DashboardLayoutResponse( + val version: Int, + val items: List, + val updatedAt: Instant?, +) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/repository/DashboardLayoutRepository.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/repository/DashboardLayoutRepository.kt new file mode 100644 index 000000000..476ef630d --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/repository/DashboardLayoutRepository.kt @@ -0,0 +1,41 @@ +package com.sprintstart.sprintstartbackend.user.repository + +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayout +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.Instant +import java.util.UUID + +interface DashboardLayoutRepository : JpaRepository { + fun findByUserId(userId: UUID): DashboardLayout? + + @Modifying + fun deleteByUserId(userId: UUID) + + /** + * Writes a user's dashboard arrangement, whether or not they already have one. + * + * One statement rather than "look, then insert or update", for the same reason as the board's + * arrangement: two tabs saving a first arrangement must resolve as last-write-wins, not as a + * constraint violation. [id] is only used when this inserts. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query( + value = """ + INSERT INTO dashboard_layouts (id, user_id, version, payload, created_at, updated_at) + VALUES (:id, :userId, :version, :payload, :now, :now) + ON CONFLICT (user_id) DO UPDATE + SET version = EXCLUDED.version, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at + """, + nativeQuery = true, + ) + fun upsert( + @Param("id") id: UUID, + @Param("userId") userId: UUID, + @Param("version") version: Int, + @Param("payload") payload: String, + @Param("now") now: Instant, + ) +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutService.kt new file mode 100644 index 000000000..7d7bd608e --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutService.kt @@ -0,0 +1,117 @@ +package com.sprintstart.sprintstartbackend.user.service + +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayout +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutItemPayload +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.SaveDashboardLayoutRequest +import com.sprintstart.sprintstartbackend.user.model.response.dashboard.DashboardLayoutResponse +import com.sprintstart.sprintstartbackend.user.repository.DashboardLayoutRepository +import com.sprintstart.sprintstartbackend.user.repository.UserRepository +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import java.time.Instant +import java.util.UUID + +/** + * Reads, writes and forgets how a user has arranged their dashboard. + * + * Every method takes the caller's auth id and nothing else that names a user, so there is no way + * to ask for somebody else's arrangement — a layout is a personal preference, and whether a PM + * should ever see a hire's is a product question this deliberately does not answer. + * + * **Versioned like the client does it.** The client stamps a layout with its `LAYOUT_VERSION` and + * throws a layout of another version away rather than half-migrating it. The server does the + * same: a read names the version the client understands, and a stored layout of any other version + * answers exactly as "nothing stored" does, so the client falls back to its default. The version is + * the client's rather than a constant here because only the client knows what its widgets and + * sizes mean — a server-side number would have to be bumped in lockstep with every frontend change + * to the vocabulary. + */ +@Service +class DashboardLayoutService( + private val dashboardLayoutRepository: DashboardLayoutRepository, + private val userRepository: UserRepository, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + /** + * The caller's arrangement, or an empty one with a null `updatedAt` when there is none to use. + * + * "None to use" covers no row, a row written under a different [version], and a row that can + * no longer be parsed. All three mean the same thing to the client — show the default — and a + * 404 for the most ordinary state of a new user would make the client treat real errors the + * same way. + */ + @Transactional(readOnly = true) + fun read(authId: String, version: Int): DashboardLayoutResponse { + val userId = resolveUserId(authId) + val stored = dashboardLayoutRepository.findByUserId(userId) + ?: return defaultLayout(version) + + if (stored.version != version) return defaultLayout(version) + + val items = decode(stored) ?: return defaultLayout(version) + + return DashboardLayoutResponse(stored.version, items, stored.updatedAt) + } + + /** + * Replaces the caller's arrangement. + * + * Last write wins: two tabs are the realistic conflict, and an arrangement is a statement about + * the whole dashboard that cannot be merged with another one. + */ + @Transactional + fun write(authId: String, request: SaveDashboardLayoutRequest): DashboardLayoutResponse { + val userId = resolveUserId(authId) + val now = Instant.now() + + dashboardLayoutRepository.upsert( + id = UUID.randomUUID(), + userId = userId, + version = request.version, + payload = json.encodeToString(itemsSerializer, request.items), + now = now, + ) + + return DashboardLayoutResponse(request.version, request.items, now) + } + + /** Forgets the caller's arrangement, so the next read answers with the default. */ + @Transactional + fun clear(authId: String) { + dashboardLayoutRepository.deleteByUserId(resolveUserId(authId)) + } + + private fun decode(stored: DashboardLayout): List? = + try { + json.decodeFromString(itemsSerializer, stored.payload) + } catch (e: SerializationException) { + logger.warn( + "Dashboard layout {} of user {} could not be read and was answered as the default; " + + "the next write replaces it", + stored.id, + stored.userId, + e, + ) + null + } + + private fun resolveUserId(authId: String): UUID = + userRepository.findIdByAuthId(authId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "User with authId: $authId not found") + } + + private fun defaultLayout(version: Int): DashboardLayoutResponse = + DashboardLayoutResponse(version, emptyList(), null) + + private companion object { + val json = Json { ignoreUnknownKeys = true } + val itemsSerializer = ListSerializer(DashboardLayoutItemPayload.serializer()) + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserService.kt index 0caf702e4..4abaf9eb5 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserService.kt @@ -14,6 +14,7 @@ import com.sprintstart.sprintstartbackend.user.model.request.user.UpdateUserEnab import com.sprintstart.sprintstartbackend.user.model.response.project.MyProjectResponse import com.sprintstart.sprintstartbackend.user.model.response.user.DeleteUserResponse import com.sprintstart.sprintstartbackend.user.model.response.user.GetUserResponse +import com.sprintstart.sprintstartbackend.user.repository.DashboardLayoutRepository import com.sprintstart.sprintstartbackend.user.repository.ProjectRepository import com.sprintstart.sprintstartbackend.user.repository.UserRepository import org.springframework.context.ApplicationEventPublisher @@ -38,6 +39,7 @@ class UserService( private val keycloakAdminClient: KeycloakAdminClient, private val githubLoginService: GithubLoginService, private val jiraDisplayNameService: JiraDisplayNameService, + private val dashboardLayoutRepository: DashboardLayoutRepository, ) { /** * Returns all persisted users. @@ -224,6 +226,7 @@ class UserService( keycloakAdminClient.deleteUser(authId) projectRepository.clearManagerForUser(id) + dashboardLayoutRepository.deleteByUserId(id) userRepository.deleteRolesByUserId(id) userRepository.deleteProjectionById(id) // What other modules recorded about this person is theirs to erase; only they know what diff --git a/src/main/resources/db/migration/V21__add_dashboard_layouts.sql b/src/main/resources/db/migration/V21__add_dashboard_layouts.sql new file mode 100644 index 000000000..08da259ce --- /dev/null +++ b/src/main/resources/db/migration/V21__add_dashboard_layouts.sql @@ -0,0 +1,27 @@ +-- Records dashboard_layouts, the one row per user that holds how they arranged their dashboard: +-- which widgets are placed, in which order, at which size. The arrangement is JSON in a single +-- TEXT column because it is read and written whole and nothing ever queries inside it; `version` +-- is the client's layout version it was written under. +-- +-- As with the rest of the schema, Hibernate's ddl-auto already emits this table; this migration is +-- idempotent so it stays a no-op against such a schema and exists so the change is recorded in the +-- migration history. +-- +-- The unique constraint on user_id backs the upsert (ON CONFLICT (user_id) DO UPDATE), which makes +-- two tabs saving a first arrangement resolve as last-write-wins. + +CREATE TABLE IF NOT EXISTS dashboard_layouts ( + id UUID NOT NULL, + user_id UUID NOT NULL, + version INTEGER NOT NULL, + payload TEXT NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (id) +); + +ALTER TABLE IF EXISTS dashboard_layouts + DROP CONSTRAINT IF EXISTS uq_dashboard_layouts_user; + +ALTER TABLE IF EXISTS dashboard_layouts + ADD CONSTRAINT uq_dashboard_layouts_user UNIQUE (user_id); diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/UserServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/UserServiceTest.kt index 5d7c2de7d..b2ae2db03 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/UserServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/UserServiceTest.kt @@ -9,6 +9,7 @@ import com.sprintstart.sprintstartbackend.user.model.entity.User import com.sprintstart.sprintstartbackend.user.model.request.user.PatchMeRequest import com.sprintstart.sprintstartbackend.user.model.request.user.PatchUserRequest import com.sprintstart.sprintstartbackend.user.model.request.user.UpdateUserEnabledRequest +import com.sprintstart.sprintstartbackend.user.repository.DashboardLayoutRepository import com.sprintstart.sprintstartbackend.user.repository.ProjectRepository import com.sprintstart.sprintstartbackend.user.repository.UserRepository import com.sprintstart.sprintstartbackend.user.service.GithubLoginService @@ -37,6 +38,7 @@ class UserServiceTest { private val projectRepository: ProjectRepository = mockk() private val eventPublisher: ApplicationEventPublisher = mockk() private val keycloakAdminClient: KeycloakAdminClient = mockk() + private val dashboardLayoutRepository: DashboardLayoutRepository = mockk(relaxed = true) private val githubLoginService = GithubLoginService(userRepository) private val jiraDisplayNameService = JiraDisplayNameService(userRepository) private val userService = UserService( @@ -46,6 +48,7 @@ class UserServiceTest { keycloakAdminClient, githubLoginService, jiraDisplayNameService, + dashboardLayoutRepository, ) @Test @@ -202,6 +205,7 @@ class UserServiceTest { verify(exactly = 1) { keycloakAdminClient.deleteUser("auth-1") } verify(exactly = 1) { userRepository.deleteRolesByUserId(user.id) } verify(exactly = 1) { userRepository.deleteProjectionById(user.id) } + verify(exactly = 1) { dashboardLayoutRepository.deleteByUserId(user.id) } // Other modules hold data about this person that only they can reach. verify(exactly = 1) { eventPublisher.publishEvent(UserDeletedEvent(user.id)) } } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutControllerTest.kt new file mode 100644 index 000000000..65e3dfe0f --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutControllerTest.kt @@ -0,0 +1,164 @@ +package com.sprintstart.sprintstartbackend.user.controller + +import com.ninjasquad.springmockk.MockkBean +import com.sprintstart.sprintstartbackend.config.SecurityConfig +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutItemPayload +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.SaveDashboardLayoutRequest +import com.sprintstart.sprintstartbackend.user.model.response.dashboard.DashboardLayoutResponse +import com.sprintstart.sprintstartbackend.user.service.DashboardLayoutService +import io.mockk.every +import io.mockk.just +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.Instant +import kotlin.test.assertEquals + +@WebMvcTest(DashboardLayoutController::class) +@Import(SecurityConfig::class) +@AutoConfigureMockMvc +class DashboardLayoutControllerTest( + @Autowired private val mockMvc: MockMvc, +) { + @MockkBean + private lateinit var dashboardLayoutService: DashboardLayoutService + + @MockkBean + private lateinit var jwtDecoder: JwtDecoder + + private val path = "/api/v1/users/me/dashboard/layout" + private val authId = "auth-alice" + + private fun jwtWithSubject(subject: String, vararg roles: String): JwtRequestPostProcessor = + jwt() + .jwt { jwt -> + jwt.subject(subject) + jwt.claim("realm_access", mapOf("roles" to roles.toList())) + }.authorities(roles.map { SimpleGrantedAuthority("ROLE_$it") }) + + private val userJwt = jwtWithSubject(authId, "USER") + + @Test + fun `reads the caller's layout, resolved from the token`() { + every { dashboardLayoutService.read(authId, 2) } returns DashboardLayoutResponse( + version = 2, + items = listOf(DashboardLayoutItemPayload("greeting", "wide")), + updatedAt = Instant.parse("2026-09-30T10:00:00Z"), + ) + + mockMvc + .perform(get(path).param("version", "2").with(userJwt)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.version").value(2)) + .andExpect(jsonPath("$.items[0].id").value("greeting")) + .andExpect(jsonPath("$.items[0].size").value("wide")) + .andExpect(jsonPath("$.updatedAt").exists()) + + verify(exactly = 1) { dashboardLayoutService.read(authId, 2) } + } + + @Test + fun `no stored layout is a 200 with no items, not a 404`() { + every { dashboardLayoutService.read(authId, 2) } returns DashboardLayoutResponse(2, emptyList(), null) + + mockMvc + .perform(get(path).param("version", "2").with(userJwt)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.items").isEmpty) + .andExpect(jsonPath("$.updatedAt").doesNotExist()) + } + + @Test + fun `a PM without the user role can arrange their own dashboard too`() { + every { dashboardLayoutService.read("auth-pm", 2) } returns DashboardLayoutResponse(2, emptyList(), null) + + mockMvc + .perform(get(path).param("version", "2").with(jwtWithSubject("auth-pm", "PM"))) + .andExpect(status().isOk) + } + + @Test + fun `writes the whole arrangement for the caller`() { + val twoWidgets = """{"version":2,"items":[{"id":"skills","size":"small"},""" + + """{"id":"greeting","size":"wide"}]}""" + val request = slot() + every { dashboardLayoutService.write(authId, capture(request)) } answers { + DashboardLayoutResponse(request.captured.version, request.captured.items, Instant.now()) + } + + mockMvc + .perform( + put(path) + .contentType(MediaType.APPLICATION_JSON) + .content(twoWidgets) + .with(userJwt), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.items[1].id").value("greeting")) + + assertEquals(2, request.captured.version) + assertEquals(listOf("skills", "greeting"), request.captured.items.map { it.id }) + } + + @Test + fun `an item without an id is refused`() { + mockMvc + .perform( + put(path) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"version":2,"items":[{"id":"","size":"small"}]}""") + .with(userJwt), + ).andExpect(status().isBadRequest) + } + + @Test + fun `a layout without a version is refused`() { + mockMvc + .perform( + put(path) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"version":0,"items":[]}""") + .with(userJwt), + ).andExpect(status().isBadRequest) + } + + @Test + fun `reset forgets the caller's layout`() { + every { dashboardLayoutService.clear(authId) } just runs + + mockMvc + .perform(delete(path).with(userJwt)) + .andExpect(status().isNoContent) + + verify(exactly = 1) { dashboardLayoutService.clear(authId) } + } + + @Test + fun `nobody can read a layout without signing in`() { + mockMvc + .perform(get(path).param("version", "2")) + .andExpect(status().isUnauthorized) + } + + @Test + fun `there is no endpoint that reads another user's layout by id`() { + mockMvc + .perform(get("/api/v1/users/some-other-user/dashboard/layout").param("version", "2").with(userJwt)) + .andExpect(status().isNotFound) + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutServiceTest.kt new file mode 100644 index 000000000..3aeb43103 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutServiceTest.kt @@ -0,0 +1,156 @@ +package com.sprintstart.sprintstartbackend.user.service + +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayout +import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutItemPayload +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.SaveDashboardLayoutRequest +import com.sprintstart.sprintstartbackend.user.repository.DashboardLayoutRepository +import com.sprintstart.sprintstartbackend.user.repository.UserRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import java.time.Instant +import java.util.Optional +import java.util.UUID + +class DashboardLayoutServiceTest { + private val dashboardLayoutRepository: DashboardLayoutRepository = mockk(relaxed = true) + private val userRepository: UserRepository = mockk() + + private lateinit var service: DashboardLayoutService + + private val authId = "auth-alice" + private val userId: UUID = UUID.randomUUID() + private val otherAuthId = "auth-bob" + private val otherUserId: UUID = UUID.randomUUID() + + private val items = listOf( + DashboardLayoutItemPayload(id = "greeting", size = "wide"), + DashboardLayoutItemPayload(id = "onboarding", size = "medium"), + DashboardLayoutItemPayload(id = "skills", size = "small"), + ) + + @BeforeEach + fun setUp() { + service = DashboardLayoutService(dashboardLayoutRepository, userRepository) + every { userRepository.findIdByAuthId(authId) } returns Optional.of(userId) + every { userRepository.findIdByAuthId(otherAuthId) } returns Optional.of(otherUserId) + } + + @Test + fun `a user who never arranged anything gets the default rather than an error`() { + every { dashboardLayoutRepository.findByUserId(userId) } returns null + + val read = service.read(authId, 2) + + assertTrue(read.items.isEmpty()) + assertNull(read.updatedAt) + assertEquals(2, read.version) + } + + @Test + fun `an arrangement survives the round trip in order and with its sizes`() { + val payload = slot() + every { + dashboardLayoutRepository.upsert(any(), userId, 2, capture(payload), any()) + } returns Unit + + val written = service.write(authId, SaveDashboardLayoutRequest(version = 2, items = items)) + assertNotNull(written.updatedAt) + + every { dashboardLayoutRepository.findByUserId(userId) } returns stored(userId, 2, payload.captured) + + val read = service.read(authId, 2) + + assertEquals(items, read.items) + assertEquals(2, read.version) + assertNotNull(read.updatedAt) + } + + @Test + fun `an arrangement emptied on purpose reads back as empty but not as the default`() { + every { dashboardLayoutRepository.findByUserId(userId) } returns stored(userId, 2, "[]") + + val read = service.read(authId, 2) + + assertTrue(read.items.isEmpty()) + assertNotNull(read.updatedAt) + } + + @Test + fun `a layout stored under another version falls back to the default`() { + val payload = """[{"id":"greeting","size":"large"}]""" + every { dashboardLayoutRepository.findByUserId(userId) } returns stored(userId, 1, payload) + + val read = service.read(authId, 2) + + // Discarded rather than half-migrated, the same trade the client makes. + assertTrue(read.items.isEmpty()) + assertNull(read.updatedAt) + assertEquals(2, read.version) + } + + @Test + fun `a layout that can no longer be parsed answers as the default instead of failing`() { + every { dashboardLayoutRepository.findByUserId(userId) } returns stored(userId, 2, "not json") + + val read = service.read(authId, 2) + + assertTrue(read.items.isEmpty()) + assertNull(read.updatedAt) + } + + @Test + fun `a user only ever reads their own layout, never somebody else's`() { + every { dashboardLayoutRepository.findByUserId(otherUserId) } returns + stored(otherUserId, 2, """[{"id":"team-overview","size":"wide"}]""") + every { dashboardLayoutRepository.findByUserId(userId) } returns null + + val read = service.read(authId, 2) + + assertTrue(read.items.isEmpty()) + verify(exactly = 1) { dashboardLayoutRepository.findByUserId(userId) } + verify(exactly = 0) { dashboardLayoutRepository.findByUserId(otherUserId) } + } + + @Test + fun `a write lands on the caller's row and nobody else's`() { + service.write(authId, SaveDashboardLayoutRequest(version = 2, items = items)) + + verify(exactly = 1) { dashboardLayoutRepository.upsert(any(), userId, 2, any(), any()) } + verify(exactly = 0) { dashboardLayoutRepository.upsert(any(), otherUserId, any(), any(), any()) } + } + + @Test + fun `a reset forgets only the caller's layout`() { + service.clear(authId) + + verify(exactly = 1) { dashboardLayoutRepository.deleteByUserId(userId) } + verify(exactly = 0) { dashboardLayoutRepository.deleteByUserId(otherUserId) } + } + + @Test + fun `an unknown caller is a 404`() { + every { userRepository.findIdByAuthId("nobody") } returns Optional.empty() + + val error = assertThrows { service.read("nobody", 2) } + + assertEquals(HttpStatus.NOT_FOUND, error.statusCode) + } + + private fun stored(owner: UUID, version: Int, payload: String) = DashboardLayout( + userId = owner, + version = version, + payload = payload, + updatedAt = Instant.parse("2026-09-30T10:00:00Z"), + ) +} From c9031b1d459bba336d70a2fbe12b7db7814d7964 Mon Sep 17 00:00:00 2001 From: BabuPlk Date: Wed, 30 Sep 2026 15:07:58 +0000 Subject: [PATCH 2/2] Move the dashboard layout payload out of model.entity DashboardLayoutItemPayload and DashboardLayoutLimits are wire DTOs, not JPA entities, so they now live in model.request.dashboard. Also drops the controller test that only proved an unmapped path answers 404; the service tests cover that a layout is always resolved from the token. --- .../dashboard}/DashboardLayoutItemPayload.kt | 2 +- .../request/dashboard/SaveDashboardLayoutRequest.kt | 2 -- .../model/response/dashboard/DashboardLayoutResponse.kt | 2 +- .../user/service/DashboardLayoutService.kt | 2 +- .../user/controller/DashboardLayoutControllerTest.kt | 9 +-------- .../user/service/DashboardLayoutServiceTest.kt | 2 +- 6 files changed, 5 insertions(+), 14 deletions(-) rename src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/{entity => request/dashboard}/DashboardLayoutItemPayload.kt (94%) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayoutItemPayload.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/DashboardLayoutItemPayload.kt similarity index 94% rename from src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayoutItemPayload.kt rename to src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/DashboardLayoutItemPayload.kt index 6c87b354c..1bc0fd444 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/entity/DashboardLayoutItemPayload.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/DashboardLayoutItemPayload.kt @@ -1,4 +1,4 @@ -package com.sprintstart.sprintstartbackend.user.model.entity +package com.sprintstart.sprintstartbackend.user.model.request.dashboard import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Size diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/SaveDashboardLayoutRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/SaveDashboardLayoutRequest.kt index a727726ce..9481dfc38 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/SaveDashboardLayoutRequest.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/request/dashboard/SaveDashboardLayoutRequest.kt @@ -1,7 +1,5 @@ package com.sprintstart.sprintstartbackend.user.model.request.dashboard -import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutItemPayload -import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutLimits import jakarta.validation.Valid import jakarta.validation.constraints.Positive import jakarta.validation.constraints.Size diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/response/dashboard/DashboardLayoutResponse.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/response/dashboard/DashboardLayoutResponse.kt index a2bee2524..933d39e5f 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/response/dashboard/DashboardLayoutResponse.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/model/response/dashboard/DashboardLayoutResponse.kt @@ -1,6 +1,6 @@ package com.sprintstart.sprintstartbackend.user.model.response.dashboard -import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutItemPayload +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.DashboardLayoutItemPayload import java.time.Instant /** diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutService.kt index 7d7bd608e..153547023 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutService.kt @@ -1,7 +1,7 @@ package com.sprintstart.sprintstartbackend.user.service import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayout -import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutItemPayload +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.DashboardLayoutItemPayload import com.sprintstart.sprintstartbackend.user.model.request.dashboard.SaveDashboardLayoutRequest import com.sprintstart.sprintstartbackend.user.model.response.dashboard.DashboardLayoutResponse import com.sprintstart.sprintstartbackend.user.repository.DashboardLayoutRepository diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutControllerTest.kt index 65e3dfe0f..ac2f5538a 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/controller/DashboardLayoutControllerTest.kt @@ -2,7 +2,7 @@ package com.sprintstart.sprintstartbackend.user.controller import com.ninjasquad.springmockk.MockkBean import com.sprintstart.sprintstartbackend.config.SecurityConfig -import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutItemPayload +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.DashboardLayoutItemPayload import com.sprintstart.sprintstartbackend.user.model.request.dashboard.SaveDashboardLayoutRequest import com.sprintstart.sprintstartbackend.user.model.response.dashboard.DashboardLayoutResponse import com.sprintstart.sprintstartbackend.user.service.DashboardLayoutService @@ -154,11 +154,4 @@ class DashboardLayoutControllerTest( .perform(get(path).param("version", "2")) .andExpect(status().isUnauthorized) } - - @Test - fun `there is no endpoint that reads another user's layout by id`() { - mockMvc - .perform(get("/api/v1/users/some-other-user/dashboard/layout").param("version", "2").with(userJwt)) - .andExpect(status().isNotFound) - } } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutServiceTest.kt index 3aeb43103..63682074a 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/user/service/DashboardLayoutServiceTest.kt @@ -1,7 +1,7 @@ package com.sprintstart.sprintstartbackend.user.service import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayout -import com.sprintstart.sprintstartbackend.user.model.entity.DashboardLayoutItemPayload +import com.sprintstart.sprintstartbackend.user.model.request.dashboard.DashboardLayoutItemPayload import com.sprintstart.sprintstartbackend.user.model.request.dashboard.SaveDashboardLayoutRequest import com.sprintstart.sprintstartbackend.user.repository.DashboardLayoutRepository import com.sprintstart.sprintstartbackend.user.repository.UserRepository