feat: add personajes page with level tracking and fix timer reactivity
CI / Build Native (push) Failing after 58s

- New /personajes page with CRUD for characters (name, class, level)
- Level editable inline, deletable (blocked if has active sales)
- ventas.html now loads characters list as datalist suggestions
- Fix timer: add reactive 'now' property so countdown updates
- Fix script loading: add defaults.js + app.js to ventas/personajes
- Fix table auto-reload: use loadCharacters()/loadSales() after mutations
- Timer now uses setTimeout recursion + visibilitychange handler
This commit is contained in:
2026-08-18 10:09:10 -04:00
parent af323da3d3
commit 661ac4d953
12 changed files with 574 additions and 10 deletions
@@ -0,0 +1,36 @@
package com.l2.shots.characters;
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 = "characters", indexes = {
@Index(name = "idx_characters_user", columnList = "user_id")
})
public class Character extends PanacheEntityBase {
@Id
public UUID id;
@Column(name = "user_id", nullable = false)
public UUID userId;
@Column(name = "name", nullable = false, length = 50)
public String name;
@Column(name = "character_class", nullable = false, length = 30)
public String characterClass;
@Column(name = "level", nullable = false)
public int level;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
}
@@ -0,0 +1,15 @@
package com.l2.shots.characters;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.UUID;
@RegisterForReflection
public record CharacterDto(
UUID id,
String name,
String characterClass,
int level,
Instant createdAt) {
}
@@ -0,0 +1,7 @@
package com.l2.shots.characters;
public class CharacterHasSalesException extends RuntimeException {
public CharacterHasSalesException(String message) {
super(message);
}
}
@@ -0,0 +1,28 @@
package com.l2.shots.characters;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
public record CharacterIn(
String name,
String characterClass,
int level) {
public CharacterIn {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name is required");
}
if (name.length() > 50) {
throw new IllegalArgumentException("name must be 50 chars or less");
}
if (characterClass == null || characterClass.isBlank()) {
throw new IllegalArgumentException("characterClass is required");
}
if (characterClass.length() > 30) {
throw new IllegalArgumentException("characterClass must be 30 chars or less");
}
if (level <= 0) {
throw new IllegalArgumentException("level must be greater than 0");
}
}
}
@@ -0,0 +1,119 @@
package com.l2.shots.characters;
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.PUT;
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/characters")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CharacterResource {
@Inject
CharacterService characterService;
@Inject
JwtCookieAuth jwtCookieAuth;
@GET
public Response list(@Context HttpHeaders headers) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
List<CharacterDto> characters = characterService.listForUser(userId.get());
return Response.ok(characters).build();
}
@POST
public Response create(@Context HttpHeaders headers, CharacterIn input) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
if (input == null) {
return Response.status(400).entity("{\"error\":\"payload inválido\"}").build();
}
try {
CharacterIn validated = new CharacterIn(
input.name(),
input.characterClass(),
input.level());
CharacterDto created = characterService.create(userId.get(), validated);
return Response.status(201).entity(created).build();
} catch (IllegalArgumentException e) {
return Response.status(400).entity("{\"error\":\"" + e.getMessage() + "\"}").build();
}
}
@PUT
@Path("/{id}/level")
public Response updateLevel(@Context HttpHeaders headers,
@PathParam("id") String idStr,
UpdateLevelRequest body) {
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();
}
if (body == null || body.level() <= 0) {
return Response.status(400).entity("{\"error\":\"level debe ser mayor a 0\"}").build();
}
Optional<CharacterDto> updated = characterService.updateLevel(userId.get(), id, body.level());
return updated.map(c -> Response.ok(c).build())
.orElse(Response.status(404).build());
}
@DELETE
@Path("/{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();
}
try {
characterService.delete(userId.get(), id);
return Response.noContent().build();
} catch (CharacterHasSalesException e) {
return Response.status(409).entity("{\"error\":\"" + e.getMessage() + "\"}").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();
}
}
public record UpdateLevelRequest(int level) {}
}
@@ -0,0 +1,72 @@
package com.l2.shots.characters;
import com.l2.shots.sales.CharacterSale;
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 CharacterService {
public List<CharacterDto> listForUser(UUID userId) {
return Character.<Character>list("userId = ?1 ORDER BY name ASC", userId)
.stream()
.map(this::toDto)
.toList();
}
public Optional<CharacterDto> getById(UUID userId, UUID id) {
Character entity = Character.find("id = ?1 AND userId = ?2", id, userId).firstResult();
if (entity == null) return Optional.empty();
return Optional.of(toDto(entity));
}
@Transactional
public CharacterDto create(UUID userId, CharacterIn input) {
Character entity = new Character();
entity.id = UUID.randomUUID();
entity.userId = userId;
entity.name = input.name();
entity.characterClass = input.characterClass();
entity.level = input.level();
entity.createdAt = Instant.now();
entity.persist();
return toDto(entity);
}
@Transactional
public Optional<CharacterDto> updateLevel(UUID userId, UUID id, int newLevel) {
Character entity = Character.find("id = ?1 AND userId = ?2", id, userId).firstResult();
if (entity == null) return Optional.empty();
entity.level = newLevel;
return Optional.of(toDto(entity));
}
@Transactional
public void delete(UUID userId, UUID id) {
Character entity = Character.find("id = ?1 AND userId = ?2", id, userId).firstResult();
if (entity == null) return;
boolean hasSales = CharacterSale.count("userId = ?1 AND characterName = ?2",
userId, entity.name) > 0;
if (hasSales) {
throw new CharacterHasSalesException(
"No se puede eliminar el personaje porque tiene ventas asociadas");
}
entity.delete();
}
private CharacterDto toDto(Character entity) {
return new CharacterDto(
entity.id,
entity.name,
entity.characterClass,
entity.level,
entity.createdAt);
}
}
@@ -33,6 +33,7 @@ public class PageResource {
public static native TemplateInstance historial();
public static native TemplateInstance usuarios();
public static native TemplateInstance ventas();
public static native TemplateInstance personajes();
}
private Optional<AuthMeResponse> currentUser(HttpHeaders headers) {
@@ -170,4 +171,17 @@ public class PageResource {
}
return Response.ok(Pages.ventas().render()).build();
}
@GET
@Path("/personajes")
public Response personajes(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isEmpty()) {
return Response.seeOther(URI.create("/login")).build();
}
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
return Response.ok(Pages.personajes().render()).build();
}
}