Add staff playtime feature, including backend services, API endpoint, and frontend integration.

WIP
This commit is contained in:
2025-11-02 22:25:10 +01:00
parent 2be79c180a
commit 8b0d2f9203
18 changed files with 321 additions and 0 deletions
@@ -51,6 +51,8 @@ public class SecurityConfig {
.requestMatchers("/api/form/**").authenticated()
.requestMatchers("/api/login/getUsername").authenticated()
.requestMatchers("/api/mail/**").authenticated()
.requestMatchers("/api/site/vote").authenticated()
.requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
@@ -2,15 +2,21 @@ package com.alttd.altitudeweb.controllers.site;
import com.alttd.altitudeweb.api.SiteApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.model.StaffPlaytimeDto;
import com.alttd.altitudeweb.model.StaffPlaytimeListDto;
import com.alttd.altitudeweb.model.VoteDataDto;
import com.alttd.altitudeweb.model.VoteStatsDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.site.StaffPtService;
import com.alttd.altitudeweb.services.site.VoteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@@ -23,6 +29,18 @@ public class SiteController implements SiteApi {
private final VoteService voteService;
private final AuthenticatedUuid authenticatedUuid;
private final StaffPtService staffPtService;
@Override
public ResponseEntity<StaffPlaytimeListDto> getStaffPlaytime(OffsetDateTime from, OffsetDateTime to) {
Optional<List<StaffPlaytimeDto>> staffPlaytimeDto = staffPtService.getStaffPlaytime(from.toInstant(), to.toInstant());
if (staffPlaytimeDto.isEmpty()) {
return ResponseEntity.noContent().build();
}
StaffPlaytimeListDto staffPlaytimeListDto = new StaffPlaytimeListDto();
staffPlaytimeListDto.addAll(staffPlaytimeDto.get());
return ResponseEntity.ok(staffPlaytimeListDto);
}
@Override
public ResponseEntity<VoteDataDto> getVoteStats() {
@@ -0,0 +1,55 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.luckperms.Player;
import com.alttd.altitudeweb.database.proxyplaytime.StaffPt;
import com.alttd.altitudeweb.model.StaffPlaytimeDto;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;
@Service
public final class StaffPtToStaffPlaytimeMapper {
private record PlaytimeInfo(long totalPlaytime, long lastPlayed) {}
public List<StaffPlaytimeDto> map(List<StaffPt> sessions, List<Player> staffMembers, long from, long to) {
Map<UUID, PlaytimeInfo> playtimeData = getUuidPlaytimeInfoMap(sessions, from, to);
List<StaffPlaytimeDto> results = new ArrayList<>(playtimeData.size());
for (Map.Entry<UUID, PlaytimeInfo> entry : playtimeData.entrySet()) {
long lastPlayedMillis = entry.getValue().lastPlayed() == Long.MIN_VALUE ? 0L : entry.getValue().lastPlayed();
StaffPlaytimeDto dto = new StaffPlaytimeDto();
dto.setStaffMember(staffMembers.stream()
.filter(player -> player.uuid().equals(entry.getKey()))
.map(Player::username)
.findFirst()
.orElse(entry.getKey().toString())
);
dto.setLastPlayed(OffsetDateTime.from(Instant.ofEpochMilli(lastPlayedMillis)));
dto.setPlaytime((int) TimeUnit.MILLISECONDS.toMinutes(entry.getValue().totalPlaytime()));
results.add(dto);
}
return results;
}
private Map<UUID, PlaytimeInfo> getUuidPlaytimeInfoMap(List<StaffPt> sessions, long from, long to) {
Map<UUID, PlaytimeInfo> playtimeData = new HashMap<>();
for (StaffPt session : sessions) {
long overlapStart = Math.max(session.sessionStart(), from);
long overlapEnd = Math.min(session.sessionEnd(), to);
if (overlapEnd <= overlapStart) {
continue;
}
PlaytimeInfo info = playtimeData.getOrDefault(session.uuid(), new PlaytimeInfo(0L, Long.MIN_VALUE));
long totalPlaytime = info.totalPlaytime() + (overlapEnd - overlapStart);
long lastPlayed = Math.max(info.lastPlayed(), overlapEnd);
playtimeData.put(session.uuid(), new PlaytimeInfo(totalPlaytime, lastPlayed));
}
return playtimeData;
}
}
@@ -0,0 +1,68 @@
package com.alttd.altitudeweb.services.site;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.luckperms.Player;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import com.alttd.altitudeweb.database.proxyplaytime.StaffPlaytimeMapper;
import com.alttd.altitudeweb.database.proxyplaytime.StaffPt;
import com.alttd.altitudeweb.mappers.StaffPtToStaffPlaytimeMapper;
import com.alttd.altitudeweb.model.StaffPlaytimeDto;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class StaffPtService {
private final static String STAFF_GROUPS = "group.admin, group.developer, group.headmod, group.manager, group.moderator, group.owner, group.trainee";
private final StaffPtToStaffPlaytimeMapper staffPtToStaffPlaytimeMapper;
public Optional<List<StaffPlaytimeDto>> getStaffPlaytime(Instant from, Instant to) {
CompletableFuture<List<Player>> staffMembersFuture = new CompletableFuture<>();
CompletableFuture<List<StaffPt>> staffPlaytimeFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LUCK_PERMS)
.runQuery(sqlSession -> {
log.debug("Loading staff members");
try {
List<Player> staffMemberList = sqlSession.getMapper(TeamMemberMapper.class)
.getTeamMembersOfGroupList(STAFF_GROUPS);
staffMembersFuture.complete(staffMemberList);
} catch (Exception e) {
log.error("Failed to load staff members", e);
staffMembersFuture.completeExceptionally(e);
}
});
List<Player> staffMembers = staffMembersFuture.join().stream()
.collect(Collectors.collectingAndThen(
Collectors.toMap(Player::uuid, player -> player, (player1, player2) -> player1),
m -> new ArrayList<>(m.values())));
Connection.getConnection(Databases.PROXY_PLAYTIME)
.runQuery(sqlSession -> {
String staffUUIDs = staffMembers.stream()
.map(Player::uuid)
.map(String::valueOf)
.collect(Collectors.joining(","));
log.debug("Loading staff playtime for group");
try {
List<StaffPt> sessionsDuring = sqlSession.getMapper(StaffPlaytimeMapper.class)
.getSessionsDuring(from.toEpochMilli(), to.toEpochMilli(), staffUUIDs);
staffPlaytimeFuture.complete(sessionsDuring);
} catch (Exception e) {
log.error("Failed to load staff playtime", e);
staffPlaytimeFuture.completeExceptionally(e);
}
});
List<StaffPt> join = staffPlaytimeFuture.join();
return Optional.of(staffPtToStaffPlaytimeMapper.map(join, staffMembers, from.toEpochMilli(), to.toEpochMilli()));
}
}