From 8b0d2f920346512e7f56a14864b867d10f9e78bb Mon Sep 17 00:00:00 2001 From: akastijn Date: Sun, 2 Nov 2025 22:25:10 +0100 Subject: [PATCH] Add staff playtime feature, including backend services, API endpoint, and frontend integration. WIP --- .../altitudeweb/config/SecurityConfig.java | 2 + .../controllers/site/SiteController.java | 18 +++++ .../mappers/StaffPtToStaffPlaytimeMapper.java | 55 +++++++++++++++ .../services/site/StaffPtService.java | 68 +++++++++++++++++++ .../alttd/altitudeweb/database/Databases.java | 1 + .../database/luckperms/TeamMemberMapper.java | 15 ++++ .../proxyplaytime/StaffPlaytimeMapper.java | 33 +++++++++ .../database/proxyplaytime/StaffPt.java | 6 ++ .../alttd/altitudeweb/setup/Connection.java | 1 + .../setup/InitializeProxyPlaytime.java | 18 +++++ frontend/src/app/app.routes.ts | 8 +++ .../head-mod/staff-pt/staff-pt.component.html | 1 + .../head-mod/staff-pt/staff-pt.component.scss | 0 .../head-mod/staff-pt/staff-pt.component.ts | 34 ++++++++++ .../pages/header/header/header.component.html | 1 + open_api/build.gradle.kts | 5 ++ open_api/src/main/resources/api.yml | 2 + .../main/resources/schemas/site/staff_pt.yml | 53 +++++++++++++++ 18 files changed, 321 insertions(+) create mode 100644 backend/src/main/java/com/alttd/altitudeweb/mappers/StaffPtToStaffPlaytimeMapper.java create mode 100644 backend/src/main/java/com/alttd/altitudeweb/services/site/StaffPtService.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/database/proxyplaytime/StaffPlaytimeMapper.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/database/proxyplaytime/StaffPt.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/setup/InitializeProxyPlaytime.java create mode 100644 frontend/src/app/pages/head-mod/staff-pt/staff-pt.component.html create mode 100644 frontend/src/app/pages/head-mod/staff-pt/staff-pt.component.scss create mode 100644 frontend/src/app/pages/head-mod/staff-pt/staff-pt.component.ts create mode 100644 open_api/src/main/resources/schemas/site/staff_pt.yml diff --git a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java index 2c185ee..c47a86c 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java +++ b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java @@ -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()) diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/site/SiteController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/site/SiteController.java index d9242b1..a2c6573 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/site/SiteController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/site/SiteController.java @@ -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 getStaffPlaytime(OffsetDateTime from, OffsetDateTime to) { + Optional> 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 getVoteStats() { diff --git a/backend/src/main/java/com/alttd/altitudeweb/mappers/StaffPtToStaffPlaytimeMapper.java b/backend/src/main/java/com/alttd/altitudeweb/mappers/StaffPtToStaffPlaytimeMapper.java new file mode 100644 index 0000000..edbff52 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/mappers/StaffPtToStaffPlaytimeMapper.java @@ -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 map(List sessions, List staffMembers, long from, long to) { + Map playtimeData = getUuidPlaytimeInfoMap(sessions, from, to); + + List results = new ArrayList<>(playtimeData.size()); + for (Map.Entry 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 getUuidPlaytimeInfoMap(List sessions, long from, long to) { + Map 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; + } +} + + diff --git a/backend/src/main/java/com/alttd/altitudeweb/services/site/StaffPtService.java b/backend/src/main/java/com/alttd/altitudeweb/services/site/StaffPtService.java new file mode 100644 index 0000000..0c33979 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/services/site/StaffPtService.java @@ -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> getStaffPlaytime(Instant from, Instant to) { + CompletableFuture> staffMembersFuture = new CompletableFuture<>(); + CompletableFuture> staffPlaytimeFuture = new CompletableFuture<>(); + Connection.getConnection(Databases.LUCK_PERMS) + .runQuery(sqlSession -> { + log.debug("Loading staff members"); + try { + List 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 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 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 join = staffPlaytimeFuture.join(); + + return Optional.of(staffPtToStaffPlaytimeMapper.map(join, staffMembers, from.toEpochMilli(), to.toEpochMilli())); + } +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/Databases.java b/database/src/main/java/com/alttd/altitudeweb/database/Databases.java index df90fad..15e1531 100644 --- a/database/src/main/java/com/alttd/altitudeweb/database/Databases.java +++ b/database/src/main/java/com/alttd/altitudeweb/database/Databases.java @@ -8,6 +8,7 @@ public enum Databases { LUCK_PERMS("luckperms"), LITE_BANS("litebans"), DISCORD("discordLink"), + PROXY_PLAYTIME("proxyplaytime"), VOTING_PLUGIN("votingplugin"); private final String internalName; diff --git a/database/src/main/java/com/alttd/altitudeweb/database/luckperms/TeamMemberMapper.java b/database/src/main/java/com/alttd/altitudeweb/database/luckperms/TeamMemberMapper.java index 75503bd..8df0e1e 100644 --- a/database/src/main/java/com/alttd/altitudeweb/database/luckperms/TeamMemberMapper.java +++ b/database/src/main/java/com/alttd/altitudeweb/database/luckperms/TeamMemberMapper.java @@ -19,4 +19,19 @@ public interface TeamMemberMapper { AND world = 'global' """) List getTeamMembers(@Param("groupPermission") String groupPermission); + + @ConstructorArgs({ + @Arg(column = "username", javaType = String.class), + @Arg(column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class) + }) + @Select(""" + SELECT players.username, players.uuid + FROM luckperms_user_permissions AS permissions + INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid + WHERE permission IN (${groupPermissions}) + AND server = 'global' + AND world = 'global' + """) + List getTeamMembersOfGroupList(@Param("groupPermissions") String groupPermissions); + } diff --git a/database/src/main/java/com/alttd/altitudeweb/database/proxyplaytime/StaffPlaytimeMapper.java b/database/src/main/java/com/alttd/altitudeweb/database/proxyplaytime/StaffPlaytimeMapper.java new file mode 100644 index 0000000..ba5233f --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/proxyplaytime/StaffPlaytimeMapper.java @@ -0,0 +1,33 @@ +package com.alttd.altitudeweb.database.proxyplaytime; + +import com.alttd.altitudeweb.type_handler.UUIDTypeHandler; +import org.apache.ibatis.annotations.Arg; +import org.apache.ibatis.annotations.ConstructorArgs; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.UUID; + +public interface StaffPlaytimeMapper { + @ConstructorArgs({ + @Arg(column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class), + @Arg(column = "serverName", javaType = String.class), + @Arg(column = "sessionStart", javaType = long.class), + @Arg(column = "sessionEnd", javaType = long.class) + }) + @Select(""" + SELECT uuid, + server_name AS serverName, + session_start AS sessionStart, + session_end AS sessionEnd + FROM sessions + WHERE session_end > #{from} + AND session_start < #{to} + AND uuid IN (${staffUUIDs}) + ORDER BY uuid, session_start + """) + List getSessionsDuring(@Param("from") long from, + @Param("to") long to, + @Param("staffUUIDs") String staffUUIDs); +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/proxyplaytime/StaffPt.java b/database/src/main/java/com/alttd/altitudeweb/database/proxyplaytime/StaffPt.java new file mode 100644 index 0000000..c141ce5 --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/proxyplaytime/StaffPt.java @@ -0,0 +1,6 @@ +package com.alttd.altitudeweb.database.proxyplaytime; + +import java.util.UUID; + +public record StaffPt(UUID uuid, String serverName, long sessionStart, long sessionEnd) { +} diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java b/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java index 3c286b8..71489cb 100644 --- a/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java +++ b/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java @@ -37,6 +37,7 @@ public class Connection { InitializeWebDb.init(); InitializeLiteBans.init(); InitializeLuckPerms.init(); + InitializeProxyPlaytime.init(); InitializeDiscord.init(); InitializeVotingPlugin.init(); } diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeProxyPlaytime.java b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeProxyPlaytime.java new file mode 100644 index 0000000..d481228 --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeProxyPlaytime.java @@ -0,0 +1,18 @@ +package com.alttd.altitudeweb.setup; + +import com.alttd.altitudeweb.database.Databases; +import com.alttd.altitudeweb.database.proxyplaytime.StaffPlaytimeMapper; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class InitializeProxyPlaytime { + + protected static void init() { + log.info("Initializing ProxyPlaytime"); + Connection.getConnection(Databases.PROXY_PLAYTIME, (configuration) -> { + configuration.addMapper(StaffPlaytimeMapper.class); + }).join(); + log.debug("Initialized ProxyPlaytime"); + } + +} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 8fd59f0..6d6caa1 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -14,6 +14,14 @@ export const routes: Routes = [ requiredAuthorizations: ['SCOPE_head_mod'] } }, + { + path: 'staff-pt', + loadComponent: () => import('./pages/particles/particles.component').then(m => m.ParticlesComponent), + canActivate: [AuthGuard], + data: { + requiredAuthorizations: ['SCOPE_head_mod'] + } + }, { path: 'map', loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent) diff --git a/frontend/src/app/pages/head-mod/staff-pt/staff-pt.component.html b/frontend/src/app/pages/head-mod/staff-pt/staff-pt.component.html new file mode 100644 index 0000000..882c9ab --- /dev/null +++ b/frontend/src/app/pages/head-mod/staff-pt/staff-pt.component.html @@ -0,0 +1 @@ +

staff-pt works!

diff --git a/frontend/src/app/pages/head-mod/staff-pt/staff-pt.component.scss b/frontend/src/app/pages/head-mod/staff-pt/staff-pt.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/head-mod/staff-pt/staff-pt.component.ts b/frontend/src/app/pages/head-mod/staff-pt/staff-pt.component.ts new file mode 100644 index 0000000..7174e15 --- /dev/null +++ b/frontend/src/app/pages/head-mod/staff-pt/staff-pt.component.ts @@ -0,0 +1,34 @@ +import {Component, inject, OnInit, signal} from '@angular/core'; +import {SiteService, StaffPlaytime} from '@api'; + +@Component({ + selector: 'app-staff-pt', + imports: [], + templateUrl: './staff-pt.component.html', + styleUrl: './staff-pt.component.scss' +}) +export class StaffPtComponent implements OnInit { + siteService = inject(SiteService); + staffPt = signal([]) + + ngOnInit(): void { + const firstDayOfWeek = new Date(); + firstDayOfWeek.setDate(firstDayOfWeek.getDate() - firstDayOfWeek.getDay()); + firstDayOfWeek.setHours(0, 0, 0, 0); + const lastDayOfWeek = new Date(firstDayOfWeek); + lastDayOfWeek.setDate(firstDayOfWeek.getDate() + 6); + lastDayOfWeek.setHours(23, 59, 59, 999); + + this.loadStaffData(firstDayOfWeek, lastDayOfWeek); + } + + loadStaffData(from: Date, to: Date) { + this.siteService.getStaffPlaytime(from.toISOString(), to.toISOString()) + .subscribe({ + next: data => { + this.staffPt.set(data); + }, + error: err => console.error('Error getting staff playtime:', err) + }); + } +} diff --git a/frontend/src/app/pages/header/header/header.component.html b/frontend/src/app/pages/header/header/header.component.html index 6d697cc..79f68ea 100644 --- a/frontend/src/app/pages/header/header/header.component.html +++ b/frontend/src/app/pages/header/header/header.component.html @@ -144,6 +144,7 @@ diff --git a/open_api/build.gradle.kts b/open_api/build.gradle.kts index 335bf55..24194c1 100644 --- a/open_api/build.gradle.kts +++ b/open_api/build.gradle.kts @@ -63,6 +63,11 @@ tasks.register< GenerateTask>("generateJavaApi") { typeMappings.put("OffsetDateTime", "Instant") importMappings.put("java.time.OffsetDateTime", "java.time.Instant") modelNameSuffix.set("Dto") + + // Make generator use Java 8 time types and map date-time -> Instant + additionalProperties.set(mapOf("dateLibrary" to "java8")) + typeMappings.set(mapOf("date-time" to "Instant")) + importMappings.set(mapOf("Instant" to "java.time.Instant")) generateModelTests.set(false) generateModelDocumentation.set(false) generateApiTests.set(false) diff --git a/open_api/src/main/resources/api.yml b/open_api/src/main/resources/api.yml index 0888a6b..9a5ccb9 100644 --- a/open_api/src/main/resources/api.yml +++ b/open_api/src/main/resources/api.yml @@ -93,3 +93,5 @@ paths: $ref: './schemas/forms/mail/mail.yml#/GetEmails' /api/site/vote: $ref: './schemas/site/vote.yml#/VoteStats' + /api/site/get-staff-playtime/{from}/{to}: + $ref: './schemas/site/staff_pt.yml#/GetStaffPlaytime' diff --git a/open_api/src/main/resources/schemas/site/staff_pt.yml b/open_api/src/main/resources/schemas/site/staff_pt.yml new file mode 100644 index 0000000..95e00de --- /dev/null +++ b/open_api/src/main/resources/schemas/site/staff_pt.yml @@ -0,0 +1,53 @@ +GetStaffPlaytime: + get: + tags: + - site + summary: Get staff playtime for a specified duration + description: Get staff playtime for all staff members for a specified duration + operationId: getStaffPlaytime + parameters: + - $ref: '#/components/parameters/From' + - $ref: '#/components/parameters/To' + responses: + '200': + description: Staff playtime retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/StaffPlaytimeList' +components: + parameters: + From: + name: from + in: path + required: true + schema: + type: string + format: date-time + example: 2025-01-01T00:00:00.000Z + To: + name: to + in: path + required: true + schema: + type: string + format: date-time + example: 2025-01-07T23:59:59.999Z + schemas: + StaffPlaytimeList: + type: array + items: + $ref: '#/components/schemas/StaffPlaytime' + StaffPlaytime: + type: object + properties: + staff_member: + type: string + description: The name of the staff member + playtime: + type: integer + description: Total playtime for the specified duration in minutes + last_played: + type: string + format: date-time + description: Last played timestamp