Backend Stability, Basically functional.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package com.osleague.groupironmen;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class GroupIronmenApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(GroupIronmenApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.osleague.groupironmen.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* CORS configuration to allow frontend and RuneLite plugin access.
|
||||
*/
|
||||
@Configuration
|
||||
public class CorsConfig {
|
||||
|
||||
@Value("${app.cors.allowed-origins}")
|
||||
private String allowedOriginsString;
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
// Parse comma-separated allowed origins
|
||||
List<String> allowedOrigins = Arrays.asList(allowedOriginsString.split(","));
|
||||
configuration.setAllowedOrigins(allowedOrigins);
|
||||
|
||||
// Allow all HTTP methods
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||
|
||||
// Allow all headers (including Authorization)
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
|
||||
// Allow credentials
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
// Max age for preflight cache
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.osleague.groupironmen.config;
|
||||
|
||||
import com.osleague.groupironmen.security.TokenAuthenticationFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* Security configuration for Group Ironmen API.
|
||||
*
|
||||
* Public endpoints (no authentication):
|
||||
* - POST /api/create-group
|
||||
* - GET /api/ge-prices
|
||||
* - GET /api/captcha-enabled
|
||||
* - GET /api/collection-log-info
|
||||
*
|
||||
* Protected endpoints (token authentication):
|
||||
* - All /api/group/{group_name}/** endpoints
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final TokenAuthenticationFilter tokenAuthenticationFilter;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// Disable CSRF (stateless API with token auth)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
|
||||
// Stateless session (no cookies, no server-side sessions)
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
|
||||
// Authorization rules
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Public endpoints
|
||||
.requestMatchers(
|
||||
"/api/create-group",
|
||||
"/api/ge-prices",
|
||||
"/api/captcha-enabled",
|
||||
"/api/collection-log-info"
|
||||
).permitAll()
|
||||
|
||||
// Protected endpoints (require authentication)
|
||||
.requestMatchers("/api/group/**").authenticated()
|
||||
|
||||
// Actuator endpoints (if enabled)
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
|
||||
// All other requests require authentication by default
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
|
||||
// Add custom token authentication filter before UsernamePasswordAuthenticationFilter
|
||||
.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.osleague.groupironmen.controller;
|
||||
|
||||
import com.osleague.groupironmen.dto.GroupMemberDto;
|
||||
import com.osleague.groupironmen.service.GroupService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Group-related authenticated endpoints.
|
||||
* All endpoints require valid token authentication.
|
||||
* Matches Rust authed.rs group endpoints.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/group/{group_name}")
|
||||
@RequiredArgsConstructor
|
||||
public class GroupController {
|
||||
|
||||
private final GroupService groupService;
|
||||
|
||||
/**
|
||||
* Get group data with delta updates.
|
||||
* GET /api/group/{group_name}/get-group-data?from_time=<epoch_millis>
|
||||
*
|
||||
* Matches Rust: pub async fn get_group_data()
|
||||
*
|
||||
* @param groupName The group name (from path)
|
||||
* @param fromTime Epoch milliseconds (optional) - only return members updated after this time
|
||||
* @param auth Authentication (contains group_id as principal)
|
||||
* @return List of member DTOs
|
||||
*/
|
||||
@GetMapping("/get-group-data")
|
||||
public ResponseEntity<?> getGroupData(
|
||||
@PathVariable("group_name") String groupName,
|
||||
@RequestParam(value = "from_time", required = false) String fromTime,
|
||||
Authentication auth) {
|
||||
|
||||
Long groupId = (Long) auth.getPrincipal();
|
||||
Instant fromTimestamp = null;
|
||||
if (fromTime != null && !fromTime.isBlank()) {
|
||||
try {
|
||||
fromTimestamp = Instant.ofEpochMilli(Long.parseLong(fromTime));
|
||||
} catch (NumberFormatException e) {
|
||||
fromTimestamp = Instant.parse(fromTime);
|
||||
}
|
||||
}
|
||||
|
||||
List<GroupMemberDto> members = groupService.getGroupData(groupId, fromTimestamp);
|
||||
log.debug("Returning {} members for group_id: {}", members.size(), groupId);
|
||||
|
||||
// ✅ Return object wrapper, not raw list
|
||||
return ResponseEntity.ok(members);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check if authenticated.
|
||||
* GET /api/group/{group_name}/am-i-logged-in
|
||||
*
|
||||
* Matches Rust: pub async fn am_i_logged_in()
|
||||
* If this endpoint is reached, authentication succeeded.
|
||||
*
|
||||
* @return Success response
|
||||
*/
|
||||
@GetMapping("/am-i-logged-in")
|
||||
public ResponseEntity<Map<String, Boolean>> amILoggedIn() {
|
||||
log.debug("Authentication check: success");
|
||||
return ResponseEntity.ok(Map.of("authenticated", true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a member is in the group.
|
||||
* GET /api/group/{group_name}/am-i-in-group?member_name=<name>
|
||||
*
|
||||
* Matches Rust: pub async fn am_i_in_group()
|
||||
*
|
||||
* @param groupName The group name (from path)
|
||||
* @param memberName The member name to check
|
||||
* @param auth Authentication (contains group_id)
|
||||
* @return Map with "in_group" boolean
|
||||
*/
|
||||
@GetMapping("/am-i-in-group")
|
||||
public ResponseEntity<Map<String, Boolean>> amIInGroup(
|
||||
@PathVariable("group_name") String groupName,
|
||||
@RequestParam("member_name") String memberName,
|
||||
Authentication auth) {
|
||||
|
||||
Long groupId = (Long) auth.getPrincipal();
|
||||
|
||||
log.debug("Checking if member '{}' is in group_id: {}", memberName, groupId);
|
||||
|
||||
boolean inGroup = groupService.isMemberInGroup(groupId, memberName);
|
||||
|
||||
return ResponseEntity.ok(Map.of("in_group", inGroup));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill data for members.
|
||||
* GET /api/group/{group_name}/get-skill-data?period=<day|month|year>
|
||||
*
|
||||
* Matches Rust: pub async fn get_skill_data()
|
||||
* TODO: Implement skill aggregation service
|
||||
*
|
||||
* @param groupName The group name
|
||||
* @param period The aggregation period (day, month, year)
|
||||
* @param auth Authentication
|
||||
* @return Skill data by period
|
||||
*/
|
||||
@GetMapping("/get-skill-data")
|
||||
public ResponseEntity<List<Map<String, Object>>> getSkillData(
|
||||
@PathVariable("group_name") String groupName,
|
||||
@RequestParam("period") String period,
|
||||
Authentication auth) {
|
||||
|
||||
Long groupId = (Long) auth.getPrincipal();
|
||||
|
||||
log.debug("Getting skill data for group_id: {}, period: {}", groupId, period);
|
||||
|
||||
// TODO: Implement skill aggregation service
|
||||
// For now, return empty list
|
||||
return ResponseEntity.ok(List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collection log data.
|
||||
* GET /api/group/{group_name}/collection-log
|
||||
*
|
||||
* Matches Rust: pub async fn collection_log()
|
||||
* TODO: Implement collection log service
|
||||
*
|
||||
* @param groupName The group name
|
||||
* @param auth Authentication
|
||||
* @return Collection log data
|
||||
*/
|
||||
@GetMapping("/collection-log")
|
||||
public ResponseEntity<List<Map<String, Object>>> getCollectionLog(
|
||||
@PathVariable("group_name") String groupName,
|
||||
Authentication auth) {
|
||||
|
||||
Long groupId = (Long) auth.getPrincipal();
|
||||
|
||||
log.debug("Getting collection log for group_id: {}", groupId);
|
||||
|
||||
// TODO: Implement collection log service
|
||||
// TODO: Implement collection log service
|
||||
// For now, return empty list
|
||||
return ResponseEntity.ok(List.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.osleague.groupironmen.controller;
|
||||
|
||||
import com.osleague.groupironmen.dto.AddMemberRequest;
|
||||
import com.osleague.groupironmen.dto.DeleteMemberRequest;
|
||||
import com.osleague.groupironmen.dto.RenameMemberRequest;
|
||||
import com.osleague.groupironmen.dto.UpdateMemberRequest;
|
||||
import com.osleague.groupironmen.service.MemberService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Member management authenticated endpoints.
|
||||
* All endpoints require valid token authentication.
|
||||
* Matches Rust authed.rs member endpoints.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/group/{group_name}")
|
||||
@RequiredArgsConstructor
|
||||
public class MemberController {
|
||||
|
||||
private final MemberService memberService;
|
||||
|
||||
/**
|
||||
* Update group member data (from RuneLite plugin).
|
||||
* POST /api/group/{group_name}/update-group-member
|
||||
*
|
||||
* Matches Rust: pub async fn update_group_member()
|
||||
*
|
||||
* IMPORTANT: This is the main endpoint the RuneLite plugin calls.
|
||||
* Only non-null fields in the request will be updated.
|
||||
*
|
||||
* @param groupName The group name (from path)
|
||||
* @param request Member update data
|
||||
* @param auth Authentication (contains group_id)
|
||||
* @return Success response
|
||||
*/
|
||||
@PostMapping("/update-group-member")
|
||||
public ResponseEntity<Map<String, String>> updateGroupMember(
|
||||
@PathVariable("group_name") String groupName,
|
||||
@Valid @RequestBody UpdateMemberRequest request,
|
||||
Authentication auth) {
|
||||
|
||||
Long groupId = (Long) auth.getPrincipal();
|
||||
|
||||
log.debug("Updating member '{}' in group_id: {}", request.getName(), groupId);
|
||||
|
||||
memberService.updateMember(groupId, request);
|
||||
|
||||
return ResponseEntity.ok(Map.of("status", "success"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new member to the group.
|
||||
* POST /api/group/{group_name}/add-group-member
|
||||
*
|
||||
* Matches Rust: pub async fn add_group_member()
|
||||
*
|
||||
* @param groupName The group name
|
||||
* @param request Add member request
|
||||
* @param auth Authentication
|
||||
* @return Success response
|
||||
*/
|
||||
@PostMapping("/add-group-member")
|
||||
public ResponseEntity<Map<String, String>> addGroupMember(
|
||||
@PathVariable("group_name") String groupName,
|
||||
@Valid @RequestBody AddMemberRequest request,
|
||||
Authentication auth) {
|
||||
|
||||
Long groupId = (Long) auth.getPrincipal();
|
||||
|
||||
log.info("Adding member '{}' to group_id: {}", request.getName(), groupId);
|
||||
|
||||
memberService.addMember(groupId, request.getName());
|
||||
|
||||
return ResponseEntity.ok(Map.of("status", "success"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a member from the group.
|
||||
* DELETE /api/group/{group_name}/delete-group-member
|
||||
*
|
||||
* Matches Rust: pub async fn delete_group_member()
|
||||
*
|
||||
* @param groupName The group name
|
||||
* @param request Delete member request
|
||||
* @param auth Authentication
|
||||
* @return Success response
|
||||
*/
|
||||
@DeleteMapping("/delete-group-member")
|
||||
public ResponseEntity<Map<String, String>> deleteGroupMember(
|
||||
@PathVariable("group_name") String groupName,
|
||||
@Valid @RequestBody DeleteMemberRequest request,
|
||||
Authentication auth) {
|
||||
|
||||
Long groupId = (Long) auth.getPrincipal();
|
||||
|
||||
log.info("Deleting member '{}' from group_id: {}", request.getName(), groupId);
|
||||
|
||||
memberService.deleteMember(groupId, request.getName());
|
||||
|
||||
return ResponseEntity.ok(Map.of("status", "success"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a group member.
|
||||
* PUT /api/group/{group_name}/rename-group-member
|
||||
*
|
||||
* Matches Rust: pub async fn rename_group_member()
|
||||
*
|
||||
* @param groupName The group name
|
||||
* @param request Rename request
|
||||
* @param auth Authentication
|
||||
* @return Success response
|
||||
*/
|
||||
@PutMapping("/rename-group-member")
|
||||
public ResponseEntity<Map<String, String>> renameGroupMember(
|
||||
@PathVariable("group_name") String groupName,
|
||||
@Valid @RequestBody RenameMemberRequest request,
|
||||
Authentication auth) {
|
||||
|
||||
Long groupId = (Long) auth.getPrincipal();
|
||||
|
||||
log.info("Renaming member '{}' -> '{}' in group_id: {}",
|
||||
request.getOriginalName(),
|
||||
request.getNewName(),
|
||||
groupId);
|
||||
|
||||
memberService.renameMember(groupId, request.getOriginalName(), request.getNewName());
|
||||
|
||||
return ResponseEntity.ok(Map.of("status", "success"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.osleague.groupironmen.controller;
|
||||
|
||||
import com.osleague.groupironmen.dto.CaptchaConfigResponse;
|
||||
import com.osleague.groupironmen.dto.CreateGroupRequest;
|
||||
import com.osleague.groupironmen.dto.CreateGroupResponse;
|
||||
import com.osleague.groupironmen.dto.GePricesResponse;
|
||||
import com.osleague.groupironmen.service.GrandExchangeService;
|
||||
import com.osleague.groupironmen.service.GroupService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Public API endpoints (no authentication required).
|
||||
* Matches Rust unauthed.rs endpoints.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@RequiredArgsConstructor
|
||||
public class PublicController {
|
||||
|
||||
private final GroupService groupService;
|
||||
private final GrandExchangeService geService;
|
||||
|
||||
@Value("${app.captcha.enabled}")
|
||||
private boolean captchaEnabled;
|
||||
|
||||
@Value("${app.captcha.sitekey}")
|
||||
private String captchaSitekey;
|
||||
|
||||
/**
|
||||
* Create a new group.
|
||||
* POST /api/create-group
|
||||
*
|
||||
* Matches Rust: pub async fn create_group()
|
||||
*/
|
||||
@PostMapping("/create-group")
|
||||
public ResponseEntity<CreateGroupResponse> createGroup(
|
||||
@Valid @RequestBody CreateGroupRequest request) {
|
||||
|
||||
log.info("Creating group: {}", request.getName());
|
||||
|
||||
// TODO: Implement captcha validation if enabled
|
||||
// if (captchaEnabled && !verifyCaptcha(request.getCaptchaResponse())) {
|
||||
// throw new ValidationException("captcha", "invalid");
|
||||
// }
|
||||
|
||||
CreateGroupResponse response = groupService.createGroup(request);
|
||||
|
||||
log.info("Group created: {} with token (first 8 chars): {}...",
|
||||
response.getName(),
|
||||
response.getToken().substring(0, 8));
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached Grand Exchange prices.
|
||||
* GET /api/ge-prices
|
||||
*
|
||||
* Matches Rust: pub async fn get_ge_prices()
|
||||
*/
|
||||
@GetMapping("/ge-prices")
|
||||
public ResponseEntity<GePricesResponse> getGePrices() {
|
||||
log.debug("Fetching GE prices");
|
||||
|
||||
Map<String, Integer> prices = geService.getCachedPrices();
|
||||
GePricesResponse response = new GePricesResponse(prices);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get captcha configuration.
|
||||
* GET /api/captcha-enabled
|
||||
*
|
||||
* Matches Rust: pub async fn captcha_enabled()
|
||||
*/
|
||||
@GetMapping("/captcha-enabled")
|
||||
public ResponseEntity<CaptchaConfigResponse> getCaptchaConfig() {
|
||||
log.debug("Fetching captcha config");
|
||||
|
||||
CaptchaConfigResponse response = CaptchaConfigResponse.builder()
|
||||
.enabled(captchaEnabled)
|
||||
.sitekey(captchaSitekey)
|
||||
.build();
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collection log info.
|
||||
* GET /api/collection-log-info
|
||||
*
|
||||
* Matches Rust: pub async fn collection_log_info()
|
||||
* TODO: Implement collection log info loading from JSON
|
||||
*/
|
||||
@GetMapping("/collection-log-info")
|
||||
public ResponseEntity<Map<String, Object>> getCollectionLogInfo() {
|
||||
log.debug("Fetching collection log info");
|
||||
|
||||
// TODO: Load from collection_log_info.json and return
|
||||
// For now, return empty map
|
||||
return ResponseEntity.ok(Map.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.osleague.groupironmen.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Request DTO for adding a new member to a group.
|
||||
*/
|
||||
@Data
|
||||
public class AddMemberRequest {
|
||||
|
||||
@NotBlank(message = "Member name is required")
|
||||
@Size(min = 1, max = 16, message = "Member name must be 1-16 characters")
|
||||
@JsonProperty("name")
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.osleague.groupironmen.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Response DTO for captcha configuration.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CaptchaConfigResponse {
|
||||
|
||||
@JsonProperty("enabled")
|
||||
private boolean enabled;
|
||||
|
||||
@JsonProperty("sitekey")
|
||||
private String sitekey;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.osleague.groupironmen.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Request DTO for creating a new group.
|
||||
* Matches Rust CreateGroup struct.
|
||||
*/
|
||||
@Data
|
||||
public class CreateGroupRequest {
|
||||
|
||||
@NotBlank(message = "Group name is required")
|
||||
@Size(min = 1, max = 64, message = "Group name must be 1-64 characters")
|
||||
@JsonProperty("name")
|
||||
private String name;
|
||||
|
||||
@NotNull(message = "Member names are required")
|
||||
@Size(min = 1, max = 5, message = "Group must have 1-5 members")
|
||||
@JsonProperty("member_names")
|
||||
private List<String> memberNames;
|
||||
|
||||
@JsonProperty("captcha_response")
|
||||
private String captchaResponse = "";
|
||||
|
||||
@JsonProperty("token")
|
||||
private String token; // Will be generated server-side
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.osleague.groupironmen.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Response DTO for group creation.
|
||||
* Returns the group token and group ID.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CreateGroupResponse {
|
||||
|
||||
@JsonProperty("token")
|
||||
private String token;
|
||||
|
||||
@JsonProperty("group_id")
|
||||
private Long groupId;
|
||||
|
||||
@JsonProperty("name")
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.osleague.groupironmen.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Request DTO for deleting a group member.
|
||||
*/
|
||||
@Data
|
||||
public class DeleteMemberRequest {
|
||||
|
||||
@NotBlank(message = "Member name is required")
|
||||
@JsonProperty("name")
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.osleague.groupironmen.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Response DTO for Grand Exchange prices.
|
||||
* Simple map of item_id -> price.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class GePricesResponse {
|
||||
private Map<String, Integer> prices; // item_id -> average price
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.osleague.groupironmen.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DTO for group member data.
|
||||
* Matches Rust GroupMember struct.
|
||||
*
|
||||
* IMPORTANT: Fields are only included if non-null (skip_serializing_if in Rust).
|
||||
* This enables delta updates - only changed fields are sent.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) // Skip null fields (matches Rust behavior)
|
||||
public class GroupMemberDto {
|
||||
|
||||
@JsonProperty("name")
|
||||
private String name;
|
||||
|
||||
@JsonProperty("stats")
|
||||
private List<Integer> stats; // 7 integers: HP, Prayer, Energy, World
|
||||
|
||||
@JsonProperty("coordinates")
|
||||
private Object coordinates; // 3 integers: x, y, plane
|
||||
|
||||
@JsonProperty("skills")
|
||||
private List<Integer> skills; // 24 integers
|
||||
|
||||
@JsonProperty("quests")
|
||||
private byte[] quests; // Binary data (Vec<u8> in Rust)
|
||||
|
||||
@JsonProperty("inventory")
|
||||
private List<Integer> inventory; // 56 integers
|
||||
|
||||
@JsonProperty("equipment")
|
||||
private List<Integer> equipment; // 28 integers
|
||||
|
||||
@JsonProperty("bank")
|
||||
private List<Integer> bank; // Variable length
|
||||
|
||||
@JsonProperty("shared_bank")
|
||||
private List<Integer> sharedBank; // Variable length
|
||||
|
||||
@JsonProperty("rune_pouch")
|
||||
private List<Integer> runePouch; // 8 integers
|
||||
|
||||
@JsonProperty("seed_vault")
|
||||
private List<Integer> seedVault; // Variable length
|
||||
|
||||
@JsonProperty("deposited")
|
||||
private List<Integer> deposited; // Variable length (not in DB yet)
|
||||
|
||||
@JsonProperty("diary_vars")
|
||||
private List<Integer> diaryVars; // 62 integers
|
||||
|
||||
@JsonProperty("interacting")
|
||||
private String interacting; // NPC name (simplified from Rust struct)
|
||||
|
||||
@JsonProperty("last_updated")
|
||||
private Instant lastUpdated; // Timestamp of most recent update
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.osleague.groupironmen.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Request DTO for renaming a group member.
|
||||
* Matches Rust RenameGroupMember struct.
|
||||
*/
|
||||
@Data
|
||||
public class RenameMemberRequest {
|
||||
|
||||
@NotBlank(message = "Original name is required")
|
||||
@JsonProperty("original_name")
|
||||
private String originalName;
|
||||
|
||||
@NotBlank(message = "New name is required")
|
||||
@JsonProperty("new_name")
|
||||
private String newName;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.osleague.groupironmen.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.osleague.groupironmen.json.InteractingDeserializer;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Request DTO for updating a group member.
|
||||
* Matches the plugin's update payload.
|
||||
*
|
||||
* IMPORTANT: All fields are optional (nullable).
|
||||
* Only provided fields will be updated in the database.
|
||||
*/
|
||||
@Data
|
||||
public class UpdateMemberRequest {
|
||||
|
||||
@NotBlank(message = "Member name is required")
|
||||
@JsonProperty("name")
|
||||
private String name;
|
||||
|
||||
@JsonProperty("stats")
|
||||
private List<Integer> stats;
|
||||
|
||||
@JsonProperty("coordinates")
|
||||
private List<Integer> coordinates;
|
||||
|
||||
@JsonProperty("skills")
|
||||
private List<Integer> skills;
|
||||
|
||||
@JsonProperty("quests")
|
||||
private byte[] quests;
|
||||
|
||||
@JsonProperty("inventory")
|
||||
private List<Integer> inventory;
|
||||
|
||||
@JsonProperty("equipment")
|
||||
private List<Integer> equipment;
|
||||
|
||||
@JsonProperty("bank")
|
||||
private List<Integer> bank;
|
||||
|
||||
@JsonProperty("shared_bank")
|
||||
private List<Integer> sharedBank;
|
||||
|
||||
@JsonProperty("rune_pouch")
|
||||
private List<Integer> runePouch;
|
||||
|
||||
@JsonProperty("seed_vault")
|
||||
private List<Integer> seedVault;
|
||||
|
||||
@JsonProperty("deposited")
|
||||
private List<Integer> deposited;
|
||||
|
||||
@JsonProperty("diary_vars")
|
||||
private List<Integer> diaryVars;
|
||||
|
||||
@JsonProperty("interacting")
|
||||
@JsonDeserialize(using = InteractingDeserializer.class)
|
||||
private String interacting;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.osleague.groupironmen.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when attempting to create a group that already exists.
|
||||
*/
|
||||
public class DuplicateGroupException extends RuntimeException {
|
||||
public DuplicateGroupException(String groupName) {
|
||||
super(String.format("Group '%s' already exists", groupName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.osleague.groupironmen.exception;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.context.request.WebRequest;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Global exception handler for Group Ironmen API.
|
||||
* Catches exceptions and returns appropriate HTTP responses.
|
||||
* Matches Rust error response format.
|
||||
*/
|
||||
@Slf4j
|
||||
@ControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/**
|
||||
* Handle GroupNotFoundException - 404 Not Found
|
||||
*/
|
||||
@ExceptionHandler(GroupNotFoundException.class)
|
||||
public ResponseEntity<Object> handleGroupNotFound(GroupNotFoundException ex, WebRequest request) {
|
||||
log.warn("Group not found: {}", ex.getMessage());
|
||||
return buildErrorResponse(ex.getMessage(), HttpStatus.NOT_FOUND, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MemberNotFoundException - 404 Not Found
|
||||
*/
|
||||
@ExceptionHandler(MemberNotFoundException.class)
|
||||
public ResponseEntity<Object> handleMemberNotFound(MemberNotFoundException ex, WebRequest request) {
|
||||
log.warn("Member not found: {}", ex.getMessage());
|
||||
return buildErrorResponse(ex.getMessage(), HttpStatus.NOT_FOUND, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GroupFullException - 400 Bad Request
|
||||
*/
|
||||
@ExceptionHandler(GroupFullException.class)
|
||||
public ResponseEntity<Object> handleGroupFull(GroupFullException ex, WebRequest request) {
|
||||
log.warn("Group full: {}", ex.getMessage());
|
||||
return buildErrorResponse(ex.getMessage(), HttpStatus.BAD_REQUEST, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle ValidationException - 400 Bad Request
|
||||
*/
|
||||
@ExceptionHandler(ValidationException.class)
|
||||
public ResponseEntity<Object> handleValidation(ValidationException ex, WebRequest request) {
|
||||
log.warn("Validation error: {}", ex.getMessage());
|
||||
return buildErrorResponse(ex.getMessage(), HttpStatus.BAD_REQUEST, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle DuplicateGroupException - 409 Conflict
|
||||
*/
|
||||
@ExceptionHandler(DuplicateGroupException.class)
|
||||
public ResponseEntity<Object> handleDuplicateGroup(DuplicateGroupException ex, WebRequest request) {
|
||||
log.warn("Duplicate group: {}", ex.getMessage());
|
||||
return buildErrorResponse(ex.getMessage(), HttpStatus.CONFLICT, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IllegalArgumentException - 400 Bad Request
|
||||
*/
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Object> handleIllegalArgument(IllegalArgumentException ex, WebRequest request) {
|
||||
log.warn("Illegal argument: {}", ex.getMessage());
|
||||
return buildErrorResponse(ex.getMessage(), HttpStatus.BAD_REQUEST, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle generic exceptions - 500 Internal Server Error
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Object> handleGenericException(Exception ex, WebRequest request) {
|
||||
log.error("Unhandled exception", ex);
|
||||
return buildErrorResponse("Internal server error", HttpStatus.INTERNAL_SERVER_ERROR, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build error response matching Rust format.
|
||||
*/
|
||||
private ResponseEntity<Object> buildErrorResponse(String message, HttpStatus status, WebRequest request) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("timestamp", Instant.now().toString());
|
||||
body.put("status", status.value());
|
||||
body.put("error", status.getReasonPhrase());
|
||||
body.put("message", message);
|
||||
body.put("path", request.getDescription(false).replace("uri=", ""));
|
||||
|
||||
return new ResponseEntity<>(body, status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.osleague.groupironmen.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when attempting to add a member to a full group (max 5 members).
|
||||
*/
|
||||
public class GroupFullException extends RuntimeException {
|
||||
public GroupFullException() {
|
||||
super("Group is full. Maximum 5 members allowed.");
|
||||
}
|
||||
|
||||
public GroupFullException(String groupName) {
|
||||
super(String.format("Group '%s' is full. Maximum 5 members allowed.", groupName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.osleague.groupironmen.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when a group cannot be found.
|
||||
*/
|
||||
public class GroupNotFoundException extends RuntimeException {
|
||||
public GroupNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public GroupNotFoundException(String groupName, String reason) {
|
||||
super(String.format("Group '%s' not found: %s", groupName, reason));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.osleague.groupironmen.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when a member cannot be found.
|
||||
*/
|
||||
public class MemberNotFoundException extends RuntimeException {
|
||||
public MemberNotFoundException(String memberName) {
|
||||
super(String.format("Member '%s' not found", memberName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.osleague.groupironmen.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when request validation fails.
|
||||
*/
|
||||
public class ValidationException extends RuntimeException {
|
||||
public ValidationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ValidationException(String field, String reason) {
|
||||
super(String.format("Validation failed for '%s': %s", field, reason));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.osleague.groupironmen.json;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Handles both string and object formats for "interacting".
|
||||
* Example inputs:
|
||||
* "interacting": "Goblin"
|
||||
* "interacting": { "name": "Goblin", "id": 1234 }
|
||||
*/
|
||||
public class InteractingDeserializer extends JsonDeserializer<String> {
|
||||
|
||||
@Override
|
||||
public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
JsonNode node = p.readValueAsTree();
|
||||
|
||||
if (node.isTextual()) {
|
||||
return node.asText(); // "Goblin"
|
||||
}
|
||||
|
||||
if (node.isObject()) {
|
||||
JsonNode nameNode = node.get("name");
|
||||
return nameNode != null ? nameNode.asText() : node.toString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.osleague.groupironmen.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Group entity - represents a group ironman team.
|
||||
* Matches the groupironman.groups table schema.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "groups", schema = "groupironman")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Group {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "group_id")
|
||||
private Long groupId;
|
||||
|
||||
@Column(name = "group_name", nullable = false, columnDefinition = "TEXT")
|
||||
private String groupName;
|
||||
|
||||
@Column(name = "group_token_hash", nullable = false, length = 64)
|
||||
private String groupTokenHash;
|
||||
|
||||
@Column(name = "version")
|
||||
@Builder.Default
|
||||
private Integer version = 1;
|
||||
|
||||
@OneToMany(mappedBy = "group", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@Builder.Default
|
||||
private List<Member> members = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Helper method to add a member to the group.
|
||||
*/
|
||||
public void addMember(Member member) {
|
||||
members.add(member);
|
||||
member.setGroup(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to remove a member from the group.
|
||||
*/
|
||||
public void removeMember(Member member) {
|
||||
members.remove(member);
|
||||
member.setGroup(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.osleague.groupironmen.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Member entity - represents a player in a group ironman team.
|
||||
* Matches the groupironman.members table schema.
|
||||
*
|
||||
* Uses JSON columns for array fields (MariaDB doesn't support native arrays).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "members", schema = "groupironman",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"group_id", "member_name"})
|
||||
)
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Member {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "member_id")
|
||||
private Long memberId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "group_id", nullable = false)
|
||||
private Group group;
|
||||
|
||||
@Column(name = "member_name", nullable = false, columnDefinition = "TEXT")
|
||||
private String memberName;
|
||||
|
||||
// Stats (7 integers: HP, Prayer, Energy, World, etc.)
|
||||
@Column(name = "stats_last_update")
|
||||
private Instant statsLastUpdate;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "stats", columnDefinition = "json")
|
||||
private List<Integer> stats;
|
||||
|
||||
// Coordinates (3 integers: x, y, plane)
|
||||
@Column(name = "coordinates_last_update")
|
||||
private Instant coordinatesLastUpdate;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "coordinates", columnDefinition = "json")
|
||||
private List<Integer> coordinates;
|
||||
|
||||
// Skills (24 integers)
|
||||
@Column(name = "skills_last_update")
|
||||
private Instant skillsLastUpdate;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "skills", columnDefinition = "json")
|
||||
private List<Integer> skills;
|
||||
|
||||
// Quests (binary data)
|
||||
@Column(name = "quests_last_update")
|
||||
private Instant questsLastUpdate;
|
||||
|
||||
@Lob
|
||||
@Column(name = "quests", columnDefinition = "BLOB")
|
||||
private byte[] quests;
|
||||
|
||||
// Inventory (56 integers)
|
||||
@Column(name = "inventory_last_update")
|
||||
private Instant inventoryLastUpdate;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "inventory", columnDefinition = "json")
|
||||
private List<Integer> inventory;
|
||||
|
||||
// Equipment (28 integers)
|
||||
@Column(name = "equipment_last_update")
|
||||
private Instant equipmentLastUpdate;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "equipment", columnDefinition = "json")
|
||||
private List<Integer> equipment;
|
||||
|
||||
// Rune pouch (8 integers)
|
||||
@Column(name = "rune_pouch_last_update")
|
||||
private Instant runePouchLastUpdate;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "rune_pouch", columnDefinition = "json")
|
||||
private List<Integer> runePouch;
|
||||
|
||||
// Bank (variable length)
|
||||
@Column(name = "bank_last_update")
|
||||
private Instant bankLastUpdate;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "bank", columnDefinition = "json")
|
||||
private List<Integer> bank;
|
||||
|
||||
// Seed vault (variable length)
|
||||
@Column(name = "seed_vault_last_update")
|
||||
private Instant seedVaultLastUpdate;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "seed_vault", columnDefinition = "json")
|
||||
private List<Integer> seedVault;
|
||||
|
||||
// Interacting NPC
|
||||
@Column(name = "interacting_last_update")
|
||||
private Instant interactingLastUpdate;
|
||||
|
||||
@Column(name = "interacting", columnDefinition = "TEXT")
|
||||
private String interacting;
|
||||
|
||||
// Diary vars (62 integers)
|
||||
@Column(name = "diary_vars_last_update")
|
||||
private Instant diaryVarsLastUpdate;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "diary_vars", columnDefinition = "json")
|
||||
private List<Integer> diaryVars;
|
||||
|
||||
@Column(name = "last_updated")
|
||||
private Instant lastUpdated;
|
||||
|
||||
|
||||
/**
|
||||
* Get the most recent update timestamp across all fields.
|
||||
* Matches Rust logic: GREATEST(stats_last_update, coordinates_last_update, ...)
|
||||
*/
|
||||
public Instant getLastUpdated() {
|
||||
Instant latest = null;
|
||||
|
||||
Instant[] timestamps = {
|
||||
statsLastUpdate, coordinatesLastUpdate, skillsLastUpdate,
|
||||
questsLastUpdate, inventoryLastUpdate, equipmentLastUpdate,
|
||||
bankLastUpdate, runePouchLastUpdate, interactingLastUpdate,
|
||||
seedVaultLastUpdate, diaryVarsLastUpdate
|
||||
};
|
||||
|
||||
for (Instant timestamp : timestamps) {
|
||||
if (timestamp != null && (latest == null || timestamp.isAfter(latest))) {
|
||||
latest = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.osleague.groupironmen.repository;
|
||||
|
||||
import com.osleague.groupironmen.model.Group;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Repository for Group entities.
|
||||
* Provides database access for group ironman teams.
|
||||
*/
|
||||
@Repository
|
||||
public interface GroupRepository extends JpaRepository<Group, Long> {
|
||||
|
||||
/**
|
||||
* Find a group by name.
|
||||
*
|
||||
* @param groupName The group name
|
||||
* @return Optional containing the group if found
|
||||
*/
|
||||
Optional<Group> findByGroupName(String groupName);
|
||||
|
||||
/**
|
||||
* Find group ID by name and token hash (for authentication).
|
||||
* Matches Rust query: SELECT group_id FROM groupironman.groups WHERE group_token_hash=$1 AND group_name=$2
|
||||
*
|
||||
* @param groupName The group name
|
||||
* @param groupTokenHash The hashed token
|
||||
* @return Optional containing the group ID if found
|
||||
*/
|
||||
@Query("SELECT g.groupId FROM Group g WHERE g.groupName = :groupName AND g.groupTokenHash = :tokenHash")
|
||||
Optional<Long> findGroupIdByNameAndTokenHash(
|
||||
@Param("groupName") String groupName,
|
||||
@Param("tokenHash") String groupTokenHash
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if a group exists by name.
|
||||
*
|
||||
* @param groupName The group name
|
||||
* @return true if group exists
|
||||
*/
|
||||
boolean existsByGroupName(String groupName);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.osleague.groupironmen.repository;
|
||||
|
||||
import com.osleague.groupironmen.model.Member;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Repository for Member entities.
|
||||
* Provides database access for group ironman members.
|
||||
*/
|
||||
@Repository
|
||||
public interface MemberRepository extends JpaRepository<Member, Long> {
|
||||
|
||||
/**
|
||||
* Find all members in a group.
|
||||
*
|
||||
* @param groupId The group ID
|
||||
* @return List of members
|
||||
*/
|
||||
@Query("SELECT m FROM Member m WHERE m.group.groupId = :groupId")
|
||||
List<Member> findByGroupId(@Param("groupId") Long groupId);
|
||||
|
||||
/**
|
||||
* Find members updated after a specific timestamp (for delta updates).
|
||||
* This matches the Rust logic that returns only changed fields since from_time.
|
||||
*
|
||||
* @param groupId The group ID
|
||||
* @param fromTimestamp The timestamp to filter by
|
||||
* @return List of members with any field updated after the timestamp
|
||||
*/
|
||||
@Query("""
|
||||
SELECT m FROM Member m
|
||||
WHERE m.group.groupId = :groupId
|
||||
AND m.lastUpdated >= :fromTime
|
||||
""")
|
||||
List<Member> findByGroupIdAndUpdatedAfter(
|
||||
@Param("groupId") Long groupId,
|
||||
@Param("fromTime") Instant fromTimestamp
|
||||
);
|
||||
|
||||
/**
|
||||
* Find a member by group ID and member name.
|
||||
*
|
||||
* @param groupId The group ID
|
||||
* @param memberName The member name
|
||||
* @return Optional containing the member if found
|
||||
*/
|
||||
@Query("SELECT m FROM Member m WHERE m.group.groupId = :groupId AND m.memberName = :memberName")
|
||||
Optional<Member> findByGroupIdAndMemberName(
|
||||
@Param("groupId") Long groupId,
|
||||
@Param("memberName") String memberName
|
||||
);
|
||||
|
||||
/**
|
||||
* Count members in a group.
|
||||
* Used to enforce max 5 members per group.
|
||||
*
|
||||
* @param groupId The group ID
|
||||
* @return Number of members
|
||||
*/
|
||||
@Query("SELECT COUNT(m) FROM Member m WHERE m.group.groupId = :groupId")
|
||||
int countByGroupId(@Param("groupId") Long groupId);
|
||||
|
||||
/**
|
||||
* Delete a member by group ID and member name.
|
||||
*
|
||||
* @param groupId The group ID
|
||||
* @param memberName The member name
|
||||
*/
|
||||
void deleteByGroupGroupIdAndMemberName(Long groupId, String memberName);
|
||||
|
||||
/**
|
||||
* Check if a member exists in a group.
|
||||
*
|
||||
* @param groupId The group ID
|
||||
* @param memberName The member name
|
||||
* @return true if member exists
|
||||
*/
|
||||
@Query("SELECT CASE WHEN COUNT(m) > 0 THEN true ELSE false END FROM Member m WHERE m.group.groupId = :groupId AND m.memberName = :memberName")
|
||||
boolean existsByGroupIdAndMemberName(
|
||||
@Param("groupId") Long groupId,
|
||||
@Param("memberName") String memberName
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.osleague.groupironmen.security;
|
||||
|
||||
import org.bouncycastle.crypto.digests.Blake2bDigest;
|
||||
import org.bouncycastle.util.encoders.Hex;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Blake2 token hasher for compatibility with Rust backend.
|
||||
* Uses Blake2b-256 with 2 iterations to hash group tokens.
|
||||
*
|
||||
* This implementation MUST match the Rust implementation exactly:
|
||||
* - Uses Blake2b (not Blake2s, despite Rust using Blake2s256 which is 256-bit Blake2s)
|
||||
* - 2 iterations of hashing
|
||||
* - Combines token + secret + salt
|
||||
* - Returns hex-encoded hash (64 characters)
|
||||
*/
|
||||
@Component
|
||||
public class Blake2TokenHasher {
|
||||
|
||||
@Value("${app.security.secret}")
|
||||
private String secret;
|
||||
|
||||
/**
|
||||
* Hash a token with Blake2b-256 (2 iterations).
|
||||
*
|
||||
* @param token The group token (UUID format)
|
||||
* @param salt The group name (used as salt)
|
||||
* @return Hex-encoded hash (64 characters)
|
||||
*/
|
||||
public String hashToken(String token, String salt) {
|
||||
if (token == null || salt == null) {
|
||||
throw new IllegalArgumentException("Token and salt cannot be null");
|
||||
}
|
||||
|
||||
// First iteration: hash(token + secret + salt)
|
||||
Blake2bDigest digest = new Blake2bDigest(256); // 256-bit = 32 bytes
|
||||
String input1 = token + secret + salt;
|
||||
byte[] input1Bytes = input1.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
digest.update(input1Bytes, 0, input1Bytes.length);
|
||||
byte[] hash1 = new byte[digest.getDigestSize()];
|
||||
digest.doFinal(hash1, 0);
|
||||
|
||||
// Second iteration: hash(hash1)
|
||||
digest.reset();
|
||||
digest.update(hash1, 0, hash1.length);
|
||||
byte[] hash2 = new byte[digest.getDigestSize()];
|
||||
digest.doFinal(hash2, 0);
|
||||
|
||||
// Return hex-encoded (lowercase)
|
||||
return Hex.toHexString(hash2).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a token matches the stored hash.
|
||||
*
|
||||
* @param token The token to verify
|
||||
* @param salt The group name
|
||||
* @param storedHash The hash stored in database
|
||||
* @return true if token is valid
|
||||
*/
|
||||
public boolean verifyToken(String token, String salt, String storedHash) {
|
||||
if (token == null || salt == null || storedHash == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String computedHash = hashToken(token, salt);
|
||||
return computedHash.equals(storedHash.toLowerCase());
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.osleague.groupironmen.security;
|
||||
|
||||
import com.osleague.groupironmen.repository.GroupRepository;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Authentication filter for group ironmen API.
|
||||
* Validates token from Authorization header and sets group_id in security context.
|
||||
*
|
||||
* Matches Rust authentication logic:
|
||||
* - Extracts group_name from path variable
|
||||
* - Reads Authorization header (raw token, no Bearer prefix)
|
||||
* - Hashes token with Blake2
|
||||
* - Queries database to verify token and get group_id
|
||||
* - Sets group_id in security context for downstream use
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final Blake2TokenHasher tokenHasher;
|
||||
private final GroupRepository groupRepository;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
|
||||
String path = request.getRequestURI();
|
||||
|
||||
// Only apply to /api/group/{group_name}/... endpoints
|
||||
if (!path.startsWith("/api/group/")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// === NEW: extract group_name directly from the URL path ===
|
||||
// Example path: /api/group/testgroup/get-group-data
|
||||
String[] parts = path.split("/");
|
||||
String groupName = (parts.length >= 4) ? parts[3] : null;
|
||||
|
||||
if (groupName == null || groupName.isBlank()) {
|
||||
log.warn("Missing group_name in path: {}", path);
|
||||
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing group name from request path");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip authentication for special group "_"
|
||||
if ("_".equals(groupName)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract Authorization header
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader == null || authHeader.isBlank()) {
|
||||
log.warn("Missing Authorization header for group: {}", groupName);
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization header missing from request");
|
||||
return;
|
||||
}
|
||||
|
||||
String token = authHeader.trim();
|
||||
|
||||
// Hash token with Blake2
|
||||
String hashedToken = tokenHasher.hashToken(token, groupName);
|
||||
|
||||
// Query database to get group_id
|
||||
Long groupId = groupRepository.findGroupIdByNameAndTokenHash(groupName, hashedToken)
|
||||
.orElse(null);
|
||||
|
||||
if (groupId == null) {
|
||||
log.warn("Invalid token for group: {}", groupName);
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid authentication credentials");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set authentication in security context
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
groupId,
|
||||
null,
|
||||
Collections.singletonList(new SimpleGrantedAuthority("ROLE_GROUP"))
|
||||
);
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
log.debug("Authenticated group: {} with groupId: {}", groupName, groupId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Authentication error", e);
|
||||
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Authentication failed");
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.osleague.groupironmen.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Service for fetching and caching Grand Exchange prices.
|
||||
* Matches Rust GE price updater logic.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class GrandExchangeService {
|
||||
|
||||
@Value("${app.ge-prices.url}")
|
||||
private String gePricesUrl;
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
private final AtomicReference<Map<String, Integer>> cachedPrices = new AtomicReference<>(new HashMap<>());
|
||||
|
||||
public GrandExchangeService(WebClient.Builder webClientBuilder) {
|
||||
this.webClient = webClientBuilder
|
||||
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached GE prices.
|
||||
* Returns cached prices (updated every 4 hours by scheduled task).
|
||||
*
|
||||
* @return Map of item_id -> price
|
||||
*/
|
||||
public Map<String, Integer> getCachedPrices() {
|
||||
return new HashMap<>(cachedPrices.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache GE prices from RuneScape Wiki API.
|
||||
* Runs every 4 hours (14400000 ms).
|
||||
* Matches Rust start_ge_updater() logic.
|
||||
*/
|
||||
@Scheduled(fixedRate = 14400000, initialDelay = 0)
|
||||
public void updatePrices() {
|
||||
try {
|
||||
log.info("Fetching GE prices from: {}", gePricesUrl);
|
||||
|
||||
// Fetch from RuneScape Wiki API
|
||||
WikiPricesResponse response = webClient.get()
|
||||
.uri(gePricesUrl)
|
||||
.retrieve()
|
||||
.bodyToMono(WikiPricesResponse.class)
|
||||
.block();
|
||||
|
||||
if (response == null || response.data == null) {
|
||||
log.warn("Failed to fetch GE prices: empty response");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to simple map: item_id -> average(high, low)
|
||||
Map<String, Integer> prices = new HashMap<>();
|
||||
response.data.forEach((itemId, priceData) -> {
|
||||
// Average high and low prices (matching Rust logic)
|
||||
Integer high = priceData.get("high");
|
||||
Integer low = priceData.get("low");
|
||||
|
||||
if (high != null && low != null) {
|
||||
int avgPrice = (high + low) / 2;
|
||||
prices.put(itemId, avgPrice);
|
||||
} else if (high != null) {
|
||||
prices.put(itemId, high);
|
||||
} else if (low != null) {
|
||||
prices.put(itemId, low);
|
||||
}
|
||||
});
|
||||
|
||||
// Update cached prices
|
||||
cachedPrices.set(prices);
|
||||
|
||||
log.info("Updated {} GE prices", prices.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to update GE prices", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for RuneScape Wiki API response.
|
||||
* Format: { "data": { "item_id": { "high": 123, "low": 100 } } }
|
||||
*/
|
||||
private static class WikiPricesResponse {
|
||||
public Map<String, Map<String, Integer>> data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.osleague.groupironmen.service;
|
||||
|
||||
import com.osleague.groupironmen.dto.CreateGroupRequest;
|
||||
import com.osleague.groupironmen.dto.CreateGroupResponse;
|
||||
import com.osleague.groupironmen.dto.GroupMemberDto;
|
||||
import com.osleague.groupironmen.exception.DuplicateGroupException;
|
||||
import com.osleague.groupironmen.exception.GroupNotFoundException;
|
||||
import com.osleague.groupironmen.model.Group;
|
||||
import com.osleague.groupironmen.model.Member;
|
||||
import com.osleague.groupironmen.repository.GroupRepository;
|
||||
import com.osleague.groupironmen.repository.MemberRepository;
|
||||
import com.osleague.groupironmen.security.Blake2TokenHasher;
|
||||
import com.osleague.groupironmen.util.ValidationUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service for managing group ironman teams.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GroupService {
|
||||
|
||||
private final GroupRepository groupRepository;
|
||||
private final MemberRepository memberRepository;
|
||||
private final Blake2TokenHasher tokenHasher;
|
||||
|
||||
/**
|
||||
* Create a new group with initial members.
|
||||
* Matches Rust create_group() logic.
|
||||
*
|
||||
* @param request Create group request
|
||||
* @return Group token and ID
|
||||
*/
|
||||
@Transactional
|
||||
public CreateGroupResponse createGroup(CreateGroupRequest request) {
|
||||
// Validate group name
|
||||
ValidationUtils.validateGroupName(request.getName());
|
||||
|
||||
// Check if group already exists
|
||||
if (groupRepository.existsByGroupName(request.getName())) {
|
||||
throw new DuplicateGroupException(request.getName());
|
||||
}
|
||||
|
||||
// Validate member names
|
||||
for (String memberName : request.getMemberNames()) {
|
||||
ValidationUtils.validateMemberName(memberName);
|
||||
}
|
||||
|
||||
// Generate UUID token
|
||||
String token = UUID.randomUUID().toString();
|
||||
|
||||
// Hash token with Blake2
|
||||
String hashedToken = tokenHasher.hashToken(token, request.getName());
|
||||
|
||||
// Create group entity
|
||||
Group group = Group.builder()
|
||||
.groupName(request.getName())
|
||||
.groupTokenHash(hashedToken)
|
||||
.version(1)
|
||||
.build();
|
||||
|
||||
// Create member entities
|
||||
for (String memberName : request.getMemberNames()) {
|
||||
Member member = Member.builder()
|
||||
.memberName(memberName)
|
||||
.group(group)
|
||||
.build();
|
||||
group.addMember(member);
|
||||
}
|
||||
|
||||
// Save group (cascades to members)
|
||||
Group savedGroup = groupRepository.save(group);
|
||||
|
||||
log.info("Created group: {} with {} members", savedGroup.getGroupName(), savedGroup.getMembers().size());
|
||||
|
||||
return CreateGroupResponse.builder()
|
||||
.token(token) // Return plain token (not hashed)
|
||||
.groupId(savedGroup.getGroupId())
|
||||
.name(savedGroup.getGroupName())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group data with delta updates.
|
||||
* Matches Rust get_group_data() logic.
|
||||
*
|
||||
* @param groupId The group ID (from authentication)
|
||||
* @param fromTimestamp Only return members updated after this time (null = all)
|
||||
* @return List of member DTOs
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<GroupMemberDto> getGroupData(Long groupId, Instant fromTimestamp) {
|
||||
List<Member> members;
|
||||
|
||||
if (fromTimestamp != null) {
|
||||
// Delta update: only members with changes since fromTimestamp
|
||||
members = memberRepository.findByGroupIdAndUpdatedAfter(groupId, fromTimestamp);
|
||||
} else {
|
||||
// Full update: all members
|
||||
members = memberRepository.findByGroupId(groupId);
|
||||
}
|
||||
|
||||
return members.stream()
|
||||
.map(this::convertToDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a member is in a group.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isMemberInGroup(Long groupId, String memberName) {
|
||||
return memberRepository.existsByGroupIdAndMemberName(groupId, memberName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Member entity to DTO.
|
||||
* IMPORTANT: Only include fields that were updated after fromTimestamp.
|
||||
* This matches Rust CASE WHEN logic for delta updates.
|
||||
*/
|
||||
private GroupMemberDto convertToDto(Member member) {
|
||||
Instant lastUpdated = member.getLastUpdated();
|
||||
|
||||
Map<String, Integer> coordinatesMap = null;
|
||||
List<Integer> coords = member.getCoordinates();
|
||||
if (coords != null && coords.size() >= 3) {
|
||||
coordinatesMap = Map.of(
|
||||
"x", coords.get(0),
|
||||
"y", coords.get(1),
|
||||
"plane", coords.get(2)
|
||||
);
|
||||
}
|
||||
|
||||
// Note: For proper delta updates, we would need to compare each field's
|
||||
// last_update timestamp against fromTimestamp. For now, we include all fields.
|
||||
// TODO: Implement per-field delta logic matching Rust CASE WHEN queries
|
||||
|
||||
return GroupMemberDto.builder()
|
||||
.name(member.getMemberName())
|
||||
.stats(member.getStats())
|
||||
.coordinates(coordinatesMap)
|
||||
.skills(member.getSkills())
|
||||
.quests(padQuestData(member.getQuests()))
|
||||
.inventory(member.getInventory())
|
||||
.equipment(member.getEquipment())
|
||||
.bank(member.getBank())
|
||||
.sharedBank(null) // TODO: Handle shared bank separately
|
||||
.runePouch(member.getRunePouch())
|
||||
.seedVault(member.getSeedVault())
|
||||
.deposited(null) // Not implemented in DB yet
|
||||
.diaryVars(member.getDiaryVars())
|
||||
.interacting(member.getInteracting())
|
||||
.lastUpdated(lastUpdated)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group by name (for admin purposes).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Group getGroupByName(String groupName) {
|
||||
return groupRepository.findByGroupName(groupName)
|
||||
.orElseThrow(() -> new GroupNotFoundException(groupName, "not found"));
|
||||
}
|
||||
private static byte[] padQuestData(byte[] questData) {
|
||||
if (questData == null) {
|
||||
return new byte[440]; // ensure full quest array
|
||||
}
|
||||
if (questData.length < 440) {
|
||||
byte[] padded = new byte[440];
|
||||
System.arraycopy(questData, 0, padded, 0, questData.length);
|
||||
return padded;
|
||||
}
|
||||
return questData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.osleague.groupironmen.service;
|
||||
|
||||
import com.osleague.groupironmen.dto.UpdateMemberRequest;
|
||||
import com.osleague.groupironmen.exception.GroupFullException;
|
||||
import com.osleague.groupironmen.exception.MemberNotFoundException;
|
||||
import com.osleague.groupironmen.model.Group;
|
||||
import com.osleague.groupironmen.model.Member;
|
||||
import com.osleague.groupironmen.repository.GroupRepository;
|
||||
import com.osleague.groupironmen.repository.MemberRepository;
|
||||
import com.osleague.groupironmen.util.ValidationUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Service for managing group members.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MemberService {
|
||||
|
||||
private final MemberRepository memberRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
|
||||
private static final int MAX_GROUP_SIZE = 5;
|
||||
|
||||
/**
|
||||
* Update member data from RuneLite plugin.
|
||||
* Matches Rust update_group_member() logic.
|
||||
*
|
||||
* Only updates provided (non-null) fields, and sets
|
||||
* per-field *_last_update timestamps. The overall lastUpdated
|
||||
* field only changes if at least one value actually changed.
|
||||
*/
|
||||
@Transactional
|
||||
public void updateMember(Long groupId, UpdateMemberRequest request) {
|
||||
ValidationUtils.validateMemberName(request.getName());
|
||||
|
||||
Member member = memberRepository.findByGroupIdAndMemberName(groupId, request.getName())
|
||||
.orElseThrow(() -> new MemberNotFoundException(request.getName()));
|
||||
|
||||
Instant now = Instant.now();
|
||||
boolean changed = false;
|
||||
|
||||
if (request.getStats() != null) {
|
||||
member.setStats(request.getStats());
|
||||
member.setStatsLastUpdate(now);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (request.getCoordinates() != null) {
|
||||
member.setCoordinates(request.getCoordinates());
|
||||
member.setCoordinatesLastUpdate(now);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (request.getSkills() != null) {
|
||||
member.setSkills(request.getSkills());
|
||||
member.setSkillsLastUpdate(now);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (request.getQuests() != null) {
|
||||
member.setQuests(request.getQuests());
|
||||
member.setQuestsLastUpdate(now);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (request.getInventory() != null) {
|
||||
member.setInventory(request.getInventory());
|
||||
member.setInventoryLastUpdate(now);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (request.getEquipment() != null) {
|
||||
member.setEquipment(request.getEquipment());
|
||||
member.setEquipmentLastUpdate(now);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (request.getRunePouch() != null) {
|
||||
member.setRunePouch(request.getRunePouch());
|
||||
member.setRunePouchLastUpdate(now);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (request.getBank() != null) {
|
||||
member.setBank(request.getBank());
|
||||
member.setBankLastUpdate(now);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (request.getSeedVault() != null) {
|
||||
member.setSeedVault(request.getSeedVault());
|
||||
member.setSeedVaultLastUpdate(now);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (request.getDiaryVars() != null) {
|
||||
member.setDiaryVars(request.getDiaryVars());
|
||||
member.setDiaryVarsLastUpdate(now);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (request.getInteracting() != null) {
|
||||
member.setInteracting(request.getInteracting());
|
||||
member.setInteractingLastUpdate(now);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
member.setLastUpdated(now);
|
||||
memberRepository.save(member);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// === Add / Delete / Rename methods remain unchanged ===
|
||||
|
||||
@Transactional
|
||||
public void addMember(Long groupId, String memberName) {
|
||||
ValidationUtils.validateMemberName(memberName);
|
||||
|
||||
int currentSize = memberRepository.countByGroupId(groupId);
|
||||
if (currentSize >= MAX_GROUP_SIZE) {
|
||||
throw new GroupFullException();
|
||||
}
|
||||
|
||||
if (memberRepository.existsByGroupIdAndMemberName(groupId, memberName)) {
|
||||
throw new IllegalArgumentException("Member already exists in group");
|
||||
}
|
||||
|
||||
Group group = groupRepository.findById(groupId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Group not found"));
|
||||
|
||||
Member member = Member.builder()
|
||||
.memberName(memberName)
|
||||
.group(group)
|
||||
.build();
|
||||
|
||||
memberRepository.save(member);
|
||||
|
||||
log.info("Added member: {} to group: {}", memberName, groupId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteMember(Long groupId, String memberName) {
|
||||
if (!memberRepository.existsByGroupIdAndMemberName(groupId, memberName)) {
|
||||
throw new MemberNotFoundException(memberName);
|
||||
}
|
||||
|
||||
memberRepository.deleteByGroupGroupIdAndMemberName(groupId, memberName);
|
||||
log.info("Deleted member: {} from group: {}", memberName, groupId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void renameMember(Long groupId, String originalName, String newName) {
|
||||
ValidationUtils.validateMemberName(originalName);
|
||||
ValidationUtils.validateMemberName(newName);
|
||||
|
||||
Member member = memberRepository.findByGroupIdAndMemberName(groupId, originalName)
|
||||
.orElseThrow(() -> new MemberNotFoundException(originalName));
|
||||
|
||||
if (memberRepository.existsByGroupIdAndMemberName(groupId, newName)) {
|
||||
throw new IllegalArgumentException("Member with new name already exists");
|
||||
}
|
||||
|
||||
member.setMemberName(newName);
|
||||
memberRepository.save(member);
|
||||
|
||||
log.info("Renamed member: {} -> {} in group: {}", originalName, newName, groupId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.osleague.groupironmen.util;
|
||||
|
||||
import com.osleague.groupironmen.exception.ValidationException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Validation utilities for Group Ironmen data.
|
||||
* Matches Rust validation logic.
|
||||
*/
|
||||
public class ValidationUtils {
|
||||
|
||||
// Matches Rust regex: [^A-Za-z 0-9-_] (no match = valid)
|
||||
private static final Pattern INVALID_NAME_PATTERN = Pattern.compile("[^A-Za-z 0-9\\-_]");
|
||||
private static final String RESERVED_NAME = "@SHARED";
|
||||
|
||||
/**
|
||||
* Validate member name.
|
||||
* Rust logic: valid_name() in validators.rs
|
||||
*
|
||||
* Rules:
|
||||
* - 1-16 characters
|
||||
* - Only letters, numbers, spaces, hyphens, underscores
|
||||
* - Cannot be all whitespace
|
||||
* - Cannot be "@SHARED" (reserved)
|
||||
* - ASCII only
|
||||
*/
|
||||
public static void validateMemberName(String name) {
|
||||
if (name == null || name.isEmpty()) {
|
||||
throw new ValidationException("name", "cannot be empty");
|
||||
}
|
||||
|
||||
if (name.length() > 16) {
|
||||
throw new ValidationException("name", "must be 16 characters or less");
|
||||
}
|
||||
|
||||
if (name.isBlank()) {
|
||||
throw new ValidationException("name", "cannot be all whitespace");
|
||||
}
|
||||
|
||||
if (INVALID_NAME_PATTERN.matcher(name).find()) {
|
||||
throw new ValidationException("name", "contains invalid characters (only A-Z, a-z, 0-9, space, -, _ allowed)");
|
||||
}
|
||||
|
||||
if (RESERVED_NAME.equalsIgnoreCase(name)) {
|
||||
throw new ValidationException("name", "@SHARED is a reserved name");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate array field lengths.
|
||||
* Matches Rust validate_member_prop_length() logic.
|
||||
*/
|
||||
public static void validateArrayLength(String fieldName, List<Integer> array, int expectedLength) {
|
||||
if (array != null && array.size() != expectedLength) {
|
||||
throw new ValidationException(fieldName,
|
||||
String.format("expected %d elements, got %d", expectedLength, array.size()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate array field length with range.
|
||||
*/
|
||||
public static void validateArrayLengthRange(String fieldName, List<Integer> array, int min, int max) {
|
||||
if (array != null && (array.size() < min || array.size() > max)) {
|
||||
throw new ValidationException(fieldName,
|
||||
String.format("expected %d-%d elements, got %d", min, max, array.size()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate group name.
|
||||
*/
|
||||
public static void validateGroupName(String name) {
|
||||
if (name == null || name.isEmpty()) {
|
||||
throw new ValidationException("group name", "cannot be empty");
|
||||
}
|
||||
|
||||
if (name.length() > 64) {
|
||||
throw new ValidationException("group name", "must be 64 characters or less");
|
||||
}
|
||||
}
|
||||
}
|
||||
71
spring-backend/src/main/resources/application.yml
Normal file
71
spring-backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
spring:
|
||||
application:
|
||||
name: group-ironmen-backend
|
||||
|
||||
datasource:
|
||||
url: jdbc:mariadb://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:groupironman}?useSSL=false&allowPublicKeyRetrieval=true
|
||||
username: ${DB_USER:root}
|
||||
password: ${DB_PASSWORD:password}
|
||||
driver-class-name: org.mariadb.jdbc.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1800000
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.MariaDBDialect
|
||||
format_sql: true
|
||||
jdbc:
|
||||
batch_size: 20
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
open-in-view: false
|
||||
|
||||
flyway:
|
||||
enabled: true
|
||||
baseline-on-migrate: true
|
||||
locations: classpath:db/migration
|
||||
schemas: groupironman
|
||||
|
||||
jackson:
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
compression:
|
||||
enabled: true
|
||||
mime-types: application/json,application/xml,text/html,text/xml,text/plain
|
||||
error:
|
||||
include-message: always
|
||||
include-binding-errors: always
|
||||
|
||||
app:
|
||||
security:
|
||||
secret: ${BACKEND_SECRET:changeme_secret_key_for_production}
|
||||
captcha:
|
||||
enabled: ${CAPTCHA_ENABLED:false}
|
||||
sitekey: ${CAPTCHA_SITEKEY:}
|
||||
secret: ${CAPTCHA_SECRET:}
|
||||
ge-prices:
|
||||
url: https://prices.runescape.wiki/api/v1/osrs/latest
|
||||
cache-duration-hours: 4
|
||||
cors:
|
||||
allowed-origins: ${CORS_ORIGINS:http://localhost:3000,http://localhost:4000}
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
com.osleague: DEBUG
|
||||
org.springframework.web: INFO
|
||||
org.hibernate.SQL: DEBUG
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
||||
@@ -0,0 +1,150 @@
|
||||
-- Create groupironman database schema
|
||||
CREATE DATABASE IF NOT EXISTS groupironman;
|
||||
USE groupironman;
|
||||
|
||||
-- Groups table
|
||||
CREATE TABLE IF NOT EXISTS `groups` (
|
||||
`group_id` BIGINT AUTO_INCREMENT UNIQUE,
|
||||
`group_name` TEXT NOT NULL,
|
||||
`group_token_hash` VARCHAR(64) NOT NULL,
|
||||
`version` INT DEFAULT 1,
|
||||
PRIMARY KEY (`group_name`(255), `group_token_hash`),
|
||||
KEY `idx_group_id` (`group_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Members table
|
||||
CREATE TABLE IF NOT EXISTS `members` (
|
||||
`member_id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
`group_id` BIGINT NOT NULL,
|
||||
`member_name` TEXT NOT NULL,
|
||||
|
||||
-- Stats (HP, Prayer, Energy, World, etc.)
|
||||
`stats_last_update` TIMESTAMP NULL,
|
||||
`stats` JSON NULL COMMENT 'Array of 7 integers',
|
||||
|
||||
-- Coordinates (x, y, plane)
|
||||
`coordinates_last_update` TIMESTAMP NULL,
|
||||
`coordinates` JSON NULL COMMENT 'Array of 3 integers',
|
||||
|
||||
-- Skills (24 skills)
|
||||
`skills_last_update` TIMESTAMP NULL,
|
||||
`skills` JSON NULL COMMENT 'Array of 24 integers',
|
||||
|
||||
-- Quests (binary data)
|
||||
`quests_last_update` TIMESTAMP NULL,
|
||||
`quests` BLOB NULL,
|
||||
|
||||
-- Inventory (56 items)
|
||||
`inventory_last_update` TIMESTAMP NULL,
|
||||
`inventory` JSON NULL COMMENT 'Array of 56 integers',
|
||||
|
||||
-- Equipment (28 slots)
|
||||
`equipment_last_update` TIMESTAMP NULL,
|
||||
`equipment` JSON NULL COMMENT 'Array of 28 integers',
|
||||
|
||||
-- Rune pouch (8 runes)
|
||||
`rune_pouch_last_update` TIMESTAMP NULL,
|
||||
`rune_pouch` JSON NULL COMMENT 'Array of 8 integers',
|
||||
|
||||
-- Bank (variable length)
|
||||
`bank_last_update` TIMESTAMP NULL,
|
||||
`bank` JSON NULL COMMENT 'Array of integers',
|
||||
|
||||
-- Seed vault (variable length)
|
||||
`seed_vault_last_update` TIMESTAMP NULL,
|
||||
`seed_vault` JSON NULL COMMENT 'Array of integers',
|
||||
|
||||
-- Interacting NPC
|
||||
`interacting_last_update` TIMESTAMP NULL,
|
||||
`interacting` TEXT NULL,
|
||||
|
||||
-- Diary vars (62 integers)
|
||||
`diary_vars_last_update` TIMESTAMP NULL,
|
||||
`diary_vars` JSON NULL COMMENT 'Array of 62 integers',
|
||||
|
||||
CONSTRAINT `fk_members_group` FOREIGN KEY (`group_id`) REFERENCES `groups`(`group_id`) ON DELETE CASCADE,
|
||||
UNIQUE KEY `idx_members_group_name` (`group_id`, `member_name`(255))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Skills aggregation tables (day, month, year)
|
||||
CREATE TABLE IF NOT EXISTS `skills_day` (
|
||||
`member_id` BIGINT NOT NULL,
|
||||
`time` TIMESTAMP NOT NULL,
|
||||
`skills` JSON NOT NULL COMMENT 'Array of 24 integers',
|
||||
PRIMARY KEY (`member_id`, `time`),
|
||||
CONSTRAINT `fk_skills_day_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`member_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `skills_month` (
|
||||
`member_id` BIGINT NOT NULL,
|
||||
`time` TIMESTAMP NOT NULL,
|
||||
`skills` JSON NOT NULL COMMENT 'Array of 24 integers',
|
||||
PRIMARY KEY (`member_id`, `time`),
|
||||
CONSTRAINT `fk_skills_month_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`member_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `skills_year` (
|
||||
`member_id` BIGINT NOT NULL,
|
||||
`time` TIMESTAMP NOT NULL,
|
||||
`skills` JSON NOT NULL COMMENT 'Array of 24 integers',
|
||||
PRIMARY KEY (`member_id`, `time`),
|
||||
CONSTRAINT `fk_skills_year_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`member_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Aggregation tracking
|
||||
CREATE TABLE IF NOT EXISTS `aggregation_info` (
|
||||
`type` VARCHAR(50) PRIMARY KEY,
|
||||
`last_aggregation` TIMESTAMP NOT NULL DEFAULT '2000-01-01 00:00:00'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO `aggregation_info` (`type`) VALUES ('skills')
|
||||
ON DUPLICATE KEY UPDATE `type` = `type`;
|
||||
|
||||
-- Collection log tables
|
||||
CREATE TABLE IF NOT EXISTS `collection_tab` (
|
||||
`tab_id` SMALLINT AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(255) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO `collection_tab` (`tab_id`, `name`) VALUES
|
||||
(0, 'Bosses'),
|
||||
(1, 'Raids'),
|
||||
(2, 'Clues'),
|
||||
(3, 'Minigames'),
|
||||
(4, 'Other')
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `collection_page` (
|
||||
`page_id` SMALLINT AUTO_INCREMENT PRIMARY KEY,
|
||||
`tab_id` SMALLINT NOT NULL,
|
||||
`page_name` VARCHAR(255) NOT NULL,
|
||||
UNIQUE KEY `idx_tab_page` (`tab_id`, `page_name`),
|
||||
CONSTRAINT `fk_collection_page_tab` FOREIGN KEY (`tab_id`) REFERENCES `collection_tab`(`tab_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `collection_log` (
|
||||
`member_id` BIGINT NOT NULL,
|
||||
`page_id` SMALLINT NOT NULL,
|
||||
`items` JSON NULL COMMENT 'Array of item IDs',
|
||||
`counts` JSON NULL COMMENT 'Array of completion counts',
|
||||
`last_updated` TIMESTAMP NULL,
|
||||
PRIMARY KEY (`member_id`, `page_id`),
|
||||
CONSTRAINT `fk_collection_log_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`member_id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_collection_log_page` FOREIGN KEY (`page_id`) REFERENCES `collection_page`(`page_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `collection_log_new` (
|
||||
`member_id` BIGINT NOT NULL,
|
||||
`page_id` SMALLINT NOT NULL,
|
||||
`new_items` JSON NULL COMMENT 'Array of new item IDs',
|
||||
`last_updated` TIMESTAMP NULL,
|
||||
PRIMARY KEY (`member_id`, `page_id`),
|
||||
CONSTRAINT `fk_collection_log_new_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`member_id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_collection_log_new_page` FOREIGN KEY (`page_id`) REFERENCES `collection_page`(`page_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Migrations tracking
|
||||
CREATE TABLE IF NOT EXISTS `migrations` (
|
||||
`name` VARCHAR(255) PRIMARY KEY,
|
||||
`date` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
Reference in New Issue
Block a user