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)) {
var authentication = new UsernamePasswordAuthenticationToken(
"parent", null, List.of(new SimpleGrantedAuthority("ROLE_PARENT")));
SecurityContextHolder.getContext().setAuthentication(authentication);
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(
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");

View File

@@ -0,0 +1,42 @@
# ADR 0004 — Multi-tenant + registro/login propio (email/contraseña)
- **Estado:** aceptada
- **Fecha:** 2026-06-21
- **Supersede (parcialmente):** ADR 0002/0003 en lo relativo a "auth ligera sin cuentas".
## Contexto
El contrato original definía la app como **una sola familia**, con auth ligera
(PIN de padres) y datos globales. El usuario pide convertirla en **multi-familia
(multi-tenant)** con **registro/login propio** y persistencia de preferencias.
## Decisión
1. **Tenant = `Family`** (cuenta con email único + contraseña BCrypt + PIN + prefs).
Las entidades raíz (`child`, `activity`, `material_item`, `reward`) llevan
`family_id`. El resto cuelga del niño.
2. **Aislamiento**: toda consulta raíz se filtra por la familia de la sesión; las
operaciones por id verifican pertenencia y, si no, responden **404** (no 403,
para no filtrar existencia).
3. **Sesión de familia ligada al dispositivo**, persistida en BD (`family_session`)
para sobrevivir a reinicios (clave para el kiosko). Cabecera `X-Auth-Session`.
Toda la API exige sesión válida; el niño NO se loguea (el adulto deja la sesión
abierta en la tablet).
4. **Panel de padres**: además de la sesión, exige **desbloqueo con PIN**
(`POST /api/parents/unlock`), que concede el rol PARENT durante 30 min.
5. **Sin Keycloak**: auth propia encapsulada en el paquete `security`
(`AuthService`, `SessionAuthService`, `SessionAuthFilter`).
6. **Preferencia OpenDyslexic** pasa a campo por niño (`child.dyslexia_font`) + un
default de cuenta (`family.default_dyslexia_font`).
## Consecuencias
- Registro abierto (cualquiera crea una familia). Rate-limiting y verificación de
email quedan como mejora futura (homelab).
- La migración del esquema (`002-multitenant.yaml`) añade `family_id` NOT NULL; en
BD ya poblada habría que hacer backfill (en este proyecto se parte de BD limpia
con `docker compose down -v`).
- La sesión de 30 días en el dispositivo es un compromiso UX/seguridad razonable
para un kiosko doméstico; revocable borrando la fila de `family_session`.
- Sustituir la auth por un IdP externo (Keycloak) solo afectaría al paquete
`security`.

View File

@@ -1,7 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app.component';
import { FontPreferenceService } from './core/font-preference.service';
describe('AppComponent', () => {
beforeEach(async () => {
@@ -15,11 +14,4 @@ describe('AppComponent', () => {
const fixture = TestBed.createComponent(AppComponent);
expect(fixture.componentInstance).toBeTruthy();
});
it('debe aplicar OpenDyslexic por defecto al arrancar', () => {
TestBed.createComponent(AppComponent); // fuerza la inicialización del servicio
const fontPreference = TestBed.inject(FontPreferenceService);
expect(fontPreference.enabled()).toBe(true);
expect(document.documentElement.getAttribute('data-dyslexia-font')).toBe('on');
});
});

View File

@@ -1,15 +1,10 @@
import { Component, inject } from '@angular/core';
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { FontPreferenceService } from './core/font-preference.service';
/** Componente raíz: monta el router. La navegación arranca en Perfiles. */
/** Componente raíz: monta el router. La navegación arranca en Perfiles (o /login). */
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: '<router-outlet />',
})
export class AppComponent {
// Inyectar el servicio fuerza su inicialización: aplica la preferencia de
// tipografía (OpenDyslexic por defecto) sobre <html> al arrancar la app.
private readonly fontPreference = inject(FontPreferenceService);
}
export class AppComponent {}

View File

@@ -3,13 +3,13 @@ import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { parentSessionInterceptor } from './core/parent-session.interceptor';
import { authInterceptor } from './core/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
// Cliente HTTP (fetch) con el interceptor de sesión de padres.
provideHttpClient(withFetch(), withInterceptors([parentSessionInterceptor])),
// Cliente HTTP (fetch) con el interceptor de sesión de familia.
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
],
};

