feat: add character sales tracking with 12h countdown timer
CI / Build Native (push) Failing after 1m11s
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user