diff --git a/readme.md b/readme.md index 9710adb..a3625a9 100644 --- a/readme.md +++ b/readme.md @@ -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 | diff --git a/src/main/java/auth_service/modules/hint/api/AdminHintController.java b/src/main/java/auth_service/modules/hint/api/AdminHintController.java index 1b66b6a..f96daf8 100644 --- a/src/main/java/auth_service/modules/hint/api/AdminHintController.java +++ b/src/main/java/auth_service/modules/hint/api/AdminHintController.java @@ -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 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 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 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()); } } diff --git a/src/main/java/auth_service/modules/hint/api/HintController.java b/src/main/java/auth_service/modules/hint/api/HintController.java index 54ab293..0199761 100644 --- a/src/main/java/auth_service/modules/hint/api/HintController.java +++ b/src/main/java/auth_service/modules/hint/api/HintController.java @@ -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 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 snoozeAuto(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable String key) { + hintService.snoozeAutoHint(principal.getIdAsLong(), key); + return ResponseEntity.noContent().build(); + } + + @PutMapping(value = "/auto/{key}/done") + public ResponseEntity 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()); } } diff --git a/src/main/java/auth_service/modules/hint/api/dto/request/AutoHintRequest.java b/src/main/java/auth_service/modules/hint/api/dto/request/AutoHintRequest.java new file mode 100644 index 0000000..be14b0f --- /dev/null +++ b/src/main/java/auth_service/modules/hint/api/dto/request/AutoHintRequest.java @@ -0,0 +1,6 @@ +package auth_service.modules.hint.api.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record AutoHintRequest(@NotNull Boolean enabled) { +} 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 index 40b01dc..d7c301d 100644 --- 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 @@ -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) { } 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 index 52e6262..849c5c6 100644 --- 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 @@ -2,5 +2,5 @@ package auth_service.modules.hint.api.dto.response; import java.util.List; -public record AdminHintListResponse(List results) { +public record AdminHintListResponse(List results, List automatic) { } 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 index 142b606..644291c 100644 --- 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 @@ -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) { } diff --git a/src/main/java/auth_service/modules/hint/api/dto/response/AutoHintResponse.java b/src/main/java/auth_service/modules/hint/api/dto/response/AutoHintResponse.java new file mode 100644 index 0000000..14a8ba6 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/api/dto/response/AutoHintResponse.java @@ -0,0 +1,4 @@ +package auth_service.modules.hint.api.dto.response; + +public record AutoHintResponse(String key, boolean enabled) { +} 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 index 0bd8921..fc6035c 100644 --- 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 @@ -2,5 +2,8 @@ package auth_service.modules.hint.api.dto.response; import java.util.List; -public record HintListResponse(List results) { +/** + * @param automatic keys of the automatic hints that are on and not hidden for this user + */ +public record HintListResponse(List results, List automatic) { } 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 index 1468b4a..5e0b501 100644 --- 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 @@ -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) { } diff --git a/src/main/java/auth_service/modules/hint/exception/HintNotFoundException.java b/src/main/java/auth_service/modules/hint/exception/HintNotFoundException.java index 83dda31..67f4ccc 100644 --- a/src/main/java/auth_service/modules/hint/exception/HintNotFoundException.java +++ b/src/main/java/auth_service/modules/hint/exception/HintNotFoundException.java @@ -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); + } } diff --git a/src/main/java/auth_service/modules/hint/model/AutoHint.java b/src/main/java/auth_service/modules/hint/model/AutoHint.java new file mode 100644 index 0000000..1ff1444 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/model/AutoHint.java @@ -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); + } + } +} diff --git a/src/main/java/auth_service/modules/hint/model/AutoHintState.java b/src/main/java/auth_service/modules/hint/model/AutoHintState.java new file mode 100644 index 0000000..cf1c1fa --- /dev/null +++ b/src/main/java/auth_service/modules/hint/model/AutoHintState.java @@ -0,0 +1,4 @@ +package auth_service.modules.hint.model; + +public record AutoHintState(AutoHint key, boolean enabled) { +} diff --git a/src/main/java/auth_service/modules/hint/model/Hint.java b/src/main/java/auth_service/modules/hint/model/Hint.java index 9783c4b..5d28221 100644 --- a/src/main/java/auth_service/modules/hint/model/Hint.java +++ b/src/main/java/auth_service/modules/hint/model/Hint.java @@ -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) { } diff --git a/src/main/java/auth_service/modules/hint/model/HintTarget.java b/src/main/java/auth_service/modules/hint/model/HintTarget.java new file mode 100644 index 0000000..8b667c7 --- /dev/null +++ b/src/main/java/auth_service/modules/hint/model/HintTarget.java @@ -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); +} diff --git a/src/main/java/auth_service/modules/hint/model/NewHint.java b/src/main/java/auth_service/modules/hint/model/NewHint.java index 5a63b89..b224c50 100644 --- a/src/main/java/auth_service/modules/hint/model/NewHint.java +++ b/src/main/java/auth_service/modules/hint/model/NewHint.java @@ -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) { } diff --git a/src/main/java/auth_service/modules/hint/repo/HintRepository.java b/src/main/java/auth_service/modules/hint/repo/HintRepository.java index 76ac035..5614925 100644 --- a/src/main/java/auth_service/modules/hint/repo/HintRepository.java +++ b/src/main/java/auth_service/modules/hint/repo/HintRepository.java @@ -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 = (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 findActiveFor(long userId, Instant now) { + /** + * @param role the active role of the caller; a target role matches this role and higher + */ + public List 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 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 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 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); + } } diff --git a/src/main/java/auth_service/modules/hint/service/AdminHintService.java b/src/main/java/auth_service/modules/hint/service/AdminHintService.java index 964a6cb..8476337 100644 --- a/src/main/java/auth_service/modules/hint/service/AdminHintService.java +++ b/src/main/java/auth_service/modules/hint/service/AdminHintService.java @@ -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 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"); + } } diff --git a/src/main/java/auth_service/modules/hint/service/HintService.java b/src/main/java/auth_service/modules/hint/service/HintService.java index 37ae451..3310aac 100644 --- a/src/main/java/auth_service/modules/hint/service/HintService.java +++ b/src/main/java/auth_service/modules/hint/service/HintService.java @@ -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 activeHints(long userId) { - return hintRepository.findActiveFor(userId, Instant.now()); + public List activeHints(long userId, Role role) { + return hintRepository.findActiveFor(userId, role, Instant.now()); + } + + public List 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) { diff --git a/src/main/resources/db/migration/V30__Hint_Targets.sql b/src/main/resources/db/migration/V30__Hint_Targets.sql new file mode 100644 index 0000000..fea74ad --- /dev/null +++ b/src/main/resources/db/migration/V30__Hint_Targets.sql @@ -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) +); diff --git a/src/test/java/auth_service/modules/hint/api/HintFlowTest.java b/src/test/java/auth_service/modules/hint/api/HintFlowTest.java index bac17b1..9022b2a 100644 --- a/src/test/java/auth_service/modules/hint/api/HintFlowTest.java +++ b/src/test/java/auth_service/modules/hint/api/HintFlowTest.java @@ -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()); + } } diff --git a/src/test/java/auth_service/modules/hint/repo/HintRepositoryTest.java b/src/test/java/auth_service/modules/hint/repo/HintRepositoryTest.java index 9109e2a..f444cc8 100644 --- a/src/test/java/auth_service/modules/hint/repo/HintRepositoryTest.java +++ b/src/test/java/auth_service/modules/hint/repo/HintRepositoryTest.java @@ -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 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(); } } diff --git a/src/test/java/auth_service/modules/hint/service/AdminHintServiceTest.java b/src/test/java/auth_service/modules/hint/service/AdminHintServiceTest.java index 04a14fa..8b70f5c 100644 --- a/src/test/java/auth_service/modules/hint/service/AdminHintServiceTest.java +++ b/src/test/java/auth_service/modules/hint/service/AdminHintServiceTest.java @@ -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); + } }