View File

@@ -4,17 +4,25 @@ import { HomeComponent } from './features/home/home.component';
import { StoreComponent } from './features/store/store.component';
import { KeypadComponent } from './features/parents/keypad.component';
import { ParentsComponent } from './features/parents/parents.component';
import { parentGuard } from './core/parent.guard';
import { LoginComponent } from './features/auth/login.component';
import { RegisterComponent } from './features/auth/register.component';
import { AccountComponent } from './features/auth/account.component';
import { authGuard, parentGuard } from './core/auth.guard';
export const routes: Routes = [
// Selección de perfil: la pantalla de entrada del kiosko.
{ path: '', component: ProfileSelectComponent },
// Día de hoy del niño (Tablero / Foco).
{ path: 'home/:childId', component: HomeComponent },
// Tienda de recompensas.
{ path: 'store/:childId', component: StoreComponent },
// PIN de padres y panel protegido por sesión.
{ path: 'pin', component: KeypadComponent },
{ path: 'parents', component: ParentsComponent, canActivate: [parentGuard] },
// Públicas.
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
// Requieren sesión de familia.
{ path: '', component: ProfileSelectComponent, canActivate: [authGuard] },
{ path: 'home/:childId', component: HomeComponent, canActivate: [authGuard] },
{ path: 'store/:childId', component: StoreComponent, canActivate: [authGuard] },
{ path: 'account', component: AccountComponent, canActivate: [authGuard] },
{ path: 'pin', component: KeypadComponent, canActivate: [authGuard] },
// Panel de padres: sesión + PIN desbloqueado.
{ path: 'parents', component: ParentsComponent, canActivate: [authGuard, parentGuard] },
{ path: '**', redirectTo: '' },
];

View File

@@ -0,0 +1,20 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
/** Exige sesión de familia; si no, al login. */
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated() ? true : router.createUrlTree(['/login']);
};
/** Exige sesión + panel desbloqueado (PIN); si no, al PIN (o al login si no hay sesión). */
export const parentGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (!auth.isAuthenticated()) {
return router.createUrlTree(['/login']);
}
return auth.panelUnlocked() ? true : router.createUrlTree(['/pin']);
};

View File

@@ -0,0 +1,43 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
/** Rutas públicas o gestionadas por su componente: no llevan auto-manejo de error. */
const PUBLIC = ['/api/auth/register', '/api/auth/login'];
const SELF_HANDLED = ['/api/parents/unlock']; // el error de PIN lo muestra el teclado
/**
* Añade la cabecera X-Auth-Session a las llamadas a la API (salvo registro/login).
* Gestión de errores:
* - 401/403 en el panel (/api/parents/**) con sesión válida → falta desbloquear → /pin.
* - 401/403 en el resto → sesión inválida/caducada → limpia y va a /login.
*/
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const isPublic = PUBLIC.some((p) => req.url.endsWith(p));
const selfHandled = SELF_HANDLED.some((p) => req.url.endsWith(p));
const request =
!isPublic && auth.sessionId
? req.clone({ setHeaders: { 'X-Auth-Session': auth.sessionId } })
: req;
return next(request).pipe(
catchError((err: HttpErrorResponse) => {
const denied = err.status === 401 || err.status === 403;
if (denied && !isPublic && !selfHandled) {
if (req.url.includes('/api/parents/') && auth.isAuthenticated()) {
auth.panelUnlocked.set(false);
router.navigate(['/pin']); // panel bloqueado/caducado, mantener la sesión
} else {
auth.clearLocal();
router.navigate(['/login']);
}
}
return throwError(() => err);
}),
);
};

View File

