diff --git a/readme.md b/readme.md index 5ff2d72..3d6a41b 100644 --- a/readme.md +++ b/readme.md @@ -45,7 +45,11 @@ To Be Played is split into three repositories: achievement run, or the hand changes of a quiet 10 minutes. A note counts new games, achievement unlocks, status changes and Steam wishlist adds, and leaves out what the followed user's profile hides (`public_user.sections`). Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or - deleted. The nightly job at 03:15 deletes notes older than 90 days (`app.inbox.keep-days`); the list shows the newest 50. + deleted. Each note keeps one copy of the lines behind its counts (`inbox_note_detail`: games, achievement or trophy + names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for + the new ones). `GET /api/inbox/{id}/details` shows them only while the reader still follows the subject, the subject + is public, else `404`. Rows of a hidden part or a hidden game are left out. The nightly job at 03:15 deletes notes + older than 90 days (`app.inbox.keep-days`); the list shows the newest 50. - **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id, appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then @@ -282,7 +286,8 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/follows` (`{following: [{id, displayName}], followers: [{id, displayName}]}`, `id` is the follow row; a follower with a private profile has `displayName` null) | login | | `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login | | `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login | -| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login | +| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login | +| `GET` | `/api/inbox/{id}/details` (`{results: [{kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login | | `PUT` | `/api/inbox/read` (marks all read; `204`) | login | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}]}`, `displayName`, `roleChangedAt`, `listed` and `publicProfile` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it; `publicProfile`: the profile is public, kept as a row in `public_user` with `profileSections`, `null` keeps row and sections, `false` ends the follows of the user with one `VISIBILITY` note each; `profileSections` `null` = all shown), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token | @@ -338,8 +343,8 @@ flowchart LR Psn -->|"NPSSO sign-in, played titles, trophy sets and lists"| PsnApi["PlayStation API (unofficial)"] LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")] Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db - Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db - Inbox --> Db + Follow -->|"follow, follow_digest, inbox_note, inbox_note_detail, public_user, sync_change, user_game_status_change, user_game_achievement, user_psn_trophy, wishlist_game, account_name"| Db + Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game"| Db Auth -->|"service token
/internal/accounts"| Account Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db ``` @@ -350,7 +355,7 @@ flowchart LR | `stats` | Public numbers for the landing page | | `version` | `GET /api/version`: commit and build time of the running image | | `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` | -| `inbox` | `inbox_note`: the notes of a user, `GET /api/inbox`, the 90-day purge | +| `inbox` | `inbox_note`, `inbox_note_detail`: the notes of a user, `GET /api/inbox`, `GET /api/inbox/{id}/details`, the 90-day purge | | `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, listing in `listed_user` (users Auth shows by name in owner lists, #26), public profiles and their sections in `public_user` (#27), role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync | | `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search | diff --git a/src/main/java/core_service/modules/follow/domain/Digest.java b/src/main/java/core_service/modules/follow/domain/Digest.java index 4b5edbc..8794a9b 100644 --- a/src/main/java/core_service/modules/follow/domain/Digest.java +++ b/src/main/java/core_service/modules/follow/domain/Digest.java @@ -9,8 +9,8 @@ import java.util.List; */ public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) { - private static final String SUMMARY = "SUMMARY"; - private static final List STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED"); + static final String SUMMARY = "SUMMARY"; + static final List STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED"); public boolean isEmpty() { return newGames == 0 && achievements == 0 && statusChanges == 0 && wishlistAdds == 0; diff --git a/src/main/java/core_service/modules/follow/domain/DigestItem.java b/src/main/java/core_service/modules/follow/domain/DigestItem.java new file mode 100644 index 0000000..3d17442 --- /dev/null +++ b/src/main/java/core_service/modules/follow/domain/DigestItem.java @@ -0,0 +1,28 @@ +package core_service.modules.follow.domain; + +/** + * One line behind a digest count. Names are snapshots. The kind is a plain string: the inbox module has its own enum. + */ +public record DigestItem(String kind, long appid, String gameName, String itemName, String fromStatus, String toStatus) { + + public static final String NEW_GAME = "NEW_GAME"; + public static final String ACHIEVEMENT = "ACHIEVEMENT"; + public static final String STATUS = "STATUS"; + public static final String WISHLIST = "WISHLIST"; + + public static DigestItem newGame(long appid, String gameName) { + return new DigestItem(NEW_GAME, appid, gameName, null, null, null); + } + + public static DigestItem achievement(long appid, String gameName, String itemName) { + return new DigestItem(ACHIEVEMENT, appid, gameName, itemName, null, null); + } + + public static DigestItem status(long appid, String gameName, String from, String to) { + return new DigestItem(STATUS, appid, gameName, null, from, to); + } + + public static DigestItem wishlist(long appid, String gameName) { + return new DigestItem(WISHLIST, appid, gameName, null, null, null); + } +} diff --git a/src/main/java/core_service/modules/follow/domain/DigestItems.java b/src/main/java/core_service/modules/follow/domain/DigestItems.java new file mode 100644 index 0000000..291cba4 --- /dev/null +++ b/src/main/java/core_service/modules/follow/domain/DigestItems.java @@ -0,0 +1,66 @@ +package core_service.modules.follow.domain; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The lines behind a {@link Digest}, in the order they are shown. + */ +public record DigestItems(List items) { + + public DigestItems plus(DigestItems other) { + List all = new ArrayList<>(items); + all.addAll(other.items); + return new DigestItems(all); + } + + /** + * Same rule as {@link Digest#visibleOn}. + * + * @param sections the profile parts the user shows, null = all + */ + public DigestItems visibleOn(Collection sections) { + if (sections == null) { + return this; + } + boolean summary = sections.contains(Digest.SUMMARY); + boolean status = Digest.STATUS_SECTIONS.stream().anyMatch(sections::contains); + return new DigestItems(items.stream().filter(item -> switch (item.kind()) { + case DigestItem.NEW_GAME, DigestItem.ACHIEVEMENT -> summary; + case DigestItem.STATUS -> status; + default -> true; + }).toList()); + } + + /** + * One STATUS line per game: the first old status to the last new one, dropped when they are equal. The line keeps + * the place of the first move of its game. + */ + public DigestItems collapseStatus() { + Map lastTo = new HashMap<>(); + for (DigestItem item : items) { + if (DigestItem.STATUS.equals(item.kind())) { + lastTo.put(item.appid(), item.toStatus()); + } + } + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + for (DigestItem item : items) { + if (!DigestItem.STATUS.equals(item.kind())) { + result.add(item); + } else if (seen.add(item.appid())) { + String to = lastTo.get(item.appid()); + if (!Objects.equals(item.fromStatus(), to)) { + result.add(DigestItem.status(item.appid(), item.gameName(), item.fromStatus(), to)); + } + } + } + return new DigestItems(result); + } +} diff --git a/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java b/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java index 768579e..661f6df 100644 --- a/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java +++ b/src/main/java/core_service/modules/follow/repo/FollowDigestRepository.java @@ -2,15 +2,19 @@ package core_service.modules.follow.repo; import core_service.modules.follow.domain.Cursor; import core_service.modules.follow.domain.Digest; +import core_service.modules.follow.domain.DigestItem; +import core_service.modules.follow.domain.DigestItems; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; import org.springframework.stereotype.Repository; import java.sql.Array; import java.sql.Timestamp; import java.time.Instant; import java.util.Arrays; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -22,6 +26,8 @@ import java.util.Optional; @RequiredArgsConstructor public class FollowDigestRepository { + static final int MAX_ACHIEVEMENTS_PER_GAME = 20; + private final NamedParameterJdbcTemplate jdbc; /** @@ -98,6 +104,90 @@ public class FollowDigestRepository { (rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0)); } + /** + * The lines behind {@link #countSync}, same joins and filters. Achievement names are not stored by the sync, only + * how many: the newest unlocks of the game (Steam and PlayStation together) stand for the new ones, at most + * {@link #MAX_ACHIEVEMENTS_PER_GAME} per game. + */ + public DigestItems findSyncItems(long userId) { + List items = new ArrayList<>(findSyncNewGames(userId)); + items.addAll(findSyncAchievements(userId)); + return new DigestItems(items); + } + + private List findSyncNewGames(long userId) { + return jdbc.query(""" + select distinct c.appid, sg.name from sync_change c + join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid + left join steam_game sg on sg.appid = c.appid + where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null and ug.removed_at is null + order by sg.name, c.appid""", user(userId), (rs, i) -> DigestItem.newGame(rs.getLong("appid"), rs.getString("name"))); + } + + private List findSyncAchievements(long userId) { + return jdbc.query(""" + with gained as ( + select c.appid, sum(c.amount) as amount from sync_change c + join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid + where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null and ug.removed_at is null + group by c.appid), + unlocks as ( + select a.appid, coalesce(s.display_name, a.api_name) as name, a.unlocked_at as at + from user_game_achievement a + left join game_achievement_schema s on s.appid = a.appid and s.api_name = a.api_name + where a.user_id = :userId and a.unlocked + union all + select st.appid, coalesce(t.name, 'Trophy ' || t.trophy_id), ut.earned_at + from user_psn_trophy ut + join user_psn_trophy_set st on st.user_id = ut.user_id and st.np_communication_id = ut.np_communication_id + join psn_trophy t on t.np_communication_id = ut.np_communication_id and t.trophy_id = ut.trophy_id + where ut.user_id = :userId), + ranked as ( + select u.appid, u.name, row_number() over (partition by u.appid order by u.at desc nulls last, u.name) as rn + from unlocks u join gained g on g.appid = u.appid) + select r.appid, sg.name as game_name, r.name from ranked r + join gained g on g.appid = r.appid + left join steam_game sg on sg.appid = r.appid + where r.rn <= least(g.amount, :cap) + order by sg.name, r.appid, r.rn""", user(userId).addValue("cap", MAX_ACHIEVEMENTS_PER_GAME), + (rs, i) -> DigestItem.achievement(rs.getLong("appid"), rs.getString("game_name"), rs.getString("name"))); + } + + /** + * The lines behind {@link #countHand}, same filters. With {@code withSyncMoves} the status moves of the sync are + * read from the same log, so a game's moves by hand and by sync stay in time order for the collapse. + */ + public DigestItems findHandItems(long userId, long afterId, long upToId, boolean withSyncMoves) { + return new DigestItems(jdbc.query(""" + select c.appid, sg.name, c.from_status, c.to_status + from user_game_status_change c + join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid + left join steam_game sg on sg.appid = c.appid + where c.user_id = :userId and c.id > :afterId and c.id <= :upToId + and (c.by_user or (:withSyncMoves and c.from_status is not null)) + and ug.hidden_at is null and ug.removed_at is null + order by c.id""", + user(userId).addValue("afterId", afterId).addValue("upToId", upToId).addValue("withSyncMoves", withSyncMoves), + (rs, i) -> rs.getString("from_status") == null + ? DigestItem.newGame(rs.getLong("appid"), rs.getString("name")) + : DigestItem.status(rs.getLong("appid"), rs.getString("name"), rs.getString("from_status"), + rs.getString("to_status")))); + } + + /** + * The lines behind {@link #countWishlist}, same id range. + */ + public DigestItems findWishlistItems(long userId, long afterId, long upToId) { + return new DigestItems(jdbc.query(""" + select c.appid, coalesce(w.name, sg.name, 'App ' || c.appid) as name from sync_change c + left join wishlist_game w on w.user_id = c.user_id and w.appid = c.appid + left join steam_game sg on sg.appid = c.appid + where c.user_id = :userId and c.kind = 'WISHLIST' and c.id > :afterId and c.id <= :upToId + order by c.id""", + user(userId).addValue("afterId", afterId).addValue("upToId", upToId), + (rs, i) -> DigestItem.wishlist(rs.getLong("appid"), rs.getString("name")))); + } + /** * @return null when there is no row or the user did not send sections: nothing is filtered */ @@ -115,10 +205,10 @@ public class FollowDigestRepository { * One note per follower, only while the followed user is public, not blocked and not deleted and the follower is * not deleted. A name is stored as it is now. * - * @return how many notes were written + * @return the ids of the notes written */ - public int insertDigestNotes(long userId, Digest digest) { - return jdbc.update(""" + public List insertDigestNotes(long userId, Digest digest) { + return jdbc.query(""" insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements, status_changes, wishlist_adds) select f.user_id, 'DIGEST', f.followed_user_id, @@ -128,9 +218,32 @@ public class FollowDigestRepository { where f.followed_user_id = :userId and exists (select 1 from public_user p where p.user_id = f.followed_user_id) and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id) - and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id))""", + and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id)) + returning id""", user(userId).addValue("newGames", digest.newGames()).addValue("achievements", digest.achievements()) - .addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds())); + .addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds()), + (rs, i) -> rs.getLong("id")); + } + + /** + * One copy of the lines per note: the note owns them and they go with it. + */ + public void insertDigestItems(List noteIds, DigestItems items) { + if (noteIds.isEmpty() || items.items().isEmpty()) { + return; + } + List rows = new ArrayList<>(); + for (long noteId : noteIds) { + for (DigestItem item : items.items()) { + rows.add(new MapSqlParameterSource("noteId", noteId).addValue("kind", item.kind()) + .addValue("appid", item.appid()).addValue("gameName", item.gameName()) + .addValue("itemName", item.itemName()).addValue("fromStatus", item.fromStatus()) + .addValue("toStatus", item.toStatus())); + } + } + jdbc.batchUpdate("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) " + + "values (:noteId, :kind, :appid, :gameName, :itemName, :fromStatus, :toStatus)", + rows.toArray(new SqlParameterSource[0])); } public void moveCursor(long userId, Instant syncStartedAt, long statusChangeId, long wishlistChangeId) { diff --git a/src/main/java/core_service/modules/follow/service/FollowDigestService.java b/src/main/java/core_service/modules/follow/service/FollowDigestService.java index f6f0966..6a105b3 100644 --- a/src/main/java/core_service/modules/follow/service/FollowDigestService.java +++ b/src/main/java/core_service/modules/follow/service/FollowDigestService.java @@ -2,6 +2,7 @@ package core_service.modules.follow.service; import core_service.modules.follow.domain.Cursor; import core_service.modules.follow.domain.Digest; +import core_service.modules.follow.domain.DigestItems; import core_service.modules.follow.repo.FollowDigestRepository; import core_service.shared.event.LibraryActivityEvent; import lombok.extern.slf4j.Slf4j; @@ -19,7 +20,8 @@ import java.util.Optional; /** * One inbox note per follower and batch. A batch is a sync with its achievement run, plus the hand changes since the - * last batch. The cursor moves in every case, so what the profile hides is never reported later. + * last batch. The cursor moves in every case, so what the profile hides is never reported later. Each note also gets + * the lines behind its counts (status moves collapsed per game). */ @Service @Slf4j @@ -71,19 +73,26 @@ public class FollowDigestService { Digest digest = digestRepository.countHand(userId, cursor.statusChangeId(), upTo); Instant syncStartedAt = cursor.syncStartedAt(); long wishlistUpTo = cursor.wishlistChangeId(); + Optional current = withSync ? digestRepository.findSyncStartedAt(userId) : Optional.empty(); + boolean newSync = current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt)); + DigestItems items = digestRepository.findHandItems(userId, cursor.statusChangeId(), upTo, newSync); if (withSync) { - Optional current = digestRepository.findSyncStartedAt(userId); - if (current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt))) { + if (newSync) { digest = digest.plus(digestRepository.countSync(userId)); + items = items.plus(digestRepository.findSyncItems(userId)); syncStartedAt = current.get(); } wishlistUpTo = digestRepository.maxWishlistChangeId(userId); digest = digest.plus(new Digest(0, 0, 0, digestRepository.countWishlist(userId, cursor.wishlistChangeId(), wishlistUpTo))); + items = items.plus(digestRepository.findWishlistItems(userId, cursor.wishlistChangeId(), wishlistUpTo)); } - digest = digest.visibleOn(digestRepository.findPublicSections(userId)); + List sections = digestRepository.findPublicSections(userId); + digest = digest.visibleOn(sections); + items = items.visibleOn(sections).collapseStatus(); if (!digest.isEmpty()) { - digestRepository.insertDigestNotes(userId, digest); + List noteIds = digestRepository.insertDigestNotes(userId, digest); + digestRepository.insertDigestItems(noteIds, items); } digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo); }); diff --git a/src/main/java/core_service/modules/inbox/api/InboxController.java b/src/main/java/core_service/modules/inbox/api/InboxController.java index 394aba9..a2570b1 100644 --- a/src/main/java/core_service/modules/inbox/api/InboxController.java +++ b/src/main/java/core_service/modules/inbox/api/InboxController.java @@ -1,13 +1,17 @@ package core_service.modules.inbox.api; import core_service.modules.inbox.api.dto.InboxDto; +import core_service.modules.inbox.api.dto.NoteDetailsDto; import core_service.modules.inbox.domain.Inbox; import core_service.modules.inbox.service.InboxService; +import core_service.shared.api.ErrorResponse; import core_service.shared.security.UserPrincipal; 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.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -24,10 +28,21 @@ public class InboxController { Inbox inbox = inboxService.inbox(principal.getIdAsLong()); return ResponseEntity.ok(new InboxDto(inbox.notes().stream() .map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.newGames(), - note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read())) + note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read(), note.details())) .toList(), inbox.unread())); } + @GetMapping("/{id}/details") + public ResponseEntity details(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) { + return inboxService.details(principal.getIdAsLong(), id) + .>map(details -> ResponseEntity.ok(new NoteDetailsDto(details.stream() + .map(detail -> new NoteDetailsDto.Detail(detail.kind().name(), detail.gameName(), + detail.itemName(), detail.fromStatus(), detail.toStatus())) + .toList()))) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse("NOTE_NOT_FOUND", "No such note"))); + } + @PutMapping("/read") public ResponseEntity markAllRead(@AuthenticationPrincipal UserPrincipal principal) { inboxService.markAllRead(principal.getIdAsLong()); diff --git a/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java b/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java index 8dc2a65..c4aff8f 100644 --- a/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java +++ b/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java @@ -11,8 +11,9 @@ public record InboxDto(List results, int unread) { /** * @param kind DIGEST, VISIBILITY, PSN_EXPIRING or PSN_EXPIRED (the PSN kinds have no displayName) * @param displayName the name of the followed user when the note was written; never a user id + * @param details the note can be opened: it has detail rows and the subject is still followed and public */ public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges, - int wishlistAdds, Instant createdAt, boolean read) { + int wishlistAdds, Instant createdAt, boolean read, boolean details) { } } diff --git a/src/main/java/core_service/modules/inbox/api/dto/NoteDetailsDto.java b/src/main/java/core_service/modules/inbox/api/dto/NoteDetailsDto.java new file mode 100644 index 0000000..c9a958c --- /dev/null +++ b/src/main/java/core_service/modules/inbox/api/dto/NoteDetailsDto.java @@ -0,0 +1,15 @@ +package core_service.modules.inbox.api.dto; + +import java.util.List; + +public record NoteDetailsDto(List results) { + + /** + * @param kind NEW_GAME, ACHIEVEMENT, STATUS or WISHLIST + * @param itemName ACHIEVEMENT only: the achievement or trophy name + * @param fromStatus STATUS only + * @param toStatus STATUS only + */ + public record Detail(String kind, String gameName, String itemName, String fromStatus, String toStatus) { + } +} diff --git a/src/main/java/core_service/modules/inbox/domain/InboxNote.java b/src/main/java/core_service/modules/inbox/domain/InboxNote.java index 29feeea..d473fc3 100644 --- a/src/main/java/core_service/modules/inbox/domain/InboxNote.java +++ b/src/main/java/core_service/modules/inbox/domain/InboxNote.java @@ -4,7 +4,8 @@ import java.time.Instant; /** * @param subjectName the name of the followed user when the note was written + * @param details the note can be opened: it has detail rows and the subject is still followed and public */ public record InboxNote(long id, NoteKind kind, String subjectName, int newGames, int achievements, - int statusChanges, int wishlistAdds, Instant createdAt, boolean read) { + int statusChanges, int wishlistAdds, Instant createdAt, boolean read, boolean details) { } diff --git a/src/main/java/core_service/modules/inbox/domain/NoteDetail.java b/src/main/java/core_service/modules/inbox/domain/NoteDetail.java new file mode 100644 index 0000000..2458b0e --- /dev/null +++ b/src/main/java/core_service/modules/inbox/domain/NoteDetail.java @@ -0,0 +1,7 @@ +package core_service.modules.inbox.domain; + +/** + * One line behind a digest note. Names are snapshots from when the note was written. + */ +public record NoteDetail(NoteDetailKind kind, String gameName, String itemName, String fromStatus, String toStatus) { +} diff --git a/src/main/java/core_service/modules/inbox/domain/NoteDetailKind.java b/src/main/java/core_service/modules/inbox/domain/NoteDetailKind.java new file mode 100644 index 0000000..6055ce2 --- /dev/null +++ b/src/main/java/core_service/modules/inbox/domain/NoteDetailKind.java @@ -0,0 +1,29 @@ +package core_service.modules.inbox.domain; + +import java.util.Collection; +import java.util.List; + +public enum NoteDetailKind { + NEW_GAME, + ACHIEVEMENT, + STATUS, + WISHLIST; + + private static final List STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED"); + + /** + * Mirror of follow.domain.Digest.visibleOn, modules may not import each other. + * + * @param sections the profile parts the user shows, null = all + */ + public boolean visibleOn(Collection sections) { + if (sections == null) { + return true; + } + return switch (this) { + case NEW_GAME, ACHIEVEMENT -> sections.contains("SUMMARY"); + case STATUS -> STATUS_SECTIONS.stream().anyMatch(sections::contains); + case WISHLIST -> true; + }; + } +} diff --git a/src/main/java/core_service/modules/inbox/repo/InboxRepository.java b/src/main/java/core_service/modules/inbox/repo/InboxRepository.java index 0fa2146..45f895d 100644 --- a/src/main/java/core_service/modules/inbox/repo/InboxRepository.java +++ b/src/main/java/core_service/modules/inbox/repo/InboxRepository.java @@ -1,15 +1,20 @@ package core_service.modules.inbox.repo; import core_service.modules.inbox.domain.InboxNote; +import core_service.modules.inbox.domain.NoteDetail; +import core_service.modules.inbox.domain.NoteDetailKind; import core_service.modules.inbox.domain.NoteKind; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; +import java.sql.Array; import java.sql.Timestamp; import java.time.Instant; +import java.util.Arrays; import java.util.List; +import java.util.Optional; @Repository @RequiredArgsConstructor @@ -19,14 +24,63 @@ public class InboxRepository { public List findNewest(long userId, int limit) { return jdbc.query(""" - select id, kind, subject_name, new_games, achievements, status_changes, wishlist_adds, created_at, read_at - from inbox_note where user_id = :userId - order by created_at desc, id desc + select n.id, n.kind, n.subject_name, n.new_games, n.achievements, n.status_changes, n.wishlist_adds, + n.created_at, n.read_at, + exists (select 1 from inbox_note_detail d where d.note_id = n.id) + and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id) + and exists (select 1 from public_user p where p.user_id = n.subject_user_id) as details + from inbox_note n where n.user_id = :userId + order by n.created_at desc, n.id desc limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit), (rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")), rs.getString("subject_name"), rs.getInt("new_games"), rs.getInt("achievements"), rs.getInt("status_changes"), rs.getInt("wishlist_adds"), - rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null)); + rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null, + rs.getBoolean("details"))); + } + + /** + * Ownership and the follow re-check in one statement. + * + * @return the followed user of a DIGEST note of this user, empty when the note is not theirs, they no longer + * follow the subject or the subject is no longer public + */ + public Optional findFollowedSubject(long userId, long noteId) { + return jdbc.query(""" + select n.subject_user_id from inbox_note n + where n.id = :noteId and n.user_id = :userId and n.kind = 'DIGEST' + and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id) + and exists (select 1 from public_user p where p.user_id = n.subject_user_id)""", + new MapSqlParameterSource("userId", userId).addValue("noteId", noteId), + (rs, i) -> rs.getLong("subject_user_id")).stream().findFirst(); + } + + /** + * @return null when there is no row or the user did not send sections: nothing is filtered + */ + public List findPublicSections(long subjectUserId) { + return jdbc.query("select sections from public_user where user_id = :userId", + new MapSqlParameterSource("userId", subjectUserId), rs -> { + if (!rs.next()) { + return null; + } + Array sections = rs.getArray("sections"); + return sections == null ? null : Arrays.asList((String[]) sections.getArray()); + }); + } + + /** + * Without the games the subject hides now. + */ + public List findDetails(long noteId, long subjectUserId) { + return jdbc.query(""" + select d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d + where d.note_id = :noteId + and not exists (select 1 from user_game ug + where ug.user_id = :subjectUserId and ug.appid = d.appid and ug.hidden_at is not null) + order by d.id""", new MapSqlParameterSource("noteId", noteId).addValue("subjectUserId", subjectUserId), + (rs, i) -> new NoteDetail(NoteDetailKind.valueOf(rs.getString("kind")), rs.getString("game_name"), + rs.getString("item_name"), rs.getString("from_status"), rs.getString("to_status"))); } public int countUnread(long userId) { diff --git a/src/main/java/core_service/modules/inbox/service/InboxService.java b/src/main/java/core_service/modules/inbox/service/InboxService.java index fe01123..8bee555 100644 --- a/src/main/java/core_service/modules/inbox/service/InboxService.java +++ b/src/main/java/core_service/modules/inbox/service/InboxService.java @@ -1,6 +1,7 @@ package core_service.modules.inbox.service; import core_service.modules.inbox.domain.Inbox; +import core_service.modules.inbox.domain.NoteDetail; import core_service.modules.inbox.repo.InboxRepository; import core_service.shared.security.RequiresRole; import core_service.shared.security.Role; @@ -13,6 +14,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.Instant; +import java.util.List; +import java.util.Optional; @Service @Slf4j @@ -32,6 +35,22 @@ public class InboxService { return new Inbox(inboxRepository.findNewest(userId, LIST_SIZE), inboxRepository.countUnread(userId)); } + /** + * Checked again on every read: the caller must own the note, still follow the subject, the subject must be + * public and show the part. + * + * @return empty when the note cannot be opened + */ + @RequiresRole(Role.USER) + @Transactional(readOnly = true) + public Optional> details(long userId, long noteId) { + return inboxRepository.findFollowedSubject(userId, noteId).map(subject -> { + List sections = inboxRepository.findPublicSections(subject); + return inboxRepository.findDetails(noteId, subject).stream() + .filter(detail -> detail.kind().visibleOn(sections)).toList(); + }); + } + @RequiresRole(Role.USER) @Transactional public void markAllRead(long userId) { diff --git a/src/main/resources/db/migration/V48__Inbox_Note_Detail.sql b/src/main/resources/db/migration/V48__Inbox_Note_Detail.sql new file mode 100644 index 0000000..bb7dea3 --- /dev/null +++ b/src/main/resources/db/migration/V48__Inbox_Note_Detail.sql @@ -0,0 +1,12 @@ +-- What a DIGEST note counted (#27 follow-up). Names are snapshots; the row goes with its note. +CREATE TABLE inbox_note_detail ( + id BIGSERIAL PRIMARY KEY, + note_id BIGINT NOT NULL REFERENCES inbox_note (id) ON DELETE CASCADE, + kind VARCHAR(16) NOT NULL CHECK (kind IN ('NEW_GAME', 'ACHIEVEMENT', 'STATUS', 'WISHLIST')), + appid BIGINT NOT NULL, + game_name VARCHAR(512), -- steam_game.name / wishlist_game.name at write time + item_name VARCHAR(512), -- ACHIEVEMENT: achievement or trophy name + from_status VARCHAR(16), -- STATUS only + to_status VARCHAR(16) +); +CREATE INDEX idx_inbox_note_detail_note ON inbox_note_detail (note_id); diff --git a/src/test/java/core_service/modules/follow/domain/DigestItemsTest.java b/src/test/java/core_service/modules/follow/domain/DigestItemsTest.java new file mode 100644 index 0000000..498719a --- /dev/null +++ b/src/test/java/core_service/modules/follow/domain/DigestItemsTest.java @@ -0,0 +1,65 @@ +package core_service.modules.follow.domain; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class DigestItemsTest extends AbstractUnitTest { + + private static DigestItems of(DigestItem... items) { + return new DigestItems(List.of(items)); + } + + private static final DigestItems ALL = of(DigestItem.newGame(1, "A"), DigestItem.achievement(1, "A", "Win"), + DigestItem.status(2, "B", "BACKLOG", "PLAYING"), DigestItem.wishlist(3, "C")); + + @Test + void collapseStatus_FirstFromToLastTo() { + DigestItems items = of(DigestItem.status(1, "A", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "FINISHED")); + + assertThat(items.collapseStatus().items()).containsExactly(DigestItem.status(1, "A", "BACKLOG", "FINISHED")); + } + + @Test + void collapseStatus_DropsAGameThatEndsWhereItStarted() { + DigestItems items = of(DigestItem.status(1, "A", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "BACKLOG")); + + assertThat(items.collapseStatus().items()).isEmpty(); + } + + @Test + void collapseStatus_KeepsGamesApartAndOtherKindsAndOrder() { + DigestItems items = of(DigestItem.newGame(9, "N"), DigestItem.status(1, "A", "BACKLOG", "PLAYING"), + DigestItem.status(2, "B", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "FINISHED")); + + assertThat(items.collapseStatus().items()).containsExactly(DigestItem.newGame(9, "N"), + DigestItem.status(1, "A", "BACKLOG", "FINISHED"), DigestItem.status(2, "B", "BACKLOG", "PLAYING")); + } + + @Test + void visibleOn_NullKeepsAll() { + assertThat(ALL.visibleOn(null)).isEqualTo(ALL); + } + + @Test + void visibleOn_StatusSectionDropsNewGamesAndAchievementsAndKeepsWishlist() { + assertThat(ALL.visibleOn(Set.of("STATUS")).items()).extracting(DigestItem::kind) + .containsExactly(DigestItem.STATUS, DigestItem.WISHLIST); + } + + @Test + void visibleOn_SummaryKeepsNewGamesAndAchievementsButNotStatus() { + assertThat(ALL.visibleOn(Set.of("SUMMARY")).items()).extracting(DigestItem::kind) + .containsExactly(DigestItem.NEW_GAME, DigestItem.ACHIEVEMENT, DigestItem.WISHLIST); + } + + @Test + void plus_KeepsReceiverFirst() { + assertThat(of(DigestItem.wishlist(1, "A")).plus(of(DigestItem.wishlist(2, "B"))).items()) + .extracting(DigestItem::appid).containsExactly(1L, 2L); + } +} diff --git a/src/test/java/core_service/modules/follow/repo/FollowDigestRepositoryTest.java b/src/test/java/core_service/modules/follow/repo/FollowDigestRepositoryTest.java index f8211d8..a32fc4c 100644 --- a/src/test/java/core_service/modules/follow/repo/FollowDigestRepositoryTest.java +++ b/src/test/java/core_service/modules/follow/repo/FollowDigestRepositoryTest.java @@ -1,6 +1,8 @@ package core_service.modules.follow.repo; import core_service.modules.follow.domain.Digest; +import core_service.modules.follow.domain.DigestItem; +import core_service.modules.follow.domain.DigestItems; import core_service.shared.test.AbstractPersistenceTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -8,6 +10,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; @Import(FollowDigestRepository.class) @@ -27,7 +31,7 @@ class FollowDigestRepositoryTest extends AbstractPersistenceTest { void seed() { jdbc.update("insert into sync_result (user_id, sync_started_at, started_by, imported_games) values (?, now(), 'AUTO', 0)", USER); for (long appid : new long[]{LISTED, REMOVED}) { - jdbc.update("insert into steam_game (appid, name) values (?, 'Game')", appid); + jdbc.update("insert into steam_game (appid, name) values (?, ?)", appid, "Game " + appid); jdbc.update("insert into user_game (user_id, appid, status, source, platform, status_set_by_user) " + "values (?, ?, 'BACKLOG', 'MANUAL', 'PC', true)", USER, appid); jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, 'ADDED', ?, null)", USER, appid); @@ -49,4 +53,81 @@ class FollowDigestRepositoryTest extends AbstractPersistenceTest { assertThat(digest.newGames()).isEqualTo(1); } + + private void unlock(long appid, String apiName, String displayName, int daysAgo) { + jdbc.update("insert into game_achievement_schema (appid, api_name, display_name) values (?, ?, ?)", appid, apiName, displayName); + jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) " + + "values (?, ?, ?, true, now() - make_interval(days => ?))", USER, appid, apiName, daysAgo); + } + + @Test + void findSyncItems_SkipsRemovedGamesAndNamesTheGame() { + DigestItems items = repository.findSyncItems(USER); + + assertThat(items.items()).contains(DigestItem.newGame(LISTED, "Game 10")); + assertThat(items.items()).noneMatch(item -> item.appid() == REMOVED); + } + + @Test + void findSyncItems_TakesTheAmountNewestUnlocks() { + unlock(LISTED, "a", "Oldest", 40); + unlock(LISTED, "b", "Third", 30); + unlock(LISTED, "c", "Second", 20); + unlock(LISTED, "d", "Newest", 10); + unlock(REMOVED, "e", "Removed one", 1); + + List achievements = repository.findSyncItems(USER).items().stream() + .filter(item -> DigestItem.ACHIEVEMENT.equals(item.kind())).toList(); + + assertThat(achievements).containsExactly(DigestItem.achievement(LISTED, "Game 10", "Newest"), + DigestItem.achievement(LISTED, "Game 10", "Second"), DigestItem.achievement(LISTED, "Game 10", "Third")); + } + + @Test + void findSyncItems_PsnTrophiesCompeteWithSteamUnlocksOfTheSameGame() { + unlock(LISTED, "a", "Old steam", 40); + jdbc.update("insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, " + + "defined_silver, defined_gold, defined_platinum) values ('NPWR1', 'trophy', 'PS4', 'T', 1, 0, 0, 0)"); + jdbc.update("insert into psn_trophy (np_communication_id, trophy_id, type, name) values ('NPWR1', 1, 'BRONZE', 'Trophy one')"); + jdbc.update("insert into psn_trophy (np_communication_id, trophy_id, type, name) values ('NPWR1', 2, 'BRONZE', null)"); + jdbc.update("insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) " + + "values (?, 'NPWR1', ?, 2, false)", USER, LISTED); + jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at) values (?, 'NPWR1', 1, now() - interval '1 day')", USER); + jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at) values (?, 'NPWR1', 2, now() - interval '2 days')", USER); + + List names = repository.findSyncItems(USER).items().stream() + .filter(item -> DigestItem.ACHIEVEMENT.equals(item.kind())).map(DigestItem::itemName).toList(); + + assertThat(names).containsExactly("Trophy one", "Trophy 2", "Old steam"); + } + + @Test + void findWishlistItems_UsesTheWishlistName() { + jdbc.update("insert into wishlist_game (user_id, appid, name) values (?, 777, 'Wished')", USER); + jdbc.update("insert into sync_change (user_id, kind, appid) values (?, 'WISHLIST', 777)", USER); + + assertThat(repository.findWishlistItems(USER, 0, Long.MAX_VALUE).items()) + .containsExactly(DigestItem.wishlist(777, "Wished")); + } + + @Test + void findHandItems_NoOldStatusIsANewGame() { + assertThat(repository.findHandItems(USER, 0, Long.MAX_VALUE, false).items()) + .containsExactly(DigestItem.newGame(LISTED, "Game 10")); + } + + @Test + void insertDigestNotesAndItems_ReturnIdsAndWriteRowsPerNote() { + jdbc.update("insert into public_user (user_id) values (?)", USER); + jdbc.update("insert into follow (user_id, followed_user_id) values (2, ?), (3, ?)", USER, USER); + + List ids = repository.insertDigestNotes(USER, new Digest(1, 0, 0, 0)); + repository.insertDigestItems(ids, new DigestItems(List.of(DigestItem.newGame(LISTED, "Game 10"), + DigestItem.wishlist(5, "W")))); + + assertThat(ids).hasSize(2); + assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail", Integer.class)).isEqualTo(4); + assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail where note_id = ?", Integer.class, ids.get(0))) + .isEqualTo(2); + } } diff --git a/src/test/java/core_service/modules/follow/service/FollowDigestServiceTest.java b/src/test/java/core_service/modules/follow/service/FollowDigestServiceTest.java index 81322a0..fd3eb38 100644 --- a/src/test/java/core_service/modules/follow/service/FollowDigestServiceTest.java +++ b/src/test/java/core_service/modules/follow/service/FollowDigestServiceTest.java @@ -79,6 +79,13 @@ class FollowDigestServiceTest extends AbstractIntegrationTest { jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, ?, ?, ?)", FOLLOWED, kind, appid, amount); } + private void moved(long appid) { + jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'BACKLOG', 'PLAYING')", + FOLLOWED, appid); + jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user) values (?, ?, 'BACKLOG', 'PLAYING', false)", + FOLLOWED, appid); + } + private void added(int count) { games(count); sync(); @@ -131,6 +138,87 @@ class FollowDigestServiceTest extends AbstractIntegrationTest { assertThat(jdbc.queryForObject("select subject_name from inbox_note limit 1", String.class)).isEqualTo("Followed"); } + private List> details(long reader) { + return jdbc.queryForList("select d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d " + + "join inbox_note n on n.id = d.note_id where n.user_id = ? order by d.id", reader); + } + + @Test + void syncBatch_WritesTheLinesBehindTheCountsForEachFollower() { + added(2); + moved(FIRST_APPID + 1); + change("WISHLIST", 777_001L, null); + + event(); + + for (long reader : List.of(FOLLOWER_ONE, FOLLOWER_TWO)) { + assertThat(details(reader)).extracting(row -> row.get("kind")) + .containsExactlyInAnyOrder("NEW_GAME", "NEW_GAME", "STATUS", "WISHLIST"); + assertThat(details(reader)).anySatisfy(row -> assertThat(row).containsEntry("game_name", "Game 1")); + } + } + + @Test + void handChangesThereAndBack_WriteNoStatusLineButTheCountStays() { + handGame(920_001L, 30); + jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", FOLLOWED, 920_001L); + jdbc.update("update user_game set status = 'BACKLOG' where user_id = ? and appid = ?", FOLLOWED, 920_001L); + jdbc.update("update user_game_status_change set changed_at = now() - interval '30 minutes' where user_id = ?", FOLLOWED); + + service.digestHandChanges(); + + assertThat(notes().get(0)).containsEntry("status_changes", 2); + assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind")).doesNotContain("STATUS"); + } + + @Test + void handMoveBeforeSyncMoveGivesOneLineFromFirstToLast() { + handGame(920_001L, 30); + jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", FOLLOWED, 920_001L); + sync(); + jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'PLAYING', 'FINISHED')", + FOLLOWED, 920_001L); + jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user) values (?, ?, 'PLAYING', 'FINISHED', false)", + FOLLOWED, 920_001L); + + event(); + + assertThat(notes().get(0)).containsEntry("status_changes", 2); + assertThat(details(FOLLOWER_ONE)).filteredOn(row -> "STATUS".equals(row.get("kind"))) + .extracting(row -> row.get("from_status"), row -> row.get("to_status")) + .containsExactly(org.assertj.core.groups.Tuple.tuple("BACKLOG", "FINISHED")); + } + + @Test + void syncMoveBeforeHandMoveGivesOneLineFromFirstToLast() { + handGame(920_001L, 30); + jdbc.update("delete from user_game_status_change where user_id = ? and appid = ?", FOLLOWED, 920_001L); + sync(); + jdbc.update("update user_game set status = 'PLAYING', status_set_by_user = false where user_id = ? and appid = ?", FOLLOWED, 920_001L); + jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'BACKLOG', 'PLAYING')", + FOLLOWED, 920_001L); + jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where user_id = ? and appid = ?", FOLLOWED, 920_001L); + + event(); + + assertThat(notes().get(0)).containsEntry("status_changes", 2); + assertThat(details(FOLLOWER_ONE)).filteredOn(row -> "STATUS".equals(row.get("kind"))) + .extracting(row -> row.get("from_status"), row -> row.get("to_status")) + .containsExactly(org.assertj.core.groups.Tuple.tuple("BACKLOG", "FINISHED")); + } + + @Test + void statusSectionOnly_WritesNoNewGameOrAchievementLines() { + added(2); + change("ACHIEVEMENTS", FIRST_APPID + 1, 3); + moved(FIRST_APPID + 1); + sections("STATUS"); + + event(); + + assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind")).containsExactly("STATUS"); + } + @Test void noActivity_WritesNoNoteButMovesTheCursor() { sync(); diff --git a/src/test/java/core_service/modules/inbox/api/InboxControllerTest.java b/src/test/java/core_service/modules/inbox/api/InboxControllerTest.java index 4fbe9ca..d835589 100644 --- a/src/test/java/core_service/modules/inbox/api/InboxControllerTest.java +++ b/src/test/java/core_service/modules/inbox/api/InboxControllerTest.java @@ -35,6 +35,23 @@ class InboxControllerTest extends AbstractIntegrationTest { @AfterEach void cleanUp() { jdbc.update("delete from inbox_note"); + jdbc.update("delete from follow where user_id in (?, ?)", READER, OTHER); + jdbc.update("delete from public_user where user_id = 7"); + jdbc.update("delete from user_game where user_id = 7"); + jdbc.update("delete from steam_game where appid = 501"); + } + + /** A note of READER with lines, the subject (7) public and followed. */ + private long noteWithDetails() { + note(READER, "Subject", 1, 2); + long id = jdbc.queryForObject("select max(id) from inbox_note", Long.class); + jdbc.update("insert into follow (user_id, followed_user_id) values (?, 7)", READER); + jdbc.update("insert into public_user (user_id) values (7)"); + jdbc.update("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) values " + + "(?, 'NEW_GAME', 501, 'Alpha', null, null, null), " + + "(?, 'ACHIEVEMENT', 501, 'Alpha', 'Win', null, null), " + + "(?, 'STATUS', 502, 'Beta', null, 'BACKLOG', 'PLAYING')", id, id, id); + return id; } private static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) { @@ -95,4 +112,62 @@ class InboxControllerTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.results[0].read").value(true)); mockMvc.perform(as(get("/api/inbox"), OTHER)).andExpect(jsonPath("$.unread").value(1)); } + + @Test + void details_OfAnOwnNoteListsTheLinesWithoutIdentifiers() throws Exception { + long id = noteWithDetails(); + + String body = mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.results.length()").value(3)) + .andExpect(jsonPath("$.results[0].kind").value("NEW_GAME")) + .andExpect(jsonPath("$.results[1].itemName").value("Win")) + .andExpect(jsonPath("$.results[2].fromStatus").value("BACKLOG")) + .andReturn().getResponse().getContentAsString(); + + assertThat(body).doesNotContain("appid", "subject", "userId"); + mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true)); + } + + @Test + void details_OfAnotherReadersNoteIs404() throws Exception { + long id = noteWithDetails(); + + mockMvc.perform(as(get("/api/inbox/" + id + "/details"), OTHER)).andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").doesNotExist()); + } + + @Test + void details_AfterUnfollowIs404AndTheListShowsNoDetails() throws Exception { + long id = noteWithDetails(); + jdbc.update("delete from follow where user_id = ?", READER); + + mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isNotFound()); + mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(false)); + } + + @Test + void details_OnlyKindsOfTheSectionsThatAreStillPublic() throws Exception { + long id = noteWithDetails(); + jdbc.update("update public_user set sections = '{STATUS}' where user_id = 7"); + + mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.results.length()").value(1)) + .andExpect(jsonPath("$.results[0].kind").value("STATUS")); + } + + @Test + void details_LeaveOutAGameTheSubjectHidesNow() throws Exception { + long id = noteWithDetails(); + jdbc.update("insert into steam_game (appid, name) values (501, 'Alpha') on conflict do nothing"); + jdbc.update("insert into user_game (user_id, appid, status, hidden_at) values (7, 501, 'BACKLOG', now())"); + + mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.results.length()").value(1)) + .andExpect(jsonPath("$.results[0].gameName").value("Beta")); + } + + @Test + void details_WithoutLoginIs401() throws Exception { + mockMvc.perform(get("/api/inbox/1/details")).andExpect(status().isUnauthorized()); + } } diff --git a/src/test/java/core_service/modules/inbox/domain/NoteDetailKindTest.java b/src/test/java/core_service/modules/inbox/domain/NoteDetailKindTest.java new file mode 100644 index 0000000..43c7a24 --- /dev/null +++ b/src/test/java/core_service/modules/inbox/domain/NoteDetailKindTest.java @@ -0,0 +1,44 @@ +package core_service.modules.inbox.domain; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class NoteDetailKindTest extends AbstractUnitTest { + + @Test + void visibleOn_NullSectionsShowsEverything() { + for (NoteDetailKind kind : NoteDetailKind.values()) { + assertThat(kind.visibleOn(null)).as(kind.name()).isTrue(); + } + } + + @Test + void visibleOn_SummaryShowsGamesAndAchievementsOnly() { + Set sections = Set.of("SUMMARY"); + + assertThat(NoteDetailKind.NEW_GAME.visibleOn(sections)).isTrue(); + assertThat(NoteDetailKind.ACHIEVEMENT.visibleOn(sections)).isTrue(); + assertThat(NoteDetailKind.STATUS.visibleOn(sections)).isFalse(); + assertThat(NoteDetailKind.WISHLIST.visibleOn(sections)).isTrue(); + } + + @Test + void visibleOn_EachStatusSectionShowsStatusButNotGames() { + for (String section : new String[]{"STATUS", "PLAYING", "FINISHED"}) { + Set sections = Set.of(section); + assertThat(NoteDetailKind.STATUS.visibleOn(sections)).as(section).isTrue(); + assertThat(NoteDetailKind.NEW_GAME.visibleOn(sections)).as(section).isFalse(); + assertThat(NoteDetailKind.ACHIEVEMENT.visibleOn(sections)).as(section).isFalse(); + } + } + + @Test + void visibleOn_OnlyStatsLeavesTheWishlist() { + assertThat(NoteDetailKind.WISHLIST.visibleOn(Set.of("STATS"))).isTrue(); + assertThat(NoteDetailKind.STATUS.visibleOn(Set.of("STATS"))).isFalse(); + } +} diff --git a/src/test/java/core_service/modules/inbox/repo/InboxRepositoryTest.java b/src/test/java/core_service/modules/inbox/repo/InboxRepositoryTest.java index 519d834..10027dd 100644 --- a/src/test/java/core_service/modules/inbox/repo/InboxRepositoryTest.java +++ b/src/test/java/core_service/modules/inbox/repo/InboxRepositoryTest.java @@ -43,4 +43,32 @@ class InboxRepositoryTest extends AbstractPersistenceTest { assertThat(deleted).isEqualTo(2); assertThat(ids()).containsExactly(3L, 4L); } + + @Test + void deleteBefore_AlsoRemovesTheDetailRows() { + jdbc.update("delete from inbox_note"); + note(1, 91, true); + jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)"); + + repository.deleteBefore(Instant.now().minus(Duration.ofDays(90))); + + assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail", Integer.class)).isZero(); + } + + @Test + void findNewest_DetailsNeedRowsAndAFollowAndAPublicSubject() { + jdbc.update("delete from inbox_note"); + note(1, 1, false); + jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)"); + assertThat(repository.findNewest(1, 10).get(0).details()).isFalse(); + + jdbc.update("insert into follow (user_id, followed_user_id) values (1, 2)"); + assertThat(repository.findNewest(1, 10).get(0).details()).isFalse(); + + jdbc.update("insert into public_user (user_id) values (2)"); + assertThat(repository.findNewest(1, 10).get(0).details()).isTrue(); + + jdbc.update("delete from inbox_note_detail"); + assertThat(repository.findNewest(1, 10).get(0).details()).isFalse(); + } }