feat: app completa recordaLexia (fases 1-5)

App web familiar de rutinas visuales para niños con TDAH: muestra cada día el
material del cole y las rutinas de tarde, con gamificación por monedas y tienda
de recompensas. Multi-niño y bilingüe ES/CA. Uso doméstico/homelab.

Backend (Spring Boot 3.5 / Java 21 / Gradle):
- Dominio por capas, PostgreSQL + Liquibase, datos semilla.
- API REST con DTOs: /today, toggle con monedas y bonos de bloque/día, monedero,
  tienda/canje, ajustes y CRUD del panel de padres.
- Seguridad ligera por PIN (BCrypt + sesion en memoria), sin Keycloak.
- Tests JUnit: generacion del dia, monedas/bonos con reversion, canje, seguridad.

Frontend (Angular 19, standalone + signals):
- Perfiles, Home (Tablero y Foco), Tienda y panel de padres (5 pestañas).
- Tipografia OpenDyslexic conmutable (accesibilidad), i18n ES/CA, TTS y sonido.
- Tokens de diseño fieles al handoff (paleta, animaciones, monedas voladoras).

Empaquetado:
- Docker multi-stage + docker-compose (PostgreSQL + backend + Nginx).
- Decisiones de arquitectura documentadas en docs/adr.
This commit is contained in:
Jaume Garriga Maestre
2026-06-21 10:48:57 +02:00
commit 52e559a159
160 changed files with 29022 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
package es.asepeyo.recordalexia;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RecordalexiaApplication {
public static void main(String[] args) {
SpringApplication.run(RecordalexiaApplication.class, args);
}
}

View File

@@ -0,0 +1,209 @@
package es.asepeyo.recordalexia.bootstrap;
import es.asepeyo.recordalexia.domain.Activity;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.EventType;
import es.asepeyo.recordalexia.domain.MaterialItem;
import es.asepeyo.recordalexia.domain.ParentUser;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.domain.SpecialEvent;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
import es.asepeyo.recordalexia.repository.ParentUserRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import java.time.Clock;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Siembra los datos de ejemplo del prototipo (niños, material, actividades, horario,
* rutinas, eventos, premios y el PIN de padres por defecto). Solo actúa si la base
* de datos está vacía, así que es seguro arrancar varias veces.
*
* Se desactiva con recordalexia.seed.enabled=false (lo hacen los tests, que montan
* sus propios datos deterministas).
*/
@Component
@ConditionalOnProperty(name = "recordalexia.seed.enabled", havingValue = "true", matchIfMissing = true)
public class DataSeeder implements ApplicationRunner {
/** PIN de padres por defecto; configurable (el panel permite cambiarlo). */
private static final String DEFAULT_PIN = "1234";
private final ChildRepository childRepository;
private final ParentUserRepository parentUserRepository;
private final MaterialItemRepository materialRepository;
private final ActivityRepository activityRepository;
private final WeeklyTemplateEntryRepository templateRepository;
private final AfternoonRoutineRepository routineRepository;
private final SpecialEventRepository eventRepository;
private final RewardRepository rewardRepository;
private final PasswordEncoder passwordEncoder;
private final Clock clock;
public DataSeeder(ChildRepository childRepository, ParentUserRepository parentUserRepository,
MaterialItemRepository materialRepository, ActivityRepository activityRepository,
WeeklyTemplateEntryRepository templateRepository,
AfternoonRoutineRepository routineRepository,
SpecialEventRepository eventRepository, RewardRepository rewardRepository,
PasswordEncoder passwordEncoder, Clock clock) {
this.childRepository = childRepository;
this.parentUserRepository = parentUserRepository;
this.materialRepository = materialRepository;
this.activityRepository = activityRepository;
this.templateRepository = templateRepository;
this.routineRepository = routineRepository;
this.eventRepository = eventRepository;
this.rewardRepository = rewardRepository;
this.passwordEncoder = passwordEncoder;
this.clock = clock;
}
@Override
@Transactional
public void run(ApplicationArguments args) {
if (childRepository.count() > 0) {
return; // Ya sembrado: no duplicar.
}
seedParent();
var materials = seedMaterials();
var activities = seedActivities(materials);
seedRewards();
// Tres niños del prototipo, cada uno con su horario y rutinas.
Child nora = childRepository.save(child("Nora", "🦊", "#F2A65A", 7, 42, LocalTime.of(8, 30)));
Child leo = childRepository.save(child("Leo", "🐢", "#5BC0BE", 9, 28, LocalTime.of(8, 30)));
Child mia = childRepository.save(child("Mía", "🦉", "#A78BD0", 6, 55, LocalTime.of(8, 15)));
for (Child c : List.of(nora, leo, mia)) {
seedWeeklyMornings(c, activities);
seedAfternoonRoutines(c);
}
// Un par de eventos para Nora alrededor de hoy, para poder probar /today.
LocalDate today = LocalDate.now(clock);
eventRepository.save(new SpecialEvent(nora, today, EventType.EXAM,
"Examen de Lengua", "Examen de Llengua", "📋", "#EC8FA4"));
eventRepository.save(new SpecialEvent(nora, today.plusDays(1), EventType.HOMEWORK,
"Ficha de mates", "Fitxa de mates", "📎", "#5B8DEF"));
}
private void seedParent() {
parentUserRepository.save(new ParentUser(passwordEncoder.encode(DEFAULT_PIN)));
}
private MaterialsCatalog seedMaterials() {
return new MaterialsCatalog(
material("Estuche", "Estoig", "✏️", "#F4C95D", "general"),
material("Libro de mates", "Llibre de mates", "📘", "#5B8DEF", "matematicas"),
material("Regla", "Regle", "📏", "#5B8DEF", "matematicas"),
material("Flauta", "Flauta", "🎵", "#A78BD0", "musica"),
material("Libreta", "Llibreta", "📓", "#A78BD0", "musica"),
material("Ropa de gimnasia", "Roba d'EF", "👕", "#7FBF6B", "gimnasia"),
material("Zapatillas", "Sabatilles", "👟", "#7FBF6B", "gimnasia"),
material("Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia"),
material("Agua", "Aigua", "💧", "#5BC0BE", "gimnasia"),
material("Lectura", "Lectura", "📖", "#F2A65A", "lengua"),
material("Cuaderno", "Quadern", "📒", "#F2A65A", "lengua"),
material("Almuerzo", "Esmorzar", "🍎", "#EC8FA4", "general"));
}
private MaterialItem material(String es, String ca, String icon, String color, String category) {
return materialRepository.save(new MaterialItem(es, ca, icon, color, category));
}
private ActivitiesCatalog seedActivities(MaterialsCatalog m) {
Activity gimnasia = activity("Gimnasia", "Gimnàstica", "🤸", "#7FBF6B",
m.ropaGimnasia, m.zapatillas, m.toalla, m.agua);
Activity musica = activity("Música", "Música", "🎵", "#A78BD0", m.flauta, m.libreta);
Activity mates = activity("Matemáticas", "Matemàtiques", "📘", "#5B8DEF",
m.libroMates, m.regla, m.estuche);
Activity lengua = activity("Lengua", "Llengua", "📖", "#F2A65A", m.lectura, m.cuaderno);
return new ActivitiesCatalog(gimnasia, musica, mates, lengua);
}
private Activity activity(String es, String ca, String icon, String color, MaterialItem... mats) {
Activity activity = new Activity(es, ca, icon, color);
for (MaterialItem mat : mats) {
activity.addMaterial(mat);
}
return activityRepository.save(activity);
}
private void seedRewards() {
rewardRepository.saveAll(List.of(
new Reward("30 min de tablet", "30 min de tauleta", "🎮", "#5B8DEF", 20),
new Reward("Elijo la cena", "Trio el sopar", "🍕", "#F2A65A", 30),
new Reward("Tarde en el parque", "Tarda al parc", "🛝", "#7FBF6B", 40),
new Reward("Peli en familia", "Pel·lícula en família", "🍿", "#A78BD0", 50),
new Reward("30 min más despierto", "30 min més despert", "🌙", "#5BC0BE", 60),
new Reward("Sorpresa dino", "Sorpresa dino", "🦖", "#EC8FA4", 80)));
}
private Child child(String name, String mascot, String color, int age, int coins, LocalTime departure) {
Child c = new Child();
c.setName(name);
c.setMascot(mascot);
c.setAccentColor(color);
c.setAge(age);
c.setCoins(coins);
c.setDepartureTime(departure);
return c;
}
/** Plantilla de mañana de lunes a viernes (cada día, una actividad del cole). */
private void seedWeeklyMornings(Child child, ActivitiesCatalog a) {
addWeekly(child, DayOfWeek.MONDAY, a.matematicas);
addWeekly(child, DayOfWeek.TUESDAY, a.gimnasia);
addWeekly(child, DayOfWeek.WEDNESDAY, a.musica);
addWeekly(child, DayOfWeek.THURSDAY, a.lengua);
addWeekly(child, DayOfWeek.FRIDAY, a.gimnasia);
}
private void addWeekly(Child child, DayOfWeek day, Activity activity) {
templateRepository.save(new WeeklyTemplateEntry(child, day, activity, 0));
}
/** Rutinas de tarde, iguales de lunes a viernes. */
private void seedAfternoonRoutines(Child child) {
for (DayOfWeek day : List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)) {
routineRepository.save(new AfternoonRoutine(child, day, "Deshacer la mochila",
"Buidar la motxilla", "🎒", "#F2A65A", 0));
routineRepository.save(new AfternoonRoutine(child, day, "Merendar",
"Berenar", "🥪", "#F4C95D", 1));
routineRepository.save(new AfternoonRoutine(child, day, "Hacer los deberes",
"Fer els deures", "📝", "#5B8DEF", 2));
routineRepository.save(new AfternoonRoutine(child, day, "Practicar piano",
"Practicar piano", "🎹", "#A78BD0", 3));
routineRepository.save(new AfternoonRoutine(child, day, "Recoger la mesa",
"Parar taula", "🍽️", "#7FBF6B", 4));
}
}
// Pequeños contenedores para pasar el catálogo sembrado con nombres claros.
private record MaterialsCatalog(
MaterialItem estuche, MaterialItem libroMates, MaterialItem regla, MaterialItem flauta,
MaterialItem libreta, MaterialItem ropaGimnasia, MaterialItem zapatillas, MaterialItem toalla,
MaterialItem agua, MaterialItem lectura, MaterialItem cuaderno, MaterialItem almuerzo) {
}
private record ActivitiesCatalog(Activity gimnasia, Activity musica, Activity matematicas,
Activity lengua) {
}
}

View File

@@ -0,0 +1,22 @@
package es.asepeyo.recordalexia.config;
import java.time.Clock;
import java.time.ZoneId;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Reloj de la aplicación fijado a Europe/Madrid. Inyectar este Clock (en vez de
* usar LocalDate.now() directamente) permite que el negocio decida qué es "hoy"
* de forma consistente y que los tests controlen la fecha.
*/
@Configuration
public class TimeConfig {
public static final ZoneId ZONE_MADRID = ZoneId.of("Europe/Madrid");
@Bean
public Clock clock() {
return Clock.system(ZONE_MADRID);
}
}

View File

@@ -0,0 +1,98 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Actividad del cole (Gimnasia, Música...). Cada actividad arrastra el material
* necesario mediante una relación N:M con {@link MaterialItem}.
*/
@Entity
@Table(name = "activity")
public class Activity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "label_es")
private String labelEs;
@Column(name = "label_ca")
private String labelCa;
private String icon;
private String color;
// N:M: el material que hay que llevar cuando toca esta actividad.
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "activity_material",
joinColumns = @JoinColumn(name = "activity_id"),
inverseJoinColumns = @JoinColumn(name = "material_item_id"))
private Set<MaterialItem> materials = new LinkedHashSet<>();
protected Activity() {
}
public Activity(String labelEs, String labelCa, String icon, String color) {
this.labelEs = labelEs;
this.labelCa = labelCa;
this.icon = icon;
this.color = color;
}
public void addMaterial(MaterialItem material) {
this.materials.add(material);
}
public Long getId() {
return id;
}
public String getLabelEs() {
return labelEs;
}
public void setLabelEs(String labelEs) {
this.labelEs = labelEs;
}
public String getLabelCa() {
return labelCa;
}
public void setLabelCa(String labelCa) {
this.labelCa = labelCa;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public Set<MaterialItem> getMaterials() {
return materials;
}
}

View File

