diff --git a/src/main/java/com/l2/shots/characters/Character.java b/src/main/java/com/l2/shots/characters/Character.java new file mode 100644 index 0000000..fe63d47 --- /dev/null +++ b/src/main/java/com/l2/shots/characters/Character.java @@ -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; +} diff --git a/src/main/java/com/l2/shots/characters/CharacterDto.java b/src/main/java/com/l2/shots/characters/CharacterDto.java new file mode 100644 index 0000000..64b5749 --- /dev/null +++ b/src/main/java/com/l2/shots/characters/CharacterDto.java @@ -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) { +} diff --git a/src/main/java/com/l2/shots/characters/CharacterHasSalesException.java b/src/main/java/com/l2/shots/characters/CharacterHasSalesException.java new file mode 100644 index 0000000..927c131 --- /dev/null +++ b/src/main/java/com/l2/shots/characters/CharacterHasSalesException.java @@ -0,0 +1,7 @@ +package com.l2.shots.characters; + +public class CharacterHasSalesException extends RuntimeException { + public CharacterHasSalesException(String message) { + super(message); + } +} diff --git a/src/main/java/com/l2/shots/characters/CharacterIn.java b/src/main/java/com/l2/shots/characters/CharacterIn.java new file mode 100644 index 0000000..c5d005f --- /dev/null +++ b/src/main/java/com/l2/shots/characters/CharacterIn.java @@ -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"); + } + } +} diff --git a/src/main/java/com/l2/shots/characters/CharacterResource.java b/src/main/java/com/l2/shots/characters/CharacterResource.java new file mode 100644 index 0000000..558e883 --- /dev/null +++ b/src/main/java/com/l2/shots/characters/CharacterResource.java @@ -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 userId = extractUserId(headers); + if (userId.isEmpty()) return Response.status(401).build(); + + List characters = characterService.listForUser(userId.get()); + return Response.ok(characters).build(); + } + + @POST + public Response create(@Context HttpHeaders headers, CharacterIn input) { + Optional 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 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 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 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 extractUserId(HttpHeaders headers) { + Optional 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) {} +} diff --git a/src/main/java/com/l2/shots/characters/CharacterService.java b/src/main/java/com/l2/shots/characters/CharacterService.java new file mode 100644 index 0000000..18cb70e --- /dev/null +++ b/src/main/java/com/l2/shots/characters/CharacterService.java @@ -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 listForUser(UUID userId) { + return Character.list("userId = ?1 ORDER BY name ASC", userId) + .stream() + .map(this::toDto) + .toList(); + } + + public Optional 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 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); + } +} diff --git a/src/main/java/com/l2/shots/ui/PageResource.java b/src/main/java/com/l2/shots/ui/PageResource.java index 45ff46d..ee22c1b 100644 --- a/src/main/java/com/l2/shots/ui/PageResource.java +++ b/src/main/java/com/l2/shots/ui/PageResource.java @@ -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 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 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(); + } } diff --git a/src/main/resources/META-INF/resources/static/js/api.js b/src/main/resources/META-INF/resources/static/js/api.js index 43c5757..110e663 100644 --- a/src/main/resources/META-INF/resources/static/js/api.js +++ b/src/main/resources/META-INF/resources/static/js/api.js @@ -124,4 +124,26 @@ window.api = { deleteSale(id) { return this.request(`/api/sales/${id}`, { method: 'DELETE' }); }, + + listCharacters() { + return this.request('/api/characters'); + }, + + createCharacter(payload) { + return this.request('/api/characters', { + method: 'POST', + body: JSON.stringify(payload), + }); + }, + + updateCharacterLevel(id, level) { + return this.request(`/api/characters/${id}/level`, { + method: 'PUT', + body: JSON.stringify({ level }), + }); + }, + + deleteCharacter(id) { + return this.request(`/api/characters/${id}`, { method: 'DELETE' }); + }, }; diff --git a/src/main/resources/db/migration/V3__characters.sql b/src/main/resources/db/migration/V3__characters.sql new file mode 100644 index 0000000..57c21ea --- /dev/null +++ b/src/main/resources/db/migration/V3__characters.sql @@ -0,0 +1,10 @@ +CREATE TABLE characters ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + name VARCHAR(50) NOT NULL, + character_class VARCHAR(30) NOT NULL, + level INT NOT NULL, + created_at TIMESTAMP NOT NULL +); + +CREATE INDEX idx_characters_user ON characters (user_id); diff --git a/src/main/resources/templates/PageResource/personajes.html b/src/main/resources/templates/PageResource/personajes.html new file mode 100644 index 0000000..5dd396b --- /dev/null +++ b/src/main/resources/templates/PageResource/personajes.html @@ -0,0 +1,207 @@ + + + + + + + Personajes — Calculadora de Craft de Shots + + + + + + {#include partials/header.html /} + {#include partials/tab-bar.html /} + +
+ +
+
+

Agregar personaje

+
+
+
+ + + +
+
+ +
+

+
+
+ +
+
+

Mis personajes

+

+
+ +
+

Cargando…

+
+ +
+

No hay personajes. Agregá uno arriba.

+
+ +
+ + + + + + + + + + + + +
NombreClaseNivelAcciones
+
+
+ +
+ +
+

+ Hacé clic en el nivel para editarlo inline · Enter o perder foco guarda +

+
+ + + + + + + + + + diff --git a/src/main/resources/templates/PageResource/ventas.html b/src/main/resources/templates/PageResource/ventas.html index 572d6c2..e6e35dc 100644 --- a/src/main/resources/templates/PageResource/ventas.html +++ b/src/main/resources/templates/PageResource/ventas.html @@ -28,8 +28,14 @@ x-model="form.characterName" maxlength="50" required - placeholder="p.ej. DarkMage"/> + list="character-suggestions" + placeholder="Seleccioná o escribí un nombre"/> + + +