@@ -0,0 +1,92 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { MeResponse } from './models';
/**
* Sesión de familia (tenant). Guarda el identificador de sesión en localStorage
* para que el dispositivo (kiosko) siga autenticado tras recargar. Expone el estado
* de la familia y si el panel de padres está desbloqueado (tras el PIN).
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private static readonly KEY = 'recordalexia.session';
private readonly http = inject(HttpClient);
private readonly sessionSignal = signal<string | null>(this.read());
readonly family = signal<MeResponse | null>(null);
/** El desbloqueo del panel vive solo en memoria: al recargar se vuelve a pedir el PIN. */
readonly panelUnlocked = signal<boolean>(false);
readonly isAuthenticated = computed(() => this.sessionSignal() !== null);
get sessionId(): string | null {
return this.sessionSignal();
}
/** Alta de familia (auto-login). */
register(email: string, password: string, name: string, pin: string): Observable<{ session: string }> {
return this.http
.post<{ session: string }>('/api/auth/register', { email, password, name, pin })
.pipe(tap((res) => this.store(res.session)));
}
/** Acceso con email + contraseña. */
login(email: string, password: string): Observable<{ session: string }> {
return this.http
.post<{ session: string }>('/api/auth/login', { email, password })
.pipe(tap((res) => this.store(res.session)));
}
/** Carga los datos de la familia autenticada. */
loadMe(): Observable<MeResponse> {
return this.http.get<MeResponse>('/api/auth/me').pipe(tap((me) => this.family.set(me)));
}
/** Desbloquea el panel de padres validando el PIN. */
unlockPanel(pin: string): Observable<void> {
return this.http
.post<void>('/api/parents/unlock', { pin })
.pipe(tap(() => this.panelUnlocked.set(true)));
}
/** Cierra sesión en el dispositivo. */
logout(): Observable<void> {
const obs = this.http.post<void>('/api/auth/logout', {});
obs.subscribe({ next: () => this.clear(), error: () => this.clear() });
return obs;
}
/** Limpia el estado local (sin llamar al backend). */
clearLocal(): void {
this.clear();
}
private store(session: string): void {
this.sessionSignal.set(session);
try {
localStorage.setItem(AuthService.KEY, session);
} catch {
// localStorage no disponible: la sesión vivirá solo en memoria.
}
}
private clear(): void {
this.sessionSignal.set(null);
this.family.set(null);
this.panelUnlocked.set(false);
try {
localStorage.removeItem(AuthService.KEY);
} catch {
// ignorar
}
}
private read(): string | null {
try {
return localStorage.getItem(AuthService.KEY);
} catch {
return null;
}
}
}

View File