@@ -0,0 +1,130 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.DayOfWeek;
/** Rutina de tarde recurrente de un niño para un día de la semana (reordenable). */
@Entity
@Table(name = "afternoon_routine")
public class AfternoonRoutine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@Enumerated(EnumType.STRING)
@Column(name = "day_of_week")
private DayOfWeek dayOfWeek;
@Column(name = "label_es")
private String labelEs;
@Column(name = "label_ca")
private String labelCa;
private String icon;
private String color;
@Column(name = "order_index")
private int orderIndex;
/** Monedas de la rutina; si es null se usa coinsPerTask del niño. */
@Column(name = "coins_reward")
private Integer coinsReward;
protected AfternoonRoutine() {
}
public AfternoonRoutine(Child child, DayOfWeek dayOfWeek, String labelEs, String labelCa,
String icon, String color, int orderIndex) {
this.child = child;
this.dayOfWeek = dayOfWeek;
this.labelEs = labelEs;
this.labelCa = labelCa;
this.icon = icon;
this.color = color;
this.orderIndex = orderIndex;
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public void setChild(Child child) {
this.child = child;
}
public DayOfWeek getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(DayOfWeek dayOfWeek) {
this.dayOfWeek = dayOfWeek;
}
public String getLabelEs() {
return labelEs;
}
public void setLabelEs(String labelEs) {
this.labelEs = labelEs;
}
public String getLabelCa() {
return labelCa;
}
public void setLabelCa(String labelCa) {
this.labelCa = labelCa;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public int getOrderIndex() {
return orderIndex;
}
public void setOrderIndex(int orderIndex) {
this.orderIndex = orderIndex;
}
public Integer getCoinsReward() {
return coinsReward;
}
public void setCoinsReward(Integer coinsReward) {
this.coinsReward = coinsReward;
}
}

View File

@@ -0,0 +1,210 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalTime;
/**
* Niño/a que usa la app. Centraliza su saldo de monedas, sus ajustes (modo de
* vista, sonido, TTS, idioma, hora de salida) y sus parámetros de gamificación.
*/
@Entity
@Table(name = "child")
public class Child {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String mascot;
@Column(name = "accent_color")
private String accentColor;
private int age;
/** Saldo actual de monedas. Nunca se modifica directamente desde fuera. */
private int coins;
/** Hora de salida de la mañana; alimenta el temporizador del frontend. */
@Column(name = "departure_time")
private LocalTime departureTime;
@Enumerated(EnumType.STRING)
@Column(name = "view_mode")
private ViewMode viewMode = ViewMode.BOARD;
@Column(name = "sound_enabled")
private boolean soundEnabled = true;
@Column(name = "tts_enabled")
private boolean ttsEnabled = true;
@Enumerated(EnumType.STRING)
private Language language = Language.ES;
// --- Parámetros de gamificación (configurables por niño) ---
@Column(name = "coins_per_task")
private int coinsPerTask = 5;
@Column(name = "coins_per_block")
private int coinsPerBlock = 10;
@Column(name = "coins_per_day")
private int coinsPerDay = 20;
public Child() {
// Constructor vacío: usado por JPA y por la creación desde el servicio/seeder.
}
// --- Comportamiento de dominio sobre el monedero ---
/** Suma monedas al saldo (ganancia). El importe debe ser >= 0. */
public void addCoins(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("El importe a sumar no puede ser negativo");
}
this.coins += amount;
}
/** ¿Tiene saldo suficiente para gastar este coste? */
public boolean canAfford(int cost) {
return this.coins >= cost;
}
/** Descuenta monedas del saldo (gasto/canje). Valida que haya saldo. */
public void spend(int cost) {
if (cost < 0) {
throw new IllegalArgumentException("El coste no puede ser negativo");
}
if (!canAfford(cost)) {
throw new IllegalStateException("Saldo insuficiente");
}
this.coins -= cost;
}
/** Resta monedas previamente ganadas (al desmarcar una tarea). No baja de 0. */
public void removeCoins(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("El importe a restar no puede ser negativo");
}
this.coins = Math.max(0, this.coins - amount);
}
// --- Getters / setters ---
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getMascot() {
return mascot;
}
public void setMascot(String mascot) {
this.mascot = mascot;
}
public String getAccentColor() {
return accentColor;
}
public void setAccentColor(String accentColor) {
this.accentColor = accentColor;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public int getCoins() {
return coins;
}
public void setCoins(int coins) {
this.coins = coins;
}
public LocalTime getDepartureTime() {
return departureTime;
}
public void setDepartureTime(LocalTime departureTime) {
this.departureTime = departureTime;
}
public ViewMode getViewMode() {
return viewMode;
}
public void setViewMode(ViewMode viewMode) {
this.viewMode = viewMode;
}
public boolean isSoundEnabled() {
return soundEnabled;
}
public void setSoundEnabled(boolean soundEnabled) {
this.soundEnabled = soundEnabled;
}
public boolean isTtsEnabled() {
return ttsEnabled;
}
public void setTtsEnabled(boolean ttsEnabled) {
this.ttsEnabled = ttsEnabled;
}
public Language getLanguage() {
return language;
}
public void setLanguage(Language language) {
this.language = language;
}
public int getCoinsPerTask() {
return coinsPerTask;
}
public void setCoinsPerTask(int coinsPerTask) {
this.coinsPerTask = coinsPerTask;
}
public int getCoinsPerBlock() {
return coinsPerBlock;
}
public void setCoinsPerBlock(int coinsPerBlock) {
this.coinsPerBlock = coinsPerBlock;
}
public int getCoinsPerDay() {
return coinsPerDay;
}
public void setCoinsPerDay(int coinsPerDay) {
this.coinsPerDay = coinsPerDay;
}
}

View File

