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.AfternoonRoutine;
|
||||||
import es.asepeyo.recordalexia.domain.Child;
|
import es.asepeyo.recordalexia.domain.Child;
|
||||||
import es.asepeyo.recordalexia.domain.EventType;
|
import es.asepeyo.recordalexia.domain.EventType;
|
||||||
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
import es.asepeyo.recordalexia.domain.MaterialItem;
|
import es.asepeyo.recordalexia.domain.MaterialItem;
|
||||||
import es.asepeyo.recordalexia.domain.ParentUser;
|
|
||||||
import es.asepeyo.recordalexia.domain.Reward;
|
import es.asepeyo.recordalexia.domain.Reward;
|
||||||
import es.asepeyo.recordalexia.domain.SpecialEvent;
|
import es.asepeyo.recordalexia.domain.SpecialEvent;
|
||||||
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
|
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
|
||||||
import es.asepeyo.recordalexia.repository.ActivityRepository;
|
import es.asepeyo.recordalexia.repository.ActivityRepository;
|
||||||
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
|
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
|
||||||
import es.asepeyo.recordalexia.repository.ParentUserRepository;
|
|
||||||
import es.asepeyo.recordalexia.repository.RewardRepository;
|
import es.asepeyo.recordalexia.repository.RewardRepository;
|
||||||
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
|
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
|
||||||
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
||||||
@@ -30,22 +30,23 @@ import org.springframework.stereotype.Component;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Siembra los datos de ejemplo del prototipo (niños, material, actividades, horario,
|
* Siembra una FAMILIA DEMO con los datos de ejemplo del prototipo (niños, material,
|
||||||
* rutinas, eventos, premios y el PIN de padres por defecto). Solo actúa si la base
|
* actividades, horario, rutinas, eventos y premios). Solo actúa si no hay familias,
|
||||||
* de datos está vacía, así que es seguro arrancar varias veces.
|
* 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
|
* Credenciales demo: demo@recordalexia.local / demo1234 · PIN 1234.
|
||||||
* sus propios datos deterministas).
|
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@ConditionalOnProperty(name = "recordalexia.seed.enabled", havingValue = "true", matchIfMissing = true)
|
@ConditionalOnProperty(name = "recordalexia.seed.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
public class DataSeeder implements ApplicationRunner {
|
public class DataSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
/** PIN de padres por defecto; configurable (el panel permite cambiarlo). */
|
private static final String DEMO_EMAIL = "demo@recordalexia.local";
|
||||||
private static final String DEFAULT_PIN = "1234";
|
private static final String DEMO_PASSWORD = "demo1234";
|
||||||
|
private static final String DEMO_PIN = "1234";
|
||||||
|
|
||||||
|
private final FamilyRepository familyRepository;
|
||||||
private final ChildRepository childRepository;
|
private final ChildRepository childRepository;
|
||||||
private final ParentUserRepository parentUserRepository;
|
|
||||||
private final MaterialItemRepository materialRepository;
|
private final MaterialItemRepository materialRepository;
|
||||||
private final ActivityRepository activityRepository;
|
private final ActivityRepository activityRepository;
|
||||||
private final WeeklyTemplateEntryRepository templateRepository;
|
private final WeeklyTemplateEntryRepository templateRepository;
|
||||||
@@ -55,14 +56,14 @@ public class DataSeeder implements ApplicationRunner {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
public DataSeeder(ChildRepository childRepository, ParentUserRepository parentUserRepository,
|
public DataSeeder(FamilyRepository familyRepository, ChildRepository childRepository,
|
||||||
MaterialItemRepository materialRepository, ActivityRepository activityRepository,
|
MaterialItemRepository materialRepository, ActivityRepository activityRepository,
|
||||||
WeeklyTemplateEntryRepository templateRepository,
|
WeeklyTemplateEntryRepository templateRepository,
|
||||||
AfternoonRoutineRepository routineRepository,
|
AfternoonRoutineRepository routineRepository,
|
||||||
SpecialEventRepository eventRepository, RewardRepository rewardRepository,
|
SpecialEventRepository eventRepository, RewardRepository rewardRepository,
|
||||||
PasswordEncoder passwordEncoder, Clock clock) {
|
PasswordEncoder passwordEncoder, Clock clock) {
|
||||||
|
this.familyRepository = familyRepository;
|
||||||
this.childRepository = childRepository;
|
this.childRepository = childRepository;
|
||||||
this.parentUserRepository = parentUserRepository;
|
|
||||||
this.materialRepository = materialRepository;
|
this.materialRepository = materialRepository;
|
||||||
this.activityRepository = activityRepository;
|
this.activityRepository = activityRepository;
|
||||||
this.templateRepository = templateRepository;
|
this.templateRepository = templateRepository;
|
||||||
@@ -76,26 +77,26 @@ public class DataSeeder implements ApplicationRunner {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void run(ApplicationArguments args) {
|
public void run(ApplicationArguments args) {
|
||||||
if (childRepository.count() > 0) {
|
if (familyRepository.count() > 0) {
|
||||||
return; // Ya sembrado: no duplicar.
|
return; // Ya sembrado: no duplicar.
|
||||||
}
|
}
|
||||||
|
|
||||||
seedParent();
|
Family family = familyRepository.save(new Family(DEMO_EMAIL,
|
||||||
var materials = seedMaterials();
|
passwordEncoder.encode(DEMO_PASSWORD), "Familia demo", passwordEncoder.encode(DEMO_PIN)));
|
||||||
var activities = seedActivities(materials);
|
|
||||||
seedRewards();
|
|
||||||
|
|
||||||
// Tres niños del prototipo, cada uno con su horario y rutinas.
|
var materials = seedMaterials(family);
|
||||||
Child nora = childRepository.save(child("Nora", "🦊", "#F2A65A", 7, 42, LocalTime.of(8, 30)));
|
var activities = seedActivities(family, materials);
|
||||||
Child leo = childRepository.save(child("Leo", "🐢", "#5BC0BE", 9, 28, LocalTime.of(8, 30)));
|
seedRewards(family);
|
||||||
Child mia = childRepository.save(child("Mía", "🦉", "#A78BD0", 6, 55, LocalTime.of(8, 15)));
|
|
||||||
|
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)) {
|
for (Child c : List.of(nora, leo, mia)) {
|
||||||
seedWeeklyMornings(c, activities);
|
seedWeeklyMornings(c, activities);
|
||||||
seedAfternoonRoutines(c);
|
seedAfternoonRoutines(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Un par de eventos para Nora alrededor de hoy, para poder probar /today.
|
|
||||||
LocalDate today = LocalDate.now(clock);
|
LocalDate today = LocalDate.now(clock);
|
||||||
eventRepository.save(new SpecialEvent(nora, today, EventType.EXAM,
|
eventRepository.save(new SpecialEvent(nora, today, EventType.EXAM,
|
||||||
"Examen de Lengua", "Examen de Llengua", "📋", "#EC8FA4"));
|
"Examen de Lengua", "Examen de Llengua", "📋", "#EC8FA4"));
|
||||||
@@ -103,60 +104,66 @@ public class DataSeeder implements ApplicationRunner {
|
|||||||
"Ficha de mates", "Fitxa de mates", "📎", "#5B8DEF"));
|
"Ficha de mates", "Fitxa de mates", "📎", "#5B8DEF"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void seedParent() {
|
private MaterialsCatalog seedMaterials(Family family) {
|
||||||
parentUserRepository.save(new ParentUser(passwordEncoder.encode(DEFAULT_PIN)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private MaterialsCatalog seedMaterials() {
|
|
||||||
return new MaterialsCatalog(
|
return new MaterialsCatalog(
|
||||||
material("Estuche", "Estoig", "✏️", "#F4C95D", "general"),
|
material(family, "Estuche", "Estoig", "✏️", "#F4C95D", "general"),
|
||||||
material("Libro de mates", "Llibre de mates", "📘", "#5B8DEF", "matematicas"),
|
material(family, "Libro de mates", "Llibre de mates", "📘", "#5B8DEF", "matematicas"),
|
||||||
material("Regla", "Regle", "📏", "#5B8DEF", "matematicas"),
|
material(family, "Regla", "Regle", "📏", "#5B8DEF", "matematicas"),
|
||||||
material("Flauta", "Flauta", "🎵", "#A78BD0", "musica"),
|
material(family, "Flauta", "Flauta", "🎵", "#A78BD0", "musica"),
|
||||||
material("Libreta", "Llibreta", "📓", "#A78BD0", "musica"),
|
material(family, "Libreta", "Llibreta", "📓", "#A78BD0", "musica"),
|
||||||
material("Ropa de gimnasia", "Roba d'EF", "👕", "#7FBF6B", "gimnasia"),
|
material(family, "Ropa de gimnasia", "Roba d'EF", "👕", "#7FBF6B", "gimnasia"),
|
||||||
material("Zapatillas", "Sabatilles", "👟", "#7FBF6B", "gimnasia"),
|
material(family, "Zapatillas", "Sabatilles", "👟", "#7FBF6B", "gimnasia"),
|
||||||
material("Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia"),
|
material(family, "Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia"),
|
||||||
material("Agua", "Aigua", "💧", "#5BC0BE", "gimnasia"),
|
material(family, "Agua", "Aigua", "💧", "#5BC0BE", "gimnasia"),
|
||||||
material("Lectura", "Lectura", "📖", "#F2A65A", "lengua"),
|
material(family, "Lectura", "Lectura", "📖", "#F2A65A", "lengua"),
|
||||||
material("Cuaderno", "Quadern", "📒", "#F2A65A", "lengua"),
|
material(family, "Cuaderno", "Quadern", "📒", "#F2A65A", "lengua"),
|
||||||
material("Almuerzo", "Esmorzar", "🍎", "#EC8FA4", "general"));
|
material(family, "Almuerzo", "Esmorzar", "🍎", "#EC8FA4", "general"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private MaterialItem material(String es, String ca, String icon, String color, String category) {
|
private MaterialItem material(Family family, String es, String ca, String icon, String color, String category) {
|
||||||
return materialRepository.save(new MaterialItem(es, ca, icon, color, category));
|
MaterialItem m = new MaterialItem(es, ca, icon, color, category);
|
||||||
|
m.setFamily(family);
|
||||||
|
return materialRepository.save(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ActivitiesCatalog seedActivities(MaterialsCatalog m) {
|
private ActivitiesCatalog seedActivities(Family family, MaterialsCatalog m) {
|
||||||
Activity gimnasia = activity("Gimnasia", "Gimnàstica", "🤸", "#7FBF6B",
|
Activity gimnasia = activity(family, "Gimnasia", "Gimnàstica", "🤸", "#7FBF6B",
|
||||||
m.ropaGimnasia, m.zapatillas, m.toalla, m.agua);
|
m.ropaGimnasia, m.zapatillas, m.toalla, m.agua);
|
||||||
Activity musica = activity("Música", "Música", "🎵", "#A78BD0", m.flauta, m.libreta);
|
Activity musica = activity(family, "Música", "Música", "🎵", "#A78BD0", m.flauta, m.libreta);
|
||||||
Activity mates = activity("Matemáticas", "Matemàtiques", "📘", "#5B8DEF",
|
Activity mates = activity(family, "Matemáticas", "Matemàtiques", "📘", "#5B8DEF",
|
||||||
m.libroMates, m.regla, m.estuche);
|
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);
|
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 activity = new Activity(es, ca, icon, color);
|
||||||
|
activity.setFamily(family);
|
||||||
for (MaterialItem mat : mats) {
|
for (MaterialItem mat : mats) {
|
||||||
activity.addMaterial(mat);
|
activity.addMaterial(mat);
|
||||||
}
|
}
|
||||||
return activityRepository.save(activity);
|
return activityRepository.save(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void seedRewards() {
|
private void seedRewards(Family family) {
|
||||||
rewardRepository.saveAll(List.of(
|
rewardRepository.saveAll(List.of(
|
||||||
new Reward("30 min de tablet", "30 min de tauleta", "🎮", "#5B8DEF", 20),
|
reward(family, "30 min de tablet", "30 min de tauleta", "🎮", "#5B8DEF", 20),
|
||||||
new Reward("Elijo la cena", "Trio el sopar", "🍕", "#F2A65A", 30),
|
reward(family, "Elijo la cena", "Trio el sopar", "🍕", "#F2A65A", 30),
|
||||||
new Reward("Tarde en el parque", "Tarda al parc", "🛝", "#7FBF6B", 40),
|
reward(family, "Tarde en el parque", "Tarda al parc", "🛝", "#7FBF6B", 40),
|
||||||
new Reward("Peli en familia", "Pel·lícula en família", "🍿", "#A78BD0", 50),
|
reward(family, "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),
|
reward(family, "30 min más despierto", "30 min més despert", "🌙", "#5BC0BE", 60),
|
||||||
new Reward("Sorpresa dino", "Sorpresa dino", "🦖", "#EC8FA4", 80)));
|
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();
|
Child c = new Child();
|
||||||
|
c.setFamily(family);
|
||||||
c.setName(name);
|
c.setName(name);
|
||||||
c.setMascot(mascot);
|
c.setMascot(mascot);
|
||||||
c.setAccentColor(color);
|
c.setAccentColor(color);
|
||||||
@@ -166,7 +173,6 @@ public class DataSeeder implements ApplicationRunner {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Plantilla de mañana de lunes a viernes (cada día, una actividad del cole). */
|
|
||||||
private void seedWeeklyMornings(Child child, ActivitiesCatalog a) {
|
private void seedWeeklyMornings(Child child, ActivitiesCatalog a) {
|
||||||
addWeekly(child, DayOfWeek.MONDAY, a.matematicas);
|
addWeekly(child, DayOfWeek.MONDAY, a.matematicas);
|
||||||
addWeekly(child, DayOfWeek.TUESDAY, a.gimnasia);
|
addWeekly(child, DayOfWeek.TUESDAY, a.gimnasia);
|
||||||
@@ -179,7 +185,6 @@ public class DataSeeder implements ApplicationRunner {
|
|||||||
templateRepository.save(new WeeklyTemplateEntry(child, day, activity, 0));
|
templateRepository.save(new WeeklyTemplateEntry(child, day, activity, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rutinas de tarde, iguales de lunes a viernes. */
|
|
||||||
private void seedAfternoonRoutines(Child child) {
|
private void seedAfternoonRoutines(Child child) {
|
||||||
for (DayOfWeek day : List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
|
for (DayOfWeek day : List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
|
||||||
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)) {
|
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(
|
private record MaterialsCatalog(
|
||||||
MaterialItem estuche, MaterialItem libroMates, MaterialItem regla, MaterialItem flauta,
|
MaterialItem estuche, MaterialItem libroMates, MaterialItem regla, MaterialItem flauta,
|
||||||
MaterialItem libreta, MaterialItem ropaGimnasia, MaterialItem zapatillas, MaterialItem toalla,
|
MaterialItem libreta, MaterialItem ropaGimnasia, MaterialItem zapatillas, MaterialItem toalla,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.JoinTable;
|
import jakarta.persistence.JoinTable;
|
||||||
import jakarta.persistence.ManyToMany;
|
import jakarta.persistence.ManyToMany;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -25,6 +26,10 @@ public class Activity {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "family_id")
|
||||||
|
private Family family;
|
||||||
|
|
||||||
@Column(name = "label_es")
|
@Column(name = "label_es")
|
||||||
private String labelEs;
|
private String labelEs;
|
||||||
|
|
||||||
@@ -60,6 +65,14 @@ public class Activity {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Family getFamily() {
|
||||||
|
return family;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFamily(Family family) {
|
||||||
|
this.family = family;
|
||||||
|
}
|
||||||
|
|
||||||
public String getLabelEs() {
|
public String getLabelEs() {
|
||||||
return labelEs;
|
return labelEs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import jakarta.persistence.Column;
|
|||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.EnumType;
|
import jakarta.persistence.EnumType;
|
||||||
import jakarta.persistence.Enumerated;
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
|
|
||||||
@@ -22,6 +25,11 @@ public class Child {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
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 name;
|
||||||
private String mascot;
|
private String mascot;
|
||||||
|
|
||||||
@@ -50,6 +58,10 @@ public class Child {
|
|||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
private Language language = Language.ES;
|
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) ---
|
// --- Parámetros de gamificación (configurables por niño) ---
|
||||||
@Column(name = "coins_per_task")
|
@Column(name = "coins_per_task")
|
||||||
private int coinsPerTask = 5;
|
private int coinsPerTask = 5;
|
||||||
@@ -104,6 +116,22 @@ public class Child {
|
|||||||
return id;
|
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() {
|
public String getName() {
|
||||||
return name;
|
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.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/** Material concreto del cole (estuche, flauta...). Texto bilingüe + emoji + color. */
|
/** Material concreto del cole (estuche, flauta...). Texto bilingüe + emoji + color. */
|
||||||
@@ -16,6 +19,10 @@ public class MaterialItem {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "family_id")
|
||||||
|
private Family family;
|
||||||
|
|
||||||
@Column(name = "label_es")
|
@Column(name = "label_es")
|
||||||
private String labelEs;
|
private String labelEs;
|
||||||
|
|
||||||
@@ -41,6 +48,14 @@ public class MaterialItem {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Family getFamily() {
|
||||||
|
return family;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFamily(Family family) {
|
||||||
|
this.family = family;
|
||||||
|
}
|
||||||
|
|
||||||
public String getLabelEs() {
|
public String getLabelEs() {
|
||||||
return labelEs;
|
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.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
/** Premio canjeable en la tienda. Compartido por todos los niños. */
|
/** Premio canjeable en la tienda. Compartido por todos los niños. */
|
||||||
@@ -16,6 +19,10 @@ public class Reward {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "family_id")
|
||||||
|
private Family family;
|
||||||
|
|
||||||
@Column(name = "label_es")
|
@Column(name = "label_es")
|
||||||
private String labelEs;
|
private String labelEs;
|
||||||
|
|
||||||
@@ -42,6 +49,14 @@ public class Reward {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Family getFamily() {
|
||||||
|
return family;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFamily(Family family) {
|
||||||
|
this.family = family;
|
||||||
|
}
|
||||||
|
|
||||||
public String getLabelEs() {
|
public String getLabelEs() {
|
||||||
return labelEs;
|
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()));
|
"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)
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
public ResponseEntity<Map<String, Object>> handleBadRequest(IllegalArgumentException ex) {
|
public ResponseEntity<Map<String, Object>> handleBadRequest(IllegalArgumentException ex) {
|
||||||
return ResponseEntity.badRequest()
|
return ResponseEntity.badRequest()
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
package es.asepeyo.recordalexia.repository;
|
package es.asepeyo.recordalexia.repository;
|
||||||
|
|
||||||
import es.asepeyo.recordalexia.domain.Activity;
|
import es.asepeyo.recordalexia.domain.Activity;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface ActivityRepository extends JpaRepository<Activity, Long> {
|
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;
|
package es.asepeyo.recordalexia.repository;
|
||||||
|
|
||||||
import es.asepeyo.recordalexia.domain.Child;
|
import es.asepeyo.recordalexia.domain.Child;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface ChildRepository extends JpaRepository<Child, Long> {
|
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;
|
package es.asepeyo.recordalexia.repository;
|
||||||
|
|
||||||
import es.asepeyo.recordalexia.domain.MaterialItem;
|
import es.asepeyo.recordalexia.domain.MaterialItem;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface MaterialItemRepository extends JpaRepository<MaterialItem, Long> {
|
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 es.asepeyo.recordalexia.domain.Reward;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface RewardRepository extends JpaRepository<Reward, Long> {
|
public interface RewardRepository extends JpaRepository<Reward, Long> {
|
||||||
|
|
||||||
/** Premios activos para mostrar en la tienda. */
|
/** Premios activos de una familia, para la tienda. */
|
||||||
List<Reward> findByActiveTrueOrderByCostAsc();
|
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;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seguridad ligera y doméstica:
|
* Seguridad multi-tenant:
|
||||||
* - Kiosko (niño): el resto de la API es de acceso libre (lectura, marcar, canjear).
|
* - Públicas: registro y login (abren sesión) y el health.
|
||||||
* - Panel de padres: /api/parents/** exige sesión válida (cabecera X-Parent-Session),
|
* - Toda la API requiere sesión de familia válida (rol FAMILY), incluido el kiosko:
|
||||||
* salvo el login que abre la sesión.
|
* 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
|
@Configuration
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
@@ -31,21 +33,23 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http, ParentAuthFilter parentAuthFilter)
|
public SecurityFilterChain filterChain(HttpSecurity http, SessionAuthFilter sessionAuthFilter)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
// El login de padres es público: es lo que abre la sesión.
|
// Públicas: abrir cuenta / sesión.
|
||||||
.requestMatchers(HttpMethod.POST, "/api/parents/login").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll()
|
||||||
.requestMatchers("/actuator/health").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")
|
.requestMatchers("/api/parents/**").hasRole("PARENT")
|
||||||
// Todo lo demás (kiosko del niño) es de acceso libre.
|
// Todo lo demás (incluido el kiosko del niño): requiere sesión de familia.
|
||||||
.anyRequest().permitAll())
|
.anyRequest().hasRole("FAMILY"))
|
||||||
.addFilterBefore(parentAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(sessionAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import jakarta.servlet.ServletException;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
@@ -13,30 +14,36 @@ import org.springframework.stereotype.Component;
|
|||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filtro que reconoce la sesión de padres a partir de la cabecera X-Parent-Session.
|
* Reconoce la sesión de familia a partir de la cabecera X-Auth-Session. Si es válida,
|
||||||
* Si la sesión es válida, marca la petición como autenticada con rol PARENT; la
|
* autentica la petición con el id de familia como principal y el rol FAMILY; añade
|
||||||
* autorización por ruta la decide {@link SecurityConfig}.
|
* PARENT si el panel está desbloqueado (PIN introducido). La autorización por ruta la
|
||||||
|
* decide {@link SecurityConfig}.
|
||||||
*/
|
*/
|
||||||
@Component
|
@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) {
|
public SessionAuthFilter(SessionAuthService sessions) {
|
||||||
this.sessionStore = sessionStore;
|
this.sessions = sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
FilterChain filterChain) throws ServletException, IOException {
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
String sessionId = request.getHeader(HEADER);
|
String handle = request.getHeader(HEADER);
|
||||||
if (sessionStore.isValid(sessionId)) {
|
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(
|
var authentication = new UsernamePasswordAuthenticationToken(
|
||||||
"parent", null, List.of(new SimpleGrantedAuthority("ROLE_PARENT")));
|
info.familyId(), null, authorities);
|
||||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
}
|
});
|
||||||
filterChain.doFilter(request, response);
|
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.domain.ViewMode;
|
||||||
import es.asepeyo.recordalexia.exception.NotFoundException;
|
import es.asepeyo.recordalexia.exception.NotFoundException;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
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.ChildRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildSummary;
|
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildSummary;
|
||||||
import es.asepeyo.recordalexia.web.dto.ChildDtos.SettingsRequest;
|
import es.asepeyo.recordalexia.web.dto.ChildDtos.SettingsRequest;
|
||||||
@@ -13,14 +15,22 @@ import java.util.List;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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
|
@Service
|
||||||
public class ChildService {
|
public class ChildService {
|
||||||
|
|
||||||
private final ChildRepository childRepository;
|
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.childRepository = childRepository;
|
||||||
|
this.familyRepository = familyRepository;
|
||||||
|
this.familyContext = familyContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -30,13 +40,12 @@ public class ChildService {
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<ChildSummary> listChildren() {
|
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(),
|
.map(c -> new ChildSummary(c.getId(), c.getName(), c.getMascot(), c.getAccentColor(),
|
||||||
c.getAge(), c.getCoins(), c.getViewMode().name(), c.getLanguage().name()))
|
c.getAge(), c.getCoins(), c.getViewMode().name(), c.getLanguage().name()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Aplica solo los ajustes presentes (no nulos) en la petición. */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateSettings(Long childId, SettingsRequest req) {
|
public void updateSettings(Long childId, SettingsRequest req) {
|
||||||
Child child = requireChild(childId);
|
Child child = requireChild(childId);
|
||||||
@@ -52,12 +61,14 @@ public class ChildService {
|
|||||||
if (req.language() != null) {
|
if (req.language() != null) {
|
||||||
child.setLanguage(Language.valueOf(req.language()));
|
child.setLanguage(Language.valueOf(req.language()));
|
||||||
}
|
}
|
||||||
|
if (req.dyslexiaFont() != null) {
|
||||||
|
child.setDyslexiaFont(req.dyslexiaFont());
|
||||||
|
}
|
||||||
if (req.departureTime() != null) {
|
if (req.departureTime() != null) {
|
||||||
child.setDepartureTime(LocalTime.parse(req.departureTime()));
|
child.setDepartureTime(LocalTime.parse(req.departureTime()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Actualiza los parámetros de gamificación de un niño (panel de padres). */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateGamification(Long childId, Integer perTask, Integer perBlock, Integer perDay) {
|
public void updateGamification(Long childId, Integer perTask, Integer perBlock, Integer perDay) {
|
||||||
Child child = requireChild(childId);
|
Child child = requireChild(childId);
|
||||||
@@ -75,21 +86,20 @@ public class ChildService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public Child create(ChildRequest req) {
|
public Child create(ChildRequest req) {
|
||||||
Child child = new Child();
|
Child child = new Child();
|
||||||
|
// El niño nace en la familia de la sesión.
|
||||||
|
child.setFamily(familyRepository.getReferenceById(familyContext.currentFamilyId()));
|
||||||
applyRequest(child, req);
|
applyRequest(child, req);
|
||||||
return childRepository.save(child);
|
return childRepository.save(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void update(Long childId, ChildRequest req) {
|
public void update(Long childId, ChildRequest req) {
|
||||||
Child child = requireChild(childId);
|
applyRequest(requireChild(childId), req);
|
||||||
applyRequest(child, req);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long childId) {
|
public void delete(Long childId) {
|
||||||
if (!childRepository.existsById(childId)) {
|
requireChild(childId); // valida pertenencia antes de borrar
|
||||||
throw new NotFoundException("No existe el niño con id " + childId);
|
|
||||||
}
|
|
||||||
childRepository.deleteById(childId);
|
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) {
|
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));
|
.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.CoinTransactionRepository;
|
||||||
import es.asepeyo.recordalexia.repository.RewardRedemptionRepository;
|
import es.asepeyo.recordalexia.repository.RewardRedemptionRepository;
|
||||||
import es.asepeyo.recordalexia.repository.RewardRepository;
|
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.RedeemResult;
|
||||||
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardView;
|
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardView;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -26,17 +27,20 @@ public class StoreService {
|
|||||||
private final RewardRepository rewardRepository;
|
private final RewardRepository rewardRepository;
|
||||||
private final CoinTransactionRepository coinTransactionRepository;
|
private final CoinTransactionRepository coinTransactionRepository;
|
||||||
private final RewardRedemptionRepository rewardRedemptionRepository;
|
private final RewardRedemptionRepository rewardRedemptionRepository;
|
||||||
|
private final FamilyContext familyContext;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
public StoreService(ChildRepository childRepository,
|
public StoreService(ChildRepository childRepository,
|
||||||
RewardRepository rewardRepository,
|
RewardRepository rewardRepository,
|
||||||
CoinTransactionRepository coinTransactionRepository,
|
CoinTransactionRepository coinTransactionRepository,
|
||||||
RewardRedemptionRepository rewardRedemptionRepository,
|
RewardRedemptionRepository rewardRedemptionRepository,
|
||||||
|
FamilyContext familyContext,
|
||||||
Clock clock) {
|
Clock clock) {
|
||||||
this.childRepository = childRepository;
|
this.childRepository = childRepository;
|
||||||
this.rewardRepository = rewardRepository;
|
this.rewardRepository = rewardRepository;
|
||||||
this.coinTransactionRepository = coinTransactionRepository;
|
this.coinTransactionRepository = coinTransactionRepository;
|
||||||
this.rewardRedemptionRepository = rewardRedemptionRepository;
|
this.rewardRedemptionRepository = rewardRedemptionRepository;
|
||||||
|
this.familyContext = familyContext;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +48,8 @@ public class StoreService {
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<RewardView> listRewards(Long childId) {
|
public List<RewardView> listRewards(Long childId) {
|
||||||
Child child = requireChild(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(),
|
.map(r -> new RewardView(r.getId(), r.getLabelEs(), r.getLabelCa(), r.getIcon(),
|
||||||
r.getColor(), r.getCost(), child.canAfford(r.getCost()),
|
r.getColor(), r.getCost(), child.canAfford(r.getCost()),
|
||||||
Math.max(0, r.getCost() - child.getCoins())))
|
Math.max(0, r.getCost() - child.getCoins())))
|
||||||
@@ -58,7 +63,7 @@ public class StoreService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public RedeemResult redeem(Long childId, Long rewardId) {
|
public RedeemResult redeem(Long childId, Long rewardId) {
|
||||||
Child child = requireChild(childId);
|
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));
|
.orElseThrow(() -> new NotFoundException("No existe el premio con id " + rewardId));
|
||||||
|
|
||||||
if (!reward.isActive()) {
|
if (!reward.isActive()) {
|
||||||
@@ -80,7 +85,7 @@ public class StoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Child requireChild(Long childId) {
|
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));
|
.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.ChildRepository;
|
||||||
import es.asepeyo.recordalexia.repository.CoinTransactionRepository;
|
import es.asepeyo.recordalexia.repository.CoinTransactionRepository;
|
||||||
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
|
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
|
||||||
|
import es.asepeyo.recordalexia.security.FamilyContext;
|
||||||
import es.asepeyo.recordalexia.web.dto.ToggleResult;
|
import es.asepeyo.recordalexia.web.dto.ToggleResult;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -35,15 +36,18 @@ public class TaskService {
|
|||||||
private final DailyTaskRepository dailyTaskRepository;
|
private final DailyTaskRepository dailyTaskRepository;
|
||||||
private final ChildRepository childRepository;
|
private final ChildRepository childRepository;
|
||||||
private final CoinTransactionRepository coinTransactionRepository;
|
private final CoinTransactionRepository coinTransactionRepository;
|
||||||
|
private final FamilyContext familyContext;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
public TaskService(DailyTaskRepository dailyTaskRepository,
|
public TaskService(DailyTaskRepository dailyTaskRepository,
|
||||||
ChildRepository childRepository,
|
ChildRepository childRepository,
|
||||||
CoinTransactionRepository coinTransactionRepository,
|
CoinTransactionRepository coinTransactionRepository,
|
||||||
|
FamilyContext familyContext,
|
||||||
Clock clock) {
|
Clock clock) {
|
||||||
this.dailyTaskRepository = dailyTaskRepository;
|
this.dailyTaskRepository = dailyTaskRepository;
|
||||||
this.childRepository = childRepository;
|
this.childRepository = childRepository;
|
||||||
this.coinTransactionRepository = coinTransactionRepository;
|
this.coinTransactionRepository = coinTransactionRepository;
|
||||||
|
this.familyContext = familyContext;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +57,10 @@ public class TaskService {
|
|||||||
.orElseThrow(() -> new NotFoundException("No existe la tarea con id " + taskId));
|
.orElseThrow(() -> new NotFoundException("No existe la tarea con id " + taskId));
|
||||||
|
|
||||||
Child child = task.getChild();
|
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();
|
Long childId = child.getId();
|
||||||
LocalDate date = task.getTaskDate();
|
LocalDate date = task.getTaskDate();
|
||||||
Slot slot = task.getSlot();
|
Slot slot = task.getSlot();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import es.asepeyo.recordalexia.domain.SpecialEvent;
|
|||||||
import es.asepeyo.recordalexia.exception.NotFoundException;
|
import es.asepeyo.recordalexia.exception.NotFoundException;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
|
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;
|
||||||
import es.asepeyo.recordalexia.web.dto.TodayResponse.ChildInfo;
|
import es.asepeyo.recordalexia.web.dto.TodayResponse.ChildInfo;
|
||||||
import es.asepeyo.recordalexia.web.dto.TodayResponse.EventView;
|
import es.asepeyo.recordalexia.web.dto.TodayResponse.EventView;
|
||||||
@@ -31,21 +32,25 @@ public class TodayService {
|
|||||||
private final DayGenerationService dayGenerationService;
|
private final DayGenerationService dayGenerationService;
|
||||||
private final ChildRepository childRepository;
|
private final ChildRepository childRepository;
|
||||||
private final SpecialEventRepository specialEventRepository;
|
private final SpecialEventRepository specialEventRepository;
|
||||||
|
private final FamilyContext familyContext;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
public TodayService(DayGenerationService dayGenerationService,
|
public TodayService(DayGenerationService dayGenerationService,
|
||||||
ChildRepository childRepository,
|
ChildRepository childRepository,
|
||||||
SpecialEventRepository specialEventRepository,
|
SpecialEventRepository specialEventRepository,
|
||||||
|
FamilyContext familyContext,
|
||||||
Clock clock) {
|
Clock clock) {
|
||||||
this.dayGenerationService = dayGenerationService;
|
this.dayGenerationService = dayGenerationService;
|
||||||
this.childRepository = childRepository;
|
this.childRepository = childRepository;
|
||||||
this.specialEventRepository = specialEventRepository;
|
this.specialEventRepository = specialEventRepository;
|
||||||
|
this.familyContext = familyContext;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public TodayResponse getToday(Long childId) {
|
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));
|
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
|
||||||
|
|
||||||
LocalDate today = LocalDate.now(clock);
|
LocalDate today = LocalDate.now(clock);
|
||||||
@@ -86,7 +91,8 @@ public class TodayService {
|
|||||||
|
|
||||||
private ChildInfo toChildInfo(Child c) {
|
private ChildInfo toChildInfo(Child c) {
|
||||||
return new ChildInfo(c.getId(), c.getName(), c.getMascot(), c.getAccentColor(),
|
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). */
|
/** 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.exception.NotFoundException;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
import es.asepeyo.recordalexia.repository.CoinTransactionRepository;
|
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;
|
||||||
import es.asepeyo.recordalexia.web.dto.WalletResponse.CoinTxView;
|
import es.asepeyo.recordalexia.web.dto.WalletResponse.CoinTxView;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -16,16 +17,19 @@ public class WalletService {
|
|||||||
|
|
||||||
private final ChildRepository childRepository;
|
private final ChildRepository childRepository;
|
||||||
private final CoinTransactionRepository coinTransactionRepository;
|
private final CoinTransactionRepository coinTransactionRepository;
|
||||||
|
private final FamilyContext familyContext;
|
||||||
|
|
||||||
public WalletService(ChildRepository childRepository,
|
public WalletService(ChildRepository childRepository,
|
||||||
CoinTransactionRepository coinTransactionRepository) {
|
CoinTransactionRepository coinTransactionRepository,
|
||||||
|
FamilyContext familyContext) {
|
||||||
this.childRepository = childRepository;
|
this.childRepository = childRepository;
|
||||||
this.coinTransactionRepository = coinTransactionRepository;
|
this.coinTransactionRepository = coinTransactionRepository;
|
||||||
|
this.familyContext = familyContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public WalletResponse getWallet(Long childId) {
|
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));
|
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
|
||||||
|
|
||||||
List<CoinTxView> history = coinTransactionRepository
|
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.domain.MaterialItem;
|
||||||
import es.asepeyo.recordalexia.exception.NotFoundException;
|
import es.asepeyo.recordalexia.exception.NotFoundException;
|
||||||
import es.asepeyo.recordalexia.repository.ActivityRepository;
|
import es.asepeyo.recordalexia.repository.ActivityRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
|
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.ActivityRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.ParentDtos.MaterialRequest;
|
import es.asepeyo.recordalexia.web.dto.ParentDtos.MaterialRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.ParentViews.ActivityView;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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
|
@RestController
|
||||||
@RequestMapping("/api/parents/catalog")
|
@RequestMapping("/api/parents/catalog")
|
||||||
public class ParentCatalogController {
|
public class ParentCatalogController {
|
||||||
|
|
||||||
private final ActivityRepository activityRepository;
|
private final ActivityRepository activityRepository;
|
||||||
private final MaterialItemRepository materialRepository;
|
private final MaterialItemRepository materialRepository;
|
||||||
|
private final FamilyRepository familyRepository;
|
||||||
|
private final FamilyContext familyContext;
|
||||||
|
|
||||||
public ParentCatalogController(ActivityRepository activityRepository,
|
public ParentCatalogController(ActivityRepository activityRepository,
|
||||||
MaterialItemRepository materialRepository) {
|
MaterialItemRepository materialRepository,
|
||||||
|
FamilyRepository familyRepository, FamilyContext familyContext) {
|
||||||
this.activityRepository = activityRepository;
|
this.activityRepository = activityRepository;
|
||||||
this.materialRepository = materialRepository;
|
this.materialRepository = materialRepository;
|
||||||
|
this.familyRepository = familyRepository;
|
||||||
|
this.familyContext = familyContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long fid() {
|
||||||
|
return familyContext.currentFamilyId();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Materiales ---
|
// --- Materiales ---
|
||||||
|
|
||||||
@GetMapping("/materials")
|
@GetMapping("/materials")
|
||||||
public List<MaterialView> listMaterials() {
|
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(),
|
.map(m -> new MaterialView(m.getId(), m.getLabelEs(), m.getLabelCa(), m.getIcon(),
|
||||||
m.getColor(), m.getCategory()))
|
m.getColor(), m.getCategory()))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -46,17 +57,18 @@ public class ParentCatalogController {
|
|||||||
|
|
||||||
@PostMapping("/materials")
|
@PostMapping("/materials")
|
||||||
public ResponseEntity<MaterialView> createMaterial(@RequestBody MaterialRequest req) {
|
public ResponseEntity<MaterialView> createMaterial(@RequestBody MaterialRequest req) {
|
||||||
MaterialItem saved = materialRepository.save(
|
MaterialItem material = new MaterialItem(req.labelEs(), req.labelCa(), req.icon(),
|
||||||
new MaterialItem(req.labelEs(), req.labelCa(), req.icon(), req.color(), req.category()));
|
req.color(), req.category());
|
||||||
|
material.setFamily(familyRepository.getReferenceById(fid()));
|
||||||
|
MaterialItem saved = materialRepository.save(material);
|
||||||
return ResponseEntity.ok(new MaterialView(saved.getId(), saved.getLabelEs(),
|
return ResponseEntity.ok(new MaterialView(saved.getId(), saved.getLabelEs(),
|
||||||
saved.getLabelCa(), saved.getIcon(), saved.getColor(), saved.getCategory()));
|
saved.getLabelCa(), saved.getIcon(), saved.getColor(), saved.getCategory()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/materials/{id}")
|
@DeleteMapping("/materials/{id}")
|
||||||
public ResponseEntity<Void> deleteMaterial(@PathVariable Long id) {
|
public ResponseEntity<Void> deleteMaterial(@PathVariable Long id) {
|
||||||
if (!materialRepository.existsById(id)) {
|
materialRepository.findByIdAndFamilyId(id, fid())
|
||||||
throw new NotFoundException("No existe el material con id " + id);
|
.orElseThrow(() -> new NotFoundException("No existe el material con id " + id));
|
||||||
}
|
|
||||||
materialRepository.deleteById(id);
|
materialRepository.deleteById(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
@@ -66,13 +78,14 @@ public class ParentCatalogController {
|
|||||||
@GetMapping("/activities")
|
@GetMapping("/activities")
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<ActivityView> listActivities() {
|
public List<ActivityView> listActivities() {
|
||||||
return activityRepository.findAll().stream().map(this::toActivityView).toList();
|
return activityRepository.findByFamilyId(fid()).stream().map(this::toActivityView).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/activities")
|
@PostMapping("/activities")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<ActivityView> createActivity(@RequestBody ActivityRequest req) {
|
public ResponseEntity<ActivityView> createActivity(@RequestBody ActivityRequest req) {
|
||||||
Activity activity = new Activity(req.labelEs(), req.labelCa(), req.icon(), req.color());
|
Activity activity = new Activity(req.labelEs(), req.labelCa(), req.icon(), req.color());
|
||||||
|
activity.setFamily(familyRepository.getReferenceById(fid()));
|
||||||
attachMaterials(activity, req.materialIds());
|
attachMaterials(activity, req.materialIds());
|
||||||
Activity saved = activityRepository.save(activity);
|
Activity saved = activityRepository.save(activity);
|
||||||
return ResponseEntity.ok(toActivityView(saved));
|
return ResponseEntity.ok(toActivityView(saved));
|
||||||
@@ -80,9 +93,8 @@ public class ParentCatalogController {
|
|||||||
|
|
||||||
@DeleteMapping("/activities/{id}")
|
@DeleteMapping("/activities/{id}")
|
||||||
public ResponseEntity<Void> deleteActivity(@PathVariable Long id) {
|
public ResponseEntity<Void> deleteActivity(@PathVariable Long id) {
|
||||||
if (!activityRepository.existsById(id)) {
|
activityRepository.findByIdAndFamilyId(id, fid())
|
||||||
throw new NotFoundException("No existe la actividad con id " + id);
|
.orElseThrow(() -> new NotFoundException("No existe la actividad con id " + id));
|
||||||
}
|
|
||||||
activityRepository.deleteById(id);
|
activityRepository.deleteById(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
@@ -92,7 +104,7 @@ public class ParentCatalogController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (Long materialId : materialIds) {
|
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));
|
.orElseThrow(() -> new NotFoundException("No existe el material con id " + materialId));
|
||||||
activity.addMaterial(material);
|
activity.addMaterial(material);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import es.asepeyo.recordalexia.domain.SpecialEvent;
|
|||||||
import es.asepeyo.recordalexia.exception.NotFoundException;
|
import es.asepeyo.recordalexia.exception.NotFoundException;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
|
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.ParentDtos.SpecialEventRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.ParentViews.EventAdminView;
|
import es.asepeyo.recordalexia.web.dto.ParentViews.EventAdminView;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@@ -28,15 +30,18 @@ public class ParentEventController {
|
|||||||
|
|
||||||
private final SpecialEventRepository eventRepository;
|
private final SpecialEventRepository eventRepository;
|
||||||
private final ChildRepository childRepository;
|
private final ChildRepository childRepository;
|
||||||
|
private final FamilyContext familyContext;
|
||||||
|
|
||||||
public ParentEventController(SpecialEventRepository eventRepository,
|
public ParentEventController(SpecialEventRepository eventRepository,
|
||||||
ChildRepository childRepository) {
|
ChildRepository childRepository, FamilyContext familyContext) {
|
||||||
this.eventRepository = eventRepository;
|
this.eventRepository = eventRepository;
|
||||||
this.childRepository = childRepository;
|
this.childRepository = childRepository;
|
||||||
|
this.familyContext = familyContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<EventAdminView> list(@RequestParam Long childId) {
|
public List<EventAdminView> list(@RequestParam Long childId) {
|
||||||
|
requireChild(childId); // aislamiento: el niño debe ser de la familia
|
||||||
return eventRepository.findByChildIdOrderByEventDateAsc(childId).stream()
|
return eventRepository.findByChildIdOrderByEventDateAsc(childId).stream()
|
||||||
.map(this::toView).toList();
|
.map(this::toView).toList();
|
||||||
}
|
}
|
||||||
@@ -52,16 +57,17 @@ public class ParentEventController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
|
@Transactional
|
||||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||||
if (!eventRepository.existsById(id)) {
|
SpecialEvent event = eventRepository.findById(id)
|
||||||
throw new NotFoundException("No existe el evento con id " + id);
|
.filter(e -> e.getChild().getFamily().getId().equals(familyContext.currentFamilyId()))
|
||||||
}
|
.orElseThrow(() -> new NotFoundException("No existe el evento con id " + id));
|
||||||
eventRepository.deleteById(id);
|
eventRepository.delete(event);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Child requireChild(Long childId) {
|
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));
|
.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.domain.Reward;
|
||||||
import es.asepeyo.recordalexia.exception.NotFoundException;
|
import es.asepeyo.recordalexia.exception.NotFoundException;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
import es.asepeyo.recordalexia.repository.RewardRepository;
|
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.ParentViews.RewardAdminView;
|
||||||
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardRequest;
|
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardRequest;
|
||||||
import java.net.URI;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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
|
@RestController
|
||||||
@RequestMapping("/api/parents/rewards")
|
@RequestMapping("/api/parents/rewards")
|
||||||
public class ParentRewardController {
|
public class ParentRewardController {
|
||||||
|
|
||||||
private final RewardRepository rewardRepository;
|
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.rewardRepository = rewardRepository;
|
||||||
|
this.familyRepository = familyRepository;
|
||||||
|
this.familyContext = familyContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<RewardAdminView> list() {
|
public List<RewardAdminView> list() {
|
||||||
return rewardRepository.findAll().stream().map(this::toView).toList();
|
return rewardRepository.findByFamilyId(familyContext.currentFamilyId()).stream()
|
||||||
|
.map(this::toView).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<RewardAdminView> create(@RequestBody RewardRequest req) {
|
public ResponseEntity<RewardAdminView> create(@RequestBody RewardRequest req) {
|
||||||
Reward reward = new Reward(req.labelEs(), req.labelCa(), req.icon(), req.color(),
|
Reward reward = new Reward(req.labelEs(), req.labelCa(), req.icon(), req.color(),
|
||||||
req.cost() != null ? req.cost() : 0);
|
req.cost() != null ? req.cost() : 0);
|
||||||
|
reward.setFamily(familyRepository.getReferenceById(familyContext.currentFamilyId()));
|
||||||
if (req.active() != null) {
|
if (req.active() != null) {
|
||||||
reward.setActive(req.active());
|
reward.setActive(req.active());
|
||||||
}
|
}
|
||||||
@@ -47,8 +56,7 @@ public class ParentRewardController {
|
|||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<Void> update(@PathVariable Long id, @RequestBody RewardRequest req) {
|
public ResponseEntity<Void> update(@PathVariable Long id, @RequestBody RewardRequest req) {
|
||||||
Reward reward = rewardRepository.findById(id)
|
Reward reward = requireOwned(id);
|
||||||
.orElseThrow(() -> new NotFoundException("No existe el premio con id " + id));
|
|
||||||
if (req.labelEs() != null) {
|
if (req.labelEs() != null) {
|
||||||
reward.setLabelEs(req.labelEs());
|
reward.setLabelEs(req.labelEs());
|
||||||
}
|
}
|
||||||
@@ -73,13 +81,16 @@ public class ParentRewardController {
|
|||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||||
if (!rewardRepository.existsById(id)) {
|
requireOwned(id);
|
||||||
throw new NotFoundException("No existe el premio con id " + id);
|
|
||||||
}
|
|
||||||
rewardRepository.deleteById(id);
|
rewardRepository.deleteById(id);
|
||||||
return ResponseEntity.noContent().build();
|
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) {
|
private RewardAdminView toView(Reward r) {
|
||||||
return new RewardAdminView(r.getId(), r.getLabelEs(), r.getLabelCa(), r.getIcon(),
|
return new RewardAdminView(r.getId(), r.getLabelEs(), r.getLabelCa(), r.getIcon(),
|
||||||
r.getColor(), r.getCost(), r.isActive());
|
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.AfternoonRoutineRepository;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
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.AfternoonRoutineRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.ParentDtos.RoutineReorderRequest;
|
import es.asepeyo.recordalexia.web.dto.ParentDtos.RoutineReorderRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.ParentDtos.WeeklyEntryRequest;
|
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.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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
|
@RestController
|
||||||
@RequestMapping("/api/parents/schedule")
|
@RequestMapping("/api/parents/schedule")
|
||||||
public class ParentScheduleController {
|
public class ParentScheduleController {
|
||||||
@@ -37,15 +38,22 @@ public class ParentScheduleController {
|
|||||||
private final AfternoonRoutineRepository routineRepository;
|
private final AfternoonRoutineRepository routineRepository;
|
||||||
private final ChildRepository childRepository;
|
private final ChildRepository childRepository;
|
||||||
private final ActivityRepository activityRepository;
|
private final ActivityRepository activityRepository;
|
||||||
|
private final FamilyContext familyContext;
|
||||||
|
|
||||||
public ParentScheduleController(WeeklyTemplateEntryRepository templateRepository,
|
public ParentScheduleController(WeeklyTemplateEntryRepository templateRepository,
|
||||||
AfternoonRoutineRepository routineRepository,
|
AfternoonRoutineRepository routineRepository,
|
||||||
ChildRepository childRepository,
|
ChildRepository childRepository,
|
||||||
ActivityRepository activityRepository) {
|
ActivityRepository activityRepository,
|
||||||
|
FamilyContext familyContext) {
|
||||||
this.templateRepository = templateRepository;
|
this.templateRepository = templateRepository;
|
||||||
this.routineRepository = routineRepository;
|
this.routineRepository = routineRepository;
|
||||||
this.childRepository = childRepository;
|
this.childRepository = childRepository;
|
||||||
this.activityRepository = activityRepository;
|
this.activityRepository = activityRepository;
|
||||||
|
this.familyContext = familyContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long fid() {
|
||||||
|
return familyContext.currentFamilyId();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Plantilla de mañana ---
|
// --- Plantilla de mañana ---
|
||||||
@@ -53,6 +61,7 @@ public class ParentScheduleController {
|
|||||||
@GetMapping("/weekly")
|
@GetMapping("/weekly")
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<WeeklyEntryView> listWeekly(@RequestParam Long childId) {
|
public List<WeeklyEntryView> listWeekly(@RequestParam Long childId) {
|
||||||
|
requireChild(childId); // aislamiento: el niño debe ser de la familia
|
||||||
return templateRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream()
|
return templateRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream()
|
||||||
.map(this::toWeeklyView).toList();
|
.map(this::toWeeklyView).toList();
|
||||||
}
|
}
|
||||||
@@ -60,7 +69,7 @@ public class ParentScheduleController {
|
|||||||
@PostMapping("/weekly")
|
@PostMapping("/weekly")
|
||||||
public ResponseEntity<WeeklyEntryView> createWeekly(@RequestBody WeeklyEntryRequest req) {
|
public ResponseEntity<WeeklyEntryView> createWeekly(@RequestBody WeeklyEntryRequest req) {
|
||||||
Child child = requireChild(req.childId());
|
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()));
|
.orElseThrow(() -> new NotFoundException("No existe la actividad con id " + req.activityId()));
|
||||||
WeeklyTemplateEntry entry = new WeeklyTemplateEntry(child, DayOfWeek.valueOf(req.dayOfWeek()),
|
WeeklyTemplateEntry entry = new WeeklyTemplateEntry(child, DayOfWeek.valueOf(req.dayOfWeek()),
|
||||||
activity, req.orderIndex() != null ? req.orderIndex() : 0);
|
activity, req.orderIndex() != null ? req.orderIndex() : 0);
|
||||||
@@ -69,18 +78,21 @@ public class ParentScheduleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/weekly/{id}")
|
@DeleteMapping("/weekly/{id}")
|
||||||
|
@Transactional
|
||||||
public ResponseEntity<Void> deleteWeekly(@PathVariable Long id) {
|
public ResponseEntity<Void> deleteWeekly(@PathVariable Long id) {
|
||||||
if (!templateRepository.existsById(id)) {
|
WeeklyTemplateEntry entry = templateRepository.findById(id)
|
||||||
throw new NotFoundException("No existe la entrada de horario con id " + id);
|
.filter(e -> e.getChild().getFamily().getId().equals(fid()))
|
||||||
}
|
.orElseThrow(() -> new NotFoundException("No existe la entrada de horario con id " + id));
|
||||||
templateRepository.deleteById(id);
|
templateRepository.delete(entry);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rutinas de tarde ---
|
// --- Rutinas de tarde ---
|
||||||
|
|
||||||
@GetMapping("/routines")
|
@GetMapping("/routines")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
public List<RoutineView> listRoutines(@RequestParam Long childId) {
|
public List<RoutineView> listRoutines(@RequestParam Long childId) {
|
||||||
|
requireChild(childId);
|
||||||
return routineRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream()
|
return routineRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream()
|
||||||
.map(this::toRoutineView).toList();
|
.map(this::toRoutineView).toList();
|
||||||
}
|
}
|
||||||
@@ -95,29 +107,32 @@ public class ParentScheduleController {
|
|||||||
return ResponseEntity.ok(toRoutineView(routineRepository.save(routine)));
|
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")
|
@PutMapping("/routines/reorder")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<Void> reorderRoutines(@RequestBody RoutineReorderRequest req) {
|
public ResponseEntity<Void> reorderRoutines(@RequestBody RoutineReorderRequest req) {
|
||||||
List<Long> ids = req.orderedIds();
|
List<Long> ids = req.orderedIds();
|
||||||
for (int i = 0; i < ids.size(); i++) {
|
for (int i = 0; i < ids.size(); i++) {
|
||||||
int orderIndex = 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();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/routines/{id}")
|
@DeleteMapping("/routines/{id}")
|
||||||
|
@Transactional
|
||||||
public ResponseEntity<Void> deleteRoutine(@PathVariable Long id) {
|
public ResponseEntity<Void> deleteRoutine(@PathVariable Long id) {
|
||||||
if (!routineRepository.existsById(id)) {
|
AfternoonRoutine routine = routineRepository.findById(id)
|
||||||
throw new NotFoundException("No existe la rutina con id " + id);
|
.filter(r -> r.getChild().getFamily().getId().equals(fid()))
|
||||||
}
|
.orElseThrow(() -> new NotFoundException("No existe la rutina con id " + id));
|
||||||
routineRepository.deleteById(id);
|
routineRepository.delete(routine);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Child requireChild(Long childId) {
|
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));
|
.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 soundEnabled,
|
||||||
Boolean ttsEnabled,
|
Boolean ttsEnabled,
|
||||||
String language,
|
String language,
|
||||||
|
Boolean dyslexiaFont,
|
||||||
String departureTime) {
|
String departureTime) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ public record TodayResponse(
|
|||||||
String viewMode,
|
String viewMode,
|
||||||
String language,
|
String language,
|
||||||
boolean soundEnabled,
|
boolean soundEnabled,
|
||||||
boolean ttsEnabled) {
|
boolean ttsEnabled,
|
||||||
|
boolean dyslexiaFont) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record TaskView(
|
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.ActivityRepository;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
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.RewardRepository;
|
||||||
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -13,8 +13,8 @@ import org.springframework.boot.test.context.SpringBootTest;
|
|||||||
import org.springframework.test.context.TestPropertySource;
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica que el sembrado del prototipo se ejecuta correctamente. Usa una BD H2
|
* Verifica que el sembrado del prototipo (familia demo + sus datos) se ejecuta
|
||||||
* propia y el seeder activado (al contrario que el resto de tests).
|
* correctamente. Usa una BD H2 propia y el seeder activado.
|
||||||
*/
|
*/
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@TestPropertySource(properties = {
|
@TestPropertySource(properties = {
|
||||||
@@ -23,19 +23,18 @@ import org.springframework.test.context.TestPropertySource;
|
|||||||
})
|
})
|
||||||
class DataSeederIT {
|
class DataSeederIT {
|
||||||
|
|
||||||
|
@Autowired private FamilyRepository familyRepository;
|
||||||
@Autowired private ChildRepository childRepository;
|
@Autowired private ChildRepository childRepository;
|
||||||
@Autowired private ParentUserRepository parentUserRepository;
|
|
||||||
@Autowired private RewardRepository rewardRepository;
|
@Autowired private RewardRepository rewardRepository;
|
||||||
@Autowired private ActivityRepository activityRepository;
|
@Autowired private ActivityRepository activityRepository;
|
||||||
@Autowired private WeeklyTemplateEntryRepository templateRepository;
|
@Autowired private WeeklyTemplateEntryRepository templateRepository;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void siembraLosDatosDelPrototipo() {
|
void siembraLaFamiliaDemoConSusDatos() {
|
||||||
|
assertThat(familyRepository.findByEmailIgnoreCase("demo@recordalexia.local")).isPresent();
|
||||||
assertThat(childRepository.count()).isEqualTo(3); // Nora, Leo, Mía
|
assertThat(childRepository.count()).isEqualTo(3); // Nora, Leo, Mía
|
||||||
assertThat(parentUserRepository.findFirstByOrderByIdAsc()).isPresent();
|
|
||||||
assertThat(rewardRepository.count()).isEqualTo(6); // 6 premios
|
assertThat(rewardRepository.count()).isEqualTo(6); // 6 premios
|
||||||
assertThat(activityRepository.count()).isEqualTo(4); // 4 actividades
|
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); // 3 niños x 5 días
|
||||||
assertThat(templateRepository.count()).isEqualTo(15);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import es.asepeyo.recordalexia.domain.Activity;
|
|||||||
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
|
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
|
||||||
import es.asepeyo.recordalexia.domain.Child;
|
import es.asepeyo.recordalexia.domain.Child;
|
||||||
import es.asepeyo.recordalexia.domain.DailyTask;
|
import es.asepeyo.recordalexia.domain.DailyTask;
|
||||||
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
import es.asepeyo.recordalexia.domain.Slot;
|
import es.asepeyo.recordalexia.domain.Slot;
|
||||||
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
|
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
|
||||||
import es.asepeyo.recordalexia.repository.ActivityRepository;
|
import es.asepeyo.recordalexia.repository.ActivityRepository;
|
||||||
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
||||||
import java.time.DayOfWeek;
|
import java.time.DayOfWeek;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -30,15 +32,21 @@ class DayGenerationServiceTest {
|
|||||||
@Autowired private ActivityRepository activityRepository;
|
@Autowired private ActivityRepository activityRepository;
|
||||||
@Autowired private WeeklyTemplateEntryRepository templateRepository;
|
@Autowired private WeeklyTemplateEntryRepository templateRepository;
|
||||||
@Autowired private AfternoonRoutineRepository routineRepository;
|
@Autowired private AfternoonRoutineRepository routineRepository;
|
||||||
|
@Autowired private FamilyRepository familyRepository;
|
||||||
|
|
||||||
|
private Family family;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generaTareasDeMananaYTardeYesIdempotente() {
|
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.
|
// Fecha fija; usamos su día de la semana para enganchar plantilla y rutina.
|
||||||
LocalDate fecha = LocalDate.of(2026, 6, 22);
|
LocalDate fecha = LocalDate.of(2026, 6, 22);
|
||||||
DayOfWeek dia = fecha.getDayOfWeek();
|
DayOfWeek dia = fecha.getDayOfWeek();
|
||||||
|
|
||||||
Child nino = childRepository.save(nuevoNino());
|
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));
|
templateRepository.save(new WeeklyTemplateEntry(nino, dia, mates, 0));
|
||||||
routineRepository.save(new AfternoonRoutine(nino, dia, "Deberes", "Deures", "📝", "#F2A65A", 0));
|
routineRepository.save(new AfternoonRoutine(nino, dia, "Deberes", "Deures", "📝", "#F2A65A", 0));
|
||||||
|
|
||||||
@@ -57,6 +65,7 @@ class DayGenerationServiceTest {
|
|||||||
|
|
||||||
private Child nuevoNino() {
|
private Child nuevoNino() {
|
||||||
Child c = new Child();
|
Child c = new Child();
|
||||||
|
c.setFamily(family);
|
||||||
c.setName("Nora");
|
c.setName("Nora");
|
||||||
c.setMascot("🦊");
|
c.setMascot("🦊");
|
||||||
c.setAccentColor("#F2A65A");
|
c.setAccentColor("#F2A65A");
|
||||||
|
|||||||
@@ -4,18 +4,26 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
import es.asepeyo.recordalexia.domain.Child;
|
import es.asepeyo.recordalexia.domain.Child;
|
||||||
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
import es.asepeyo.recordalexia.domain.Reward;
|
import es.asepeyo.recordalexia.domain.Reward;
|
||||||
import es.asepeyo.recordalexia.exception.InsufficientCoinsException;
|
import es.asepeyo.recordalexia.exception.InsufficientCoinsException;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
import es.asepeyo.recordalexia.repository.RewardRedemptionRepository;
|
import es.asepeyo.recordalexia.repository.RewardRedemptionRepository;
|
||||||
import es.asepeyo.recordalexia.repository.RewardRepository;
|
import es.asepeyo.recordalexia.repository.RewardRepository;
|
||||||
import es.asepeyo.recordalexia.web.dto.StoreDtos.RedeemResult;
|
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.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
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;
|
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
|
@SpringBootTest
|
||||||
@Transactional
|
@Transactional
|
||||||
class StoreServiceTest {
|
class StoreServiceTest {
|
||||||
@@ -24,11 +32,26 @@ class StoreServiceTest {
|
|||||||
@Autowired private ChildRepository childRepository;
|
@Autowired private ChildRepository childRepository;
|
||||||
@Autowired private RewardRepository rewardRepository;
|
@Autowired private RewardRepository rewardRepository;
|
||||||
@Autowired private RewardRedemptionRepository redemptionRepository;
|
@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
|
@Test
|
||||||
void canjeaPremioYdescuentaMonedas() {
|
void canjeaPremioYdescuentaMonedas() {
|
||||||
Child nino = childRepository.save(ninoConSaldo(50));
|
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());
|
RedeemResult resultado = storeService.redeem(nino.getId(), premio.getId());
|
||||||
|
|
||||||
@@ -41,18 +64,18 @@ class StoreServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void rechazaCanjeSiNoHaySaldoSuficiente() {
|
void rechazaCanjeSiNoHaySaldoSuficiente() {
|
||||||
Child nino = childRepository.save(ninoConSaldo(30));
|
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()))
|
assertThatThrownBy(() -> storeService.redeem(nino.getId(), caro.getId()))
|
||||||
.isInstanceOf(InsufficientCoinsException.class)
|
.isInstanceOf(InsufficientCoinsException.class)
|
||||||
.satisfies(ex -> assertThat(((InsufficientCoinsException) ex).getMissing()).isEqualTo(50));
|
.satisfies(ex -> assertThat(((InsufficientCoinsException) ex).getMissing()).isEqualTo(50));
|
||||||
|
|
||||||
// No se ha tocado el saldo.
|
|
||||||
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(30);
|
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(30);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Child ninoConSaldo(int saldo) {
|
private Child ninoConSaldo(int saldo) {
|
||||||
Child c = new Child();
|
Child c = new Child();
|
||||||
|
c.setFamily(family);
|
||||||
c.setName("Mía");
|
c.setName("Mía");
|
||||||
c.setMascot("🦉");
|
c.setMascot("🦉");
|
||||||
c.setAccentColor("#A78BD0");
|
c.setAccentColor("#A78BD0");
|
||||||
@@ -60,4 +83,10 @@ class StoreServiceTest {
|
|||||||
c.setCoins(saldo);
|
c.setCoins(saldo);
|
||||||
return c;
|
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.Child;
|
||||||
import es.asepeyo.recordalexia.domain.DailyTask;
|
import es.asepeyo.recordalexia.domain.DailyTask;
|
||||||
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
import es.asepeyo.recordalexia.domain.Slot;
|
import es.asepeyo.recordalexia.domain.Slot;
|
||||||
import es.asepeyo.recordalexia.domain.TaskOrigin;
|
import es.asepeyo.recordalexia.domain.TaskOrigin;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
|
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
import java.time.LocalDate;
|
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.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
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;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica el marcado/desmarcado de tareas, las monedas por tarea y los bonos de
|
* Monedas y bonos de bloque/día (con reversión). Monta una familia y la deja como
|
||||||
* bloque y de día, incluida su reversión coherente al desmarcar.
|
* sesión actual (lo que lee FamilyContext) para que el aislamiento no estorbe.
|
||||||
*
|
|
||||||
* Niño con coinsPerTask=5, coinsPerBlock=10, coinsPerDay=20. Día con 2 tareas de
|
|
||||||
* mañana y 1 de tarde.
|
|
||||||
*/
|
*/
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -30,6 +35,21 @@ class TaskServiceTest {
|
|||||||
@Autowired private TaskService taskService;
|
@Autowired private TaskService taskService;
|
||||||
@Autowired private ChildRepository childRepository;
|
@Autowired private ChildRepository childRepository;
|
||||||
@Autowired private DailyTaskRepository dailyTaskRepository;
|
@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
|
@Test
|
||||||
void monedasYbonosDeBloqueYdiaConReversion() {
|
void monedasYbonosDeBloqueYdiaConReversion() {
|
||||||
@@ -38,33 +58,28 @@ class TaskServiceTest {
|
|||||||
DailyTask m2 = nuevaTarea(nino, Slot.MORNING, "Lengua", 1);
|
DailyTask m2 = nuevaTarea(nino, Slot.MORNING, "Lengua", 1);
|
||||||
DailyTask t1 = nuevaTarea(nino, Slot.AFTERNOON, "Deberes", 0);
|
DailyTask t1 = nuevaTarea(nino, Slot.AFTERNOON, "Deberes", 0);
|
||||||
|
|
||||||
// 1ª tarea de mañana: +5 (bloque aún incompleto).
|
|
||||||
var r1 = taskService.toggle(m1.getId());
|
var r1 = taskService.toggle(m1.getId());
|
||||||
assertThat(r1.coinsEarned()).isEqualTo(5);
|
assertThat(r1.coinsEarned()).isEqualTo(5);
|
||||||
assertThat(r1.newBalance()).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());
|
var r2 = taskService.toggle(m2.getId());
|
||||||
assertThat(r2.coinsEarned()).isEqualTo(15);
|
assertThat(r2.coinsEarned()).isEqualTo(15);
|
||||||
assertThat(r2.newBalance()).isEqualTo(20);
|
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());
|
var r3 = taskService.toggle(t1.getId());
|
||||||
assertThat(r3.coinsEarned()).isEqualTo(35);
|
assertThat(r3.coinsEarned()).isEqualTo(35);
|
||||||
assertThat(r3.newBalance()).isEqualTo(55);
|
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());
|
var r4 = taskService.toggle(t1.getId());
|
||||||
assertThat(r4.coinsEarned()).isEqualTo(-35);
|
assertThat(r4.coinsEarned()).isEqualTo(-35);
|
||||||
assertThat(r4.newBalance()).isEqualTo(20);
|
assertThat(r4.newBalance()).isEqualTo(20);
|
||||||
|
|
||||||
// El bono de mañana se conserva (la mañana sigue completa).
|
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(20);
|
||||||
Child recargado = childRepository.findById(nino.getId()).orElseThrow();
|
|
||||||
assertThat(recargado.getCoins()).isEqualTo(20);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Child nuevoNino() {
|
private Child nuevoNino() {
|
||||||
Child c = new Child();
|
Child c = new Child();
|
||||||
|
c.setFamily(family);
|
||||||
c.setName("Leo");
|
c.setName("Leo");
|
||||||
c.setMascot("🐢");
|
c.setMascot("🐢");
|
||||||
c.setAccentColor("#5BC0BE");
|
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.Activity;
|
||||||
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
|
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
|
||||||
import es.asepeyo.recordalexia.domain.Child;
|
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.Reward;
|
||||||
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
|
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
|
||||||
import es.asepeyo.recordalexia.repository.ActivityRepository;
|
import es.asepeyo.recordalexia.repository.ActivityRepository;
|
||||||
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
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.RewardRepository;
|
||||||
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
||||||
|
import es.asepeyo.recordalexia.security.SessionAuthFilter;
|
||||||
import java.time.DayOfWeek;
|
import java.time.DayOfWeek;
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/** Flujo del kiosko vía API (con sesión de familia): ver día, marcar, canjear. */
|
||||||
* Test de integración del flujo del kiosko a través de la API REST:
|
|
||||||
* ver el día de hoy, marcar una tarea (ganar monedas) y canjear un premio.
|
|
||||||
*/
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
class TodayFlowIT {
|
class TodayFlowIT {
|
||||||
|
|
||||||
@Autowired private MockMvc mockMvc;
|
@Autowired private MockMvc mockMvc;
|
||||||
@@ -41,19 +47,28 @@ class TodayFlowIT {
|
|||||||
@Autowired private WeeklyTemplateEntryRepository templateRepository;
|
@Autowired private WeeklyTemplateEntryRepository templateRepository;
|
||||||
@Autowired private AfternoonRoutineRepository routineRepository;
|
@Autowired private AfternoonRoutineRepository routineRepository;
|
||||||
@Autowired private RewardRepository rewardRepository;
|
@Autowired private RewardRepository rewardRepository;
|
||||||
|
@Autowired private FamilyRepository familyRepository;
|
||||||
|
@Autowired private FamilySessionRepository sessionRepository;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void flujoVerDiaMarcarTareaYcanjear() throws Exception {
|
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();
|
DayOfWeek hoy = LocalDate.now().getDayOfWeek();
|
||||||
Child nino = childRepository.save(nuevoNino());
|
Child nino = childRepository.save(nuevoNino(family));
|
||||||
Activity mates = activityRepository.save(new Activity("Mates", "Mates", "📘", "#5B8DEF"));
|
Activity mates = new Activity("Mates", "Mates", "📘", "#5B8DEF");
|
||||||
|
mates.setFamily(family);
|
||||||
|
mates = activityRepository.save(mates);
|
||||||
templateRepository.save(new WeeklyTemplateEntry(nino, hoy, mates, 0));
|
templateRepository.save(new WeeklyTemplateEntry(nino, hoy, mates, 0));
|
||||||
routineRepository.save(new AfternoonRoutine(nino, hoy, "Deberes", "Deures", "📝", "#F2A65A", 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(status().isOk())
|
||||||
.andExpect(jsonPath("$.morning.length()").value(1))
|
.andExpect(jsonPath("$.morning.length()").value(1))
|
||||||
.andExpect(jsonPath("$.afternoon.length()").value(1))
|
.andExpect(jsonPath("$.afternoon.length()").value(1))
|
||||||
@@ -63,25 +78,24 @@ class TodayFlowIT {
|
|||||||
JsonNode json = objectMapper.readTree(body);
|
JsonNode json = objectMapper.readTree(body);
|
||||||
long taskId = json.path("morning").get(0).path("id").asLong();
|
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
|
mockMvc.perform(post("/api/tasks/{taskId}/toggle", taskId).header(SessionAuthFilter.HEADER, session))
|
||||||
// gana +5 (tarea) +10 (bono de bloque) = 15. El día no se completa (queda la tarde).
|
|
||||||
mockMvc.perform(post("/api/tasks/{taskId}/toggle", taskId))
|
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.done").value(true))
|
.andExpect(jsonPath("$.done").value(true))
|
||||||
.andExpect(jsonPath("$.coinsEarned").value(15))
|
.andExpect(jsonPath("$.coinsEarned").value(15))
|
||||||
.andExpect(jsonPath("$.newBalance").value(65));
|
.andExpect(jsonPath("$.newBalance").value(65));
|
||||||
|
|
||||||
// 3) Canjear un premio de coste 5.
|
|
||||||
mockMvc.perform(post("/api/rewards/{rewardId}/redeem", premio.getId())
|
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(status().isOk())
|
||||||
.andExpect(jsonPath("$.newBalance").value(60));
|
.andExpect(jsonPath("$.newBalance").value(60));
|
||||||
|
|
||||||
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(60);
|
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(60);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Child nuevoNino() {
|
private Child nuevoNino(Family family) {
|
||||||
Child c = new Child();
|
Child c = new Child();
|
||||||
|
c.setFamily(family);
|
||||||
c.setName("Nora");
|
c.setName("Nora");
|
||||||
c.setMascot("🦊");
|
c.setMascot("🦊");
|
||||||
c.setAccentColor("#F2A65A");
|
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 { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { FontPreferenceService } from './core/font-preference.service';
|
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
describe('AppComponent', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -15,11 +14,4 @@ describe('AppComponent', () => {
|
|||||||
const fixture = TestBed.createComponent(AppComponent);
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
expect(fixture.componentInstance).toBeTruthy();
|
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 { 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({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet],
|
||||||
template: '<router-outlet />',
|
template: '<router-outlet />',
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/
|
|||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { parentSessionInterceptor } from './core/parent-session.interceptor';
|
import { authInterceptor } from './core/auth.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
// Cliente HTTP (fetch) con el interceptor de sesión de padres.
|
// Cliente HTTP (fetch) con el interceptor de sesión de familia.
|
||||||
provideHttpClient(withFetch(), withInterceptors([parentSessionInterceptor])),
|
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,17 +4,25 @@ import { HomeComponent } from './features/home/home.component';
|
|||||||
import { StoreComponent } from './features/store/store.component';
|
import { StoreComponent } from './features/store/store.component';
|
||||||
import { KeypadComponent } from './features/parents/keypad.component';
|
import { KeypadComponent } from './features/parents/keypad.component';
|
||||||
import { ParentsComponent } from './features/parents/parents.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 = [
|
export const routes: Routes = [
|
||||||
// Selección de perfil: la pantalla de entrada del kiosko.
|
// Públicas.
|
||||||
{ path: '', component: ProfileSelectComponent },
|
{ path: 'login', component: LoginComponent },
|
||||||
// Día de hoy del niño (Tablero / Foco).
|
{ path: 'register', component: RegisterComponent },
|
||||||
{ path: 'home/:childId', component: HomeComponent },
|
|
||||||
// Tienda de recompensas.
|
// Requieren sesión de familia.
|
||||||
{ path: 'store/:childId', component: StoreComponent },
|
{ path: '', component: ProfileSelectComponent, canActivate: [authGuard] },
|
||||||
// PIN de padres y panel protegido por sesión.
|
{ path: 'home/:childId', component: HomeComponent, canActivate: [authGuard] },
|
||||||
{ path: 'pin', component: KeypadComponent },
|
{ path: 'store/:childId', component: StoreComponent, canActivate: [authGuard] },
|
||||||
{ path: 'parents', component: ParentsComponent, canActivate: [parentGuard] },
|
{ 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: '' },
|
{ 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';
|
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
|
* La fuente de verdad está en el BACKEND: el ajuste por niño (`child.dyslexiaFont`,
|
||||||
* `data-dyslexia-font` en el elemento <html>, que es el interruptor que el
|
* llega en /today) y el default de la cuenta (`family.defaultDyslexiaFont`). Este
|
||||||
* fichero de tokens (_theme.scss) usa para alternar entre OpenDyslexic y las
|
* servicio solo refleja ese valor; quien decide lo aplica con apply().
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class FontPreferenceService {
|
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);
|
private readonly document = inject(DOCUMENT);
|
||||||
|
|
||||||
/** Estado reactivo: ¿está activada OpenDyslexic? Por defecto, sí. */
|
/** ¿Está activada OpenDyslexic ahora mismo? (para la UI que quiera mostrarlo). */
|
||||||
private readonly enabledSignal = signal<boolean>(this.readInitialState());
|
readonly enabled = signal<boolean>(this.readDom());
|
||||||
|
|
||||||
/** Señal de solo lectura para que la consuma la UI. */
|
/** Aplica el valor al DOM y actualiza la señal. */
|
||||||
readonly enabled = this.enabledSignal.asReadonly();
|
apply(enabled: boolean): void {
|
||||||
|
this.enabled.set(enabled);
|
||||||
constructor() {
|
this.document.documentElement.setAttribute('data-dyslexia-font', enabled ? 'on' : 'off');
|
||||||
// Sincroniza el DOM con el estado inicial al arrancar la app.
|
|
||||||
this.applyToDom(this.enabledSignal());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Activa o desactiva OpenDyslexic y propaga el cambio al DOM y a la persistencia. */
|
private readDom(): boolean {
|
||||||
setEnabled(enabled: boolean): void {
|
return this.document.documentElement.getAttribute('data-dyslexia-font') !== 'off';
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface ChildInfo {
|
|||||||
language: Language;
|
language: Language;
|
||||||
soundEnabled: boolean;
|
soundEnabled: boolean;
|
||||||
ttsEnabled: boolean;
|
ttsEnabled: boolean;
|
||||||
|
dyslexiaFont: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tarea del día (mañana o tarde). Lleva texto ES y CA. */
|
/** Tarea del día (mañana o tarde). Lleva texto ES y CA. */
|
||||||
@@ -94,6 +95,7 @@ export interface SettingsRequest {
|
|||||||
soundEnabled?: boolean;
|
soundEnabled?: boolean;
|
||||||
ttsEnabled?: boolean;
|
ttsEnabled?: boolean;
|
||||||
language?: Language;
|
language?: Language;
|
||||||
|
dyslexiaFont?: boolean;
|
||||||
departureTime?: string;
|
departureTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +187,15 @@ export interface GamificationView {
|
|||||||
coinsPerDay: number;
|
coinsPerDay: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Auth / cuenta de familia -----
|
||||||
|
export interface MeResponse {
|
||||||
|
familyId: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
uiLanguage: string;
|
||||||
|
defaultDyslexiaFont: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Panel de padres: peticiones -----
|
// ----- Panel de padres: peticiones -----
|
||||||
export interface ChildRequest {
|
export interface ChildRequest {
|
||||||
name?: string;
|
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 { Component, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { ApiService } from '../../core/api.service';
|
import { ApiService } from '../../core/api.service';
|
||||||
|
import { FontPreferenceService } from '../../core/font-preference.service';
|
||||||
import { I18nService } from '../../core/i18n.service';
|
import { I18nService } from '../../core/i18n.service';
|
||||||
import { SoundService } from '../../core/sound.service';
|
import { SoundService } from '../../core/sound.service';
|
||||||
import { TodayResponse, ViewMode } from '../../core/models';
|
import { TodayResponse, ViewMode } from '../../core/models';
|
||||||
@@ -47,6 +48,7 @@ export class HomeComponent implements OnInit {
|
|||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly sound = inject(SoundService);
|
private readonly sound = inject(SoundService);
|
||||||
|
private readonly fontPreference = inject(FontPreferenceService);
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
|
|
||||||
@ViewChild('wallet') private wallet?: WalletComponent;
|
@ViewChild('wallet') private wallet?: WalletComponent;
|
||||||
@@ -88,6 +90,8 @@ export class HomeComponent implements OnInit {
|
|||||||
this.today.set(data);
|
this.today.set(data);
|
||||||
this.mode.set(data.child.viewMode);
|
this.mode.set(data.child.viewMode);
|
||||||
this.i18n.setLang(data.child.language);
|
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);
|
this.loading.set(false);
|
||||||
},
|
},
|
||||||
error: () => this.loading.set(false),
|
error: () => this.loading.set(false),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { I18nService } from '../../core/i18n.service';
|
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). */
|
/** Teclado numérico 3×4 para el PIN de padres (4 dígitos, shake al fallar). */
|
||||||
@Component({
|
@Component({
|
||||||
@@ -87,7 +87,7 @@ import { ParentSessionService } from '../../core/parent-session.service';
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class KeypadComponent {
|
export class KeypadComponent {
|
||||||
private readonly session = inject(ParentSessionService);
|
private readonly auth = inject(AuthService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ export class KeypadComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private submit(code: string): void {
|
private submit(code: string): void {
|
||||||
this.session.login(code).subscribe({
|
this.auth.unlockPanel(code).subscribe({
|
||||||
next: () => this.router.navigate(['/parents']),
|
next: () => this.router.navigate(['/parents']),
|
||||||
error: () => {
|
error: () => {
|
||||||
this.error.set(true);
|
this.error.set(true);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, inject, signal } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ParentApiService } from '../../core/parent-api.service';
|
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 { I18nService } from '../../core/i18n.service';
|
||||||
import { ChildSummary } from '../../core/models';
|
import { ChildSummary } from '../../core/models';
|
||||||
import { ScheduleTabComponent } from './schedule-tab.component';
|
import { ScheduleTabComponent } from './schedule-tab.component';
|
||||||
@@ -90,7 +90,7 @@ type Tab = 'schedule' | 'materials' | 'events' | 'routines' | 'rewards';
|
|||||||
})
|
})
|
||||||
export class ParentsComponent {
|
export class ParentsComponent {
|
||||||
private readonly parentApi = inject(ParentApiService);
|
private readonly parentApi = inject(ParentApiService);
|
||||||
private readonly session = inject(ParentSessionService);
|
private readonly auth = inject(AuthService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
protected readonly i18n = inject(I18nService);
|
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 {
|
logout(): void {
|
||||||
this.session.logout();
|
this.auth.panelUnlocked.set(false);
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import { RewardAdminView } from '../../core/models';
|
|||||||
<label class="adm-chip">
|
<label class="adm-chip">
|
||||||
<input type="checkbox" [(ngModel)]="ttsEnabled" (change)="saveSettings()" /> 🗣️ {{ i18n.t('readAloud') }}
|
<input type="checkbox" [(ngModel)]="ttsEnabled" (change)="saveSettings()" /> 🗣️ {{ i18n.t('readAloud') }}
|
||||||
</label>
|
</label>
|
||||||
|
<label class="adm-chip">
|
||||||
|
<input type="checkbox" [(ngModel)]="dyslexiaFont" (change)="saveSettings()" /> 🔤 OpenDyslexic
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,6 +81,7 @@ export class RewardsTabComponent {
|
|||||||
this.api.getToday(value).subscribe((t) => {
|
this.api.getToday(value).subscribe((t) => {
|
||||||
this.soundEnabled = t.child.soundEnabled;
|
this.soundEnabled = t.child.soundEnabled;
|
||||||
this.ttsEnabled = t.child.ttsEnabled;
|
this.ttsEnabled = t.child.ttsEnabled;
|
||||||
|
this.dyslexiaFont = t.child.dyslexiaFont;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
private _childId!: number;
|
private _childId!: number;
|
||||||
@@ -94,6 +98,7 @@ export class RewardsTabComponent {
|
|||||||
protected perDay = 20;
|
protected perDay = 20;
|
||||||
protected soundEnabled = true;
|
protected soundEnabled = true;
|
||||||
protected ttsEnabled = true;
|
protected ttsEnabled = true;
|
||||||
|
protected dyslexiaFont = true;
|
||||||
|
|
||||||
protected icon = '';
|
protected icon = '';
|
||||||
protected labelEs = '';
|
protected labelEs = '';
|
||||||
@@ -121,7 +126,11 @@ export class RewardsTabComponent {
|
|||||||
|
|
||||||
saveSettings(): void {
|
saveSettings(): void {
|
||||||
this.parentApi
|
this.parentApi
|
||||||
.updateSettings(this._childId, { soundEnabled: this.soundEnabled, ttsEnabled: this.ttsEnabled })
|
.updateSettings(this._childId, {
|
||||||
|
soundEnabled: this.soundEnabled,
|
||||||
|
ttsEnabled: this.ttsEnabled,
|
||||||
|
dyslexiaFont: this.dyslexiaFont,
|
||||||
|
})
|
||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<main class="profiles">
|
<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>
|
<h1 class="profiles__title">{{ i18n.t('whoEntersToday') }}</h1>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profiles {
|
.profiles {
|
||||||
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -11,6 +12,21 @@
|
|||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
padding: var(--space-6) var(--space-4);
|
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 {
|
&__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 2.4rem;
|
font-size: 2.4rem;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ApiService } from '../../core/api.service';
|
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 { I18nService } from '../../core/i18n.service';
|
||||||
import { KioskService } from '../../core/kiosk.service';
|
import { KioskService } from '../../core/kiosk.service';
|
||||||
import { ChildSummary } from '../../core/models';
|
import { ChildSummary } from '../../core/models';
|
||||||
@@ -23,6 +25,8 @@ export class ProfileSelectComponent implements OnInit {
|
|||||||
private readonly api = inject(ApiService);
|
private readonly api = inject(ApiService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly kiosk = inject(KioskService);
|
private readonly kiosk = inject(KioskService);
|
||||||
|
protected readonly auth = inject(AuthService);
|
||||||
|
private readonly fontPreference = inject(FontPreferenceService);
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
|
|
||||||
protected readonly children = signal<ChildSummary[]>([]);
|
protected readonly children = signal<ChildSummary[]>([]);
|
||||||
@@ -30,6 +34,18 @@ export class ProfileSelectComponent implements OnInit {
|
|||||||
protected readonly error = signal(false);
|
protected readonly error = signal(false);
|
||||||
|
|
||||||
ngOnInit(): void {
|
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({
|
this.api.getChildren().subscribe({
|
||||||
next: (list) => {
|
next: (list) => {
|
||||||
this.children.set(list);
|
this.children.set(list);
|
||||||
@@ -52,4 +68,8 @@ export class ProfileSelectComponent implements OnInit {
|
|||||||
openParents(): void {
|
openParents(): void {
|
||||||
this.router.navigate(['/parents']);
|
this.router.navigate(['/parents']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openAccount(): void {
|
||||||
|
this.router.navigate(['/account']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user