@@ -2,79 +2,28 @@ import { DOCUMENT } from '@angular/common';
import { Injectable, inject, signal } from '@angular/core';
/**
* Gestiona la preferencia de tipografía OpenDyslexic.
* Aplica la preferencia de tipografía OpenDyslexic al DOM, alternando el atributo
* `data-dyslexia-font` en <html> (interruptor que usa _theme.scss para cambiar
* entre OpenDyslexic y las tipografías de marca).
*
* Es la "costura" de accesibilidad: aplica (o quita) el atributo
* `data-dyslexia-font` en el elemento <html>, que es el interruptor que el
* fichero de tokens (_theme.scss) usa para alternar entre OpenDyslexic y las
* tipografías de marca del handoff (Fredoka/Nunito).
*
* Decisión de producto (Fase 1): OpenDyslexic activada POR DEFECTO y aplicada a
* TODO el texto. Es una preferencia por niño; de momento se persiste en
* localStorage. En la Fase 5 esta preferencia pasará a leerse/escribirse contra
* el backend (ajustes por niño), sustituyendo el almacenamiento local.
* La fuente de verdad está en el BACKEND: el ajuste por niño (`child.dyslexiaFont`,
* llega en /today) y el default de la cuenta (`family.defaultDyslexiaFont`). Este
* servicio solo refleja ese valor; quien decide lo aplica con apply().
*/
@Injectable({ providedIn: 'root' })
export class FontPreferenceService {
/** Clave de persistencia temporal hasta el cableado con el backend. */
private static readonly STORAGE_KEY = 'recordalexia.dyslexiaFont';
private readonly document = inject(DOCUMENT);
/** Estado reactivo: ¿está activada OpenDyslexic? Por defecto, sí. */
private readonly enabledSignal = signal<boolean>(this.readInitialState());
/** ¿Está activada OpenDyslexic ahora mismo? (para la UI que quiera mostrarlo). */
readonly enabled = signal<boolean>(this.readDom());
/** Señal de solo lectura para que la consuma la UI. */
readonly enabled = this.enabledSignal.asReadonly();
constructor() {
// Sincroniza el DOM con el estado inicial al arrancar la app.
this.applyToDom(this.enabledSignal());
/** Aplica el valor al DOM y actualiza la señal. */
apply(enabled: boolean): void {
this.enabled.set(enabled);
this.document.documentElement.setAttribute('data-dyslexia-font', enabled ? 'on' : 'off');
}
/** Activa o desactiva OpenDyslexic y propaga el cambio al DOM y a la persistencia. */
setEnabled(enabled: boolean): void {
this.enabledSignal.set(enabled);
this.applyToDom(enabled);
this.persist(enabled);
}
/** Alterna el estado actual. */
toggle(): void {
this.setEnabled(!this.enabledSignal());
}
/** Lee el estado inicial de localStorage; si no hay nada guardado, ACTIVA por defecto. */
private readInitialState(): boolean {
const stored = this.safeGetItem(FontPreferenceService.STORAGE_KEY);
return stored === null ? true : stored === 'true';
}
/** Refleja la preferencia en <html data-dyslexia-font="on|off">. */
private applyToDom(enabled: boolean): void {
this.document.documentElement.setAttribute(
'data-dyslexia-font',
enabled ? 'on' : 'off',
);
}
/** Guarda la preferencia, tolerando entornos sin localStorage. */
private persist(enabled: boolean): void {
try {
this.document.defaultView?.localStorage.setItem(
FontPreferenceService.STORAGE_KEY,
String(enabled),
);
} catch {
// localStorage no disponible (modo kiosko restringido): se ignora.
}
}
private safeGetItem(key: string): string | null {
try {
return this.document.defaultView?.localStorage.getItem(key) ?? null;
} catch {
return null;
}
private readDom(): boolean {
return this.document.documentElement.getAttribute('data-dyslexia-font') !== 'off';
}
}

View File

@@ -26,6 +26,7 @@ export interface ChildInfo {
language: Language;
soundEnabled: boolean;
ttsEnabled: boolean;
dyslexiaFont: boolean;
}
/** Tarea del día (mañana o tarde). Lleva texto ES y CA. */
@@ -94,6 +95,7 @@ export interface SettingsRequest {
soundEnabled?: boolean;
ttsEnabled?: boolean;
language?: Language;
dyslexiaFont?: boolean;
departureTime?: string;
}
@@ -185,6 +187,15 @@ export interface GamificationView {
coinsPerDay: number;
}
// ----- Auth / cuenta de familia -----
export interface MeResponse {
familyId: number;
email: string;
name: string;
uiLanguage: string;
defaultDyslexiaFont: boolean;
}
// ----- Panel de padres: peticiones -----
export interface ChildRequest {
name?: string;

View File

@@ -1,16 +0,0 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { ParentSessionService } from './parent-session.service';
/**
* Añade la cabecera X-Parent-Session a las peticiones del panel de padres
* (/api/parents/**), salvo al propio login. El resto de la API (kiosko) no la lleva.
*/
export const parentSessionInterceptor: HttpInterceptorFn = (req, next) => {
const session = inject(ParentSessionService);
const isParentApi = req.url.includes('/api/parents/') && !req.url.endsWith('/parents/login');
if (isParentApi && session.sessionId) {
return next(req.clone({ setHeaders: { 'X-Parent-Session': session.sessionId } }));
}
return next(req);
};

View File

@@ -1,57 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
/**
* Sesión del panel de padres. Guarda el identificador opaco devuelto por el login
* (cabecera X-Parent-Session) y lo mantiene en sessionStorage para sobrevivir a
* recargas mientras dura la pestaña. No es una credencial: es un ticket temporal.
*/
@Injectable({ providedIn: 'root' })
export class ParentSessionService {
private static readonly KEY = 'recordalexia.parentSession';
private readonly http = inject(HttpClient);
private readonly currentId = signal<string | null>(this.read());
readonly isAuthenticated = computed(() => this.currentId() !== null);
/** Identificador actual para la cabecera (o null si no hay sesión). */
get sessionId(): string | null {
return this.currentId();
}
/** Valida el PIN; si es correcto guarda la sesión. */
login(pin: string): Observable<{ session: string }> {
return this.http.post<{ session: string }>('/api/parents/login', { pin }).pipe(
tap((res) => {
this.currentId.set(res.session);
this.write(res.session);
}),
);
}
logout(): void {
this.currentId.set(null);
try {
sessionStorage.removeItem(ParentSessionService.KEY);
} catch {
// sessionStorage no disponible: nada que limpiar.
}
}
private read(): string | null {
try {
return sessionStorage.getItem(ParentSessionService.KEY);
} catch {
return null;
}
}
private write(value: string): void {
try {
sessionStorage.setItem(ParentSessionService.KEY, value);
} catch {
// Ignorar si no hay sessionStorage (modo kiosko restringido).
}
}
}

View File

@@ -1,10 +0,0 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ParentSessionService } from './parent-session.service';
/** Protege el panel de padres: sin sesión, redirige al teclado del PIN. */
export const parentGuard: CanActivateFn = () => {
const session = inject(ParentSessionService);
const router = inject(Router);
return session.isAuthenticated() ? true : router.createUrlTree(['/pin']);
};

View File

@@ -0,0 +1,148 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { AuthService } from '../../core/auth.service';
/** Pantalla de cuenta: datos, preferencias, cambio de contraseña/PIN y cerrar sesión. */
@Component({
selector: 'app-account',
imports: [FormsModule],
template: `
<main class="acc">
<header class="acc__top">
<button type="button" class="acc__back" (click)="back()"></button>
<h1 class="acc__title">⚙️ Mi cuenta</h1>
<button type="button" class="acc__logout" (click)="logout()">Cerrar sesión</button>
</header>
@if (auth.family(); as f) {
<div class="adm-card">
<p class="adm-label">Familia</p>
<p>{{ f.name }} · {{ f.email }}</p>
</div>
}
<div class="adm-card">
<p class="adm-label">Preferencias</p>
<div class="adm-row">
<label class="adm-label">Idioma del panel</label>
<select class="adm-input" [(ngModel)]="uiLanguage">
<option value="ES">Español</option>
<option value="CA">Català</option>
</select>
<label class="adm-chip">
<input type="checkbox" [(ngModel)]="defaultDyslexiaFont" /> OpenDyslexic por defecto
</label>
<button class="adm-btn" (click)="savePrefs()">Guardar</button>
</div>
@if (prefsSaved()) { <p class="acc__ok">Preferencias guardadas ✓</p> }
</div>
<div class="adm-card">
<p class="adm-label">Cambiar contraseña</p>
<div class="adm-row">
<input class="adm-input" type="password" placeholder="Actual" [(ngModel)]="curPass" />
<input class="adm-input" type="password" placeholder="Nueva (mín. 6)" [(ngModel)]="newPass" />
<button class="adm-btn" [disabled]="!curPass || newPass.length < 6" (click)="savePassword()">Cambiar</button>
</div>
@if (passMsg()) { <p class="acc__ok">{{ passMsg() }}</p> }
</div>
<div class="adm-card">
<p class="adm-label">Cambiar PIN del panel</p>
<div class="adm-row">
<input class="adm-input" type="password" placeholder="PIN actual" maxlength="4" [(ngModel)]="curPin" />
<input class="adm-input" placeholder="PIN nuevo (4 díg.)" maxlength="4" [(ngModel)]="newPin" />
<button class="adm-btn" [disabled]="!curPin || newPin.length !== 4" (click)="savePin()">Cambiar</button>
</div>
@if (pinMsg()) { <p class="acc__ok">{{ pinMsg() }}</p> }
</div>
</main>
`,
styles: [
`
.acc { max-width: 720px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
.acc__top { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-5); }
.acc__title { flex: 1; margin: 0; font-size: 1.6rem; }
.acc__back {
all: unset; cursor: pointer; width: var(--touch-nav); height: var(--touch-nav); border-radius: 50%;
background: var(--surface); box-shadow: var(--shadow-btn); display: flex; align-items: center;
justify-content: center; font-size: 28px; color: var(--text-2);
}
.acc__logout {
all: unset; cursor: pointer; font-family: var(--font-display); font-weight: 700;
color: var(--accent-pink); background: color-mix(in srgb, var(--accent-pink) 14%, #fff);
padding: 10px 16px; border-radius: var(--radius-pill);
}
.acc__ok { margin: 10px 0 0; color: var(--accent-green); font-weight: 700; }
`,
],
})
export class AccountComponent implements OnInit {
protected readonly auth = inject(AuthService);
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
protected uiLanguage = 'ES';
protected defaultDyslexiaFont = true;
protected curPass = '';
protected newPass = '';
protected curPin = '';
protected newPin = '';
protected readonly prefsSaved = signal(false);
protected readonly passMsg = signal<string | null>(null);
protected readonly pinMsg = signal<string | null>(null);
ngOnInit(): void {
this.auth.loadMe().subscribe((me) => {
this.uiLanguage = me.uiLanguage;
this.defaultDyslexiaFont = me.defaultDyslexiaFont;
});
}
savePrefs(): void {
this.http
.put<void>('/api/account/prefs', {
uiLanguage: this.uiLanguage,
defaultDyslexiaFont: this.defaultDyslexiaFont,
})
.subscribe(() => {
this.prefsSaved.set(true);
setTimeout(() => this.prefsSaved.set(false), 2000);
});
}
savePassword(): void {
this.http
.put<void>('/api/account/password', { currentPassword: this.curPass, newPassword: this.newPass })
.subscribe({
next: () => {
this.passMsg.set('Contraseña actualizada ✓');
this.curPass = this.newPass = '';
},
error: () => this.passMsg.set('La contraseña actual no es correcta'),
});
}
savePin(): void {
this.http
.put<void>('/api/account/pin', { currentPin: this.curPin, newPin: this.newPin })
.subscribe({
next: () => {
this.pinMsg.set('PIN actualizado ✓');
this.curPin = this.newPin = '';
},
error: () => this.pinMsg.set('El PIN actual no es correcto'),
});
}
back(): void {
this.router.navigate(['/']);
}
logout(): void {
this.auth.logout().subscribe();
this.router.navigate(['/login']);
}
}

View File

@@ -0,0 +1,84 @@
:host {
display: block;
}
.auth {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
}
.auth__card {
width: 100%;
max-width: 380px;
background: var(--surface);
border: 1px solid var(--border-1);
border-radius: var(--radius-card);
padding: var(--space-6);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
gap: var(--space-3);
text-align: center;
animation: slideUp 0.4s ease both;
}
.auth__title {
margin: 0;
font-size: 2rem;
}
.auth__sub {
margin: 0 0 var(--space-2);
color: var(--text-2);
}
.auth__input {
font-family: var(--font-body);
font-size: 1rem;
padding: 12px 14px;
border: 2px solid var(--border-2);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-strong);
width: 100%;
box-sizing: border-box;
}
.auth__err {
margin: 0;
color: var(--accent-pink);
font-weight: 700;
}
.auth__btn {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.05rem;
border: 0;
border-radius: 18px;
padding: 14px;
min-height: var(--touch-nav);
background: var(--accent-blue);
color: #fff;
cursor: pointer;
margin-top: var(--space-2);
}
.auth__btn:disabled {
opacity: 0.45;
cursor: default;
}
.auth__alt {
margin: var(--space-2) 0 0;
color: var(--text-2);
}
.auth__alt a {
color: var(--accent-blue);
font-weight: 700;
text-decoration: none;
}

View File

@@ -0,0 +1,54 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../core/auth.service';
/** Acceso de familia con email + contraseña. */
@Component({
selector: 'app-login',
imports: [FormsModule, RouterLink],
template: `
<main class="auth">
<section class="auth__card">
<h1 class="auth__title">recordaLexia 🦊</h1>
<p class="auth__sub">Entra con tu cuenta de familia</p>
<input class="auth__input" type="email" placeholder="Email" [(ngModel)]="email" autocomplete="username" />
<input class="auth__input" type="password" placeholder="Contraseña" [(ngModel)]="password"
autocomplete="current-password" (keyup.enter)="submit()" />
@if (error()) { <p class="auth__err">Email o contraseña incorrectos</p> }
<button class="auth__btn" [disabled]="loading() || !email || !password" (click)="submit()">
{{ loading() ? 'Entrando…' : 'Entrar' }}
</button>
<p class="auth__alt">¿No tienes cuenta? <a routerLink="/register">Crear una</a></p>
</section>
</main>
`,
styleUrl: './auth.scss',
})
export class LoginComponent {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
protected email = '';
protected password = '';
protected readonly loading = signal(false);
protected readonly error = signal(false);
submit(): void {
if (!this.email || !this.password) {
return;
}
this.loading.set(true);
this.error.set(false);
this.auth.login(this.email.trim(), this.password).subscribe({
next: () => this.auth.loadMe().subscribe(() => this.router.navigate(['/'])),
error: () => {
this.error.set(true);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,64 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../core/auth.service';
/** Alta de una familia nueva (email + contraseña + PIN del panel). */
@Component({
selector: 'app-register',
imports: [FormsModule, RouterLink],
template: `
<main class="auth">
<section class="auth__card">
<h1 class="auth__title">Crear familia 🦊</h1>
<p class="auth__sub">Una cuenta para toda la familia</p>
<input class="auth__input" placeholder="Nombre de la familia" [(ngModel)]="name" />
<input class="auth__input" type="email" placeholder="Email" [(ngModel)]="email" autocomplete="username" />
<input class="auth__input" type="password" placeholder="Contraseña (mín. 6)" [(ngModel)]="password"
autocomplete="new-password" />
<input class="auth__input" inputmode="numeric" maxlength="4" placeholder="PIN de padres (4 dígitos)"
[(ngModel)]="pin" />
@if (error()) { <p class="auth__err">{{ error() }}</p> }
<button class="auth__btn" [disabled]="loading() || !valid()" (click)="submit()">
{{ loading() ? 'Creando…' : 'Crear cuenta' }}
</button>
<p class="auth__alt">¿Ya tienes cuenta? <a routerLink="/login">Entrar</a></p>
</section>
</main>
`,
styleUrl: './auth.scss',
})
export class RegisterComponent {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
protected name = '';
protected email = '';
protected password = '';
protected pin = '';
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
valid(): boolean {
return !!this.email && this.password.length >= 6 && /^\d{4}$/.test(this.pin) && !!this.name;
}
submit(): void {
if (!this.valid()) {
return;
}
this.loading.set(true);
this.error.set(null);
this.auth.register(this.email.trim(), this.password, this.name.trim(), this.pin).subscribe({
next: () => this.auth.loadMe().subscribe(() => this.router.navigate(['/'])),
error: (e: HttpErrorResponse) => {
this.error.set(e.status === 409 ? 'Ese email ya está registrado' : 'No se pudo crear la cuenta');
this.loading.set(false);
},
});
}
}

View File

@@ -1,6 +1,7 @@
import { Component, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { FontPreferenceService } from '../../core/font-preference.service';
import { I18nService } from '../../core/i18n.service';
import { SoundService } from '../../core/sound.service';
import { TodayResponse, ViewMode } from '../../core/models';
@@ -47,6 +48,7 @@ export class HomeComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly sound = inject(SoundService);
private readonly fontPreference = inject(FontPreferenceService);
protected readonly i18n = inject(I18nService);
@ViewChild('wallet') private wallet?: WalletComponent;
@@ -88,6 +90,8 @@ export class HomeComponent implements OnInit {
this.today.set(data);
this.mode.set(data.child.viewMode);
this.i18n.setLang(data.child.language);
// Aplica la preferencia de tipografía de ESTE niño.
this.fontPreference.apply(data.child.dyslexiaFont);
this.loading.set(false);
},
error: () => this.loading.set(false),

View File

@@ -1,7 +1,7 @@
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { I18nService } from '../../core/i18n.service';
import { ParentSessionService } from '../../core/parent-session.service';
import { AuthService } from '../../core/auth.service';
/** Teclado numérico 3×4 para el PIN de padres (4 dígitos, shake al fallar). */
@Component({
@@ -87,7 +87,7 @@ import { ParentSessionService } from '../../core/parent-session.service';
],
})
export class KeypadComponent {
private readonly session = inject(ParentSessionService);
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
protected readonly i18n = inject(I18nService);
@@ -122,7 +122,7 @@ export class KeypadComponent {
}
private submit(code: string): void {
this.session.login(code).subscribe({
this.auth.unlockPanel(code).subscribe({
next: () => this.router.navigate(['/parents']),
error: () => {
this.error.set(true);

View File

@@ -2,7 +2,7 @@ import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ParentApiService } from '../../core/parent-api.service';
import { ParentSessionService } from '../../core/parent-session.service';
import { AuthService } from '../../core/auth.service';
import { I18nService } from '../../core/i18n.service';
import { ChildSummary } from '../../core/models';
import { ScheduleTabComponent } from './schedule-tab.component';
@@ -90,7 +90,7 @@ type Tab = 'schedule' | 'materials' | 'events' | 'routines' | 'rewards';
})
export class ParentsComponent {
private readonly parentApi = inject(ParentApiService);
private readonly session = inject(ParentSessionService);
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
protected readonly i18n = inject(I18nService);
@@ -107,8 +107,9 @@ export class ParentsComponent {
});
}
/** "Salir" del panel: lo bloquea (vuelve a pedir PIN) y regresa al kiosko. */
logout(): void {
this.session.logout();
this.auth.panelUnlocked.set(false);
this.router.navigate(['/']);
}
}

View File

@@ -29,6 +29,9 @@ import { RewardAdminView } from '../../core/models';
<label class="adm-chip">
<input type="checkbox" [(ngModel)]="ttsEnabled" (change)="saveSettings()" /> 🗣️ {{ i18n.t('readAloud') }}
</label>
<label class="adm-chip">
<input type="checkbox" [(ngModel)]="dyslexiaFont" (change)="saveSettings()" /> 🔤 OpenDyslexic
</label>
</div>
</div>
@@ -78,6 +81,7 @@ export class RewardsTabComponent {
this.api.getToday(value).subscribe((t) => {
this.soundEnabled = t.child.soundEnabled;
this.ttsEnabled = t.child.ttsEnabled;
this.dyslexiaFont = t.child.dyslexiaFont;
});
}
private _childId!: number;
@@ -94,6 +98,7 @@ export class RewardsTabComponent {
protected perDay = 20;
protected soundEnabled = true;
protected ttsEnabled = true;
protected dyslexiaFont = true;
protected icon = '';
protected labelEs = '';
@@ -121,7 +126,11 @@ export class RewardsTabComponent {
saveSettings(): void {
this.parentApi
.updateSettings(this._childId, { soundEnabled: this.soundEnabled, ttsEnabled: this.ttsEnabled })
.updateSettings(this._childId, {
soundEnabled: this.soundEnabled,
ttsEnabled: this.ttsEnabled,
dyslexiaFont: this.dyslexiaFont,
})
.subscribe();
}

View File

@@ -1,4 +1,8 @@
<main class="profiles">
<button type="button" class="profiles__account" (click)="openAccount()">
👨‍👩‍👧 {{ auth.family()?.name || 'Mi cuenta' }}
</button>
<h1 class="profiles__title">{{ i18n.t('whoEntersToday') }}</h1>
@if (loading()) {

View File

@@ -3,6 +3,7 @@
}
.profiles {
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
@@ -11,6 +12,21 @@
gap: var(--space-6);
padding: var(--space-6) var(--space-4);
&__account {
all: unset;
cursor: pointer;
position: absolute;
top: 20px;
right: 24px;
padding: 8px 16px;
border-radius: var(--radius-pill);
background: var(--surface);
box-shadow: var(--shadow-card);
font-family: var(--font-display);
font-weight: 700;
color: var(--text-1);
}
&__title {
margin: 0;
font-size: 2.4rem;

View File

@@ -1,6 +1,8 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service';
import { FontPreferenceService } from '../../core/font-preference.service';
import { I18nService } from '../../core/i18n.service';
import { KioskService } from '../../core/kiosk.service';
import { ChildSummary } from '../../core/models';
@@ -23,6 +25,8 @@ export class ProfileSelectComponent implements OnInit {
private readonly api = inject(ApiService);
private readonly router = inject(Router);
private readonly kiosk = inject(KioskService);
protected readonly auth = inject(AuthService);
private readonly fontPreference = inject(FontPreferenceService);
protected readonly i18n = inject(I18nService);
protected readonly children = signal<ChildSummary[]>([]);
@@ -30,6 +34,18 @@ export class ProfileSelectComponent implements OnInit {
protected readonly error = signal(false);
ngOnInit(): void {
// En la pantalla de perfiles aún no hay niño elegido: aplica el default de la cuenta.
const applyDefault = () => {
const f = this.auth.family();
if (f) {
this.fontPreference.apply(f.defaultDyslexiaFont);
}
};
if (!this.auth.family()) {
this.auth.loadMe().subscribe(applyDefault);
} else {
applyDefault();
}
this.api.getChildren().subscribe({
next: (list) => {
this.children.set(list);
@@ -52,4 +68,8 @@ export class ProfileSelectComponent implements OnInit {
openParents(): void {
this.router.navigate(['/parents']);
}
openAccount(): void {
this.router.navigate(['/account']);
}
}