@@ -0,0 +1,74 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
/**
* Movimiento de monedas: positivo al ganar (tarea, bono), negativo al canjear.
* Forma el historial del monedero; nunca se borra.
*/
@Entity
@Table(name = "coin_transaction")
public class CoinTransaction {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@Column(name = "tx_date")
private LocalDate txDate;
private int amount;
private String reason;
@Column(name = "created_at")
private Instant createdAt;
protected CoinTransaction() {
}
public CoinTransaction(Child child, LocalDate txDate, int amount, String reason) {
this.child = child;
this.txDate = txDate;
this.amount = amount;
this.reason = reason;
this.createdAt = Instant.now();
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public LocalDate getTxDate() {
return txDate;
}
public int getAmount() {
return amount;
}
public String getReason() {
return reason;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View File

@@ -0,0 +1,148 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
/**
* Instancia de una tarea para un día concreto. Es la unidad que el niño marca.
* Se genera a partir de la plantilla semanal, las rutinas de tarde y los eventos.
*/
@Entity
@Table(name = "daily_task")
public class DailyTask {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@Column(name = "task_date")
private LocalDate taskDate;
@Enumerated(EnumType.STRING)
private Slot slot;
@Column(name = "label_es")
private String labelEs;
@Column(name = "label_ca")
private String labelCa;
private String icon;
private String color;
@Enumerated(EnumType.STRING)
private TaskStatus status = TaskStatus.PENDING;
@Column(name = "coins_reward")
private int coinsReward;
@Column(name = "completed_at")
private Instant completedAt;
@Enumerated(EnumType.STRING)
private TaskOrigin origin;
@Column(name = "order_index")
private int orderIndex;
protected DailyTask() {
}
public DailyTask(Child child, LocalDate taskDate, Slot slot, String labelEs, String labelCa,
String icon, String color, int coinsReward, TaskOrigin origin, int orderIndex) {
this.child = child;
this.taskDate = taskDate;
this.slot = slot;
this.labelEs = labelEs;
this.labelCa = labelCa;
this.icon = icon;
this.color = color;
this.coinsReward = coinsReward;
this.origin = origin;
this.orderIndex = orderIndex;
}
/** ¿Está ya completada? */
public boolean isDone() {
return status == TaskStatus.DONE;
}
/** Marca la tarea como completada y registra el instante. Idempotente. */
public void markDone(Instant when) {
this.status = TaskStatus.DONE;
this.completedAt = when;
}
/** Vuelve a dejar la tarea pendiente (al desmarcar). */
public void markPending() {
this.status = TaskStatus.PENDING;
this.completedAt = null;
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public LocalDate getTaskDate() {
return taskDate;
}
public Slot getSlot() {
return slot;
}
public String getLabelEs() {
return labelEs;
}
public String getLabelCa() {
return labelCa;
}
public String getIcon() {
return icon;
}
public String getColor() {
return color;
}
public TaskStatus getStatus() {
return status;
}
public int getCoinsReward() {
return coinsReward;
}
public Instant getCompletedAt() {
return completedAt;
}
public TaskOrigin getOrigin() {
return origin;
}
public int getOrderIndex() {
return orderIndex;
}
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.domain;
/** Tipo de evento especial mostrado en el banner del día. */
public enum EventType {
EXAM,
HOMEWORK
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.domain;
/** Idioma activo de la UI para el niño. */
public enum Language {
ES,
CA
}

View File

@@ -0,0 +1,83 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/** Material concreto del cole (estuche, flauta...). Texto bilingüe + emoji + color. */
@Entity
@Table(name = "material_item")
public class MaterialItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "label_es")
private String labelEs;
@Column(name = "label_ca")
private String labelCa;
private String icon;
private String color;
private String category;
protected MaterialItem() {
}
public MaterialItem(String labelEs, String labelCa, String icon, String color, String category) {
this.labelEs = labelEs;
this.labelCa = labelCa;
this.icon = icon;
this.color = color;
this.category = category;
}
public Long getId() {
return id;
}
public String getLabelEs() {
return labelEs;
}
public void setLabelEs(String labelEs) {
this.labelEs = labelEs;
}
public String getLabelCa() {
return labelCa;
}
public void setLabelCa(String labelCa) {
this.labelCa = labelCa;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
}

View File

@@ -0,0 +1,53 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* Credenciales del panel de padres. Guarda únicamente el HASH del PIN (BCrypt),
* nunca el PIN en claro. El PIN es configurable; no se hardcodea.
*/
@Entity
@Table(name = "parent_user")
public class ParentUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "pin_hash")
private String pinHash;
@Column(name = "updated_at")
private Instant updatedAt;
protected ParentUser() {
}
public ParentUser(String pinHash) {
this.pinHash = pinHash;
this.updatedAt = Instant.now();
}
public Long getId() {
return id;
}
public String getPinHash() {
return pinHash;
}
public void setPinHash(String pinHash) {
this.pinHash = pinHash;
this.updatedAt = Instant.now();
}
public Instant getUpdatedAt() {
return updatedAt;
}
}

View File

@@ -0,0 +1,92 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/** Premio canjeable en la tienda. Compartido por todos los niños. */
@Entity
@Table(name = "reward")
public class Reward {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "label_es")
private String labelEs;
@Column(name = "label_ca")
private String labelCa;
private String icon;
private String color;
private int cost;
private boolean active = true;
protected Reward() {
}
public Reward(String labelEs, String labelCa, String icon, String color, int cost) {
this.labelEs = labelEs;
this.labelCa = labelCa;
this.icon = icon;
this.color = color;
this.cost = cost;
}
public Long getId() {
return id;
}
public String getLabelEs() {
return labelEs;
}
public void setLabelEs(String labelEs) {
this.labelEs = labelEs;
}
public String getLabelCa() {
return labelCa;
}
public void setLabelCa(String labelCa) {
this.labelCa = labelCa;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public int getCost() {
return cost;
}
public void setCost(int cost) {
this.cost = cost;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
}

View File

@@ -0,0 +1,74 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
/** Canje de un premio por parte de un niño. Histórico, no se borra. */
@Entity
@Table(name = "reward_redemption")
public class RewardRedemption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "reward_id")
private Reward reward;
@Column(name = "redeemed_date")
private LocalDate redeemedDate;
private int cost;
@Column(name = "created_at")
private Instant createdAt;
protected RewardRedemption() {
}
public RewardRedemption(Child child, Reward reward, LocalDate redeemedDate, int cost) {
this.child = child;
this.reward = reward;
this.redeemedDate = redeemedDate;
this.cost = cost;
this.createdAt = Instant.now();
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public Reward getReward() {
return reward;
}
public LocalDate getRedeemedDate() {
return redeemedDate;
}
public int getCost() {
return cost;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.domain;
/** Bloque del día: mañana (cole) o tarde (rutinas). */
public enum Slot {
MORNING,
AFTERNOON
}

View File

@@ -0,0 +1,117 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
/** Evento puntual (examen o deberes) de un niño en una fecha concreta. */
@Entity
@Table(name = "special_event")
public class SpecialEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@Column(name = "event_date")
private LocalDate eventDate;
@Enumerated(EnumType.STRING)
private EventType type;
@Column(name = "title_es")
private String titleEs;
@Column(name = "title_ca")
private String titleCa;
private String icon;
private String color;
protected SpecialEvent() {
}
public SpecialEvent(Child child, LocalDate eventDate, EventType type, String titleEs,
String titleCa, String icon, String color) {
this.child = child;
this.eventDate = eventDate;
this.type = type;
this.titleEs = titleEs;
this.titleCa = titleCa;
this.icon = icon;
this.color = color;
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public void setChild(Child child) {
this.child = child;
}
public LocalDate getEventDate() {
return eventDate;
}
public void setEventDate(LocalDate eventDate) {
this.eventDate = eventDate;
}
public EventType getType() {
return type;
}
public void setType(EventType type) {
this.type = type;
}
public String getTitleEs() {
return titleEs;
}
public void setTitleEs(String titleEs) {
this.titleEs = titleEs;
}
public String getTitleCa() {
return titleCa;
}
public void setTitleCa(String titleCa) {
this.titleCa = titleCa;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
}

View File

@@ -0,0 +1,8 @@
package es.asepeyo.recordalexia.domain;
/** Origen de una tarea del día: de qué fuente de plantilla se generó. */
public enum TaskOrigin {
TEMPLATE,
ROUTINE,
EVENT
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.domain;
/** Estado de una tarea del día. */
public enum TaskStatus {
PENDING,
DONE
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.domain;
/** Modo de presentación del día: tablero (todo a la vista) o foco (una tarea). */
public enum ViewMode {
BOARD,
FOCUS
}

View File

@@ -0,0 +1,100 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.DayOfWeek;
/**
* Entrada de plantilla semanal de MAÑANA: qué actividad del cole tiene un niño un
* día concreto de la semana. La tarde se modela aparte con {@link AfternoonRoutine}.
*/
@Entity
@Table(name = "weekly_template_entry")
public class WeeklyTemplateEntry {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@Enumerated(EnumType.STRING)
@Column(name = "day_of_week")
private DayOfWeek dayOfWeek;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "activity_id")
private Activity activity;
@Column(name = "order_index")
private int orderIndex;
/** Monedas de la tarea; si es null se usa coinsPerTask del niño. */
@Column(name = "coins_reward")
private Integer coinsReward;
protected WeeklyTemplateEntry() {
}
public WeeklyTemplateEntry(Child child, DayOfWeek dayOfWeek, Activity activity, int orderIndex) {
this.child = child;
this.dayOfWeek = dayOfWeek;
this.activity = activity;
this.orderIndex = orderIndex;
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public void setChild(Child child) {
this.child = child;
}
public DayOfWeek getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(DayOfWeek dayOfWeek) {
this.dayOfWeek = dayOfWeek;
}
public Activity getActivity() {
return activity;
}
public void setActivity(Activity activity) {
this.activity = activity;
}
public int getOrderIndex() {
return orderIndex;
}
public void setOrderIndex(int orderIndex) {
this.orderIndex = orderIndex;
}
public Integer getCoinsReward() {
return coinsReward;
}
public void setCoinsReward(Integer coinsReward) {
this.coinsReward = coinsReward;
}
}

View File

@@ -0,0 +1,32 @@
package es.asepeyo.recordalexia.exception;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** Traduce las excepciones de dominio a respuestas HTTP claras (sin filtrar internals). */
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(NotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "not_found", "message", ex.getMessage()));
}
@ExceptionHandler(InsufficientCoinsException.class)
public ResponseEntity<Map<String, Object>> handleInsufficientCoins(InsufficientCoinsException ex) {
// 409 Conflict: la petición es válida pero el estado (saldo) no la permite.
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "insufficient_coins", "missing", ex.getMissing(),
"message", ex.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleBadRequest(IllegalArgumentException ex) {
return ResponseEntity.badRequest()
.body(Map.of("error", "bad_request", "message", ex.getMessage()));
}
}

View File

@@ -0,0 +1,19 @@
package es.asepeyo.recordalexia.exception;
/**
* Saldo insuficiente para canjear un premio. Lleva cuántas monedas faltan para
* que el frontend pueda mostrar el mensaje "te faltan N".
*/
public class InsufficientCoinsException extends RuntimeException {
private final int missing;
public InsufficientCoinsException(int missing) {
super("Saldo insuficiente: faltan " + missing + " monedas");
this.missing = missing;
}
public int getMissing() {
return missing;
}
}

View File

@@ -0,0 +1,9 @@
package es.asepeyo.recordalexia.exception;
/** Recurso no encontrado (niño, tarea, premio...). Se traduce a HTTP 404. */
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.Activity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ActivityRepository extends JpaRepository<Activity, Long> {
}

View File

@@ -0,0 +1,14 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import java.time.DayOfWeek;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AfternoonRoutineRepository extends JpaRepository<AfternoonRoutine, Long> {
/** Rutinas de tarde de un niño para un día de la semana, ya ordenadas. */
List<AfternoonRoutine> findByChildIdAndDayOfWeekOrderByOrderIndexAsc(Long childId, DayOfWeek dayOfWeek);
List<AfternoonRoutine> findByChildIdOrderByDayOfWeekAscOrderIndexAsc(Long childId);
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.Child;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ChildRepository extends JpaRepository<Child, Long> {
}

View File

@@ -0,0 +1,28 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.CoinTransaction;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface CoinTransactionRepository extends JpaRepository<CoinTransaction, Long> {
/** Historial del monedero, lo más reciente primero. */
List<CoinTransaction> findByChildIdOrderByCreatedAtDesc(Long childId);
/**
* Suma neta de monedas de un motivo concreto en una fecha. Sirve para saber si
* un bono (bloque/día) está actualmente activo: el otorgar suma +importe y el
* revertir resta -importe con el MISMO motivo, así que neto > 0 = activo.
*/
@Query("""
select coalesce(sum(t.amount), 0)
from CoinTransaction t
where t.child.id = :childId and t.txDate = :date and t.reason = :reason
""")
int sumAmount(@Param("childId") Long childId,
@Param("date") LocalDate date,
@Param("reason") String reason);
}

View File

@@ -0,0 +1,22 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.domain.TaskStatus;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DailyTaskRepository extends JpaRepository<DailyTask, Long> {
/** Tareas de un niño en una fecha, ordenadas por bloque y orden. */
List<DailyTask> findByChildIdAndTaskDateOrderBySlotAscOrderIndexAsc(Long childId, LocalDate taskDate);
/** ¿Ya se generó el día? Sirve para que la generación sea idempotente. */
boolean existsByChildIdAndTaskDate(Long childId, LocalDate taskDate);
/** Tareas de un bloque concreto (mañana/tarde) en una fecha. */
List<DailyTask> findByChildIdAndTaskDateAndSlot(Long childId, LocalDate taskDate, Slot slot);
long countByChildIdAndTaskDateAndStatus(Long childId, LocalDate taskDate, TaskStatus status);
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.MaterialItem;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MaterialItemRepository extends JpaRepository<MaterialItem, Long> {
}

View File

@@ -0,0 +1,11 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.ParentUser;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ParentUserRepository extends JpaRepository<ParentUser, Long> {
/** Solo hay un usuario de padres en el hogar; devuelve el primero. */
Optional<ParentUser> findFirstByOrderByIdAsc();
}

View File

@@ -0,0 +1,10 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.RewardRedemption;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RewardRedemptionRepository extends JpaRepository<RewardRedemption, Long> {
List<RewardRedemption> findByChildIdOrderByCreatedAtDesc(Long childId);
}

View File

@@ -0,0 +1,11 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.Reward;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RewardRepository extends JpaRepository<Reward, Long> {
/** Premios activos para mostrar en la tienda. */
List<Reward> findByActiveTrueOrderByCostAsc();
}

View File

@@ -0,0 +1,14 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.SpecialEvent;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SpecialEventRepository extends JpaRepository<SpecialEvent, Long> {
/** Eventos de un niño en una fecha concreta (para generar el día). */
List<SpecialEvent> findByChildIdAndEventDate(Long childId, LocalDate eventDate);
List<SpecialEvent> findByChildIdOrderByEventDateAsc(Long childId);
}

View File

@@ -0,0 +1,14 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import java.time.DayOfWeek;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface WeeklyTemplateEntryRepository extends JpaRepository<WeeklyTemplateEntry, Long> {
/** Entradas de mañana de un niño para un día de la semana, ya ordenadas. */
List<WeeklyTemplateEntry> findByChildIdAndDayOfWeekOrderByOrderIndexAsc(Long childId, DayOfWeek dayOfWeek);
List<WeeklyTemplateEntry> findByChildIdOrderByDayOfWeekAscOrderIndexAsc(Long childId);
}

View File

@@ -0,0 +1,42 @@
package es.asepeyo.recordalexia.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Filtro que reconoce la sesión de padres a partir de la cabecera X-Parent-Session.
* Si la sesión es válida, marca la petición como autenticada con rol PARENT; la
* autorización por ruta la decide {@link SecurityConfig}.
*/
@Component
public class ParentAuthFilter extends OncePerRequestFilter {
public static final String HEADER = "X-Parent-Session";
private final ParentSessionStore sessionStore;
public ParentAuthFilter(ParentSessionStore sessionStore) {
this.sessionStore = sessionStore;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String sessionId = request.getHeader(HEADER);
if (sessionStore.isValid(sessionId)) {
var authentication = new UsernamePasswordAuthenticationToken(
"parent", null, List.of(new SimpleGrantedAuthority("ROLE_PARENT")));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,54 @@
package es.asepeyo.recordalexia.security;
import es.asepeyo.recordalexia.domain.ParentUser;
import es.asepeyo.recordalexia.repository.ParentUserRepository;
import java.util.Optional;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Autenticación del panel de padres por PIN.
*
* Encapsula la verificación del PIN (hash BCrypt) y la apertura de sesión. Está
* aislada a propósito para poder sustituirla por un proveedor externo (Keycloak)
* sin afectar a controladores ni servicios de negocio.
*/
@Service
public class ParentAuthService {
private final ParentUserRepository parentUserRepository;
private final ParentSessionStore sessionStore;
private final PasswordEncoder passwordEncoder;
public ParentAuthService(ParentUserRepository parentUserRepository,
ParentSessionStore sessionStore,
PasswordEncoder passwordEncoder) {
this.parentUserRepository = parentUserRepository;
this.sessionStore = sessionStore;
this.passwordEncoder = passwordEncoder;
}
/**
* Valida el PIN y, si es correcto, abre sesión y devuelve su identificador.
* Devuelve vacío si el PIN no es válido (el controlador responde 401).
*/
@Transactional(readOnly = true)
public Optional<String> login(String pin) {
return parentUserRepository.findFirstByOrderByIdAsc()
.filter(parent -> passwordEncoder.matches(pin, parent.getPinHash()))
.map(parent -> sessionStore.issue());
}
/** Cambia el PIN si el actual es correcto. Devuelve true si se cambió. */
@Transactional
public boolean changePin(String currentPin, String newPin) {
Optional<ParentUser> parent = parentUserRepository.findFirstByOrderByIdAsc()
.filter(p -> passwordEncoder.matches(currentPin, p.getPinHash()));
if (parent.isEmpty()) {
return false;
}
parent.get().setPinHash(passwordEncoder.encode(newPin));
return true;
}
}

View File

@@ -0,0 +1,52 @@
package es.asepeyo.recordalexia.security;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
/**
* Almacén de sesiones del panel de padres, en memoria.
*
* Cada sesión se identifica por un valor opaco aleatorio (el "token" que viaja en
* la cabecera X-Parent-Token). Es deliberadamente simple (homelab, instancia
* única) y está encapsulado para que, si en el futuro se externaliza la auth
* (p. ej. Keycloak), se sustituya sin tocar el resto del código.
*/
@Component
public class ParentSessionStore {
/** Vigencia de la sesión de padres. */
private static final Duration TTL = Duration.ofHours(2);
private final Map<String, Instant> sessions = new ConcurrentHashMap<>();
/** Abre una sesión nueva y devuelve su identificador opaco. */
public String issue() {
String id = UUID.randomUUID().toString();
sessions.put(id, Instant.now().plus(TTL));
return id;
}
/** ¿La sesión existe y no ha caducado? Limpia las caducadas de paso. */
public boolean isValid(String id) {
if (id == null) {
return false;
}
Instant expiry = sessions.get(id);
if (expiry == null) {
return false;
}
if (expiry.isBefore(Instant.now())) {
sessions.remove(id);
return false;
}
return true;
}
public void revoke(String id) {
sessions.remove(id);
}
}

View File

@@ -0,0 +1,63 @@
package es.asepeyo.recordalexia.security;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Seguridad ligera y doméstica:
* - Kiosko (niño): el resto de la API es de acceso libre (lectura, marcar, canjear).
* - Panel de padres: /api/parents/** exige sesión válida (cabecera X-Parent-Session),
* salvo el login que abre la sesión.
*
* Sin Keycloak/OAuth2 en esta fase; la auth queda encapsulada en el paquete security.
*/
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, ParentAuthFilter parentAuthFilter)
throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// El login de padres es público: es lo que abre la sesión.
.requestMatchers(HttpMethod.POST, "/api/parents/login").permitAll()
.requestMatchers("/actuator/health").permitAll()
// El resto del panel de padres exige rol PARENT.
.requestMatchers("/api/parents/**").hasRole("PARENT")
// Todo lo demás (kiosko del niño) es de acceso libre.
.anyRequest().permitAll())
.addFilterBefore(parentAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/** CORS permisivo para desarrollo (ng serve en otro puerto). En prod va tras Nginx. */
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

View File

@@ -0,0 +1,121 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.Language;
import es.asepeyo.recordalexia.domain.ViewMode;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildRequest;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildSummary;
import es.asepeyo.recordalexia.web.dto.ChildDtos.SettingsRequest;
import java.time.LocalTime;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Perfiles de niños: consulta, ajustes, parámetros de gamificación y CRUD. */
@Service
public class ChildService {
private final ChildRepository childRepository;
public ChildService(ChildRepository childRepository) {
this.childRepository = childRepository;
}
@Transactional(readOnly = true)
public Child get(Long childId) {
return requireChild(childId);
}
@Transactional(readOnly = true)
public List<ChildSummary> listChildren() {
return childRepository.findAll().stream()
.map(c -> new ChildSummary(c.getId(), c.getName(), c.getMascot(), c.getAccentColor(),
c.getAge(), c.getCoins(), c.getViewMode().name(), c.getLanguage().name()))
.toList();
}
/** Aplica solo los ajustes presentes (no nulos) en la petición. */
@Transactional
public void updateSettings(Long childId, SettingsRequest req) {
Child child = requireChild(childId);
if (req.viewMode() != null) {
child.setViewMode(ViewMode.valueOf(req.viewMode()));
}
if (req.soundEnabled() != null) {
child.setSoundEnabled(req.soundEnabled());
}
if (req.ttsEnabled() != null) {
child.setTtsEnabled(req.ttsEnabled());
}
if (req.language() != null) {
child.setLanguage(Language.valueOf(req.language()));
}
if (req.departureTime() != null) {
child.setDepartureTime(LocalTime.parse(req.departureTime()));
}
}
/** Actualiza los parámetros de gamificación de un niño (panel de padres). */
@Transactional
public void updateGamification(Long childId, Integer perTask, Integer perBlock, Integer perDay) {
Child child = requireChild(childId);
if (perTask != null) {
child.setCoinsPerTask(perTask);
}
if (perBlock != null) {
child.setCoinsPerBlock(perBlock);
}
if (perDay != null) {
child.setCoinsPerDay(perDay);
}
}
@Transactional
public Child create(ChildRequest req) {
Child child = new Child();
applyRequest(child, req);
return childRepository.save(child);
}
@Transactional
public void update(Long childId, ChildRequest req) {
Child child = requireChild(childId);
applyRequest(child, req);
}
@Transactional
public void delete(Long childId) {
if (!childRepository.existsById(childId)) {
throw new NotFoundException("No existe el niño con id " + childId);
}
childRepository.deleteById(childId);
}
private void applyRequest(Child child, ChildRequest req) {
if (req.name() != null) {
child.setName(req.name());
}
if (req.mascot() != null) {
child.setMascot(req.mascot());
}
if (req.accentColor() != null) {
child.setAccentColor(req.accentColor());
}
if (req.age() != null) {
child.setAge(req.age());
}
if (req.departureTime() != null) {
child.setDepartureTime(LocalTime.parse(req.departureTime()));
}
if (req.coins() != null) {
child.setCoins(req.coins());
}
}
private Child requireChild(Long childId) {
return childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}
}

View File

@@ -0,0 +1,25 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Slot;
/** Motivos de los movimientos de monedas (texto estable guardado en BD). */
public final class CoinReason {
public static final String TASK = "TASK";
public static final String TASK_REVERT = "TASK_REVERT";
public static final String DAY_BONUS = "DAY_BONUS";
public static final String DAY_BONUS_REVERT = "DAY_BONUS_REVERT";
public static final String REDEEM = "REDEEM";
private CoinReason() {
}
/** Bono de bloque, distinguiendo mañana/tarde: "BLOCK_BONUS_MORNING". */
public static String blockBonus(Slot slot) {
return "BLOCK_BONUS_" + slot.name();
}
public static String blockBonusRevert(Slot slot) {
return "BLOCK_BONUS_REVERT_" + slot.name();
}
}

View File

@@ -0,0 +1,88 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.domain.TaskOrigin;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Genera las tareas de un día concreto para un niño a partir de su plantilla.
*
* Mañana: desde {@link WeeklyTemplateEntry} (actividades del cole del día).
* Tarde: desde {@link AfternoonRoutine} (rutinas del día).
* Los eventos (exámenes/deberes) NO se materializan como tareas marcables: se
* muestran en el banner informativo del día (ver TodayService).
*
* La operación es IDEMPOTENTE: si el día ya tiene tareas, no se vuelve a generar.
*/
@Service
public class DayGenerationService {
private final ChildRepository childRepository;
private final WeeklyTemplateEntryRepository templateRepository;
private final AfternoonRoutineRepository routineRepository;
private final DailyTaskRepository dailyTaskRepository;
public DayGenerationService(ChildRepository childRepository,
WeeklyTemplateEntryRepository templateRepository,
AfternoonRoutineRepository routineRepository,
DailyTaskRepository dailyTaskRepository) {
this.childRepository = childRepository;
this.templateRepository = templateRepository;
this.routineRepository = routineRepository;
this.dailyTaskRepository = dailyTaskRepository;
}
/**
* Asegura que existan las tareas del día indicado para el niño. Si ya existen,
* no hace nada (idempotente). Devuelve la lista de tareas resultante.
*/
@Transactional
public List<DailyTask> generateIfAbsent(Long childId, LocalDate date) {
if (dailyTaskRepository.existsByChildIdAndTaskDate(childId, date)) {
return dailyTaskRepository.findByChildIdAndTaskDateOrderBySlotAscOrderIndexAsc(childId, date);
}
Child child = childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
DayOfWeek dayOfWeek = date.getDayOfWeek();
List<DailyTask> nuevas = new ArrayList<>();
// --- Mañana: una tarea por cada actividad del cole de ese día ---
List<WeeklyTemplateEntry> entradas =
templateRepository.findByChildIdAndDayOfWeekOrderByOrderIndexAsc(childId, dayOfWeek);
for (WeeklyTemplateEntry entrada : entradas) {
var actividad = entrada.getActivity();
int monedas = entrada.getCoinsReward() != null ? entrada.getCoinsReward() : child.getCoinsPerTask();
nuevas.add(new DailyTask(child, date, Slot.MORNING,
actividad.getLabelEs(), actividad.getLabelCa(), actividad.getIcon(), actividad.getColor(),
monedas, TaskOrigin.TEMPLATE, entrada.getOrderIndex()));
}
// --- Tarde: una tarea por cada rutina del día ---
List<AfternoonRoutine> rutinas =
routineRepository.findByChildIdAndDayOfWeekOrderByOrderIndexAsc(childId, dayOfWeek);
for (AfternoonRoutine rutina : rutinas) {
int monedas = rutina.getCoinsReward() != null ? rutina.getCoinsReward() : child.getCoinsPerTask();
nuevas.add(new DailyTask(child, date, Slot.AFTERNOON,
rutina.getLabelEs(), rutina.getLabelCa(), rutina.getIcon(), rutina.getColor(),
monedas, TaskOrigin.ROUTINE, rutina.getOrderIndex()));
}
return dailyTaskRepository.saveAll(nuevas);
}
}

View File

@@ -0,0 +1,38 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.web.dto.TodayResponse.ProgressView;
import java.util.List;
/** Calcula el progreso del día (por bloque y global) a partir de las tareas. */
public final class ProgressCalculator {
private ProgressCalculator() {
}
public static ProgressView from(List<DailyTask> tasks) {
int morningTotal = 0;
int morningDone = 0;
int afternoonTotal = 0;
int afternoonDone = 0;
for (DailyTask t : tasks) {
boolean done = t.isDone();
if (t.getSlot() == Slot.MORNING) {
morningTotal++;
if (done) {
morningDone++;
}
} else if (t.getSlot() == Slot.AFTERNOON) {
afternoonTotal++;
if (done) {
afternoonDone++;
}
}
}
return new ProgressView(
morningDone, morningTotal,
afternoonDone, afternoonTotal,
morningDone + afternoonDone, morningTotal + afternoonTotal);
}
}

View File

@@ -0,0 +1,86 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.CoinTransaction;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.domain.RewardRedemption;
import es.asepeyo.recordalexia.exception.InsufficientCoinsException;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.CoinTransactionRepository;
import es.asepeyo.recordalexia.repository.RewardRedemptionRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RedeemResult;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardView;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Tienda de premios: listado visible para el niño y canje con control de saldo. */
@Service
public class StoreService {
private final ChildRepository childRepository;
private final RewardRepository rewardRepository;
private final CoinTransactionRepository coinTransactionRepository;
private final RewardRedemptionRepository rewardRedemptionRepository;
private final Clock clock;
public StoreService(ChildRepository childRepository,
RewardRepository rewardRepository,
CoinTransactionRepository coinTransactionRepository,
RewardRedemptionRepository rewardRedemptionRepository,
Clock clock) {
this.childRepository = childRepository;
this.rewardRepository = rewardRepository;
this.coinTransactionRepository = coinTransactionRepository;
this.rewardRedemptionRepository = rewardRedemptionRepository;
this.clock = clock;
}
/** Premios activos, indicando si el niño puede permitírselos y cuánto le falta. */
@Transactional(readOnly = true)
public List<RewardView> listRewards(Long childId) {
Child child = requireChild(childId);
return rewardRepository.findByActiveTrueOrderByCostAsc().stream()
.map(r -> new RewardView(r.getId(), r.getLabelEs(), r.getLabelCa(), r.getIcon(),
r.getColor(), r.getCost(), child.canAfford(r.getCost()),
Math.max(0, r.getCost() - child.getCoins())))
.toList();
}
/**
* Canjea un premio: valida saldo, descuenta monedas, registra la transacción
* negativa y el canje. Si no llega, lanza {@link InsufficientCoinsException}.
*/
@Transactional
public RedeemResult redeem(Long childId, Long rewardId) {
Child child = requireChild(childId);
Reward reward = rewardRepository.findById(rewardId)
.orElseThrow(() -> new NotFoundException("No existe el premio con id " + rewardId));
if (!reward.isActive()) {
throw new NotFoundException("El premio " + rewardId + " no está disponible");
}
if (!child.canAfford(reward.getCost())) {
throw new InsufficientCoinsException(reward.getCost() - child.getCoins());
}
LocalDate today = LocalDate.now(clock);
child.spend(reward.getCost());
coinTransactionRepository.save(
new CoinTransaction(child, today, -reward.getCost(), CoinReason.REDEEM));
rewardRedemptionRepository.save(
new RewardRedemption(child, reward, today, reward.getCost()));
childRepository.save(child);
return new RedeemResult(reward.getId(), reward.getCost(), child.getCoins());
}
private Child requireChild(Long childId) {
return childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}
}

View File

@@ -0,0 +1,124 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.CoinTransaction;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.CoinTransactionRepository;
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
import es.asepeyo.recordalexia.web.dto.ToggleResult;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Marcado/desmarcado de tareas y gestión de monedas.
*
* Al marcar una tarea como hecha se suman sus monedas; si al hacerlo se completa
* un bloque (mañana o tarde) o el día entero, se otorga el bono correspondiente.
* Al desmarcar, todo se revierte de forma coherente: monedas de la tarea y los
* bonos que dejen de corresponder.
*
* Los bonos se reconcilian con el estado real del día: el motivo de bono suma
* +importe al otorgarse y -importe al revertirse (mismo motivo), de modo que la
* suma neta > 0 indica que el bono está activo. Esto hace la operación robusta
* ante cualquier secuencia de marcar/desmarcar.
*/
@Service
public class TaskService {
private final DailyTaskRepository dailyTaskRepository;
private final ChildRepository childRepository;
private final CoinTransactionRepository coinTransactionRepository;
private final Clock clock;
public TaskService(DailyTaskRepository dailyTaskRepository,
ChildRepository childRepository,
CoinTransactionRepository coinTransactionRepository,
Clock clock) {
this.dailyTaskRepository = dailyTaskRepository;
this.childRepository = childRepository;
this.coinTransactionRepository = coinTransactionRepository;
this.clock = clock;
}
@Transactional
public ToggleResult toggle(Long taskId) {
DailyTask task = dailyTaskRepository.findById(taskId)
.orElseThrow(() -> new NotFoundException("No existe la tarea con id " + taskId));
Child child = task.getChild();
Long childId = child.getId();
LocalDate date = task.getTaskDate();
Slot slot = task.getSlot();
int balanceBefore = child.getCoins();
if (task.isDone()) {
// --- Desmarcar: revertir monedas de la tarea ---
task.markPending();
child.removeCoins(task.getCoinsReward());
recordTx(child, date, -task.getCoinsReward(), CoinReason.TASK);
} else {
// --- Marcar: sumar monedas de la tarea ---
task.markDone(Instant.now(clock));
child.addCoins(task.getCoinsReward());
recordTx(child, date, task.getCoinsReward(), CoinReason.TASK);
}
// La consulta de tareas refleja ya el cambio (autoflush antes de la query).
// Reconciliar el bono de bloque y el del día con el estado real.
reconcileBonus(child, date, CoinReason.blockBonus(slot), child.getCoinsPerBlock(),
isSlotComplete(childId, date, slot));
reconcileBonus(child, date, CoinReason.DAY_BONUS, child.getCoinsPerDay(),
isDayComplete(childId, date));
childRepository.save(child);
List<DailyTask> tasks =
dailyTaskRepository.findByChildIdAndTaskDateOrderBySlotAscOrderIndexAsc(childId, date);
int balanceAfter = child.getCoins();
return new ToggleResult(
task.getId(),
task.isDone(),
balanceAfter - balanceBefore,
balanceAfter,
ProgressCalculator.from(tasks));
}
/** Otorga o revierte un bono para que su estado case con {@code shouldBeActive}. */
private void reconcileBonus(Child child, LocalDate date, String reason, int amount,
boolean shouldBeActive) {
boolean currentlyActive =
coinTransactionRepository.sumAmount(child.getId(), date, reason) > 0;
if (shouldBeActive && !currentlyActive) {
child.addCoins(amount);
recordTx(child, date, amount, reason);
} else if (!shouldBeActive && currentlyActive) {
child.removeCoins(amount);
recordTx(child, date, -amount, reason);
}
}
/** Un bloque está completo si tiene tareas y todas están hechas. */
private boolean isSlotComplete(Long childId, LocalDate date, Slot slot) {
List<DailyTask> slotTasks =
dailyTaskRepository.findByChildIdAndTaskDateAndSlot(childId, date, slot);
return !slotTasks.isEmpty() && slotTasks.stream().allMatch(DailyTask::isDone);
}
/** El día está completo si tiene tareas y todas están hechas. */
private boolean isDayComplete(Long childId, LocalDate date) {
List<DailyTask> tasks =
dailyTaskRepository.findByChildIdAndTaskDateOrderBySlotAscOrderIndexAsc(childId, date);
return !tasks.isEmpty() && tasks.stream().allMatch(DailyTask::isDone);
}
private void recordTx(Child child, LocalDate date, int amount, String reason) {
coinTransactionRepository.save(new CoinTransaction(child, date, amount, reason));
}
}

View File

@@ -0,0 +1,101 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.domain.SpecialEvent;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
import es.asepeyo.recordalexia.web.dto.TodayResponse;
import es.asepeyo.recordalexia.web.dto.TodayResponse.ChildInfo;
import es.asepeyo.recordalexia.web.dto.TodayResponse.EventView;
import es.asepeyo.recordalexia.web.dto.TodayResponse.TaskView;
import es.asepeyo.recordalexia.web.dto.TodayResponse.TimerInfo;
import es.asepeyo.recordalexia.web.dto.TodayResponse.WalletInfo;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Construye la vista del día de hoy para un niño (genera el día si hace falta). */
@Service
public class TodayService {
private static final DateTimeFormatter HHMM = DateTimeFormatter.ofPattern("HH:mm");
private final DayGenerationService dayGenerationService;
private final ChildRepository childRepository;
private final SpecialEventRepository specialEventRepository;
private final Clock clock;
public TodayService(DayGenerationService dayGenerationService,
ChildRepository childRepository,
SpecialEventRepository specialEventRepository,
Clock clock) {
this.dayGenerationService = dayGenerationService;
this.childRepository = childRepository;
this.specialEventRepository = specialEventRepository;
this.clock = clock;
}
@Transactional
public TodayResponse getToday(Long childId) {
Child child = childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
LocalDate today = LocalDate.now(clock);
List<DailyTask> tasks = dayGenerationService.generateIfAbsent(childId, today);
List<TaskView> morning = tasks.stream()
.filter(t -> t.getSlot() == Slot.MORNING)
.map(this::toTaskView)
.toList();
List<TaskView> afternoon = tasks.stream()
.filter(t -> t.getSlot() == Slot.AFTERNOON)
.map(this::toTaskView)
.toList();
List<EventView> events = specialEventRepository.findByChildIdAndEventDate(childId, today).stream()
.map(this::toEventView)
.toList();
return new TodayResponse(
toChildInfo(child),
morning,
afternoon,
events,
ProgressCalculator.from(tasks),
new WalletInfo(child.getCoins()),
buildTimer(child));
}
private TaskView toTaskView(DailyTask t) {
return new TaskView(t.getId(), t.getLabelEs(), t.getLabelCa(), t.getIcon(), t.getColor(),
t.isDone(), t.getCoinsReward(), t.getOrderIndex());
}
private EventView toEventView(SpecialEvent e) {
return new EventView(e.getId(), e.getType().name(), e.getTitleEs(), e.getTitleCa(),
e.getIcon(), e.getColor());
}
private ChildInfo toChildInfo(Child c) {
return new ChildInfo(c.getId(), c.getName(), c.getMascot(), c.getAccentColor(),
c.getViewMode().name(), c.getLanguage().name(), c.isSoundEnabled(), c.isTtsEnabled());
}
/** Temporizador de salida: minutos que faltan hasta departureTime (>= 0). */
private TimerInfo buildTimer(Child child) {
LocalTime departure = child.getDepartureTime();
if (departure == null) {
return new TimerInfo(null, null);
}
long minutes = Duration.between(LocalTime.now(clock), departure).toMinutes();
return new TimerInfo(departure.format(HHMM), (int) Math.max(0, minutes));
}
}

View File

@@ -0,0 +1,39 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.CoinTransactionRepository;
import es.asepeyo.recordalexia.web.dto.WalletResponse;
import es.asepeyo.recordalexia.web.dto.WalletResponse.CoinTxView;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Consulta del monedero: saldo actual e historial de movimientos. */
@Service
public class WalletService {
private final ChildRepository childRepository;
private final CoinTransactionRepository coinTransactionRepository;
public WalletService(ChildRepository childRepository,
CoinTransactionRepository coinTransactionRepository) {
this.childRepository = childRepository;
this.coinTransactionRepository = coinTransactionRepository;
}
@Transactional(readOnly = true)
public WalletResponse getWallet(Long childId) {
Child child = childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
List<CoinTxView> history = coinTransactionRepository
.findByChildIdOrderByCreatedAtDesc(childId).stream()
.map(tx -> new CoinTxView(tx.getId(), tx.getTxDate(), tx.getAmount(),
tx.getReason(), tx.getCreatedAt()))
.toList();
return new WalletResponse(child.getCoins(), history);
}
}

View File

@@ -0,0 +1,65 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.service.ChildService;
import es.asepeyo.recordalexia.service.StoreService;
import es.asepeyo.recordalexia.service.TodayService;
import es.asepeyo.recordalexia.service.WalletService;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildSummary;
import es.asepeyo.recordalexia.web.dto.ChildDtos.SettingsRequest;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardView;
import es.asepeyo.recordalexia.web.dto.TodayResponse;
import es.asepeyo.recordalexia.web.dto.WalletResponse;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** API del kiosko orientada al niño: perfiles, día de hoy, monedero, tienda, ajustes. */
@RestController
@RequestMapping("/api/children")
public class ChildController {
private final ChildService childService;
private final TodayService todayService;
private final WalletService walletService;
private final StoreService storeService;
public ChildController(ChildService childService, TodayService todayService,
WalletService walletService, StoreService storeService) {
this.childService = childService;
this.todayService = todayService;
this.walletService = walletService;
this.storeService = storeService;
}
@GetMapping
public List<ChildSummary> listChildren() {
return childService.listChildren();
}
@GetMapping("/{id}/today")
public TodayResponse today(@PathVariable Long id) {
return todayService.getToday(id);
}
@GetMapping("/{id}/wallet")
public WalletResponse wallet(@PathVariable Long id) {
return walletService.getWallet(id);
}
@GetMapping("/{id}/rewards")
public List<RewardView> rewards(@PathVariable Long id) {
return storeService.listRewards(id);
}
@PutMapping("/{id}/settings")
public ResponseEntity<Void> updateSettings(@PathVariable Long id,
@RequestBody SettingsRequest request) {
childService.updateSettings(id, request);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,40 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.security.ParentAuthService;
import es.asepeyo.recordalexia.web.dto.ParentDtos.ChangePinRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.LoginRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.LoginResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Autenticación del panel de padres: abrir sesión con PIN y cambiar el PIN. */
@RestController
@RequestMapping("/api/parents")
public class ParentAuthController {
private final ParentAuthService parentAuthService;
public ParentAuthController(ParentAuthService parentAuthService) {
this.parentAuthService = parentAuthService;
}
/** Valida el PIN. 200 con la sesión si es correcto; 401 si no. */
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
return parentAuthService.login(request.pin())
.map(session -> ResponseEntity.ok(new LoginResponse(session)))
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
}
/** Cambia el PIN (requiere sesión de padres válida). */
@PostMapping("/change-pin")
public ResponseEntity<Void> changePin(@RequestBody ChangePinRequest request) {
boolean changed = parentAuthService.changePin(request.currentPin(), request.newPin());
return changed ? ResponseEntity.noContent().build()
: ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}

View File

@@ -0,0 +1,106 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Activity;
import es.asepeyo.recordalexia.domain.MaterialItem;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
import es.asepeyo.recordalexia.web.dto.ParentDtos.ActivityRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.MaterialRequest;
import es.asepeyo.recordalexia.web.dto.ParentViews.ActivityView;
import es.asepeyo.recordalexia.web.dto.ParentViews.MaterialView;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** CRUD de actividades del cole y su material (panel de padres). */
@RestController
@RequestMapping("/api/parents/catalog")
public class ParentCatalogController {
private final ActivityRepository activityRepository;
private final MaterialItemRepository materialRepository;
public ParentCatalogController(ActivityRepository activityRepository,
MaterialItemRepository materialRepository) {
this.activityRepository = activityRepository;
this.materialRepository = materialRepository;
}
// --- Materiales ---
@GetMapping("/materials")
public List<MaterialView> listMaterials() {
return materialRepository.findAll().stream()
.map(m -> new MaterialView(m.getId(), m.getLabelEs(), m.getLabelCa(), m.getIcon(),
m.getColor(), m.getCategory()))
.toList();
}
@PostMapping("/materials")
public ResponseEntity<MaterialView> createMaterial(@RequestBody MaterialRequest req) {
MaterialItem saved = materialRepository.save(
new MaterialItem(req.labelEs(), req.labelCa(), req.icon(), req.color(), req.category()));
return ResponseEntity.ok(new MaterialView(saved.getId(), saved.getLabelEs(),
saved.getLabelCa(), saved.getIcon(), saved.getColor(), saved.getCategory()));
}
@DeleteMapping("/materials/{id}")
public ResponseEntity<Void> deleteMaterial(@PathVariable Long id) {
if (!materialRepository.existsById(id)) {
throw new NotFoundException("No existe el material con id " + id);
}
materialRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
// --- Actividades (con su material asociado) ---
@GetMapping("/activities")
@Transactional(readOnly = true)
public List<ActivityView> listActivities() {
return activityRepository.findAll().stream().map(this::toActivityView).toList();
}
@PostMapping("/activities")
@Transactional
public ResponseEntity<ActivityView> createActivity(@RequestBody ActivityRequest req) {
Activity activity = new Activity(req.labelEs(), req.labelCa(), req.icon(), req.color());
attachMaterials(activity, req.materialIds());
Activity saved = activityRepository.save(activity);
return ResponseEntity.ok(toActivityView(saved));
}
@DeleteMapping("/activities/{id}")
public ResponseEntity<Void> deleteActivity(@PathVariable Long id) {
if (!activityRepository.existsById(id)) {
throw new NotFoundException("No existe la actividad con id " + id);
}
activityRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private void attachMaterials(Activity activity, List<Long> materialIds) {
if (materialIds == null) {
return;
}
for (Long materialId : materialIds) {
MaterialItem material = materialRepository.findById(materialId)
.orElseThrow(() -> new NotFoundException("No existe el material con id " + materialId));
activity.addMaterial(material);
}
}
private ActivityView toActivityView(Activity a) {
List<Long> materialIds = a.getMaterials().stream().map(MaterialItem::getId).toList();
return new ActivityView(a.getId(), a.getLabelEs(), a.getLabelCa(), a.getIcon(),
a.getColor(), materialIds);
}
}

View File

@@ -0,0 +1,74 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.service.ChildService;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildRequest;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildSummary;
import es.asepeyo.recordalexia.web.dto.ParentDtos.GamificationRequest;
import es.asepeyo.recordalexia.web.dto.ParentViews.GamificationView;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** CRUD de niños y ajuste de gamificación desde el panel de padres. */
@RestController
@RequestMapping("/api/parents/children")
public class ParentChildController {
private final ChildService childService;
public ParentChildController(ChildService childService) {
this.childService = childService;
}
@GetMapping
public List<ChildSummary> list() {
return childService.listChildren();
}
@PostMapping
public ResponseEntity<ChildSummary> create(@RequestBody ChildRequest request) {
Child child = childService.create(request);
return ResponseEntity.created(URI.create("/api/parents/children/" + child.getId()))
.body(new ChildSummary(child.getId(), child.getName(), child.getMascot(),
child.getAccentColor(), child.getAge(), child.getCoins(),
child.getViewMode().name(), child.getLanguage().name()));
}
@PutMapping("/{id}")
public ResponseEntity<Void> update(@PathVariable Long id, @RequestBody ChildRequest request) {
childService.update(id, request);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
childService.delete(id);
return ResponseEntity.noContent().build();
}
/** Valores actuales de gamificación del niño (para precargar el panel). */
@GetMapping("/{id}/gamification")
public GamificationView getGamification(@PathVariable Long id) {
Child child = childService.get(id);
return new GamificationView(child.getCoinsPerTask(), child.getCoinsPerBlock(),
child.getCoinsPerDay());
}
/** Parámetros de gamificación del niño (monedas por tarea / bloque / día). */
@PutMapping("/{id}/gamification")
public ResponseEntity<Void> updateGamification(@PathVariable Long id,
@RequestBody GamificationRequest request) {
childService.updateGamification(id, request.coinsPerTask(), request.coinsPerBlock(),
request.coinsPerDay());
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,72 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.EventType;
import es.asepeyo.recordalexia.domain.SpecialEvent;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
import es.asepeyo.recordalexia.web.dto.ParentDtos.SpecialEventRequest;
import es.asepeyo.recordalexia.web.dto.ParentViews.EventAdminView;
import java.net.URI;
import java.time.LocalDate;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** CRUD de eventos especiales (exámenes/deberes) desde el panel de padres. */
@RestController
@RequestMapping("/api/parents/events")
public class ParentEventController {
private final SpecialEventRepository eventRepository;
private final ChildRepository childRepository;
public ParentEventController(SpecialEventRepository eventRepository,
ChildRepository childRepository) {
this.eventRepository = eventRepository;
this.childRepository = childRepository;
}
@GetMapping
public List<EventAdminView> list(@RequestParam Long childId) {
return eventRepository.findByChildIdOrderByEventDateAsc(childId).stream()
.map(this::toView).toList();
}
@PostMapping
public ResponseEntity<EventAdminView> create(@RequestBody SpecialEventRequest req) {
Child child = requireChild(req.childId());
SpecialEvent event = new SpecialEvent(child, LocalDate.parse(req.date()),
EventType.valueOf(req.type()), req.titleEs(), req.titleCa(), req.icon(), req.color());
SpecialEvent saved = eventRepository.save(event);
return ResponseEntity.created(URI.create("/api/parents/events/" + saved.getId()))
.body(toView(saved));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!eventRepository.existsById(id)) {
throw new NotFoundException("No existe el evento con id " + id);
}
eventRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private Child requireChild(Long childId) {
return childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}
private EventAdminView toView(SpecialEvent e) {
return new EventAdminView(e.getId(), e.getChild().getId(), e.getEventDate(),
e.getType().name(), e.getTitleEs(), e.getTitleCa(), e.getIcon(), e.getColor());
}
}

View File

@@ -0,0 +1,87 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.web.dto.ParentViews.RewardAdminView;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardRequest;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** CRUD del catálogo de premios desde el panel de padres. */
@RestController
@RequestMapping("/api/parents/rewards")
public class ParentRewardController {
private final RewardRepository rewardRepository;
public ParentRewardController(RewardRepository rewardRepository) {
this.rewardRepository = rewardRepository;
}
@GetMapping
public List<RewardAdminView> list() {
return rewardRepository.findAll().stream().map(this::toView).toList();
}
@PostMapping
public ResponseEntity<RewardAdminView> create(@RequestBody RewardRequest req) {
Reward reward = new Reward(req.labelEs(), req.labelCa(), req.icon(), req.color(),
req.cost() != null ? req.cost() : 0);
if (req.active() != null) {
reward.setActive(req.active());
}
Reward saved = rewardRepository.save(reward);
return ResponseEntity.created(URI.create("/api/parents/rewards/" + saved.getId()))
.body(toView(saved));
}
@PutMapping("/{id}")
public ResponseEntity<Void> update(@PathVariable Long id, @RequestBody RewardRequest req) {
Reward reward = rewardRepository.findById(id)
.orElseThrow(() -> new NotFoundException("No existe el premio con id " + id));
if (req.labelEs() != null) {
reward.setLabelEs(req.labelEs());
}
if (req.labelCa() != null) {
reward.setLabelCa(req.labelCa());
}
if (req.icon() != null) {
reward.setIcon(req.icon());
}
if (req.color() != null) {
reward.setColor(req.color());
}
if (req.cost() != null) {
reward.setCost(req.cost());
}
if (req.active() != null) {
reward.setActive(req.active());
}
rewardRepository.save(reward);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!rewardRepository.existsById(id)) {
throw new NotFoundException("No existe el premio con id " + id);
}
rewardRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private RewardAdminView toView(Reward r) {
return new RewardAdminView(r.getId(), r.getLabelEs(), r.getLabelCa(), r.getIcon(),
r.getColor(), r.getCost(), r.isActive());
}
}

View File

@@ -0,0 +1,135 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Activity;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import es.asepeyo.recordalexia.web.dto.ParentDtos.AfternoonRoutineRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.RoutineReorderRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.WeeklyEntryRequest;
import es.asepeyo.recordalexia.web.dto.ParentViews.RoutineView;
import es.asepeyo.recordalexia.web.dto.ParentViews.WeeklyEntryView;
import java.time.DayOfWeek;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** CRUD del horario semanal de mañana y de las rutinas de tarde (panel de padres). */
@RestController
@RequestMapping("/api/parents/schedule")
public class ParentScheduleController {
private final WeeklyTemplateEntryRepository templateRepository;
private final AfternoonRoutineRepository routineRepository;
private final ChildRepository childRepository;
private final ActivityRepository activityRepository;
public ParentScheduleController(WeeklyTemplateEntryRepository templateRepository,
AfternoonRoutineRepository routineRepository,
ChildRepository childRepository,
ActivityRepository activityRepository) {
this.templateRepository = templateRepository;
this.routineRepository = routineRepository;
this.childRepository = childRepository;
this.activityRepository = activityRepository;
}
// --- Plantilla de mañana ---
@GetMapping("/weekly")
@Transactional(readOnly = true)
public List<WeeklyEntryView> listWeekly(@RequestParam Long childId) {
return templateRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream()
.map(this::toWeeklyView).toList();
}
@PostMapping("/weekly")
public ResponseEntity<WeeklyEntryView> createWeekly(@RequestBody WeeklyEntryRequest req) {
Child child = requireChild(req.childId());
Activity activity = activityRepository.findById(req.activityId())
.orElseThrow(() -> new NotFoundException("No existe la actividad con id " + req.activityId()));
WeeklyTemplateEntry entry = new WeeklyTemplateEntry(child, DayOfWeek.valueOf(req.dayOfWeek()),
activity, req.orderIndex() != null ? req.orderIndex() : 0);
entry.setCoinsReward(req.coinsReward());
return ResponseEntity.ok(toWeeklyView(templateRepository.save(entry)));
}
@DeleteMapping("/weekly/{id}")
public ResponseEntity<Void> deleteWeekly(@PathVariable Long id) {
if (!templateRepository.existsById(id)) {
throw new NotFoundException("No existe la entrada de horario con id " + id);
}
templateRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
// --- Rutinas de tarde ---
@GetMapping("/routines")
public List<RoutineView> listRoutines(@RequestParam Long childId) {
return routineRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream()
.map(this::toRoutineView).toList();
}
@PostMapping("/routines")
public ResponseEntity<RoutineView> createRoutine(@RequestBody AfternoonRoutineRequest req) {
Child child = requireChild(req.childId());
AfternoonRoutine routine = new AfternoonRoutine(child, DayOfWeek.valueOf(req.dayOfWeek()),
req.labelEs(), req.labelCa(), req.icon(), req.color(),
req.orderIndex() != null ? req.orderIndex() : 0);
routine.setCoinsReward(req.coinsReward());
return ResponseEntity.ok(toRoutineView(routineRepository.save(routine)));
}
/** Reordena las rutinas: asigna orderIndex según la posición en la lista recibida. */
@PutMapping("/routines/reorder")
@Transactional
public ResponseEntity<Void> reorderRoutines(@RequestBody RoutineReorderRequest req) {
List<Long> ids = req.orderedIds();
for (int i = 0; i < ids.size(); i++) {
int orderIndex = i;
routineRepository.findById(ids.get(i)).ifPresent(r -> r.setOrderIndex(orderIndex));
}
return ResponseEntity.noContent().build();
}
@DeleteMapping("/routines/{id}")
public ResponseEntity<Void> deleteRoutine(@PathVariable Long id) {
if (!routineRepository.existsById(id)) {
throw new NotFoundException("No existe la rutina con id " + id);
}
routineRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private Child requireChild(Long childId) {
return childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}
private WeeklyEntryView toWeeklyView(WeeklyTemplateEntry e) {
Activity a = e.getActivity();
return new WeeklyEntryView(e.getId(), e.getChild().getId(), e.getDayOfWeek().name(),
a.getId(), a.getLabelEs(), a.getIcon(), a.getColor(), e.getOrderIndex(), e.getCoinsReward());
}
private RoutineView toRoutineView(AfternoonRoutine r) {
return new RoutineView(r.getId(), r.getChild().getId(), r.getDayOfWeek().name(),
r.getLabelEs(), r.getLabelCa(), r.getIcon(), r.getColor(), r.getOrderIndex(),
r.getCoinsReward());
}
}

View File

@@ -0,0 +1,27 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.service.StoreService;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RedeemResult;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Canje de premios de la tienda. */
@RestController
@RequestMapping("/api/rewards")
public class StoreController {
private final StoreService storeService;
public StoreController(StoreService storeService) {
this.storeService = storeService;
}
/** Canjea un premio para un niño (el niño se indica por query param childId). */
@PostMapping("/{rewardId}/redeem")
public RedeemResult redeem(@PathVariable Long rewardId,
@org.springframework.web.bind.annotation.RequestParam Long childId) {
return storeService.redeem(childId, rewardId);
}
}

View File

@@ -0,0 +1,25 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.service.TaskService;
import es.asepeyo.recordalexia.web.dto.ToggleResult;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Marcado/desmarcado de tareas del día. */
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@PostMapping("/{taskId}/toggle")
public ToggleResult toggle(@PathVariable Long taskId) {
return taskService.toggle(taskId);
}
}

View File

@@ -0,0 +1,42 @@
package es.asepeyo.recordalexia.web.dto;
/** DTOs relacionados con el niño: resumen de perfil y actualización de ajustes. */
public final class ChildDtos {
private ChildDtos() {
}
/** Resumen para la pantalla de selección de perfil. */
public record ChildSummary(
Long id,
String name,
String mascot,
String accentColor,
int age,
int coins,
String viewMode,
String language) {
}
/**
* Ajustes editables del niño. Todos opcionales: solo se aplican los no nulos.
* departureTime en formato ISO "HH:mm".
*/
public record SettingsRequest(
String viewMode,
Boolean soundEnabled,
Boolean ttsEnabled,
String language,
String departureTime) {
}
/** Petición de alta/edición de un niño desde el panel de padres. */
public record ChildRequest(
String name,
String mascot,
String accentColor,
Integer age,
String departureTime,
Integer coins) {
}
}

View File

@@ -0,0 +1,53 @@
package es.asepeyo.recordalexia.web.dto;
import java.util.List;
/** DTOs del panel de padres: autenticación, gamificación y altas de configuración. */
public final class ParentDtos {
private ParentDtos() {
}
// --- Autenticación ---
public record LoginRequest(String pin) {
}
/** Identificador de sesión a enviar luego en la cabecera X-Parent-Session. */
public record LoginResponse(String session) {
}
public record ChangePinRequest(String currentPin, String newPin) {
}
// --- Gamificación (por niño) ---
public record GamificationRequest(Integer coinsPerTask, Integer coinsPerBlock, Integer coinsPerDay) {
}
// --- Catálogo ---
public record ActivityRequest(
String labelEs, String labelCa, String icon, String color, List<Long> materialIds) {
}
public record MaterialRequest(
String labelEs, String labelCa, String icon, String color, String category) {
}
// --- Horario / rutinas ---
public record WeeklyEntryRequest(
Long childId, String dayOfWeek, Long activityId, Integer orderIndex, Integer coinsReward) {
}
public record AfternoonRoutineRequest(
Long childId, String dayOfWeek, String labelEs, String labelCa, String icon, String color,
Integer orderIndex, Integer coinsReward) {
}
/** Reordenación de rutinas: lista de ids en el nuevo orden deseado. */
public record RoutineReorderRequest(List<Long> orderedIds) {
}
// --- Eventos ---
public record SpecialEventRequest(
Long childId, String date, String type, String titleEs, String titleCa, String icon, String color) {
}
}

View File

@@ -0,0 +1,42 @@
package es.asepeyo.recordalexia.web.dto;
import java.time.LocalDate;
import java.util.List;
/** Vistas de lectura del panel de padres (nunca exponen entidades JPA). */
public final class ParentViews {
private ParentViews() {
}
public record RewardAdminView(
Long id, String labelEs, String labelCa, String icon, String color, int cost, boolean active) {
}
public record MaterialView(
Long id, String labelEs, String labelCa, String icon, String color, String category) {
}
public record ActivityView(
Long id, String labelEs, String labelCa, String icon, String color, List<Long> materialIds) {
}
public record WeeklyEntryView(
Long id, Long childId, String dayOfWeek, Long activityId, String activityLabelEs,
String icon, String color, int orderIndex, Integer coinsReward) {
}
public record RoutineView(
Long id, Long childId, String dayOfWeek, String labelEs, String labelCa, String icon,
String color, int orderIndex, Integer coinsReward) {
}
public record EventAdminView(
Long id, Long childId, LocalDate date, String type, String titleEs, String titleCa,
String icon, String color) {
}
/** Valores actuales de gamificación de un niño (para precargar el panel). */
public record GamificationView(int coinsPerTask, int coinsPerBlock, int coinsPerDay) {
}
}

View File

@@ -0,0 +1,34 @@
package es.asepeyo.recordalexia.web.dto;
/** DTOs de la tienda de premios. */
public final class StoreDtos {
private StoreDtos() {
}
/** Premio tal como lo ve el niño, con si puede permitírselo y cuánto le falta. */
public record RewardView(
Long id,
String labelEs,
String labelCa,
String icon,
String color,
int cost,
boolean affordable,
int missing) {
}
/** Resultado de un canje: saldo nuevo y premio canjeado. */
public record RedeemResult(Long rewardId, int cost, int newBalance) {
}
/** Alta/edición de premio desde el panel de padres. */
public record RewardRequest(
String labelEs,
String labelCa,
String icon,
String color,
Integer cost,
Boolean active) {
}
}

View File

@@ -0,0 +1,65 @@
package es.asepeyo.recordalexia.web.dto;
import java.util.List;
/**
* Payload de GET /api/children/{id}/today. Lleva las tareas de mañana y tarde, los
* eventos del día, el progreso, el monedero y el temporizador de salida. Los textos
* van en ES y CA; el frontend elige según el idioma activo.
*/
public record TodayResponse(
ChildInfo child,
List<TaskView> morning,
List<TaskView> afternoon,
List<EventView> specialEvents,
ProgressView progress,
WalletInfo wallet,
TimerInfo timer) {
public record ChildInfo(
Long id,
String name,
String mascot,
String accentColor,
String viewMode,
String language,
boolean soundEnabled,
boolean ttsEnabled) {
}
public record TaskView(
Long id,
String labelEs,
String labelCa,
String icon,
String color,
boolean done,
int coinsReward,
int orderIndex) {
}
public record EventView(
Long id,
String type,
String titleEs,
String titleCa,
String icon,
String color) {
}
public record ProgressView(
int morningDone,
int morningTotal,
int afternoonDone,
int afternoonTotal,
int totalDone,
int total) {
}
public record WalletInfo(int coins) {
}
/** departureTime en formato ISO (HH:mm); minutesUntilDeparture puede ser null. */
public record TimerInfo(String departureTime, Integer minutesUntilDeparture) {
}
}

View File

@@ -0,0 +1,15 @@
package es.asepeyo.recordalexia.web.dto;
import es.asepeyo.recordalexia.web.dto.TodayResponse.ProgressView;
/**
* Resultado de marcar/desmarcar una tarea: estado nuevo, saldo, monedas ganadas en
* este toggle (incluidos bonos) y el progreso actualizado del día.
*/
public record ToggleResult(
Long taskId,
boolean done,
int coinsEarned,
int newBalance,
ProgressView progress) {
}

View File

@@ -0,0 +1,17 @@
package es.asepeyo.recordalexia.web.dto;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
/** Saldo del monedero más el historial de movimientos. */
public record WalletResponse(int coins, List<CoinTxView> history) {
public record CoinTxView(
Long id,
LocalDate date,
int amount,
String reason,
Instant createdAt) {
}
}

View File

@@ -0,0 +1,54 @@
# Configuración base de recordaLexia (backend).
# Los valores dependientes del entorno se externalizan en variables de entorno
# (ver .env.example en la raíz).
#
# CREDENCIALES: no se declaran aquí. Spring Boot enlaza automáticamente, por
# binding relajado, las variables de entorno SPRING_DATASOURCE_USERNAME y
# SPRING_DATASOURCE_PASSWORD sobre spring.datasource.*. Así la credencial nunca
# vive en texto plano dentro del repositorio (norma del proyecto).
spring:
application:
name: recordalexia
# --- Origen de datos (PostgreSQL) ---
# En docker-compose, DB_HOST=postgres. En desarrollo local fuera de Docker,
# localhost. Usuario/clave llegan por SPRING_DATASOURCE_USERNAME/_PASSWORD.
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:recordalexia}
# --- JPA / Hibernate ---
# El esquema lo gobierna Liquibase, por eso ddl-auto = validate (Hibernate solo
# valida que el mapeo case con las tablas, nunca crea ni altera). open-in-view
# desactivado: el patrón por capas cierra la sesión en el service, no en la vista.
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
jdbc:
# Zona horaria fija: el negocio decide "hoy" en Europe/Madrid.
time_zone: Europe/Madrid
# --- Liquibase ---
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
# --- Serialización JSON ---
jackson:
time-zone: Europe/Madrid
# --- Servidor ---
server:
port: ${SERVER_PORT:8080}
# --- Actuator: solo health, usado por el healthcheck de Docker ---
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
probes:
enabled: true

View File

@@ -0,0 +1 @@
# Los changesets de Liquibase de la Fase 2 (dominio) vivirán aquí.

View File

@@ -0,0 +1,261 @@
# Esquema inicial de recordaLexia (Fase 2).
# Todas las tablas del dominio. Los textos visibles llevan variante ES y CA.
databaseChangeLog:
- changeSet:
id: 001-create-child
author: recordalexia
changes:
- createTable:
tableName: child
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: name, type: VARCHAR(80), constraints: { nullable: false } }
- column: { name: mascot, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: accent_color, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: age, type: INT, constraints: { nullable: false } }
- column: { name: coins, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
- column: { name: departure_time, type: TIME }
- column: { name: view_mode, type: VARCHAR(10), defaultValue: 'BOARD', constraints: { nullable: false } }
- column: { name: sound_enabled, type: BOOLEAN, defaultValueBoolean: true, constraints: { nullable: false } }
- column: { name: tts_enabled, type: BOOLEAN, defaultValueBoolean: true, constraints: { nullable: false } }
- column: { name: language, type: VARCHAR(2), defaultValue: 'ES', constraints: { nullable: false } }
- column: { name: coins_per_task, type: INT, defaultValueNumeric: 5, constraints: { nullable: false } }
- column: { name: coins_per_block, type: INT, defaultValueNumeric: 10, constraints: { nullable: false } }
- column: { name: coins_per_day, type: INT, defaultValueNumeric: 20, constraints: { nullable: false } }
- changeSet:
id: 002-create-parent-user
author: recordalexia
changes:
- createTable:
tableName: parent_user
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: pin_hash, type: VARCHAR(100), constraints: { nullable: false } }
- column: { name: updated_at, type: TIMESTAMP }
- changeSet:
id: 003-create-activity
author: recordalexia
changes:
- createTable:
tableName: activity
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: label_es, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: label_ca, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- changeSet:
id: 004-create-material-item
author: recordalexia
changes:
- createTable:
tableName: material_item
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: label_es, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: label_ca, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: category, type: VARCHAR(40) }
- changeSet:
id: 005-create-activity-material
author: recordalexia
changes:
- createTable:
tableName: activity_material
columns:
- column: { name: activity_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: material_item_id, type: BIGINT, constraints: { nullable: false } }
- addPrimaryKey:
tableName: activity_material
columnNames: activity_id, material_item_id
constraintName: pk_activity_material
- addForeignKeyConstraint:
baseTableName: activity_material
baseColumnNames: activity_id
referencedTableName: activity
referencedColumnNames: id
constraintName: fk_actmat_activity
onDelete: CASCADE
- addForeignKeyConstraint:
baseTableName: activity_material
baseColumnNames: material_item_id
referencedTableName: material_item
referencedColumnNames: id
constraintName: fk_actmat_material
onDelete: CASCADE
- changeSet:
id: 006-create-weekly-template-entry
author: recordalexia
changes:
- createTable:
tableName: weekly_template_entry
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: day_of_week, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: activity_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: order_index, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
- column: { name: coins_reward, type: INT }
- addForeignKeyConstraint:
baseTableName: weekly_template_entry
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_wte_child
onDelete: CASCADE
- addForeignKeyConstraint:
baseTableName: weekly_template_entry
baseColumnNames: activity_id
referencedTableName: activity
referencedColumnNames: id
constraintName: fk_wte_activity
- changeSet:
id: 007-create-afternoon-routine
author: recordalexia
changes:
- createTable:
tableName: afternoon_routine
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: day_of_week, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: label_es, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: label_ca, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: order_index, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
- column: { name: coins_reward, type: INT }
- addForeignKeyConstraint:
baseTableName: afternoon_routine
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_routine_child
onDelete: CASCADE
- changeSet:
id: 008-create-special-event
author: recordalexia
changes:
- createTable:
tableName: special_event
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: event_date, type: DATE, constraints: { nullable: false } }
- column: { name: type, type: VARCHAR(10), constraints: { nullable: false } }
- column: { name: title_es, type: VARCHAR(160), constraints: { nullable: false } }
- column: { name: title_ca, type: VARCHAR(160), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: special_event
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_event_child
onDelete: CASCADE
- changeSet:
id: 009-create-daily-task
author: recordalexia
changes:
- createTable:
tableName: daily_task
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: task_date, type: DATE, constraints: { nullable: false } }
- column: { name: slot, type: VARCHAR(10), constraints: { nullable: false } }
- column: { name: label_es, type: VARCHAR(160), constraints: { nullable: false } }
- column: { name: label_ca, type: VARCHAR(160), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: status, type: VARCHAR(10), defaultValue: 'PENDING', constraints: { nullable: false } }
- column: { name: coins_reward, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
- column: { name: completed_at, type: TIMESTAMP }
- column: { name: origin, type: VARCHAR(20), constraints: { nullable: false } }
- column: { name: order_index, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: daily_task
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_task_child
onDelete: CASCADE
- createIndex:
tableName: daily_task
indexName: idx_task_child_date
columns:
- column: { name: child_id }
- column: { name: task_date }
- changeSet:
id: 010-create-reward
author: recordalexia
changes:
- createTable:
tableName: reward
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: label_es, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: label_ca, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: cost, type: INT, constraints: { nullable: false } }
- column: { name: active, type: BOOLEAN, defaultValueBoolean: true, constraints: { nullable: false } }
- changeSet:
id: 011-create-coin-transaction
author: recordalexia
changes:
- createTable:
tableName: coin_transaction
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: tx_date, type: DATE, constraints: { nullable: false } }
- column: { name: amount, type: INT, constraints: { nullable: false } }
- column: { name: reason, type: VARCHAR(40), constraints: { nullable: false } }
- column: { name: created_at, type: TIMESTAMP, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: coin_transaction
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_tx_child
onDelete: CASCADE
- changeSet:
id: 012-create-reward-redemption
author: recordalexia
changes:
- createTable:
tableName: reward_redemption
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: reward_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: redeemed_date, type: DATE, constraints: { nullable: false } }
- column: { name: cost, type: INT, constraints: { nullable: false } }
- column: { name: created_at, type: TIMESTAMP, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: reward_redemption
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_redemption_child
onDelete: CASCADE
- addForeignKeyConstraint:
baseTableName: reward_redemption
baseColumnNames: reward_id
referencedTableName: reward
referencedColumnNames: id
constraintName: fk_redemption_reward

View File

@@ -0,0 +1,9 @@
# Changelog maestro de Liquibase.
# Incluye, en orden, todos los changesets ubicados en changes/.
# En la Fase 1 (esqueleto) no hay aún ningún changeset: el esquema de dominio
# (niños, materiales, actividades, eventos, rutinas, premios, monedero...) se
# añadirá en la Fase 2 como ficheros independientes dentro de changes/.
databaseChangeLog:
- includeAll:
path: changes/
relativeToChangelogFile: true

View File

@@ -0,0 +1,13 @@
package es.asepeyo.recordalexia;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class RecordalexiaApplicationTests {
@Test
void contextLoads() {
}
}

View File

@@ -0,0 +1,41 @@
package es.asepeyo.recordalexia.bootstrap;
import static org.assertj.core.api.Assertions.assertThat;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.ParentUserRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
/**
* Verifica que el sembrado del prototipo se ejecuta correctamente. Usa una BD H2
* propia y el seeder activado (al contrario que el resto de tests).
*/
@SpringBootTest
@TestPropertySource(properties = {
"recordalexia.seed.enabled=true",
"spring.datasource.url=jdbc:h2:mem:seedtest;MODE=PostgreSQL;DB_CLOSE_DELAY=-1"
})
class DataSeederIT {
@Autowired private ChildRepository childRepository;
@Autowired private ParentUserRepository parentUserRepository;
@Autowired private RewardRepository rewardRepository;
@Autowired private ActivityRepository activityRepository;
@Autowired private WeeklyTemplateEntryRepository templateRepository;
@Test
void siembraLosDatosDelPrototipo() {
assertThat(childRepository.count()).isEqualTo(3); // Nora, Leo, Mía
assertThat(parentUserRepository.findFirstByOrderByIdAsc()).isPresent();
assertThat(rewardRepository.count()).isEqualTo(6); // 6 premios
assertThat(activityRepository.count()).isEqualTo(4); // 4 actividades
// Cada niño tiene 5 entradas de mañana (L-V): 3 niños x 5 = 15.
assertThat(templateRepository.count()).isEqualTo(15);
}
}

View File

@@ -0,0 +1,66 @@
package es.asepeyo.recordalexia.service;
import static org.assertj.core.api.Assertions.assertThat;
import es.asepeyo.recordalexia.domain.Activity;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
/** Verifica la generación del día: mañana desde plantilla, tarde desde rutinas, idempotente. */
@SpringBootTest
@Transactional
class DayGenerationServiceTest {
@Autowired private DayGenerationService dayGenerationService;
@Autowired private ChildRepository childRepository;
@Autowired private ActivityRepository activityRepository;
@Autowired private WeeklyTemplateEntryRepository templateRepository;
@Autowired private AfternoonRoutineRepository routineRepository;
@Test
void generaTareasDeMananaYTardeYesIdempotente() {
// Fecha fija; usamos su día de la semana para enganchar plantilla y rutina.
LocalDate fecha = LocalDate.of(2026, 6, 22);
DayOfWeek dia = fecha.getDayOfWeek();
Child nino = childRepository.save(nuevoNino());
Activity mates = activityRepository.save(new Activity("Mates", "Mates", "📘", "#5B8DEF"));
templateRepository.save(new WeeklyTemplateEntry(nino, dia, mates, 0));
routineRepository.save(new AfternoonRoutine(nino, dia, "Deberes", "Deures", "📝", "#F2A65A", 0));
List<DailyTask> primera = dayGenerationService.generateIfAbsent(nino.getId(), fecha);
assertThat(primera).hasSize(2);
assertThat(primera).filteredOn(t -> t.getSlot() == Slot.MORNING)
.singleElement().extracting(DailyTask::getLabelEs).isEqualTo("Mates");
assertThat(primera).filteredOn(t -> t.getSlot() == Slot.AFTERNOON)
.singleElement().extracting(DailyTask::getLabelEs).isEqualTo("Deberes");
// Segunda llamada: no debe duplicar (idempotente).
List<DailyTask> segunda = dayGenerationService.generateIfAbsent(nino.getId(), fecha);
assertThat(segunda).hasSize(2);
}
private Child nuevoNino() {
Child c = new Child();
c.setName("Nora");
c.setMascot("🦊");
c.setAccentColor("#F2A65A");
c.setAge(7);
return c;
}
}

View File

@@ -0,0 +1,63 @@
package es.asepeyo.recordalexia.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.exception.InsufficientCoinsException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.RewardRedemptionRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RedeemResult;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
/** Verifica el canje de premios: descuento correcto y error por saldo insuficiente. */
@SpringBootTest
@Transactional
class StoreServiceTest {
@Autowired private StoreService storeService;
@Autowired private ChildRepository childRepository;
@Autowired private RewardRepository rewardRepository;
@Autowired private RewardRedemptionRepository redemptionRepository;
@Test
void canjeaPremioYdescuentaMonedas() {
Child nino = childRepository.save(ninoConSaldo(50));
Reward premio = rewardRepository.save(new Reward("Tablet", "Tauleta", "🎮", "#5B8DEF", 20));
RedeemResult resultado = storeService.redeem(nino.getId(), premio.getId());
assertThat(resultado.cost()).isEqualTo(20);
assertThat(resultado.newBalance()).isEqualTo(30);
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(30);
assertThat(redemptionRepository.findByChildIdOrderByCreatedAtDesc(nino.getId())).hasSize(1);
}
@Test
void rechazaCanjeSiNoHaySaldoSuficiente() {
Child nino = childRepository.save(ninoConSaldo(30));
Reward caro = rewardRepository.save(new Reward("Dino", "Dino", "🦖", "#EC8FA4", 80));
assertThatThrownBy(() -> storeService.redeem(nino.getId(), caro.getId()))
.isInstanceOf(InsufficientCoinsException.class)
.satisfies(ex -> assertThat(((InsufficientCoinsException) ex).getMissing()).isEqualTo(50));
// No se ha tocado el saldo.
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(30);
}
private Child ninoConSaldo(int saldo) {
Child c = new Child();
c.setName("Mía");
c.setMascot("🦉");
c.setAccentColor("#A78BD0");
c.setAge(6);
c.setCoins(saldo);
return c;
}
}

View File

@@ -0,0 +1,82 @@
package es.asepeyo.recordalexia.service;
import static org.assertj.core.api.Assertions.assertThat;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.domain.TaskOrigin;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
/**
* Verifica el marcado/desmarcado de tareas, las monedas por tarea y los bonos de
* bloque y de día, incluida su reversión coherente al desmarcar.
*
* Niño con coinsPerTask=5, coinsPerBlock=10, coinsPerDay=20. Día con 2 tareas de
* mañana y 1 de tarde.
*/
@SpringBootTest
@Transactional
class TaskServiceTest {
private static final LocalDate DIA = LocalDate.of(2026, 6, 22);
@Autowired private TaskService taskService;
@Autowired private ChildRepository childRepository;
@Autowired private DailyTaskRepository dailyTaskRepository;
@Test
void monedasYbonosDeBloqueYdiaConReversion() {
Child nino = childRepository.save(nuevoNino());
DailyTask m1 = nuevaTarea(nino, Slot.MORNING, "Mates", 0);
DailyTask m2 = nuevaTarea(nino, Slot.MORNING, "Lengua", 1);
DailyTask t1 = nuevaTarea(nino, Slot.AFTERNOON, "Deberes", 0);
// 1ª tarea de mañana: +5 (bloque aún incompleto).
var r1 = taskService.toggle(m1.getId());
assertThat(r1.coinsEarned()).isEqualTo(5);
assertThat(r1.newBalance()).isEqualTo(5);
// 2ª tarea de mañana: +5 tarea +10 bono de bloque (mañana completa).
var r2 = taskService.toggle(m2.getId());
assertThat(r2.coinsEarned()).isEqualTo(15);
assertThat(r2.newBalance()).isEqualTo(20);
// Tarea de tarde: +5 tarea +10 bono de tarde +20 bono de día (todo hecho).
var r3 = taskService.toggle(t1.getId());
assertThat(r3.coinsEarned()).isEqualTo(35);
assertThat(r3.newBalance()).isEqualTo(55);
// Desmarcar la de tarde: -5 tarea, -10 bono de tarde, -20 bono de día.
var r4 = taskService.toggle(t1.getId());
assertThat(r4.coinsEarned()).isEqualTo(-35);
assertThat(r4.newBalance()).isEqualTo(20);
// El bono de mañana se conserva (la mañana sigue completa).
Child recargado = childRepository.findById(nino.getId()).orElseThrow();
assertThat(recargado.getCoins()).isEqualTo(20);
}
private Child nuevoNino() {
Child c = new Child();
c.setName("Leo");
c.setMascot("🐢");
c.setAccentColor("#5BC0BE");
c.setAge(9);
c.setCoinsPerTask(5);
c.setCoinsPerBlock(10);
c.setCoinsPerDay(20);
return c;
}
private DailyTask nuevaTarea(Child nino, Slot slot, String label, int orden) {
return dailyTaskRepository.save(new DailyTask(nino, DIA, slot, label, label, "", "#5B8DEF",
5, TaskOrigin.TEMPLATE, orden));
}
}

View File

@@ -0,0 +1,68 @@
package es.asepeyo.recordalexia.web;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import es.asepeyo.recordalexia.domain.ParentUser;
import es.asepeyo.recordalexia.repository.ParentUserRepository;
import es.asepeyo.recordalexia.security.ParentAuthFilter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
/** Verifica el login por PIN y que el panel de padres queda protegido. */
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class ParentAuthIT {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private ParentUserRepository parentUserRepository;
@Autowired private PasswordEncoder passwordEncoder;
@BeforeEach
void setUp() {
parentUserRepository.save(new ParentUser(passwordEncoder.encode("1234")));
}
@Test
void loginCorrectoAbreSesionYpermiteAccederAlPanel() throws Exception {
// Login con PIN correcto -> 200 con identificador de sesión.
String body = mockMvc.perform(post("/api/parents/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"pin\":\"1234\"}"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
JsonNode json = objectMapper.readTree(body);
String session = json.path("session").asText();
// Con la sesión, el panel responde.
mockMvc.perform(get("/api/parents/children").header(ParentAuthFilter.HEADER, session))
.andExpect(status().isOk());
}
@Test
void pinIncorrectoDevuelve401() throws Exception {
mockMvc.perform(post("/api/parents/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"pin\":\"0000\"}"))
.andExpect(status().isUnauthorized());
}
@Test
void panelSinSesionEstaProhibido() throws Exception {
mockMvc.perform(get("/api/parents/children"))
.andExpect(status().isForbidden());
}
}

View File

@@ -0,0 +1,92 @@
package es.asepeyo.recordalexia.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import es.asepeyo.recordalexia.domain.Activity;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import java.time.DayOfWeek;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
/**
* Test de integración del flujo del kiosko a través de la API REST:
* ver el día de hoy, marcar una tarea (ganar monedas) y canjear un premio.
*/
@SpringBootTest
@AutoConfigureMockMvc
class TodayFlowIT {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private ChildRepository childRepository;
@Autowired private ActivityRepository activityRepository;
@Autowired private WeeklyTemplateEntryRepository templateRepository;
@Autowired private AfternoonRoutineRepository routineRepository;
@Autowired private RewardRepository rewardRepository;
@Test
void flujoVerDiaMarcarTareaYcanjear() throws Exception {
// Datos para el día de hoy (engancha plantilla y rutina al día de la semana actual).
DayOfWeek hoy = LocalDate.now().getDayOfWeek();
Child nino = childRepository.save(nuevoNino());
Activity mates = activityRepository.save(new Activity("Mates", "Mates", "📘", "#5B8DEF"));
templateRepository.save(new WeeklyTemplateEntry(nino, hoy, mates, 0));
routineRepository.save(new AfternoonRoutine(nino, hoy, "Deberes", "Deures", "📝", "#F2A65A", 0));
Reward premio = rewardRepository.save(new Reward("Tablet", "Tauleta", "🎮", "#5B8DEF", 5));
// 1) Ver el día: una tarea de mañana y una de tarde.
String body = mockMvc.perform(get("/api/children/{id}/today", nino.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.morning.length()").value(1))
.andExpect(jsonPath("$.afternoon.length()").value(1))
.andExpect(jsonPath("$.wallet.coins").value(50))
.andReturn().getResponse().getContentAsString();
JsonNode json = objectMapper.readTree(body);
long taskId = json.path("morning").get(0).path("id").asLong();
// 2) Marcar la única tarea de mañana: completa el bloque mañana, así que
// gana +5 (tarea) +10 (bono de bloque) = 15. El día no se completa (queda la tarde).
mockMvc.perform(post("/api/tasks/{taskId}/toggle", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.done").value(true))
.andExpect(jsonPath("$.coinsEarned").value(15))
.andExpect(jsonPath("$.newBalance").value(65));
// 3) Canjear un premio de coste 5.
mockMvc.perform(post("/api/rewards/{rewardId}/redeem", premio.getId())
.param("childId", String.valueOf(nino.getId())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.newBalance").value(60));
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(60);
}
private Child nuevoNino() {
Child c = new Child();
c.setName("Nora");
c.setMascot("🦊");
c.setAccentColor("#F2A65A");
c.setAge(7);
c.setCoins(50);
return c;
}
}

View File

@@ -0,0 +1,18 @@
# Configuración de TEST (Fase 2).
# H2 en memoria (modo PostgreSQL) para que el contexto cargue sin un Postgres real.
# Liquibase crea el esquema sobre H2. El seeder se desactiva: cada test monta sus
# propios datos deterministas. (En la Fase futura se valorará Testcontainers.)
spring:
datasource:
url: jdbc:h2:mem:recordalexia;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
jpa:
hibernate:
ddl-auto: none
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
recordalexia:
seed:
enabled: false