From 32d4f40eec1cce1bf7164e8157d577d8039e4398 Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 28 Sep 2026 10:07:49 +0200 Subject: [PATCH] feat(hint): add admin hints for the dashboard --- readme.md | 11 ++ .../modules/hint/api/AdminHintController.java | 57 ++++++++ .../modules/hint/api/HintController.java | 40 ++++++ .../hint/api/HintExceptionController.java | 28 ++++ .../api/dto/request/CreateHintRequest.java | 15 ++ .../dto/response/AdminHintListResponse.java | 6 + .../api/dto/response/AdminHintResponse.java | 7 + .../api/dto/response/CreatedHintResponse.java | 4 + .../api/dto/response/HintListResponse.java | 6 + .../hint/api/dto/response/HintResponse.java | 6 + .../hint/exception/HintNotFoundException.java | 7 + .../hint/exception/InvalidHintException.java | 7 + .../modules/hint/model/AdminHint.java | 6 + .../auth_service/modules/hint/model/Hint.java | 9 ++ .../modules/hint/model/HintKind.java | 8 ++ .../modules/hint/model/HintStatus.java | 8 ++ .../modules/hint/model/NewHint.java | 6 + .../modules/hint/repo/HintRepository.java | 84 +++++++++++ .../hint/service/AdminHintService.java | 58 ++++++++ .../modules/hint/service/HintService.java | 31 ++++ .../resources/db/migration/V15__Hints.sql | 21 +++ .../modules/hint/api/HintFlowTest.java | 132 ++++++++++++++++++ .../modules/hint/repo/HintRepositoryTest.java | 109 +++++++++++++++ .../hint/service/AdminHintServiceTest.java | 88 ++++++++++++ 24 files changed, 754 insertions(+) create mode 100644 src/main/java/auth_service/modules/hint/api/AdminHintController.java create mode 100644 src/main/java/auth_service/modules/hint/api/HintController.java create mode 100644 src/main/java/auth_service/modules/hint/api/HintExceptionController.java create mode 100644 src/main/java/auth_service/modules/hint/api/dto/request/CreateHintRequest.java create mode 100644 src/main/java/auth_service/modules/hint/api/dto/response/AdminHintListResponse.java create mode 100644 src/main/java/auth_service/modules/hint/api/dto/response/AdminHintResponse.java create mode 100644 src/main/java/auth_service/modules/hint/api/dto/response/CreatedHintResponse.java create mode 100644 src/main/java/auth_service/modules/hint/api/dto/response/HintListResponse.java create mode 100644 src/main/java/auth_service/modules/hint/api/dto/response/HintResponse.java create mode 100644 src/main/java/auth_service/modules/hint/exception/HintNotFoundException.java create mode 100644 src/main/java/auth_service/modules/hint/exception/InvalidHintException.java create mode 100644 src/main/java/auth_service/modules/hint/model/AdminHint.java create mode 100644 src/main/java/auth_service/modules/hint/model/Hint.java create mode 100644 src/main/java/auth_service/modules/hint/model/HintKind.java create mode 100644 src/main/java/auth_service/modules/hint/model/HintStatus.java create mode 100644 src/main/java/auth_service/modules/hint/model/NewHint.java create mode 100644 src/main/java/auth_service/modules/hint/repo/HintRepository.java create mode 100644 src/main/java/auth_service/modules/hint/service/AdminHintService.java create mode 100644 src/main/java/auth_service/modules/hint/service/HintService.java create mode 100644 src/main/resources/db/migration/V15__Hints.sql create mode 100644 src/test/java/auth_service/modules/hint/api/HintFlowTest.java create mode 100644 src/test/java/auth_service/modules/hint/repo/HintRepositoryTest.java create mode 100644 src/test/java/auth_service/modules/hint/service/AdminHintServiceTest.java diff --git a/readme.md b/readme.md index 67aa8f1..fd13f17 100644 --- a/readme.md +++ b/readme.md @@ -54,6 +54,10 @@ To Be Played is split into three repositories: (`users.enabled`; blocking ends every login, the login then answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as "Delete my account"). No action on the own account or on another admin. Every action and every "view as" is a row in `admin_audit`. +- **Dashboard hints**: a PLATFORM_ADMIN writes a hint for all users (plain text in English, German optional, start + and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first. + "Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never + comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`. - **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets `users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no public profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an undo needs a @@ -77,6 +81,11 @@ To Be Played is split into three repositories: | `GET` | `/api/admin/users?query=&page=&size=` (`{content, page, size, totalElements, totalPages}`) | PLATFORM_ADMIN | | `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`) | PLATFORM_ADMIN | | `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN | +| `GET` | `/api/hints` (`{results}`: running hints not closed for good, newest first) | access token | +| `PUT` | `/api/hints/{id}/done` (`204`; `404 HINT_NOT_FOUND`) | access token | +| `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount`, newest first) | PLATFORM_ADMIN | +| `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}`; `201 {id}`, `400 INVALID_HINT`) | PLATFORM_ADMIN | +| `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN | | `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token | | `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `PUT` | `/api/users/settings/appearance` | access token | @@ -114,6 +123,8 @@ through `CoreAccountService`. `admin` (users page, audit) works the same way. It deletes through the port `shared/account/AccountDeleter`, and the login module writes "view as" to the audit table through `shared/audit/AdminAuditLog`, because modules never use each other. +`hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through +`shared/audit/AdminAuditLog`. ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base diff --git a/src/main/java/auth_service/modules/hint/api/AdminHintController.java b/src/main/java/auth_service/modules/hint/api/AdminHintController.java new file mode 100644 index 0000000..dd6b060 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/api/AdminHintController.java @@ -0,0 +1,57 @@ +package auth_service.modules.hint.api; + +import auth_service.modules.hint.api.dto.request.CreateHintRequest; +import auth_service.modules.hint.api.dto.response.AdminHintListResponse; +import auth_service.modules.hint.api.dto.response.AdminHintResponse; +import auth_service.modules.hint.api.dto.response.CreatedHintResponse; +import auth_service.modules.hint.model.AdminHint; +import auth_service.modules.hint.model.Hint; +import auth_service.modules.hint.model.NewHint; +import auth_service.modules.hint.service.AdminHintService; +import auth_service.shared.security.UserPrincipal; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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.RestController; + +@RestController +@RequestMapping("/api/admin/hints") +@RequiredArgsConstructor +public class AdminHintController { + + private final AdminHintService adminHintService; + + @GetMapping(version = "1.0") + public ResponseEntity findAll() { + return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream() + .map(AdminHintController::toApi).toList())); + } + + @PostMapping(version = "1.0") + public ResponseEntity create(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody CreateHintRequest request) { + long id = adminHintService.create(principal.getIdAsLong(), + new NewHint(request.textEn(), request.textDe(), request.kind(), request.startsAt(), request.endsAt())); + return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id)); + } + + @PutMapping(value = "/{hintId}/end", version = "1.0") + public ResponseEntity end(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) { + adminHintService.end(principal.getIdAsLong(), hintId); + return ResponseEntity.noContent().build(); + } + + private static AdminHintResponse toApi(AdminHint admin) { + Hint hint = admin.hint(); + return new AdminHintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(), + hint.endsAt(), admin.status().name(), admin.createdBy(), admin.createdAt(), admin.doneCount()); + } +} diff --git a/src/main/java/auth_service/modules/hint/api/HintController.java b/src/main/java/auth_service/modules/hint/api/HintController.java new file mode 100644 index 0000000..24cc8a6 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/api/HintController.java @@ -0,0 +1,40 @@ +package auth_service.modules.hint.api; + +import auth_service.modules.hint.api.dto.response.HintListResponse; +import auth_service.modules.hint.api.dto.response.HintResponse; +import auth_service.modules.hint.model.Hint; +import auth_service.modules.hint.service.HintService; +import auth_service.shared.security.UserPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/hints") +@RequiredArgsConstructor +public class HintController { + + private final HintService hintService; + + @GetMapping(version = "1.0") + public ResponseEntity activeHints(@AuthenticationPrincipal UserPrincipal principal) { + return ResponseEntity.ok(new HintListResponse(hintService.activeHints(principal.getIdAsLong()).stream() + .map(HintController::toApi).toList())); + } + + @PutMapping(value = "/{hintId}/done", version = "1.0") + public ResponseEntity markDone(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) { + hintService.markDone(principal.getIdAsLong(), hintId); + return ResponseEntity.noContent().build(); + } + + private static HintResponse toApi(Hint hint) { + return new HintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(), + hint.endsAt()); + } +} diff --git a/src/main/java/auth_service/modules/hint/api/HintExceptionController.java b/src/main/java/auth_service/modules/hint/api/HintExceptionController.java new file mode 100644 index 0000000..6720a65 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/api/HintExceptionController.java @@ -0,0 +1,28 @@ +package auth_service.modules.hint.api; + +import auth_service.modules.hint.exception.HintNotFoundException; +import auth_service.modules.hint.exception.InvalidHintException; +import auth_service.shared.api.dto.response.ErrorResponse; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = {HintController.class, AdminHintController.class}) +@Order(value = 1) +public class HintExceptionController { + + @ExceptionHandler(HintNotFoundException.class) + public ResponseEntity clientError(HintNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.APPLICATION_JSON) + .body(new ErrorResponse("HINT_NOT_FOUND", ex.getMessage())); + } + + @ExceptionHandler(InvalidHintException.class) + public ResponseEntity clientError(InvalidHintException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON) + .body(new ErrorResponse("INVALID_HINT", ex.getMessage())); + } +} diff --git a/src/main/java/auth_service/modules/hint/api/dto/request/CreateHintRequest.java b/src/main/java/auth_service/modules/hint/api/dto/request/CreateHintRequest.java new file mode 100644 index 0000000..40b01dc --- /dev/null +++ b/src/main/java/auth_service/modules/hint/api/dto/request/CreateHintRequest.java @@ -0,0 +1,15 @@ +package auth_service.modules.hint.api.dto.request; + +import auth_service.modules.hint.model.HintKind; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.Instant; + +/** + * Plain text, never read as HTML. + */ +public record CreateHintRequest(@NotBlank @Size(max = 500) String textEn, @Size(max = 500) String textDe, + @NotNull HintKind kind, @NotNull Instant startsAt, @NotNull Instant endsAt) { +} diff --git a/src/main/java/auth_service/modules/hint/api/dto/response/AdminHintListResponse.java b/src/main/java/auth_service/modules/hint/api/dto/response/AdminHintListResponse.java new file mode 100644 index 0000000..52e6262 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/api/dto/response/AdminHintListResponse.java @@ -0,0 +1,6 @@ +package auth_service.modules.hint.api.dto.response; + +import java.util.List; + +public record AdminHintListResponse(List results) { +} diff --git a/src/main/java/auth_service/modules/hint/api/dto/response/AdminHintResponse.java b/src/main/java/auth_service/modules/hint/api/dto/response/AdminHintResponse.java new file mode 100644 index 0000000..142b606 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/api/dto/response/AdminHintResponse.java @@ -0,0 +1,7 @@ +package auth_service.modules.hint.api.dto.response; + +import java.time.Instant; + +public record AdminHintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt, + String status, long createdBy, Instant createdAt, long doneCount) { +} diff --git a/src/main/java/auth_service/modules/hint/api/dto/response/CreatedHintResponse.java b/src/main/java/auth_service/modules/hint/api/dto/response/CreatedHintResponse.java new file mode 100644 index 0000000..372745e --- /dev/null +++ b/src/main/java/auth_service/modules/hint/api/dto/response/CreatedHintResponse.java @@ -0,0 +1,4 @@ +package auth_service.modules.hint.api.dto.response; + +public record CreatedHintResponse(long id) { +} diff --git a/src/main/java/auth_service/modules/hint/api/dto/response/HintListResponse.java b/src/main/java/auth_service/modules/hint/api/dto/response/HintListResponse.java new file mode 100644 index 0000000..0bd8921 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/api/dto/response/HintListResponse.java @@ -0,0 +1,6 @@ +package auth_service.modules.hint.api.dto.response; + +import java.util.List; + +public record HintListResponse(List results) { +} diff --git a/src/main/java/auth_service/modules/hint/api/dto/response/HintResponse.java b/src/main/java/auth_service/modules/hint/api/dto/response/HintResponse.java new file mode 100644 index 0000000..1468b4a --- /dev/null +++ b/src/main/java/auth_service/modules/hint/api/dto/response/HintResponse.java @@ -0,0 +1,6 @@ +package auth_service.modules.hint.api.dto.response; + +import java.time.Instant; + +public record HintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt) { +} diff --git a/src/main/java/auth_service/modules/hint/exception/HintNotFoundException.java b/src/main/java/auth_service/modules/hint/exception/HintNotFoundException.java new file mode 100644 index 0000000..83dda31 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/exception/HintNotFoundException.java @@ -0,0 +1,7 @@ +package auth_service.modules.hint.exception; + +public class HintNotFoundException extends RuntimeException { + public HintNotFoundException(long hintId) { + super("No hint with id " + hintId); + } +} diff --git a/src/main/java/auth_service/modules/hint/exception/InvalidHintException.java b/src/main/java/auth_service/modules/hint/exception/InvalidHintException.java new file mode 100644 index 0000000..47ccd94 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/exception/InvalidHintException.java @@ -0,0 +1,7 @@ +package auth_service.modules.hint.exception; + +public class InvalidHintException extends RuntimeException { + public InvalidHintException(String message) { + super(message); + } +} diff --git a/src/main/java/auth_service/modules/hint/model/AdminHint.java b/src/main/java/auth_service/modules/hint/model/AdminHint.java new file mode 100644 index 0000000..8b9a7fc --- /dev/null +++ b/src/main/java/auth_service/modules/hint/model/AdminHint.java @@ -0,0 +1,6 @@ +package auth_service.modules.hint.model; + +import java.time.Instant; + +public record AdminHint(Hint hint, HintStatus status, long createdBy, Instant createdAt, long doneCount) { +} diff --git a/src/main/java/auth_service/modules/hint/model/Hint.java b/src/main/java/auth_service/modules/hint/model/Hint.java new file mode 100644 index 0000000..9783c4b --- /dev/null +++ b/src/main/java/auth_service/modules/hint/model/Hint.java @@ -0,0 +1,9 @@ +package auth_service.modules.hint.model; + +import java.time.Instant; + +/** + * @param textDe null: German users see {@code textEn} + */ +public record Hint(long id, String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt) { +} diff --git a/src/main/java/auth_service/modules/hint/model/HintKind.java b/src/main/java/auth_service/modules/hint/model/HintKind.java new file mode 100644 index 0000000..5a2932b --- /dev/null +++ b/src/main/java/auth_service/modules/hint/model/HintKind.java @@ -0,0 +1,8 @@ +package auth_service.modules.hint.model; + +/** + * DISMISS closes by itself after 30 s or with X; ACCEPT stays until the user clicks "OK". + */ +public enum HintKind { + DISMISS, ACCEPT +} diff --git a/src/main/java/auth_service/modules/hint/model/HintStatus.java b/src/main/java/auth_service/modules/hint/model/HintStatus.java new file mode 100644 index 0000000..2a4549b --- /dev/null +++ b/src/main/java/auth_service/modules/hint/model/HintStatus.java @@ -0,0 +1,8 @@ +package auth_service.modules.hint.model; + +/** + * Computed with the server's clock, so the admin page does not depend on the browser's. + */ +public enum HintStatus { + PLANNED, ACTIVE, ENDED +} diff --git a/src/main/java/auth_service/modules/hint/model/NewHint.java b/src/main/java/auth_service/modules/hint/model/NewHint.java new file mode 100644 index 0000000..5a63b89 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/model/NewHint.java @@ -0,0 +1,6 @@ +package auth_service.modules.hint.model; + +import java.time.Instant; + +public record NewHint(String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt) { +} diff --git a/src/main/java/auth_service/modules/hint/repo/HintRepository.java b/src/main/java/auth_service/modules/hint/repo/HintRepository.java new file mode 100644 index 0000000..76ac035 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/repo/HintRepository.java @@ -0,0 +1,84 @@ +package auth_service.modules.hint.repo; + +import auth_service.modules.hint.model.AdminHint; +import auth_service.modules.hint.model.Hint; +import auth_service.modules.hint.model.HintKind; +import auth_service.modules.hint.model.HintStatus; +import auth_service.modules.hint.model.NewHint; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +@Repository +public class HintRepository { + + private static final String COLUMNS = "h.id, h.text_en, h.text_de, h.kind, h.starts_at, h.ends_at"; + + private static final RowMapper HINT = (rs, row) -> new Hint( + rs.getLong("id"), + rs.getString("text_en"), + rs.getString("text_de"), + HintKind.valueOf(rs.getString("kind")), + rs.getTimestamp("starts_at").toInstant(), + rs.getTimestamp("ends_at").toInstant()); + + private final JdbcTemplate jdbc; + + public HintRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public List findActiveFor(long userId, Instant now) { + Timestamp at = Timestamp.from(now); + return jdbc.query(""" + SELECT %s FROM hint h + WHERE h.starts_at <= ? AND h.ends_at > ? + AND NOT EXISTS (SELECT 1 FROM hint_done d WHERE d.hint_id = h.id AND d.user_id = ?) + ORDER BY h.starts_at DESC, h.id DESC""".formatted(COLUMNS), HINT, at, at, userId); + } + + public List findAll(Instant now) { + Timestamp at = Timestamp.from(now); + return jdbc.query(""" + SELECT %s, h.created_by, h.created_at, + CASE WHEN h.ends_at <= ? THEN 'ENDED' WHEN h.starts_at > ? THEN 'PLANNED' ELSE 'ACTIVE' END + AS status, + (SELECT COUNT(*) FROM hint_done d WHERE d.hint_id = h.id) AS done_count + FROM hint h ORDER BY h.created_at DESC, h.id DESC""".formatted(COLUMNS), + (rs, row) -> new AdminHint(HINT.mapRow(rs, row), HintStatus.valueOf(rs.getString("status")), + rs.getLong("created_by"), rs.getTimestamp("created_at").toInstant(), rs.getLong("done_count")), + at, at); + } + + public boolean exists(long hintId) { + return Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS (SELECT 1 FROM hint WHERE id = ?)", Boolean.class, + hintId)); + } + + public long insert(NewHint hint, long createdBy) { + return Objects.requireNonNull(jdbc.queryForObject(""" + INSERT INTO hint (text_en, text_de, kind, starts_at, ends_at, created_by) VALUES (?, ?, ?, ?, ?, ?) + RETURNING id""", Long.class, hint.textEn(), hint.textDe(), hint.kind().name(), + Timestamp.from(hint.startsAt()), Timestamp.from(hint.endsAt()), createdBy)); + } + + public void markDone(long hintId, long userId) { + jdbc.update("INSERT INTO hint_done (hint_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING", hintId, userId); + } + + /** + * A planned hint ends before it starts, so it never shows. + * + * @return false when the hint had already ended + */ + public boolean end(long hintId, Instant now) { + Timestamp at = Timestamp.from(now); + return jdbc.update("UPDATE hint SET ends_at = ?, starts_at = LEAST(starts_at, ?) WHERE id = ? AND ends_at > ?", + at, at, hintId, at) > 0; + } +} diff --git a/src/main/java/auth_service/modules/hint/service/AdminHintService.java b/src/main/java/auth_service/modules/hint/service/AdminHintService.java new file mode 100644 index 0000000..964a6cb --- /dev/null +++ b/src/main/java/auth_service/modules/hint/service/AdminHintService.java @@ -0,0 +1,58 @@ +package auth_service.modules.hint.service; + +import auth_service.modules.hint.exception.HintNotFoundException; +import auth_service.modules.hint.exception.InvalidHintException; +import auth_service.modules.hint.model.AdminHint; +import auth_service.modules.hint.model.NewHint; +import auth_service.modules.hint.repo.HintRepository; +import auth_service.shared.audit.AdminAuditLog; +import auth_service.shared.security.RequiresRole; +import auth_service.shared.security.Role; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +@Service +@RequiresRole(Role.PLATFORM_ADMIN) +@RequiredArgsConstructor +@Slf4j +public class AdminHintService { + + private final HintRepository hintRepository; + private final AdminAuditLog adminAuditLog; + + public List findAll() { + return hintRepository.findAll(Instant.now()); + } + + @Transactional + public long create(long actorUserId, NewHint hint) { + if (!hint.endsAt().isAfter(hint.startsAt())) { + throw new InvalidHintException("The end must be after the start"); + } + if (!hint.endsAt().isAfter(Instant.now())) { + throw new InvalidHintException("The end must be in the future"); + } + String textDe = hint.textDe() == null || hint.textDe().isBlank() ? null : hint.textDe(); + long id = hintRepository.insert(new NewHint(hint.textEn(), textDe, hint.kind(), hint.startsAt(), hint.endsAt()), + actorUserId); + adminAuditLog.record(actorUserId, "HINT_CREATE", null, String.valueOf(id)); + log.info("Admin {} created hint {}", actorUserId, id); + return id; + } + + @Transactional + public void end(long actorUserId, long hintId) { + if (!hintRepository.exists(hintId)) { + throw new HintNotFoundException(hintId); + } + if (hintRepository.end(hintId, Instant.now())) { + adminAuditLog.record(actorUserId, "HINT_END", null, String.valueOf(hintId)); + log.info("Admin {} ended hint {}", actorUserId, hintId); + } + } +} diff --git a/src/main/java/auth_service/modules/hint/service/HintService.java b/src/main/java/auth_service/modules/hint/service/HintService.java new file mode 100644 index 0000000..37ae451 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/service/HintService.java @@ -0,0 +1,31 @@ +package auth_service.modules.hint.service; + +import auth_service.modules.hint.exception.HintNotFoundException; +import auth_service.modules.hint.model.Hint; +import auth_service.modules.hint.repo.HintRepository; +import auth_service.shared.security.RequiresRole; +import auth_service.shared.security.Role; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +@Service +@RequiresRole(Role.USER) +@RequiredArgsConstructor +public class HintService { + + private final HintRepository hintRepository; + + public List activeHints(long userId) { + return hintRepository.findActiveFor(userId, Instant.now()); + } + + public void markDone(long userId, long hintId) { + if (!hintRepository.exists(hintId)) { + throw new HintNotFoundException(hintId); + } + hintRepository.markDone(hintId, userId); + } +} diff --git a/src/main/resources/db/migration/V15__Hints.sql b/src/main/resources/db/migration/V15__Hints.sql new file mode 100644 index 0000000..f239bb8 --- /dev/null +++ b/src/main/resources/db/migration/V15__Hints.sql @@ -0,0 +1,21 @@ +-- Admin hints on the dashboard. No FK on created_by, like admin_audit: the row outlives a deleted admin. +-- ends_at = starts_at is an ended hint that never started. +CREATE TABLE hint ( + id BIGSERIAL PRIMARY KEY, + text_en VARCHAR(500) NOT NULL, + text_de VARCHAR(500), + kind VARCHAR(8) NOT NULL CHECK (kind IN ('DISMISS', 'ACCEPT')), + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL CHECK (ends_at >= starts_at), + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- A row: this user never sees this hint again. +CREATE TABLE hint_done ( + hint_id BIGINT NOT NULL REFERENCES hint (id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + done_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (hint_id, user_id) +); +CREATE INDEX idx_hint_done_user ON hint_done (user_id); diff --git a/src/test/java/auth_service/modules/hint/api/HintFlowTest.java b/src/test/java/auth_service/modules/hint/api/HintFlowTest.java new file mode 100644 index 0000000..e0ad46e --- /dev/null +++ b/src/test/java/auth_service/modules/hint/api/HintFlowTest.java @@ -0,0 +1,132 @@ +package auth_service.modules.hint.api; + +import auth_service.configuration.security.ClientHeaderFilter; +import auth_service.shared.api.AuthCookies; +import auth_service.shared.test.AbstractIntegrationTest; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +class HintFlowTest extends AbstractIntegrationTest { + + private static final String PASSWORD = "correct horse battery"; + private static final String ADMIN = "hint-flow-admin@example.com"; + private static final String PLAYER = "hint-flow-player@example.com"; + private static final String OTHER = "hint-flow-other@example.com"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private PasswordEncoder passwordEncoder; + + private void insertUser(String email, String role) { + jdbc.update("DELETE FROM users WHERE email = ?", email); + jdbc.update("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?)", + email, passwordEncoder.encode(PASSWORD), role, email.substring(0, 16)); + } + + @BeforeEach + void seed() { + jdbc.update("DELETE FROM hint"); + insertUser(ADMIN, "PLATFORM_ADMIN"); + insertUser(PLAYER, "USER"); + insertUser(OTHER, "USER"); + } + + private Cookie login(String email) throws Exception { + return new Cookie(AuthCookies.ACCESS_COOKIE, mockMvc.perform(post("/api/auth/login") + .header(ClientHeaderFilter.CLIENT_HEADER, "web").contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}")) + .andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE).getValue()); + } + + private ResultActions create(Cookie session, String body) throws Exception { + return mockMvc.perform(post("/api/admin/hints").header(ClientHeaderFilter.CLIENT_HEADER, "web").cookie(session) + .contentType(MediaType.APPLICATION_JSON).content(body)); + } + + private static String hint(String textDe, Instant startsAt, Instant endsAt) { + return """ + {"textEn":"Link your Steam account","textDe":%s,"kind":"ACCEPT","startsAt":"%s","endsAt":"%s"}""" + .formatted(textDe == null ? "null" : "\"" + textDe + "\"", startsAt, endsAt); + } + + private long createRunning(Cookie admin) throws Exception { + String body = create(admin, hint(" ", Instant.now().minusSeconds(60), Instant.now().plus(Duration.ofDays(1)))) + .andExpect(status().isCreated()).andReturn().getResponse().getContentAsString(); + return Long.parseLong(body.replaceAll("\\D", "")); + } + + private ResultActions done(Cookie session, long hintId) throws Exception { + return mockMvc.perform(put("/api/hints/" + hintId + "/done").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(session)); + } + + @Test + void doneHint_ComesBackOnlyForOtherUsers() throws Exception { + Cookie admin = login(ADMIN); + Cookie player = login(PLAYER); + Cookie other = login(OTHER); + long id = createRunning(admin); + + mockMvc.perform(get("/api/hints").cookie(player)).andExpect(status().isOk()) + .andExpect(jsonPath("$.results[0].id").value(id)) + .andExpect(jsonPath("$.results[0].kind").value("ACCEPT")) + .andExpect(jsonPath("$.results[0].textDe").doesNotExist()); + done(player, id).andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/hints").cookie(player)).andExpect(jsonPath("$.results.length()").value(0)); + mockMvc.perform(get("/api/hints").cookie(other)).andExpect(jsonPath("$.results.length()").value(1)); + mockMvc.perform(get("/api/admin/hints").cookie(admin)).andExpect(jsonPath("$.results[0].doneCount").value(1)); + done(player, id + 1).andExpect(status().isNotFound()); + } + + @Test + void endedHint_IsGoneAndAudited() throws Exception { + Cookie admin = login(ADMIN); + long id = createRunning(admin); + + mockMvc.perform(put("/api/admin/hints/" + id + "/end").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(admin)).andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/hints").cookie(login(PLAYER))).andExpect(jsonPath("$.results.length()").value(0)); + mockMvc.perform(get("/api/admin/hints").cookie(admin)).andExpect(jsonPath("$.results[0].status").value("ENDED")); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit WHERE action IN ('HINT_CREATE', 'HINT_END') " + + "AND detail = ?", Long.class, String.valueOf(id))).isEqualTo(2); + } + + @Test + void create_RefusesUsersAndInvalidInput() throws Exception { + Instant now = Instant.now(); + String valid = hint(null, now, now.plus(Duration.ofDays(1))); + + create(login(PLAYER), valid).andExpect(status().isForbidden()); + Cookie admin = login(ADMIN); + create(admin, hint(null, now, now.minusSeconds(1))).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_HINT")); + create(admin, valid.replace("Link your Steam account", " ")).andExpect(status().isBadRequest()); + create(admin, valid.replace("Link your Steam account", "x".repeat(501))).andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/auth_service/modules/hint/repo/HintRepositoryTest.java b/src/test/java/auth_service/modules/hint/repo/HintRepositoryTest.java new file mode 100644 index 0000000..d5d8988 --- /dev/null +++ b/src/test/java/auth_service/modules/hint/repo/HintRepositoryTest.java @@ -0,0 +1,109 @@ +package auth_service.modules.hint.repo; + +import auth_service.modules.hint.model.AdminHint; +import auth_service.modules.hint.model.Hint; +import auth_service.modules.hint.model.HintKind; +import auth_service.modules.hint.model.HintStatus; +import auth_service.modules.hint.model.NewHint; +import auth_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +@Import(HintRepository.class) +class HintRepositoryTest extends AbstractPersistenceTest { + + private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS); + private static final Duration DAY = Duration.ofDays(1); + + @Autowired + private HintRepository repository; + + @Autowired + private JdbcTemplate jdbc; + + private long alice; + private long bob; + + private long user(String email) { + return jdbc.queryForObject("INSERT INTO users (email, password, enabled) VALUES (?, 'x', true) RETURNING id", + Long.class, email); + } + + private long hint(String text, Instant startsAt, Instant endsAt) { + return repository.insert(new NewHint(text, null, HintKind.DISMISS, startsAt, endsAt), alice); + } + + @BeforeEach + void seed() { + jdbc.update("DELETE FROM hint"); + jdbc.update("DELETE FROM users"); + alice = user("alice@example.com"); + bob = user("bob@example.com"); + } + + @Test + void findActiveFor_ReturnsOnlyRunningHintsNewestStartFirst() { + hint("ended", NOW.minus(DAY.multipliedBy(2)), NOW.minus(DAY)); + hint("planned", NOW.plus(DAY), NOW.plus(DAY.multipliedBy(2))); + hint("older", NOW.minus(DAY.multipliedBy(2)), NOW.plus(DAY)); + hint("newer", NOW.minus(DAY), NOW.plus(DAY)); + + assertThat(repository.findActiveFor(alice, NOW)).extracting(Hint::textEn).containsExactly("newer", "older"); + } + + @Test + void findAll_ComputesTheStatusWithTheGivenClock() { + hint("ended", NOW.minus(DAY.multipliedBy(2)), NOW); + hint("active", NOW, NOW.plus(DAY)); + hint("planned", NOW.plus(DAY), NOW.plus(DAY.multipliedBy(2))); + + assertThat(repository.findAll(NOW)).extracting(admin -> admin.hint().textEn(), AdminHint::status) + .containsExactlyInAnyOrder(tuple("ended", HintStatus.ENDED), tuple("active", HintStatus.ACTIVE), + tuple("planned", HintStatus.PLANNED)); + } + + @Test + void markDone_HidesTheHintForThisUserOnly() { + long id = hint("news", NOW.minus(DAY), NOW.plus(DAY)); + + repository.markDone(id, alice); + repository.markDone(id, alice); + + assertThat(repository.findActiveFor(alice, NOW)).isEmpty(); + assertThat(repository.findActiveFor(bob, NOW)).extracting(Hint::id).containsExactly(id); + assertThat(repository.findAll(NOW)).extracting(AdminHint::doneCount).containsExactly(1L); + } + + @Test + void deletedUser_LosesTheDoneRows() { + long id = hint("news", NOW.minus(DAY), NOW.plus(DAY)); + repository.markDone(id, bob); + + jdbc.update("DELETE FROM users WHERE id = ?", bob); + + assertThat(repository.findAll(NOW)).extracting(AdminHint::doneCount).containsExactly(0L); + } + + @Test + void end_StopsARunningHintAndAPlannedOneNeverStarts() { + long running = hint("running", NOW.minus(DAY), NOW.plus(DAY)); + long planned = hint("planned", NOW.plus(DAY), NOW.plus(DAY.multipliedBy(2))); + + assertThat(repository.end(running, NOW)).isTrue(); + assertThat(repository.end(planned, NOW)).isTrue(); + assertThat(repository.end(running, NOW)).isFalse(); + + assertThat(repository.findActiveFor(alice, NOW)).isEmpty(); + assertThat(repository.findActiveFor(alice, NOW.plus(DAY.multipliedBy(3).dividedBy(2)))).isEmpty(); + } +} diff --git a/src/test/java/auth_service/modules/hint/service/AdminHintServiceTest.java b/src/test/java/auth_service/modules/hint/service/AdminHintServiceTest.java new file mode 100644 index 0000000..04a14fa --- /dev/null +++ b/src/test/java/auth_service/modules/hint/service/AdminHintServiceTest.java @@ -0,0 +1,88 @@ +package auth_service.modules.hint.service; + +import auth_service.modules.hint.exception.HintNotFoundException; +import auth_service.modules.hint.exception.InvalidHintException; +import auth_service.modules.hint.model.HintKind; +import auth_service.modules.hint.model.NewHint; +import auth_service.modules.hint.repo.HintRepository; +import auth_service.shared.audit.AdminAuditLog; +import auth_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class AdminHintServiceTest extends AbstractUnitTest { + + private static final long ADMIN = 1L; + private static final Duration DAY = Duration.ofDays(1); + + @Mock + private HintRepository hintRepository; + + @Mock + private AdminAuditLog adminAuditLog; + + private AdminHintService service; + + @BeforeEach + void setUp() { + service = new AdminHintService(hintRepository, adminAuditLog); + } + + private static NewHint hint(Instant startsAt, Instant endsAt) { + return new NewHint("Link your Steam account", null, HintKind.ACCEPT, startsAt, endsAt); + } + + @Test + void create_StoresAndAudits() { + NewHint hint = hint(Instant.now().plus(DAY), Instant.now().plus(DAY.multipliedBy(2))); + when(hintRepository.insert(hint, ADMIN)).thenReturn(7L); + + assertThat(service.create(ADMIN, hint)).isEqualTo(7L); + verify(adminAuditLog).record(ADMIN, "HINT_CREATE", null, "7"); + } + + @Test + void create_RefusesAnEndBeforeTheStartOrInThePast() { + Instant now = Instant.now(); + + assertThatThrownBy(() -> service.create(ADMIN, hint(now.plus(DAY), now.plus(DAY)))) + .isInstanceOf(InvalidHintException.class); + assertThatThrownBy(() -> service.create(ADMIN, hint(now.minus(DAY.multipliedBy(2)), now.minus(DAY)))) + .isInstanceOf(InvalidHintException.class); + verify(hintRepository, never()).insert(any(), anyLong()); + verifyNoInteractions(adminAuditLog); + } + + @Test + void end_AuditsOnlyWhenItEndedTheHint() { + when(hintRepository.exists(7L)).thenReturn(true); + when(hintRepository.end(eq(7L), any())).thenReturn(true, false); + + service.end(ADMIN, 7L); + service.end(ADMIN, 7L); + + verify(adminAuditLog).record(ADMIN, "HINT_END", null, "7"); + } + + @Test + void end_UnknownHint() { + assertThatThrownBy(() -> service.end(ADMIN, 7L)).isInstanceOf(HintNotFoundException.class); + verify(adminAuditLog, never()).record(anyLong(), anyString(), isNull(), anyString()); + } +}