diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/auth/UserRoleEnum.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/auth/UserRoleEnum.java index 6ceea4e9..8d4b1ac2 100644 --- a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/auth/UserRoleEnum.java +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/auth/UserRoleEnum.java @@ -2,5 +2,7 @@ public enum UserRoleEnum { ROLE_ADMIN, - ROLE_MANAGER + ROLE_MANAGER, + ROLE_STEWARD, + ROLE_DRIVER } diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/menu/MenuEntityCategory.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/menu/MenuEntityCategory.java index 7d9372a0..1feffb7c 100644 --- a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/menu/MenuEntityCategory.java +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/menu/MenuEntityCategory.java @@ -8,6 +8,7 @@ public enum MenuEntityCategory { LIVE_TIMING("Live Timing"), ENTRYLIST("Entrylist"), BALANCE_OF_PERFORMANCE("Balance of Performance"), + STEWARDING("Stewarding"), MAP("Map"); private final String name; diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Appeal.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Appeal.java new file mode 100644 index 00000000..81de0de4 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Appeal.java @@ -0,0 +1,26 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Appeal { + private String id; + private String decisionId; + private Integer filedByUserId; + private String filedByEntryId; + private String reason; + private AppealStatus status; + private String response; + private Integer respondedByUserId; + private Instant filedAt; + private Instant respondedAt; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/AppealStatus.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/AppealStatus.java new file mode 100644 index 00000000..aeba339f --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/AppealStatus.java @@ -0,0 +1,14 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AppealStatus { + PENDING("Pending"), + ACCEPTED("Accepted"), + REJECTED("Rejected"); + + private final String description; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Incident.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Incident.java new file mode 100644 index 00000000..9ce3d7c5 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Incident.java @@ -0,0 +1,30 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Incident { + private String id; + private String sessionId; + private String title; + private String description; + private Integer lap; + private String timestampInSession; + private Double mapMarkerX; + private Double mapMarkerY; + private String videoUrl; + private String involvedCarsText; + private IncidentStatus status; + private Integer reportedByUserId; + private Instant createdAt; + private Instant updatedAt; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/IncidentStatus.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/IncidentStatus.java new file mode 100644 index 00000000..4cc4d54d --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/IncidentStatus.java @@ -0,0 +1,17 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum IncidentStatus { + REPORTED("Reported"), + UNDER_REVIEW("Under Review"), + DECISION_MADE("Decision Made"), + APPEALED("Appealed"), + APPEAL_REVIEWED("Appeal Reviewed"), + CLOSED("Closed"); + + private final String description; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/PenaltyCatalog.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/PenaltyCatalog.java new file mode 100644 index 00000000..27b539a1 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/PenaltyCatalog.java @@ -0,0 +1,21 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class PenaltyCatalog { + private String id; + private String name; + private String description; + private Instant createdAt; + private Instant updatedAt; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/PenaltyDefinition.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/PenaltyDefinition.java new file mode 100644 index 00000000..eaae4d1f --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/PenaltyDefinition.java @@ -0,0 +1,24 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class PenaltyDefinition { + private String id; + private String catalogId; + private String code; + private String name; + private String description; + private String category; + private PenaltySessionType sessionType; + private String defaultPenalty; + private Integer severity; + private Integer sortOrder; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/PenaltySessionType.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/PenaltySessionType.java new file mode 100644 index 00000000..ee2ae3b9 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/PenaltySessionType.java @@ -0,0 +1,15 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PenaltySessionType { + PRACTICE("Practice"), + QUALIFYING("Qualifying"), + RACE("Race"), + ALL("All"); + + private final String description; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/ReasoningTemplate.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/ReasoningTemplate.java new file mode 100644 index 00000000..96b69a33 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/ReasoningTemplate.java @@ -0,0 +1,19 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ReasoningTemplate { + private String id; + private String name; + private String category; + private String templateText; + private Integer sortOrder; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Round.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Round.java new file mode 100644 index 00000000..18216eb4 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Round.java @@ -0,0 +1,29 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Round { + private String id; + private String seriesId; + private String trackId; + private String title; + private LocalDate startDate; + private LocalDate endDate; + private Instant createdAt; + private Instant updatedAt; + + @EqualsAndHashCode.Exclude + private StewardingTrack track; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/RoundSession.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/RoundSession.java new file mode 100644 index 00000000..17264cb3 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/RoundSession.java @@ -0,0 +1,24 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class RoundSession { + private String id; + private String roundId; + private StewSessionType sessionType; + private String title; + private Instant startTime; + private Instant endTime; + private Integer sortOrder; + private Instant createdAt; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Series.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Series.java new file mode 100644 index 00000000..7acb2aeb --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/Series.java @@ -0,0 +1,31 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Series { + private String id; + private String title; + private String description; + private String discordWebhookUrl; + private Boolean videoUrlEnabled; + private String penaltyCatalogId; + private LocalDate startDate; + private LocalDate endDate; + private Instant createdAt; + private Instant updatedAt; + + @EqualsAndHashCode.Exclude + private PenaltyCatalog penaltyCatalog; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewSessionType.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewSessionType.java new file mode 100644 index 00000000..2993eae0 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewSessionType.java @@ -0,0 +1,14 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum StewSessionType { + PRACTICE("Practice"), + QUALIFYING("Qualifying"), + RACE("Race"); + + private final String description; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardDecision.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardDecision.java new file mode 100644 index 00000000..72bd988a --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardDecision.java @@ -0,0 +1,30 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class StewardDecision { + private String id; + private String incidentId; + private String sessionId; + private Integer decidedByUserId; + private String penaltyDefinitionId; + private String customPenalty; + private String reasoning; + private String reasoningTemplateId; + private Boolean isNoAction; + private String penalizedEntryId; + private String penalizedCarText; + private Instant decidedAt; + private String supersededById; + private Boolean isActive; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingEntrylist.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingEntrylist.java new file mode 100644 index 00000000..d1d1ae9f --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingEntrylist.java @@ -0,0 +1,20 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class StewardingEntrylist { + private String id; + private String roundId; + private Instant uploadedAt; + private String rawJson; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingEntrylistDriver.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingEntrylistDriver.java new file mode 100644 index 00000000..3ff1508a --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingEntrylistDriver.java @@ -0,0 +1,21 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class StewardingEntrylistDriver { + private String id; + private String entryId; + private String firstName; + private String lastName; + private String shortName; + private String steamId; + private Integer category; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingEntrylistEntry.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingEntrylistEntry.java new file mode 100644 index 00000000..6a2bf9ac --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingEntrylistEntry.java @@ -0,0 +1,20 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class StewardingEntrylistEntry { + private String id; + private String entrylistId; + private Integer raceNumber; + private Integer carModelId; + private String teamName; + private String displayName; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingTrack.java b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingTrack.java new file mode 100644 index 00000000..3ed18761 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/entities/stewarding/StewardingTrack.java @@ -0,0 +1,23 @@ +package de.sustineo.simdesk.entities.stewarding; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class StewardingTrack { + private String id; + private String name; + private String country; + private String mapImageUrl; + private String mapMetadata; + private Instant createdAt; + private Instant updatedAt; +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/PenaltyCatalogMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/PenaltyCatalogMapper.java new file mode 100644 index 00000000..7213688d --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/PenaltyCatalogMapper.java @@ -0,0 +1,41 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.PenaltyCatalog; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface PenaltyCatalogMapper { + @Results(id = "penaltyCatalogResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "name", column = "name"), + @Result(property = "description", column = "description"), + @Result(property = "createdAt", column = "created_at"), + @Result(property = "updatedAt", column = "updated_at"), + }) + @Select("SELECT * FROM stewarding_penalty_catalog ORDER BY name") + List findAll(); + + @ResultMap("penaltyCatalogResultMap") + @Select("SELECT * FROM stewarding_penalty_catalog WHERE id = #{id}") + PenaltyCatalog findById(String id); + + @Insert(""" + INSERT INTO stewarding_penalty_catalog (id, name, description, created_at, updated_at) + VALUES (#{id}, #{name}, #{description}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """) + void insert(PenaltyCatalog catalog); + + @Update(""" + UPDATE stewarding_penalty_catalog + SET name = #{name}, description = #{description}, updated_at = CURRENT_TIMESTAMP + WHERE id = #{id} + """) + void update(PenaltyCatalog catalog); + + @Delete("DELETE FROM stewarding_penalty_catalog WHERE id = #{id}") + void delete(String id); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/PenaltyDefinitionMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/PenaltyDefinitionMapper.java new file mode 100644 index 00000000..bcbebdd4 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/PenaltyDefinitionMapper.java @@ -0,0 +1,59 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.PenaltyDefinition; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface PenaltyDefinitionMapper { + @Results(id = "penaltyDefinitionResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "catalogId", column = "catalog_id"), + @Result(property = "code", column = "code"), + @Result(property = "name", column = "name"), + @Result(property = "description", column = "description"), + @Result(property = "category", column = "category"), + @Result(property = "sessionType", column = "session_type"), + @Result(property = "defaultPenalty", column = "default_penalty"), + @Result(property = "severity", column = "severity"), + @Result(property = "sortOrder", column = "sort_order"), + }) + @Select("SELECT * FROM stewarding_penalty_definition WHERE catalog_id = #{catalogId} ORDER BY sort_order, name") + List findByCatalogId(String catalogId); + + @ResultMap("penaltyDefinitionResultMap") + @Select("SELECT * FROM stewarding_penalty_definition WHERE id = #{id}") + PenaltyDefinition findById(String id); + + @ResultMap("penaltyDefinitionResultMap") + @Select(""" + SELECT * FROM stewarding_penalty_definition + WHERE catalog_id = #{catalogId} AND (session_type = #{sessionType} OR session_type = 'ALL') + ORDER BY sort_order, name + """) + List findByCatalogIdAndSessionType(String catalogId, String sessionType); + + @ResultMap("penaltyDefinitionResultMap") + @Select("SELECT * FROM stewarding_penalty_definition WHERE catalog_id = #{catalogId} ORDER BY category, sort_order") + List findByCatalogIdGroupedByCategory(String catalogId); + + @Insert(""" + INSERT INTO stewarding_penalty_definition (id, catalog_id, code, name, description, category, session_type, default_penalty, severity, sort_order) + VALUES (#{id}, #{catalogId}, #{code}, #{name}, #{description}, #{category}, #{sessionType}, #{defaultPenalty}, #{severity}, #{sortOrder}) + """) + void insert(PenaltyDefinition definition); + + @Update(""" + UPDATE stewarding_penalty_definition + SET catalog_id = #{catalogId}, code = #{code}, name = #{name}, description = #{description}, category = #{category}, + session_type = #{sessionType}, default_penalty = #{defaultPenalty}, severity = #{severity}, sort_order = #{sortOrder} + WHERE id = #{id} + """) + void update(PenaltyDefinition definition); + + @Delete("DELETE FROM stewarding_penalty_definition WHERE id = #{id}") + void delete(String id); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/ReasoningTemplateMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/ReasoningTemplateMapper.java new file mode 100644 index 00000000..0bec7eb5 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/ReasoningTemplateMapper.java @@ -0,0 +1,45 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.ReasoningTemplate; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface ReasoningTemplateMapper { + @Results(id = "reasoningTemplateResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "name", column = "name"), + @Result(property = "category", column = "category"), + @Result(property = "templateText", column = "template_text"), + @Result(property = "sortOrder", column = "sort_order"), + }) + @Select("SELECT * FROM stewarding_reasoning_template ORDER BY category, sort_order") + List findAll(); + + @ResultMap("reasoningTemplateResultMap") + @Select("SELECT * FROM stewarding_reasoning_template WHERE id = #{id}") + ReasoningTemplate findById(String id); + + @ResultMap("reasoningTemplateResultMap") + @Select("SELECT * FROM stewarding_reasoning_template WHERE category = #{category} ORDER BY sort_order") + List findByCategory(String category); + + @Insert(""" + INSERT INTO stewarding_reasoning_template (id, name, category, template_text, sort_order) + VALUES (#{id}, #{name}, #{category}, #{templateText}, #{sortOrder}) + """) + void insert(ReasoningTemplate template); + + @Update(""" + UPDATE stewarding_reasoning_template + SET name = #{name}, category = #{category}, template_text = #{templateText}, sort_order = #{sortOrder} + WHERE id = #{id} + """) + void update(ReasoningTemplate template); + + @Delete("DELETE FROM stewarding_reasoning_template WHERE id = #{id}") + void delete(String id); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/RoundMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/RoundMapper.java new file mode 100644 index 00000000..2740ab6c --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/RoundMapper.java @@ -0,0 +1,53 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.Round; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface RoundMapper { + @Results(id = "roundResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "seriesId", column = "series_id"), + @Result(property = "trackId", column = "track_id"), + @Result(property = "title", column = "title"), + @Result(property = "startDate", column = "start_date"), + @Result(property = "endDate", column = "end_date"), + @Result(property = "createdAt", column = "created_at"), + @Result(property = "updatedAt", column = "updated_at"), + }) + @Select("SELECT * FROM stewarding_round ORDER BY start_date DESC") + List findAll(); + + @ResultMap("roundResultMap") + @Select("SELECT * FROM stewarding_round WHERE id = #{id}") + Round findById(String id); + + @ResultMap("roundResultMap") + @Select("SELECT * FROM stewarding_round WHERE series_id = #{seriesId} ORDER BY start_date") + List findBySeriesId(String seriesId); + + @ResultMap("roundResultMap") + @Select("SELECT * FROM stewarding_round WHERE track_id = #{trackId}") + List findByTrackId(String trackId); + + @Insert(""" + INSERT INTO stewarding_round (id, series_id, track_id, title, start_date, end_date, created_at, updated_at) + VALUES (#{id}, #{seriesId}, #{trackId}, #{title}, #{startDate}, #{endDate}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """) + void insert(Round round); + + @Update(""" + UPDATE stewarding_round + SET series_id = #{seriesId}, track_id = #{trackId}, title = #{title}, + start_date = #{startDate}, end_date = #{endDate}, updated_at = CURRENT_TIMESTAMP + WHERE id = #{id} + """) + void update(Round round); + + @Delete("DELETE FROM stewarding_round WHERE id = #{id}") + void delete(String id); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/RoundSessionMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/RoundSessionMapper.java new file mode 100644 index 00000000..88e38049 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/RoundSessionMapper.java @@ -0,0 +1,45 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.RoundSession; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface RoundSessionMapper { + @Results(id = "roundSessionResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "roundId", column = "round_id"), + @Result(property = "sessionType", column = "session_type"), + @Result(property = "title", column = "title"), + @Result(property = "startTime", column = "start_time"), + @Result(property = "endTime", column = "end_time"), + @Result(property = "sortOrder", column = "sort_order"), + @Result(property = "createdAt", column = "created_at"), + }) + @Select("SELECT * FROM stewarding_session WHERE round_id = #{roundId} ORDER BY sort_order") + List findByRoundId(String roundId); + + @ResultMap("roundSessionResultMap") + @Select("SELECT * FROM stewarding_session WHERE id = #{id}") + RoundSession findById(String id); + + @Insert(""" + INSERT INTO stewarding_session (id, round_id, session_type, title, start_time, end_time, sort_order, created_at) + VALUES (#{id}, #{roundId}, #{sessionType}, #{title}, #{startTime}, #{endTime}, #{sortOrder}, CURRENT_TIMESTAMP) + """) + void insert(RoundSession session); + + @Update(""" + UPDATE stewarding_session + SET round_id = #{roundId}, session_type = #{sessionType}, title = #{title}, + start_time = #{startTime}, end_time = #{endTime}, sort_order = #{sortOrder} + WHERE id = #{id} + """) + void update(RoundSession session); + + @Delete("DELETE FROM stewarding_session WHERE id = #{id}") + void delete(String id); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/SeriesMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/SeriesMapper.java new file mode 100644 index 00000000..db0ce24a --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/SeriesMapper.java @@ -0,0 +1,48 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.Series; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface SeriesMapper { + @Results(id = "seriesResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "title", column = "title"), + @Result(property = "description", column = "description"), + @Result(property = "discordWebhookUrl", column = "discord_webhook_url"), + @Result(property = "videoUrlEnabled", column = "video_url_enabled"), + @Result(property = "penaltyCatalogId", column = "penalty_catalog_id"), + @Result(property = "startDate", column = "start_date"), + @Result(property = "endDate", column = "end_date"), + @Result(property = "createdAt", column = "created_at"), + @Result(property = "updatedAt", column = "updated_at"), + }) + @Select("SELECT * FROM stewarding_series ORDER BY start_date DESC") + List findAll(); + + @ResultMap("seriesResultMap") + @Select("SELECT * FROM stewarding_series WHERE id = #{id}") + Series findById(String id); + + @Insert(""" + INSERT INTO stewarding_series (id, title, description, discord_webhook_url, video_url_enabled, penalty_catalog_id, start_date, end_date, created_at, updated_at) + VALUES (#{id}, #{title}, #{description}, #{discordWebhookUrl}, #{videoUrlEnabled}, #{penaltyCatalogId}, #{startDate}, #{endDate}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """) + void insert(Series series); + + @Update(""" + UPDATE stewarding_series + SET title = #{title}, description = #{description}, discord_webhook_url = #{discordWebhookUrl}, + video_url_enabled = #{videoUrlEnabled}, penalty_catalog_id = #{penaltyCatalogId}, + start_date = #{startDate}, end_date = #{endDate}, updated_at = CURRENT_TIMESTAMP + WHERE id = #{id} + """) + void update(Series series); + + @Delete("DELETE FROM stewarding_series WHERE id = #{id}") + void delete(String id); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardDecisionMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardDecisionMapper.java new file mode 100644 index 00000000..98e32883 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardDecisionMapper.java @@ -0,0 +1,60 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.StewardDecision; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface StewardDecisionMapper { + @Results(id = "stewardDecisionResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "incidentId", column = "incident_id"), + @Result(property = "sessionId", column = "session_id"), + @Result(property = "decidedByUserId", column = "decided_by_user_id"), + @Result(property = "penaltyDefinitionId", column = "penalty_definition_id"), + @Result(property = "customPenalty", column = "custom_penalty"), + @Result(property = "reasoning", column = "reasoning"), + @Result(property = "reasoningTemplateId", column = "reasoning_template_id"), + @Result(property = "isNoAction", column = "is_no_action"), + @Result(property = "penalizedEntryId", column = "penalized_entry_id"), + @Result(property = "penalizedCarText", column = "penalized_car_text"), + @Result(property = "decidedAt", column = "decided_at"), + @Result(property = "supersededById", column = "superseded_by_id"), + @Result(property = "isActive", column = "is_active"), + }) + @Select("SELECT * FROM stewarding_decision WHERE id = #{id}") + StewardDecision findById(String id); + + @ResultMap("stewardDecisionResultMap") + @Select("SELECT * FROM stewarding_decision WHERE incident_id = #{incidentId} AND is_active = true") + List findActiveByIncidentId(String incidentId); + + @ResultMap("stewardDecisionResultMap") + @Select("SELECT * FROM stewarding_decision WHERE incident_id = #{incidentId} ORDER BY decided_at DESC") + List findByIncidentId(String incidentId); + + @ResultMap("stewardDecisionResultMap") + @Select("SELECT * FROM stewarding_decision WHERE session_id = #{sessionId} AND is_active = true ORDER BY decided_at DESC") + List findBySessionId(String sessionId); + + @ResultMap("stewardDecisionResultMap") + @Select("SELECT * FROM stewarding_decision WHERE incident_id IS NULL AND session_id = #{sessionId} AND is_active = true ORDER BY decided_at DESC") + List findManualBySessionId(String sessionId); + + @Insert(""" + INSERT INTO stewarding_decision (id, incident_id, session_id, decided_by_user_id, penalty_definition_id, custom_penalty, + reasoning, reasoning_template_id, is_no_action, penalized_entry_id, penalized_car_text, decided_at, superseded_by_id, is_active) + VALUES (#{id}, #{incidentId}, #{sessionId}, #{decidedByUserId}, #{penaltyDefinitionId}, #{customPenalty}, + #{reasoning}, #{reasoningTemplateId}, #{isNoAction}, #{penalizedEntryId}, #{penalizedCarText}, CURRENT_TIMESTAMP, #{supersededById}, #{isActive}) + """) + void insert(StewardDecision decision); + + @Update("UPDATE stewarding_decision SET is_active = false WHERE id = #{id}") + void deactivate(String id); + + @Update("UPDATE stewarding_decision SET superseded_by_id = #{supersededById} WHERE id = #{id}") + void setSupersededBy(String id, String supersededById); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingAppealMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingAppealMapper.java new file mode 100644 index 00000000..2cde2ba6 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingAppealMapper.java @@ -0,0 +1,43 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.Appeal; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface StewardingAppealMapper { + @Results(id = "stewardingAppealResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "decisionId", column = "decision_id"), + @Result(property = "filedByUserId", column = "filed_by_user_id"), + @Result(property = "filedByEntryId", column = "filed_by_entry_id"), + @Result(property = "reason", column = "reason"), + @Result(property = "status", column = "status"), + @Result(property = "response", column = "response"), + @Result(property = "respondedByUserId", column = "responded_by_user_id"), + @Result(property = "filedAt", column = "filed_at"), + @Result(property = "respondedAt", column = "responded_at"), + }) + @Select("SELECT * FROM stewarding_appeal WHERE decision_id = #{decisionId} ORDER BY filed_at DESC") + List findByDecisionId(String decisionId); + + @ResultMap("stewardingAppealResultMap") + @Select("SELECT * FROM stewarding_appeal WHERE id = #{id}") + Appeal findById(String id); + + @Insert(""" + INSERT INTO stewarding_appeal (id, decision_id, filed_by_user_id, filed_by_entry_id, reason, status, filed_at) + VALUES (#{id}, #{decisionId}, #{filedByUserId}, #{filedByEntryId}, #{reason}, #{status}, CURRENT_TIMESTAMP) + """) + void insert(Appeal appeal); + + @Update(""" + UPDATE stewarding_appeal + SET status = #{status}, response = #{response}, responded_by_user_id = #{respondedByUserId}, responded_at = CURRENT_TIMESTAMP + WHERE id = #{id} + """) + void updateResponse(String id, String status, String response, Integer respondedByUserId); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingEntrylistDriverMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingEntrylistDriverMapper.java new file mode 100644 index 00000000..ce5ba519 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingEntrylistDriverMapper.java @@ -0,0 +1,36 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.StewardingEntrylistDriver; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface StewardingEntrylistDriverMapper { + @Results(id = "stewardingEntrylistDriverResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "entryId", column = "entry_id"), + @Result(property = "firstName", column = "first_name"), + @Result(property = "lastName", column = "last_name"), + @Result(property = "shortName", column = "short_name"), + @Result(property = "steamId", column = "steam_id"), + @Result(property = "category", column = "category"), + }) + @Select("SELECT * FROM stewarding_entrylist_driver WHERE entry_id = #{entryId}") + List findByEntryId(String entryId); + + @ResultMap("stewardingEntrylistDriverResultMap") + @Select("SELECT * FROM stewarding_entrylist_driver WHERE id = #{id}") + StewardingEntrylistDriver findById(String id); + + @Insert(""" + INSERT INTO stewarding_entrylist_driver (id, entry_id, first_name, last_name, short_name, steam_id, category) + VALUES (#{id}, #{entryId}, #{firstName}, #{lastName}, #{shortName}, #{steamId}, #{category}) + """) + void insert(StewardingEntrylistDriver driver); + + @Delete("DELETE FROM stewarding_entrylist_driver WHERE entry_id = #{entryId}") + void deleteByEntryId(String entryId); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingEntrylistEntryMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingEntrylistEntryMapper.java new file mode 100644 index 00000000..c833c22c --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingEntrylistEntryMapper.java @@ -0,0 +1,35 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.StewardingEntrylistEntry; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface StewardingEntrylistEntryMapper { + @Results(id = "stewardingEntrylistEntryResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "entrylistId", column = "entrylist_id"), + @Result(property = "raceNumber", column = "race_number"), + @Result(property = "carModelId", column = "car_model_id"), + @Result(property = "teamName", column = "team_name"), + @Result(property = "displayName", column = "display_name"), + }) + @Select("SELECT * FROM stewarding_entrylist_entry WHERE entrylist_id = #{entrylistId} ORDER BY race_number") + List findByEntrylistId(String entrylistId); + + @ResultMap("stewardingEntrylistEntryResultMap") + @Select("SELECT * FROM stewarding_entrylist_entry WHERE id = #{id}") + StewardingEntrylistEntry findById(String id); + + @Insert(""" + INSERT INTO stewarding_entrylist_entry (id, entrylist_id, race_number, car_model_id, team_name, display_name) + VALUES (#{id}, #{entrylistId}, #{raceNumber}, #{carModelId}, #{teamName}, #{displayName}) + """) + void insert(StewardingEntrylistEntry entry); + + @Delete("DELETE FROM stewarding_entrylist_entry WHERE entrylist_id = #{entrylistId}") + void deleteByEntrylistId(String entrylistId); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingEntrylistMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingEntrylistMapper.java new file mode 100644 index 00000000..33bb5732 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingEntrylistMapper.java @@ -0,0 +1,33 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.StewardingEntrylist; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface StewardingEntrylistMapper { + @Results(id = "stewardingEntrylistResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "roundId", column = "round_id"), + @Result(property = "uploadedAt", column = "uploaded_at"), + @Result(property = "rawJson", column = "raw_json"), + }) + @Select("SELECT * FROM stewarding_entrylist WHERE round_id = #{roundId}") + List findByRoundId(String roundId); + + @ResultMap("stewardingEntrylistResultMap") + @Select("SELECT * FROM stewarding_entrylist WHERE id = #{id}") + StewardingEntrylist findById(String id); + + @Insert(""" + INSERT INTO stewarding_entrylist (id, round_id, uploaded_at, raw_json) + VALUES (#{id}, #{roundId}, CURRENT_TIMESTAMP, #{rawJson}) + """) + void insert(StewardingEntrylist entrylist); + + @Delete("DELETE FROM stewarding_entrylist WHERE round_id = #{roundId}") + void deleteByRoundId(String roundId); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingIncidentInvolvedEntryMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingIncidentInvolvedEntryMapper.java new file mode 100644 index 00000000..fd0a9a1e --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingIncidentInvolvedEntryMapper.java @@ -0,0 +1,22 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface StewardingIncidentInvolvedEntryMapper { + @Select("SELECT entry_id FROM stewarding_incident_involved_entry WHERE incident_id = #{incidentId}") + List findEntryIdsByIncidentId(String incidentId); + + @Insert("INSERT INTO stewarding_incident_involved_entry (incident_id, entry_id) VALUES (#{incidentId}, #{entryId})") + void insert(String incidentId, String entryId); + + @Delete("DELETE FROM stewarding_incident_involved_entry WHERE incident_id = #{incidentId}") + void deleteByIncidentId(String incidentId); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingIncidentMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingIncidentMapper.java new file mode 100644 index 00000000..be252fe0 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingIncidentMapper.java @@ -0,0 +1,58 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.Incident; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface StewardingIncidentMapper { + @Results(id = "stewardingIncidentResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "sessionId", column = "session_id"), + @Result(property = "title", column = "title"), + @Result(property = "description", column = "description"), + @Result(property = "lap", column = "lap"), + @Result(property = "timestampInSession", column = "timestamp_in_session"), + @Result(property = "mapMarkerX", column = "map_marker_x"), + @Result(property = "mapMarkerY", column = "map_marker_y"), + @Result(property = "videoUrl", column = "video_url"), + @Result(property = "involvedCarsText", column = "involved_cars_text"), + @Result(property = "status", column = "status"), + @Result(property = "reportedByUserId", column = "reported_by_user_id"), + @Result(property = "createdAt", column = "created_at"), + @Result(property = "updatedAt", column = "updated_at"), + }) + @Select("SELECT * FROM stewarding_incident WHERE session_id = #{sessionId} ORDER BY created_at DESC") + List findBySessionId(String sessionId); + + @ResultMap("stewardingIncidentResultMap") + @Select("SELECT * FROM stewarding_incident WHERE id = #{id}") + Incident findById(String id); + + @ResultMap("stewardingIncidentResultMap") + @Select("SELECT * FROM stewarding_incident WHERE session_id = #{sessionId} AND status = #{status} ORDER BY created_at DESC") + List findBySessionIdAndStatus(String sessionId, String status); + + @Insert(""" + INSERT INTO stewarding_incident (id, session_id, title, description, lap, timestamp_in_session, map_marker_x, map_marker_y, + video_url, involved_cars_text, status, reported_by_user_id, created_at, updated_at) + VALUES (#{id}, #{sessionId}, #{title}, #{description}, #{lap}, #{timestampInSession}, #{mapMarkerX}, #{mapMarkerY}, + #{videoUrl}, #{involvedCarsText}, #{status}, #{reportedByUserId}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """) + void insert(Incident incident); + + @Update("UPDATE stewarding_incident SET status = #{status}, updated_at = CURRENT_TIMESTAMP WHERE id = #{id}") + void updateStatus(String id, String status); + + @Update(""" + UPDATE stewarding_incident + SET title = #{title}, description = #{description}, lap = #{lap}, timestamp_in_session = #{timestampInSession}, + map_marker_x = #{mapMarkerX}, map_marker_y = #{mapMarkerY}, video_url = #{videoUrl}, + involved_cars_text = #{involvedCarsText}, status = #{status}, updated_at = CURRENT_TIMESTAMP + WHERE id = #{id} + """) + void update(Incident incident); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingTrackMapper.java b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingTrackMapper.java new file mode 100644 index 00000000..a90fe766 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/mybatis/mapper/StewardingTrackMapper.java @@ -0,0 +1,43 @@ +package de.sustineo.simdesk.mybatis.mapper; + +import de.sustineo.simdesk.entities.stewarding.StewardingTrack; +import org.apache.ibatis.annotations.*; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Mapper +public interface StewardingTrackMapper { + @Results(id = "stewardingTrackResultMap", value = { + @Result(id = true, property = "id", column = "id"), + @Result(property = "name", column = "name"), + @Result(property = "country", column = "country"), + @Result(property = "mapImageUrl", column = "map_image_url"), + @Result(property = "mapMetadata", column = "map_metadata"), + @Result(property = "createdAt", column = "created_at"), + @Result(property = "updatedAt", column = "updated_at"), + }) + @Select("SELECT * FROM stewarding_track ORDER BY name") + List findAll(); + + @ResultMap("stewardingTrackResultMap") + @Select("SELECT * FROM stewarding_track WHERE id = #{id}") + StewardingTrack findById(String id); + + @Insert(""" + INSERT INTO stewarding_track (id, name, country, map_image_url, map_metadata, created_at, updated_at) + VALUES (#{id}, #{name}, #{country}, #{mapImageUrl}, #{mapMetadata}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """) + void insert(StewardingTrack track); + + @Update(""" + UPDATE stewarding_track + SET name = #{name}, country = #{country}, map_image_url = #{mapImageUrl}, map_metadata = #{mapMetadata}, updated_at = CURRENT_TIMESTAMP + WHERE id = #{id} + """) + void update(StewardingTrack track); + + @Delete("DELETE FROM stewarding_track WHERE id = #{id}") + void delete(String id); +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/services/MenuService.java b/simdesk-web/src/main/java/de/sustineo/simdesk/services/MenuService.java index dc44c14a..2e60ff46 100644 --- a/simdesk-web/src/main/java/de/sustineo/simdesk/services/MenuService.java +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/services/MenuService.java @@ -8,6 +8,7 @@ import de.sustineo.simdesk.entities.menu.MenuEntityCategory; import de.sustineo.simdesk.services.auth.SecurityService; import de.sustineo.simdesk.views.*; +import de.sustineo.simdesk.views.stewarding.*; import lombok.Getter; import org.springframework.stereotype.Service; @@ -55,6 +56,16 @@ public List getItems() { items.add(MenuEntity.ofInternal(MenuEntityCategory.MAP, "Map", VaadinIcon.MAP_MARKER, MapView.class)); } + if (SpringProfile.isStewardingEnabled()) { + items.add(MenuEntity.ofInternal(MenuEntityCategory.STEWARDING, "Series", VaadinIcon.CALENDAR, SeriesListView.class)); + + if (securityService.hasAnyAuthority(UserRoleEnum.ROLE_ADMIN, UserRoleEnum.ROLE_STEWARD)) { + items.add(MenuEntity.ofInternal(MenuEntityCategory.STEWARDING, "Tracks", VaadinIcon.ROAD, StewardingTrackListView.class)); + items.add(MenuEntity.ofInternal(MenuEntityCategory.STEWARDING, "Penalty Catalogs", VaadinIcon.BOOK, PenaltyCatalogListView.class)); + items.add(MenuEntity.ofInternal(MenuEntityCategory.STEWARDING, "Templates", VaadinIcon.FILE_TEXT_O, ReasoningTemplateListView.class)); + } + } + return items; } diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/PenaltyCatalogService.java b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/PenaltyCatalogService.java new file mode 100644 index 00000000..baba086a --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/PenaltyCatalogService.java @@ -0,0 +1,71 @@ +package de.sustineo.simdesk.services.stewarding; + +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.PenaltyCatalog; +import de.sustineo.simdesk.entities.stewarding.PenaltyDefinition; +import de.sustineo.simdesk.mybatis.mapper.PenaltyCatalogMapper; +import de.sustineo.simdesk.mybatis.mapper.PenaltyDefinitionMapper; +import de.sustineo.simdesk.services.IdGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Service +@RequiredArgsConstructor +public class PenaltyCatalogService { + private final PenaltyCatalogMapper catalogMapper; + private final PenaltyDefinitionMapper definitionMapper; + private final IdGenerator idGenerator; + + public List getAllCatalogs() { + return catalogMapper.findAll(); + } + + public PenaltyCatalog getCatalogById(String id) { + return catalogMapper.findById(id); + } + + @Transactional + public void createCatalog(PenaltyCatalog catalog) { + catalog.setId(idGenerator.generateRandomString(12)); + catalogMapper.insert(catalog); + } + + @Transactional + public void updateCatalog(PenaltyCatalog catalog) { + catalogMapper.update(catalog); + } + + @Transactional + public void deleteCatalog(String id) { + catalogMapper.delete(id); + } + + public List getDefinitionsByCatalogId(String catalogId) { + return definitionMapper.findByCatalogId(catalogId); + } + + public List getDefinitionsForSessionType(String catalogId, String sessionType) { + return definitionMapper.findByCatalogIdAndSessionType(catalogId, sessionType); + } + + @Transactional + public void createDefinition(PenaltyDefinition definition) { + definition.setId(idGenerator.generateRandomString(12)); + definitionMapper.insert(definition); + } + + @Transactional + public void updateDefinition(PenaltyDefinition definition) { + definitionMapper.update(definition); + } + + @Transactional + public void deleteDefinition(String id) { + definitionMapper.delete(id); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/ReasoningTemplateService.java b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/ReasoningTemplateService.java new file mode 100644 index 00000000..d2899e4e --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/ReasoningTemplateService.java @@ -0,0 +1,57 @@ +package de.sustineo.simdesk.services.stewarding; + +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.ReasoningTemplate; +import de.sustineo.simdesk.mybatis.mapper.ReasoningTemplateMapper; +import de.sustineo.simdesk.services.IdGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +@Profile(SpringProfile.STEWARDING) +@Service +@RequiredArgsConstructor +public class ReasoningTemplateService { + private final ReasoningTemplateMapper templateMapper; + private final IdGenerator idGenerator; + + public List getAllTemplates() { + return templateMapper.findAll(); + } + + public ReasoningTemplate getTemplateById(String id) { + return templateMapper.findById(id); + } + + public List getTemplatesByCategory(String category) { + return templateMapper.findByCategory(category); + } + + @Transactional + public void createTemplate(ReasoningTemplate template) { + template.setId(idGenerator.generateRandomString(12)); + templateMapper.insert(template); + } + + @Transactional + public void updateTemplate(ReasoningTemplate template) { + templateMapper.update(template); + } + + @Transactional + public void deleteTemplate(String id) { + templateMapper.delete(id); + } + + public String renderTemplate(String templateText, Map variables) { + String result = templateText; + for (Map.Entry entry : variables.entrySet()) { + result = result.replace("{" + entry.getKey() + "}", entry.getValue()); + } + return result; + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/RoundService.java b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/RoundService.java new file mode 100644 index 00000000..d24936a6 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/RoundService.java @@ -0,0 +1,81 @@ +package de.sustineo.simdesk.services.stewarding; + +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.Round; +import de.sustineo.simdesk.entities.stewarding.RoundSession; +import de.sustineo.simdesk.mybatis.mapper.RoundMapper; +import de.sustineo.simdesk.mybatis.mapper.RoundSessionMapper; +import de.sustineo.simdesk.mybatis.mapper.StewardingTrackMapper; +import de.sustineo.simdesk.services.IdGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Service +@RequiredArgsConstructor +public class RoundService { + private final RoundMapper roundMapper; + private final RoundSessionMapper sessionMapper; + private final StewardingTrackMapper trackMapper; + private final IdGenerator idGenerator; + + public List getAllRounds() { + return roundMapper.findAll(); + } + + public Round getRoundById(String id) { + Round round = roundMapper.findById(id); + if (round != null && round.getTrackId() != null) { + round.setTrack(trackMapper.findById(round.getTrackId())); + } + return round; + } + + public List getRoundsBySeriesId(String seriesId) { + return roundMapper.findBySeriesId(seriesId); + } + + @Transactional + public void createRound(Round round) { + round.setId(idGenerator.generateRandomString(12)); + roundMapper.insert(round); + } + + @Transactional + public void updateRound(Round round) { + roundMapper.update(round); + } + + @Transactional + public void deleteRound(String id) { + roundMapper.delete(id); + } + + public List getSessionsByRoundId(String roundId) { + return sessionMapper.findByRoundId(roundId); + } + + public RoundSession getSessionById(String id) { + return sessionMapper.findById(id); + } + + @Transactional + public void createSession(RoundSession session) { + session.setId(idGenerator.generateRandomString(12)); + sessionMapper.insert(session); + } + + @Transactional + public void updateSession(RoundSession session) { + sessionMapper.update(session); + } + + @Transactional + public void deleteSession(String id) { + sessionMapper.delete(id); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/SeriesService.java b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/SeriesService.java new file mode 100644 index 00000000..ec3bb74e --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/SeriesService.java @@ -0,0 +1,50 @@ +package de.sustineo.simdesk.services.stewarding; + +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.Series; +import de.sustineo.simdesk.mybatis.mapper.PenaltyCatalogMapper; +import de.sustineo.simdesk.mybatis.mapper.SeriesMapper; +import de.sustineo.simdesk.services.IdGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Service +@RequiredArgsConstructor +public class SeriesService { + private final SeriesMapper seriesMapper; + private final PenaltyCatalogMapper catalogMapper; + private final IdGenerator idGenerator; + + public List getAllSeries() { + return seriesMapper.findAll(); + } + + public Series getSeriesById(String id) { + Series series = seriesMapper.findById(id); + if (series != null && series.getPenaltyCatalogId() != null) { + series.setPenaltyCatalog(catalogMapper.findById(series.getPenaltyCatalogId())); + } + return series; + } + + @Transactional + public void createSeries(Series series) { + series.setId(idGenerator.generateRandomString(12)); + seriesMapper.insert(series); + } + + @Transactional + public void updateSeries(Series series) { + seriesMapper.update(series); + } + + @Transactional + public void deleteSeries(String id) { + seriesMapper.delete(id); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardDecisionService.java b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardDecisionService.java new file mode 100644 index 00000000..2c30ee30 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardDecisionService.java @@ -0,0 +1,61 @@ +package de.sustineo.simdesk.services.stewarding; + +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.IncidentStatus; +import de.sustineo.simdesk.entities.stewarding.StewardDecision; +import de.sustineo.simdesk.mybatis.mapper.StewardDecisionMapper; +import de.sustineo.simdesk.mybatis.mapper.StewardingIncidentMapper; +import de.sustineo.simdesk.services.IdGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Service +@RequiredArgsConstructor +public class StewardDecisionService { + private final StewardDecisionMapper decisionMapper; + private final StewardingIncidentMapper incidentMapper; + private final IdGenerator idGenerator; + + public StewardDecision getDecisionById(String id) { + return decisionMapper.findById(id); + } + + public StewardDecision getActiveDecisionByIncidentId(String incidentId) { + List decisions = decisionMapper.findActiveByIncidentId(incidentId); + return decisions.isEmpty() ? null : decisions.getFirst(); + } + + public List getDecisionHistory(String incidentId) { + return decisionMapper.findByIncidentId(incidentId); + } + + public List getDecisionsBySessionId(String sessionId) { + return decisionMapper.findBySessionId(sessionId); + } + + public List getManualDecisionsBySessionId(String sessionId) { + return decisionMapper.findManualBySessionId(sessionId); + } + + @Transactional + public void makeDecision(StewardDecision decision) { + decision.setId(idGenerator.generateRandomString(12)); + decisionMapper.insert(decision); + if (decision.getIncidentId() != null) { + incidentMapper.updateStatus(decision.getIncidentId(), IncidentStatus.DECISION_MADE.name()); + } + } + + @Transactional + public void reviseDecision(String oldDecisionId, StewardDecision newDecision) { + newDecision.setId(idGenerator.generateRandomString(12)); + decisionMapper.deactivate(oldDecisionId); + decisionMapper.insert(newDecision); + decisionMapper.setSupersededBy(oldDecisionId, newDecision.getId()); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingAppealService.java b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingAppealService.java new file mode 100644 index 00000000..740f64d4 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingAppealService.java @@ -0,0 +1,59 @@ +package de.sustineo.simdesk.services.stewarding; + +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.Appeal; +import de.sustineo.simdesk.entities.stewarding.AppealStatus; +import de.sustineo.simdesk.entities.stewarding.IncidentStatus; +import de.sustineo.simdesk.entities.stewarding.StewardDecision; +import de.sustineo.simdesk.mybatis.mapper.StewardDecisionMapper; +import de.sustineo.simdesk.mybatis.mapper.StewardingAppealMapper; +import de.sustineo.simdesk.mybatis.mapper.StewardingIncidentMapper; +import de.sustineo.simdesk.services.IdGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Service +@RequiredArgsConstructor +public class StewardingAppealService { + private final StewardingAppealMapper appealMapper; + private final StewardingIncidentMapper incidentMapper; + private final StewardDecisionMapper decisionMapper; + private final IdGenerator idGenerator; + + public List getAppealsByDecisionId(String decisionId) { + return appealMapper.findByDecisionId(decisionId); + } + + public Appeal getAppealById(String id) { + return appealMapper.findById(id); + } + + @Transactional + public void fileAppeal(Appeal appeal) { + appeal.setId(idGenerator.generateRandomString(12)); + appealMapper.insert(appeal); + StewardDecision decision = decisionMapper.findById(appeal.getDecisionId()); + if (decision != null && decision.getIncidentId() != null) { + incidentMapper.updateStatus(decision.getIncidentId(), IncidentStatus.APPEALED.name()); + } + } + + @Transactional + public void reviewAppeal(String id, AppealStatus status, String response, Integer respondedByUserId) { + appealMapper.updateResponse(id, status.name(), response, respondedByUserId); + if (status == AppealStatus.ACCEPTED || status == AppealStatus.REJECTED) { + Appeal appeal = appealMapper.findById(id); + if (appeal != null) { + StewardDecision decision = decisionMapper.findById(appeal.getDecisionId()); + if (decision != null && decision.getIncidentId() != null) { + incidentMapper.updateStatus(decision.getIncidentId(), IncidentStatus.APPEAL_REVIEWED.name()); + } + } + } + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingDiscordNotificationService.java b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingDiscordNotificationService.java new file mode 100644 index 00000000..c6ca7a6c --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingDiscordNotificationService.java @@ -0,0 +1,168 @@ +package de.sustineo.simdesk.services.stewarding; + +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.*; +import de.sustineo.simdesk.mybatis.mapper.SeriesMapper; +import lombok.extern.java.Log; +import org.springframework.context.annotation.Profile; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; + +@Log +@Profile(SpringProfile.STEWARDING) +@Service +public class StewardingDiscordNotificationService { + private static final int COLOR_BLUE = 0x3498DB; + private static final int COLOR_RED = 0xE74C3C; + private static final int COLOR_GREEN = 0x2ECC71; + private static final int COLOR_YELLOW = 0xF1C40F; + private static final int COLOR_ORANGE = 0xE67E22; + + private final SeriesMapper seriesMapper; + private final RestClient restClient; + + public StewardingDiscordNotificationService(SeriesMapper seriesMapper) { + this.seriesMapper = seriesMapper; + this.restClient = RestClient.create(); + } + + @Async + public void sendIncidentNotification(String seriesId, Incident incident) { + String webhookUrl = getWebhookUrl(seriesId); + if (webhookUrl == null) { + return; + } + + Map embed = Map.of( + "title", "🚩 New Incident Reported", + "description", incident.getTitle() != null ? incident.getTitle() : "No title", + "color", COLOR_BLUE, + "fields", List.of( + Map.of("name", "Lap", "value", incident.getLap() != null ? String.valueOf(incident.getLap()) : "N/A", "inline", true), + Map.of("name", "Status", "value", incident.getStatus() != null ? incident.getStatus().getDescription() : "Reported", "inline", true) + ), + "timestamp", Instant.now().toString() + ); + + sendWebhook(webhookUrl, Map.of("embeds", List.of(embed))); + } + + @Async + public void sendDecisionNotification(String seriesId, StewardDecision decision, Incident incident, String penaltyName) { + String webhookUrl = getWebhookUrl(seriesId); + if (webhookUrl == null) { + return; + } + + boolean isNoAction = Boolean.TRUE.equals(decision.getIsNoAction()); + int color = isNoAction ? COLOR_GREEN : COLOR_RED; + String title = isNoAction ? "✅ No Further Action" : "⚖️ Steward Decision"; + + Map embed = Map.of( + "title", title, + "description", incident != null && incident.getTitle() != null ? incident.getTitle() : "Manual decision", + "color", color, + "fields", List.of( + Map.of("name", "Penalty", "value", penaltyName != null ? penaltyName : "No action", "inline", true), + Map.of("name", "Reasoning", "value", decision.getReasoning() != null ? decision.getReasoning() : "N/A", "inline", false) + ), + "timestamp", Instant.now().toString() + ); + + sendWebhook(webhookUrl, Map.of("embeds", List.of(embed))); + } + + @Async + public void sendAppealNotification(String seriesId, Appeal appeal) { + String webhookUrl = getWebhookUrl(seriesId); + if (webhookUrl == null) { + return; + } + + Map embed = Map.of( + "title", "📋 Appeal Filed", + "description", appeal.getReason() != null ? appeal.getReason() : "No reason provided", + "color", COLOR_YELLOW, + "fields", List.of( + Map.of("name", "Decision ID", "value", String.valueOf(appeal.getDecisionId()), "inline", true), + Map.of("name", "Status", "value", appeal.getStatus() != null ? appeal.getStatus().getDescription() : "Pending", "inline", true) + ), + "timestamp", Instant.now().toString() + ); + + sendWebhook(webhookUrl, Map.of("embeds", List.of(embed))); + } + + @Async + public void sendAppealReviewedNotification(String seriesId, Appeal appeal) { + String webhookUrl = getWebhookUrl(seriesId); + if (webhookUrl == null) { + return; + } + + int color = appeal.getStatus() == AppealStatus.ACCEPTED ? COLOR_GREEN : COLOR_RED; + + Map embed = Map.of( + "title", "📋 Appeal Reviewed", + "description", appeal.getResponse() != null ? appeal.getResponse() : "No response provided", + "color", color, + "fields", List.of( + Map.of("name", "Decision ID", "value", String.valueOf(appeal.getDecisionId()), "inline", true), + Map.of("name", "Result", "value", appeal.getStatus() != null ? appeal.getStatus().getDescription() : "N/A", "inline", true) + ), + "timestamp", Instant.now().toString() + ); + + sendWebhook(webhookUrl, Map.of("embeds", List.of(embed))); + } + + @Async + public void sendDecisionRevisedNotification(String seriesId, StewardDecision oldDecision, StewardDecision newDecision) { + String webhookUrl = getWebhookUrl(seriesId); + if (webhookUrl == null) { + return; + } + + Map embed = Map.of( + "title", "🔄 Decision Revised", + "description", "A previous steward decision has been revised.", + "color", COLOR_ORANGE, + "fields", List.of( + Map.of("name", "Previous Decision ID", "value", String.valueOf(oldDecision.getId()), "inline", true), + Map.of("name", "New Decision ID", "value", String.valueOf(newDecision.getId()), "inline", true), + Map.of("name", "New Reasoning", "value", newDecision.getReasoning() != null ? newDecision.getReasoning() : "N/A", "inline", false) + ), + "timestamp", Instant.now().toString() + ); + + sendWebhook(webhookUrl, Map.of("embeds", List.of(embed))); + } + + private String getWebhookUrl(String seriesId) { + Series series = seriesMapper.findById(seriesId); + if (series == null || series.getDiscordWebhookUrl() == null || series.getDiscordWebhookUrl().isBlank()) { + return null; + } + return series.getDiscordWebhookUrl(); + } + + private void sendWebhook(String webhookUrl, Map payload) { + try { + restClient.post() + .uri(webhookUrl) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .toBodilessEntity(); + } catch (Exception e) { + log.log(Level.WARNING, "Failed to send Discord webhook notification", e); + } + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingEntrylistService.java b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingEntrylistService.java new file mode 100644 index 00000000..a8c8f63c --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingEntrylistService.java @@ -0,0 +1,117 @@ +package de.sustineo.simdesk.services.stewarding; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.StewardingEntrylist; +import de.sustineo.simdesk.entities.stewarding.StewardingEntrylistDriver; +import de.sustineo.simdesk.entities.stewarding.StewardingEntrylistEntry; +import de.sustineo.simdesk.mybatis.mapper.StewardingEntrylistDriverMapper; +import de.sustineo.simdesk.mybatis.mapper.StewardingEntrylistEntryMapper; +import de.sustineo.simdesk.mybatis.mapper.StewardingEntrylistMapper; +import de.sustineo.simdesk.services.IdGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Service +@RequiredArgsConstructor +public class StewardingEntrylistService { + private final StewardingEntrylistMapper entrylistMapper; + private final StewardingEntrylistEntryMapper entryMapper; + private final StewardingEntrylistDriverMapper driverMapper; + private final ObjectMapper objectMapper; + private final IdGenerator idGenerator; + + public StewardingEntrylist getEntrylistByRoundId(String roundId) { + List entrylists = entrylistMapper.findByRoundId(roundId); + return entrylists.isEmpty() ? null : entrylists.getFirst(); + } + + public List getEntriesByEntrylistId(String entrylistId) { + return entryMapper.findByEntrylistId(entrylistId); + } + + public List getDriversByEntryId(String entryId) { + return driverMapper.findByEntryId(entryId); + } + + @Transactional + public void uploadEntrylistForRound(String roundId, String jsonContent) { + deleteEntrylistForRound(roundId); + + JsonNode root; + try { + root = objectMapper.readTree(jsonContent); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid JSON content", e); + } + + StewardingEntrylist entrylist = new StewardingEntrylist(); + entrylist.setId(idGenerator.generateRandomString(12)); + entrylist.setRoundId(roundId); + entrylist.setRawJson(jsonContent); + entrylistMapper.insert(entrylist); + + parseAndInsertEntries(root, entrylist); + } + + private void parseAndInsertEntries(JsonNode root, StewardingEntrylist entrylist) { + + JsonNode entries = root.get("entries"); + if (entries == null || !entries.isArray()) { + return; + } + + for (JsonNode entryNode : entries) { + int raceNumber = entryNode.path("raceNumber").asInt(); + int forcedCarModel = entryNode.path("forcedCarModel").asInt(); + + JsonNode driversNode = entryNode.get("drivers"); + String displayName = "#" + raceNumber; + if (driversNode != null && driversNode.isArray() && !driversNode.isEmpty()) { + String shortName = driversNode.get(0).path("shortName").asText(""); + displayName = "#" + raceNumber + " \u2014 " + shortName; + } + + StewardingEntrylistEntry entry = new StewardingEntrylistEntry(); + entry.setId(idGenerator.generateRandomString(12)); + entry.setEntrylistId(entrylist.getId()); + entry.setRaceNumber(raceNumber); + entry.setCarModelId(forcedCarModel); + entry.setDisplayName(displayName); + entryMapper.insert(entry); + + if (driversNode != null && driversNode.isArray()) { + for (JsonNode driverNode : driversNode) { + StewardingEntrylistDriver driver = new StewardingEntrylistDriver(); + driver.setId(idGenerator.generateRandomString(12)); + driver.setEntryId(entry.getId()); + driver.setFirstName(driverNode.path("firstName").asText("")); + driver.setLastName(driverNode.path("lastName").asText("")); + driver.setShortName(driverNode.path("shortName").asText("")); + driver.setSteamId(driverNode.path("playerID").asText("")); + driver.setCategory(driverNode.path("driverCategory").asInt(0)); + driverMapper.insert(driver); + } + } + } + } + + @Transactional + public void deleteEntrylistForRound(String roundId) { + List existing = entrylistMapper.findByRoundId(roundId); + for (StewardingEntrylist entrylist : existing) { + List entries = entryMapper.findByEntrylistId(entrylist.getId()); + for (StewardingEntrylistEntry entry : entries) { + driverMapper.deleteByEntryId(entry.getId()); + } + entryMapper.deleteByEntrylistId(entrylist.getId()); + } + entrylistMapper.deleteByRoundId(roundId); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingIncidentService.java b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingIncidentService.java new file mode 100644 index 00000000..777838d9 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingIncidentService.java @@ -0,0 +1,53 @@ +package de.sustineo.simdesk.services.stewarding; + +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.Incident; +import de.sustineo.simdesk.entities.stewarding.IncidentStatus; +import de.sustineo.simdesk.mybatis.mapper.StewardingIncidentInvolvedEntryMapper; +import de.sustineo.simdesk.mybatis.mapper.StewardingIncidentMapper; +import de.sustineo.simdesk.services.IdGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Service +@RequiredArgsConstructor +public class StewardingIncidentService { + private final StewardingIncidentMapper incidentMapper; + private final StewardingIncidentInvolvedEntryMapper involvedEntryMapper; + private final IdGenerator idGenerator; + + public List getIncidentsBySessionId(String sessionId) { + return incidentMapper.findBySessionId(sessionId); + } + + public Incident getIncidentById(String id) { + return incidentMapper.findById(id); + } + + public List getIncidentsBySessionIdAndStatus(String sessionId, IncidentStatus status) { + return incidentMapper.findBySessionIdAndStatus(sessionId, status.name()); + } + + @Transactional + public void createIncident(Incident incident, List involvedEntryIds) { + incident.setId(idGenerator.generateRandomString(12)); + incidentMapper.insert(incident); + for (String entryId : involvedEntryIds) { + involvedEntryMapper.insert(incident.getId(), entryId); + } + } + + @Transactional + public void updateIncidentStatus(String id, IncidentStatus status) { + incidentMapper.updateStatus(id, status.name()); + } + + public List getInvolvedEntryIds(String incidentId) { + return involvedEntryMapper.findEntryIdsByIncidentId(incidentId); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingTrackService.java b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingTrackService.java new file mode 100644 index 00000000..709adb8d --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/services/stewarding/StewardingTrackService.java @@ -0,0 +1,44 @@ +package de.sustineo.simdesk.services.stewarding; + +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.StewardingTrack; +import de.sustineo.simdesk.mybatis.mapper.StewardingTrackMapper; +import de.sustineo.simdesk.services.IdGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Service +@RequiredArgsConstructor +public class StewardingTrackService { + private final StewardingTrackMapper trackMapper; + private final IdGenerator idGenerator; + + public List getAllTracks() { + return trackMapper.findAll(); + } + + public StewardingTrack getTrackById(String id) { + return trackMapper.findById(id); + } + + @Transactional + public void createTrack(StewardingTrack track) { + track.setId(idGenerator.generateRandomString(12)); + trackMapper.insert(track); + } + + @Transactional + public void updateTrack(StewardingTrack track) { + trackMapper.update(track); + } + + @Transactional + public void deleteTrack(String id) { + trackMapper.delete(id); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/IncidentDetailView.java b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/IncidentDetailView.java new file mode 100644 index 00000000..e0ba5b9b --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/IncidentDetailView.java @@ -0,0 +1,317 @@ +package de.sustineo.simdesk.views.stewarding; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.html.Anchor; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.RouteParam; +import com.vaadin.flow.router.RouteParameters; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.auth.UserRoleEnum; +import de.sustineo.simdesk.entities.stewarding.*; +import de.sustineo.simdesk.layouts.MainLayout; +import de.sustineo.simdesk.services.NotificationService; +import de.sustineo.simdesk.services.auth.SecurityService; +import de.sustineo.simdesk.services.stewarding.*; +import de.sustineo.simdesk.views.BaseView; +import org.springframework.context.annotation.Profile; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Route(value = "/stewarding/series/:seriesId/rounds/:roundId/incidents/:incidentId", layout = MainLayout.class) +@AnonymousAllowed +public class IncidentDetailView extends BaseView { + private final StewardingIncidentService incidentService; + private final StewardDecisionService decisionService; + private final StewardingAppealService appealService; + private final PenaltyCatalogService catalogService; + private final ReasoningTemplateService templateService; + private final SeriesService seriesService; + private final RoundService roundService; + private final StewardingEntrylistService entrylistService; + private final SecurityService securityService; + private final NotificationService notificationService; + private String seriesId; + private String roundId; + private String incidentId; + + public IncidentDetailView(StewardingIncidentService incidentService, StewardDecisionService decisionService, + StewardingAppealService appealService, PenaltyCatalogService catalogService, + ReasoningTemplateService templateService, SeriesService seriesService, + RoundService roundService, StewardingEntrylistService entrylistService, + SecurityService securityService, NotificationService notificationService) { + this.incidentService = incidentService; + this.decisionService = decisionService; + this.appealService = appealService; + this.catalogService = catalogService; + this.templateService = templateService; + this.seriesService = seriesService; + this.roundService = roundService; + this.entrylistService = entrylistService; + this.securityService = securityService; + this.notificationService = notificationService; + } + + @Override + public String getPageTitle() { + return "Incident Details"; + } + + @Override + public void beforeEnter(BeforeEnterEvent event) { + setSizeFull(); + setPadding(false); + setSpacing(false); + removeAll(); + + String seriesIdParam = event.getRouteParameters().get("seriesId").orElse(null); + String roundIdParam = event.getRouteParameters().get("roundId").orElse(null); + String incidentIdParam = event.getRouteParameters().get("incidentId").orElse(null); + if (seriesIdParam == null || roundIdParam == null || incidentIdParam == null) { + getUI().ifPresent(ui -> ui.navigate(SeriesListView.class)); + return; + } + + seriesId = seriesIdParam; + roundId = roundIdParam; + incidentId = incidentIdParam; + + Series series = seriesService.getSeriesById(seriesId); + Round round = roundService.getRoundById(roundId); + Incident incident = incidentService.getIncidentById(incidentId); + if (series == null || round == null || incident == null) { + getUI().ifPresent(ui -> ui.navigate(SeriesListView.class)); + return; + } + + add(createViewHeader(incident.getTitle())); + + Button backButton = new Button("← Back to " + round.getTitle(), e -> + getUI().ifPresent(ui -> ui.navigate(RoundDetailView.class, + new RouteParameters( + new RouteParam("seriesId", String.valueOf(seriesId)), + new RouteParam("roundId", String.valueOf(roundId)) + )))); + backButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + add(backButton); + + // Incident details + VerticalLayout detailsLayout = new VerticalLayout(); + detailsLayout.setPadding(true); + detailsLayout.setSpacing(false); + + if (incident.getDescription() != null && !incident.getDescription().isEmpty()) { + detailsLayout.add(new Span(incident.getDescription())); + } + if (incident.getLap() != null) { + detailsLayout.add(createDetailRow("Lap", String.valueOf(incident.getLap()))); + } + if (incident.getTimestampInSession() != null) { + detailsLayout.add(createDetailRow("Time in Session", incident.getTimestampInSession())); + } + if (incident.getMapMarkerX() != null && incident.getMapMarkerY() != null) { + detailsLayout.add(createDetailRow("Map Location", String.format("X: %.2f, Y: %.2f", incident.getMapMarkerX(), incident.getMapMarkerY()))); + } + if (incident.getInvolvedCarsText() != null) { + detailsLayout.add(createDetailRow("Involved Cars", incident.getInvolvedCarsText())); + } + if (incident.getVideoUrl() != null && !incident.getVideoUrl().isEmpty()) { + Anchor videoLink = new Anchor(incident.getVideoUrl(), "Video Evidence"); + videoLink.setTarget("_blank"); + HorizontalLayout videoRow = new HorizontalLayout(); + Span videoLabel = new Span("Evidence: "); + videoLabel.getStyle().set("font-weight", "bold"); + videoRow.setSpacing(false); + videoRow.getStyle().set("gap", "var(--lumo-space-xs)"); + videoRow.add(videoLabel, videoLink); + detailsLayout.add(videoRow); + } + detailsLayout.add(createDetailRow("Status", incident.getStatus() != null ? incident.getStatus().getDescription() : "-")); + add(detailsLayout); + + // Steward Decision section (ADMIN only) + if (securityService.hasAnyAuthority(UserRoleEnum.ROLE_ADMIN, UserRoleEnum.ROLE_STEWARD)) { + add(createDecisionSection(incident, series)); + } + + // Decision History + add(createDecisionHistorySection(incidentId)); + + // Appeals section + add(createAppealsSection(incidentId)); + } + + private VerticalLayout createDecisionSection(Incident incident, Series series) { + VerticalLayout layout = new VerticalLayout(); + layout.setPadding(true); + layout.add(new H3("Steward Decision")); + + StewardDecision activeDecision = decisionService.getActiveDecisionByIncidentId(incident.getId()); + if (activeDecision != null) { + layout.add(new Paragraph("Penalty: " + (activeDecision.getCustomPenalty() != null ? activeDecision.getCustomPenalty() : "-"))); + layout.add(new Paragraph("Reasoning: " + (activeDecision.getReasoning() != null ? activeDecision.getReasoning() : "-"))); + layout.add(new Paragraph("No Further Action: " + (Boolean.TRUE.equals(activeDecision.getIsNoAction()) ? "Yes" : "No"))); + + Button reviseButton = new Button("Revise Decision"); + reviseButton.addThemeVariants(ButtonVariant.LUMO_ERROR); + reviseButton.addClickListener(e -> { + layout.removeAll(); + layout.add(new H3("Revise Decision")); + layout.add(createDecisionForm(incident, series, activeDecision.getId())); + }); + layout.add(reviseButton); + } else { + layout.add(createDecisionForm(incident, series, null)); + } + + return layout; + } + + private FormLayout createDecisionForm(Incident incident, Series series, String existingDecisionId) { + FormLayout form = new FormLayout(); + form.setResponsiveSteps( + new FormLayout.ResponsiveStep("0", 1), + new FormLayout.ResponsiveStep("600px", 2) + ); + + RoundSession session = roundService.getSessionById(incident.getSessionId()); + + ComboBox penaltyCombo = new ComboBox<>("Penalty"); + if (series.getPenaltyCatalogId() != null && session != null && session.getSessionType() != null) { + List definitions = catalogService.getDefinitionsForSessionType( + series.getPenaltyCatalogId(), session.getSessionType().name()); + penaltyCombo.setItems(definitions); + penaltyCombo.setItemLabelGenerator(d -> d.getCode() + " - " + d.getName()); + } + + TextField customPenaltyField = new TextField("Custom Penalty"); + customPenaltyField.setWidthFull(); + + TextArea reasoningField = new TextArea("Reasoning"); + reasoningField.setWidthFull(); + + ComboBox templateCombo = new ComboBox<>("Reasoning Template"); + templateCombo.setItems(templateService.getAllTemplates()); + templateCombo.setItemLabelGenerator(ReasoningTemplate::getName); + templateCombo.addValueChangeListener(e -> { + if (e.getValue() != null) { + reasoningField.setValue(e.getValue().getTemplateText()); + } + }); + + TextField penalizedCarField = new TextField("Penalized Car"); + penalizedCarField.setWidthFull(); + + Checkbox noActionCheckbox = new Checkbox("No Further Action"); + + form.add(penaltyCombo, customPenaltyField, templateCombo, reasoningField, penalizedCarField, noActionCheckbox); + + Button saveButton = new Button("Save Decision", e -> { + StewardDecision decision = StewardDecision.builder() + .incidentId(incident.getId()) + .sessionId(incident.getSessionId()) + .penaltyDefinitionId(penaltyCombo.getValue() != null ? penaltyCombo.getValue().getId() : null) + .customPenalty(customPenaltyField.getValue()) + .reasoning(reasoningField.getValue()) + .reasoningTemplateId(templateCombo.getValue() != null ? templateCombo.getValue().getId() : null) + .isNoAction(noActionCheckbox.getValue()) + .penalizedCarText(penalizedCarField.getValue()) + .isActive(true) + .build(); + + if (existingDecisionId != null) { + decisionService.reviseDecision(existingDecisionId, decision); + } else { + decisionService.makeDecision(decision); + } + + notificationService.showSuccessNotification("Decision saved"); + getUI().ifPresent(ui -> ui.navigate(IncidentDetailView.class, + new RouteParameters(new RouteParam("seriesId", seriesId), new RouteParam("roundId", roundId), new RouteParam("incidentId", incidentId)))); + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + form.add(saveButton); + + return form; + } + + private VerticalLayout createDecisionHistorySection(String incidentId) { + VerticalLayout layout = new VerticalLayout(); + layout.setPadding(true); + layout.add(new H3("Decision History")); + + List history = decisionService.getDecisionHistory(incidentId); + if (history.isEmpty()) { + layout.add(new Paragraph("No decisions yet.")); + return layout; + } + + for (StewardDecision decision : history) { + HorizontalLayout row = new HorizontalLayout(); + row.setAlignItems(Alignment.CENTER); + + Span status = new Span(Boolean.TRUE.equals(decision.getIsActive()) ? "ACTIVE" : "SUPERSEDED"); + Span penalty = new Span(decision.getCustomPenalty() != null ? decision.getCustomPenalty() : "-"); + Span reasoning = new Span(decision.getReasoning() != null ? decision.getReasoning() : "-"); + Span decidedAt = new Span(decision.getDecidedAt() != null ? decision.getDecidedAt().toString() : "-"); + + row.add(status, penalty, reasoning, decidedAt); + layout.add(row); + } + + return layout; + } + + private VerticalLayout createAppealsSection(String incidentId) { + VerticalLayout layout = new VerticalLayout(); + layout.setPadding(true); + layout.add(new H3("Appeals")); + + StewardDecision activeDecision = decisionService.getActiveDecisionByIncidentId(incidentId); + if (activeDecision == null) { + layout.add(new Paragraph("No active decision to appeal.")); + return layout; + } + + List appeals = appealService.getAppealsByDecisionId(activeDecision.getId()); + if (appeals.isEmpty()) { + layout.add(new Paragraph("No appeals filed.")); + } else { + Grid grid = new Grid<>(Appeal.class, false); + grid.addColumn(Appeal::getReason).setHeader("Reason").setAutoWidth(true); + grid.addColumn(appeal -> appeal.getStatus() != null ? appeal.getStatus().getDescription() : "-") + .setHeader("Status").setAutoWidth(true); + grid.addColumn(appeal -> appeal.getResponse() != null ? appeal.getResponse() : "-") + .setHeader("Response").setAutoWidth(true); + grid.addColumn(Appeal::getFiledAt).setHeader("Filed At").setAutoWidth(true); + grid.setItems(appeals); + layout.add(grid); + } + + return layout; + } + + private HorizontalLayout createDetailRow(String label, String value) { + Span labelSpan = new Span(label + ": "); + labelSpan.getStyle().set("font-weight", "bold"); + Span valueSpan = new Span(value); + HorizontalLayout row = new HorizontalLayout(labelSpan, valueSpan); + row.setSpacing(false); + row.getStyle().set("gap", "var(--lumo-space-xs)"); + return row; + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/PenaltyCatalogDetailView.java b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/PenaltyCatalogDetailView.java new file mode 100644 index 00000000..68481340 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/PenaltyCatalogDetailView.java @@ -0,0 +1,169 @@ +package de.sustineo.simdesk.views.stewarding; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.GridVariant; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.Route; +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.PenaltyCatalog; +import de.sustineo.simdesk.entities.stewarding.PenaltyDefinition; +import de.sustineo.simdesk.entities.stewarding.PenaltySessionType; +import de.sustineo.simdesk.layouts.MainLayout; +import de.sustineo.simdesk.services.NotificationService; +import de.sustineo.simdesk.services.stewarding.PenaltyCatalogService; +import de.sustineo.simdesk.views.BaseView; +import jakarta.annotation.security.RolesAllowed; +import org.springframework.context.annotation.Profile; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Route(value = "/stewarding/catalogs/:catalogId", layout = MainLayout.class) +@RolesAllowed({"ADMIN", "STEWARD"}) +public class PenaltyCatalogDetailView extends BaseView { + private final PenaltyCatalogService catalogService; + private final NotificationService notificationService; + private Grid grid; + private String catalogId; + + public PenaltyCatalogDetailView(PenaltyCatalogService catalogService, NotificationService notificationService) { + this.catalogService = catalogService; + this.notificationService = notificationService; + } + + @Override + public String getPageTitle() { + return "Penalty Catalog Details"; + } + + @Override + public void beforeEnter(BeforeEnterEvent event) { + setSizeFull(); + setPadding(false); + setSpacing(false); + removeAll(); + + String catalogIdParam = event.getRouteParameters().get("catalogId").orElse(null); + if (catalogIdParam == null) { + getUI().ifPresent(ui -> ui.navigate(PenaltyCatalogListView.class)); + return; + } + + catalogId = catalogIdParam; + + PenaltyCatalog catalog = catalogService.getCatalogById(catalogId); + if (catalog == null) { + getUI().ifPresent(ui -> ui.navigate(PenaltyCatalogListView.class)); + return; + } + + add(createViewHeader(catalog.getName())); + + Button backButton = new Button("← Back to Penalty Catalogs", e -> + getUI().ifPresent(ui -> ui.navigate(PenaltyCatalogListView.class))); + backButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + add(backButton); + + VerticalLayout infoLayout = new VerticalLayout(); + infoLayout.setPadding(true); + infoLayout.setSpacing(false); + if (catalog.getDescription() != null && !catalog.getDescription().isEmpty()) { + infoLayout.add(new Paragraph(catalog.getDescription())); + } + add(infoLayout); + + HorizontalLayout actionLayout = new HorizontalLayout(); + Button addPenaltyButton = new Button("Add Penalty", e -> openPenaltyDialog(catalogId)); + addPenaltyButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + actionLayout.add(addPenaltyButton); + add(actionLayout); + + List definitions = catalogService.getDefinitionsByCatalogId(catalogId); + grid = new Grid<>(PenaltyDefinition.class, false); + grid.addColumn(PenaltyDefinition::getCode).setHeader("Code").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.addColumn(PenaltyDefinition::getName).setHeader("Name").setSortable(true); + grid.addColumn(PenaltyDefinition::getCategory).setHeader("Category").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.addColumn(def -> def.getSessionType() != null ? def.getSessionType().getDescription() : "-") + .setHeader("Session Type").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.addColumn(PenaltyDefinition::getDefaultPenalty).setHeader("Default Penalty").setAutoWidth(true).setFlexGrow(0); + grid.addColumn(PenaltyDefinition::getSeverity).setHeader("Severity").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.setItems(definitions); + grid.setSizeFull(); + grid.setSelectionMode(Grid.SelectionMode.NONE); + grid.setColumnReorderingAllowed(true); + grid.addThemeVariants(GridVariant.LUMO_NO_BORDER); + + addAndExpand(grid); + } + + private void openPenaltyDialog(String catalogId) { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("Add Penalty Definition"); + dialog.setWidth("600px"); + + FormLayout form = new FormLayout(); + form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 2)); + + TextField codeField = new TextField("Code"); + codeField.setRequired(true); + + TextField nameField = new TextField("Name"); + nameField.setRequired(true); + + TextField categoryField = new TextField("Category"); + + ComboBox sessionTypeCombo = new ComboBox<>("Session Type"); + sessionTypeCombo.setItems(PenaltySessionType.values()); + sessionTypeCombo.setItemLabelGenerator(PenaltySessionType::getDescription); + + TextField defaultPenaltyField = new TextField("Default Penalty"); + + IntegerField severityField = new IntegerField("Severity"); + severityField.setMin(0); + + IntegerField sortOrderField = new IntegerField("Sort Order"); + sortOrderField.setMin(0); + + form.add(codeField, nameField, categoryField, sessionTypeCombo, defaultPenaltyField, severityField, sortOrderField); + + Button saveButton = new Button("Save", e -> { + if (codeField.isEmpty() || nameField.isEmpty()) { + notificationService.showErrorNotification("Code and Name are required"); + return; + } + + PenaltyDefinition definition = PenaltyDefinition.builder() + .catalogId(catalogId) + .code(codeField.getValue()) + .name(nameField.getValue()) + .category(categoryField.getValue()) + .sessionType(sessionTypeCombo.getValue()) + .defaultPenalty(defaultPenaltyField.getValue()) + .severity(severityField.getValue()) + .sortOrder(sortOrderField.getValue()) + .build(); + + catalogService.createDefinition(definition); + dialog.close(); + notificationService.showSuccessNotification("Penalty definition added"); + grid.setItems(catalogService.getDefinitionsByCatalogId(catalogId)); + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button cancelButton = new Button("Cancel", e -> dialog.close()); + + dialog.add(form); + dialog.getFooter().add(cancelButton, saveButton); + dialog.open(); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/PenaltyCatalogListView.java b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/PenaltyCatalogListView.java new file mode 100644 index 00000000..719513cd --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/PenaltyCatalogListView.java @@ -0,0 +1,118 @@ +package de.sustineo.simdesk.views.stewarding; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.GridVariant; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.RouteParameters; +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.PenaltyCatalog; +import de.sustineo.simdesk.layouts.MainLayout; +import de.sustineo.simdesk.services.NotificationService; +import de.sustineo.simdesk.services.stewarding.PenaltyCatalogService; +import de.sustineo.simdesk.views.BaseView; +import jakarta.annotation.security.RolesAllowed; +import org.springframework.context.annotation.Profile; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Route(value = "/stewarding/catalogs", layout = MainLayout.class) +@RolesAllowed({"ADMIN", "STEWARD"}) +public class PenaltyCatalogListView extends BaseView { + private final PenaltyCatalogService catalogService; + private final NotificationService notificationService; + private Grid grid; + + public PenaltyCatalogListView(PenaltyCatalogService catalogService, NotificationService notificationService) { + this.catalogService = catalogService; + this.notificationService = notificationService; + } + + @Override + public String getPageTitle() { + return "Penalty Catalogs"; + } + + @Override + public void beforeEnter(BeforeEnterEvent event) { + setSizeFull(); + setPadding(false); + setSpacing(false); + removeAll(); + + HorizontalLayout headerLayout = new HorizontalLayout(); + headerLayout.setWidthFull(); + headerLayout.setAlignItems(Alignment.CENTER); + headerLayout.add(createViewHeader()); + + Button newButton = new Button("New Catalog", e -> openNewCatalogDialog()); + newButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + headerLayout.add(newButton); + + add(headerLayout); + + List catalogs = catalogService.getAllCatalogs(); + grid = new Grid<>(PenaltyCatalog.class, false); + grid.addColumn(PenaltyCatalog::getName).setHeader("Name").setSortable(true); + grid.addColumn(PenaltyCatalog::getDescription).setHeader("Description").setSortable(true); + grid.setItems(catalogs); + grid.setSizeFull(); + grid.setSelectionMode(Grid.SelectionMode.NONE); + grid.setColumnReorderingAllowed(true); + grid.addThemeVariants(GridVariant.LUMO_NO_BORDER); + grid.addItemClickListener(e -> + getUI().ifPresent(ui -> ui.navigate(PenaltyCatalogDetailView.class, + new RouteParameters("catalogId", String.valueOf(e.getItem().getId())))) + ); + + addAndExpand(grid); + } + + private void openNewCatalogDialog() { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("New Penalty Catalog"); + dialog.setWidth("600px"); + + FormLayout form = new FormLayout(); + form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1)); + + TextField nameField = new TextField("Name"); + nameField.setRequired(true); + nameField.setWidthFull(); + + TextField descriptionField = new TextField("Description"); + descriptionField.setWidthFull(); + + form.add(nameField, descriptionField); + + Button saveButton = new Button("Save", e -> { + if (nameField.isEmpty()) { + notificationService.showErrorNotification("Name is required"); + return; + } + + PenaltyCatalog catalog = PenaltyCatalog.builder() + .name(nameField.getValue()) + .description(descriptionField.getValue()) + .build(); + catalogService.createCatalog(catalog); + dialog.close(); + notificationService.showSuccessNotification("Catalog created"); + grid.setItems(catalogService.getAllCatalogs()); + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button cancelButton = new Button("Cancel", e -> dialog.close()); + + dialog.add(form); + dialog.getFooter().add(cancelButton, saveButton); + dialog.open(); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/ReasoningTemplateListView.java b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/ReasoningTemplateListView.java new file mode 100644 index 00000000..971a7a73 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/ReasoningTemplateListView.java @@ -0,0 +1,127 @@ +package de.sustineo.simdesk.views.stewarding; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.GridVariant; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.Route; +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.ReasoningTemplate; +import de.sustineo.simdesk.layouts.MainLayout; +import de.sustineo.simdesk.services.NotificationService; +import de.sustineo.simdesk.services.stewarding.ReasoningTemplateService; +import de.sustineo.simdesk.views.BaseView; +import jakarta.annotation.security.RolesAllowed; +import org.springframework.context.annotation.Profile; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Route(value = "/stewarding/templates", layout = MainLayout.class) +@RolesAllowed({"ADMIN", "STEWARD"}) +public class ReasoningTemplateListView extends BaseView { + private final ReasoningTemplateService templateService; + private final NotificationService notificationService; + private Grid grid; + + public ReasoningTemplateListView(ReasoningTemplateService templateService, NotificationService notificationService) { + this.templateService = templateService; + this.notificationService = notificationService; + } + + @Override + public String getPageTitle() { + return "Reasoning Templates"; + } + + @Override + public void beforeEnter(BeforeEnterEvent event) { + setSizeFull(); + setPadding(false); + setSpacing(false); + removeAll(); + + HorizontalLayout headerLayout = new HorizontalLayout(); + headerLayout.setWidthFull(); + headerLayout.setAlignItems(Alignment.CENTER); + headerLayout.add(createViewHeader()); + + Button newButton = new Button("New Template", e -> openTemplateDialog()); + newButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + headerLayout.add(newButton); + + add(headerLayout); + + List templates = templateService.getAllTemplates(); + grid = new Grid<>(ReasoningTemplate.class, false); + grid.addColumn(ReasoningTemplate::getName).setHeader("Name").setSortable(true); + grid.addColumn(ReasoningTemplate::getCategory).setHeader("Category").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.addColumn(template -> { + String text = template.getTemplateText(); + if (text != null && text.length() > 100) { + return text.substring(0, 100) + "..."; + } + return text != null ? text : "-"; + }).setHeader("Template Text").setAutoWidth(true); + grid.setItems(templates); + grid.setSizeFull(); + grid.setSelectionMode(Grid.SelectionMode.NONE); + grid.setColumnReorderingAllowed(true); + grid.addThemeVariants(GridVariant.LUMO_NO_BORDER); + + addAndExpand(grid); + } + + private void openTemplateDialog() { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("New Reasoning Template"); + dialog.setWidth("600px"); + + FormLayout form = new FormLayout(); + form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1)); + + TextField nameField = new TextField("Name"); + nameField.setRequired(true); + nameField.setWidthFull(); + + TextField categoryField = new TextField("Category"); + categoryField.setWidthFull(); + + TextArea templateTextField = new TextArea("Template Text"); + templateTextField.setWidthFull(); + templateTextField.setMinHeight("150px"); + + form.add(nameField, categoryField, templateTextField); + + Button saveButton = new Button("Save", e -> { + if (nameField.isEmpty()) { + notificationService.showErrorNotification("Name is required"); + return; + } + + ReasoningTemplate template = ReasoningTemplate.builder() + .name(nameField.getValue()) + .category(categoryField.getValue()) + .templateText(templateTextField.getValue()) + .build(); + + templateService.createTemplate(template); + dialog.close(); + notificationService.showSuccessNotification("Template created"); + grid.setItems(templateService.getAllTemplates()); + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button cancelButton = new Button("Cancel", e -> dialog.close()); + + dialog.add(form); + dialog.getFooter().add(cancelButton, saveButton); + dialog.open(); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/RoundDetailView.java b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/RoundDetailView.java new file mode 100644 index 00000000..45ad903d --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/RoundDetailView.java @@ -0,0 +1,519 @@ +package de.sustineo.simdesk.views.stewarding; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.combobox.MultiSelectComboBox; +import com.vaadin.flow.component.confirmdialog.ConfirmDialog; +import com.vaadin.flow.component.datepicker.DatePicker; +import com.vaadin.flow.component.datetimepicker.DateTimePicker; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.GridVariant; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.tabs.TabSheet; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.component.textfield.NumberField; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.component.upload.Upload; +import com.vaadin.flow.server.streams.UploadHandler; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.RouteParam; +import com.vaadin.flow.router.RouteParameters; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.auth.UserRoleEnum; +import de.sustineo.simdesk.entities.stewarding.*; +import de.sustineo.simdesk.layouts.MainLayout; +import de.sustineo.simdesk.services.NotificationService; +import de.sustineo.simdesk.services.auth.SecurityService; +import de.sustineo.simdesk.services.stewarding.RoundService; +import de.sustineo.simdesk.services.stewarding.SeriesService; +import de.sustineo.simdesk.services.stewarding.StewardingEntrylistService; +import de.sustineo.simdesk.services.stewarding.StewardingIncidentService; +import de.sustineo.simdesk.services.stewarding.StewardingTrackService; +import de.sustineo.simdesk.views.BaseView; +import org.springframework.context.annotation.Profile; + +import java.nio.charset.StandardCharsets; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Profile(SpringProfile.STEWARDING) +@Route(value = "/stewarding/series/:seriesId/rounds/:roundId", layout = MainLayout.class) +@AnonymousAllowed +public class RoundDetailView extends BaseView { + private final SeriesService seriesService; + private final RoundService roundService; + private final StewardingIncidentService incidentService; + private final StewardingEntrylistService entrylistService; + private final StewardingTrackService trackService; + private final SecurityService securityService; + private final NotificationService notificationService; + private String seriesId; + private String roundId; + + public RoundDetailView(SeriesService seriesService, RoundService roundService, + StewardingIncidentService incidentService, StewardingEntrylistService entrylistService, + StewardingTrackService trackService, SecurityService securityService, + NotificationService notificationService) { + this.seriesService = seriesService; + this.roundService = roundService; + this.incidentService = incidentService; + this.entrylistService = entrylistService; + this.trackService = trackService; + this.securityService = securityService; + this.notificationService = notificationService; + } + + @Override + public String getPageTitle() { + return "Round Details"; + } + + @Override + public void beforeEnter(BeforeEnterEvent event) { + setSizeFull(); + setPadding(false); + setSpacing(false); + removeAll(); + + String seriesIdParam = event.getRouteParameters().get("seriesId").orElse(null); + String roundIdParam = event.getRouteParameters().get("roundId").orElse(null); + if (seriesIdParam == null || roundIdParam == null) { + getUI().ifPresent(ui -> ui.navigate(SeriesListView.class)); + return; + } + + seriesId = seriesIdParam; + roundId = roundIdParam; + + Series series = seriesService.getSeriesById(seriesId); + Round round = roundService.getRoundById(roundId); + if (series == null || round == null) { + getUI().ifPresent(ui -> ui.navigate(SeriesListView.class)); + return; + } + + add(createViewHeader(round.getTitle())); + + Button backButton = new Button("← Back to " + series.getTitle(), e -> + getUI().ifPresent(ui -> ui.navigate(SeriesDetailView.class, + new RouteParameters("seriesId", String.valueOf(seriesId))))); + backButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + add(backButton); + + VerticalLayout infoLayout = new VerticalLayout(); + infoLayout.setPadding(true); + infoLayout.setSpacing(false); + if (round.getTrack() != null) { + infoLayout.add(createDetailRow("Track", round.getTrack().getName())); + } + if (round.getStartDate() != null && round.getEndDate() != null) { + infoLayout.add(createDetailRow("Date", round.getStartDate() + " — " + round.getEndDate())); + } + add(infoLayout); + + if (securityService.hasAnyAuthority(UserRoleEnum.ROLE_ADMIN, UserRoleEnum.ROLE_STEWARD)) { + HorizontalLayout actionLayout = new HorizontalLayout(); + Button editButton = new Button("Edit Round"); + editButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + editButton.addClickListener(e -> openEditRoundDialog(round, seriesId)); + actionLayout.add(editButton); + + Button deleteButton = new Button("Delete Round"); + deleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR); + deleteButton.addClickListener(e -> { + ConfirmDialog confirmDialog = new ConfirmDialog(); + confirmDialog.setHeader("Delete Round"); + confirmDialog.setText("Are you sure you want to delete this round?"); + confirmDialog.setCancelable(true); + confirmDialog.setConfirmText("Delete"); + confirmDialog.setConfirmButtonTheme("error primary"); + confirmDialog.addConfirmListener(ev -> { + roundService.deleteRound(roundId); + getUI().ifPresent(ui -> ui.navigate(SeriesDetailView.class, + new RouteParameters("seriesId", String.valueOf(seriesId)))); + }); + confirmDialog.open(); + }); + actionLayout.add(deleteButton); + add(actionLayout); + } + + TabSheet tabSheet = new TabSheet(); + tabSheet.setSizeFull(); + + tabSheet.add("Sessions", createSessionsTab(roundId)); + tabSheet.add("Incidents", createIncidentsTab(seriesId, roundId, series)); + tabSheet.add("Entrylist", createEntrylistTab(roundId)); + + addAndExpand(tabSheet); + } + + private HorizontalLayout createDetailRow(String label, String value) { + Span labelSpan = new Span(label + ": "); + labelSpan.getStyle().set("font-weight", "bold"); + Span valueSpan = new Span(value); + HorizontalLayout row = new HorizontalLayout(labelSpan, valueSpan); + row.setSpacing(false); + row.getStyle().set("gap", "var(--lumo-space-xs)"); + return row; + } + + private VerticalLayout createSessionsTab(String roundId) { + VerticalLayout layout = new VerticalLayout(); + layout.setSizeFull(); + + if (securityService.hasAnyAuthority(UserRoleEnum.ROLE_ADMIN, UserRoleEnum.ROLE_STEWARD)) { + Button addSessionButton = new Button("Add Session"); + addSessionButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + addSessionButton.addClickListener(e -> openAddSessionDialog(roundId)); + layout.add(addSessionButton); + } + + List sessions = roundService.getSessionsByRoundId(roundId); + Grid grid = new Grid<>(RoundSession.class, false); + grid.addColumn(session -> session.getSessionType() != null ? session.getSessionType().getDescription() : "-") + .setHeader("Type").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.addColumn(RoundSession::getTitle).setHeader("Title").setSortable(true); + grid.addColumn(RoundSession::getStartTime).setHeader("Start Time").setAutoWidth(true).setFlexGrow(0); + grid.addColumn(RoundSession::getEndTime).setHeader("End Time").setAutoWidth(true).setFlexGrow(0); + grid.setItems(sessions); + grid.setSizeFull(); + grid.setSelectionMode(Grid.SelectionMode.NONE); + grid.setColumnReorderingAllowed(true); + grid.addThemeVariants(GridVariant.LUMO_NO_BORDER); + + layout.addAndExpand(grid); + return layout; + } + + private void openAddSessionDialog(String roundId) { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("Add Session"); + dialog.setWidth("600px"); + + FormLayout form = new FormLayout(); + form.setResponsiveSteps( + new FormLayout.ResponsiveStep("0", 1), + new FormLayout.ResponsiveStep("600px", 2) + ); + + ComboBox typeCombo = new ComboBox<>("Session Type"); + typeCombo.setItems(StewSessionType.values()); + typeCombo.setItemLabelGenerator(StewSessionType::getDescription); + typeCombo.setRequired(true); + typeCombo.setWidthFull(); + + TextField titleField = new TextField("Title"); + titleField.setRequired(true); + titleField.setWidthFull(); + + DateTimePicker startTimePicker = new DateTimePicker("Start Time"); + startTimePicker.setWidthFull(); + + DateTimePicker endTimePicker = new DateTimePicker("End Time"); + endTimePicker.setWidthFull(); + + IntegerField sortOrderField = new IntegerField("Sort Order"); + sortOrderField.setMin(0); + sortOrderField.setValue(0); + sortOrderField.setWidthFull(); + + form.add(typeCombo, titleField, startTimePicker, endTimePicker, sortOrderField); + + Button saveButton = new Button("Save", e -> { + if (titleField.isEmpty() || typeCombo.isEmpty()) { + notificationService.showErrorNotification("Session type and title are required"); + return; + } + + RoundSession session = RoundSession.builder() + .roundId(roundId) + .sessionType(typeCombo.getValue()) + .title(titleField.getValue()) + .startTime(startTimePicker.getValue() != null ? startTimePicker.getValue().toInstant(ZoneOffset.UTC) : null) + .endTime(endTimePicker.getValue() != null ? endTimePicker.getValue().toInstant(ZoneOffset.UTC) : null) + .sortOrder(sortOrderField.getValue()) + .build(); + + roundService.createSession(session); + dialog.close(); + notificationService.showSuccessNotification("Session created"); + getUI().ifPresent(ui -> ui.navigate(RoundDetailView.class, + new RouteParameters(new RouteParam("seriesId", seriesId), new RouteParam("roundId", roundId)))); + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button cancelButton = new Button("Cancel", e -> dialog.close()); + + dialog.add(form); + dialog.getFooter().add(cancelButton, saveButton); + dialog.open(); + } + + private VerticalLayout createIncidentsTab(String seriesId, String roundId, Series series) { + VerticalLayout layout = new VerticalLayout(); + layout.setSizeFull(); + + if (securityService.hasAnyAuthority(UserRoleEnum.ROLE_ADMIN, UserRoleEnum.ROLE_STEWARD, UserRoleEnum.ROLE_DRIVER)) { + Button reportButton = new Button("Report Incident"); + reportButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + reportButton.addClickListener(e -> openReportIncidentDialog(seriesId, roundId, series)); + layout.add(reportButton); + } + + List sessions = roundService.getSessionsByRoundId(roundId); + List allIncidents = new ArrayList<>(); + for (RoundSession session : sessions) { + allIncidents.addAll(incidentService.getIncidentsBySessionId(session.getId())); + } + + Grid grid = new Grid<>(Incident.class, false); + grid.addColumn(incident -> { + RoundSession session = roundService.getSessionById(incident.getSessionId()); + return session != null ? session.getTitle() : "-"; + }).setHeader("Session").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.addColumn(Incident::getTitle).setHeader("Title").setSortable(true); + grid.addColumn(incident -> incident.getStatus() != null ? incident.getStatus().getDescription() : "-") + .setHeader("Status").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.addColumn(Incident::getCreatedAt).setHeader("Created").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.setItems(allIncidents); + grid.setSizeFull(); + grid.setSelectionMode(Grid.SelectionMode.NONE); + grid.setColumnReorderingAllowed(true); + grid.addThemeVariants(GridVariant.LUMO_NO_BORDER); + grid.addItemClickListener(e -> + getUI().ifPresent(ui -> ui.navigate(IncidentDetailView.class, + new RouteParameters( + new RouteParam("seriesId", String.valueOf(seriesId)), + new RouteParam("roundId", String.valueOf(roundId)), + new RouteParam("incidentId", String.valueOf(e.getItem().getId())) + ))) + ); + + layout.addAndExpand(grid); + return layout; + } + + private void openReportIncidentDialog(String seriesId, String roundId, Series series) { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("Report Incident"); + dialog.setWidth("600px"); + + FormLayout form = new FormLayout(); + form.setResponsiveSteps( + new FormLayout.ResponsiveStep("0", 1), + new FormLayout.ResponsiveStep("600px", 2) + ); + + List sessions = roundService.getSessionsByRoundId(roundId); + ComboBox sessionCombo = new ComboBox<>("Session"); + sessionCombo.setItems(sessions); + sessionCombo.setItemLabelGenerator(RoundSession::getTitle); + sessionCombo.setRequired(true); + sessionCombo.setWidthFull(); + + TextField titleField = new TextField("Title"); + titleField.setRequired(true); + titleField.setWidthFull(); + + TextArea descriptionField = new TextArea("Description"); + descriptionField.setWidthFull(); + descriptionField.setMinHeight("100px"); + + NumberField mapMarkerXField = new NumberField("Map Marker X"); + mapMarkerXField.setWidthFull(); + + NumberField mapMarkerYField = new NumberField("Map Marker Y"); + mapMarkerYField.setWidthFull(); + + MultiSelectComboBox involvedCarsCombo = new MultiSelectComboBox<>("Involved Cars"); + involvedCarsCombo.setWidthFull(); + StewardingEntrylist entrylist = entrylistService.getEntrylistByRoundId(roundId); + if (entrylist != null) { + List entries = entrylistService.getEntriesByEntrylistId(entrylist.getId()); + involvedCarsCombo.setItems(entries); + } + involvedCarsCombo.setItemLabelGenerator(StewardingEntrylistEntry::getDisplayName); + + form.add(sessionCombo, 2); + form.add(titleField, 2); + form.add(descriptionField, 2); + form.add(mapMarkerXField, mapMarkerYField); + form.add(involvedCarsCombo, 2); + + final TextField videoUrlField; + if (Boolean.TRUE.equals(series.getVideoUrlEnabled())) { + videoUrlField = new TextField("Video URL"); + videoUrlField.setWidthFull(); + form.add(videoUrlField, 2); + } else { + videoUrlField = null; + } + + Button saveButton = new Button("Report", e -> { + if (sessionCombo.isEmpty()) { + notificationService.showErrorNotification("Session is required"); + return; + } + if (titleField.isEmpty()) { + notificationService.showErrorNotification("Title is required"); + return; + } + + Set selectedEntries = involvedCarsCombo.getValue(); + String involvedCarsText = selectedEntries.stream() + .map(StewardingEntrylistEntry::getDisplayName) + .collect(Collectors.joining(", ")); + List involvedEntryIds = selectedEntries.stream() + .map(StewardingEntrylistEntry::getId) + .collect(Collectors.toList()); + + String videoUrl = videoUrlField != null ? videoUrlField.getValue() : null; + + Incident incident = Incident.builder() + .sessionId(sessionCombo.getValue().getId()) + .title(titleField.getValue()) + .description(descriptionField.getValue()) + .mapMarkerX(mapMarkerXField.getValue()) + .mapMarkerY(mapMarkerYField.getValue()) + .involvedCarsText(involvedCarsText) + .videoUrl(videoUrl) + .status(IncidentStatus.REPORTED) + .build(); + + incidentService.createIncident(incident, involvedEntryIds); + dialog.close(); + notificationService.showSuccessNotification("Incident reported"); + getUI().ifPresent(ui -> ui.navigate(RoundDetailView.class, + new RouteParameters(new RouteParam("seriesId", seriesId), new RouteParam("roundId", roundId)))); + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button cancelButton = new Button("Cancel", e -> dialog.close()); + + dialog.add(form); + dialog.getFooter().add(cancelButton, saveButton); + dialog.open(); + } + + private VerticalLayout createEntrylistTab(String roundId) { + VerticalLayout layout = new VerticalLayout(); + layout.setSizeFull(); + layout.setPadding(true); + layout.add(new H3("Entrylist")); + + if (securityService.hasAnyAuthority(UserRoleEnum.ROLE_ADMIN, UserRoleEnum.ROLE_STEWARD)) { + Upload upload = new Upload(); + upload.setUploadHandler(UploadHandler.inMemory((metadata, data) -> { + String json = new String(data, StandardCharsets.UTF_8); + try { + entrylistService.uploadEntrylistForRound(roundId, json); + getUI().ifPresent(ui -> ui.access(() -> { + notificationService.showSuccessNotification("Entrylist uploaded successfully"); + ui.navigate(RoundDetailView.class, + new RouteParameters(new RouteParam("seriesId", seriesId), new RouteParam("roundId", roundId))); + })); + } catch (IllegalArgumentException ex) { + getUI().ifPresent(ui -> ui.access(() -> + notificationService.showErrorNotification("Invalid entrylist JSON: " + ex.getMessage()) + )); + } + })); + upload.setAcceptedFileTypes("application/json", ".json"); + upload.setMaxFiles(1); + layout.add(upload); + } + + StewardingEntrylist entrylist = entrylistService.getEntrylistByRoundId(roundId); + if (entrylist != null) { + List entries = entrylistService.getEntriesByEntrylistId(entrylist.getId()); + Grid entrylistGrid = new Grid<>(StewardingEntrylistEntry.class, false); + entrylistGrid.addColumn(StewardingEntrylistEntry::getRaceNumber).setHeader("Race Number").setAutoWidth(true).setFlexGrow(0).setSortable(true); + entrylistGrid.addColumn(StewardingEntrylistEntry::getTeamName).setHeader("Team Name").setSortable(true); + entrylistGrid.addColumn(StewardingEntrylistEntry::getDisplayName).setHeader("Display Name").setSortable(true); + entrylistGrid.setItems(entries); + entrylistGrid.setSelectionMode(Grid.SelectionMode.NONE); + entrylistGrid.setColumnReorderingAllowed(true); + entrylistGrid.addThemeVariants(GridVariant.LUMO_NO_BORDER); + layout.addAndExpand(entrylistGrid); + } + + return layout; + } + + private void openEditRoundDialog(Round round, String seriesId) { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("Edit Round"); + dialog.setWidth("600px"); + + FormLayout form = new FormLayout(); + form.setResponsiveSteps( + new FormLayout.ResponsiveStep("0", 1), + new FormLayout.ResponsiveStep("600px", 2) + ); + + TextField titleField = new TextField("Title"); + titleField.setWidthFull(); + titleField.setRequired(true); + titleField.setValue(round.getTitle() != null ? round.getTitle() : ""); + + ComboBox trackCombo = new ComboBox<>("Track"); + List tracks = trackService.getAllTracks(); + trackCombo.setItems(tracks); + trackCombo.setItemLabelGenerator(StewardingTrack::getName); + trackCombo.setWidthFull(); + if (round.getTrack() != null) { + tracks.stream().filter(t -> t.getId().equals(round.getTrackId())).findFirst().ifPresent(trackCombo::setValue); + } + + DatePicker startDatePicker = new DatePicker("Start Date"); + startDatePicker.setWidthFull(); + startDatePicker.setValue(round.getStartDate()); + + DatePicker endDatePicker = new DatePicker("End Date"); + endDatePicker.setWidthFull(); + endDatePicker.setValue(round.getEndDate()); + + form.add(titleField, 2); + form.add(trackCombo, 2); + form.add(startDatePicker); + form.add(endDatePicker); + + Button saveButton = new Button("Save", e -> { + if (titleField.isEmpty()) { + notificationService.showErrorNotification("Title is required"); + return; + } + + round.setTitle(titleField.getValue()); + round.setTrackId(trackCombo.getValue() != null ? trackCombo.getValue().getId() : null); + round.setStartDate(startDatePicker.getValue()); + round.setEndDate(endDatePicker.getValue()); + + roundService.updateRound(round); + dialog.close(); + notificationService.showSuccessNotification("Round updated"); + getUI().ifPresent(ui -> ui.navigate(RoundDetailView.class, + new RouteParameters(new RouteParam("seriesId", seriesId), new RouteParam("roundId", roundId)))); + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button cancelButton = new Button("Cancel", e -> dialog.close()); + + dialog.add(form); + dialog.getFooter().add(cancelButton, saveButton); + dialog.open(); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/SeriesDetailView.java b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/SeriesDetailView.java new file mode 100644 index 00000000..16961a98 --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/SeriesDetailView.java @@ -0,0 +1,323 @@ +package de.sustineo.simdesk.views.stewarding; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.confirmdialog.ConfirmDialog; +import com.vaadin.flow.component.datepicker.DatePicker; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.GridVariant; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.RouteParam; +import com.vaadin.flow.router.RouteParameters; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.auth.UserRoleEnum; +import de.sustineo.simdesk.entities.stewarding.PenaltyCatalog; +import de.sustineo.simdesk.entities.stewarding.Round; +import de.sustineo.simdesk.entities.stewarding.Series; +import de.sustineo.simdesk.entities.stewarding.StewardingTrack; +import de.sustineo.simdesk.layouts.MainLayout; +import de.sustineo.simdesk.services.NotificationService; +import de.sustineo.simdesk.services.auth.SecurityService; +import de.sustineo.simdesk.services.stewarding.PenaltyCatalogService; +import de.sustineo.simdesk.services.stewarding.RoundService; +import de.sustineo.simdesk.services.stewarding.SeriesService; +import de.sustineo.simdesk.services.stewarding.StewardingTrackService; +import de.sustineo.simdesk.views.BaseView; +import org.springframework.context.annotation.Profile; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Route(value = "/stewarding/series/:seriesId", layout = MainLayout.class) +@AnonymousAllowed +public class SeriesDetailView extends BaseView { + private final SeriesService seriesService; + private final RoundService roundService; + private final StewardingTrackService trackService; + private final PenaltyCatalogService catalogService; + private final SecurityService securityService; + private final NotificationService notificationService; + private Grid roundsGrid; + private String seriesId; + + public SeriesDetailView(SeriesService seriesService, RoundService roundService, + StewardingTrackService trackService, PenaltyCatalogService catalogService, + SecurityService securityService, NotificationService notificationService) { + this.seriesService = seriesService; + this.roundService = roundService; + this.trackService = trackService; + this.catalogService = catalogService; + this.securityService = securityService; + this.notificationService = notificationService; + } + + @Override + public String getPageTitle() { + return "Series Details"; + } + + @Override + public void beforeEnter(BeforeEnterEvent event) { + setSizeFull(); + setPadding(false); + setSpacing(false); + removeAll(); + + String seriesIdParam = event.getRouteParameters().get("seriesId").orElse(null); + if (seriesIdParam == null) { + getUI().ifPresent(ui -> ui.navigate(SeriesListView.class)); + return; + } + + seriesId = seriesIdParam; + + Series series = seriesService.getSeriesById(seriesId); + if (series == null) { + getUI().ifPresent(ui -> ui.navigate(SeriesListView.class)); + return; + } + + add(createViewHeader(series.getTitle())); + + Button backButton = new Button("← Back to Series", e -> + getUI().ifPresent(ui -> ui.navigate(SeriesListView.class))); + backButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + add(backButton); + + VerticalLayout infoLayout = new VerticalLayout(); + infoLayout.setPadding(true); + infoLayout.setSpacing(false); + if (series.getDescription() != null && !series.getDescription().isEmpty()) { + infoLayout.add(new Span(series.getDescription())); + } + if (series.getPenaltyCatalog() != null) { + infoLayout.add(createDetailRow("Penalty Catalog", series.getPenaltyCatalog().getName())); + } + if (series.getStartDate() != null && series.getEndDate() != null) { + infoLayout.add(createDetailRow("Date", series.getStartDate() + " — " + series.getEndDate())); + } + infoLayout.add(createDetailRow("Video URL Enabled", Boolean.TRUE.equals(series.getVideoUrlEnabled()) ? "Yes" : "No")); + add(infoLayout); + + if (securityService.hasAnyAuthority(UserRoleEnum.ROLE_ADMIN, UserRoleEnum.ROLE_STEWARD)) { + HorizontalLayout actionLayout = new HorizontalLayout(); + Button editButton = new Button("Edit Series"); + editButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + editButton.addClickListener(e -> openEditSeriesDialog(series)); + actionLayout.add(editButton); + + Button deleteButton = new Button("Delete Series"); + deleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR); + deleteButton.addClickListener(e -> { + ConfirmDialog confirmDialog = new ConfirmDialog(); + confirmDialog.setHeader("Delete Series"); + confirmDialog.setText("Are you sure you want to delete this series?"); + confirmDialog.setCancelable(true); + confirmDialog.setConfirmText("Delete"); + confirmDialog.setConfirmButtonTheme("error primary"); + confirmDialog.addConfirmListener(ev -> { + seriesService.deleteSeries(seriesId); + getUI().ifPresent(ui -> ui.navigate(SeriesListView.class)); + }); + confirmDialog.open(); + }); + actionLayout.add(deleteButton); + add(actionLayout); + } + + // Rounds grid + HorizontalLayout roundsHeader = new HorizontalLayout(); + roundsHeader.setWidthFull(); + roundsHeader.setAlignItems(Alignment.CENTER); + + if (securityService.hasAnyAuthority(UserRoleEnum.ROLE_ADMIN, UserRoleEnum.ROLE_STEWARD)) { + Button newRoundButton = new Button("New Round", e -> openNewRoundDialog(seriesId)); + newRoundButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + roundsHeader.add(newRoundButton); + } + + add(roundsHeader); + + List rounds = roundService.getRoundsBySeriesId(seriesId); + roundsGrid = new Grid<>(Round.class, false); + roundsGrid.addColumn(Round::getTitle).setHeader("Title").setSortable(true); + roundsGrid.addColumn(round -> round.getTrack() != null ? round.getTrack().getName() : "-") + .setHeader("Track").setAutoWidth(true).setFlexGrow(0).setSortable(true); + roundsGrid.addColumn(Round::getStartDate).setHeader("Start Date").setAutoWidth(true).setFlexGrow(0).setSortable(true); + roundsGrid.addColumn(Round::getEndDate).setHeader("End Date").setAutoWidth(true).setFlexGrow(0).setSortable(true); + roundsGrid.setItems(rounds); + roundsGrid.setSizeFull(); + roundsGrid.setSelectionMode(Grid.SelectionMode.NONE); + roundsGrid.setColumnReorderingAllowed(true); + roundsGrid.addThemeVariants(GridVariant.LUMO_NO_BORDER); + roundsGrid.addItemClickListener(e -> + getUI().ifPresent(ui -> ui.navigate(RoundDetailView.class, + new RouteParameters( + new RouteParam("seriesId", String.valueOf(seriesId)), + new RouteParam("roundId", String.valueOf(e.getItem().getId())) + ))) + ); + + addAndExpand(roundsGrid); + } + + private HorizontalLayout createDetailRow(String label, String value) { + Span labelSpan = new Span(label + ": "); + labelSpan.getStyle().set("font-weight", "bold"); + Span valueSpan = new Span(value); + HorizontalLayout row = new HorizontalLayout(labelSpan, valueSpan); + row.setSpacing(false); + row.getStyle().set("gap", "var(--lumo-space-xs)"); + return row; + } + + private void openEditSeriesDialog(Series series) { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("Edit Series"); + dialog.setWidth("700px"); + + FormLayout form = new FormLayout(); + form.setResponsiveSteps( + new FormLayout.ResponsiveStep("0", 1), + new FormLayout.ResponsiveStep("600px", 2) + ); + + TextField titleField = new TextField("Title"); + titleField.setWidthFull(); + titleField.setRequired(true); + titleField.setValue(series.getTitle() != null ? series.getTitle() : ""); + + TextArea descriptionField = new TextArea("Description"); + descriptionField.setWidthFull(); + descriptionField.setValue(series.getDescription() != null ? series.getDescription() : ""); + + ComboBox catalogCombo = new ComboBox<>("Penalty Catalog"); + List catalogs = catalogService.getAllCatalogs(); + catalogCombo.setItems(catalogs); + catalogCombo.setItemLabelGenerator(PenaltyCatalog::getName); + catalogCombo.setWidthFull(); + if (series.getPenaltyCatalog() != null) { + catalogs.stream().filter(c -> c.getId().equals(series.getPenaltyCatalogId())).findFirst().ifPresent(catalogCombo::setValue); + } + + TextField webhookField = new TextField("Discord Webhook URL"); + webhookField.setWidthFull(); + webhookField.setValue(series.getDiscordWebhookUrl() != null ? series.getDiscordWebhookUrl() : ""); + + Checkbox videoUrlEnabledCheckbox = new Checkbox("Enable Video URL for incident reports"); + videoUrlEnabledCheckbox.setValue(Boolean.TRUE.equals(series.getVideoUrlEnabled())); + + DatePicker startDatePicker = new DatePicker("Start Date"); + startDatePicker.setWidthFull(); + startDatePicker.setValue(series.getStartDate()); + + DatePicker endDatePicker = new DatePicker("End Date"); + endDatePicker.setWidthFull(); + endDatePicker.setValue(series.getEndDate()); + + form.add(titleField, 2); + form.add(descriptionField, 2); + form.add(catalogCombo, 2); + form.add(webhookField, 2); + form.add(videoUrlEnabledCheckbox, 2); + form.add(startDatePicker); + form.add(endDatePicker); + + Button saveButton = new Button("Save", e -> { + if (titleField.isEmpty()) { + notificationService.showErrorNotification("Title is required"); + return; + } + + series.setTitle(titleField.getValue()); + series.setDescription(descriptionField.getValue()); + series.setPenaltyCatalogId(catalogCombo.getValue() != null ? catalogCombo.getValue().getId() : null); + series.setDiscordWebhookUrl(webhookField.getValue()); + series.setVideoUrlEnabled(videoUrlEnabledCheckbox.getValue()); + series.setStartDate(startDatePicker.getValue()); + series.setEndDate(endDatePicker.getValue()); + + seriesService.updateSeries(series); + dialog.close(); + notificationService.showSuccessNotification("Series updated"); + getUI().ifPresent(ui -> ui.navigate(SeriesDetailView.class, new RouteParameters("seriesId", seriesId))); + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button cancelButton = new Button("Cancel", e -> dialog.close()); + + dialog.add(form); + dialog.getFooter().add(cancelButton, saveButton); + dialog.open(); + } + + private void openNewRoundDialog(String seriesId) { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("New Round"); + dialog.setWidth("600px"); + + FormLayout form = new FormLayout(); + form.setResponsiveSteps( + new FormLayout.ResponsiveStep("0", 1), + new FormLayout.ResponsiveStep("600px", 2) + ); + + TextField titleField = new TextField("Title"); + titleField.setWidthFull(); + titleField.setRequired(true); + + ComboBox trackCombo = new ComboBox<>("Track"); + trackCombo.setItems(trackService.getAllTracks()); + trackCombo.setItemLabelGenerator(StewardingTrack::getName); + trackCombo.setWidthFull(); + + DatePicker startDatePicker = new DatePicker("Start Date"); + startDatePicker.setWidthFull(); + + DatePicker endDatePicker = new DatePicker("End Date"); + endDatePicker.setWidthFull(); + + form.add(titleField, 2); + form.add(trackCombo, 2); + form.add(startDatePicker); + form.add(endDatePicker); + + Button saveButton = new Button("Save", e -> { + if (titleField.isEmpty()) { + notificationService.showErrorNotification("Title is required"); + return; + } + + Round round = Round.builder() + .seriesId(seriesId) + .title(titleField.getValue()) + .trackId(trackCombo.getValue() != null ? trackCombo.getValue().getId() : null) + .startDate(startDatePicker.getValue()) + .endDate(endDatePicker.getValue()) + .build(); + + roundService.createRound(round); + dialog.close(); + notificationService.showSuccessNotification("Round created"); + roundsGrid.setItems(roundService.getRoundsBySeriesId(seriesId)); + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button cancelButton = new Button("Cancel", e -> dialog.close()); + + dialog.add(form); + dialog.getFooter().add(cancelButton, saveButton); + dialog.open(); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/SeriesListView.java b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/SeriesListView.java new file mode 100644 index 00000000..9c9362ff --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/SeriesListView.java @@ -0,0 +1,165 @@ +package de.sustineo.simdesk.views.stewarding; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.datepicker.DatePicker; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.GridVariant; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.RouteParameters; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.auth.UserRoleEnum; +import de.sustineo.simdesk.entities.stewarding.PenaltyCatalog; +import de.sustineo.simdesk.entities.stewarding.Series; +import de.sustineo.simdesk.layouts.MainLayout; +import de.sustineo.simdesk.services.NotificationService; +import de.sustineo.simdesk.services.auth.SecurityService; +import de.sustineo.simdesk.services.stewarding.PenaltyCatalogService; +import de.sustineo.simdesk.services.stewarding.SeriesService; +import de.sustineo.simdesk.views.BaseView; +import org.springframework.context.annotation.Profile; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Route(value = "/stewarding/series", layout = MainLayout.class) +@AnonymousAllowed +public class SeriesListView extends BaseView { + private final SeriesService seriesService; + private final PenaltyCatalogService catalogService; + private final SecurityService securityService; + private final NotificationService notificationService; + private Grid grid; + + public SeriesListView(SeriesService seriesService, PenaltyCatalogService catalogService, + SecurityService securityService, NotificationService notificationService) { + this.seriesService = seriesService; + this.catalogService = catalogService; + this.securityService = securityService; + this.notificationService = notificationService; + } + + @Override + public String getPageTitle() { + return "Series"; + } + + @Override + public void beforeEnter(BeforeEnterEvent event) { + setSizeFull(); + setPadding(false); + setSpacing(false); + removeAll(); + + HorizontalLayout headerLayout = new HorizontalLayout(); + headerLayout.setWidthFull(); + headerLayout.setAlignItems(Alignment.CENTER); + headerLayout.add(createViewHeader()); + + if (securityService.hasAnyAuthority(UserRoleEnum.ROLE_ADMIN, UserRoleEnum.ROLE_STEWARD)) { + Button newButton = new Button("New Series", e -> openNewSeriesDialog()); + newButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + headerLayout.add(newButton); + } + + add(headerLayout); + + List seriesList = seriesService.getAllSeries(); + grid = new Grid<>(Series.class, false); + grid.addColumn(Series::getTitle).setHeader("Title").setSortable(true); + grid.addColumn(Series::getStartDate).setHeader("Start Date").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.addColumn(Series::getEndDate).setHeader("End Date").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.setItems(seriesList); + grid.setSizeFull(); + grid.setSelectionMode(Grid.SelectionMode.NONE); + grid.setColumnReorderingAllowed(true); + grid.addThemeVariants(GridVariant.LUMO_NO_BORDER); + grid.addItemClickListener(e -> + getUI().ifPresent(ui -> ui.navigate(SeriesDetailView.class, + new RouteParameters("seriesId", String.valueOf(e.getItem().getId())))) + ); + + addAndExpand(grid); + } + + private void openNewSeriesDialog() { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("New Series"); + dialog.setWidth("700px"); + + FormLayout form = new FormLayout(); + form.setResponsiveSteps( + new FormLayout.ResponsiveStep("0", 1), + new FormLayout.ResponsiveStep("600px", 2) + ); + + TextField titleField = new TextField("Title"); + titleField.setWidthFull(); + titleField.setRequired(true); + + TextArea descriptionField = new TextArea("Description"); + descriptionField.setWidthFull(); + + ComboBox catalogCombo = new ComboBox<>("Penalty Catalog"); + catalogCombo.setItems(catalogService.getAllCatalogs()); + catalogCombo.setItemLabelGenerator(PenaltyCatalog::getName); + catalogCombo.setWidthFull(); + + TextField webhookField = new TextField("Discord Webhook URL"); + webhookField.setWidthFull(); + + Checkbox videoUrlEnabledCheckbox = new Checkbox("Enable Video URL for incident reports"); + + DatePicker startDatePicker = new DatePicker("Start Date"); + startDatePicker.setWidthFull(); + + DatePicker endDatePicker = new DatePicker("End Date"); + endDatePicker.setWidthFull(); + + form.add(titleField, 2); + form.add(descriptionField, 2); + form.add(catalogCombo, 2); + form.add(webhookField, 2); + form.add(videoUrlEnabledCheckbox, 2); + form.add(startDatePicker); + form.add(endDatePicker); + + Button saveButton = new Button("Save", e -> { + if (titleField.isEmpty()) { + notificationService.showErrorNotification("Title is required"); + return; + } + + Series series = Series.builder() + .title(titleField.getValue()) + .description(descriptionField.getValue()) + .penaltyCatalogId(catalogCombo.getValue() != null ? catalogCombo.getValue().getId() : null) + .discordWebhookUrl(webhookField.getValue()) + .videoUrlEnabled(videoUrlEnabledCheckbox.getValue()) + .startDate(startDatePicker.getValue()) + .endDate(endDatePicker.getValue()) + .build(); + + seriesService.createSeries(series); + dialog.close(); + notificationService.showSuccessNotification("Series created"); + grid.setItems(seriesService.getAllSeries()); + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button cancelButton = new Button("Cancel", e -> dialog.close()); + + dialog.add(form); + dialog.getFooter().add(cancelButton, saveButton); + dialog.open(); + } +} diff --git a/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/StewardingTrackListView.java b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/StewardingTrackListView.java new file mode 100644 index 00000000..79d4b5cc --- /dev/null +++ b/simdesk-web/src/main/java/de/sustineo/simdesk/views/stewarding/StewardingTrackListView.java @@ -0,0 +1,121 @@ +package de.sustineo.simdesk.views.stewarding; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.GridVariant; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.Route; +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.StewardingTrack; +import de.sustineo.simdesk.layouts.MainLayout; +import de.sustineo.simdesk.services.NotificationService; +import de.sustineo.simdesk.services.stewarding.StewardingTrackService; +import de.sustineo.simdesk.views.BaseView; +import jakarta.annotation.security.RolesAllowed; +import org.springframework.context.annotation.Profile; + +import java.util.List; + +@Profile(SpringProfile.STEWARDING) +@Route(value = "/stewarding/tracks", layout = MainLayout.class) +@RolesAllowed({"ADMIN", "STEWARD"}) +public class StewardingTrackListView extends BaseView { + private final StewardingTrackService trackService; + private final NotificationService notificationService; + private Grid grid; + + public StewardingTrackListView(StewardingTrackService trackService, NotificationService notificationService) { + this.trackService = trackService; + this.notificationService = notificationService; + } + + @Override + public String getPageTitle() { + return "Stewarding Tracks"; + } + + @Override + public void beforeEnter(BeforeEnterEvent event) { + setSizeFull(); + setPadding(false); + setSpacing(false); + removeAll(); + + HorizontalLayout headerLayout = new HorizontalLayout(); + headerLayout.setWidthFull(); + headerLayout.setAlignItems(Alignment.CENTER); + headerLayout.add(createViewHeader()); + + Button newButton = new Button("New Track", e -> openNewTrackDialog()); + newButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + headerLayout.add(newButton); + + add(headerLayout); + + List tracks = trackService.getAllTracks(); + grid = new Grid<>(StewardingTrack.class, false); + grid.addColumn(StewardingTrack::getName).setHeader("Name").setSortable(true); + grid.addColumn(StewardingTrack::getCountry).setHeader("Country").setAutoWidth(true).setFlexGrow(0).setSortable(true); + grid.setItems(tracks); + grid.setSizeFull(); + grid.setSelectionMode(Grid.SelectionMode.NONE); + grid.setColumnReorderingAllowed(true); + grid.addThemeVariants(GridVariant.LUMO_NO_BORDER); + + addAndExpand(grid); + } + + private void openNewTrackDialog() { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("New Track"); + dialog.setWidth("600px"); + + FormLayout form = new FormLayout(); + form.setResponsiveSteps( + new FormLayout.ResponsiveStep("0", 1), + new FormLayout.ResponsiveStep("600px", 2) + ); + + TextField nameField = new TextField("Name"); + nameField.setRequired(true); + nameField.setWidthFull(); + + TextField countryField = new TextField("Country"); + countryField.setWidthFull(); + + TextField mapImageUrlField = new TextField("Map Image URL"); + mapImageUrlField.setWidthFull(); + + form.add(nameField, countryField, mapImageUrlField); + + Button saveButton = new Button("Save", e -> { + if (nameField.isEmpty()) { + notificationService.showErrorNotification("Name is required"); + return; + } + + StewardingTrack track = StewardingTrack.builder() + .name(nameField.getValue()) + .country(countryField.getValue()) + .mapImageUrl(mapImageUrlField.getValue()) + .build(); + + trackService.createTrack(track); + dialog.close(); + notificationService.showSuccessNotification("Track created"); + grid.setItems(trackService.getAllTracks()); + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button cancelButton = new Button("Cancel", e -> dialog.close()); + + dialog.add(form); + dialog.getFooter().add(cancelButton, saveButton); + dialog.open(); + } +} diff --git a/simdesk-web/src/main/resources/db/migration/postgres/V2_11_0__stewarding.sql b/simdesk-web/src/main/resources/db/migration/postgres/V2_11_0__stewarding.sql new file mode 100644 index 00000000..1360f4f6 --- /dev/null +++ b/simdesk-web/src/main/resources/db/migration/postgres/V2_11_0__stewarding.sql @@ -0,0 +1,218 @@ +-- Stewarding module: tracks, penalty catalogs, definitions, reasoning templates, +-- series, rounds, sessions, entrylist, incidents, decisions, appeals, roles. + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_track +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + name VARCHAR(255) NOT NULL, + country VARCHAR(100), + map_image_url VARCHAR(500), + map_metadata TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_penalty_catalog +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_penalty_definition +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + catalog_id VARCHAR(12) NOT NULL, + code VARCHAR(50), + name VARCHAR(255) NOT NULL, + description TEXT, + category VARCHAR(100), + session_type VARCHAR(20) NOT NULL DEFAULT 'ALL', + default_penalty VARCHAR(255), + severity INTEGER, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (catalog_id) REFERENCES simdesk.stewarding_penalty_catalog (id) +); + +CREATE INDEX ix_stewarding_penalty_definition_catalog_id ON simdesk.stewarding_penalty_definition (catalog_id); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_reasoning_template +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + name VARCHAR(255) NOT NULL, + category VARCHAR(100), + template_text TEXT NOT NULL, + sort_order INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_series +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + discord_webhook_url VARCHAR(500), + video_url_enabled BOOLEAN NOT NULL DEFAULT FALSE, + penalty_catalog_id VARCHAR(12), + start_date DATE, + end_date DATE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (penalty_catalog_id) REFERENCES simdesk.stewarding_penalty_catalog (id) +); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_round +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + series_id VARCHAR(12), + track_id VARCHAR(12), + title VARCHAR(255) NOT NULL, + start_date DATE, + end_date DATE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (series_id) REFERENCES simdesk.stewarding_series (id), + FOREIGN KEY (track_id) REFERENCES simdesk.stewarding_track (id) +); + +CREATE INDEX ix_stewarding_round_series_id ON simdesk.stewarding_round (series_id); +CREATE INDEX ix_stewarding_round_track_id ON simdesk.stewarding_round (track_id); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_session +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + round_id VARCHAR(12) NOT NULL, + session_type VARCHAR(20) NOT NULL, + title VARCHAR(255), + start_time TIMESTAMP, + end_time TIMESTAMP, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (round_id) REFERENCES simdesk.stewarding_round (id) +); + +CREATE INDEX ix_stewarding_session_round_id ON simdesk.stewarding_session (round_id); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_entrylist +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + round_id VARCHAR(12), + uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + raw_json TEXT, + FOREIGN KEY (round_id) REFERENCES simdesk.stewarding_round (id) +); + +CREATE INDEX ix_stewarding_entrylist_round_id ON simdesk.stewarding_entrylist (round_id); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_entrylist_entry +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + entrylist_id VARCHAR(12) NOT NULL, + race_number INTEGER NOT NULL, + car_model_id INTEGER, + team_name VARCHAR(255), + display_name VARCHAR(255), + FOREIGN KEY (entrylist_id) REFERENCES simdesk.stewarding_entrylist (id) ON DELETE CASCADE +); + +CREATE INDEX ix_stewarding_entrylist_entry_entrylist_id ON simdesk.stewarding_entrylist_entry (entrylist_id); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_entrylist_driver +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + entry_id VARCHAR(12) NOT NULL, + first_name VARCHAR(100), + last_name VARCHAR(100), + short_name VARCHAR(10), + steam_id VARCHAR(50), + category INTEGER, + FOREIGN KEY (entry_id) REFERENCES simdesk.stewarding_entrylist_entry (id) ON DELETE CASCADE +); + +CREATE INDEX ix_stewarding_entrylist_driver_entry_id ON simdesk.stewarding_entrylist_driver (entry_id); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_incident +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + session_id VARCHAR(12) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + lap INTEGER, + timestamp_in_session VARCHAR(100), + map_marker_x DOUBLE PRECISION, + map_marker_y DOUBLE PRECISION, + video_url VARCHAR(500), + involved_cars_text VARCHAR(500), + status VARCHAR(30) NOT NULL DEFAULT 'REPORTED', + reported_by_user_id INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES simdesk.stewarding_session (id) +); + +CREATE INDEX ix_stewarding_incident_session_id ON simdesk.stewarding_incident (session_id); +CREATE INDEX ix_stewarding_incident_status ON simdesk.stewarding_incident (status); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_incident_involved_entry +( + incident_id VARCHAR(12) NOT NULL, + entry_id VARCHAR(12) NOT NULL, + PRIMARY KEY (incident_id, entry_id), + FOREIGN KEY (incident_id) REFERENCES simdesk.stewarding_incident (id) ON DELETE CASCADE, + FOREIGN KEY (entry_id) REFERENCES simdesk.stewarding_entrylist_entry (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_decision +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + incident_id VARCHAR(12), + session_id VARCHAR(12) NOT NULL, + decided_by_user_id INTEGER, + penalty_definition_id VARCHAR(12), + custom_penalty VARCHAR(255), + reasoning TEXT, + reasoning_template_id VARCHAR(12), + is_no_action BOOLEAN NOT NULL DEFAULT FALSE, + penalized_entry_id VARCHAR(12), + penalized_car_text VARCHAR(255), + decided_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_by_id VARCHAR(12), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + FOREIGN KEY (incident_id) REFERENCES simdesk.stewarding_incident (id), + FOREIGN KEY (session_id) REFERENCES simdesk.stewarding_session (id), + FOREIGN KEY (penalty_definition_id) REFERENCES simdesk.stewarding_penalty_definition (id), + FOREIGN KEY (reasoning_template_id) REFERENCES simdesk.stewarding_reasoning_template (id), + FOREIGN KEY (penalized_entry_id) REFERENCES simdesk.stewarding_entrylist_entry (id), + FOREIGN KEY (superseded_by_id) REFERENCES simdesk.stewarding_decision (id) +); + +CREATE INDEX ix_stewarding_decision_incident_id ON simdesk.stewarding_decision (incident_id); +CREATE INDEX ix_stewarding_decision_session_id ON simdesk.stewarding_decision (session_id); +CREATE INDEX ix_stewarding_decision_is_active ON simdesk.stewarding_decision (is_active); + +CREATE TABLE IF NOT EXISTS simdesk.stewarding_appeal +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + decision_id VARCHAR(12) NOT NULL, + filed_by_user_id INTEGER, + filed_by_entry_id VARCHAR(12), + reason TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + response TEXT, + responded_by_user_id INTEGER, + filed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + responded_at TIMESTAMP, + FOREIGN KEY (decision_id) REFERENCES simdesk.stewarding_decision (id), + FOREIGN KEY (filed_by_entry_id) REFERENCES simdesk.stewarding_entrylist_entry (id) +); + +CREATE INDEX ix_stewarding_appeal_decision_id ON simdesk.stewarding_appeal (decision_id); +CREATE INDEX ix_stewarding_appeal_status ON simdesk.stewarding_appeal (status); + +-- Stewarding roles +INSERT INTO simdesk.user_role (name, description) +VALUES ('ROLE_STEWARD', 'Role with stewarding access to manage incidents, decisions and penalties'); + +INSERT INTO simdesk.user_role (name, description) +VALUES ('ROLE_DRIVER', 'Default role for all users with access to view decisions and file appeals'); diff --git a/simdesk-web/src/main/resources/db/migration/sqlite/V2_11_0__stewarding.sql b/simdesk-web/src/main/resources/db/migration/sqlite/V2_11_0__stewarding.sql new file mode 100644 index 00000000..2f795077 --- /dev/null +++ b/simdesk-web/src/main/resources/db/migration/sqlite/V2_11_0__stewarding.sql @@ -0,0 +1,218 @@ +-- Stewarding module: tracks, penalty catalogs, definitions, reasoning templates, +-- series, rounds, sessions, entrylist, incidents, decisions, appeals, roles. + +CREATE TABLE IF NOT EXISTS stewarding_track +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + name VARCHAR(255) NOT NULL, + country VARCHAR(100), + map_image_url VARCHAR(500), + map_metadata TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS stewarding_penalty_catalog +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS stewarding_penalty_definition +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + catalog_id VARCHAR(12) NOT NULL, + code VARCHAR(50), + name VARCHAR(255) NOT NULL, + description TEXT, + category VARCHAR(100), + session_type VARCHAR(20) NOT NULL DEFAULT 'ALL', + default_penalty VARCHAR(255), + severity INTEGER, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (catalog_id) REFERENCES stewarding_penalty_catalog (id) +); + +CREATE INDEX ix_stewarding_penalty_definition_catalog_id ON stewarding_penalty_definition (catalog_id); + +CREATE TABLE IF NOT EXISTS stewarding_reasoning_template +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + name VARCHAR(255) NOT NULL, + category VARCHAR(100), + template_text TEXT NOT NULL, + sort_order INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS stewarding_series +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + discord_webhook_url VARCHAR(500), + video_url_enabled BOOLEAN NOT NULL DEFAULT FALSE, + penalty_catalog_id VARCHAR(12), + start_date DATE, + end_date DATE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (penalty_catalog_id) REFERENCES stewarding_penalty_catalog (id) +); + +CREATE TABLE IF NOT EXISTS stewarding_round +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + series_id VARCHAR(12), + track_id VARCHAR(12), + title VARCHAR(255) NOT NULL, + start_date DATE, + end_date DATE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (series_id) REFERENCES stewarding_series (id), + FOREIGN KEY (track_id) REFERENCES stewarding_track (id) +); + +CREATE INDEX ix_stewarding_round_series_id ON stewarding_round (series_id); +CREATE INDEX ix_stewarding_round_track_id ON stewarding_round (track_id); + +CREATE TABLE IF NOT EXISTS stewarding_session +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + round_id VARCHAR(12) NOT NULL, + session_type VARCHAR(20) NOT NULL, + title VARCHAR(255), + start_time TIMESTAMP, + end_time TIMESTAMP, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (round_id) REFERENCES stewarding_round (id) +); + +CREATE INDEX ix_stewarding_session_round_id ON stewarding_session (round_id); + +CREATE TABLE IF NOT EXISTS stewarding_entrylist +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + round_id VARCHAR(12), + uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + raw_json TEXT, + FOREIGN KEY (round_id) REFERENCES stewarding_round (id) +); + +CREATE INDEX ix_stewarding_entrylist_round_id ON stewarding_entrylist (round_id); + +CREATE TABLE IF NOT EXISTS stewarding_entrylist_entry +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + entrylist_id VARCHAR(12) NOT NULL, + race_number INTEGER NOT NULL, + car_model_id INTEGER, + team_name VARCHAR(255), + display_name VARCHAR(255), + FOREIGN KEY (entrylist_id) REFERENCES stewarding_entrylist (id) ON DELETE CASCADE +); + +CREATE INDEX ix_stewarding_entrylist_entry_entrylist_id ON stewarding_entrylist_entry (entrylist_id); + +CREATE TABLE IF NOT EXISTS stewarding_entrylist_driver +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + entry_id VARCHAR(12) NOT NULL, + first_name VARCHAR(100), + last_name VARCHAR(100), + short_name VARCHAR(10), + steam_id VARCHAR(50), + category INTEGER, + FOREIGN KEY (entry_id) REFERENCES stewarding_entrylist_entry (id) ON DELETE CASCADE +); + +CREATE INDEX ix_stewarding_entrylist_driver_entry_id ON stewarding_entrylist_driver (entry_id); + +CREATE TABLE IF NOT EXISTS stewarding_incident +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + session_id VARCHAR(12) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + lap INTEGER, + timestamp_in_session VARCHAR(100), + map_marker_x REAL, + map_marker_y REAL, + video_url VARCHAR(500), + involved_cars_text VARCHAR(500), + status VARCHAR(30) NOT NULL DEFAULT 'REPORTED', + reported_by_user_id INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES stewarding_session (id) +); + +CREATE INDEX ix_stewarding_incident_session_id ON stewarding_incident (session_id); +CREATE INDEX ix_stewarding_incident_status ON stewarding_incident (status); + +CREATE TABLE IF NOT EXISTS stewarding_incident_involved_entry +( + incident_id VARCHAR(12) NOT NULL, + entry_id VARCHAR(12) NOT NULL, + PRIMARY KEY (incident_id, entry_id), + FOREIGN KEY (incident_id) REFERENCES stewarding_incident (id) ON DELETE CASCADE, + FOREIGN KEY (entry_id) REFERENCES stewarding_entrylist_entry (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS stewarding_decision +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + incident_id VARCHAR(12), + session_id VARCHAR(12) NOT NULL, + decided_by_user_id INTEGER, + penalty_definition_id VARCHAR(12), + custom_penalty VARCHAR(255), + reasoning TEXT, + reasoning_template_id VARCHAR(12), + is_no_action BOOLEAN NOT NULL DEFAULT FALSE, + penalized_entry_id VARCHAR(12), + penalized_car_text VARCHAR(255), + decided_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_by_id VARCHAR(12), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + FOREIGN KEY (incident_id) REFERENCES stewarding_incident (id), + FOREIGN KEY (session_id) REFERENCES stewarding_session (id), + FOREIGN KEY (penalty_definition_id) REFERENCES stewarding_penalty_definition (id), + FOREIGN KEY (reasoning_template_id) REFERENCES stewarding_reasoning_template (id), + FOREIGN KEY (penalized_entry_id) REFERENCES stewarding_entrylist_entry (id), + FOREIGN KEY (superseded_by_id) REFERENCES stewarding_decision (id) +); + +CREATE INDEX ix_stewarding_decision_incident_id ON stewarding_decision (incident_id); +CREATE INDEX ix_stewarding_decision_session_id ON stewarding_decision (session_id); +CREATE INDEX ix_stewarding_decision_is_active ON stewarding_decision (is_active); + +CREATE TABLE IF NOT EXISTS stewarding_appeal +( + id VARCHAR(12) PRIMARY KEY NOT NULL, + decision_id VARCHAR(12) NOT NULL, + filed_by_user_id INTEGER, + filed_by_entry_id VARCHAR(12), + reason TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + response TEXT, + responded_by_user_id INTEGER, + filed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + responded_at TIMESTAMP, + FOREIGN KEY (decision_id) REFERENCES stewarding_decision (id), + FOREIGN KEY (filed_by_entry_id) REFERENCES stewarding_entrylist_entry (id) +); + +CREATE INDEX ix_stewarding_appeal_decision_id ON stewarding_appeal (decision_id); +CREATE INDEX ix_stewarding_appeal_status ON stewarding_appeal (status); + +-- Stewarding roles +INSERT INTO user_role (name, description) +VALUES ('ROLE_STEWARD', 'Role with stewarding access to manage incidents, decisions and penalties'); + +INSERT INTO user_role (name, description) +VALUES ('ROLE_DRIVER', 'Default role for all users with access to view decisions and file appeals'); diff --git a/simdesk-web/src/test/java/de/sustineo/simdesk/services/stewarding/PenaltyCatalogServiceTest.java b/simdesk-web/src/test/java/de/sustineo/simdesk/services/stewarding/PenaltyCatalogServiceTest.java new file mode 100644 index 00000000..598984e7 --- /dev/null +++ b/simdesk-web/src/test/java/de/sustineo/simdesk/services/stewarding/PenaltyCatalogServiceTest.java @@ -0,0 +1,75 @@ +package de.sustineo.simdesk.services.stewarding; + +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.PenaltyCatalog; +import de.sustineo.simdesk.entities.stewarding.PenaltyDefinition; +import de.sustineo.simdesk.entities.stewarding.PenaltySessionType; +import de.sustineo.simdesk.mybatis.mapper.PenaltyCatalogMapper; +import de.sustineo.simdesk.mybatis.mapper.PenaltyDefinitionMapper; +import de.sustineo.simdesk.services.IdGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ActiveProfiles({SpringProfile.STEWARDING}) +@SpringBootTest(classes = { + PenaltyCatalogService.class, + SpringProfile.class +}) +class PenaltyCatalogServiceTest { + + @Autowired + private PenaltyCatalogService penaltyCatalogService; + + @MockitoBean + private PenaltyCatalogMapper penaltyCatalogMapper; + + @MockitoBean + private PenaltyDefinitionMapper penaltyDefinitionMapper; + + @MockitoBean + private IdGenerator idGenerator; + + @Test + void getDefinitionsForSessionType_shouldReturnMatchingDefinitions() { + PenaltyDefinition racePenalty = PenaltyDefinition.builder() + .id("def123456789") + .catalogId("cat123456789") + .code("PEN-001") + .name("Causing a collision") + .sessionType(PenaltySessionType.RACE) + .defaultPenalty("5 second time penalty") + .build(); + + when(penaltyDefinitionMapper.findByCatalogIdAndSessionType("cat123456789", "RACE")) + .thenReturn(List.of(racePenalty)); + + List result = penaltyCatalogService.getDefinitionsForSessionType("cat123456789", "RACE"); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().getName()).isEqualTo("Causing a collision"); + } + + @Test + void getAllCatalogs_shouldReturnAll() { + PenaltyCatalog catalog = PenaltyCatalog.builder() + .id("cat123456789") + .name("2025 Season Rules") + .description("Standard penalty rules for 2025") + .build(); + + when(penaltyCatalogMapper.findAll()).thenReturn(List.of(catalog)); + + List result = penaltyCatalogService.getAllCatalogs(); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().getName()).isEqualTo("2025 Season Rules"); + } +} diff --git a/simdesk-web/src/test/java/de/sustineo/simdesk/services/stewarding/ReasoningTemplateServiceTest.java b/simdesk-web/src/test/java/de/sustineo/simdesk/services/stewarding/ReasoningTemplateServiceTest.java new file mode 100644 index 00000000..02766869 --- /dev/null +++ b/simdesk-web/src/test/java/de/sustineo/simdesk/services/stewarding/ReasoningTemplateServiceTest.java @@ -0,0 +1,104 @@ +package de.sustineo.simdesk.services.stewarding; + +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.ReasoningTemplate; +import de.sustineo.simdesk.mybatis.mapper.ReasoningTemplateMapper; +import de.sustineo.simdesk.services.IdGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ActiveProfiles({SpringProfile.STEWARDING}) +@SpringBootTest(classes = { + ReasoningTemplateService.class, + SpringProfile.class +}) +class ReasoningTemplateServiceTest { + + @Autowired + private ReasoningTemplateService reasoningTemplateService; + + @MockitoBean + private ReasoningTemplateMapper reasoningTemplateMapper; + + @MockitoBean + private IdGenerator idGenerator; + + @Test + void renderTemplate_shouldReplacePlaceholders() { + String template = "Car {car_a} made contact with Car {car_b} at {location}."; + Map variables = Map.of( + "car_a", "#001", + "car_b", "#042", + "location", "Turn 5" + ); + + String result = reasoningTemplateService.renderTemplate(template, variables); + + assertThat(result).isEqualTo("Car #001 made contact with Car #042 at Turn 5."); + } + + @Test + void renderTemplate_shouldKeepUnmatchedPlaceholders() { + String template = "Car {car_a} was penalized for {reason}."; + Map variables = Map.of("car_a", "#001"); + + String result = reasoningTemplateService.renderTemplate(template, variables); + + assertThat(result).isEqualTo("Car #001 was penalized for {reason}."); + } + + @Test + void renderTemplate_shouldHandleEmptyVariables() { + String template = "No placeholders here."; + Map variables = Map.of(); + + String result = reasoningTemplateService.renderTemplate(template, variables); + + assertThat(result).isEqualTo("No placeholders here."); + } + + @Test + void getAllTemplates_shouldReturnAll() { + ReasoningTemplate template = ReasoningTemplate.builder() + .id("tmpl12345678") + .name("Test Template") + .category("Contact") + .templateText("Template text") + .sortOrder(0) + .build(); + + when(reasoningTemplateMapper.findAll()).thenReturn(List.of(template)); + + List result = reasoningTemplateService.getAllTemplates(); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().getName()).isEqualTo("Test Template"); + } + + @Test + void getTemplatesByCategory_shouldFilterByCategory() { + ReasoningTemplate template = ReasoningTemplate.builder() + .id("tmpl12345678") + .name("Contact Template") + .category("Contact") + .templateText("Contact template text") + .sortOrder(0) + .build(); + + when(reasoningTemplateMapper.findByCategory("Contact")).thenReturn(List.of(template)); + + List result = reasoningTemplateService.getTemplatesByCategory("Contact"); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().getCategory()).isEqualTo("Contact"); + } +} diff --git a/simdesk-web/src/test/java/de/sustineo/simdesk/services/stewarding/StewardingEntrylistServiceTest.java b/simdesk-web/src/test/java/de/sustineo/simdesk/services/stewarding/StewardingEntrylistServiceTest.java new file mode 100644 index 00000000..7ccf0b2f --- /dev/null +++ b/simdesk-web/src/test/java/de/sustineo/simdesk/services/stewarding/StewardingEntrylistServiceTest.java @@ -0,0 +1,121 @@ +package de.sustineo.simdesk.services.stewarding; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.sustineo.simdesk.configuration.SpringProfile; +import de.sustineo.simdesk.entities.stewarding.StewardingEntrylist; +import de.sustineo.simdesk.entities.stewarding.StewardingEntrylistDriver; +import de.sustineo.simdesk.entities.stewarding.StewardingEntrylistEntry; +import de.sustineo.simdesk.mybatis.mapper.StewardingEntrylistDriverMapper; +import de.sustineo.simdesk.mybatis.mapper.StewardingEntrylistEntryMapper; +import de.sustineo.simdesk.mybatis.mapper.StewardingEntrylistMapper; +import de.sustineo.simdesk.services.IdGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ActiveProfiles({SpringProfile.STEWARDING}) +@SpringBootTest(classes = { + StewardingEntrylistService.class, + ObjectMapper.class, + SpringProfile.class +}) +class StewardingEntrylistServiceTest { + + @Autowired + private StewardingEntrylistService entrylistService; + + @MockitoBean + private StewardingEntrylistMapper entrylistMapper; + + @MockitoBean + private StewardingEntrylistEntryMapper entrylistEntryMapper; + + @MockitoBean + private StewardingEntrylistDriverMapper entrylistDriverMapper; + + @MockitoBean + private IdGenerator idGenerator; + + @Test + void uploadEntrylistForRound_shouldParseValidAccJson() { + when(entrylistMapper.findByRoundId("round1")).thenReturn(Collections.emptyList()); + when(idGenerator.generateRandomString(12)).thenReturn("testid123456"); + + String accJson = """ + { + "entries": [ + { + "drivers": [ + { + "firstName": "Max", + "lastName": "Mustermann", + "shortName": "MUS", + "driverCategory": 1, + "playerID": "S76561198000000001" + } + ], + "raceNumber": 1, + "forcedCarModel": 35, + "defaultGridPosition": -1, + "overrideDriverInfo": 0 + }, + { + "drivers": [ + { + "firstName": "John", + "lastName": "Doe", + "shortName": "DOE", + "driverCategory": 2, + "playerID": "S76561198000000002" + }, + { + "firstName": "Jane", + "lastName": "Smith", + "shortName": "SMI", + "driverCategory": 1, + "playerID": "S76561198000000003" + } + ], + "raceNumber": 42, + "forcedCarModel": 30 + } + ], + "forceEntryList": 1 + } + """; + + entrylistService.uploadEntrylistForRound("round1", accJson); + + verify(entrylistMapper).deleteByRoundId("round1"); + verify(entrylistMapper).insert(any(StewardingEntrylist.class)); + verify(entrylistEntryMapper, times(2)).insert(any(StewardingEntrylistEntry.class)); + verify(entrylistDriverMapper, times(3)).insert(any(StewardingEntrylistDriver.class)); + } + + @Test + void uploadEntrylistForRound_shouldHandleEmptyEntries() { + when(entrylistMapper.findByRoundId("round1")).thenReturn(Collections.emptyList()); + when(idGenerator.generateRandomString(12)).thenReturn("testid123456"); + + String accJson = """ + { + "entries": [], + "forceEntryList": 1 + } + """; + + entrylistService.uploadEntrylistForRound("round1", accJson); + + verify(entrylistMapper).deleteByRoundId("round1"); + verify(entrylistMapper).insert(any(StewardingEntrylist.class)); + verify(entrylistEntryMapper, never()).insert(any()); + verify(entrylistDriverMapper, never()).insert(any()); + } +}