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
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.sprintstart.sprintstartbackend.user.model.request.dashboard

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,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.sprintstart.sprintstartbackend.user.model.request.dashboard

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<DashboardLayoutItemPayload>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.sprintstart.sprintstartbackend.user.model.response.dashboard

import com.sprintstart.sprintstartbackend.user.model.request.dashboard.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<DashboardLayoutItemPayload>,
val updatedAt: Instant?,
)
Original file line number Diff line number Diff line change
@@ -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<DashboardLayout, UUID> {
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,
)
}
Loading
Loading