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,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; }
}
}