feat: shot-crafter-calculator with H2 persistence and production history

Quarkus 3.20.1 monolith serving React 18 + TypeScript + Tailwind SPA.

Features:
- Three-tab calculator (Insumos, Fórmulas, Calculadora) for
  Soulshot, Spiritshot and Blessed Spiritshot crafting in Lineage 2
  Interlude/Clásico with all 15 grades and pre-loaded recipes
- Real-time profitability computation (cristales → ore →
  crafteos → shots → cost → sale → ganancia)
- Multi-user auth with JWT in httpOnly cookie (bcrypt + RSA 2048)
- H2 file-based persistence in ./data/shots.mv.db (file-based, H2)
- Auto-save on state changes (debounced 500ms)
- Production history with stats (total/avg/best/worst/last5avg)
  and per-run detail modal with snapshot of insumos+formulas

Stack:
- Backend: Quarkus REST + Hibernate ORM Panache + smallrye-jwt
- Frontend: React 18 + TypeScript + Vite + Tailwind 3
- Build: Maven runs frontend-maven-plugin (Node 22 + npm ci)
  then copies dist to META-INF/resources for Quarkus to serve

Verified:
- 5 backend endpoints + 5 history endpoints with curl
- 35/35 browser tests via Playwright + Chromium
- All TS strict, all builds green
This commit is contained in:
2026-08-12 16:03:51 -04:00
commit 9c9fb3a2ca
57 changed files with 6260 additions and 0 deletions
@@ -0,0 +1,18 @@
package com.l2.shots.auth;
import java.time.Instant;
import java.util.UUID;
public class AuthMeResponse {
public UUID id;
public String username;
public Instant createdAt;
public AuthMeResponse() {}
public AuthMeResponse(UUID id, String username, Instant createdAt) {
this.id = id;
this.username = username;
this.createdAt = createdAt;
}
}
@@ -0,0 +1,123 @@
package com.l2.shots.auth;
import io.quarkus.security.Authenticated;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.util.Optional;
@Path("/api/auth")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AuthResource {
@Inject
AuthService authService;
@Inject
JwtCookieAuth jwtCookieAuth;
@ConfigProperty(name = "app.auth.cookie-name")
String cookieName;
@ConfigProperty(name = "app.auth.cookie-max-age-seconds")
int cookieMaxAge;
@POST
@Path("/register")
public Response register(Credentials creds) {
Optional<User> result = authService.register(creds.username, creds.password);
if (result.isEmpty()) {
return Response.status(409)
.entity(new ErrorBody("username no disponible o datos inválidos"))
.build();
}
User user = result.get();
String token = authService.buildToken(user.id);
return Response.ok(AuthService.toAuthMe(user))
.cookie(buildAuthCookie(token))
.build();
}
@POST
@Path("/login")
public Response login(Credentials creds) {
Optional<User> result = authService.authenticate(creds.username, creds.password);
if (result.isEmpty()) {
return Response.status(401)
.entity(new ErrorBody("credenciales inválidas"))
.build();
}
User user = result.get();
String token = authService.buildToken(user.id);
return Response.ok(AuthService.toAuthMe(user))
.cookie(buildAuthCookie(token))
.build();
}
@POST
@Path("/logout")
public Response logout() {
return Response.noContent()
.cookie(clearAuthCookie())
.build();
}
@GET
@Path("/me")
public Response me(@Context HttpHeaders headers) {
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
if (jwt.isEmpty()) {
return Response.status(401).build();
}
return authService.getUserFromToken(jwt.get())
.map(u -> Response.ok(u).build())
.orElse(Response.status(401).build());
}
@GET
@Path("/check")
@Authenticated
public Response check() {
return Response.ok().build();
}
private NewCookie buildAuthCookie(String token) {
return new NewCookie.Builder(cookieName)
.value(token)
.path("/")
.httpOnly(true)
.secure(false)
.sameSite(NewCookie.SameSite.LAX)
.maxAge(cookieMaxAge)
.build();
}
private NewCookie clearAuthCookie() {
return new NewCookie.Builder(cookieName)
.value("")
.path("/")
.httpOnly(true)
.secure(false)
.sameSite(NewCookie.SameSite.LAX)
.maxAge(0)
.build();
}
public static class ErrorBody {
public String error;
public ErrorBody() {}
public ErrorBody(String error) { this.error = error; }
}
}
@@ -0,0 +1,75 @@
package com.l2.shots.auth;
import io.quarkus.elytron.security.common.BcryptUtil;
import io.smallrye.jwt.build.Jwt;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
@ApplicationScoped
public class AuthService {
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]{3,30}$");
public static final int MIN_PASSWORD_LENGTH = 8;
@ConfigProperty(name = "mp.jwt.verify.issuer")
String issuer;
@Transactional
public Optional<User> register(String username, String password) {
if (username == null || password == null) return Optional.empty();
username = username.trim();
if (!USERNAME_PATTERN.matcher(username).matches()) return Optional.empty();
if (password.length() < MIN_PASSWORD_LENGTH) return Optional.empty();
if (User.findByUsernameCaseInsensitive(username) != null) return Optional.empty();
User user = new User();
user.id = UUID.randomUUID();
user.username = username.toLowerCase();
user.passwordHash = BcryptUtil.bcryptHash(password);
user.createdAt = Instant.now();
user.persist();
return Optional.of(user);
}
public Optional<User> authenticate(String username, String password) {
if (username == null || password == null) return Optional.empty();
User user = User.findByUsernameCaseInsensitive(username.trim());
if (user == null) return Optional.empty();
if (!BcryptUtil.matches(password, user.passwordHash)) return Optional.empty();
return Optional.of(user);
}
public String buildToken(UUID userId) {
return Jwt.issuer(issuer)
.subject(userId.toString())
.groups(Set.of("user"))
.expiresIn(Duration.ofSeconds(86400))
.sign();
}
public Optional<AuthMeResponse> getUserFromToken(JsonWebToken jwt) {
if (jwt == null || jwt.getSubject() == null) return Optional.empty();
try {
UUID userId = UUID.fromString(jwt.getSubject());
User user = User.findById(userId);
if (user == null) return Optional.empty();
return Optional.of(new AuthMeResponse(user.id, user.username, user.createdAt));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
public static AuthMeResponse toAuthMe(User user) {
return new AuthMeResponse(user.id, user.username, user.createdAt);
}
}
@@ -0,0 +1,6 @@
package com.l2.shots.auth;
public class Credentials {
public String username;
public String password;
}
@@ -0,0 +1,45 @@
package com.l2.shots.auth;
import io.smallrye.jwt.auth.principal.JWTParser;
import io.smallrye.jwt.auth.principal.ParseException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.HttpHeaders;
import java.util.Map;
import java.util.Optional;
@ApplicationScoped
public class JwtCookieAuth {
@Inject
JWTParser parser;
@ConfigProperty(name = "app.auth.cookie-name")
String cookieName;
public Optional<JsonWebToken> extractToken(HttpHeaders headers) {
String auth = headers.getHeaderString("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
return Optional.of(parseToken(auth.substring(7)));
}
Map<String, Cookie> cookies = headers.getCookies();
Cookie cookie = cookies.get(cookieName);
if (cookie != null && cookie.getValue() != null && !cookie.getValue().isEmpty()) {
return Optional.of(parseToken(cookie.getValue()));
}
return Optional.empty();
}
private JsonWebToken parseToken(String token) {
try {
return parser.parse(token);
} catch (ParseException e) {
return null;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package com.l2.shots.auth;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "users")
public class User extends PanacheEntityBase {
@Id
public UUID id;
@Column(unique = true, nullable = false, length = 30)
public String username;
@Column(name = "password_hash", nullable = false, length = 100)
public String passwordHash;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
public static User findByUsername(String username) {
return find("username", username.toLowerCase()).firstResult();
}
public static User findByUsernameCaseInsensitive(String username) {
return find("LOWER(username) = ?1", username.toLowerCase()).firstResult();
}
}
@@ -0,0 +1,29 @@
package com.l2.shots.auth;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "user_state")
public class UserState extends PanacheEntityBase {
@Id
@Column(name = "user_id")
public UUID userId;
@Column(name = "state_json", nullable = false, columnDefinition = "TEXT")
public String stateJson;
@Column(name = "updated_at", nullable = false)
public Instant updatedAt;
public static UserState findByUserId(UUID userId) {
return findById(userId);
}
}
@@ -0,0 +1,120 @@
package com.l2.shots.history;
import com.l2.shots.auth.JwtCookieAuth;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Path("/api/history")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class HistoryResource {
@Inject
HistoryService historyService;
@Inject
JwtCookieAuth jwtCookieAuth;
@POST
@Path("/runs")
public Response saveRun(@Context HttpHeaders headers, RunIn input) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
if (input == null || input.items == null || input.snapshot == null) {
return Response.status(400).entity("{\"error\":\"payload inválido\"}").build();
}
if (input.items.size() > 100) {
return Response.status(400).entity("{\"error\":\"demasiados items\"}").build();
}
if (input.label != null && input.label.length() > 100) {
return Response.status(400).entity("{\"error\":\"label demasiado largo\"}").build();
}
if (input.totalCristalesUsed <= 0) {
return Response.status(400).entity("{\"error\":\"no hay cristales usados\"}").build();
}
RunSummary saved = historyService.saveRun(userId.get(), input);
return Response.status(201).entity(saved).build();
}
@GET
@Path("/runs")
public Response list(@Context HttpHeaders headers) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
List<RunSummary> runs = historyService.listForUser(userId.get());
return Response.ok(runs).build();
}
@GET
@Path("/runs/{id}")
public Response get(@Context HttpHeaders headers, @PathParam("id") String idStr) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
UUID id;
try {
id = UUID.fromString(idStr);
} catch (IllegalArgumentException e) {
return Response.status(400).entity("{\"error\":\"id inválido\"}").build();
}
return historyService.getById(userId.get(), id)
.map(d -> Response.ok(d).build())
.orElse(Response.status(404).build());
}
@DELETE
@Path("/runs/{id}")
public Response delete(@Context HttpHeaders headers, @PathParam("id") String idStr) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
UUID id;
try {
id = UUID.fromString(idStr);
} catch (IllegalArgumentException e) {
return Response.status(400).entity("{\"error\":\"id inválido\"}").build();
}
boolean deleted = historyService.deleteForUser(userId.get(), id);
return deleted ? Response.noContent().build() : Response.status(404).build();
}
@GET
@Path("/stats")
public Response stats(@Context HttpHeaders headers) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
HistoryStats stats = historyService.computeStats(userId.get());
return Response.ok(stats).build();
}
private Optional<UUID> extractUserId(HttpHeaders headers) {
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
if (jwt.isEmpty()) return Optional.empty();
try {
return Optional.of(UUID.fromString(jwt.get().getSubject()));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
}
@@ -0,0 +1,127 @@
package com.l2.shots.history;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class HistoryService {
private final ObjectMapper mapper = new ObjectMapper();
public List<RunSummary> listForUser(UUID userId) {
return ProductionRun.<ProductionRun>list("userId = ?1 ORDER BY createdAt DESC", userId)
.stream()
.map(RunSummary::new)
.toList();
}
public Optional<RunDetails> getById(UUID userId, UUID id) {
ProductionRun entity = ProductionRun.find("id = ?1 AND userId = ?2", id, userId).firstResult();
if (entity == null) return Optional.empty();
try {
List<RunItem> items = mapper.readValue(entity.itemsJson, mapper.getTypeFactory()
.constructCollectionType(List.class, RunItem.class));
RunSnapshot snapshot = mapper.readValue(entity.snapshotJson, RunSnapshot.class);
return Optional.of(new RunDetails(entity, items, snapshot));
} catch (JsonProcessingException e) {
return Optional.empty();
}
}
@Transactional
public RunSummary saveRun(UUID userId, RunIn input) {
ProductionRun entity = new ProductionRun();
entity.id = UUID.randomUUID();
entity.userId = userId;
entity.createdAt = Instant.now();
entity.label = input.label;
entity.totalCost = input.totalCost;
entity.totalSale = input.totalSale;
entity.totalProfit = input.totalProfit;
entity.totalShots = input.totalShots;
entity.totalCristalesUsed = input.totalCristalesUsed;
entity.totalOreUsed = input.totalOreUsed;
try {
entity.itemsJson = mapper.writeValueAsString(input.items);
entity.snapshotJson = mapper.writeValueAsString(input.snapshot);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize run payload", e);
}
entity.persist();
return new RunSummary(entity);
}
@Transactional
public boolean deleteForUser(UUID userId, UUID id) {
return ProductionRun.delete("id = ?1 AND userId = ?2", id, userId) > 0;
}
public HistoryStats computeStats(UUID userId) {
List<ProductionRun> runs = ProductionRun.list(
"userId = ?1 ORDER BY createdAt DESC", userId);
HistoryStats stats = new HistoryStats();
stats.totalRuns = runs.size();
if (runs.isEmpty()) {
stats.totalCost = 0;
stats.totalSale = 0;
stats.totalProfit = 0;
stats.totalShots = 0;
stats.avgProfit = 0;
stats.avgCost = 0;
stats.avgSale = 0;
stats.bestRun = null;
stats.worstRun = null;
stats.last5Avg = 0;
stats.last10Avg = 0;
return stats;
}
long totalCost = 0;
long totalSale = 0;
long totalProfit = 0;
long totalShots = 0;
ProductionRun best = runs.get(0);
ProductionRun worst = runs.get(0);
for (ProductionRun r : runs) {
totalCost += r.totalCost;
totalSale += r.totalSale;
totalProfit += r.totalProfit;
totalShots += r.totalShots;
if (r.totalProfit > best.totalProfit) best = r;
if (r.totalProfit < worst.totalProfit) worst = r;
}
stats.totalCost = totalCost;
stats.totalSale = totalSale;
stats.totalProfit = totalProfit;
stats.totalShots = totalShots;
stats.avgProfit = totalProfit / runs.size();
stats.avgCost = totalCost / runs.size();
stats.avgSale = totalSale / runs.size();
stats.bestRun = new RunSummary(best);
stats.worstRun = new RunSummary(worst);
int n5 = Math.min(5, runs.size());
int n10 = Math.min(10, runs.size());
long sum5 = 0;
long sum10 = 0;
for (int i = 0; i < n10; i++) {
sum10 += runs.get(i).totalProfit;
if (i < n5) sum5 += runs.get(i).totalProfit;
}
stats.last5Avg = n5 > 0 ? sum5 / n5 : 0;
stats.last10Avg = n10 > 0 ? sum10 / n10 : 0;
return stats;
}
}
@@ -0,0 +1,16 @@
package com.l2.shots.history;
public class HistoryStats {
public int totalRuns;
public long totalCost;
public long totalSale;
public long totalProfit;
public long totalShots;
public long avgProfit;
public long avgCost;
public long avgSale;
public RunSummary bestRun;
public RunSummary worstRun;
public long last5Avg;
public long last10Avg;
}
@@ -0,0 +1,54 @@
package com.l2.shots.history;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "production_runs", indexes = {
@Index(name = "idx_runs_user_created", columnList = "user_id, created_at")
})
public class ProductionRun extends PanacheEntityBase {
@Id
public UUID id;
@Column(name = "user_id", nullable = false)
public UUID userId;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
@Column(length = 100)
public String label;
@Column(name = "total_cost", nullable = false)
public long totalCost;
@Column(name = "total_sale", nullable = false)
public long totalSale;
@Column(name = "total_profit", nullable = false)
public long totalProfit;
@Column(name = "total_shots", nullable = false)
public long totalShots;
@Column(name = "total_cristales_used", nullable = false)
public long totalCristalesUsed;
@Column(name = "total_ore_used", nullable = false)
public long totalOreUsed;
@Column(name = "items_json", nullable = false, columnDefinition = "TEXT")
public String itemsJson;
@Column(name = "snapshot_json", nullable = false, columnDefinition = "TEXT")
public String snapshotJson;
}
@@ -0,0 +1,16 @@
package com.l2.shots.history;
import java.util.List;
public class RunDetails extends RunSummary {
public List<RunItem> items;
public RunSnapshot snapshot;
public RunDetails() {}
public RunDetails(ProductionRun r, List<RunItem> items, RunSnapshot snapshot) {
super(r);
this.items = items;
this.snapshot = snapshot;
}
}
@@ -0,0 +1,15 @@
package com.l2.shots.history;
import java.util.List;
public class RunIn {
public String label;
public long totalCost;
public long totalSale;
public long totalProfit;
public long totalShots;
public long totalCristalesUsed;
public long totalOreUsed;
public List<RunItem> items;
public RunSnapshot snapshot;
}
@@ -0,0 +1,31 @@
package com.l2.shots.history;
public class RunItem {
public String tipo;
public String grado;
public int cristalesDisponibles;
public int cristalesUsados;
public int oreNecesario;
public int crafteosPosibles;
public int shotsObtenidos;
public long costoTotal;
public long valorVenta;
public long ganancia;
public RunItem() {}
public RunItem(String tipo, String grado, int cristalesDisponibles, int cristalesUsados,
int oreNecesario, int crafteosPosibles, int shotsObtenidos,
long costoTotal, long valorVenta, long ganancia) {
this.tipo = tipo;
this.grado = grado;
this.cristalesDisponibles = cristalesDisponibles;
this.cristalesUsados = cristalesUsados;
this.oreNecesario = oreNecesario;
this.crafteosPosibles = crafteosPosibles;
this.shotsObtenidos = shotsObtenidos;
this.costoTotal = costoTotal;
this.valorVenta = valorVenta;
this.ganancia = ganancia;
}
}
@@ -0,0 +1,16 @@
package com.l2.shots.history;
import java.util.List;
import java.util.Map;
public class RunSnapshot {
public Map<String, Object> insumos;
public List<Map<String, Object>> formulas;
public RunSnapshot() {}
public RunSnapshot(Map<String, Object> insumos, List<Map<String, Object>> formulas) {
this.insumos = insumos;
this.formulas = formulas;
}
}
@@ -0,0 +1,30 @@
package com.l2.shots.history;
import java.time.Instant;
import java.util.UUID;
public class RunSummary {
public UUID id;
public Instant createdAt;
public String label;
public long totalCost;
public long totalSale;
public long totalProfit;
public long totalShots;
public long totalCristalesUsed;
public long totalOreUsed;
public RunSummary() {}
public RunSummary(ProductionRun r) {
this.id = r.id;
this.createdAt = r.createdAt;
this.label = r.label;
this.totalCost = r.totalCost;
this.totalSale = r.totalSale;
this.totalProfit = r.totalProfit;
this.totalShots = r.totalShots;
this.totalCristalesUsed = r.totalCristalesUsed;
this.totalOreUsed = r.totalOreUsed;
}
}
@@ -0,0 +1,59 @@
package com.l2.shots.state;
import java.util.List;
import java.util.Map;
public class AppState {
public Insumos insumos;
public List<FormulaDto> formulas;
public Map<String, Map<String, Integer>> disponibles;
public AppState() {}
public AppState(Insumos insumos, List<FormulaDto> formulas, Map<String, Map<String, Integer>> disponibles) {
this.insumos = insumos;
this.formulas = formulas;
this.disponibles = disponibles;
}
public static class Insumos {
public Map<String, Integer> cristales;
public int soulOre;
public int spiritOre;
public Map<String, Map<String, Integer>> venta;
public Insumos() {}
public Insumos(Map<String, Integer> cristales, int soulOre, int spiritOre,
Map<String, Map<String, Integer>> venta) {
this.cristales = cristales;
this.soulOre = soulOre;
this.spiritOre = spiritOre;
this.venta = venta;
}
}
public static class FormulaDto {
public String id;
public String tipo;
public String grado;
public int cristalesReq;
public Integer soulOreReq;
public Integer spiritOreReq;
public int shotsObtenidos;
public FormulaDto() {}
public FormulaDto(String id, String tipo, String grado, int cristalesReq,
Integer soulOreReq, Integer spiritOreReq, int shotsObtenidos) {
this.id = id;
this.tipo = tipo;
this.grado = grado;
this.cristalesReq = cristalesReq;
this.soulOreReq = soulOreReq;
this.spiritOreReq = spiritOreReq;
this.shotsObtenidos = shotsObtenidos;
}
}
}
@@ -0,0 +1,74 @@
package com.l2.shots.state;
import com.l2.shots.auth.JwtCookieAuth;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.util.Optional;
import java.util.UUID;
@Path("/api/state")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class StateResource {
@Inject
StateService stateService;
@Inject
JwtCookieAuth jwtCookieAuth;
@GET
public Response get(@Context HttpHeaders headers) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
Optional<AppState> state = stateService.getForUser(userId.get());
return state.map(s -> Response.ok(s).build())
.orElse(Response.status(404).build());
}
@PUT
public Response put(@Context HttpHeaders headers, AppState state) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
if (state == null || state.insumos == null || state.formulas == null || state.disponibles == null) {
return Response.status(400).entity("{\"error\":\"estado inválido\"}").build();
}
if (state.formulas.size() > 200) {
return Response.status(400).entity("{\"error\":\"estado demasiado grande\"}").build();
}
stateService.saveForUser(userId.get(), state);
return Response.noContent().build();
}
@DELETE
public Response reset(@Context HttpHeaders headers) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
stateService.deleteForUser(userId.get());
return Response.noContent().build();
}
private Optional<UUID> extractUserId(HttpHeaders headers) {
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
if (jwt.isEmpty()) return Optional.empty();
try {
return Optional.of(UUID.fromString(jwt.get().getSubject()));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
}
@@ -0,0 +1,48 @@
package com.l2.shots.state;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.l2.shots.auth.UserState;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.time.Instant;
import java.util.Optional;
@ApplicationScoped
public class StateService {
private final ObjectMapper mapper = new ObjectMapper();
public Optional<AppState> getForUser(java.util.UUID userId) {
UserState entity = UserState.findByUserId(userId);
if (entity == null) return Optional.empty();
try {
return Optional.of(mapper.readValue(entity.stateJson, AppState.class));
} catch (JsonProcessingException e) {
return Optional.empty();
}
}
@Transactional
public void saveForUser(java.util.UUID userId, AppState state) {
try {
String json = mapper.writeValueAsString(state);
UserState entity = UserState.findByUserId(userId);
if (entity == null) {
entity = new UserState();
entity.userId = userId;
}
entity.stateJson = json;
entity.updatedAt = Instant.now();
entity.persist();
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize state", e);
}
}
@Transactional
public void deleteForUser(java.util.UUID userId) {
UserState.deleteById(userId);
}
}