feat(hint): target groups and automatic hints, V30
Build, Test & Push Auth Service / quality-check (push) Successful in 59s
Build, Test & Push Auth Service / docker (push) Successful in 1m34s

Hint targets combined with AND: without Steam, role and higher, new
users by registration_date, empty library flag. Four automatic hints
(NO_STEAM, EMPTY_LIBRARY, PILE_GREW, LINK_QUEUE), on by default; the
admin turns each on or off (audited). Users snooze one for 7 days (X)
or hide it for good (tick), stored on the server. Hint text may use
**bold** and [text](url).
This commit is contained in:
lobo committed 2026-10-02 14:36:49 +02:00
1 parent 5a2552ddf6
commit 19463a1a4d
23 files changed
+468 -36

No files matched your search

+13 -3
View File
@@ -80,6 +80,13 @@ To Be Played is split into three repositories:
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`.
A hint can target groups, combined with AND (none = all users): without Steam, role (this role and higher, by the
active role of the token), new users (`users.registration_date` less than N days ago), empty library (Auth only
sends a flag; the Frontend checks it with Core). The text may use `**bold**` and `[text](url)`; the Frontend
renders it, never as HTML. Four automatic hints (`NO_STEAM`, `EMPTY_LIBRARY`, `PILE_GREW`, `LINK_QUEUE`) are on
by default; a PLATFORM_ADMIN turns each on or off (`auto_hint`, audited). `GET /api/hints` lists the keys that are
on and not hidden for the user; the Frontend checks the conditions. X hides one for 7 days, "Do not show again"
for good (`auto_hint_hidden`).
- **What's new**: a PLATFORM_ADMIN publishes news entries (plain text, English, German optional; edit and delete, no
drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it"
sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all
@@ -129,11 +136,14 @@ To Be Played is split into three repositories:
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
| `POST` | `/api/admin/users/{id}/password-reset` (`204`; `409 NOT_ACTIVE`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
| `GET` | `/api/hints` (`{results}`: running hints not closed for good, newest first) | access token |
| `GET` | `/api/hints` (`{results}`: running hints for this user not closed for good, newest first, with `emptyLibraryOnly`; `automatic`: keys of the automatic hints that are on and not hidden) | access token |
| `PUT` | `/api/hints/auto/{key}/snooze` (hide 7 days; `204`; `404 HINT_NOT_FOUND`) | access token |
| `PUT` | `/api/hints/auto/{key}/done` (hide for good; `204`; `404 HINT_NOT_FOUND`) | 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 |
| `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount` and the `target*` fields, newest first; `automatic`: `[{key, enabled}]`) | PLATFORM_ADMIN |
| `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}` and optional `targetNoSteam`, `targetRole` (this role and higher), `targetNewDays` (1-365), `targetEmptyLibrary`; `201 {id}`, `400 INVALID_HINT` for the time range; `400 VALIDATION_FAILED` for a bad `targetNewDays` or `targetRole`) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/hints/auto/{key}` (`{enabled}`; `204`; `404 HINT_NOT_FOUND`) | PLATFORM_ADMIN |
| `GET` | `/api/news` (`{results}` with `seen`, newest first) | access token |
| `PUT` | `/api/news/seen` (`204`) | access token |
| `GET` | `/api/admin/news` (`{results}`, newest first) | PLATFORM_ADMIN |
@@ -1,11 +1,14 @@
package auth_service.modules.hint.api;
import auth_service.modules.hint.api.dto.request.AutoHintRequest;
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.AutoHintResponse;
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.HintTarget;
import auth_service.modules.hint.model.NewHint;
import auth_service.modules.hint.service.AdminHintService;
import auth_service.shared.security.UserPrincipal;
@@ -32,14 +35,18 @@ public class AdminHintController {
@GetMapping
public ResponseEntity<AdminHintListResponse> findAll() {
return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream()
.map(AdminHintController::toApi).toList()));
.map(AdminHintController::toApi).toList(),
adminHintService.findAutoHints().stream()
.map(state -> new AutoHintResponse(state.key().name(), state.enabled())).toList()));
}
@PostMapping
public ResponseEntity<CreatedHintResponse> 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()));
new NewHint(request.textEn(), request.textDe(), request.kind(), request.startsAt(), request.endsAt(),
new HintTarget(Boolean.TRUE.equals(request.targetNoSteam()), request.targetRole(),
request.targetNewDays(), Boolean.TRUE.equals(request.targetEmptyLibrary()))));
return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id));
}
@@ -49,9 +56,19 @@ public class AdminHintController {
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/auto/{key}")
public ResponseEntity<Void> setAuto(@AuthenticationPrincipal UserPrincipal principal, @PathVariable String key,
@Valid @RequestBody AutoHintRequest request) {
adminHintService.setAutoHintEnabled(principal.getIdAsLong(), key, request.enabled());
return ResponseEntity.noContent().build();
}
private static AdminHintResponse toApi(AdminHint admin) {
Hint hint = admin.hint();
HintTarget target = hint.target();
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());
hint.endsAt(), admin.status().name(), admin.createdBy(), admin.createdAt(), admin.doneCount(),
target.noSteam(), target.role() == null ? null : target.role().name(), target.newDays(),
target.emptyLibrary());
}
}
@@ -2,6 +2,7 @@ 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.AutoHint;
import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.service.HintService;
import auth_service.shared.security.UserPrincipal;
@@ -23,8 +24,23 @@ public class HintController {
@GetMapping
public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(new HintListResponse(hintService.activeHints(principal.getIdAsLong()).stream()
.map(HintController::toApi).toList()));
long userId = principal.getIdAsLong();
return ResponseEntity.ok(new HintListResponse(
hintService.activeHints(userId, principal.role()).stream().map(HintController::toApi).toList(),
hintService.autoHints(userId).stream().map(AutoHint::name).toList()));
}
@PutMapping(value = "/auto/{key}/snooze")
public ResponseEntity<Void> snoozeAuto(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String key) {
hintService.snoozeAutoHint(principal.getIdAsLong(), key);
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/auto/{key}/done")
public ResponseEntity<Void> hideAuto(@AuthenticationPrincipal UserPrincipal principal, @PathVariable String key) {
hintService.hideAutoHintForGood(principal.getIdAsLong(), key);
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/{hintId}/done")
@@ -35,6 +51,6 @@ public class HintController {
private static HintResponse toApi(Hint hint) {
return new HintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
hint.endsAt());
hint.endsAt(), hint.target().emptyLibrary());
}
}
@@ -0,0 +1,6 @@
package auth_service.modules.hint.api.dto.request;
import jakarta.validation.constraints.NotNull;
public record AutoHintRequest(@NotNull Boolean enabled) {
}
@@ -1,6 +1,9 @@
package auth_service.modules.hint.api.dto.request;
import auth_service.modules.hint.model.HintKind;
import auth_service.shared.security.Role;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -8,8 +11,12 @@ import jakarta.validation.constraints.Size;
import java.time.Instant;
/**
* Plain text, never read as HTML.
* Plain text with {@code **bold**} and {@code [text](url)}; the Frontend renders it, never as HTML.
* Target fields are optional, missing = every user. Wrappers, because a missing primitive is a 400.
* {@code targetRole} means this role and higher.
*/
public record CreateHintRequest(@NotBlank @Size(max = 500) String textEn, @Size(max = 500) String textDe,
@NotNull HintKind kind, @NotNull Instant startsAt, @NotNull Instant endsAt) {
@NotNull HintKind kind, @NotNull Instant startsAt, @NotNull Instant endsAt,
Boolean targetNoSteam, Role targetRole, @Min(1) @Max(365) Integer targetNewDays,
Boolean targetEmptyLibrary) {
}
@@ -2,5 +2,5 @@ package auth_service.modules.hint.api.dto.response;
import java.util.List;
public record AdminHintListResponse(List<AdminHintResponse> results) {
public record AdminHintListResponse(List<AdminHintResponse> results, List<AutoHintResponse> automatic) {
}
@@ -3,5 +3,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) {
String status, long createdBy, Instant createdAt, long doneCount,
boolean targetNoSteam, String targetRole, Integer targetNewDays,
boolean targetEmptyLibrary) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.hint.api.dto.response;
public record AutoHintResponse(String key, boolean enabled) {
}
@@ -2,5 +2,8 @@ package auth_service.modules.hint.api.dto.response;
import java.util.List;
public record HintListResponse(List<HintResponse> results) {
/**
* @param automatic keys of the automatic hints that are on and not hidden for this user
*/
public record HintListResponse(List<HintResponse> results, List<String> automatic) {
}
@@ -2,5 +2,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) {
public record HintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt,
boolean emptyLibraryOnly) {
}
@@ -4,4 +4,8 @@ public class HintNotFoundException extends RuntimeException {
public HintNotFoundException(long hintId) {
super("No hint with id " + hintId);
}
public HintNotFoundException(String key) {
super("No automatic hint " + key);
}
}
@@ -0,0 +1,18 @@
package auth_service.modules.hint.model;
import auth_service.modules.hint.exception.HintNotFoundException;
/**
* Built-in hints. The Frontend owns the texts and the conditions.
*/
public enum AutoHint {
NO_STEAM, EMPTY_LIBRARY, PILE_GREW, LINK_QUEUE;
public static AutoHint parse(String key) {
try {
return valueOf(key);
} catch (IllegalArgumentException _) {
throw new HintNotFoundException(key);
}
}
}
@@ -0,0 +1,4 @@
package auth_service.modules.hint.model;
public record AutoHintState(AutoHint key, boolean enabled) {
}
@@ -5,5 +5,6 @@ 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) {
public record Hint(long id, String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt,
HintTarget target) {
}
@@ -0,0 +1,15 @@
package auth_service.modules.hint.model;
import auth_service.shared.security.Role;
/**
* Target groups of a hint, combined with AND.
*
* @param role null: every role; else this role and higher (by the active role)
* @param newDays null: any registration date; else registered less than this many days ago
* @param emptyLibrary checked by the Frontend with Core's library summary
*/
public record HintTarget(boolean noSteam, Role role, Integer newDays, boolean emptyLibrary) {
public static final HintTarget ALL = new HintTarget(false, null, null, false);
}
@@ -2,5 +2,6 @@ package auth_service.modules.hint.model;
import java.time.Instant;
public record NewHint(String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt) {
public record NewHint(String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt,
HintTarget target) {
}
@@ -1,23 +1,31 @@
package auth_service.modules.hint.repo;
import auth_service.modules.hint.model.AdminHint;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.AutoHintState;
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.HintTarget;
import auth_service.modules.hint.model.NewHint;
import auth_service.shared.security.Role;
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.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Comparator;
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 String COLUMNS = "h.id, h.text_en, h.text_de, h.kind, h.starts_at, h.ends_at, "
+ "h.target_no_steam, h.target_role, h.target_new_days, h.target_empty_library";
private static final RowMapper<Hint> HINT = (rs, row) -> new Hint(
rs.getLong("id"),
@@ -25,7 +33,9 @@ public class HintRepository {
rs.getString("text_de"),
HintKind.valueOf(rs.getString("kind")),
rs.getTimestamp("starts_at").toInstant(),
rs.getTimestamp("ends_at").toInstant());
rs.getTimestamp("ends_at").toInstant(),
new HintTarget(rs.getBoolean("target_no_steam"), role(rs.getString("target_role")),
rs.getObject("target_new_days", Integer.class), rs.getBoolean("target_empty_library")));
private final JdbcTemplate jdbc;
@@ -33,13 +43,56 @@ public class HintRepository {
this.jdbc = jdbc;
}
public List<Hint> findActiveFor(long userId, Instant now) {
/**
* @param role the active role of the caller; a target role matches this role and higher
*/
public List<Hint> findActiveFor(long userId, Role role, Instant now) {
Timestamp at = Timestamp.from(now);
// registration_date is naive UTC
LocalDateTime utc = LocalDateTime.ofInstant(now, ZoneOffset.UTC);
return jdbc.query("""
SELECT %s FROM hint h
SELECT %s FROM hint h JOIN users u ON u.id = ?
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);
AND NOT EXISTS (SELECT 1 FROM hint_done d WHERE d.hint_id = h.id AND d.user_id = u.id)
AND (NOT h.target_no_steam OR u.steam_id IS NULL)
AND (h.target_role IS NULL
OR CASE h.target_role WHEN 'USER' THEN 0 WHEN 'AUDITOR' THEN 1 ELSE 2 END <= ?)
AND (h.target_new_days IS NULL
OR u.registration_date > ?::timestamp - make_interval(days => h.target_new_days))
ORDER BY h.starts_at DESC, h.id DESC""".formatted(COLUMNS), HINT, userId, at, at, role.ordinal(),
utc);
}
public List<AutoHint> findVisibleAutoHints(long userId, Instant now) {
return jdbc.query("""
SELECT a.hint_key FROM auto_hint a
WHERE a.enabled
AND NOT EXISTS (SELECT 1 FROM auto_hint_hidden x
WHERE x.user_id = ? AND x.hint_key = a.hint_key
AND (x.hidden_until IS NULL OR x.hidden_until > ?))""",
(rs, row) -> AutoHint.valueOf(rs.getString("hint_key")), userId, Timestamp.from(now))
.stream().sorted().toList();
}
/**
* @param until null: for good. A snooze never undoes "for good".
*/
public void hideAutoHint(long userId, AutoHint key, Instant until) {
jdbc.update("""
INSERT INTO auto_hint_hidden (user_id, hint_key, hidden_until) VALUES (?, ?, ?)
ON CONFLICT (user_id, hint_key) DO UPDATE SET hidden_until =
CASE WHEN auto_hint_hidden.hidden_until IS NULL THEN NULL ELSE EXCLUDED.hidden_until END""",
userId, key.name(), until == null ? null : Timestamp.from(until));
}
public List<AutoHintState> findAutoHints() {
return jdbc.query("SELECT hint_key, enabled FROM auto_hint",
(rs, row) -> new AutoHintState(AutoHint.valueOf(rs.getString("hint_key")), rs.getBoolean("enabled")))
.stream().sorted(Comparator.comparing(AutoHintState::key)).toList();
}
public void setAutoHintEnabled(AutoHint key, boolean enabled) {
jdbc.update("UPDATE auto_hint SET enabled = ? WHERE hint_key = ?", enabled, key.name());
}
public List<AdminHint> findAll(Instant now) {
@@ -61,10 +114,14 @@ public class HintRepository {
}
public long insert(NewHint hint, long createdBy) {
HintTarget target = hint.target();
return Objects.requireNonNull(jdbc.queryForObject("""
INSERT INTO hint (text_en, text_de, kind, starts_at, ends_at, created_by) VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO hint (text_en, text_de, kind, starts_at, ends_at, created_by, target_no_steam, target_role,
target_new_days, target_empty_library)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id""", Long.class, hint.textEn(), hint.textDe(), hint.kind().name(),
Timestamp.from(hint.startsAt()), Timestamp.from(hint.endsAt()), createdBy));
Timestamp.from(hint.startsAt()), Timestamp.from(hint.endsAt()), createdBy, target.noSteam(),
target.role() == null ? null : target.role().name(), target.newDays(), target.emptyLibrary()));
}
public void markDone(long hintId, long userId) {
@@ -81,4 +138,9 @@ public class HintRepository {
return jdbc.update("UPDATE hint SET ends_at = ?, starts_at = LEAST(starts_at, ?) WHERE id = ? AND ends_at > ?",
at, at, hintId, at) > 0;
}
// Not Role.parse: it turns null into USER, and null here means "every role".
private static Role role(String name) {
return name == null ? null : Role.valueOf(name);
}
}
@@ -3,6 +3,8 @@ 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.AutoHint;
import auth_service.modules.hint.model.AutoHintState;
import auth_service.modules.hint.model.NewHint;
import auth_service.modules.hint.repo.HintRepository;
import auth_service.shared.audit.AdminAuditLog;
@@ -38,8 +40,8 @@ public class AdminHintService {
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);
long id = hintRepository.insert(new NewHint(hint.textEn(), textDe, hint.kind(), hint.startsAt(), hint.endsAt(),
hint.target()), actorUserId);
adminAuditLog.record(actorUserId, "HINT_CREATE", null, String.valueOf(id));
log.info("Admin {} created hint {}", actorUserId, id);
return id;
@@ -55,4 +57,16 @@ public class AdminHintService {
log.info("Admin {} ended hint {}", actorUserId, hintId);
}
}
public List<AutoHintState> findAutoHints() {
return hintRepository.findAutoHints();
}
@Transactional
public void setAutoHintEnabled(long actorUserId, String key, boolean enabled) {
AutoHint hint = AutoHint.parse(key);
hintRepository.setAutoHintEnabled(hint, enabled);
adminAuditLog.record(actorUserId, enabled ? "AUTO_HINT_ON" : "AUTO_HINT_OFF", null, hint.name());
log.info("Admin {} turned automatic hint {} {}", actorUserId, hint, enabled ? "on" : "off");
}
}
@@ -1,6 +1,7 @@
package auth_service.modules.hint.service;
import auth_service.modules.hint.exception.HintNotFoundException;
import auth_service.modules.hint.model.AutoHint;
import auth_service.modules.hint.model.Hint;
import auth_service.modules.hint.repo.HintRepository;
import auth_service.shared.security.RequiresRole;
@@ -8,6 +9,7 @@ import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@@ -16,10 +18,24 @@ import java.util.List;
@RequiredArgsConstructor
public class HintService {
private static final Duration SNOOZE = Duration.ofDays(7);
private final HintRepository hintRepository;
public List<Hint> activeHints(long userId) {
return hintRepository.findActiveFor(userId, Instant.now());
public List<Hint> activeHints(long userId, Role role) {
return hintRepository.findActiveFor(userId, role, Instant.now());
}
public List<AutoHint> autoHints(long userId) {
return hintRepository.findVisibleAutoHints(userId, Instant.now());
}
public void snoozeAutoHint(long userId, String key) {
hintRepository.hideAutoHint(userId, AutoHint.parse(key), Instant.now().plus(SNOOZE));
}
public void hideAutoHintForGood(long userId, String key) {
hintRepository.hideAutoHint(userId, AutoHint.parse(key), null);
}
public void markDone(long userId, long hintId) {
@@ -0,0 +1,23 @@
-- Target groups of an admin hint, combined with AND. All defaults = every user, so old hints keep working.
ALTER TABLE hint
ADD COLUMN target_no_steam BOOLEAN NOT NULL DEFAULT FALSE,
-- This role and higher.
ADD COLUMN target_role VARCHAR(16) CHECK (target_role IN ('USER', 'AUDITOR', 'PLATFORM_ADMIN')),
ADD COLUMN target_new_days SMALLINT CHECK (target_new_days BETWEEN 1 AND 365),
-- Checked by the Frontend with Core's library summary; Auth only sends the flag.
ADD COLUMN target_empty_library BOOLEAN NOT NULL DEFAULT FALSE;
-- The built-in hints; the texts live in the Frontend. A PLATFORM_ADMIN turns each on or off.
CREATE TABLE auto_hint (
hint_key VARCHAR(16) PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT TRUE
);
INSERT INTO auto_hint (hint_key) VALUES ('NO_STEAM'), ('EMPTY_LIBRARY'), ('PILE_GREW'), ('LINK_QUEUE');
-- hidden_until NULL: hidden for good ("Do not show again"); else hidden until then (X: 7 days).
CREATE TABLE auto_hint_hidden (
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
hint_key VARCHAR(16) NOT NULL REFERENCES auto_hint (hint_key),
hidden_until TIMESTAMPTZ,
PRIMARY KEY (user_id, hint_key)
);
@@ -128,4 +128,70 @@ class HintFlowTest extends AbstractIntegrationTest {
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());
}
private ResultActions authPut(Cookie session, String path, String body) throws Exception {
return mockMvc.perform(put(path).header(ClientHeaderFilter.CLIENT_HEADER, "web").cookie(session)
.contentType(MediaType.APPLICATION_JSON).content(body));
}
@Test
void targetedHint_ReachesOnlyTheMatchingUsers() throws Exception {
jdbc.update("UPDATE users SET steam_id = 4711 WHERE email = ?", OTHER);
jdbc.update("UPDATE users SET registration_date = now() AT TIME ZONE 'UTC' WHERE email IN (?, ?)", PLAYER,
OTHER);
Cookie admin = login(ADMIN);
String body = hint(null, Instant.now().minusSeconds(60), Instant.now().plus(Duration.ofDays(1)));
create(admin, body.replace("}", ",\"targetNoSteam\":true,\"targetNewDays\":7,\"targetRole\":\"USER\"}"))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/hints").cookie(login(PLAYER))).andExpect(jsonPath("$.results.length()").value(1));
mockMvc.perform(get("/api/hints").cookie(login(OTHER))).andExpect(jsonPath("$.results.length()").value(0));
mockMvc.perform(get("/api/admin/hints").cookie(admin)).andExpect(jsonPath("$.results[0].targetNoSteam")
.value(true)).andExpect(jsonPath("$.results[0].targetNewDays").value(7))
.andExpect(jsonPath("$.results[0].targetRole").value("USER"));
}
@Test
void create_WithoutTargetFieldsReachesEveryone_AndRefusesZeroDays() throws Exception {
Cookie admin = login(ADMIN);
createRunning(admin);
String body = hint(null, Instant.now().minusSeconds(60), Instant.now().plus(Duration.ofDays(1)));
mockMvc.perform(get("/api/hints").cookie(login(PLAYER))).andExpect(jsonPath("$.results.length()").value(1))
.andExpect(jsonPath("$.results[0].emptyLibraryOnly").value(false));
create(admin, body.replace("}", ",\"targetNewDays\":0}")).andExpect(status().isBadRequest());
}
@Test
void autoHints_AreHiddenPerUserAndSwitchedByAdmin() throws Exception {
Cookie player = login(PLAYER);
Cookie other = login(OTHER);
Cookie admin = login(ADMIN);
mockMvc.perform(get("/api/hints").cookie(player)).andExpect(jsonPath("$.automatic.length()").value(4));
authPut(player, "/api/hints/auto/NO_STEAM/snooze", "").andExpect(status().isNoContent());
authPut(player, "/api/hints/auto/PILE_GREW/done", "").andExpect(status().isNoContent());
mockMvc.perform(get("/api/hints").cookie(player)).andExpect(jsonPath("$.automatic.length()").value(2));
mockMvc.perform(get("/api/hints").cookie(other)).andExpect(jsonPath("$.automatic.length()").value(4));
authPut(player, "/api/hints/auto/NOPE/done", "").andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("HINT_NOT_FOUND"));
authPut(admin, "/api/admin/hints/auto/LINK_QUEUE", "{\"enabled\":false}").andExpect(status().isNoContent());
try {
mockMvc.perform(get("/api/hints").cookie(other)).andExpect(jsonPath("$.automatic.length()").value(3));
mockMvc.perform(get("/api/admin/hints").cookie(admin))
.andExpect(jsonPath("$.automatic.length()").value(4))
.andExpect(jsonPath("$.automatic[3].enabled").value(false));
} finally {
authPut(admin, "/api/admin/hints/auto/LINK_QUEUE", "{\"enabled\":true}");
}
}
@Test
void autoHintToggle_RefusesUsersAndAMissingFlag() throws Exception {
authPut(login(PLAYER), "/api/admin/hints/auto/NO_STEAM", "{\"enabled\":false}")
.andExpect(status().isForbidden());
authPut(login(ADMIN), "/api/admin/hints/auto/NO_STEAM", "{}").andExpect(status().isBadRequest());
}
}
@@ -1,19 +1,24 @@
package auth_service.modules.hint.repo;
import auth_service.modules.hint.model.AdminHint;
import auth_service.modules.hint.model.AutoHint;
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.HintTarget;
import auth_service.modules.hint.model.NewHint;
import auth_service.shared.security.Role;
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.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
@@ -38,7 +43,7 @@ class HintRepositoryTest extends AbstractPersistenceTest {
}
private long hint(String text, Instant startsAt, Instant endsAt) {
return repository.insert(new NewHint(text, null, HintKind.DISMISS, startsAt, endsAt), alice);
return repository.insert(new NewHint(text, null, HintKind.DISMISS, startsAt, endsAt, HintTarget.ALL), alice);
}
@BeforeEach
@@ -56,7 +61,7 @@ class HintRepositoryTest extends AbstractPersistenceTest {
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");
assertThat(texts(alice, Role.USER)).containsExactly("newer", "older");
}
@Test
@@ -77,8 +82,8 @@ class HintRepositoryTest extends AbstractPersistenceTest {
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.findActiveFor(alice, Role.USER, NOW)).isEmpty();
assertThat(repository.findActiveFor(bob, Role.USER, NOW)).extracting(Hint::id).containsExactly(id);
assertThat(repository.findAll(NOW)).extracting(AdminHint::doneCount).containsExactly(1L);
}
@@ -101,7 +106,113 @@ class HintRepositoryTest extends AbstractPersistenceTest {
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();
assertThat(repository.findActiveFor(alice, Role.USER, NOW)).isEmpty();
assertThat(repository.findActiveFor(alice, Role.USER, NOW.plus(DAY.multipliedBy(3).dividedBy(2)))).isEmpty();
}
private long targeted(String text, HintTarget target) {
return repository.insert(new NewHint(text, null, HintKind.DISMISS, NOW.minus(DAY), NOW.plus(DAY), target),
alice);
}
private long user(String email, Long steamId, Instant registered) {
long id = user(email);
jdbc.update("UPDATE users SET steam_id = ?, registration_date = ? WHERE id = ?", steamId,
registered == null ? null : Timestamp.from(registered), id);
return id;
}
private List<String> texts(long userId, Role role) {
return repository.findActiveFor(userId, role, NOW).stream().map(Hint::textEn).toList();
}
@Test
void findActiveFor_HintWithoutTargetReachesEveryone() {
hint("all", NOW.minus(DAY), NOW.plus(DAY));
long oldWithSteam = user("old@example.com", 1L, NOW.minus(DAY.multipliedBy(900)));
assertThat(texts(oldWithSteam, Role.USER)).containsExactly("all");
assertThat(texts(alice, Role.USER)).containsExactly("all");
}
@Test
void findActiveFor_NewAndWithoutSteamReachesOnlyBoth() {
targeted("both", new HintTarget(true, null, 7, false));
long newNoSteam = user("new-no-steam@example.com", null, NOW.minus(DAY.multipliedBy(2)));
long newSteam = user("new-steam@example.com", 2L, NOW.minus(DAY.multipliedBy(2)));
long oldNoSteam = user("old-no-steam@example.com", null, NOW.minus(DAY.multipliedBy(30)));
assertThat(texts(newNoSteam, Role.USER)).containsExactly("both");
assertThat(texts(newSteam, Role.USER)).isEmpty();
assertThat(texts(oldNoSteam, Role.USER)).isEmpty();
}
@Test
void findActiveFor_NullRegistrationDateIsNotNew() {
targeted("new", new HintTarget(false, null, 7, false));
long unknown = user("unknown@example.com", null, null);
assertThat(texts(unknown, Role.USER)).isEmpty();
}
@Test
void findActiveFor_RoleTargetMatchesThisRoleAndHigher() {
targeted("user", new HintTarget(false, Role.USER, null, false));
targeted("auditor", new HintTarget(false, Role.AUDITOR, null, false));
targeted("admin", new HintTarget(false, Role.PLATFORM_ADMIN, null, false));
assertThat(texts(alice, Role.USER)).containsExactly("user");
assertThat(texts(alice, Role.AUDITOR)).containsExactlyInAnyOrder("user", "auditor");
assertThat(texts(alice, Role.PLATFORM_ADMIN)).containsExactlyInAnyOrder("user", "auditor", "admin");
}
@Test
void findActiveFor_SendsTheEmptyLibraryFlag() {
targeted("empty", new HintTarget(false, null, null, true));
assertThat(repository.findActiveFor(alice, Role.USER, NOW)).extracting(h -> h.target().emptyLibrary())
.containsExactly(true);
}
@Test
void findVisibleAutoHints_HidesSnoozedUntilThenAndForever() {
repository.hideAutoHint(alice, AutoHint.NO_STEAM, NOW.plus(DAY));
repository.hideAutoHint(alice, AutoHint.PILE_GREW, null);
assertThat(repository.findVisibleAutoHints(alice, NOW))
.containsExactly(AutoHint.EMPTY_LIBRARY, AutoHint.LINK_QUEUE);
assertThat(repository.findVisibleAutoHints(alice, NOW.plus(DAY.multipliedBy(2))))
.containsExactly(AutoHint.NO_STEAM, AutoHint.EMPTY_LIBRARY, AutoHint.LINK_QUEUE);
assertThat(repository.findVisibleAutoHints(bob, NOW)).hasSize(4);
}
@Test
void hideAutoHint_SnoozeNeverUndoesForever() {
repository.hideAutoHint(alice, AutoHint.NO_STEAM, null);
repository.hideAutoHint(alice, AutoHint.NO_STEAM, NOW.plus(DAY));
assertThat(repository.findVisibleAutoHints(alice, NOW.plus(DAY.multipliedBy(2))))
.doesNotContain(AutoHint.NO_STEAM);
}
@Test
void findVisibleAutoHints_SkipsDisabled() {
repository.setAutoHintEnabled(AutoHint.LINK_QUEUE, false);
try {
assertThat(repository.findVisibleAutoHints(alice, NOW)).doesNotContain(AutoHint.LINK_QUEUE);
assertThat(repository.findAutoHints()).filteredOn(state -> !state.enabled()).hasSize(1);
} finally {
repository.setAutoHintEnabled(AutoHint.LINK_QUEUE, true);
}
}
@Test
void deletedUser_LosesTheHiddenAutoHints() {
repository.hideAutoHint(bob, AutoHint.NO_STEAM, null);
jdbc.update("DELETE FROM users WHERE id = ?", bob);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM auto_hint_hidden", Long.class)).isZero();
}
}
@@ -2,10 +2,13 @@ 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.AutoHint;
import auth_service.modules.hint.model.HintKind;
import auth_service.modules.hint.model.HintTarget;
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.Role;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -45,7 +48,7 @@ class AdminHintServiceTest extends AbstractUnitTest {
}
private static NewHint hint(Instant startsAt, Instant endsAt) {
return new NewHint("Link your Steam account", null, HintKind.ACCEPT, startsAt, endsAt);
return new NewHint("Link your Steam account", null, HintKind.ACCEPT, startsAt, endsAt, HintTarget.ALL);
}
@Test
@@ -85,4 +88,32 @@ class AdminHintServiceTest extends AbstractUnitTest {
assertThatThrownBy(() -> service.end(ADMIN, 7L)).isInstanceOf(HintNotFoundException.class);
verify(adminAuditLog, never()).record(anyLong(), anyString(), isNull(), anyString());
}
@Test
void create_KeepsTheTarget() {
HintTarget target = new HintTarget(true, Role.AUDITOR, 7, true);
NewHint hint = new NewHint("x", " ", HintKind.DISMISS, Instant.now(), Instant.now().plus(DAY), target);
service.create(ADMIN, hint);
verify(hintRepository).insert(new NewHint("x", null, HintKind.DISMISS, hint.startsAt(), hint.endsAt(), target),
ADMIN);
}
@Test
void setAutoHintEnabled_Audits() {
service.setAutoHintEnabled(ADMIN, "PILE_GREW", false);
service.setAutoHintEnabled(ADMIN, "PILE_GREW", true);
verify(hintRepository).setAutoHintEnabled(AutoHint.PILE_GREW, false);
verify(adminAuditLog).record(ADMIN, "AUTO_HINT_OFF", null, "PILE_GREW");
verify(adminAuditLog).record(ADMIN, "AUTO_HINT_ON", null, "PILE_GREW");
}
@Test
void setAutoHintEnabled_UnknownKey() {
assertThatThrownBy(() -> service.setAutoHintEnabled(ADMIN, "NOPE", true))
.isInstanceOf(HintNotFoundException.class);
verifyNoInteractions(adminAuditLog);
}
}