feat: add character sales tracking with 12h countdown timer
CI / Build Native (push) Failing after 1m11s

- New page /ventas to track characters selling items
- Default 12h duration per character sale
- Real-time countdown with color-coded badges (green/yellow/red/gray)
- Sound alert toggle when sales expire
- CRUD API: GET/POST/DELETE /api/sales
- Flyway migration V2 for character_sales table
- Private per-user (same user sees only their own characters)
- Expired sales remain visible with 'Expirado' badge until manually deleted
This commit is contained in:
2026-08-18 09:22:41 -04:00
parent 8ca11252d7
commit af323da3d3
11 changed files with 848 additions and 0 deletions
@@ -0,0 +1,36 @@
package com.l2.shots.sales;
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 = "character_sales", indexes = {
@Index(name = "idx_char_sales_user", columnList = "user_id, started_at")
})
public class CharacterSale extends PanacheEntityBase {
@Id
public UUID id;
@Column(name = "user_id", nullable = false)
public UUID userId;
@Column(name = "character_name", nullable = false, length = 50)
public String characterName;
@Column(name = "items_description", nullable = false, columnDefinition = "TEXT")
public String itemsDescription;
@Column(name = "started_at", nullable = false)
public Instant startedAt;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
}
@@ -0,0 +1,16 @@
package com.l2.shots.sales;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.UUID;
@RegisterForReflection
public record CharacterSaleDto(
UUID id,
String characterName,
String itemsDescription,
Instant startedAt,
Instant expiresAt,
Instant now) {
}
@@ -0,0 +1,27 @@
package com.l2.shots.sales;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
@RegisterForReflection
public record CharacterSaleIn(
String characterName,
String itemsDescription,
Instant startedAt) {
public CharacterSaleIn {
if (characterName == null || characterName.isBlank()) {
throw new IllegalArgumentException("characterName is required");
}
if (characterName.length() > 50) {
throw new IllegalArgumentException("characterName must be 50 chars or less");
}
if (itemsDescription == null || itemsDescription.isBlank()) {
throw new IllegalArgumentException("itemsDescription is required");
}
if (itemsDescription.length() > 2000) {
throw new IllegalArgumentException("itemsDescription must be 2000 chars or less");
}
}
}
@@ -0,0 +1,88 @@
package com.l2.shots.sales;
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/sales")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CharacterSaleResource {
@Inject
CharacterSaleService saleService;
@Inject
JwtCookieAuth jwtCookieAuth;
@GET
public Response list(@Context HttpHeaders headers) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
List<CharacterSaleDto> sales = saleService.listForUser(userId.get());
return Response.ok(sales).build();
}
@POST
public Response create(@Context HttpHeaders headers, CharacterSaleIn 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 {
CharacterSaleIn validated = new CharacterSaleIn(
input.characterName(),
input.itemsDescription(),
input.startedAt());
CharacterSaleDto saved = saleService.create(userId.get(), validated);
return Response.status(201).entity(saved).build();
} catch (IllegalArgumentException e) {
return Response.status(400).entity("{\"error\":\"" + e.getMessage() + "\"}").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();
}
boolean deleted = saleService.deleteForUser(userId.get(), id);
return deleted ? Response.noContent().build() : Response.status(404).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,59 @@
package com.l2.shots.sales;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class CharacterSaleService {
public static final Duration DEFAULT_DURATION = Duration.ofHours(12);
public List<CharacterSaleDto> listForUser(UUID userId) {
return CharacterSale.<CharacterSale>list("userId = ?1 ORDER BY createdAt DESC", userId)
.stream()
.map(this::toDto)
.toList();
}
public Optional<CharacterSaleDto> getById(UUID userId, UUID id) {
CharacterSale entity = CharacterSale.find("id = ?1 AND userId = ?2", id, userId).firstResult();
if (entity == null) return Optional.empty();
return Optional.of(toDto(entity));
}
@Transactional
public CharacterSaleDto create(UUID userId, CharacterSaleIn input) {
CharacterSale entity = new CharacterSale();
entity.id = UUID.randomUUID();
entity.userId = userId;
entity.characterName = input.characterName();
entity.itemsDescription = input.itemsDescription();
entity.startedAt = input.startedAt() != null ? input.startedAt() : Instant.now();
entity.createdAt = Instant.now();
entity.persist();
return toDto(entity);
}
@Transactional
public boolean deleteForUser(UUID userId, UUID id) {
return CharacterSale.delete("id = ?1 AND userId = ?2", id, userId) > 0;
}
private CharacterSaleDto toDto(CharacterSale entity) {
Instant now = Instant.now();
Instant expiresAt = entity.startedAt.plus(DEFAULT_DURATION);
return new CharacterSaleDto(
entity.id,
entity.characterName,
entity.itemsDescription,
entity.startedAt,
expiresAt,
now);
}
}
@@ -0,0 +1,173 @@
package com.l2.shots.ui;
import com.l2.shots.auth.AuthMeResponse;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
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 java.net.URI;
import java.util.Optional;
@Path("/")
@Produces(MediaType.TEXT_HTML)
public class PageResource {
@Inject
TemplateData templateData;
@CheckedTemplate
static class Pages {
public static native TemplateInstance login();
public static native TemplateInstance register();
public static native TemplateInstance changePassword();
public static native TemplateInstance insumos();
public static native TemplateInstance formulas();
public static native TemplateInstance calculadora();
public static native TemplateInstance historial();
public static native TemplateInstance usuarios();
public static native TemplateInstance ventas();
}
private Optional<AuthMeResponse> currentUser(HttpHeaders headers) {
return templateData.currentUser(headers);
}
@GET
public Response root(@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.seeOther(URI.create("/insumos")).build();
}
@GET
@Path("/login")
public Response login(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isPresent()) {
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
return Response.seeOther(URI.create("/insumos")).build();
}
return Response.ok(Pages.login().render()).build();
}
@GET
@Path("/register")
public Response register(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isPresent()) {
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
return Response.seeOther(URI.create("/insumos")).build();
}
return Response.ok(Pages.register().render()).build();
}
@GET
@Path("/change-password")
public Response changePassword(@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("/insumos")).build();
}
return Response.ok(Pages.changePassword().render()).build();
}
@GET
@Path("/insumos")
public Response insumos(@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.insumos().render()).build();
}
@GET
@Path("/formulas")
public Response formulas(@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.formulas().render()).build();
}
@GET
@Path("/calculadora")
public Response calculadora(@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.calculadora().render()).build();
}
@GET
@Path("/historial")
public Response historial(@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.historial().render()).build();
}
@GET
@Path("/usuarios")
public Response usuarios(@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();
}
if (!Boolean.TRUE.equals(user.get().isAdmin())) {
return Response.seeOther(URI.create("/insumos")).build();
}
return Response.ok(Pages.usuarios().render()).build();
}
@GET
@Path("/ventas")
public Response ventas(@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.ventas().render()).build();
}
}