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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
121
backend/src/main/java/es/asepeyo/recordalexia/domain/Family.java
Normal file
121
backend/src/main/java/es/asepeyo/recordalexia/domain/Family.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
@@ -13,30 +14,36 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* Filtro que reconoce la sesión de padres a partir de la cabecera X-Parent-Session.
|
||||
* Si la sesión es válida, marca la petición como autenticada con rol PARENT; la
|
||||
* autorización por ruta la decide {@link SecurityConfig}.
|
||||
* Reconoce la sesión de familia a partir de la cabecera X-Auth-Session. Si es válida,
|
||||
* autentica la petición con el id de familia como principal y el rol FAMILY; añade
|
||||
* PARENT si el panel está desbloqueado (PIN introducido). La autorización por ruta la
|
||||
* decide {@link SecurityConfig}.
|
||||
*/
|
||||
@Component
|
||||
public class ParentAuthFilter extends OncePerRequestFilter {
|
||||
public class SessionAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
public static final String HEADER = "X-Parent-Session";
|
||||
public static final String HEADER = "X-Auth-Session";
|
||||
|
||||
private final ParentSessionStore sessionStore;
|
||||
private final SessionAuthService sessions;
|
||||
|
||||
public ParentAuthFilter(ParentSessionStore sessionStore) {
|
||||
this.sessionStore = sessionStore;
|
||||
public SessionAuthFilter(SessionAuthService sessions) {
|
||||
this.sessions = sessions;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String sessionId = request.getHeader(HEADER);
|
||||
if (sessionStore.isValid(sessionId)) {
|
||||
String handle = request.getHeader(HEADER);
|
||||
sessions.resolve(handle).ifPresent(info -> {
|
||||
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_FAMILY"));
|
||||
if (info.panelUnlocked()) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_PARENT"));
|
||||
}
|
||||
var authentication = new UsernamePasswordAuthenticationToken(
|
||||
"parent", null, List.of(new SimpleGrantedAuthority("ROLE_PARENT")));
|
||||
info.familyId(), null, authorities);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
});
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public final class ChildDtos {
|
||||
Boolean soundEnabled,
|
||||
Boolean ttsEnabled,
|
||||
String language,
|
||||
Boolean dyslexiaFont,
|
||||
String departureTime) {
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ public record TodayResponse(
|
||||
String viewMode,
|
||||
String language,
|
||||
boolean soundEnabled,
|
||||
boolean ttsEnabled) {
|
||||
boolean ttsEnabled,
|
||||
boolean dyslexiaFont) {
|
||||
}
|
||||
|
||||
public record TaskView(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
105
backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java
Normal file
105
backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
42
docs/adr/0004-multi-tenant-y-auth.md
Normal file
42
docs/adr/0004-multi-tenant-y-auth.md
Normal 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`.
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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])),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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: '' },
|
||||
];
|
||||
|
||||
20
frontend/src/app/core/auth.guard.ts
Normal file
20
frontend/src/app/core/auth.guard.ts
Normal 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']);
|
||||
};
|
||||
43
frontend/src/app/core/auth.interceptor.ts
Normal file
43
frontend/src/app/core/auth.interceptor.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
92
frontend/src/app/core/auth.service.ts
Normal file
92
frontend/src/app/core/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
};
|
||||
148
frontend/src/app/features/auth/account.component.ts
Normal file
148
frontend/src/app/features/auth/account.component.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
84
frontend/src/app/features/auth/auth.scss
Normal file
84
frontend/src/app/features/auth/auth.scss
Normal 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;
|
||||
}
|
||||
54
frontend/src/app/features/auth/login.component.ts
Normal file
54
frontend/src/app/features/auth/login.component.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
64
frontend/src/app/features/auth/register.component.ts
Normal file
64
frontend/src/app/features/auth/register.component.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(['/']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user