feat: cuentas de familia (multi-tenant), registro/login y preferencias

Convierte recordaLexia de una sola familia a multi-familia, con cuentas
propias y persistencia de preferencias.

Backend:
- Tenant Family (email único + contraseña BCrypt + PIN + prefs de cuenta);
  family_id en child/activity/material_item/reward; aislamiento por familia
  (acceso cruzado responde 404).
- Auth propia (sin Keycloak): registro/login email+contraseña, sesiones de
  familia persistidas en BD (sobreviven a reinicios), panel de padres tras PIN.
- Liquibase 002-multitenant; seeder crea una familia demo.
- Tests de aislamiento entre familias, registro/login y gate del panel.

Frontend:
- Login, registro y pantalla de cuenta; guards (sesion + PIN) e interceptor
  de sesion global; perfiles scopeados a la familia.

Preferencias:
- OpenDyslexic persistida por nino (child.dyslexiaFont) y default de cuenta.

Decisiones en docs/adr/0004.
This commit is contained in:
Jaume Garriga Maestre
2026-06-21 13:11:34 +02:00
parent 52e559a159
commit 24a0c8a0dd
72 changed files with 1959 additions and 647 deletions

View File

@@ -4,16 +4,16 @@ 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.Family;
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.FamilyRepository;
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;
@@ -30,22 +30,23 @@ 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.
* Siembra una FAMILIA DEMO con los datos de ejemplo del prototipo (niños, material,
* actividades, horario, rutinas, eventos y premios). Solo actúa si no hay familias,
* así que es seguro arrancar varias veces. Se desactiva con
* recordalexia.seed.enabled=false (lo hacen los tests).
*
* Se desactiva con recordalexia.seed.enabled=false (lo hacen los tests, que montan
* sus propios datos deterministas).
* Credenciales demo: demo@recordalexia.local / demo1234 · PIN 1234.
*/
@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 static final String DEMO_EMAIL = "demo@recordalexia.local";
private static final String DEMO_PASSWORD = "demo1234";
private static final String DEMO_PIN = "1234";
private final FamilyRepository familyRepository;
private final ChildRepository childRepository;
private final ParentUserRepository parentUserRepository;
private final MaterialItemRepository materialRepository;
private final ActivityRepository activityRepository;
private final WeeklyTemplateEntryRepository templateRepository;
@@ -55,14 +56,14 @@ public class DataSeeder implements ApplicationRunner {
private final PasswordEncoder passwordEncoder;
private final Clock clock;
public DataSeeder(ChildRepository childRepository, ParentUserRepository parentUserRepository,
public DataSeeder(FamilyRepository familyRepository, ChildRepository childRepository,
MaterialItemRepository materialRepository, ActivityRepository activityRepository,
WeeklyTemplateEntryRepository templateRepository,
AfternoonRoutineRepository routineRepository,
SpecialEventRepository eventRepository, RewardRepository rewardRepository,
PasswordEncoder passwordEncoder, Clock clock) {
this.familyRepository = familyRepository;
this.childRepository = childRepository;
this.parentUserRepository = parentUserRepository;
this.materialRepository = materialRepository;
this.activityRepository = activityRepository;
this.templateRepository = templateRepository;
@@ -76,26 +77,26 @@ public class DataSeeder implements ApplicationRunner {
@Override
@Transactional
public void run(ApplicationArguments args) {
if (childRepository.count() > 0) {
if (familyRepository.count() > 0) {
return; // Ya sembrado: no duplicar.
}
seedParent();
var materials = seedMaterials();
var activities = seedActivities(materials);
seedRewards();
Family family = familyRepository.save(new Family(DEMO_EMAIL,
passwordEncoder.encode(DEMO_PASSWORD), "Familia demo", passwordEncoder.encode(DEMO_PIN)));
// 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)));
var materials = seedMaterials(family);
var activities = seedActivities(family, materials);
seedRewards(family);
Child nora = childRepository.save(child(family, "Nora", "🦊", "#F2A65A", 7, 42, LocalTime.of(8, 30)));
Child leo = childRepository.save(child(family, "Leo", "🐢", "#5BC0BE", 9, 28, LocalTime.of(8, 30)));
Child mia = childRepository.save(child(family, "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"));
@@ -103,60 +104,66 @@ public class DataSeeder implements ApplicationRunner {
"Ficha de mates", "Fitxa de mates", "📎", "#5B8DEF"));
}
private void seedParent() {
parentUserRepository.save(new ParentUser(passwordEncoder.encode(DEFAULT_PIN)));
}
private MaterialsCatalog seedMaterials() {
private MaterialsCatalog seedMaterials(Family family) {
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"));
material(family, "Estuche", "Estoig", "✏️", "#F4C95D", "general"),
material(family, "Libro de mates", "Llibre de mates", "📘", "#5B8DEF", "matematicas"),
material(family, "Regla", "Regle", "📏", "#5B8DEF", "matematicas"),
material(family, "Flauta", "Flauta", "🎵", "#A78BD0", "musica"),
material(family, "Libreta", "Llibreta", "📓", "#A78BD0", "musica"),
material(family, "Ropa de gimnasia", "Roba d'EF", "👕", "#7FBF6B", "gimnasia"),
material(family, "Zapatillas", "Sabatilles", "👟", "#7FBF6B", "gimnasia"),
material(family, "Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia"),
material(family, "Agua", "Aigua", "💧", "#5BC0BE", "gimnasia"),
material(family, "Lectura", "Lectura", "📖", "#F2A65A", "lengua"),
material(family, "Cuaderno", "Quadern", "📒", "#F2A65A", "lengua"),
material(family, "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 MaterialItem material(Family family, String es, String ca, String icon, String color, String category) {
MaterialItem m = new MaterialItem(es, ca, icon, color, category);
m.setFamily(family);
return materialRepository.save(m);
}
private ActivitiesCatalog seedActivities(MaterialsCatalog m) {
Activity gimnasia = activity("Gimnasia", "Gimnàstica", "🤸", "#7FBF6B",
private ActivitiesCatalog seedActivities(Family family, MaterialsCatalog m) {
Activity gimnasia = activity(family, "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",
Activity musica = activity(family, "Música", "Música", "🎵", "#A78BD0", m.flauta, m.libreta);
Activity mates = activity(family, "Matemáticas", "Matemàtiques", "📘", "#5B8DEF",
m.libroMates, m.regla, m.estuche);
Activity lengua = activity("Lengua", "Llengua", "📖", "#F2A65A", m.lectura, m.cuaderno);
Activity lengua = activity(family, "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) {
private Activity activity(Family family, String es, String ca, String icon, String color, MaterialItem... mats) {
Activity activity = new Activity(es, ca, icon, color);
activity.setFamily(family);
for (MaterialItem mat : mats) {
activity.addMaterial(mat);
}
return activityRepository.save(activity);
}
private void seedRewards() {
private void seedRewards(Family family) {
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)));
reward(family, "30 min de tablet", "30 min de tauleta", "🎮", "#5B8DEF", 20),
reward(family, "Elijo la cena", "Trio el sopar", "🍕", "#F2A65A", 30),
reward(family, "Tarde en el parque", "Tarda al parc", "🛝", "#7FBF6B", 40),
reward(family, "Peli en familia", "Pel·lícula en família", "🍿", "#A78BD0", 50),
reward(family, "30 min más despierto", "30 min més despert", "🌙", "#5BC0BE", 60),
reward(family, "Sorpresa dino", "Sorpresa dino", "🦖", "#EC8FA4", 80)));
}
private Child child(String name, String mascot, String color, int age, int coins, LocalTime departure) {
private Reward reward(Family family, String es, String ca, String icon, String color, int cost) {
Reward r = new Reward(es, ca, icon, color, cost);
r.setFamily(family);
return r;
}
private Child child(Family family, String name, String mascot, String color, int age, int coins, LocalTime departure) {
Child c = new Child();
c.setFamily(family);
c.setName(name);
c.setMascot(mascot);
c.setAccentColor(color);
@@ -166,7 +173,6 @@ public class DataSeeder implements ApplicationRunner {
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);
@@ -179,7 +185,6 @@ public class DataSeeder implements ApplicationRunner {
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)) {
@@ -196,7 +201,6 @@ public class DataSeeder implements ApplicationRunner {
}
}
// 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,

View File

@@ -9,6 +9,7 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.util.LinkedHashSet;
import java.util.Set;
@@ -25,6 +26,10 @@ public class Activity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "family_id")
private Family family;
@Column(name = "label_es")
private String labelEs;
@@ -60,6 +65,14 @@ public class Activity {
return id;
}
public Family getFamily() {
return family;
}
public void setFamily(Family family) {
this.family = family;
}
public String getLabelEs() {
return labelEs;
}

View File

@@ -4,9 +4,12 @@ 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.LocalTime;
@@ -22,6 +25,11 @@ public class Child {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** Familia (tenant) a la que pertenece este niño. */
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "family_id")
private Family family;
private String name;
private String mascot;
@@ -50,6 +58,10 @@ public class Child {
@Enumerated(EnumType.STRING)
private Language language = Language.ES;
/** Preferencia de accesibilidad: usar la tipografía OpenDyslexic. */
@Column(name = "dyslexia_font")
private boolean dyslexiaFont = true;
// --- Parámetros de gamificación (configurables por niño) ---
@Column(name = "coins_per_task")
private int coinsPerTask = 5;
@@ -104,6 +116,22 @@ public class Child {
return id;
}
public Family getFamily() {
return family;
}
public void setFamily(Family family) {
this.family = family;
}
public boolean isDyslexiaFont() {
return dyslexiaFont;
}
public void setDyslexiaFont(boolean dyslexiaFont) {
this.dyslexiaFont = dyslexiaFont;
}
public String getName() {
return name;
}

View File

@@ -0,0 +1,121 @@
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.Instant;
/**
* Cuenta de FAMILIA: el "inquilino" (tenant) de la app. Cada familia tiene sus
* propios niños, catálogos y datos, aislados del resto.
*
* Guarda el hash de la contraseña (BCrypt) para el login y el hash del PIN para el
* gate del panel de padres. Nunca se almacenan en claro.
*/
@Entity
@Table(name = "family")
public class Family {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** Identificador de acceso (único). */
@Column(nullable = false, unique = true)
private String email;
/** Hash BCrypt de la contraseña de acceso. */
@Column(name = "pass_hash", nullable = false)
private String passHash;
/** Nombre visible de la familia. */
private String name;
/** Hash BCrypt del PIN que protege el panel de padres. */
@Column(name = "pin_hash", nullable = false)
private String pinHash;
// --- Preferencias de cuenta ---
@Enumerated(EnumType.STRING)
@Column(name = "ui_language")
private Language uiLanguage = Language.ES;
@Column(name = "default_dyslexia_font")
private boolean defaultDyslexiaFont = true;
@Column(name = "created_at")
private Instant createdAt;
public Family() {
// JPA / creación desde el servicio.
}
public Family(String email, String passHash, String name, String pinHash) {
this.email = email;
this.passHash = passHash;
this.name = name;
this.pinHash = pinHash;
this.createdAt = Instant.now();
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassHash() {
return passHash;
}
public void setPassHash(String passHash) {
this.passHash = passHash;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPinHash() {
return pinHash;
}
public void setPinHash(String pinHash) {
this.pinHash = pinHash;
}
public Language getUiLanguage() {
return uiLanguage;
}
public void setUiLanguage(Language uiLanguage) {
this.uiLanguage = uiLanguage;
}
public boolean isDefaultDyslexiaFont() {
return defaultDyslexiaFont;
}
public void setDefaultDyslexiaFont(boolean defaultDyslexiaFont) {
this.defaultDyslexiaFont = defaultDyslexiaFont;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View File

@@ -0,0 +1,89 @@
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;
/**
* Sesión de una familia en un dispositivo. Persistida en BD para sobrevivir a
* reinicios del backend (el kiosko no se desautentica). Se identifica por un
* "handle" opaco aleatorio que viaja en la cabecera X-Auth-Session.
*
* Lleva además panelUnlockedUntil: cuándo expira el desbloqueo del panel de padres
* (tras introducir el PIN), para que el niño no entre al panel.
*/
@Entity
@Table(name = "family_session")
public class FamilySession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** Valor opaco aleatorio que identifica la sesión. */
@Column(nullable = false, unique = true)
private String handle;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "family_id")
private Family family;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
/** Hasta cuándo el panel de padres está desbloqueado (null = bloqueado). */
@Column(name = "panel_unlocked_until")
private Instant panelUnlockedUntil;
public FamilySession() {
}
public FamilySession(String handle, Family family, Instant expiresAt) {
this.handle = handle;
this.family = family;
this.expiresAt = expiresAt;
}
public boolean isExpired(Instant now) {
return expiresAt.isBefore(now);
}
public boolean isPanelUnlocked(Instant now) {
return panelUnlockedUntil != null && panelUnlockedUntil.isAfter(now);
}
public Long getId() {
return id;
}
public String getHandle() {
return handle;
}
public Family getFamily() {
return family;
}
public Instant getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Instant expiresAt) {
this.expiresAt = expiresAt;
}
public Instant getPanelUnlockedUntil() {
return panelUnlockedUntil;
}
public void setPanelUnlockedUntil(Instant panelUnlockedUntil) {
this.panelUnlockedUntil = panelUnlockedUntil;
}
}

View File

@@ -2,9 +2,12 @@ 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;
/** Material concreto del cole (estuche, flauta...). Texto bilingüe + emoji + color. */
@@ -16,6 +19,10 @@ public class MaterialItem {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "family_id")
private Family family;
@Column(name = "label_es")
private String labelEs;
@@ -41,6 +48,14 @@ public class MaterialItem {
return id;
}
public Family getFamily() {
return family;
}
public void setFamily(Family family) {
this.family = family;
}
public String getLabelEs() {
return labelEs;
}

View File

@@ -1,53 +0,0 @@
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

@@ -2,9 +2,12 @@ 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;
/** Premio canjeable en la tienda. Compartido por todos los niños. */
@@ -16,6 +19,10 @@ public class Reward {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "family_id")
private Family family;
@Column(name = "label_es")
private String labelEs;
@@ -42,6 +49,14 @@ public class Reward {
return id;
}
public Family getFamily() {
return family;
}
public void setFamily(Family family) {
this.family = family;
}
public String getLabelEs() {
return labelEs;
}

View File

@@ -0,0 +1,9 @@
package es.asepeyo.recordalexia.exception;
/** Conflicto de estado, p. ej. email ya registrado. Se traduce a HTTP 409. */
public class ConflictException extends RuntimeException {
public ConflictException(String message) {
super(message);
}
}

View File

@@ -24,6 +24,12 @@ public class GlobalExceptionHandler {
"message", ex.getMessage()));
}
@ExceptionHandler(ConflictException.class)
public ResponseEntity<Map<String, Object>> handleConflict(ConflictException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "conflict", "message", ex.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleBadRequest(IllegalArgumentException ex) {
return ResponseEntity.badRequest()

View File

@@ -1,7 +1,13 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.Activity;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ActivityRepository extends JpaRepository<Activity, Long> {
List<Activity> findByFamilyId(Long familyId);
Optional<Activity> findByIdAndFamilyId(Long id, Long familyId);
}

View File

@@ -1,7 +1,17 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.Child;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ChildRepository extends JpaRepository<Child, Long> {
/** Niños de una familia (tenant). */
List<Child> findByFamilyIdOrderByIdAsc(Long familyId);
/** Carga un niño solo si pertenece a la familia (aislamiento). */
Optional<Child> findByIdAndFamilyId(Long id, Long familyId);
boolean existsByIdAndFamilyId(Long id, Long familyId);
}

View File

@@ -0,0 +1,12 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.Family;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FamilyRepository extends JpaRepository<Family, Long> {
Optional<Family> findByEmailIgnoreCase(String email);
boolean existsByEmailIgnoreCase(String email);
}

View File

@@ -0,0 +1,10 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.FamilySession;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FamilySessionRepository extends JpaRepository<FamilySession, Long> {
Optional<FamilySession> findByHandle(String handle);
}

View File

@@ -1,7 +1,13 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.MaterialItem;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MaterialItemRepository extends JpaRepository<MaterialItem, Long> {
List<MaterialItem> findByFamilyId(Long familyId);
Optional<MaterialItem> findByIdAndFamilyId(Long id, Long familyId);
}

View File

@@ -1,11 +0,0 @@
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

@@ -2,10 +2,16 @@ package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.Reward;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RewardRepository extends JpaRepository<Reward, Long> {
/** Premios activos para mostrar en la tienda. */
List<Reward> findByActiveTrueOrderByCostAsc();
/** Premios activos de una familia, para la tienda. */
List<Reward> findByFamilyIdAndActiveTrueOrderByCostAsc(Long familyId);
/** Catálogo completo de premios de una familia (panel). */
List<Reward> findByFamilyId(Long familyId);
Optional<Reward> findByIdAndFamilyId(Long id, Long familyId);
}

View File

@@ -0,0 +1,57 @@
package es.asepeyo.recordalexia.security;
import es.asepeyo.recordalexia.domain.Family;
import es.asepeyo.recordalexia.exception.ConflictException;
import es.asepeyo.recordalexia.repository.FamilyRepository;
import java.util.Optional;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Registro y acceso de familias. La contraseña y el PIN se guardan con hash BCrypt.
* Encapsulado para poder sustituirlo por un IdP externo en el futuro sin tocar el
* resto del código.
*/
@Service
public class AuthService {
private final FamilyRepository familyRepository;
private final SessionAuthService sessions;
private final PasswordEncoder encoder;
public AuthService(FamilyRepository familyRepository, SessionAuthService sessions,
PasswordEncoder encoder) {
this.familyRepository = familyRepository;
this.sessions = sessions;
this.encoder = encoder;
}
/** Registra una familia nueva y abre sesión (auto-login). Devuelve el handle. */
@Transactional
public String register(String email, String rawPass, String name, String rawPin) {
String normalized = email.trim().toLowerCase();
if (familyRepository.existsByEmailIgnoreCase(normalized)) {
throw new ConflictException("Ese email ya está registrado");
}
Family family = familyRepository.save(
new Family(normalized, encoder.encode(rawPass), name, encoder.encode(rawPin)));
return sessions.openSession(family);
}
/** Valida credenciales; si son correctas abre sesión y devuelve el handle. */
@Transactional
public Optional<String> login(String email, String rawPass) {
return familyRepository.findByEmailIgnoreCase(email.trim().toLowerCase())
.filter(family -> encoder.matches(rawPass, family.getPassHash()))
.map(sessions::openSession);
}
/** Comprueba el PIN del panel para una familia. */
@Transactional(readOnly = true)
public boolean checkPin(Long familyId, String rawPin) {
return familyRepository.findById(familyId)
.filter(family -> encoder.matches(rawPin, family.getPinHash()))
.isPresent();
}
}

View File

@@ -0,0 +1,21 @@
package es.asepeyo.recordalexia.security;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
/**
* Acceso a la familia (tenant) de la petición actual. La sesión la resuelve
* {@link SessionAuthFilter}, que deja el id de familia como principal.
*/
@Component
public class FamilyContext {
/** Id de la familia autenticada. Lanza si no hay sesión (no debería pasar tras el filtro). */
public Long currentFamilyId() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof Long familyId) {
return familyId;
}
throw new IllegalStateException("No hay familia en la sesión actual");
}
}

View File

@@ -1,54 +0,0 @@
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

@@ -1,52 +0,0 @@
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

@@ -15,12 +15,14 @@ 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.
* Seguridad multi-tenant:
* - Públicas: registro y login (abren sesión) y el health.
* - Toda la API requiere sesión de familia válida (rol FAMILY), incluido el kiosko:
* el dispositivo guarda la sesión tras el login del adulto y el niño no se loguea.
* - El panel de padres (/api/parents/**) exige además el rol PARENT, que se obtiene
* desbloqueando con el PIN (POST /api/parents/unlock, accesible con rol FAMILY).
*
* Sin Keycloak/OAuth2 en esta fase; la auth queda encapsulada en el paquete security.
* Sin Keycloak: auth propia encapsulada en este paquete.
*/
@Configuration
public class SecurityConfig {
@@ -31,21 +33,23 @@ public class SecurityConfig {
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, ParentAuthFilter parentAuthFilter)
public SecurityFilterChain filterChain(HttpSecurity http, SessionAuthFilter sessionAuthFilter)
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()
// Públicas: abrir cuenta / sesión.
.requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll()
.requestMatchers("/actuator/health").permitAll()
// El resto del panel de padres exige rol PARENT.
// Desbloqueo del panel: basta con tener sesión de familia.
.requestMatchers(HttpMethod.POST, "/api/parents/unlock").hasRole("FAMILY")
// Resto del panel de padres: requiere PIN desbloqueado.
.requestMatchers("/api/parents/**").hasRole("PARENT")
// Todo lo demás (kiosko del niño) es de acceso libre.
.anyRequest().permitAll())
.addFilterBefore(parentAuthFilter, UsernamePasswordAuthenticationFilter.class);
// Todo lo demás (incluido el kiosko del niño): requiere sesión de familia.
.anyRequest().hasRole("FAMILY"))
.addFilterBefore(sessionAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

View File

@@ -5,6 +5,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@@ -13,30 +14,36 @@ 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}.
* Reconoce la sesión de familia a partir de la cabecera X-Auth-Session. Si es válida,
* autentica la petición con el id de familia como principal y el rol FAMILY; añade
* PARENT si el panel está desbloqueado (PIN introducido). La autorización por ruta la
* decide {@link SecurityConfig}.
*/
@Component
public class ParentAuthFilter extends OncePerRequestFilter {
public class SessionAuthFilter extends OncePerRequestFilter {
public static final String HEADER = "X-Parent-Session";
public static final String HEADER = "X-Auth-Session";
private final ParentSessionStore sessionStore;
private final SessionAuthService sessions;
public ParentAuthFilter(ParentSessionStore sessionStore) {
this.sessionStore = sessionStore;
public SessionAuthFilter(SessionAuthService sessions) {
this.sessions = sessions;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String sessionId = request.getHeader(HEADER);
if (sessionStore.isValid(sessionId)) {
String handle = request.getHeader(HEADER);
sessions.resolve(handle).ifPresent(info -> {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_FAMILY"));
if (info.panelUnlocked()) {
authorities.add(new SimpleGrantedAuthority("ROLE_PARENT"));
}
var authentication = new UsernamePasswordAuthenticationToken(
"parent", null, List.of(new SimpleGrantedAuthority("ROLE_PARENT")));
info.familyId(), null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
});
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,72 @@
package es.asepeyo.recordalexia.security;
import es.asepeyo.recordalexia.domain.Family;
import es.asepeyo.recordalexia.domain.FamilySession;
import es.asepeyo.recordalexia.repository.FamilySessionRepository;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Sesiones de familia persistidas en BD. Sobreviven a reinicios del backend (clave
* para el kiosko, que no debe desautenticarse). El "handle" opaco viaja en la
* cabecera X-Auth-Session.
*/
@Service
public class SessionAuthService {
/** Vigencia de la sesión del dispositivo (larga, para el kiosko). */
private static final Duration SESSION_TTL = Duration.ofDays(30);
/** Vigencia del desbloqueo del panel de padres tras el PIN. */
private static final Duration PANEL_TTL = Duration.ofMinutes(30);
private final FamilySessionRepository sessionRepository;
public SessionAuthService(FamilySessionRepository sessionRepository) {
this.sessionRepository = sessionRepository;
}
/** Datos mínimos de sesión que necesita el filtro (sin tocar entidades lazy fuera de tx). */
public record SessionInfo(Long familyId, boolean panelUnlocked) {
}
/** Abre una sesión nueva para una familia y devuelve su handle. */
@Transactional
public String openSession(Family family) {
String handle = UUID.randomUUID().toString().replace("-", "");
sessionRepository.save(new FamilySession(handle, family, Instant.now().plus(SESSION_TTL)));
return handle;
}
/** Resuelve una sesión válida; limpia las caducadas. */
@Transactional
public Optional<SessionInfo> resolve(String handle) {
if (handle == null || handle.isBlank()) {
return Optional.empty();
}
Instant now = Instant.now();
return sessionRepository.findByHandle(handle).flatMap(session -> {
if (session.isExpired(now)) {
sessionRepository.delete(session);
return Optional.empty();
}
return Optional.of(new SessionInfo(session.getFamily().getId(), session.isPanelUnlocked(now)));
});
}
/** Marca el panel de padres como desbloqueado durante PANEL_TTL. */
@Transactional
public void unlockPanel(String handle) {
sessionRepository.findByHandle(handle).ifPresent(session ->
session.setPanelUnlockedUntil(Instant.now().plus(PANEL_TTL)));
}
/** Cierra (revoca) una sesión. */
@Transactional
public void revoke(String handle) {
sessionRepository.findByHandle(handle).ifPresent(sessionRepository::delete);
}
}

View File

@@ -5,6 +5,8 @@ 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.repository.FamilyRepository;
import es.asepeyo.recordalexia.security.FamilyContext;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildRequest;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildSummary;
import es.asepeyo.recordalexia.web.dto.ChildDtos.SettingsRequest;
@@ -13,14 +15,22 @@ 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. */
/**
* Perfiles de niños, SIEMPRE scopeados a la familia (tenant) de la sesión. Un niño
* solo es accesible si pertenece a la familia actual; si no, se trata como inexistente.
*/
@Service
public class ChildService {
private final ChildRepository childRepository;
private final FamilyRepository familyRepository;
private final FamilyContext familyContext;
public ChildService(ChildRepository childRepository) {
public ChildService(ChildRepository childRepository, FamilyRepository familyRepository,
FamilyContext familyContext) {
this.childRepository = childRepository;
this.familyRepository = familyRepository;
this.familyContext = familyContext;
}
@Transactional(readOnly = true)
@@ -30,13 +40,12 @@ public class ChildService {
@Transactional(readOnly = true)
public List<ChildSummary> listChildren() {
return childRepository.findAll().stream()
return childRepository.findByFamilyIdOrderByIdAsc(familyContext.currentFamilyId()).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);
@@ -52,12 +61,14 @@ public class ChildService {
if (req.language() != null) {
child.setLanguage(Language.valueOf(req.language()));
}
if (req.dyslexiaFont() != null) {
child.setDyslexiaFont(req.dyslexiaFont());
}
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);
@@ -75,21 +86,20 @@ public class ChildService {
@Transactional
public Child create(ChildRequest req) {
Child child = new Child();
// El niño nace en la familia de la sesión.
child.setFamily(familyRepository.getReferenceById(familyContext.currentFamilyId()));
applyRequest(child, req);
return childRepository.save(child);
}
@Transactional
public void update(Long childId, ChildRequest req) {
Child child = requireChild(childId);
applyRequest(child, req);
applyRequest(requireChild(childId), req);
}
@Transactional
public void delete(Long childId) {
if (!childRepository.existsById(childId)) {
throw new NotFoundException("No existe el niño con id " + childId);
}
requireChild(childId); // valida pertenencia antes de borrar
childRepository.deleteById(childId);
}
@@ -114,8 +124,9 @@ public class ChildService {
}
}
/** Carga un niño SOLO si pertenece a la familia actual. */
private Child requireChild(Long childId) {
return childRepository.findById(childId)
return childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId())
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}
}

View File

@@ -10,6 +10,7 @@ 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.security.FamilyContext;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RedeemResult;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardView;
import java.time.Clock;
@@ -26,17 +27,20 @@ public class StoreService {
private final RewardRepository rewardRepository;
private final CoinTransactionRepository coinTransactionRepository;
private final RewardRedemptionRepository rewardRedemptionRepository;
private final FamilyContext familyContext;
private final Clock clock;
public StoreService(ChildRepository childRepository,
RewardRepository rewardRepository,
CoinTransactionRepository coinTransactionRepository,
RewardRedemptionRepository rewardRedemptionRepository,
FamilyContext familyContext,
Clock clock) {
this.childRepository = childRepository;
this.rewardRepository = rewardRepository;
this.coinTransactionRepository = coinTransactionRepository;
this.rewardRedemptionRepository = rewardRedemptionRepository;
this.familyContext = familyContext;
this.clock = clock;
}
@@ -44,7 +48,8 @@ public class StoreService {
@Transactional(readOnly = true)
public List<RewardView> listRewards(Long childId) {
Child child = requireChild(childId);
return rewardRepository.findByActiveTrueOrderByCostAsc().stream()
return rewardRepository
.findByFamilyIdAndActiveTrueOrderByCostAsc(familyContext.currentFamilyId()).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())))
@@ -58,7 +63,7 @@ public class StoreService {
@Transactional
public RedeemResult redeem(Long childId, Long rewardId) {
Child child = requireChild(childId);
Reward reward = rewardRepository.findById(rewardId)
Reward reward = rewardRepository.findByIdAndFamilyId(rewardId, familyContext.currentFamilyId())
.orElseThrow(() -> new NotFoundException("No existe el premio con id " + rewardId));
if (!reward.isActive()) {
@@ -80,7 +85,7 @@ public class StoreService {
}
private Child requireChild(Long childId) {
return childRepository.findById(childId)
return childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId())
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}
}

View File

@@ -8,6 +8,7 @@ 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.security.FamilyContext;
import es.asepeyo.recordalexia.web.dto.ToggleResult;
import java.time.Clock;
import java.time.Instant;
@@ -35,15 +36,18 @@ public class TaskService {
private final DailyTaskRepository dailyTaskRepository;
private final ChildRepository childRepository;
private final CoinTransactionRepository coinTransactionRepository;
private final FamilyContext familyContext;
private final Clock clock;
public TaskService(DailyTaskRepository dailyTaskRepository,
ChildRepository childRepository,
CoinTransactionRepository coinTransactionRepository,
FamilyContext familyContext,
Clock clock) {
this.dailyTaskRepository = dailyTaskRepository;
this.childRepository = childRepository;
this.coinTransactionRepository = coinTransactionRepository;
this.familyContext = familyContext;
this.clock = clock;
}
@@ -53,6 +57,10 @@ public class TaskService {
.orElseThrow(() -> new NotFoundException("No existe la tarea con id " + taskId));
Child child = task.getChild();
// Aislamiento: la tarea debe pertenecer a un niño de la familia de la sesión.
if (!child.getFamily().getId().equals(familyContext.currentFamilyId())) {
throw new NotFoundException("No existe la tarea con id " + taskId);
}
Long childId = child.getId();
LocalDate date = task.getTaskDate();
Slot slot = task.getSlot();

View File

@@ -7,6 +7,7 @@ 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.security.FamilyContext;
import es.asepeyo.recordalexia.web.dto.TodayResponse;
import es.asepeyo.recordalexia.web.dto.TodayResponse.ChildInfo;
import es.asepeyo.recordalexia.web.dto.TodayResponse.EventView;
@@ -31,21 +32,25 @@ public class TodayService {
private final DayGenerationService dayGenerationService;
private final ChildRepository childRepository;
private final SpecialEventRepository specialEventRepository;
private final FamilyContext familyContext;
private final Clock clock;
public TodayService(DayGenerationService dayGenerationService,
ChildRepository childRepository,
SpecialEventRepository specialEventRepository,
FamilyContext familyContext,
Clock clock) {
this.dayGenerationService = dayGenerationService;
this.childRepository = childRepository;
this.specialEventRepository = specialEventRepository;
this.familyContext = familyContext;
this.clock = clock;
}
@Transactional
public TodayResponse getToday(Long childId) {
Child child = childRepository.findById(childId)
// Solo el niño de la familia de la sesión (aislamiento).
Child child = childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId())
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
LocalDate today = LocalDate.now(clock);
@@ -86,7 +91,8 @@ public class TodayService {
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());
c.getViewMode().name(), c.getLanguage().name(), c.isSoundEnabled(), c.isTtsEnabled(),
c.isDyslexiaFont());
}
/** Temporizador de salida: minutos que faltan hasta departureTime (>= 0). */

View File

@@ -4,6 +4,7 @@ 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.security.FamilyContext;
import es.asepeyo.recordalexia.web.dto.WalletResponse;
import es.asepeyo.recordalexia.web.dto.WalletResponse.CoinTxView;
import java.util.List;
@@ -16,16 +17,19 @@ public class WalletService {
private final ChildRepository childRepository;
private final CoinTransactionRepository coinTransactionRepository;
private final FamilyContext familyContext;
public WalletService(ChildRepository childRepository,
CoinTransactionRepository coinTransactionRepository) {
CoinTransactionRepository coinTransactionRepository,
FamilyContext familyContext) {
this.childRepository = childRepository;
this.coinTransactionRepository = coinTransactionRepository;
this.familyContext = familyContext;
}
@Transactional(readOnly = true)
public WalletResponse getWallet(Long childId) {
Child child = childRepository.findById(childId)
Child child = childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId())
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
List<CoinTxView> history = coinTransactionRepository

View File

@@ -0,0 +1,90 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Family;
import es.asepeyo.recordalexia.domain.Language;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.security.FamilyContext;
import es.asepeyo.recordalexia.web.dto.AuthDtos.AccountPrefsRequest;
import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePasswordRequest;
import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePinRequest;
import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
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;
/** Preferencias de la cuenta de familia y cambio de credenciales. */
@RestController
@RequestMapping("/api/account")
public class AccountController {
private final FamilyContext familyContext;
private final FamilyRepository familyRepository;
private final PasswordEncoder encoder;
public AccountController(FamilyContext familyContext, FamilyRepository familyRepository,
PasswordEncoder encoder) {
this.familyContext = familyContext;
this.familyRepository = familyRepository;
this.encoder = encoder;
}
@GetMapping
public MeResponse get() {
Family f = current();
return new MeResponse(f.getId(), f.getEmail(), f.getName(),
f.getUiLanguage().name(), f.isDefaultDyslexiaFont());
}
@PutMapping("/prefs")
@Transactional
public ResponseEntity<Void> updatePrefs(@RequestBody AccountPrefsRequest req) {
Family f = current();
if (req.uiLanguage() != null) {
f.setUiLanguage(Language.valueOf(req.uiLanguage()));
}
if (req.defaultDyslexiaFont() != null) {
f.setDefaultDyslexiaFont(req.defaultDyslexiaFont());
}
return ResponseEntity.noContent().build();
}
@PutMapping("/password")
@Transactional
public ResponseEntity<Void> changePassword(@RequestBody ChangePasswordRequest req) {
Family f = current();
if (!encoder.matches(req.currentPassword(), f.getPassHash())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (req.newPassword() == null || req.newPassword().length() < 6) {
throw new IllegalArgumentException("La contraseña debe tener al menos 6 caracteres");
}
f.setPassHash(encoder.encode(req.newPassword()));
return ResponseEntity.noContent().build();
}
@PutMapping("/pin")
@Transactional
public ResponseEntity<Void> changePin(@RequestBody ChangePinRequest req) {
Family f = current();
if (!encoder.matches(req.currentPin(), f.getPinHash())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (req.newPin() == null || !req.newPin().matches("\\d{4}")) {
throw new IllegalArgumentException("El PIN debe ser de 4 dígitos");
}
f.setPinHash(encoder.encode(req.newPin()));
return ResponseEntity.noContent().build();
}
private Family current() {
return familyRepository.findById(familyContext.currentFamilyId())
.orElseThrow(() -> new NotFoundException("Familia no encontrada"));
}
}

View File

@@ -0,0 +1,86 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Family;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.security.AuthService;
import es.asepeyo.recordalexia.security.FamilyContext;
import es.asepeyo.recordalexia.security.SessionAuthFilter;
import es.asepeyo.recordalexia.security.SessionAuthService;
import es.asepeyo.recordalexia.web.dto.AuthDtos.LoginRequest;
import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse;
import es.asepeyo.recordalexia.web.dto.AuthDtos.RegisterRequest;
import es.asepeyo.recordalexia.web.dto.AuthDtos.SessionResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Registro, acceso y sesión de familias. */
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
private final SessionAuthService sessions;
private final FamilyContext familyContext;
private final FamilyRepository familyRepository;
public AuthController(AuthService authService, SessionAuthService sessions,
FamilyContext familyContext, FamilyRepository familyRepository) {
this.authService = authService;
this.sessions = sessions;
this.familyContext = familyContext;
this.familyRepository = familyRepository;
}
/** Alta de familia + auto-login. */
@PostMapping("/register")
public ResponseEntity<SessionResponse> register(@RequestBody RegisterRequest req) {
requireText(req.email(), "email");
if (req.password() == null || req.password().length() < 6) {
throw new IllegalArgumentException("La contraseña debe tener al menos 6 caracteres");
}
if (req.pin() == null || !req.pin().matches("\\d{4}")) {
throw new IllegalArgumentException("El PIN debe ser de 4 dígitos");
}
String session = authService.register(req.email(), req.password(), req.name(), req.pin());
return ResponseEntity.status(HttpStatus.CREATED).body(new SessionResponse(session));
}
/** Acceso con email + contraseña. 200 con sesión, 401 si no. */
@PostMapping("/login")
public ResponseEntity<SessionResponse> login(@RequestBody LoginRequest req) {
return authService.login(req.email(), req.password())
.map(session -> ResponseEntity.ok(new SessionResponse(session)))
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
}
/** Cierra la sesión del dispositivo. */
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestHeader(value = SessionAuthFilter.HEADER, required = false) String handle) {
if (handle != null) {
sessions.revoke(handle);
}
return ResponseEntity.noContent().build();
}
/** Datos de la familia autenticada (para el frontend). */
@GetMapping("/me")
public MeResponse me() {
Family f = familyRepository.findById(familyContext.currentFamilyId())
.orElseThrow(() -> new NotFoundException("Familia no encontrada"));
return new MeResponse(f.getId(), f.getEmail(), f.getName(),
f.getUiLanguage().name(), f.isDefaultDyslexiaFont());
}
private void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Falta el campo " + field);
}
}
}

View File

@@ -0,0 +1,42 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.security.AuthService;
import es.asepeyo.recordalexia.security.FamilyContext;
import es.asepeyo.recordalexia.security.SessionAuthFilter;
import es.asepeyo.recordalexia.security.SessionAuthService;
import es.asepeyo.recordalexia.web.dto.AuthDtos.UnlockRequest;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Gate del panel de padres: desbloquea con el PIN de la familia. */
@RestController
@RequestMapping("/api/parents")
public class PanelController {
private final AuthService authService;
private final SessionAuthService sessions;
private final FamilyContext familyContext;
public PanelController(AuthService authService, SessionAuthService sessions,
FamilyContext familyContext) {
this.authService = authService;
this.sessions = sessions;
this.familyContext = familyContext;
}
/** Valida el PIN y desbloquea el panel para esta sesión. 204 si OK, 401 si no. */
@PostMapping("/unlock")
public ResponseEntity<Void> unlock(@RequestBody UnlockRequest req,
@RequestHeader(SessionAuthFilter.HEADER) String handle) {
if (authService.checkPin(familyContext.currentFamilyId(), req.pin())) {
sessions.unlockPanel(handle);
return ResponseEntity.noContent().build();
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}

View File

@@ -1,40 +0,0 @@
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

@@ -4,7 +4,9 @@ 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.FamilyRepository;
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
import es.asepeyo.recordalexia.security.FamilyContext;
import es.asepeyo.recordalexia.web.dto.ParentDtos.ActivityRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.MaterialRequest;
import es.asepeyo.recordalexia.web.dto.ParentViews.ActivityView;
@@ -20,25 +22,34 @@ 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). */
/** CRUD de actividades del cole y su material, por familia (panel de padres). */
@RestController
@RequestMapping("/api/parents/catalog")
public class ParentCatalogController {
private final ActivityRepository activityRepository;
private final MaterialItemRepository materialRepository;
private final FamilyRepository familyRepository;
private final FamilyContext familyContext;
public ParentCatalogController(ActivityRepository activityRepository,
MaterialItemRepository materialRepository) {
MaterialItemRepository materialRepository,
FamilyRepository familyRepository, FamilyContext familyContext) {
this.activityRepository = activityRepository;
this.materialRepository = materialRepository;
this.familyRepository = familyRepository;
this.familyContext = familyContext;
}
private Long fid() {
return familyContext.currentFamilyId();
}
// --- Materiales ---
@GetMapping("/materials")
public List<MaterialView> listMaterials() {
return materialRepository.findAll().stream()
return materialRepository.findByFamilyId(fid()).stream()
.map(m -> new MaterialView(m.getId(), m.getLabelEs(), m.getLabelCa(), m.getIcon(),
m.getColor(), m.getCategory()))
.toList();
@@ -46,17 +57,18 @@ public class ParentCatalogController {
@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()));
MaterialItem material = new MaterialItem(req.labelEs(), req.labelCa(), req.icon(),
req.color(), req.category());
material.setFamily(familyRepository.getReferenceById(fid()));
MaterialItem saved = materialRepository.save(material);
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.findByIdAndFamilyId(id, fid())
.orElseThrow(() -> new NotFoundException("No existe el material con id " + id));
materialRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
@@ -66,13 +78,14 @@ public class ParentCatalogController {
@GetMapping("/activities")
@Transactional(readOnly = true)
public List<ActivityView> listActivities() {
return activityRepository.findAll().stream().map(this::toActivityView).toList();
return activityRepository.findByFamilyId(fid()).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());
activity.setFamily(familyRepository.getReferenceById(fid()));
attachMaterials(activity, req.materialIds());
Activity saved = activityRepository.save(activity);
return ResponseEntity.ok(toActivityView(saved));
@@ -80,9 +93,8 @@ public class ParentCatalogController {
@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.findByIdAndFamilyId(id, fid())
.orElseThrow(() -> new NotFoundException("No existe la actividad con id " + id));
activityRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
@@ -92,7 +104,7 @@ public class ParentCatalogController {
return;
}
for (Long materialId : materialIds) {
MaterialItem material = materialRepository.findById(materialId)
MaterialItem material = materialRepository.findByIdAndFamilyId(materialId, fid())
.orElseThrow(() -> new NotFoundException("No existe el material con id " + materialId));
activity.addMaterial(material);
}

View File

@@ -6,12 +6,14 @@ 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.security.FamilyContext;
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.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -28,15 +30,18 @@ public class ParentEventController {
private final SpecialEventRepository eventRepository;
private final ChildRepository childRepository;
private final FamilyContext familyContext;
public ParentEventController(SpecialEventRepository eventRepository,
ChildRepository childRepository) {
ChildRepository childRepository, FamilyContext familyContext) {
this.eventRepository = eventRepository;
this.childRepository = childRepository;
this.familyContext = familyContext;
}
@GetMapping
public List<EventAdminView> list(@RequestParam Long childId) {
requireChild(childId); // aislamiento: el niño debe ser de la familia
return eventRepository.findByChildIdOrderByEventDateAsc(childId).stream()
.map(this::toView).toList();
}
@@ -52,16 +57,17 @@ public class ParentEventController {
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!eventRepository.existsById(id)) {
throw new NotFoundException("No existe el evento con id " + id);
}
eventRepository.deleteById(id);
SpecialEvent event = eventRepository.findById(id)
.filter(e -> e.getChild().getFamily().getId().equals(familyContext.currentFamilyId()))
.orElseThrow(() -> new NotFoundException("No existe el evento con id " + id));
eventRepository.delete(event);
return ResponseEntity.noContent().build();
}
private Child requireChild(Long childId) {
return childRepository.findById(childId)
return childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId())
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}

View File

@@ -2,7 +2,9 @@ package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.security.FamilyContext;
import es.asepeyo.recordalexia.web.dto.ParentViews.RewardAdminView;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardRequest;
import java.net.URI;
@@ -17,26 +19,33 @@ 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. */
/** CRUD del catálogo de premios de la familia (panel de padres). */
@RestController
@RequestMapping("/api/parents/rewards")
public class ParentRewardController {
private final RewardRepository rewardRepository;
private final FamilyRepository familyRepository;
private final FamilyContext familyContext;
public ParentRewardController(RewardRepository rewardRepository) {
public ParentRewardController(RewardRepository rewardRepository,
FamilyRepository familyRepository, FamilyContext familyContext) {
this.rewardRepository = rewardRepository;
this.familyRepository = familyRepository;
this.familyContext = familyContext;
}
@GetMapping
public List<RewardAdminView> list() {
return rewardRepository.findAll().stream().map(this::toView).toList();
return rewardRepository.findByFamilyId(familyContext.currentFamilyId()).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);
reward.setFamily(familyRepository.getReferenceById(familyContext.currentFamilyId()));
if (req.active() != null) {
reward.setActive(req.active());
}
@@ -47,8 +56,7 @@ public class ParentRewardController {
@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));
Reward reward = requireOwned(id);
if (req.labelEs() != null) {
reward.setLabelEs(req.labelEs());
}
@@ -73,13 +81,16 @@ public class ParentRewardController {
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!rewardRepository.existsById(id)) {
throw new NotFoundException("No existe el premio con id " + id);
}
requireOwned(id);
rewardRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private Reward requireOwned(Long id) {
return rewardRepository.findByIdAndFamilyId(id, familyContext.currentFamilyId())
.orElseThrow(() -> new NotFoundException("No existe el premio con id " + id));
}
private RewardAdminView toView(Reward r) {
return new RewardAdminView(r.getId(), r.getLabelEs(), r.getLabelCa(), r.getIcon(),
r.getColor(), r.getCost(), r.isActive());

View File

@@ -9,6 +9,7 @@ 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.security.FamilyContext;
import es.asepeyo.recordalexia.web.dto.ParentDtos.AfternoonRoutineRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.RoutineReorderRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.WeeklyEntryRequest;
@@ -28,7 +29,7 @@ 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). */
/** CRUD del horario semanal de mañana y de las rutinas de tarde (scopeado a la familia). */
@RestController
@RequestMapping("/api/parents/schedule")
public class ParentScheduleController {
@@ -37,15 +38,22 @@ public class ParentScheduleController {
private final AfternoonRoutineRepository routineRepository;
private final ChildRepository childRepository;
private final ActivityRepository activityRepository;
private final FamilyContext familyContext;
public ParentScheduleController(WeeklyTemplateEntryRepository templateRepository,
AfternoonRoutineRepository routineRepository,
ChildRepository childRepository,
ActivityRepository activityRepository) {
ActivityRepository activityRepository,
FamilyContext familyContext) {
this.templateRepository = templateRepository;
this.routineRepository = routineRepository;
this.childRepository = childRepository;
this.activityRepository = activityRepository;
this.familyContext = familyContext;
}
private Long fid() {
return familyContext.currentFamilyId();
}
// --- Plantilla de mañana ---
@@ -53,6 +61,7 @@ public class ParentScheduleController {
@GetMapping("/weekly")
@Transactional(readOnly = true)
public List<WeeklyEntryView> listWeekly(@RequestParam Long childId) {
requireChild(childId); // aislamiento: el niño debe ser de la familia
return templateRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream()
.map(this::toWeeklyView).toList();
}
@@ -60,7 +69,7 @@ public class ParentScheduleController {
@PostMapping("/weekly")
public ResponseEntity<WeeklyEntryView> createWeekly(@RequestBody WeeklyEntryRequest req) {
Child child = requireChild(req.childId());
Activity activity = activityRepository.findById(req.activityId())
Activity activity = activityRepository.findByIdAndFamilyId(req.activityId(), fid())
.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);
@@ -69,18 +78,21 @@ public class ParentScheduleController {
}
@DeleteMapping("/weekly/{id}")
@Transactional
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);
WeeklyTemplateEntry entry = templateRepository.findById(id)
.filter(e -> e.getChild().getFamily().getId().equals(fid()))
.orElseThrow(() -> new NotFoundException("No existe la entrada de horario con id " + id));
templateRepository.delete(entry);
return ResponseEntity.noContent().build();
}
// --- Rutinas de tarde ---
@GetMapping("/routines")
@Transactional(readOnly = true)
public List<RoutineView> listRoutines(@RequestParam Long childId) {
requireChild(childId);
return routineRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream()
.map(this::toRoutineView).toList();
}
@@ -95,29 +107,32 @@ public class ParentScheduleController {
return ResponseEntity.ok(toRoutineView(routineRepository.save(routine)));
}
/** Reordena las rutinas: asigna orderIndex según la posición en la lista recibida. */
/** Reordena rutinas (solo las de la familia): orderIndex según la posición 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));
routineRepository.findById(ids.get(i))
.filter(r -> r.getChild().getFamily().getId().equals(fid()))
.ifPresent(r -> r.setOrderIndex(orderIndex));
}
return ResponseEntity.noContent().build();
}
@DeleteMapping("/routines/{id}")
@Transactional
public ResponseEntity<Void> deleteRoutine(@PathVariable Long id) {
if (!routineRepository.existsById(id)) {
throw new NotFoundException("No existe la rutina con id " + id);
}
routineRepository.deleteById(id);
AfternoonRoutine routine = routineRepository.findById(id)
.filter(r -> r.getChild().getFamily().getId().equals(fid()))
.orElseThrow(() -> new NotFoundException("No existe la rutina con id " + id));
routineRepository.delete(routine);
return ResponseEntity.noContent().build();
}
private Child requireChild(Long childId) {
return childRepository.findById(childId)
return childRepository.findByIdAndFamilyId(childId, fid())
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}

View File

@@ -0,0 +1,34 @@
package es.asepeyo.recordalexia.web.dto;
/** DTOs de autenticación y cuenta. El campo de contraseña se llama "password" en el JSON. */
public final class AuthDtos {
private AuthDtos() {
}
public record RegisterRequest(String email, String password, String name, String pin) {
}
public record LoginRequest(String email, String password) {
}
/** Identificador de sesión a enviar luego en la cabecera X-Auth-Session. */
public record SessionResponse(String session) {
}
public record MeResponse(Long familyId, String email, String name, String uiLanguage,
boolean defaultDyslexiaFont) {
}
public record UnlockRequest(String pin) {
}
public record ChangePasswordRequest(String currentPassword, String newPassword) {
}
public record ChangePinRequest(String currentPin, String newPin) {
}
public record AccountPrefsRequest(String uiLanguage, Boolean defaultDyslexiaFont) {
}
}

View File

@@ -27,6 +27,7 @@ public final class ChildDtos {
Boolean soundEnabled,
Boolean ttsEnabled,
String language,
Boolean dyslexiaFont,
String departureTime) {
}

View File

@@ -24,7 +24,8 @@ public record TodayResponse(
String viewMode,
String language,
boolean soundEnabled,
boolean ttsEnabled) {
boolean ttsEnabled,
boolean dyslexiaFont) {
}
public record TaskView(

View File

@@ -0,0 +1,103 @@
# Multi-tenant: cuentas de familia + aislamiento de datos por familia.
# Nota: family_id se añade como NOT NULL directamente; en una BD nueva las tablas
# están vacías cuando corre este changeset (el seeder corre después), así que es
# seguro. Para migrar una BD ya poblada habría que hacer backfill antes.
databaseChangeLog:
- changeSet:
id: 100-create-family
author: recordalexia
changes:
- createTable:
tableName: family
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: email, type: VARCHAR(160), constraints: { nullable: false, unique: true } }
- column: { name: pass_hash, type: VARCHAR(100), constraints: { nullable: false } }
- column: { name: name, type: VARCHAR(120) }
- column: { name: pin_hash, type: VARCHAR(100), constraints: { nullable: false } }
- column: { name: ui_language, type: VARCHAR(2), defaultValue: 'ES', constraints: { nullable: false } }
- column: { name: default_dyslexia_font, type: BOOLEAN, defaultValueBoolean: true, constraints: { nullable: false } }
- column: { name: created_at, type: TIMESTAMP }
- changeSet:
id: 101-create-family-session
author: recordalexia
changes:
- createTable:
tableName: family_session
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: handle, type: VARCHAR(80), constraints: { nullable: false, unique: true } }
- column: { name: family_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: expires_at, type: TIMESTAMP, constraints: { nullable: false } }
- column: { name: panel_unlocked_until, type: TIMESTAMP }
- addForeignKeyConstraint:
baseTableName: family_session
baseColumnNames: family_id
referencedTableName: family
referencedColumnNames: id
constraintName: fk_session_family
onDelete: CASCADE
- changeSet:
id: 102-add-family-to-child
author: recordalexia
changes:
- addColumn:
tableName: child
columns:
- column: { name: family_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: dyslexia_font, type: BOOLEAN, defaultValueBoolean: true, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: child
baseColumnNames: family_id
referencedTableName: family
referencedColumnNames: id
constraintName: fk_child_family
onDelete: CASCADE
- changeSet:
id: 103-add-family-to-catalog
author: recordalexia
changes:
- addColumn:
tableName: activity
columns:
- column: { name: family_id, type: BIGINT, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: activity
baseColumnNames: family_id
referencedTableName: family
referencedColumnNames: id
constraintName: fk_activity_family
onDelete: CASCADE
- addColumn:
tableName: material_item
columns:
- column: { name: family_id, type: BIGINT, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: material_item
baseColumnNames: family_id
referencedTableName: family
referencedColumnNames: id
constraintName: fk_material_family
onDelete: CASCADE
- addColumn:
tableName: reward
columns:
- column: { name: family_id, type: BIGINT, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: reward
baseColumnNames: family_id
referencedTableName: family
referencedColumnNames: id
constraintName: fk_reward_family
onDelete: CASCADE
- changeSet:
id: 104-drop-parent-user
author: recordalexia
changes:
# La auth por PIN único se sustituye por cuentas de familia.
- dropTable:
tableName: parent_user

View File

@@ -4,7 +4,7 @@ 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.FamilyRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import org.junit.jupiter.api.Test;
@@ -13,8 +13,8 @@ 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).
* Verifica que el sembrado del prototipo (familia demo + sus datos) se ejecuta
* correctamente. Usa una BD H2 propia y el seeder activado.
*/
@SpringBootTest
@TestPropertySource(properties = {
@@ -23,19 +23,18 @@ import org.springframework.test.context.TestPropertySource;
})
class DataSeederIT {
@Autowired private FamilyRepository familyRepository;
@Autowired private ChildRepository childRepository;
@Autowired private ParentUserRepository parentUserRepository;
@Autowired private RewardRepository rewardRepository;
@Autowired private ActivityRepository activityRepository;
@Autowired private WeeklyTemplateEntryRepository templateRepository;
@Test
void siembraLosDatosDelPrototipo() {
void siembraLaFamiliaDemoConSusDatos() {
assertThat(familyRepository.findByEmailIgnoreCase("demo@recordalexia.local")).isPresent();
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);
assertThat(templateRepository.count()).isEqualTo(15); // 3 niños x 5 días
}
}

View File

@@ -6,11 +6,13 @@ 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.Family;
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.FamilyRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import java.time.DayOfWeek;
import java.time.LocalDate;
@@ -30,15 +32,21 @@ class DayGenerationServiceTest {
@Autowired private ActivityRepository activityRepository;
@Autowired private WeeklyTemplateEntryRepository templateRepository;
@Autowired private AfternoonRoutineRepository routineRepository;
@Autowired private FamilyRepository familyRepository;
private Family family;
@Test
void generaTareasDeMananaYTardeYesIdempotente() {
family = familyRepository.save(new Family("a@x.com", "h", "A", "p"));
// 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"));
Activity actividad = new Activity("Mates", "Mates", "📘", "#5B8DEF");
actividad.setFamily(family);
Activity mates = activityRepository.save(actividad);
templateRepository.save(new WeeklyTemplateEntry(nino, dia, mates, 0));
routineRepository.save(new AfternoonRoutine(nino, dia, "Deberes", "Deures", "📝", "#F2A65A", 0));
@@ -57,6 +65,7 @@ class DayGenerationServiceTest {
private Child nuevoNino() {
Child c = new Child();
c.setFamily(family);
c.setName("Nora");
c.setMascot("🦊");
c.setAccentColor("#F2A65A");

View File

@@ -4,18 +4,26 @@ 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.Family;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.exception.InsufficientCoinsException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.repository.RewardRedemptionRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RedeemResult;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.annotation.Transactional;
/** Verifica el canje de premios: descuento correcto y error por saldo insuficiente. */
/** Canje de premios: descuento correcto y error por saldo insuficiente. */
@SpringBootTest
@Transactional
class StoreServiceTest {
@@ -24,11 +32,26 @@ class StoreServiceTest {
@Autowired private ChildRepository childRepository;
@Autowired private RewardRepository rewardRepository;
@Autowired private RewardRedemptionRepository redemptionRepository;
@Autowired private FamilyRepository familyRepository;
private Family family;
@BeforeEach
void auth() {
family = familyRepository.save(new Family("a@x.com", "h", "A", "p"));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
family.getId(), null, List.of(new SimpleGrantedAuthority("ROLE_FAMILY"))));
}
@AfterEach
void clear() {
SecurityContextHolder.clearContext();
}
@Test
void canjeaPremioYdescuentaMonedas() {
Child nino = childRepository.save(ninoConSaldo(50));
Reward premio = rewardRepository.save(new Reward("Tablet", "Tauleta", "🎮", "#5B8DEF", 20));
Reward premio = rewardRepository.save(reward("Tablet", 20));
RedeemResult resultado = storeService.redeem(nino.getId(), premio.getId());
@@ -41,18 +64,18 @@ class StoreServiceTest {
@Test
void rechazaCanjeSiNoHaySaldoSuficiente() {
Child nino = childRepository.save(ninoConSaldo(30));
Reward caro = rewardRepository.save(new Reward("Dino", "Dino", "🦖", "#EC8FA4", 80));
Reward caro = rewardRepository.save(reward("Dino", 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.setFamily(family);
c.setName("Mía");
c.setMascot("🦉");
c.setAccentColor("#A78BD0");
@@ -60,4 +83,10 @@ class StoreServiceTest {
c.setCoins(saldo);
return c;
}
private Reward reward(String es, int cost) {
Reward r = new Reward(es, es, "🎮", "#5B8DEF", cost);
r.setFamily(family);
return r;
}
}

View File

@@ -4,22 +4,27 @@ 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.Family;
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 es.asepeyo.recordalexia.repository.FamilyRepository;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
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.
* Monedas y bonos de bloque/día (con reversión). Monta una familia y la deja como
* sesión actual (lo que lee FamilyContext) para que el aislamiento no estorbe.
*/
@SpringBootTest
@Transactional
@@ -30,6 +35,21 @@ class TaskServiceTest {
@Autowired private TaskService taskService;
@Autowired private ChildRepository childRepository;
@Autowired private DailyTaskRepository dailyTaskRepository;
@Autowired private FamilyRepository familyRepository;
private Family family;
@BeforeEach
void auth() {
family = familyRepository.save(new Family("a@x.com", "h", "A", "p"));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
family.getId(), null, List.of(new SimpleGrantedAuthority("ROLE_FAMILY"))));
}
@AfterEach
void clear() {
SecurityContextHolder.clearContext();
}
@Test
void monedasYbonosDeBloqueYdiaConReversion() {
@@ -38,33 +58,28 @@ class TaskServiceTest {
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);
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(20);
}
private Child nuevoNino() {
Child c = new Child();
c.setFamily(family);
c.setName("Leo");
c.setMascot("🐢");
c.setAccentColor("#5BC0BE");

View File

@@ -0,0 +1,105 @@
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.Child;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.security.SessionAuthFilter;
import es.asepeyo.recordalexia.web.dto.AuthDtos.LoginRequest;
import es.asepeyo.recordalexia.web.dto.AuthDtos.RegisterRequest;
import es.asepeyo.recordalexia.web.dto.AuthDtos.UnlockRequest;
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.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
/** Registro/login, gate del panel por PIN y aislamiento entre familias (multi-tenant). */
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class AuthIT {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private FamilyRepository familyRepository;
@Autowired private ChildRepository childRepository;
private String register(String email) throws Exception {
String body = mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new RegisterRequest(email, "secret123", "Familia", "1234"))))
.andExpect(status().isCreated())
.andReturn().getResponse().getContentAsString();
return objectMapper.readTree(body).path("session").asText();
}
@Test
void registroAbreSesionYmeDevuelveLaFamilia() throws Exception {
String session = register("uno@x.com");
mockMvc.perform(get("/api/auth/me").header(SessionAuthFilter.HEADER, session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("uno@x.com"));
}
@Test
void loginIncorrectoDevuelve401() throws Exception {
register("dos@x.com");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new LoginRequest("dos@x.com", "malo"))))
.andExpect(status().isUnauthorized());
}
@Test
void panelExigePinDesbloqueado() throws Exception {
String session = register("tres@x.com");
// Con sesión pero sin desbloquear el panel: prohibido.
mockMvc.perform(get("/api/parents/children").header(SessionAuthFilter.HEADER, session))
.andExpect(status().isForbidden());
// Desbloquear con el PIN.
mockMvc.perform(post("/api/parents/unlock")
.header(SessionAuthFilter.HEADER, session)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new UnlockRequest("1234"))))
.andExpect(status().isNoContent());
// Ahora sí.
mockMvc.perform(get("/api/parents/children").header(SessionAuthFilter.HEADER, session))
.andExpect(status().isOk());
}
@Test
void sinSesionTodoEstaProhibido() throws Exception {
mockMvc.perform(get("/api/children")).andExpect(status().isForbidden());
}
@Test
void unaFamiliaNoVeLosDatosDeOtra() throws Exception {
// Familia A con un niño.
String sessionA = register("a@x.com");
Long familyAId = objectMapper.readTree(mockMvc.perform(get("/api/auth/me")
.header(SessionAuthFilter.HEADER, sessionA))
.andReturn().getResponse().getContentAsString()).path("familyId").asLong();
Child child = new Child();
child.setFamily(familyRepository.getReferenceById(familyAId));
child.setName("Nora");
child.setMascot("🦊");
child.setAccentColor("#F2A65A");
child.setAge(7);
Long childAId = childRepository.save(child).getId();
// Familia B no puede ver el día del niño de A (404, no 403, para no filtrar existencia).
String sessionB = register("b@x.com");
mockMvc.perform(get("/api/children/{id}/today", childAId).header(SessionAuthFilter.HEADER, sessionB))
.andExpect(status().isNotFound());
}
}

View File

@@ -1,68 +0,0 @@
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

@@ -11,27 +11,33 @@ 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.Family;
import es.asepeyo.recordalexia.domain.FamilySession;
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.FamilyRepository;
import es.asepeyo.recordalexia.repository.FamilySessionRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import es.asepeyo.recordalexia.security.SessionAuthFilter;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
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;
import org.springframework.transaction.annotation.Transactional;
/**
* 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.
*/
/** Flujo del kiosko vía API (con sesión de familia): ver día, marcar, canjear. */
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class TodayFlowIT {
@Autowired private MockMvc mockMvc;
@@ -41,19 +47,28 @@ class TodayFlowIT {
@Autowired private WeeklyTemplateEntryRepository templateRepository;
@Autowired private AfternoonRoutineRepository routineRepository;
@Autowired private RewardRepository rewardRepository;
@Autowired private FamilyRepository familyRepository;
@Autowired private FamilySessionRepository sessionRepository;
@Test
void flujoVerDiaMarcarTareaYcanjear() throws Exception {
// Datos para el día de hoy (engancha plantilla y rutina al día de la semana actual).
Family family = familyRepository.save(new Family("flow@x.com", "h", "Flow", "p"));
String session = "flow-session";
sessionRepository.save(new FamilySession(session, family, Instant.now().plus(1, ChronoUnit.DAYS)));
DayOfWeek hoy = LocalDate.now().getDayOfWeek();
Child nino = childRepository.save(nuevoNino());
Activity mates = activityRepository.save(new Activity("Mates", "Mates", "📘", "#5B8DEF"));
Child nino = childRepository.save(nuevoNino(family));
Activity mates = new Activity("Mates", "Mates", "📘", "#5B8DEF");
mates.setFamily(family);
mates = activityRepository.save(mates);
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));
Reward premio = new Reward("Tablet", "Tauleta", "🎮", "#5B8DEF", 5);
premio.setFamily(family);
premio = rewardRepository.save(premio);
// 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()))
String body = mockMvc.perform(get("/api/children/{id}/today", nino.getId())
.header(SessionAuthFilter.HEADER, session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.morning.length()").value(1))
.andExpect(jsonPath("$.afternoon.length()").value(1))
@@ -63,25 +78,24 @@ class TodayFlowIT {
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))
mockMvc.perform(post("/api/tasks/{taskId}/toggle", taskId).header(SessionAuthFilter.HEADER, session))
.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())))
.param("childId", String.valueOf(nino.getId()))
.header(SessionAuthFilter.HEADER, session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.newBalance").value(60));
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(60);
}
private Child nuevoNino() {
private Child nuevoNino(Family family) {
Child c = new Child();
c.setFamily(family);
c.setName("Nora");
c.setMascot("🦊");
c.setAccentColor("#F2A65A");