Compare commits
14 Commits
242282412c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dfe0218a4 | ||
|
|
6b655634ca | ||
|
|
ddd1a96af9 | ||
|
|
203d9b0b47 | ||
|
|
4606f71bdc | ||
|
|
e5385f7ddd | ||
|
|
4eccee9b05 | ||
|
|
1db262f8ea | ||
|
|
0e8bfbcc9c | ||
|
|
5c79480aa4 | ||
|
|
3ec611d373 | ||
|
|
5421080b04 | ||
|
|
08c7289f25 | ||
|
|
c165b759b7 |
25
.env.prod.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Plantilla de variables de PRODUCCIÓN. Copiar a .env.prod en la VM y rellenar.
|
||||||
|
# .env.prod NO se versiona jamás (está en .gitignore).
|
||||||
|
#
|
||||||
|
# cp .env.prod.example .env.prod
|
||||||
|
|
||||||
|
# --- Base de datos (solo red interna del compose) ---
|
||||||
|
DB_NAME=recordalexia
|
||||||
|
DB_USER=recordalexia
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
# --- Dominio público (enlaces del email y CORS) ---
|
||||||
|
PUBLIC_BASE_URL=https://recordalexia.jaumegar.work
|
||||||
|
|
||||||
|
# --- Correo saliente (cualquier relay SMTP estándar) ---
|
||||||
|
# Sin SMTP_HOST, los emails de recuperación se quedan en el log del backend.
|
||||||
|
MAIL_FROM=no-reply@jaumegar.work
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
|
||||||
|
# --- Backups (deploy/backup.sh) ---
|
||||||
|
# Remoto rclone para la copia fuera de la VM (ej. "b2:recordalexia-backups").
|
||||||
|
# Vacío = solo copia local (NO recomendado para abrir el registro al público).
|
||||||
|
RCLONE_REMOTE=
|
||||||
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# --- Entorno / secretos ---
|
# --- Entorno / secretos ---
|
||||||
# El .env real (con credenciales) NUNCA se versiona. Solo .env.example.
|
# El .env real (con credenciales) NUNCA se versiona. Solo .env.example.
|
||||||
.env
|
.env
|
||||||
|
.env.prod
|
||||||
|
|
||||||
# --- Sistema operativo ---
|
# --- Sistema operativo ---
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -23,13 +23,21 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
|
// Recuperación de contraseña por email; sin spring.mail.host configurado la app
|
||||||
|
// funciona igual (MailService cae a modo log para desarrollo y tests).
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||||
implementation 'org.liquibase:liquibase-core'
|
implementation 'org.liquibase:liquibase-core'
|
||||||
runtimeOnly 'org.postgresql:postgresql'
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
// H2 solo en test: permite que el contexto cargue sin un Postgres real en la
|
// H2 en test para los tests rápidos de servicio/web. La verificación de las
|
||||||
// Fase 1 (esqueleto). La infraestructura de test realista (Testcontainers)
|
// migraciones Liquibase y del mapeo JPA (ddl-auto=validate) se hace contra un
|
||||||
// se introducirá en la Fase 2, junto con el dominio.
|
// Postgres REAL vía Testcontainers (MigrationIT): H2 no detecta
|
||||||
|
// incompatibilidades de tipos/SQL propias de Postgres (lección aprendida con
|
||||||
|
// el CAST(NULL AS INTEGER) del changeset 003).
|
||||||
testRuntimeOnly 'com.h2database:h2'
|
testRuntimeOnly 'com.h2database:h2'
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
|
||||||
|
testImplementation 'org.testcontainers:junit-jupiter'
|
||||||
|
testImplementation 'org.testcontainers:postgresql'
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,16 @@ import es.asepeyo.recordalexia.domain.EventType;
|
|||||||
import es.asepeyo.recordalexia.domain.Family;
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
import es.asepeyo.recordalexia.domain.MaterialItem;
|
import es.asepeyo.recordalexia.domain.MaterialItem;
|
||||||
import es.asepeyo.recordalexia.domain.Reward;
|
import es.asepeyo.recordalexia.domain.Reward;
|
||||||
import es.asepeyo.recordalexia.domain.RoutineTask;
|
|
||||||
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.ChildRepository;
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
|
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
|
||||||
import es.asepeyo.recordalexia.repository.RewardRepository;
|
import es.asepeyo.recordalexia.repository.RewardRepository;
|
||||||
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
|
|
||||||
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
|
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
|
||||||
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
||||||
|
import es.asepeyo.recordalexia.service.InitialDatasetService;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.DayOfWeek;
|
import java.time.DayOfWeek;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -39,8 +37,11 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
*
|
*
|
||||||
* Credenciales demo: demo@recordalexia.local / demo1234 · PIN 1234.
|
* Credenciales demo: demo@recordalexia.local / demo1234 · PIN 1234.
|
||||||
*/
|
*/
|
||||||
|
// FAIL-SAFE: sin la propiedad, NO se siembra (una instancia pública desplegada sin
|
||||||
|
// configurar jamás debe nacer con la familia demo y su PIN público). El compose
|
||||||
|
// local y los tests que la necesitan la activan explícitamente.
|
||||||
@Component
|
@Component
|
||||||
@ConditionalOnProperty(name = "recordalexia.seed.enabled", havingValue = "true", matchIfMissing = true)
|
@ConditionalOnProperty(name = "recordalexia.seed.enabled", havingValue = "true", matchIfMissing = false)
|
||||||
public class DataSeeder implements ApplicationRunner {
|
public class DataSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
private static final String DEMO_EMAIL = "demo@recordalexia.local";
|
private static final String DEMO_EMAIL = "demo@recordalexia.local";
|
||||||
@@ -52,29 +53,26 @@ public class DataSeeder implements ApplicationRunner {
|
|||||||
private final MaterialItemRepository materialRepository;
|
private final MaterialItemRepository materialRepository;
|
||||||
private final ActivityRepository activityRepository;
|
private final ActivityRepository activityRepository;
|
||||||
private final WeeklyTemplateEntryRepository templateRepository;
|
private final WeeklyTemplateEntryRepository templateRepository;
|
||||||
private final AfternoonRoutineRepository routineRepository;
|
|
||||||
private final RoutineTaskRepository routineTaskRepository;
|
|
||||||
private final SpecialEventRepository eventRepository;
|
private final SpecialEventRepository eventRepository;
|
||||||
private final RewardRepository rewardRepository;
|
private final RewardRepository rewardRepository;
|
||||||
|
private final InitialDatasetService initialDataset;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
public DataSeeder(FamilyRepository familyRepository, ChildRepository childRepository,
|
public DataSeeder(FamilyRepository familyRepository, ChildRepository childRepository,
|
||||||
MaterialItemRepository materialRepository, ActivityRepository activityRepository,
|
MaterialItemRepository materialRepository, ActivityRepository activityRepository,
|
||||||
WeeklyTemplateEntryRepository templateRepository,
|
WeeklyTemplateEntryRepository templateRepository,
|
||||||
AfternoonRoutineRepository routineRepository,
|
|
||||||
RoutineTaskRepository routineTaskRepository,
|
|
||||||
SpecialEventRepository eventRepository, RewardRepository rewardRepository,
|
SpecialEventRepository eventRepository, RewardRepository rewardRepository,
|
||||||
|
InitialDatasetService initialDataset,
|
||||||
PasswordEncoder passwordEncoder, Clock clock) {
|
PasswordEncoder passwordEncoder, Clock clock) {
|
||||||
this.familyRepository = familyRepository;
|
this.familyRepository = familyRepository;
|
||||||
this.childRepository = childRepository;
|
this.childRepository = childRepository;
|
||||||
this.materialRepository = materialRepository;
|
this.materialRepository = materialRepository;
|
||||||
this.activityRepository = activityRepository;
|
this.activityRepository = activityRepository;
|
||||||
this.templateRepository = templateRepository;
|
this.templateRepository = templateRepository;
|
||||||
this.routineRepository = routineRepository;
|
|
||||||
this.routineTaskRepository = routineTaskRepository;
|
|
||||||
this.eventRepository = eventRepository;
|
this.eventRepository = eventRepository;
|
||||||
this.rewardRepository = rewardRepository;
|
this.rewardRepository = rewardRepository;
|
||||||
|
this.initialDataset = initialDataset;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
@@ -89,18 +87,26 @@ public class DataSeeder implements ApplicationRunner {
|
|||||||
Family family = familyRepository.save(new Family(DEMO_EMAIL,
|
Family family = familyRepository.save(new Family(DEMO_EMAIL,
|
||||||
passwordEncoder.encode(DEMO_PASSWORD), "Familia demo", passwordEncoder.encode(DEMO_PIN)));
|
passwordEncoder.encode(DEMO_PASSWORD), "Familia demo", passwordEncoder.encode(DEMO_PIN)));
|
||||||
|
|
||||||
var materials = seedMaterials(family);
|
// Catálogo base (materiales + rutinas): la MISMA provisión que recibe cualquier
|
||||||
var activities = seedActivities(family, materials);
|
// familia real al registrarse. La demo solo añade sus extras encima.
|
||||||
var routines = seedRoutineCatalog(family);
|
initialDataset.provisionFamily(family);
|
||||||
|
var activities = seedActivities(family);
|
||||||
seedRewards(family);
|
seedRewards(family);
|
||||||
|
|
||||||
Child nora = childRepository.save(child(family, "Nora", "🦊", "#F2A65A", 7, 42, LocalTime.of(8, 30)));
|
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 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)));
|
Child mia = childRepository.save(child(family, "Mía", "🦉", "#A78BD0", 6, 55, LocalTime.of(8, 15)));
|
||||||
|
|
||||||
for (Child c : List.of(nora, leo, mia)) {
|
// Nora y Mía llevan el horario genérico de ejemplo; Leo (9 años, 4º de
|
||||||
|
// primaria) lleva un horario real de 4t A, como en una familia de verdad.
|
||||||
|
for (Child c : List.of(nora, mia)) {
|
||||||
seedWeeklyMornings(c, activities);
|
seedWeeklyMornings(c, activities);
|
||||||
seedAfternoonRoutines(c, routines);
|
}
|
||||||
|
seedWeekly4tA(leo, family, activities);
|
||||||
|
|
||||||
|
for (Child c : List.of(nora, leo, mia)) {
|
||||||
|
// Rutinas de tarde escalonadas por edad, desde el catálogo de la familia.
|
||||||
|
initialDataset.provisionChildRoutines(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalDate today = LocalDate.now(clock);
|
LocalDate today = LocalDate.now(clock);
|
||||||
@@ -110,38 +116,40 @@ public class DataSeeder implements ApplicationRunner {
|
|||||||
"Ficha de mates", "Fitxa de mates", "📎", "#5B8DEF"));
|
"Ficha de mates", "Fitxa de mates", "📎", "#5B8DEF"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private MaterialsCatalog seedMaterials(Family family) {
|
|
||||||
return new MaterialsCatalog(
|
|
||||||
material(family, "Estuche", "Estoig", "✏️", "#F4C95D", "general"),
|
|
||||||
material(family, "Libro de mates", "Llibre de mates", "📘", "#5B8DEF", "matematicas"),
|
|
||||||
material(family, "Regla", "Regle", "📏", "#5B8DEF", "matematicas"),
|
|
||||||
material(family, "Flauta", "Flauta", "🎵", "#A78BD0", "musica"),
|
|
||||||
material(family, "Libreta", "Llibreta", "📓", "#A78BD0", "musica"),
|
|
||||||
material(family, "Ropa de gimnasia", "Roba d'EF", "👕", "#7FBF6B", "gimnasia"),
|
|
||||||
material(family, "Zapatillas", "Sabatilles", "👟", "#7FBF6B", "gimnasia"),
|
|
||||||
material(family, "Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia"),
|
|
||||||
material(family, "Agua", "Aigua", "💧", "#5BC0BE", "gimnasia"),
|
|
||||||
material(family, "Lectura", "Lectura", "📖", "#F2A65A", "lengua"),
|
|
||||||
material(family, "Cuaderno", "Quadern", "📒", "#F2A65A", "lengua"),
|
|
||||||
material(family, "Almuerzo", "Esmorzar", "🍎", "#EC8FA4", "general"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private MaterialItem material(Family family, 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) {
|
||||||
MaterialItem m = new MaterialItem(es, ca, icon, color, category);
|
MaterialItem m = new MaterialItem(es, ca, icon, color, category);
|
||||||
m.setFamily(family);
|
m.setFamily(family);
|
||||||
return materialRepository.save(m);
|
return materialRepository.save(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ActivitiesCatalog seedActivities(Family family, MaterialsCatalog m) {
|
/**
|
||||||
|
* Actividades de la demo. El material sale del catálogo base ya provisionado
|
||||||
|
* (por su etiqueta ES) más tres extras que solo la demo necesita.
|
||||||
|
*/
|
||||||
|
private ActivitiesCatalog seedActivities(Family family) {
|
||||||
|
List<MaterialItem> base = materialRepository.findByFamilyId(family.getId());
|
||||||
|
MaterialItem flauta = material(family, "Flauta", "Flauta", "🎵", "#A78BD0", "musica");
|
||||||
|
MaterialItem regla = material(family, "Regla", "Regle", "📏", "#5B8DEF", "matematicas");
|
||||||
|
MaterialItem toalla = material(family, "Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia");
|
||||||
|
|
||||||
Activity gimnasia = activity(family, "Gimnasia", "Gimnàstica", "🤸", "#7FBF6B",
|
Activity gimnasia = activity(family, "Gimnasia", "Gimnàstica", "🤸", "#7FBF6B",
|
||||||
m.ropaGimnasia, m.zapatillas, m.toalla, m.agua);
|
fromBase(base, "Ropa de gimnasia"), fromBase(base, "Zapatillas"), toalla,
|
||||||
Activity musica = activity(family, "Música", "Música", "🎵", "#A78BD0", m.flauta, m.libreta);
|
fromBase(base, "Botella de agua"));
|
||||||
|
Activity musica = activity(family, "Música", "Música", "🎵", "#A78BD0",
|
||||||
|
flauta, fromBase(base, "Libreta"));
|
||||||
Activity mates = activity(family, "Matemáticas", "Matemàtiques", "📘", "#5B8DEF",
|
Activity mates = activity(family, "Matemáticas", "Matemàtiques", "📘", "#5B8DEF",
|
||||||
m.libroMates, m.regla, m.estuche);
|
fromBase(base, "Libro de texto"), regla, fromBase(base, "Estuche"));
|
||||||
Activity lengua = activity(family, "Lengua", "Llengua", "📖", "#F2A65A", m.lectura, m.cuaderno);
|
Activity lengua = activity(family, "Lengua", "Llengua", "📖", "#F2A65A",
|
||||||
|
fromBase(base, "Lectura"), fromBase(base, "Libreta"));
|
||||||
return new ActivitiesCatalog(gimnasia, musica, mates, lengua);
|
return new ActivitiesCatalog(gimnasia, musica, mates, lengua);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Localiza un material del catálogo base por su etiqueta ES (clave estable del seed). */
|
||||||
|
private MaterialItem fromBase(List<MaterialItem> base, String labelEs) {
|
||||||
|
return base.stream().filter(m -> m.getLabelEs().equals(labelEs)).findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Falta el material base: " + labelEs));
|
||||||
|
}
|
||||||
|
|
||||||
private Activity activity(Family family, 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);
|
activity.setFamily(family);
|
||||||
@@ -191,45 +199,45 @@ public class DataSeeder implements ApplicationRunner {
|
|||||||
templateRepository.save(new WeeklyTemplateEntry(child, day, activity, 0));
|
templateRepository.save(new WeeklyTemplateEntry(child, day, activity, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Catálogo de rutinas reutilizables de la familia (se definen una sola vez). */
|
/**
|
||||||
private RoutinesCatalog seedRoutineCatalog(Family family) {
|
* Horario REAL de 4º de primaria (4t A, curso 2025-26) para Leo: asignaturas
|
||||||
return new RoutinesCatalog(
|
* propias del curso más las genéricas reutilizadas (Matemáticas, Música,
|
||||||
routineTask(family, "Deshacer la mochila", "Buidar la motxilla", "🎒", "#F2A65A"),
|
* Gimnasia=Ed. Física), en el orden cronológico de cada día. El patio no se
|
||||||
routineTask(family, "Merendar", "Berenar", "🥪", "#F4C95D"),
|
* siembra: no es una actividad con material que preparar.
|
||||||
routineTask(family, "Hacer los deberes", "Fer els deures", "📝", "#5B8DEF"),
|
*/
|
||||||
routineTask(family, "Practicar piano", "Practicar piano", "🎹", "#A78BD0"),
|
private void seedWeekly4tA(Child leo, Family family, ActivitiesCatalog base) {
|
||||||
routineTask(family, "Recoger la mesa", "Parar taula", "🍽️", "#7FBF6B"));
|
// La toalla (extra de la demo) acompaña a Piscina: hay que meterla en la mochila.
|
||||||
|
MaterialItem toalla = fromBase(materialRepository.findByFamilyId(family.getId()), "Toalla");
|
||||||
|
|
||||||
|
Activity catala = activity(family, "Català", "Català", "📕", "#F4C95D");
|
||||||
|
Activity castellano = activity(family, "Castellano", "Castellà", "📙", "#F2A65A");
|
||||||
|
Activity medio = activity(family, "Medio", "Medi", "🌍", "#7FBF6B");
|
||||||
|
Activity ingles = activity(family, "Inglés", "Anglès", "🇬🇧", "#A78BD0");
|
||||||
|
Activity piscina = activity(family, "Piscina", "Piscina", "🏊", "#5BC0BE", toalla);
|
||||||
|
Activity lectura = activity(family, "Lectura", "Lectura", "📖", "#EC8FA4");
|
||||||
|
Activity religiosa = activity(family, "Cultura religiosa", "Cultura religiosa", "🙏", "#F2A65A");
|
||||||
|
Activity workshop = activity(family, "Workshop", "Workshop", "🛠️", "#A78BD0");
|
||||||
|
Activity robotica = activity(family, "Robótica", "Robòtica", "🤖", "#EC8FA4");
|
||||||
|
Activity plastica = activity(family, "Plástica", "Plàstica", "🎨", "#EC8FA4");
|
||||||
|
Activity tutoria = activity(family, "Tutoría", "Tutoria", "💬", "#5BC0BE");
|
||||||
|
|
||||||
|
addDay(leo, DayOfWeek.MONDAY, base.matematicas(), medio, castellano, piscina, ingles, catala);
|
||||||
|
addDay(leo, DayOfWeek.TUESDAY, catala, base.musica(), base.gimnasia(), ingles, religiosa,
|
||||||
|
base.matematicas());
|
||||||
|
addDay(leo, DayOfWeek.WEDNESDAY, catala, medio, ingles, lectura, base.matematicas(), castellano);
|
||||||
|
addDay(leo, DayOfWeek.THURSDAY, workshop, base.matematicas(), medio, base.musica(), robotica,
|
||||||
|
base.gimnasia());
|
||||||
|
addDay(leo, DayOfWeek.FRIDAY, base.matematicas(), plastica, catala, castellano, tutoria);
|
||||||
}
|
}
|
||||||
|
|
||||||
private RoutineTask routineTask(Family family, String es, String ca, String icon, String color) {
|
/** Añade las asignaturas de un día en orden cronológico (orderIndex incremental). */
|
||||||
RoutineTask rt = new RoutineTask(es, ca, icon, color);
|
private void addDay(Child child, DayOfWeek day, Activity... subjects) {
|
||||||
rt.setFamily(family);
|
for (int i = 0; i < subjects.length; i++) {
|
||||||
return routineTaskRepository.save(rt);
|
templateRepository.save(new WeeklyTemplateEntry(child, day, subjects[i], i));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Asigna las rutinas del catálogo a cada día laborable, reutilizando las mismas. */
|
|
||||||
private void seedAfternoonRoutines(Child child, RoutinesCatalog r) {
|
|
||||||
for (DayOfWeek day : List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
|
|
||||||
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)) {
|
|
||||||
routineRepository.save(new AfternoonRoutine(child, day, r.mochila, 0));
|
|
||||||
routineRepository.save(new AfternoonRoutine(child, day, r.merienda, 1));
|
|
||||||
routineRepository.save(new AfternoonRoutine(child, day, r.deberes, 2));
|
|
||||||
routineRepository.save(new AfternoonRoutine(child, day, r.piano, 3));
|
|
||||||
routineRepository.save(new AfternoonRoutine(child, day, r.mesa, 4));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record MaterialsCatalog(
|
|
||||||
MaterialItem estuche, MaterialItem libroMates, MaterialItem regla, MaterialItem flauta,
|
|
||||||
MaterialItem libreta, MaterialItem ropaGimnasia, MaterialItem zapatillas, MaterialItem toalla,
|
|
||||||
MaterialItem agua, MaterialItem lectura, MaterialItem cuaderno, MaterialItem almuerzo) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private record ActivitiesCatalog(Activity gimnasia, Activity musica, Activity matematicas,
|
private record ActivitiesCatalog(Activity gimnasia, Activity musica, Activity matematicas,
|
||||||
Activity lengua) {
|
Activity lengua) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record RoutinesCatalog(RoutineTask mochila, RoutineTask merienda, RoutineTask deberes,
|
|
||||||
RoutineTask piano, RoutineTask mesa) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package es.asepeyo.recordalexia.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Async: el email de recuperación se envía fuera del hilo de la petición para que
|
||||||
|
* la respuesta tarde lo mismo exista o no la cuenta (anti-enumeración por timing).
|
||||||
|
* @Scheduled: purga periódica del rate-limiter.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
@EnableScheduling
|
||||||
|
public class ConcurrencyConfig {
|
||||||
|
}
|
||||||
@@ -51,6 +51,10 @@ public class Family {
|
|||||||
@Column(name = "created_at")
|
@Column(name = "created_at")
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
|
/** Cuándo aceptó la política de privacidad (RGPD); null en cuentas antiguas. */
|
||||||
|
@Column(name = "privacy_accepted_at")
|
||||||
|
private Instant privacyAcceptedAt;
|
||||||
|
|
||||||
public Family() {
|
public Family() {
|
||||||
// JPA / creación desde el servicio.
|
// JPA / creación desde el servicio.
|
||||||
}
|
}
|
||||||
@@ -118,4 +122,12 @@ public class Family {
|
|||||||
public Instant getCreatedAt() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Instant getPrivacyAcceptedAt() {
|
||||||
|
return privacyAcceptedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPrivacyAcceptedAt(Instant privacyAcceptedAt) {
|
||||||
|
this.privacyAcceptedAt = privacyAcceptedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Código de recuperación de contraseña (el "token" del enlace del email). Se
|
||||||
|
* guarda SOLO su SHA-256 — el valor real viaja únicamente en el email —, es de un
|
||||||
|
* solo uso, caduca pronto y emitir uno nuevo elimina los anteriores de la familia.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "password_reset_token")
|
||||||
|
public class RecoveryCode {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "family_id")
|
||||||
|
private Family family;
|
||||||
|
|
||||||
|
/** SHA-256 (hex) del código; nunca el valor en claro. */
|
||||||
|
@Column(name = "token_hash", nullable = false, unique = true)
|
||||||
|
private String tokenHash;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
/** Instante de consumo; null mientras siga siendo utilizable. */
|
||||||
|
@Column(name = "used_at")
|
||||||
|
private Instant usedAt;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
protected RecoveryCode() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public RecoveryCode(Family family, String tokenHash, Instant expiresAt, Instant createdAt) {
|
||||||
|
this.family = family;
|
||||||
|
this.tokenHash = tokenHash;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUsable(Instant now) {
|
||||||
|
return usedAt == null && now.isBefore(expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Family getFamily() {
|
||||||
|
return family;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTokenHash() {
|
||||||
|
return tokenHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getExpiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiresAt(Instant expiresAt) {
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getUsedAt() {
|
||||||
|
return usedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUsedAt(Instant usedAt) {
|
||||||
|
this.usedAt = usedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,4 +35,13 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.badRequest()
|
return ResponseEntity.badRequest()
|
||||||
.body(Map.of("error", "bad_request", "message", ex.getMessage()));
|
.body(Map.of("error", "bad_request", "message", ex.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(TooManyAttemptsException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleTooManyAttempts(TooManyAttemptsException ex) {
|
||||||
|
// 429 + Retry-After: frena la fuerza bruta sin revelar nada más.
|
||||||
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()))
|
||||||
|
.body(Map.of("error", "too_many_attempts",
|
||||||
|
"retryAfterSeconds", ex.getRetryAfterSeconds(), "message", ex.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package es.asepeyo.recordalexia.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demasiados intentos fallidos (login, PIN o solicitud de reset): la petición se
|
||||||
|
* rechaza SIN evaluar credenciales. Lleva los segundos de espera para la cabecera
|
||||||
|
* Retry-After.
|
||||||
|
*/
|
||||||
|
public class TooManyAttemptsException extends RuntimeException {
|
||||||
|
|
||||||
|
private final long retryAfterSeconds;
|
||||||
|
|
||||||
|
public TooManyAttemptsException(long retryAfterSeconds) {
|
||||||
|
super("Demasiados intentos. Espera antes de volver a probar.");
|
||||||
|
this.retryAfterSeconds = retryAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getRetryAfterSeconds() {
|
||||||
|
return retryAfterSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,4 +11,7 @@ public interface AfternoonRoutineRepository extends JpaRepository<AfternoonRouti
|
|||||||
List<AfternoonRoutine> findByChildIdAndDayOfWeekOrderByOrderIndexAsc(Long childId, DayOfWeek dayOfWeek);
|
List<AfternoonRoutine> findByChildIdAndDayOfWeekOrderByOrderIndexAsc(Long childId, DayOfWeek dayOfWeek);
|
||||||
|
|
||||||
List<AfternoonRoutine> findByChildIdOrderByDayOfWeekAscOrderIndexAsc(Long childId);
|
List<AfternoonRoutine> findByChildIdOrderByDayOfWeekAscOrderIndexAsc(Long childId);
|
||||||
|
|
||||||
|
/** Nº de asignaciones de un niño en un día; da el siguiente orderIndex al asignar. */
|
||||||
|
int countByChildIdAndDayOfWeek(Long childId, DayOfWeek dayOfWeek);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
public interface FamilySessionRepository extends JpaRepository<FamilySession, Long> {
|
public interface FamilySessionRepository extends JpaRepository<FamilySession, Long> {
|
||||||
|
|
||||||
Optional<FamilySession> findByHandle(String handle);
|
Optional<FamilySession> findByHandle(String handle);
|
||||||
|
|
||||||
|
/** Cierra todas las sesiones de una familia (tras restablecer la contraseña). */
|
||||||
|
void deleteByFamilyId(Long familyId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package es.asepeyo.recordalexia.repository;
|
||||||
|
|
||||||
|
import es.asepeyo.recordalexia.domain.RecoveryCode;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface RecoveryCodeRepository extends JpaRepository<RecoveryCode, Long> {
|
||||||
|
|
||||||
|
Optional<RecoveryCode> findByTokenHash(String tokenHash);
|
||||||
|
|
||||||
|
/** Emitir un código nuevo invalida (borra) los anteriores de la familia. */
|
||||||
|
void deleteByFamilyId(Long familyId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package es.asepeyo.recordalexia.security;
|
||||||
|
|
||||||
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
|
import es.asepeyo.recordalexia.domain.RecoveryCode;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilySessionRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.RecoveryCodeRepository;
|
||||||
|
import es.asepeyo.recordalexia.service.MailService;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recuperación de contraseña por email. Reglas de seguridad:
|
||||||
|
* - La solicitud responde IGUAL exista o no la cuenta (anti-enumeración); el email
|
||||||
|
* sale asíncrono para no delatar por tiempos.
|
||||||
|
* - En BD solo vive el SHA-256 del código; el valor real va únicamente en el enlace.
|
||||||
|
* - Un solo uso, caducidad de 30 min, y emitir uno nuevo borra los anteriores.
|
||||||
|
* - Restablecer cierra TODAS las sesiones de la familia (si alguien tenía la
|
||||||
|
* contraseña vieja, pierde el acceso ya).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AccountRecoveryService {
|
||||||
|
|
||||||
|
static final Duration CODE_TTL = Duration.ofMinutes(30);
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private final FamilyRepository familyRepository;
|
||||||
|
private final RecoveryCodeRepository codeRepository;
|
||||||
|
private final FamilySessionRepository sessionRepository;
|
||||||
|
private final MailService mailService;
|
||||||
|
private final PasswordEncoder encoder;
|
||||||
|
private final Clock clock;
|
||||||
|
private final String publicBaseUrl;
|
||||||
|
|
||||||
|
public AccountRecoveryService(FamilyRepository familyRepository,
|
||||||
|
RecoveryCodeRepository codeRepository,
|
||||||
|
FamilySessionRepository sessionRepository,
|
||||||
|
MailService mailService, PasswordEncoder encoder, Clock clock,
|
||||||
|
@Value("${recordalexia.public-base-url:http://localhost:8088}") String publicBaseUrl) {
|
||||||
|
this.familyRepository = familyRepository;
|
||||||
|
this.codeRepository = codeRepository;
|
||||||
|
this.sessionRepository = sessionRepository;
|
||||||
|
this.mailService = mailService;
|
||||||
|
this.encoder = encoder;
|
||||||
|
this.clock = clock;
|
||||||
|
this.publicBaseUrl = publicBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solicita el restablecimiento. NUNCA revela si el email existe: sin cuenta,
|
||||||
|
* simplemente no pasa nada (y el controlador responde 202 igualmente).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void requestReset(String email) {
|
||||||
|
familyRepository.findByEmailIgnoreCase(email.trim().toLowerCase()).ifPresent(family -> {
|
||||||
|
codeRepository.deleteByFamilyId(family.getId());
|
||||||
|
String rawCode = generateCode();
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
codeRepository.save(new RecoveryCode(family, sha256(rawCode), now.plus(CODE_TTL), now));
|
||||||
|
String link = publicBaseUrl + "/reset?code=" + rawCode;
|
||||||
|
mailService.sendPasswordReset(family.getEmail(), link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restablece la contraseña con un código vigente y sin usar. Los errores son
|
||||||
|
* genéricos a propósito: no se distingue "no existe" de "caducado" o "usado".
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void resetPassword(String rawCode, String newPassword) {
|
||||||
|
if (newPassword == null || newPassword.length() < 6) {
|
||||||
|
throw new IllegalArgumentException("La contraseña debe tener al menos 6 caracteres");
|
||||||
|
}
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
RecoveryCode code = codeRepository.findByTokenHash(sha256(rawCode))
|
||||||
|
.filter(c -> c.isUsable(now))
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("El enlace no es válido o ha caducado"));
|
||||||
|
|
||||||
|
Family family = code.getFamily();
|
||||||
|
family.setPassHash(encoder.encode(newPassword));
|
||||||
|
code.setUsedAt(now);
|
||||||
|
// Cerrar todas las sesiones: quien tuviera acceso con la contraseña vieja, fuera.
|
||||||
|
sessionRepository.deleteByFamilyId(family.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 32 bytes aleatorios en base64url: apto para viajar en una URL. */
|
||||||
|
private String generateCode() {
|
||||||
|
byte[] bytes = new byte[32];
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256(String value) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 no disponible", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,8 +37,10 @@ public class AuthService {
|
|||||||
if (familyRepository.existsByEmailIgnoreCase(normalized)) {
|
if (familyRepository.existsByEmailIgnoreCase(normalized)) {
|
||||||
throw new ConflictException("Ese email ya está registrado");
|
throw new ConflictException("Ese email ya está registrado");
|
||||||
}
|
}
|
||||||
Family family = familyRepository.save(
|
Family family = new Family(normalized, encoder.encode(rawPass), name, encoder.encode(rawPin));
|
||||||
new Family(normalized, encoder.encode(rawPass), name, encoder.encode(rawPin)));
|
// El controlador ya exigió la aceptación; aquí queda constancia de cuándo (RGPD).
|
||||||
|
family.setPrivacyAcceptedAt(java.time.Instant.now());
|
||||||
|
family = familyRepository.save(family);
|
||||||
// Sembrar el catálogo inicial (materiales + rutinas) para no empezar de cero.
|
// Sembrar el catálogo inicial (materiales + rutinas) para no empezar de cero.
|
||||||
initialDataset.provisionFamily(family);
|
initialDataset.provisionFamily(family);
|
||||||
return sessions.openSession(family);
|
return sessions.openSession(family);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package es.asepeyo.recordalexia.security;
|
||||||
|
|
||||||
|
import es.asepeyo.recordalexia.exception.TooManyAttemptsException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freno de fuerza bruta en memoria (la app corre como instancia única). Cuenta
|
||||||
|
* fallos por clave (login:email:ip, unlock:sesión, forgot:email:ip) y, al superar
|
||||||
|
* el umbral, bloquea temporalmente con backoff exponencial: cada bloqueo sucesivo
|
||||||
|
* duplica la espera. Con un PIN de 4 dígitos (10.000 combinaciones) este backoff
|
||||||
|
* convierte el ataque de minutos en días.
|
||||||
|
*
|
||||||
|
* La comprobación se hace ANTES de evaluar credenciales: durante un bloqueo ni
|
||||||
|
* siquiera se toca BCrypt.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class LoginAttemptService {
|
||||||
|
|
||||||
|
/** Fallos consecutivos permitidos antes del primer bloqueo. */
|
||||||
|
static final int MAX_FAILURES = 5;
|
||||||
|
/** Primer bloqueo; cada bloqueo sucesivo lo duplica. */
|
||||||
|
static final Duration BASE_BLOCK = Duration.ofSeconds(30);
|
||||||
|
/** Tope del backoff. */
|
||||||
|
static final Duration MAX_BLOCK = Duration.ofHours(1);
|
||||||
|
/** Ventana en la que los fallos cuentan como consecutivos. */
|
||||||
|
static final Duration FAILURE_WINDOW = Duration.ofMinutes(15);
|
||||||
|
/** Antigüedad a partir de la cual una entrada sin bloqueo activo se purga. */
|
||||||
|
private static final Duration STALE_AFTER = Duration.ofHours(2);
|
||||||
|
|
||||||
|
/** Estado por clave: fallos en ventana, nº de bloqueos ya impuestos y bloqueo activo. */
|
||||||
|
private record Attempts(int failures, int blocks, Instant windowStart, Instant blockedUntil) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, Attempts> attempts = new ConcurrentHashMap<>();
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public LoginAttemptService(Clock clock) {
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lanza 429 si la clave está bloqueada. Llamar SIEMPRE antes de validar nada. */
|
||||||
|
public void checkAllowed(String key) {
|
||||||
|
Attempts current = attempts.get(key);
|
||||||
|
if (current != null && current.blockedUntil() != null) {
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
if (now.isBefore(current.blockedUntil())) {
|
||||||
|
long seconds = Math.max(1, Duration.between(now, current.blockedUntil()).getSeconds());
|
||||||
|
throw new TooManyAttemptsException(seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registra un fallo; al llegar al umbral impone el siguiente bloqueo del backoff. */
|
||||||
|
public void onFailure(String key) {
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
attempts.compute(key, (k, previous) -> {
|
||||||
|
int blocks = previous == null ? 0 : previous.blocks();
|
||||||
|
int failures = previous == null ? 0 : previous.failures();
|
||||||
|
Instant windowStart = previous == null ? now : previous.windowStart();
|
||||||
|
// Fallos antiguos no cuentan: se abre ventana nueva.
|
||||||
|
if (previous == null || windowStart.plus(FAILURE_WINDOW).isBefore(now)) {
|
||||||
|
failures = 0;
|
||||||
|
windowStart = now;
|
||||||
|
}
|
||||||
|
failures++;
|
||||||
|
if (failures < MAX_FAILURES) {
|
||||||
|
return new Attempts(failures, blocks, windowStart, null);
|
||||||
|
}
|
||||||
|
// Umbral alcanzado: bloqueo con backoff (30s, 1m, 2m, ... cap 1h).
|
||||||
|
blocks++;
|
||||||
|
long factor = 1L << Math.min(blocks - 1, 20);
|
||||||
|
Duration block = BASE_BLOCK.multipliedBy(factor);
|
||||||
|
if (block.compareTo(MAX_BLOCK) > 0) {
|
||||||
|
block = MAX_BLOCK;
|
||||||
|
}
|
||||||
|
return new Attempts(0, blocks, now, now.plus(block));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un acceso correcto perdona el historial de la clave. */
|
||||||
|
public void onSuccess(String key) {
|
||||||
|
attempts.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Purga horaria de entradas frías (sin bloqueo activo y con ventana antigua). */
|
||||||
|
@Scheduled(fixedDelayString = "PT1H")
|
||||||
|
public void cleanup() {
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
attempts.entrySet().removeIf(entry -> {
|
||||||
|
Attempts a = entry.getValue();
|
||||||
|
boolean blockActive = a.blockedUntil() != null && now.isBefore(a.blockedUntil());
|
||||||
|
return !blockActive && a.windowStart().plus(STALE_AFTER).isBefore(now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package es.asepeyo.recordalexia.security;
|
package es.asepeyo.recordalexia.security;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
@@ -33,15 +34,17 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http, SessionAuthFilter sessionAuthFilter)
|
public SecurityFilterChain filterChain(HttpSecurity http, SessionAuthFilter sessionAuthFilter,
|
||||||
|
CorsConfigurationSource corsConfigurationSource)
|
||||||
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
|
||||||
// Públicas: abrir cuenta / sesión.
|
// Públicas: abrir cuenta / sesión / recuperar contraseña.
|
||||||
.requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login",
|
||||||
|
"/api/auth/forgot-password", "/api/auth/reset-password").permitAll()
|
||||||
.requestMatchers("/actuator/health").permitAll()
|
.requestMatchers("/actuator/health").permitAll()
|
||||||
// Desbloqueo del panel: basta con tener sesión de familia.
|
// Desbloqueo del panel: basta con tener sesión de familia.
|
||||||
.requestMatchers(HttpMethod.POST, "/api/parents/unlock").hasRole("FAMILY")
|
.requestMatchers(HttpMethod.POST, "/api/parents/unlock").hasRole("FAMILY")
|
||||||
@@ -53,11 +56,15 @@ public class SecurityConfig {
|
|||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CORS permisivo para desarrollo (ng serve en otro puerto). En prod va tras Nginx. */
|
/**
|
||||||
|
* Orígenes CORS por configuración: "*" en desarrollo (ng serve en otro puerto);
|
||||||
|
* en producción, application-prod.yml fija el/los dominios públicos reales.
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource(
|
||||||
|
@Value("${recordalexia.cors.allowed-origins:*}") List<String> allowedOrigins) {
|
||||||
CorsConfiguration config = new CorsConfiguration();
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
config.setAllowedOriginPatterns(List.of("*"));
|
config.setAllowedOriginPatterns(allowedOrigins);
|
||||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||||
config.setAllowedHeaders(List.of("*"));
|
config.setAllowedHeaders(List.of("*"));
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package es.asepeyo.recordalexia.service;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envío de correo. Con SMTP configurado (spring.mail.host) usa JavaMailSender;
|
||||||
|
* sin él (desarrollo, CI) escribe el enlace en el log y sigue: el flujo de
|
||||||
|
* recuperación es probable de extremo a extremo sin infraestructura de correo.
|
||||||
|
* El envío es @Async para que la respuesta HTTP tarde lo mismo exista o no la
|
||||||
|
* cuenta (anti-enumeración por timing).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class MailService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MailService.class);
|
||||||
|
|
||||||
|
private final ObjectProvider<JavaMailSender> mailSender;
|
||||||
|
private final String from;
|
||||||
|
|
||||||
|
public MailService(ObjectProvider<JavaMailSender> mailSender,
|
||||||
|
@Value("${recordalexia.mail.from:no-reply@recordalexia.local}") String from) {
|
||||||
|
this.mailSender = mailSender;
|
||||||
|
this.from = from;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Email de recuperación de contraseña con el enlace de un solo uso. */
|
||||||
|
@Async
|
||||||
|
public void sendPasswordReset(String to, String link) {
|
||||||
|
JavaMailSender sender = mailSender.getIfAvailable();
|
||||||
|
if (sender == null) {
|
||||||
|
// Modo desarrollo: sin SMTP, el enlace queda en el log del backend.
|
||||||
|
log.info("[MAIL DEV] Restablecimiento de contraseña para {}: {}", to, link);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
|
message.setFrom(from);
|
||||||
|
message.setTo(to);
|
||||||
|
message.setSubject("recordaLexia · Restablecer contraseña / Restablir contrasenya");
|
||||||
|
message.setText("""
|
||||||
|
Hola,
|
||||||
|
|
||||||
|
Alguien (esperamos que tú) ha pedido restablecer la contraseña de recordaLexia.
|
||||||
|
El enlace caduca en 30 minutos y solo puede usarse una vez:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
Si no lo pediste, ignora este mensaje: tu contraseña no cambia.
|
||||||
|
|
||||||
|
— · —
|
||||||
|
|
||||||
|
Hola,
|
||||||
|
|
||||||
|
Algú (esperem que tu) ha demanat restablir la contrasenya de recordaLexia.
|
||||||
|
L'enllaç caduca en 30 minuts i només es pot fer servir una vegada:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
Si no ho vas demanar, ignora aquest missatge: la teva contrasenya no canvia.
|
||||||
|
""".formatted(link, link));
|
||||||
|
try {
|
||||||
|
sender.send(message);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// No propagamos: el emisor es asíncrono y la respuesta HTTP ya fue neutra.
|
||||||
|
log.error("No se pudo enviar el email de recuperación a {}", to, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import es.asepeyo.recordalexia.domain.DailyTask;
|
|||||||
import es.asepeyo.recordalexia.domain.Slot;
|
import es.asepeyo.recordalexia.domain.Slot;
|
||||||
import es.asepeyo.recordalexia.domain.SpecialEvent;
|
import es.asepeyo.recordalexia.domain.SpecialEvent;
|
||||||
import es.asepeyo.recordalexia.exception.NotFoundException;
|
import es.asepeyo.recordalexia.exception.NotFoundException;
|
||||||
|
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
||||||
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.repository.WeeklyTemplateEntryRepository;
|
||||||
import es.asepeyo.recordalexia.security.FamilyContext;
|
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;
|
||||||
@@ -15,11 +17,13 @@ import es.asepeyo.recordalexia.web.dto.TodayResponse.TaskView;
|
|||||||
import es.asepeyo.recordalexia.web.dto.TodayResponse.TimerInfo;
|
import es.asepeyo.recordalexia.web.dto.TodayResponse.TimerInfo;
|
||||||
import es.asepeyo.recordalexia.web.dto.TodayResponse.WalletInfo;
|
import es.asepeyo.recordalexia.web.dto.TodayResponse.WalletInfo;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.time.DayOfWeek;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -32,17 +36,23 @@ 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 WeeklyTemplateEntryRepository templateRepository;
|
||||||
|
private final AfternoonRoutineRepository routineRepository;
|
||||||
private final FamilyContext familyContext;
|
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,
|
||||||
|
WeeklyTemplateEntryRepository templateRepository,
|
||||||
|
AfternoonRoutineRepository routineRepository,
|
||||||
FamilyContext familyContext,
|
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.templateRepository = templateRepository;
|
||||||
|
this.routineRepository = routineRepository;
|
||||||
this.familyContext = familyContext;
|
this.familyContext = familyContext;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
@@ -76,7 +86,65 @@ public class TodayService {
|
|||||||
events,
|
events,
|
||||||
ProgressCalculator.from(tasks),
|
ProgressCalculator.from(tasks),
|
||||||
new WalletInfo(child.getCoins()),
|
new WalletInfo(child.getCoins()),
|
||||||
buildTimer(child));
|
buildTimer(child.getDepartureTime(), LocalTime.now(clock), morning.isEmpty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vista previa de MAÑANA, en solo lectura. Se calcula al vuelo desde las
|
||||||
|
* plantillas (horario semanal y rutinas) SIN materializar DailyTask: así los
|
||||||
|
* cambios que los padres hagan esta noche en el panel siguen entrando cuando
|
||||||
|
* el día se genere de verdad. Los ids son sintéticos y negativos para que
|
||||||
|
* nunca sean accionables (el toggle con id negativo no existe). Sin
|
||||||
|
* temporizador y con progreso a cero: nada que marcar todavía.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public TodayResponse getTomorrow(Long childId) {
|
||||||
|
Child child = childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId())
|
||||||
|
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
|
||||||
|
|
||||||
|
LocalDate tomorrow = LocalDate.now(clock).plusDays(1);
|
||||||
|
DayOfWeek dayOfWeek = tomorrow.getDayOfWeek();
|
||||||
|
AtomicLong syntheticId = new AtomicLong(-1);
|
||||||
|
|
||||||
|
List<TaskView> morning = templateRepository
|
||||||
|
.findByChildIdAndDayOfWeekOrderByOrderIndexAsc(childId, dayOfWeek).stream()
|
||||||
|
.map(entry -> {
|
||||||
|
var actividad = entry.getActivity();
|
||||||
|
int monedas = entry.getCoinsReward() != null ? entry.getCoinsReward()
|
||||||
|
: child.getCoinsPerTask();
|
||||||
|
return new TaskView(syntheticId.getAndDecrement(), actividad.getLabelEs(),
|
||||||
|
actividad.getLabelCa(), actividad.getIcon(), actividad.getColor(),
|
||||||
|
false, monedas, entry.getOrderIndex());
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<TaskView> afternoon = routineRepository
|
||||||
|
.findByChildIdAndDayOfWeekOrderByOrderIndexAsc(childId, dayOfWeek).stream()
|
||||||
|
.map(rutina -> {
|
||||||
|
var tarea = rutina.getRoutineTask();
|
||||||
|
int monedas = rutina.getCoinsReward() != null ? rutina.getCoinsReward()
|
||||||
|
: tarea.getCoinsReward() != null ? tarea.getCoinsReward()
|
||||||
|
: child.getCoinsPerTask();
|
||||||
|
return new TaskView(syntheticId.getAndDecrement(), tarea.getLabelEs(),
|
||||||
|
tarea.getLabelCa(), tarea.getIcon(), tarea.getColor(),
|
||||||
|
false, monedas, rutina.getOrderIndex());
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<EventView> events = specialEventRepository
|
||||||
|
.findByChildIdAndEventDate(childId, tomorrow).stream()
|
||||||
|
.map(this::toEventView)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
int total = morning.size() + afternoon.size();
|
||||||
|
return new TodayResponse(
|
||||||
|
toChildInfo(child),
|
||||||
|
morning,
|
||||||
|
afternoon,
|
||||||
|
events,
|
||||||
|
new TodayResponse.ProgressView(0, morning.size(), 0, afternoon.size(), 0, total),
|
||||||
|
new WalletInfo(child.getCoins()),
|
||||||
|
new TimerInfo(null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private TaskView toTaskView(DailyTask t) {
|
private TaskView toTaskView(DailyTask t) {
|
||||||
@@ -95,13 +163,20 @@ public class TodayService {
|
|||||||
c.isDyslexiaFont());
|
c.isDyslexiaFont());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Temporizador de salida: minutos que faltan hasta departureTime (>= 0). */
|
/**
|
||||||
private TimerInfo buildTimer(Child child) {
|
* Temporizador de salida. Solo tiene sentido si HOY hay cole (hay tareas de
|
||||||
LocalTime departure = child.getDepartureTime();
|
* mañana) y aún no ha pasado la hora: pasado ese momento desaparece, en vez de
|
||||||
if (departure == null) {
|
* quedarse clavado en "0 min" el resto del día (o en domingo). Con minutos a
|
||||||
|
* null el kiosko no lo pinta.
|
||||||
|
*/
|
||||||
|
static TimerInfo buildTimer(LocalTime departure, LocalTime now, boolean sinColeHoy) {
|
||||||
|
if (departure == null || sinColeHoy) {
|
||||||
return new TimerInfo(null, null);
|
return new TimerInfo(null, null);
|
||||||
}
|
}
|
||||||
long minutes = Duration.between(LocalTime.now(clock), departure).toMinutes();
|
long minutes = Duration.between(now, departure).toMinutes();
|
||||||
return new TimerInfo(departure.format(HHMM), (int) Math.max(0, minutes));
|
if (minutes < 0) {
|
||||||
|
return new TimerInfo(departure.format(HHMM), null);
|
||||||
|
}
|
||||||
|
return new TimerInfo(departure.format(HHMM), (int) minutes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,35 +3,40 @@ package es.asepeyo.recordalexia.web;
|
|||||||
import es.asepeyo.recordalexia.domain.Family;
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
import es.asepeyo.recordalexia.domain.Language;
|
import es.asepeyo.recordalexia.domain.Language;
|
||||||
import es.asepeyo.recordalexia.exception.NotFoundException;
|
import es.asepeyo.recordalexia.exception.NotFoundException;
|
||||||
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
import es.asepeyo.recordalexia.security.FamilyContext;
|
import es.asepeyo.recordalexia.security.FamilyContext;
|
||||||
import es.asepeyo.recordalexia.web.dto.AuthDtos.AccountPrefsRequest;
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.AccountPrefsRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePasswordRequest;
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePasswordRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePinRequest;
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePinRequest;
|
||||||
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.DeleteAccountRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse;
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
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;
|
||||||
|
|
||||||
/** Preferencias de la cuenta de familia y cambio de credenciales. */
|
/** Preferencias de la cuenta de familia, cambio de credenciales y borrado (RGPD). */
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/account")
|
@RequestMapping("/api/account")
|
||||||
public class AccountController {
|
public class AccountController {
|
||||||
|
|
||||||
private final FamilyContext familyContext;
|
private final FamilyContext familyContext;
|
||||||
private final FamilyRepository familyRepository;
|
private final FamilyRepository familyRepository;
|
||||||
|
private final ChildRepository childRepository;
|
||||||
private final PasswordEncoder encoder;
|
private final PasswordEncoder encoder;
|
||||||
|
|
||||||
public AccountController(FamilyContext familyContext, FamilyRepository familyRepository,
|
public AccountController(FamilyContext familyContext, FamilyRepository familyRepository,
|
||||||
PasswordEncoder encoder) {
|
ChildRepository childRepository, PasswordEncoder encoder) {
|
||||||
this.familyContext = familyContext;
|
this.familyContext = familyContext;
|
||||||
this.familyRepository = familyRepository;
|
this.familyRepository = familyRepository;
|
||||||
|
this.childRepository = childRepository;
|
||||||
this.encoder = encoder;
|
this.encoder = encoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +88,24 @@ public class AccountController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Borrado DEFINITIVO de la cuenta (RGPD: derecho de supresión). Exige la
|
||||||
|
* contraseña. Primero los niños (su cascada elimina tareas, monedas y canjes
|
||||||
|
* antes de que caigan los premios) y después la familia (cascada de catálogos,
|
||||||
|
* premios, sesiones y códigos de recuperación). No queda nada.
|
||||||
|
*/
|
||||||
|
@PostMapping("/delete")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Void> deleteAccount(@RequestBody DeleteAccountRequest req) {
|
||||||
|
Family f = current();
|
||||||
|
if (req.password() == null || !encoder.matches(req.password(), f.getPassHash())) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
}
|
||||||
|
childRepository.deleteAll(childRepository.findByFamilyIdOrderByIdAsc(f.getId()));
|
||||||
|
familyRepository.delete(f);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
private Family current() {
|
private Family current() {
|
||||||
return familyRepository.findById(familyContext.currentFamilyId())
|
return familyRepository.findById(familyContext.currentFamilyId())
|
||||||
.orElseThrow(() -> new NotFoundException("Familia no encontrada"));
|
.orElseThrow(() -> new NotFoundException("Familia no encontrada"));
|
||||||
|
|||||||
@@ -3,14 +3,20 @@ package es.asepeyo.recordalexia.web;
|
|||||||
import es.asepeyo.recordalexia.domain.Family;
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
import es.asepeyo.recordalexia.exception.NotFoundException;
|
import es.asepeyo.recordalexia.exception.NotFoundException;
|
||||||
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
|
import es.asepeyo.recordalexia.security.AccountRecoveryService;
|
||||||
import es.asepeyo.recordalexia.security.AuthService;
|
import es.asepeyo.recordalexia.security.AuthService;
|
||||||
import es.asepeyo.recordalexia.security.FamilyContext;
|
import es.asepeyo.recordalexia.security.FamilyContext;
|
||||||
|
import es.asepeyo.recordalexia.security.LoginAttemptService;
|
||||||
import es.asepeyo.recordalexia.security.SessionAuthFilter;
|
import es.asepeyo.recordalexia.security.SessionAuthFilter;
|
||||||
import es.asepeyo.recordalexia.security.SessionAuthService;
|
import es.asepeyo.recordalexia.security.SessionAuthService;
|
||||||
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.ForgotPasswordRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.AuthDtos.LoginRequest;
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.LoginRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse;
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse;
|
||||||
import es.asepeyo.recordalexia.web.dto.AuthDtos.RegisterRequest;
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.RegisterRequest;
|
||||||
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.ResetPasswordRequest;
|
||||||
import es.asepeyo.recordalexia.web.dto.AuthDtos.SessionResponse;
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.SessionResponse;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import java.util.Map;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -20,22 +26,31 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
|||||||
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;
|
||||||
|
|
||||||
/** Registro, acceso y sesión de familias. */
|
/** Registro, acceso, sesión y recuperación de contraseña de familias. */
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
|
/** Cuerpo fijo del forgot: idéntico exista o no la cuenta (anti-enumeración). */
|
||||||
|
private static final Map<String, String> FORGOT_BODY =
|
||||||
|
Map.of("message", "Si el email existe, recibirás instrucciones en unos minutos");
|
||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
private final SessionAuthService sessions;
|
private final SessionAuthService sessions;
|
||||||
private final FamilyContext familyContext;
|
private final FamilyContext familyContext;
|
||||||
private final FamilyRepository familyRepository;
|
private final FamilyRepository familyRepository;
|
||||||
|
private final LoginAttemptService attempts;
|
||||||
|
private final AccountRecoveryService recovery;
|
||||||
|
|
||||||
public AuthController(AuthService authService, SessionAuthService sessions,
|
public AuthController(AuthService authService, SessionAuthService sessions,
|
||||||
FamilyContext familyContext, FamilyRepository familyRepository) {
|
FamilyContext familyContext, FamilyRepository familyRepository,
|
||||||
|
LoginAttemptService attempts, AccountRecoveryService recovery) {
|
||||||
this.authService = authService;
|
this.authService = authService;
|
||||||
this.sessions = sessions;
|
this.sessions = sessions;
|
||||||
this.familyContext = familyContext;
|
this.familyContext = familyContext;
|
||||||
this.familyRepository = familyRepository;
|
this.familyRepository = familyRepository;
|
||||||
|
this.attempts = attempts;
|
||||||
|
this.recovery = recovery;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alta de familia + auto-login. */
|
/** Alta de familia + auto-login. */
|
||||||
@@ -48,16 +63,49 @@ public class AuthController {
|
|||||||
if (req.pin() == null || !req.pin().matches("\\d{4}")) {
|
if (req.pin() == null || !req.pin().matches("\\d{4}")) {
|
||||||
throw new IllegalArgumentException("El PIN debe ser de 4 dígitos");
|
throw new IllegalArgumentException("El PIN debe ser de 4 dígitos");
|
||||||
}
|
}
|
||||||
|
// RGPD: sin aceptación explícita de la política de privacidad no hay cuenta.
|
||||||
|
if (req.privacyAccepted() == null || !req.privacyAccepted()) {
|
||||||
|
throw new IllegalArgumentException("Debes aceptar la política de privacidad");
|
||||||
|
}
|
||||||
String session = authService.register(req.email(), req.password(), req.name(), req.pin());
|
String session = authService.register(req.email(), req.password(), req.name(), req.pin());
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(new SessionResponse(session));
|
return ResponseEntity.status(HttpStatus.CREATED).body(new SessionResponse(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Acceso con email + contraseña. 200 con sesión, 401 si no. */
|
/** Acceso con email + contraseña. 200 con sesión, 401 si no, 429 si fuerza bruta. */
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<SessionResponse> login(@RequestBody LoginRequest req) {
|
public ResponseEntity<SessionResponse> login(@RequestBody LoginRequest req,
|
||||||
|
HttpServletRequest http) {
|
||||||
|
// El freno se comprueba ANTES de tocar BCrypt: bloqueado = ni se evalúa.
|
||||||
|
String key = "login:" + normalize(req.email()) + ":" + clientIp(http);
|
||||||
|
attempts.checkAllowed(key);
|
||||||
return authService.login(req.email(), req.password())
|
return authService.login(req.email(), req.password())
|
||||||
.map(session -> ResponseEntity.ok(new SessionResponse(session)))
|
.map(session -> {
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
|
attempts.onSuccess(key);
|
||||||
|
return ResponseEntity.ok(new SessionResponse(session));
|
||||||
|
})
|
||||||
|
.orElseGet(() -> {
|
||||||
|
attempts.onFailure(key);
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Solicita restablecer contraseña. SIEMPRE 202 con el mismo cuerpo. */
|
||||||
|
@PostMapping("/forgot-password")
|
||||||
|
public ResponseEntity<Map<String, String>> forgotPassword(@RequestBody ForgotPasswordRequest req,
|
||||||
|
HttpServletRequest http) {
|
||||||
|
requireText(req.email(), "email");
|
||||||
|
attempts.checkAllowed("forgot:" + normalize(req.email()) + ":" + clientIp(http));
|
||||||
|
attempts.onFailure("forgot:" + normalize(req.email()) + ":" + clientIp(http));
|
||||||
|
recovery.requestReset(req.email());
|
||||||
|
return ResponseEntity.status(HttpStatus.ACCEPTED).body(FORGOT_BODY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restablece la contraseña con el código del email. 204 o 400 genérico. */
|
||||||
|
@PostMapping("/reset-password")
|
||||||
|
public ResponseEntity<Void> resetPassword(@RequestBody ResetPasswordRequest req) {
|
||||||
|
requireText(req.code(), "code");
|
||||||
|
recovery.resetPassword(req.code(), req.newPassword());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cierra la sesión del dispositivo. */
|
/** Cierra la sesión del dispositivo. */
|
||||||
@@ -83,4 +131,17 @@ public class AuthController {
|
|||||||
throw new IllegalArgumentException("Falta el campo " + field);
|
throw new IllegalArgumentException("Falta el campo " + field);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalize(String email) {
|
||||||
|
return email == null ? "" : email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IP real del cliente: primer valor de X-Forwarded-For (lo fija nginx) o remota. */
|
||||||
|
private String clientIp(HttpServletRequest request) {
|
||||||
|
String forwarded = request.getHeader("X-Forwarded-For");
|
||||||
|
if (forwarded != null && !forwarded.isBlank()) {
|
||||||
|
return forwarded.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ public class ChildController {
|
|||||||
return todayService.getToday(id);
|
return todayService.getToday(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vista previa de mañana (solo lectura): para preparar la mochila la noche antes. */
|
||||||
|
@GetMapping("/{id}/tomorrow")
|
||||||
|
public TodayResponse tomorrow(@PathVariable Long id) {
|
||||||
|
return todayService.getTomorrow(id);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/wallet")
|
@GetMapping("/{id}/wallet")
|
||||||
public WalletResponse wallet(@PathVariable Long id) {
|
public WalletResponse wallet(@PathVariable Long id) {
|
||||||
return walletService.getWallet(id);
|
return walletService.getWallet(id);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package es.asepeyo.recordalexia.web;
|
|||||||
|
|
||||||
import es.asepeyo.recordalexia.security.AuthService;
|
import es.asepeyo.recordalexia.security.AuthService;
|
||||||
import es.asepeyo.recordalexia.security.FamilyContext;
|
import es.asepeyo.recordalexia.security.FamilyContext;
|
||||||
|
import es.asepeyo.recordalexia.security.LoginAttemptService;
|
||||||
import es.asepeyo.recordalexia.security.SessionAuthFilter;
|
import es.asepeyo.recordalexia.security.SessionAuthFilter;
|
||||||
import es.asepeyo.recordalexia.security.SessionAuthService;
|
import es.asepeyo.recordalexia.security.SessionAuthService;
|
||||||
import es.asepeyo.recordalexia.web.dto.AuthDtos.UnlockRequest;
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.UnlockRequest;
|
||||||
@@ -21,22 +22,31 @@ public class PanelController {
|
|||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
private final SessionAuthService sessions;
|
private final SessionAuthService sessions;
|
||||||
private final FamilyContext familyContext;
|
private final FamilyContext familyContext;
|
||||||
|
private final LoginAttemptService attempts;
|
||||||
|
|
||||||
public PanelController(AuthService authService, SessionAuthService sessions,
|
public PanelController(AuthService authService, SessionAuthService sessions,
|
||||||
FamilyContext familyContext) {
|
FamilyContext familyContext, LoginAttemptService attempts) {
|
||||||
this.authService = authService;
|
this.authService = authService;
|
||||||
this.sessions = sessions;
|
this.sessions = sessions;
|
||||||
this.familyContext = familyContext;
|
this.familyContext = familyContext;
|
||||||
|
this.attempts = attempts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valida el PIN y desbloquea el panel para esta sesión. 204 si OK, 401 si no. */
|
/**
|
||||||
|
* Valida el PIN y desbloquea el panel. 204 si OK, 401 si no, 429 si fuerza
|
||||||
|
* bruta: con solo 10.000 combinaciones, el PIN necesita freno con backoff.
|
||||||
|
*/
|
||||||
@PostMapping("/unlock")
|
@PostMapping("/unlock")
|
||||||
public ResponseEntity<Void> unlock(@RequestBody UnlockRequest req,
|
public ResponseEntity<Void> unlock(@RequestBody UnlockRequest req,
|
||||||
@RequestHeader(SessionAuthFilter.HEADER) String handle) {
|
@RequestHeader(SessionAuthFilter.HEADER) String handle) {
|
||||||
|
String key = "unlock:" + handle;
|
||||||
|
attempts.checkAllowed(key);
|
||||||
if (authService.checkPin(familyContext.currentFamilyId(), req.pin())) {
|
if (authService.checkPin(familyContext.currentFamilyId(), req.pin())) {
|
||||||
|
attempts.onSuccess(key);
|
||||||
sessions.unlockPanel(handle);
|
sessions.unlockPanel(handle);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
attempts.onFailure(key);
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,9 +109,12 @@ public class ParentScheduleController {
|
|||||||
Child child = requireChild(req.childId());
|
Child child = requireChild(req.childId());
|
||||||
RoutineTask task = routineTaskRepository.findByIdAndFamilyId(req.routineTaskId(), fid())
|
RoutineTask task = routineTaskRepository.findByIdAndFamilyId(req.routineTaskId(), fid())
|
||||||
.orElseThrow(() -> new NotFoundException("No existe la rutina con id " + req.routineTaskId()));
|
.orElseThrow(() -> new NotFoundException("No existe la rutina con id " + req.routineTaskId()));
|
||||||
int orderIndex = req.orderIndex() != null ? req.orderIndex() : 0;
|
|
||||||
List<RoutineView> created = req.daysOfWeek().stream().map(day -> {
|
List<RoutineView> created = req.daysOfWeek().stream().map(day -> {
|
||||||
AfternoonRoutine routine = new AfternoonRoutine(child, DayOfWeek.valueOf(day), task, orderIndex);
|
DayOfWeek dayOfWeek = DayOfWeek.valueOf(day);
|
||||||
|
// Sin orden explícito, la rutina se añade al final DE CADA día destino.
|
||||||
|
int orderIndex = req.orderIndex() != null ? req.orderIndex()
|
||||||
|
: routineRepository.countByChildIdAndDayOfWeek(child.getId(), dayOfWeek);
|
||||||
|
AfternoonRoutine routine = new AfternoonRoutine(child, dayOfWeek, task, orderIndex);
|
||||||
routine.setCoinsReward(req.coinsReward());
|
routine.setCoinsReward(req.coinsReward());
|
||||||
return toRoutineView(routineRepository.save(routine));
|
return toRoutineView(routineRepository.save(routine));
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|||||||
@@ -6,7 +6,20 @@ public final class AuthDtos {
|
|||||||
private AuthDtos() {
|
private AuthDtos() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RegisterRequest(String email, String password, String name, String pin) {
|
/** privacyAccepted debe ser true: sin aceptar la política no se crea la cuenta. */
|
||||||
|
public record RegisterRequest(String email, String password, String name, String pin,
|
||||||
|
Boolean privacyAccepted) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ForgotPasswordRequest(String email) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** code = valor del enlace del email de recuperación (un solo uso, 30 min). */
|
||||||
|
public record ResetPasswordRequest(String code, String newPassword) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirmación con contraseña para el borrado definitivo de la cuenta. */
|
||||||
|
public record DeleteAccountRequest(String password) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record LoginRequest(String email, String password) {
|
public record LoginRequest(String email, String password) {
|
||||||
|
|||||||
19
backend/src/main/resources/application-prod.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Perfil de PRODUCCIÓN (instancia pública). Se activa con
|
||||||
|
# SPRING_PROFILES_ACTIVE=prod. Ningún secreto vive aquí: todo llega por variables
|
||||||
|
# de entorno (.env.prod del compose de producción).
|
||||||
|
#
|
||||||
|
# El SMTP se configura ÍNTEGRAMENTE por entorno gracias al binding relajado de
|
||||||
|
# Spring (no hace falta declararlo en YAML):
|
||||||
|
# SPRING_MAIL_HOST, SPRING_MAIL_PORT, SPRING_MAIL_USERNAME, SPRING_MAIL_PASSWORD,
|
||||||
|
# SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true,
|
||||||
|
# SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true
|
||||||
|
# Sin SPRING_MAIL_HOST, MailService cae a modo log (no envía).
|
||||||
|
recordalexia:
|
||||||
|
# Dominio público real: obligatorio (sin default a localhost).
|
||||||
|
public-base-url: ${PUBLIC_BASE_URL}
|
||||||
|
cors:
|
||||||
|
# Solo el dominio público; nada de "*" en producción.
|
||||||
|
allowed-origins: ${CORS_ALLOWED_ORIGINS}
|
||||||
|
mail:
|
||||||
|
from: ${MAIL_FROM:no-reply@recordalexia.local}
|
||||||
|
# seed.enabled NO se declara: fail-safe, la instancia pública jamás siembra demo.
|
||||||
@@ -38,6 +38,19 @@ spring:
|
|||||||
jackson:
|
jackson:
|
||||||
time-zone: Europe/Madrid
|
time-zone: Europe/Madrid
|
||||||
|
|
||||||
|
# --- Propiedades propias de recordaLexia ---
|
||||||
|
# seed.enabled NO se declara aquí a propósito (fail-safe): sin la propiedad, el
|
||||||
|
# seeder demo no corre. El docker-compose local y los tests la activan explícito.
|
||||||
|
recordalexia:
|
||||||
|
# Base pública para construir enlaces (email de recuperación). En producción la
|
||||||
|
# fija application-prod.yml con el dominio real.
|
||||||
|
public-base-url: ${PUBLIC_BASE_URL:http://localhost:8088}
|
||||||
|
# Orígenes CORS permitidos; "*" SOLO vale para desarrollo (ng serve).
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:*}
|
||||||
|
mail:
|
||||||
|
from: ${MAIL_FROM:no-reply@recordalexia.local}
|
||||||
|
|
||||||
# --- Servidor ---
|
# --- Servidor ---
|
||||||
server:
|
server:
|
||||||
port: ${SERVER_PORT:8080}
|
port: ${SERVER_PORT:8080}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# Los changesets de Liquibase de la Fase 2 (dominio) vivirán aquí.
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Borrado en cascada del catálogo de rutinas.
|
||||||
|
# Al eliminar una rutina del catálogo (routine_task) deben desaparecer sus
|
||||||
|
# asignaciones a días (afternoon_routine): es el modelo mental del padre
|
||||||
|
# ("quito Merendar" = desaparece de todos los días). Sin esto, el DELETE
|
||||||
|
# fallaba con violación de FK y el panel devolvía un error 500.
|
||||||
|
databaseChangeLog:
|
||||||
|
- changeSet:
|
||||||
|
id: 400-cascade-routine-assignment
|
||||||
|
author: recordalexia
|
||||||
|
changes:
|
||||||
|
- dropForeignKeyConstraint:
|
||||||
|
baseTableName: afternoon_routine
|
||||||
|
constraintName: fk_routine_assignment_task
|
||||||
|
- addForeignKeyConstraint:
|
||||||
|
baseTableName: afternoon_routine
|
||||||
|
baseColumnNames: routine_task_id
|
||||||
|
referencedTableName: routine_task
|
||||||
|
referencedColumnNames: id
|
||||||
|
constraintName: fk_routine_assignment_task
|
||||||
|
onDelete: CASCADE
|
||||||
|
rollback:
|
||||||
|
- dropForeignKeyConstraint:
|
||||||
|
baseTableName: afternoon_routine
|
||||||
|
constraintName: fk_routine_assignment_task
|
||||||
|
- addForeignKeyConstraint:
|
||||||
|
baseTableName: afternoon_routine
|
||||||
|
baseColumnNames: routine_task_id
|
||||||
|
referencedTableName: routine_task
|
||||||
|
referencedColumnNames: id
|
||||||
|
constraintName: fk_routine_assignment_task
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Recuperación de cuenta (restablecer contraseña) + consentimiento RGPD.
|
||||||
|
# El token NUNCA se guarda en claro: solo su SHA-256 (un volcado de BD no permite
|
||||||
|
# usarlo). privacy_accepted_at registra cuándo aceptó la familia la política de
|
||||||
|
# privacidad (nullable: las cuentas anteriores a esta versión no lo tienen).
|
||||||
|
databaseChangeLog:
|
||||||
|
- changeSet:
|
||||||
|
id: 500-create-reset-token
|
||||||
|
author: recordalexia
|
||||||
|
changes:
|
||||||
|
- createTable:
|
||||||
|
tableName: password_reset_token
|
||||||
|
columns:
|
||||||
|
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
|
||||||
|
- column: { name: family_id, type: BIGINT, constraints: { nullable: false } }
|
||||||
|
- column: { name: token_hash, type: VARCHAR(64), constraints: { nullable: false, unique: true } }
|
||||||
|
- column: { name: expires_at, type: TIMESTAMP, constraints: { nullable: false } }
|
||||||
|
- column: { name: used_at, type: TIMESTAMP }
|
||||||
|
- column: { name: created_at, type: TIMESTAMP, constraints: { nullable: false } }
|
||||||
|
- addForeignKeyConstraint:
|
||||||
|
baseTableName: password_reset_token
|
||||||
|
baseColumnNames: family_id
|
||||||
|
referencedTableName: family
|
||||||
|
referencedColumnNames: id
|
||||||
|
constraintName: fk_reset_token_family
|
||||||
|
onDelete: CASCADE
|
||||||
|
rollback:
|
||||||
|
- dropTable:
|
||||||
|
tableName: password_reset_token
|
||||||
|
|
||||||
|
- changeSet:
|
||||||
|
id: 501-add-privacy-accepted
|
||||||
|
author: recordalexia
|
||||||
|
changes:
|
||||||
|
- addColumn:
|
||||||
|
tableName: family
|
||||||
|
columns:
|
||||||
|
- column: { name: privacy_accepted_at, type: TIMESTAMP }
|
||||||
|
rollback:
|
||||||
|
- dropColumn:
|
||||||
|
tableName: family
|
||||||
|
columnName: privacy_accepted_at
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package es.asepeyo.recordalexia;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica el changelog completo de Liquibase contra un Postgres REAL (misma
|
||||||
|
* imagen que producción). Cubre lo que H2 no puede: tipado estricto de Postgres
|
||||||
|
* en la migración SQL, y que el esquema resultante casa con el mapeo JPA
|
||||||
|
* (ddl-auto=validate). Con el seeder activado comprueba además el estado final.
|
||||||
|
*
|
||||||
|
* Se desactiva sola si Docker no está disponible en la máquina.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Testcontainers(disabledWithoutDocker = true)
|
||||||
|
@TestPropertySource(properties = "recordalexia.seed.enabled=true")
|
||||||
|
class MigrationIT {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
@ServiceConnection
|
||||||
|
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
|
||||||
|
|
||||||
|
@Autowired private FamilyRepository familyRepository;
|
||||||
|
@Autowired private RoutineTaskRepository routineTaskRepository;
|
||||||
|
@Autowired private AfternoonRoutineRepository routineRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lasMigracionesAplicanYelSeederPueblaEnPostgresReal() {
|
||||||
|
// Si llegamos aquí, Liquibase aplicó 001-004 y Hibernate validó el esquema.
|
||||||
|
assertThat(familyRepository.findByEmailIgnoreCase("demo@recordalexia.local")).isPresent();
|
||||||
|
assertThat(routineTaskRepository.count()).isEqualTo(14);
|
||||||
|
assertThat(routineRepository.count()).isEqualTo(75); // 3 niños x 5 rutinas x 5 días
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,23 @@ package es.asepeyo.recordalexia.bootstrap;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.AfternoonRoutineRepository;
|
||||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
|
||||||
import es.asepeyo.recordalexia.repository.RewardRepository;
|
import es.asepeyo.recordalexia.repository.RewardRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
|
||||||
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
||||||
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.test.context.TestPropertySource;
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica que el sembrado del prototipo (familia demo + sus datos) se ejecuta
|
* Verifica que el sembrado del prototipo (familia demo + sus datos) se ejecuta
|
||||||
* correctamente. Usa una BD H2 propia y el seeder activado.
|
* correctamente. La demo se apoya en la MISMA provisión inicial que una familia
|
||||||
|
* real (InitialDatasetService) y añade encima sus extras. Usa una BD H2 propia.
|
||||||
*/
|
*/
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@TestPropertySource(properties = {
|
@TestPropertySource(properties = {
|
||||||
@@ -28,13 +33,37 @@ class DataSeederIT {
|
|||||||
@Autowired private RewardRepository rewardRepository;
|
@Autowired private RewardRepository rewardRepository;
|
||||||
@Autowired private ActivityRepository activityRepository;
|
@Autowired private ActivityRepository activityRepository;
|
||||||
@Autowired private WeeklyTemplateEntryRepository templateRepository;
|
@Autowired private WeeklyTemplateEntryRepository templateRepository;
|
||||||
|
@Autowired private MaterialItemRepository materialRepository;
|
||||||
|
@Autowired private RoutineTaskRepository routineTaskRepository;
|
||||||
|
@Autowired private AfternoonRoutineRepository routineRepository;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void siembraLaFamiliaDemoConSusDatos() {
|
void siembraLaFamiliaDemoConSusDatos() {
|
||||||
assertThat(familyRepository.findByEmailIgnoreCase("demo@recordalexia.local")).isPresent();
|
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(rewardRepository.count()).isEqualTo(6); // 6 premios
|
assertThat(rewardRepository.count()).isEqualTo(6); // 6 premios
|
||||||
assertThat(activityRepository.count()).isEqualTo(4); // 4 actividades
|
assertThat(activityRepository.count()).isEqualTo(15); // 4 genéricas + 11 de 4t A
|
||||||
assertThat(templateRepository.count()).isEqualTo(15); // 3 niños x 5 días
|
// Nora y Mía: 5 días x 1 asignatura; Leo: horario real 4t A (29 entradas).
|
||||||
|
assertThat(templateRepository.count()).isEqualTo(5 + 5 + 29);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaccional: navega la relación LAZY entrada→actividad, que requiere sesión.
|
||||||
|
@Test
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
void leoLlevaElHorarioRealDe4tA() {
|
||||||
|
Long leoId = childRepository.findAll().stream()
|
||||||
|
.filter(c -> c.getName().equals("Leo")).findFirst().orElseThrow().getId();
|
||||||
|
var lunes = templateRepository
|
||||||
|
.findByChildIdAndDayOfWeekOrderByOrderIndexAsc(leoId, java.time.DayOfWeek.MONDAY);
|
||||||
|
assertThat(lunes).extracting(e -> e.getActivity().getLabelEs())
|
||||||
|
.containsExactly("Matemáticas", "Medio", "Castellano", "Piscina", "Inglés", "Català");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void laDemoUsaLaProvisionInicialComun() {
|
||||||
|
assertThat(materialRepository.count()).isEqualTo(12); // 9 base + 3 extras demo
|
||||||
|
assertThat(routineTaskRepository.count()).isEqualTo(14); // catálogo común completo
|
||||||
|
// 3 niños (6, 7 y 9 años) -> tramo base: 5 rutinas x 5 días laborables cada uno.
|
||||||
|
assertThat(routineRepository.count()).isEqualTo(75);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package es.asepeyo.recordalexia.repository;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
|
||||||
|
import es.asepeyo.recordalexia.domain.Child;
|
||||||
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
|
import es.asepeyo.recordalexia.domain.RoutineTask;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Borrar una rutina del catálogo arrastra sus asignaciones a días (ON DELETE
|
||||||
|
* CASCADE del changeset 004). Es el modelo mental del padre: "quito Merendar"
|
||||||
|
* significa que desaparece de todos los días, no un error 500.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
class RoutineTaskCascadeTest {
|
||||||
|
|
||||||
|
@Autowired private FamilyRepository familyRepository;
|
||||||
|
@Autowired private ChildRepository childRepository;
|
||||||
|
@Autowired private RoutineTaskRepository routineTaskRepository;
|
||||||
|
@Autowired private AfternoonRoutineRepository routineRepository;
|
||||||
|
@PersistenceContext private EntityManager entityManager;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void borrarRutinaDelCatalogoArrastraSusAsignaciones() {
|
||||||
|
Family family = familyRepository.save(new Family("cascade@x.com", "h", "C", "p"));
|
||||||
|
Child child = new Child();
|
||||||
|
child.setFamily(family);
|
||||||
|
child.setName("Test");
|
||||||
|
child.setMascot("🦊");
|
||||||
|
child.setAccentColor("#F2A65A");
|
||||||
|
child.setAge(8);
|
||||||
|
child = childRepository.save(child);
|
||||||
|
|
||||||
|
RoutineTask merendar = new RoutineTask("Merendar", "Berenar", "🥪", "#F4C95D");
|
||||||
|
merendar.setFamily(family);
|
||||||
|
merendar = routineTaskRepository.save(merendar);
|
||||||
|
routineRepository.save(new AfternoonRoutine(child, DayOfWeek.MONDAY, merendar, 0));
|
||||||
|
routineRepository.save(new AfternoonRoutine(child, DayOfWeek.TUESDAY, merendar, 0));
|
||||||
|
routineRepository.flush();
|
||||||
|
// Contexto limpio, como en una petición real: el cascade lo resuelve la BD,
|
||||||
|
// no la sesión de Hibernate (que si tuviera las asignaciones cargadas se
|
||||||
|
// quejaría de referencias a una entidad borrada).
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
routineTaskRepository.deleteById(merendar.getId());
|
||||||
|
routineTaskRepository.flush();
|
||||||
|
|
||||||
|
assertThat(routineRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(child.getId()))
|
||||||
|
.isEmpty();
|
||||||
|
assertThat(routineTaskRepository.findById(merendar.getId())).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package es.asepeyo.recordalexia.security;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import es.asepeyo.recordalexia.exception.TooManyAttemptsException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reglas del freno de fuerza bruta, con reloj controlado (sin Spring ni esperas
|
||||||
|
* reales): umbral exacto, backoff creciente, perdón por éxito y caducidad del bloqueo.
|
||||||
|
*/
|
||||||
|
class LoginAttemptServiceTest {
|
||||||
|
|
||||||
|
/** Reloj mutable: los tests avanzan el tiempo a voluntad. */
|
||||||
|
private static final class MutableClock extends Clock {
|
||||||
|
private Instant now = Instant.parse("2026-07-13T10:00:00Z");
|
||||||
|
|
||||||
|
void advance(Duration d) {
|
||||||
|
now = now.plus(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Instant instant() {
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ZoneId getZone() {
|
||||||
|
return ZoneId.of("Europe/Madrid");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Clock withZone(ZoneId zone) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final MutableClock clock = new MutableClock();
|
||||||
|
private final LoginAttemptService service = new LoginAttemptService(clock);
|
||||||
|
|
||||||
|
private void fail(String key, int times) {
|
||||||
|
for (int i = 0; i < times; i++) {
|
||||||
|
service.onFailure(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void porDebajoDelUmbralNoBloquea() {
|
||||||
|
fail("login:a@x.com:1.2.3.4", 4);
|
||||||
|
assertThatCode(() -> service.checkAllowed("login:a@x.com:1.2.3.4")).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alQuintoFalloBloqueaConRetryAfter() {
|
||||||
|
fail("k", 5);
|
||||||
|
assertThatThrownBy(() -> service.checkAllowed("k"))
|
||||||
|
.isInstanceOf(TooManyAttemptsException.class)
|
||||||
|
.satisfies(ex -> {
|
||||||
|
long s = ((TooManyAttemptsException) ex).getRetryAfterSeconds();
|
||||||
|
org.assertj.core.api.Assertions.assertThat(s).isBetween(1L, 30L);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void elBloqueoCaducaYlosSucesivosDuplican() {
|
||||||
|
fail("k", 5); // 1er bloqueo: 30s
|
||||||
|
clock.advance(Duration.ofSeconds(31));
|
||||||
|
assertThatCode(() -> service.checkAllowed("k")).doesNotThrowAnyException();
|
||||||
|
|
||||||
|
fail("k", 5); // 2º bloqueo: 60s
|
||||||
|
clock.advance(Duration.ofSeconds(45));
|
||||||
|
assertThatThrownBy(() -> service.checkAllowed("k"))
|
||||||
|
.isInstanceOf(TooManyAttemptsException.class); // 45s < 60s: sigue bloqueado
|
||||||
|
clock.advance(Duration.ofSeconds(20));
|
||||||
|
assertThatCode(() -> service.checkAllowed("k")).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unExitoPerdonaElHistorial() {
|
||||||
|
fail("k", 4);
|
||||||
|
service.onSuccess("k");
|
||||||
|
fail("k", 4); // sin el perdón, esto sería el 8º fallo y estaría bloqueado
|
||||||
|
assertThatCode(() -> service.checkAllowed("k")).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void losFallosViejosNoCuentan() {
|
||||||
|
fail("k", 4);
|
||||||
|
clock.advance(Duration.ofMinutes(16)); // fuera de la ventana de 15 min
|
||||||
|
fail("k", 4);
|
||||||
|
assertThatCode(() -> service.checkAllowed("k")).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void laLimpiezaPurgaEntradasFrias() {
|
||||||
|
fail("k", 2);
|
||||||
|
clock.advance(Duration.ofHours(3));
|
||||||
|
service.cleanup();
|
||||||
|
// Tras la purga, la clave empieza de cero: 4 fallos no bloquean.
|
||||||
|
fail("k", 4);
|
||||||
|
assertThatCode(() -> service.checkAllowed("k")).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package es.asepeyo.recordalexia.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import es.asepeyo.recordalexia.web.dto.TodayResponse.TimerInfo;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reglas del temporizador "SALIMOS EN X min": solo aparece si hoy hay cole y la
|
||||||
|
* hora de salida no ha pasado; nunca se queda clavado en "0 min" el resto del día.
|
||||||
|
*/
|
||||||
|
class TodayServiceTimerTest {
|
||||||
|
|
||||||
|
private static final LocalTime SALIDA = LocalTime.of(8, 30);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sinHoraDeSalidaNoHayTemporizador() {
|
||||||
|
TimerInfo timer = TodayService.buildTimer(null, LocalTime.of(8, 0), false);
|
||||||
|
assertThat(timer.departureTime()).isNull();
|
||||||
|
assertThat(timer.minutesUntilDeparture()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sinColeHoyNoHayTemporizador() {
|
||||||
|
TimerInfo timer = TodayService.buildTimer(SALIDA, LocalTime.of(8, 0), true);
|
||||||
|
assertThat(timer.minutesUntilDeparture()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void antesDeLaHoraMuestraLosMinutosQueFaltan() {
|
||||||
|
TimerInfo timer = TodayService.buildTimer(SALIDA, LocalTime.of(8, 10), false);
|
||||||
|
assertThat(timer.departureTime()).isEqualTo("08:30");
|
||||||
|
assertThat(timer.minutesUntilDeparture()).isEqualTo(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void justoALaHoraMuestraCero() {
|
||||||
|
TimerInfo timer = TodayService.buildTimer(SALIDA, LocalTime.of(8, 30), false);
|
||||||
|
assertThat(timer.minutesUntilDeparture()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pasadaLaHoraDesaparece() {
|
||||||
|
TimerInfo timer = TodayService.buildTimer(SALIDA, LocalTime.of(10, 0), false);
|
||||||
|
assertThat(timer.minutesUntilDeparture()).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package es.asepeyo.recordalexia.web;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
|
||||||
|
import es.asepeyo.recordalexia.domain.Child;
|
||||||
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
|
import es.asepeyo.recordalexia.domain.FamilySession;
|
||||||
|
import es.asepeyo.recordalexia.domain.RecoveryCode;
|
||||||
|
import es.asepeyo.recordalexia.domain.RoutineTask;
|
||||||
|
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilySessionRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.RecoveryCodeRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
|
||||||
|
import es.asepeyo.recordalexia.service.DayGenerationService;
|
||||||
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.DeleteAccountRequest;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import es.asepeyo.recordalexia.security.SessionAuthFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RGPD, derecho de supresión: borrar la cuenta elimina a la familia y TODO lo suyo
|
||||||
|
* (niños, tareas generadas, rutinas y catálogo, premios, sesiones y códigos de
|
||||||
|
* recuperación). Nada queda accesible ni almacenado.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
|
class AccountDeletionIT {
|
||||||
|
|
||||||
|
@Autowired private MockMvc mockMvc;
|
||||||
|
@Autowired private ObjectMapper objectMapper;
|
||||||
|
@Autowired private FamilyRepository familyRepository;
|
||||||
|
@Autowired private ChildRepository childRepository;
|
||||||
|
@Autowired private RoutineTaskRepository routineTaskRepository;
|
||||||
|
@Autowired private AfternoonRoutineRepository routineRepository;
|
||||||
|
@Autowired private DailyTaskRepository dailyTaskRepository;
|
||||||
|
@Autowired private FamilySessionRepository sessionRepository;
|
||||||
|
@Autowired private RecoveryCodeRepository codeRepository;
|
||||||
|
@Autowired private DayGenerationService dayGenerationService;
|
||||||
|
@Autowired private PasswordEncoder encoder;
|
||||||
|
@PersistenceContext private EntityManager entityManager;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void borrarLaCuentaNoDejaNiRastro() throws Exception {
|
||||||
|
// Una familia con vida real: niño, rutinas, día generado, premio, sesión y código.
|
||||||
|
Family f = familyRepository.save(new Family("adios@x.com", encoder.encode("secret123"),
|
||||||
|
"F", encoder.encode("1234")));
|
||||||
|
String handle = "sesion-adios";
|
||||||
|
sessionRepository.save(new FamilySession(handle, f, Instant.now().plus(1, ChronoUnit.DAYS)));
|
||||||
|
|
||||||
|
Child child = new Child();
|
||||||
|
child.setFamily(f);
|
||||||
|
child.setName("Test");
|
||||||
|
child.setMascot("🦊");
|
||||||
|
child.setAccentColor("#F2A65A");
|
||||||
|
child.setAge(8);
|
||||||
|
child = childRepository.save(child);
|
||||||
|
Long childId = child.getId();
|
||||||
|
|
||||||
|
RoutineTask rutina = new RoutineTask("Merendar", "Berenar", "🥪", "#F4C95D");
|
||||||
|
rutina.setFamily(f);
|
||||||
|
rutina = routineTaskRepository.save(rutina);
|
||||||
|
routineRepository.save(new AfternoonRoutine(child, LocalDate.now().getDayOfWeek(), rutina, 0));
|
||||||
|
dayGenerationService.generateIfAbsent(childId, LocalDate.now());
|
||||||
|
|
||||||
|
codeRepository.save(new RecoveryCode(f, "hash-prueba", Instant.now().plus(30, ChronoUnit.MINUTES),
|
||||||
|
Instant.now()));
|
||||||
|
|
||||||
|
// Contexto limpio antes del borrado: las cascadas las resuelve la BD.
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/account/delete")
|
||||||
|
.header(SessionAuthFilter.HEADER, handle)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new DeleteAccountRequest("secret123"))))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
// En producción el commit de la petición hace el flush; aquí compartimos
|
||||||
|
// transacción con el test, así que lo forzamos antes de limpiar el contexto.
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
// Ni familia, ni niños, ni tareas, ni catálogo, ni sesiones, ni códigos.
|
||||||
|
Long familyId = f.getId();
|
||||||
|
assertThat(familyRepository.findByEmailIgnoreCase("adios@x.com")).isEmpty();
|
||||||
|
assertThat(childRepository.findByFamilyIdOrderByIdAsc(familyId)).isEmpty();
|
||||||
|
assertThat(routineTaskRepository.findByFamilyId(familyId)).isEmpty();
|
||||||
|
assertThat(routineRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId)).isEmpty();
|
||||||
|
assertThat(dailyTaskRepository.existsByChildIdAndTaskDate(childId, LocalDate.now())).isFalse();
|
||||||
|
assertThat(sessionRepository.findByHandle(handle)).isEmpty();
|
||||||
|
assertThat(codeRepository.count()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sinLaContrasenaCorrectaNoSeBorraNada() throws Exception {
|
||||||
|
Family f = familyRepository.save(new Family("segura@x.com", encoder.encode("secret123"),
|
||||||
|
"F", encoder.encode("1234")));
|
||||||
|
String handle = "sesion-segura";
|
||||||
|
sessionRepository.save(new FamilySession(handle, f, Instant.now().plus(1, ChronoUnit.DAYS)));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/account/delete")
|
||||||
|
.header(SessionAuthFilter.HEADER, handle)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new DeleteAccountRequest("incorrecta"))))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
assertThat(familyRepository.existsByEmailIgnoreCase("segura@x.com")).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package es.asepeyo.recordalexia.web;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
|
import es.asepeyo.recordalexia.domain.FamilySession;
|
||||||
|
import es.asepeyo.recordalexia.domain.RecoveryCode;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilySessionRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.RecoveryCodeRepository;
|
||||||
|
import es.asepeyo.recordalexia.service.MailService;
|
||||||
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.ForgotPasswordRequest;
|
||||||
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.LoginRequest;
|
||||||
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.ResetPasswordRequest;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
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.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flujo completo de recuperación de contraseña. El MailService va mockeado: el
|
||||||
|
* enlace (con el código en claro) se captura del argumento, como haría el email.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
|
class AccountRecoveryIT {
|
||||||
|
|
||||||
|
@Autowired private MockMvc mockMvc;
|
||||||
|
@Autowired private ObjectMapper objectMapper;
|
||||||
|
@Autowired private FamilyRepository familyRepository;
|
||||||
|
@Autowired private RecoveryCodeRepository codeRepository;
|
||||||
|
@Autowired private FamilySessionRepository sessionRepository;
|
||||||
|
@Autowired private PasswordEncoder encoder;
|
||||||
|
@MockitoBean private MailService mailService;
|
||||||
|
|
||||||
|
private Family family(String email) {
|
||||||
|
return familyRepository.save(new Family(email, encoder.encode("vieja123"), "F",
|
||||||
|
encoder.encode("1234")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String forgot(String email) throws Exception {
|
||||||
|
return mockMvc.perform(post("/api/auth/forgot-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new ForgotPasswordRequest(email))))
|
||||||
|
.andExpect(status().isAccepted())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrae el código en claro del enlace capturado del "email". */
|
||||||
|
private String capturedCode() {
|
||||||
|
ArgumentCaptor<String> link = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(mailService).sendPasswordReset(anyString(), link.capture());
|
||||||
|
return link.getValue().substring(link.getValue().indexOf("code=") + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActionsStatus reset(String code, String newPassword) throws Exception {
|
||||||
|
int status = mockMvc.perform(post("/api/auth/reset-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new ResetPasswordRequest(code, newPassword))))
|
||||||
|
.andReturn().getResponse().getStatus();
|
||||||
|
return new ResultActionsStatus(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ResultActionsStatus(int status) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emailExistenteEmiteCodigoYemailInexistenteRespondeIgual() throws Exception {
|
||||||
|
family("existe@x.com");
|
||||||
|
|
||||||
|
String bodyExiste = forgot("existe@x.com");
|
||||||
|
String bodyNoExiste = forgot("nadie@x.com");
|
||||||
|
|
||||||
|
// Anti-enumeración: cuerpos idénticos; y solo el existente generó código/email.
|
||||||
|
assertThat(bodyExiste).isEqualTo(bodyNoExiste);
|
||||||
|
verify(mailService).sendPasswordReset(eq("existe@x.com"), anyString());
|
||||||
|
verify(mailService, never()).sendPasswordReset(eq("nadie@x.com"), anyString());
|
||||||
|
assertThat(codeRepository.count()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void elResetCambiaLaContrasenaYcierraLasSesiones() throws Exception {
|
||||||
|
Family f = family("reset@x.com");
|
||||||
|
sessionRepository.save(new FamilySession("sesion-vieja", f,
|
||||||
|
Instant.now().plus(1, ChronoUnit.DAYS)));
|
||||||
|
|
||||||
|
forgot("reset@x.com");
|
||||||
|
assertThat(reset(capturedCode(), "nueva456").status()).isEqualTo(204);
|
||||||
|
|
||||||
|
// La contraseña nueva entra; la sesión antigua ha muerto.
|
||||||
|
mockMvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new LoginRequest("reset@x.com", "nueva456"))))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
assertThat(sessionRepository.findByHandle("sesion-vieja")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unCodigoNoSePuedeUsarDosVeces() throws Exception {
|
||||||
|
family("unavez@x.com");
|
||||||
|
forgot("unavez@x.com");
|
||||||
|
String code = capturedCode();
|
||||||
|
|
||||||
|
assertThat(reset(code, "nueva456").status()).isEqualTo(204);
|
||||||
|
assertThat(reset(code, "otra789").status()).isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unCodigoCaducadoSeRechaza() throws Exception {
|
||||||
|
family("tarde@x.com");
|
||||||
|
forgot("tarde@x.com");
|
||||||
|
String code = capturedCode();
|
||||||
|
// Forzar la caducidad en BD (más simple y directo que manipular el reloj).
|
||||||
|
RecoveryCode stored = codeRepository.findAll().get(0);
|
||||||
|
stored.setExpiresAt(Instant.now().minus(1, ChronoUnit.MINUTES));
|
||||||
|
codeRepository.saveAndFlush(stored);
|
||||||
|
|
||||||
|
assertThat(reset(code, "nueva456").status()).isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pedirUnoNuevoInvalidaElAnterior() throws Exception {
|
||||||
|
family("dosveces@x.com");
|
||||||
|
forgot("dosveces@x.com");
|
||||||
|
String primero = capturedCode();
|
||||||
|
forgot("dosveces@x.com");
|
||||||
|
|
||||||
|
// Solo el más reciente vale: el primero ya no existe en BD.
|
||||||
|
assertThat(reset(primero, "nueva456").status()).isEqualTo(400);
|
||||||
|
assertThat(codeRepository.count()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,12 +37,30 @@ class AuthIT {
|
|||||||
String body = mockMvc.perform(post("/api/auth/register")
|
String body = mockMvc.perform(post("/api/auth/register")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(
|
.content(objectMapper.writeValueAsString(
|
||||||
new RegisterRequest(email, "secret123", "Familia", "1234"))))
|
new RegisterRequest(email, "secret123", "Familia", "1234", true))))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andReturn().getResponse().getContentAsString();
|
.andReturn().getResponse().getContentAsString();
|
||||||
return objectMapper.readTree(body).path("session").asText();
|
return objectMapper.readTree(body).path("session").asText();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sinAceptarLaPoliticaNoHayRegistro() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/auth/register")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(
|
||||||
|
new RegisterRequest("nop@x.com", "secret123", "Familia", "1234", false))))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
org.assertj.core.api.Assertions.assertThat(
|
||||||
|
familyRepository.existsByEmailIgnoreCase("nop@x.com")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void elSeederNoSiembraSinLaPropiedad() {
|
||||||
|
// Contexto por defecto (sin recordalexia.seed.enabled): fail-safe, sin demo.
|
||||||
|
org.assertj.core.api.Assertions.assertThat(
|
||||||
|
familyRepository.findByEmailIgnoreCase("demo@recordalexia.local")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void registroAbreSesionYmeDevuelveLaFamilia() throws Exception {
|
void registroAbreSesionYmeDevuelveLaFamilia() throws Exception {
|
||||||
String session = register("uno@x.com");
|
String session = register("uno@x.com");
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package es.asepeyo.recordalexia.web;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import es.asepeyo.recordalexia.domain.Family;
|
||||||
|
import es.asepeyo.recordalexia.domain.FamilySession;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
|
import es.asepeyo.recordalexia.repository.FamilySessionRepository;
|
||||||
|
import es.asepeyo.recordalexia.security.SessionAuthFilter;
|
||||||
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.LoginRequest;
|
||||||
|
import es.asepeyo.recordalexia.web.dto.AuthDtos.UnlockRequest;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freno de fuerza bruta en los puntos sensibles: login (contraseña) y unlock
|
||||||
|
* (PIN de 4 dígitos). Al 6º fallo consecutivo responde 429 con Retry-After.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
|
class RateLimitIT {
|
||||||
|
|
||||||
|
@Autowired private MockMvc mockMvc;
|
||||||
|
@Autowired private ObjectMapper objectMapper;
|
||||||
|
@Autowired private FamilyRepository familyRepository;
|
||||||
|
@Autowired private FamilySessionRepository sessionRepository;
|
||||||
|
@Autowired private PasswordEncoder encoder;
|
||||||
|
|
||||||
|
private Family family(String email) {
|
||||||
|
return familyRepository.save(new Family(email, encoder.encode("buena123"), "F",
|
||||||
|
encoder.encode("1234")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loginFallido(String email) throws Exception {
|
||||||
|
mockMvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new LoginRequest(email, "malísima"))))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void elSextoLoginFallidoDevuelve429ConRetryAfter() throws Exception {
|
||||||
|
family("bruto@x.com");
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
loginFallido("bruto@x.com");
|
||||||
|
}
|
||||||
|
mockMvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new LoginRequest("bruto@x.com", "malísima"))))
|
||||||
|
.andExpect(status().isTooManyRequests())
|
||||||
|
.andExpect(header().exists("Retry-After"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unDespisteYlaContrasenaBuenaEntranSinCastigo() throws Exception {
|
||||||
|
family("despiste@x.com");
|
||||||
|
loginFallido("despiste@x.com");
|
||||||
|
loginFallido("despiste@x.com");
|
||||||
|
mockMvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new LoginRequest("despiste@x.com", "buena123"))))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void elSextoPinFallidoDevuelve429() throws Exception {
|
||||||
|
Family f = family("pin@x.com");
|
||||||
|
String handle = "sesion-pin-bruta";
|
||||||
|
sessionRepository.save(new FamilySession(handle, f, Instant.now().plus(1, ChronoUnit.DAYS)));
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
mockMvc.perform(post("/api/parents/unlock")
|
||||||
|
.header(SessionAuthFilter.HEADER, handle)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new UnlockRequest("0000"))))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
mockMvc.perform(post("/api/parents/unlock")
|
||||||
|
.header(SessionAuthFilter.HEADER, handle)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new UnlockRequest("0000"))))
|
||||||
|
.andExpect(status().isTooManyRequests())
|
||||||
|
.andExpect(header().exists("Retry-After"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void elPinCorrectoTrasUnFalloDesbloquea() throws Exception {
|
||||||
|
Family f = family("pinok@x.com");
|
||||||
|
String handle = "sesion-pin-ok";
|
||||||
|
sessionRepository.save(new FamilySession(handle, f, Instant.now().plus(1, ChronoUnit.DAYS)));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/parents/unlock")
|
||||||
|
.header(SessionAuthFilter.HEADER, handle)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new UnlockRequest("9999"))))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
mockMvc.perform(post("/api/parents/unlock")
|
||||||
|
.header(SessionAuthFilter.HEADER, handle)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(new UnlockRequest("1234"))))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ 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.DailyTaskRepository;
|
||||||
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||||
import es.asepeyo.recordalexia.repository.FamilySessionRepository;
|
import es.asepeyo.recordalexia.repository.FamilySessionRepository;
|
||||||
import es.asepeyo.recordalexia.repository.RewardRepository;
|
import es.asepeyo.recordalexia.repository.RewardRepository;
|
||||||
@@ -52,6 +53,7 @@ class TodayFlowIT {
|
|||||||
@Autowired private RewardRepository rewardRepository;
|
@Autowired private RewardRepository rewardRepository;
|
||||||
@Autowired private FamilyRepository familyRepository;
|
@Autowired private FamilyRepository familyRepository;
|
||||||
@Autowired private FamilySessionRepository sessionRepository;
|
@Autowired private FamilySessionRepository sessionRepository;
|
||||||
|
@Autowired private DailyTaskRepository dailyTaskRepository;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void flujoVerDiaMarcarTareaYcanjear() throws Exception {
|
void flujoVerDiaMarcarTareaYcanjear() throws Exception {
|
||||||
@@ -99,6 +101,39 @@ class TodayFlowIT {
|
|||||||
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(60);
|
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void laVistaDeMananaEsSoloLecturaYnoMaterializaTareas() throws Exception {
|
||||||
|
Family family = familyRepository.save(new Family("tmrw@x.com", "h", "Tmrw", "p"));
|
||||||
|
String session = "tomorrow-session";
|
||||||
|
sessionRepository.save(new FamilySession(session, family, Instant.now().plus(1, ChronoUnit.DAYS)));
|
||||||
|
|
||||||
|
LocalDate manana = LocalDate.now().plusDays(1);
|
||||||
|
Child nino = childRepository.save(nuevoNino(family));
|
||||||
|
Activity lengua = new Activity("Lengua", "Llengua", "📖", "#F2A65A");
|
||||||
|
lengua.setFamily(family);
|
||||||
|
lengua = activityRepository.save(lengua);
|
||||||
|
templateRepository.save(new WeeklyTemplateEntry(nino, manana.getDayOfWeek(), lengua, 0));
|
||||||
|
RoutineTask merendar = new RoutineTask("Merendar", "Berenar", "🥪", "#F4C95D");
|
||||||
|
merendar.setFamily(family);
|
||||||
|
merendar = routineTaskRepository.save(merendar);
|
||||||
|
routineRepository.save(new AfternoonRoutine(nino, manana.getDayOfWeek(), merendar, 0));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/children/{id}/tomorrow", nino.getId())
|
||||||
|
.header(SessionAuthFilter.HEADER, session))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.morning.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.morning[0].labelEs").value("Lengua"))
|
||||||
|
.andExpect(jsonPath("$.morning[0].done").value(false))
|
||||||
|
// Ids sintéticos negativos: la vista previa no es accionable.
|
||||||
|
.andExpect(jsonPath("$.morning[0].id").value(-1))
|
||||||
|
.andExpect(jsonPath("$.afternoon.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.timer.minutesUntilDeparture").doesNotExist());
|
||||||
|
|
||||||
|
// Lo esencial: mirar mañana NO genera las tareas de mañana (el padre aún
|
||||||
|
// puede cambiar el plan esta noche y entrará al generarse de verdad).
|
||||||
|
assertThat(dailyTaskRepository.existsByChildIdAndTaskDate(nino.getId(), manana)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
private Child nuevoNino(Family family) {
|
private Child nuevoNino(Family family) {
|
||||||
Child c = new Child();
|
Child c = new Child();
|
||||||
c.setFamily(family);
|
c.setFamily(family);
|
||||||
|
|||||||
52
deploy/backup.sh
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Backup diario de la base de datos de recordaLexia (producción).
|
||||||
|
#
|
||||||
|
# Qué hace:
|
||||||
|
# 1. pg_dump comprimido del Postgres del compose de producción.
|
||||||
|
# 2. Retención local: conserva los últimos BACKUP_KEEP (por defecto 14).
|
||||||
|
# 3. Copia FUERA de la VM con rclone si RCLONE_REMOTE está definido
|
||||||
|
# (ej. "b2:recordalexia-backups" o "gdrive:backups/recordalexia").
|
||||||
|
#
|
||||||
|
# Instalación (cron del usuario en la VM, 03:30 hora de Madrid):
|
||||||
|
# crontab -e
|
||||||
|
# 30 3 * * * /ruta/al/repo/deploy/backup.sh >> $HOME/recordalexia-backup.log 2>&1
|
||||||
|
#
|
||||||
|
# RESTAURAR: ver docs/operacion-saas.md (procedimiento ensayado paso a paso).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Configuración (sobreescribible por variables de entorno) ---
|
||||||
|
REPO_DIR="${REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-$HOME/backups/recordalexia}"
|
||||||
|
BACKUP_KEEP="${BACKUP_KEEP:-14}"
|
||||||
|
RCLONE_REMOTE="${RCLONE_REMOTE:-}"
|
||||||
|
COMPOSE=(docker compose -f "$REPO_DIR/docker-compose.prod.yml" --env-file "$REPO_DIR/.env.prod")
|
||||||
|
|
||||||
|
# Credenciales de la BD: las mismas del compose (nunca en este script).
|
||||||
|
source "$REPO_DIR/.env.prod"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
FILE="$BACKUP_DIR/recordalexia-$STAMP.sql.gz"
|
||||||
|
|
||||||
|
echo "[$(date -Is)] Iniciando backup -> $FILE"
|
||||||
|
"${COMPOSE[@]}" exec -T postgres pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$FILE"
|
||||||
|
|
||||||
|
# El backup vacío o diminuto es un fallo, no un backup.
|
||||||
|
SIZE=$(stat -c%s "$FILE" 2>/dev/null || stat -f%z "$FILE")
|
||||||
|
if [ "$SIZE" -lt 1024 ]; then
|
||||||
|
echo "[$(date -Is)] ERROR: backup sospechosamente pequeño ($SIZE bytes)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[$(date -Is)] Backup OK ($SIZE bytes)"
|
||||||
|
|
||||||
|
# Retención local: borrar los que sobren, del más antiguo al más nuevo.
|
||||||
|
ls -1t "$BACKUP_DIR"/recordalexia-*.sql.gz | tail -n +$((BACKUP_KEEP + 1)) | xargs -r rm --
|
||||||
|
echo "[$(date -Is)] Retención aplicada (máx. $BACKUP_KEEP locales)"
|
||||||
|
|
||||||
|
# Copia fuera de la VM (si hay remoto configurado en rclone).
|
||||||
|
if [ -n "$RCLONE_REMOTE" ]; then
|
||||||
|
rclone copy "$FILE" "$RCLONE_REMOTE/" --no-traverse
|
||||||
|
echo "[$(date -Is)] Copia externa OK -> $RCLONE_REMOTE"
|
||||||
|
else
|
||||||
|
echo "[$(date -Is)] AVISO: sin RCLONE_REMOTE; el backup solo existe en esta VM"
|
||||||
|
fi
|
||||||
40
deploy/nginx-recordalexia.conf
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Server block de recordaLexia para el nginx NATIVO de la VM.
|
||||||
|
# Instalación: copiar a /etc/nginx/sites-available/recordalexia, enlazar en
|
||||||
|
# sites-enabled, y emitir el certificado con:
|
||||||
|
# sudo certbot --nginx -d recordalexia.jaumegar.work
|
||||||
|
# (certbot reescribe este fichero añadiendo el bloque TLS y la redirección 80→443)
|
||||||
|
#
|
||||||
|
# El upstream es el contenedor frontend del compose de producción, que solo
|
||||||
|
# escucha en loopback: este nginx es la única puerta de entrada.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name recordalexia.jaumegar.work;
|
||||||
|
|
||||||
|
# Subidas holgadas (la app apenas sube nada, pero evitamos el 413 histórico).
|
||||||
|
client_max_body_size 16m;
|
||||||
|
|
||||||
|
# --- Cabeceras de seguridad (spec production-hardening) ---
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'none'" always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8089;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
# La IP real del cliente: el rate-limiter del backend la usa como clave.
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# El service worker y el manifest no deben quedar cacheados por intermediarios
|
||||||
|
# de forma agresiva: la PWA se actualiza comprobándolos.
|
||||||
|
location = /ngsw-worker.js {
|
||||||
|
proxy_pass http://127.0.0.1:8089;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
docker-compose.prod.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Stack de PRODUCCIÓN de recordaLexia (instancia pública en la VM).
|
||||||
|
# docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
|
||||||
|
#
|
||||||
|
# Principios:
|
||||||
|
# - Superficie mínima: Postgres y backend NO publican puertos; el frontend solo
|
||||||
|
# escucha en 127.0.0.1:8089 y el nginx NATIVO de la VM hace el proxy TLS.
|
||||||
|
# - Límites de recursos: la VM aloja más servicios; nadie se la come.
|
||||||
|
# - Sin familia demo: el seeder es fail-safe y aquí NO se activa.
|
||||||
|
# - Ningún secreto en este fichero: todo llega de .env.prod (fuera de git).
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: recordalexia-prod-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_NAME}
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
volumes:
|
||||||
|
- pgdata-prod:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
mem_limit: 512m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
container_name: recordalexia-prod-backend
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
SPRING_PROFILES_ACTIVE: prod
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: "5432"
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
SPRING_DATASOURCE_USERNAME: ${DB_USER}
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
|
||||||
|
# Dominio público (enlaces del email de recuperación) y CORS estricto.
|
||||||
|
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
||||||
|
CORS_ALLOWED_ORIGINS: ${PUBLIC_BASE_URL}
|
||||||
|
MAIL_FROM: ${MAIL_FROM}
|
||||||
|
# SMTP del proveedor (binding relajado de Spring). Sin SMTP_HOST definido,
|
||||||
|
# los emails de recuperación se quedan en el log del contenedor.
|
||||||
|
SPRING_MAIL_HOST: ${SMTP_HOST:-}
|
||||||
|
SPRING_MAIL_PORT: ${SMTP_PORT:-587}
|
||||||
|
SPRING_MAIL_USERNAME: ${SMTP_USERNAME:-}
|
||||||
|
SPRING_MAIL_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "true"
|
||||||
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "true"
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
mem_limit: 768m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
container_name: recordalexia-prod-frontend
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
# SOLO loopback: el único acceso desde fuera es el nginx nativo de la VM.
|
||||||
|
- "127.0.0.1:8089:80"
|
||||||
|
mem_limit: 64m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata-prod:
|
||||||
@@ -35,6 +35,9 @@ services:
|
|||||||
# Spring enlaza estas dos sobre spring.datasource.* por binding relajado.
|
# Spring enlaza estas dos sobre spring.datasource.* por binding relajado.
|
||||||
SPRING_DATASOURCE_USERNAME: ${DB_USER}
|
SPRING_DATASOURCE_USERNAME: ${DB_USER}
|
||||||
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
|
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
|
||||||
|
# Solo en LOCAL: sembrar la familia demo. En producción no se declara
|
||||||
|
# (el seeder es fail-safe: sin la propiedad, no siembra nada).
|
||||||
|
RECORDALEXIA_SEED_ENABLED: "true"
|
||||||
TZ: Europe/Madrid
|
TZ: Europe/Madrid
|
||||||
expose:
|
expose:
|
||||||
- "8080"
|
- "8080"
|
||||||
|
|||||||
105
docs/adr/adr-001-autenticacion-propia-sesion-familia.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# ADR-001: Autenticación propia por sesión de familia (sin Keycloak)
|
||||||
|
|
||||||
|
| Metadata | Valor |
|
||||||
|
|----------|-------|
|
||||||
|
| **Estado** | Aceptado (implementado) |
|
||||||
|
| **Fecha** | 2026-07-12 |
|
||||||
|
| **Autor(es)** | Jaume Garriga Maestre |
|
||||||
|
| **Supersede** | — |
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
recordaLexia es una app **doméstica/homelab** para niños con TDAH que corre en una
|
||||||
|
tablet en modo kiosko. Fuera del stack corporativo ECA por diseño: no hay Gravitee,
|
||||||
|
ni Kubernetes, ni infraestructura gestionada donde apoyar un IdP.
|
||||||
|
|
||||||
|
Requisitos que condicionan la autenticación:
|
||||||
|
|
||||||
|
- **Fricción cero para el niño**: abre la tablet y ve su día. Un niño de 7 años con
|
||||||
|
TDAH no puede depender de contraseñas ni de flujos de login.
|
||||||
|
- **Multi-tenant real**: varias familias en la misma instancia, con aislamiento
|
||||||
|
estricto de datos (un niño de la familia A no existe para la familia B).
|
||||||
|
- **Zona de configuración protegida**: el panel de padres debe quedar fuera del
|
||||||
|
alcance del niño sin romper el kiosko.
|
||||||
|
- **Operación doméstica**: un contenedor más en docker-compose es coste asumible;
|
||||||
|
operar un Keycloak (memoria, actualizaciones, backups del realm) no lo es.
|
||||||
|
|
||||||
|
## Opciones Consideradas
|
||||||
|
|
||||||
|
### Opción A: Keycloak OAuth2 (estándar corporativo)
|
||||||
|
|
||||||
|
| Pros | Contras |
|
||||||
|
|------|---------|
|
||||||
|
| Estándar Asepeyo, flujos probados | Un servicio pesado más que operar en un homelab |
|
||||||
|
| SSO, federación, MFA disponibles | Fricción de login incompatible con el kiosko infantil |
|
||||||
|
| Separación IdP/aplicación | Sobredimensionado: no hay federación ni múltiples clientes |
|
||||||
|
|
||||||
|
### Opción B: Auth propia por sesión de familia + PIN (elegida)
|
||||||
|
|
||||||
|
| Pros | Contras |
|
||||||
|
|------|---------|
|
||||||
|
| Cero fricción para el niño (la sesión vive en el dispositivo) | Implementación propia: superficie de error en código nuestro |
|
||||||
|
| Dos niveles con un mecanismo (rol FAMILY / rol PARENT vía PIN) | Sin SSO ni MFA (irrelevantes en el contexto doméstico) |
|
||||||
|
| Nada extra que operar; BCrypt + tabla de sesiones | Rotación/expiración de sesión hay que diseñarla a mano |
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
**Opción B.** El adulto se registra/loguea una vez; el dispositivo conserva una
|
||||||
|
sesión de familia (`FamilySession`, handle opaco + expiración) que da el rol
|
||||||
|
`FAMILY` a toda la API del kiosko. El panel de padres (`/api/parents/**`) exige el
|
||||||
|
rol `PARENT`, que se obtiene desbloqueando con PIN (`POST /api/parents/unlock`) y
|
||||||
|
caduca solo. Contraseña y PIN con hash BCrypt. Todo scopeado a
|
||||||
|
`FamilyContext.currentFamilyId()`.
|
||||||
|
|
||||||
|
La clase `AuthService` queda **encapsulada** precisamente para poder sustituirla por
|
||||||
|
un IdP externo sin tocar el resto del código, si el contexto cambia.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant A as Adulto
|
||||||
|
participant K as Tablet kiosko
|
||||||
|
participant API as Backend
|
||||||
|
A->>API: register/login (una vez)
|
||||||
|
API-->>K: sesión de familia (rol FAMILY)
|
||||||
|
Note over K: el niño usa la app sin loguearse
|
||||||
|
A->>API: PIN → unlock (rol PARENT, temporal)
|
||||||
|
API-->>A: panel de padres
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
### Optimizamos para:
|
||||||
|
- **Usabilidad del niño**: el requisito nº 1 del producto; cualquier fricción de
|
||||||
|
auth rompe el caso de uso.
|
||||||
|
- **Operabilidad doméstica**: menos piezas que mantener en un homelab.
|
||||||
|
|
||||||
|
### Sacrificamos:
|
||||||
|
- **Robustez de un IdP maduro**: aceptable porque la superficie expuesta es la red
|
||||||
|
local doméstica y los datos, aunque personales, no salen de casa.
|
||||||
|
- **Estándares corporativos (Keycloak)**: desviación consciente y documentada;
|
||||||
|
el proyecto está explícitamente fuera del ECA.
|
||||||
|
|
||||||
|
### Asunciones que podrían invalidar esta decisión:
|
||||||
|
- Si la app se expusiera a Internet o se ofreciera como SaaS multi-hogar,
|
||||||
|
reconsiderar: haría falta IdP real, MFA y hardening de sesiones.
|
||||||
|
- Si Asepeyo integrara este producto en su cartera, aplicaría el stack ECA
|
||||||
|
(Keycloak + Gravitee) y este ADR quedaría supersedido.
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- (+) Kiosko sin fricción; multi-tenant con una sola tabla extra.
|
||||||
|
- (+) CORS/CSRF simplificados (API stateless con cabecera de sesión propia).
|
||||||
|
- (−) El equipo asume el mantenimiento del código de auth → mitigado con tests de
|
||||||
|
integración (`AuthIT`: registro, login fallido, gate por PIN, aislamiento entre
|
||||||
|
familias).
|
||||||
|
|
||||||
|
## Criterios de Éxito
|
||||||
|
|
||||||
|
- [x] El niño usa la app sin credenciales tras el primer login del adulto.
|
||||||
|
- [x] `/api/parents/**` inaccesible sin PIN (test verde).
|
||||||
|
- [x] Una familia no puede ver datos de otra (test verde, 404 para no filtrar existencia).
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- `backend/src/main/java/es/asepeyo/recordalexia/security/` (SecurityConfig, AuthService, SessionAuthFilter, FamilyContext)
|
||||||
|
- `backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java`
|
||||||
98
docs/adr/adr-002-catalogo-rutinas-reutilizable.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# ADR-002: Normalización de las rutinas de tarde (catálogo + asignación)
|
||||||
|
|
||||||
|
| Metadata | Valor |
|
||||||
|
|----------|-------|
|
||||||
|
| **Estado** | Aceptado (implementado) |
|
||||||
|
| **Fecha** | 2026-07-12 |
|
||||||
|
| **Autor(es)** | Jaume Garriga Maestre |
|
||||||
|
| **Supersede** | — |
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El modelo original de `AfternoonRoutine` mezclaba en una fila la **definición** de
|
||||||
|
la rutina (labelEs/labelCa, emoji, color, monedas) y su **asignación** (niño, día,
|
||||||
|
orden). Para tener "Merendar" de lunes a viernes había que crear la misma rutina
|
||||||
|
cinco veces, y editar el emoji exigía tocar cinco filas. El usuario lo detectó como
|
||||||
|
fricción real de uso.
|
||||||
|
|
||||||
|
Las **mañanas ya estaban bien normalizadas**: `Activity` (catálogo por familia) +
|
||||||
|
`WeeklyTemplateEntry` (asignación niño+día+orden+override de monedas). Las tardes
|
||||||
|
eran la anomalía.
|
||||||
|
|
||||||
|
## Opciones Consideradas
|
||||||
|
|
||||||
|
### Opción A: Mantener el modelo y duplicar en la UI ("crear en varios días")
|
||||||
|
|
||||||
|
| Pros | Contras |
|
||||||
|
|------|---------|
|
||||||
|
| Sin migración de datos | La duplicación persiste en BD: editar sigue costando N filas |
|
||||||
|
| Cambio solo de frontend | La asimetría mañana/tarde se consolida |
|
||||||
|
|
||||||
|
### Opción B: Reutilizar `Activity` también para las tardes
|
||||||
|
|
||||||
|
| Pros | Contras |
|
||||||
|
|------|---------|
|
||||||
|
| Una sola entidad de catálogo | Semántica forzada: Activity arrastra material del cole (N:M) que la rutina no necesita |
|
||||||
|
| Menos código | Acopla dos conceptos de dominio distintos |
|
||||||
|
|
||||||
|
### Opción C: Nueva entidad `RoutineTask` gemela de `Activity` (elegida)
|
||||||
|
|
||||||
|
| Pros | Contras |
|
||||||
|
|------|---------|
|
||||||
|
| Simetría total mañana/tarde: mismo patrón mental y de código | Migración de datos con deduplicación |
|
||||||
|
| Definir una vez, asignar a N días y N niños | Una entidad y un CRUD más |
|
||||||
|
| Monedas con cascada: override por día → catálogo → niño | |
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
**Opción C.** `RoutineTask` = catálogo por familia (labels, emoji, color, monedas
|
||||||
|
por defecto). `AfternoonRoutine` queda como pura asignación (`child + dayOfWeek +
|
||||||
|
routineTask + orderIndex + coinsReward` como override). La migración Liquibase
|
||||||
|
(changeset 003) consolida los duplicados existentes por clave natural
|
||||||
|
(familia + labels + emoji + color) sin pérdida; el changeset 004 añade
|
||||||
|
`ON DELETE CASCADE` para que borrar del catálogo arrastre las asignaciones
|
||||||
|
(el modelo mental del padre).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
FAMILY ||--o{ ROUTINE_TASK : "define una vez"
|
||||||
|
ROUTINE_TASK ||--o{ AFTERNOON_ROUTINE : "se asigna a N dias/niños"
|
||||||
|
CHILD ||--o{ AFTERNOON_ROUTINE : ""
|
||||||
|
FAMILY ||--o{ ACTIVITY : "(patrón gemelo de mañanas)"
|
||||||
|
ACTIVITY ||--o{ WEEKLY_TEMPLATE_ENTRY : ""
|
||||||
|
CHILD ||--o{ WEEKLY_TEMPLATE_ENTRY : ""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
### Optimizamos para:
|
||||||
|
- **Coste de edición del padre**: cambiar una rutina se hace UNA vez y se propaga.
|
||||||
|
- **Consistencia del dominio**: un solo patrón (catálogo+asignación) para todo el día.
|
||||||
|
|
||||||
|
### Sacrificamos:
|
||||||
|
- **Simplicidad de una tabla única**: aceptable; el patrón ya existía en mañanas.
|
||||||
|
- **Riesgo puntual de migración**: mitigado con rollback explícito por changeset y
|
||||||
|
verificación contra Postgres real (Testcontainers, `MigrationIT`).
|
||||||
|
|
||||||
|
### Asunciones que podrían invalidar esta decisión:
|
||||||
|
- Si apareciera la necesidad de rutinas con material asociado (como Activity),
|
||||||
|
reconsiderar la unificación con `Activity` (Opción B) en lugar de duplicar N:M.
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- (+) Alta multi-día en una llamada (`POST /routines` con lista de días).
|
||||||
|
- (+) Cascada de monedas expresiva: día > catálogo > `coinsPerTask` del niño.
|
||||||
|
- (−) Lección aprendida en la migración: H2 no detecta el tipado estricto de
|
||||||
|
Postgres (`CAST(NULL AS INTEGER)`); por eso existe `MigrationIT` con Testcontainers.
|
||||||
|
|
||||||
|
## Criterios de Éxito
|
||||||
|
|
||||||
|
- [x] Una rutina definida una vez y asignada a varios días/niños (tests verdes).
|
||||||
|
- [x] Migración sin pérdida verificada contra Postgres 16 real.
|
||||||
|
- [x] Borrar del catálogo arrastra asignaciones sin error 500 (test de cascade).
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- `backend/.../domain/RoutineTask.java`, `domain/AfternoonRoutine.java`
|
||||||
|
- `backend/src/main/resources/db/changelog/changes/003-routine-catalog.yaml`, `004-routine-task-cascade.yaml`
|
||||||
|
- Artefactos SDD en engram: `sdd/rutinas-reutilizables/{proposal,spec,design,tasks,verify-report}`
|
||||||
90
docs/adr/adr-003-provision-inicial-dataset.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# ADR-003: Provisión inicial de datos al alta (familia y niño)
|
||||||
|
|
||||||
|
| Metadata | Valor |
|
||||||
|
|----------|-------|
|
||||||
|
| **Estado** | Aceptado (implementado) |
|
||||||
|
| **Fecha** | 2026-07-12 |
|
||||||
|
| **Autor(es)** | Jaume Garriga Maestre |
|
||||||
|
| **Supersede** | — |
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Una familia recién registrada empezaba con la app vacía: había que crear a mano
|
||||||
|
todos los materiales, actividades y rutinas. El objetivo es que cualquier familia
|
||||||
|
arranque con un **dataset inicial útil**: material escolar típico y rutinas de
|
||||||
|
tarde apropiadas a la edad de cada niño (tramos 8-9 / 10-11 / 12-14, tomados de la
|
||||||
|
literatura de desarrollo de autonomía en TDAH), sin impedir personalizarlo después.
|
||||||
|
|
||||||
|
Restricción estructural clave: los datos cuelgan de `family_id` y `child_id`, que
|
||||||
|
son **datos de runtime** (se crean al registrarse), no de esquema.
|
||||||
|
|
||||||
|
## Opciones Consideradas
|
||||||
|
|
||||||
|
### Opción A: INSERTs en un changeset Liquibase
|
||||||
|
|
||||||
|
| Pros | Contras |
|
||||||
|
|------|---------|
|
||||||
|
| Versionado junto al esquema | **Inviable**: Liquibase corre ANTES que cualquier ApplicationRunner; en la migración no existe aún ninguna familia a la que apuntar (FK NOT NULL) |
|
||||||
|
|
||||||
|
### Opción B: Ampliar el DataSeeder
|
||||||
|
|
||||||
|
| Pros | Contras |
|
||||||
|
|------|---------|
|
||||||
|
| Sencillo | Solo actúa con BD vacía (familia demo); las familias reales que se registren después no reciben nada |
|
||||||
|
|
||||||
|
### Opción C: Servicio de provisión invocado en el alta (elegida)
|
||||||
|
|
||||||
|
| Pros | Contras |
|
||||||
|
|------|---------|
|
||||||
|
| Se ejecuta exactamente cuando existen los datos padre (familia/niño) | Lógica de negocio adicional que testear |
|
||||||
|
| La edad del niño está disponible en su alta → tramo correcto | El catálogo posterior al alta no se "resincroniza" (decisión consciente) |
|
||||||
|
| Transaccional con el registro: o todo o nada | |
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
**Opción C.** `InitialDatasetService` con dos operaciones:
|
||||||
|
|
||||||
|
- `provisionFamily(family)` — en `AuthService.register()`: catálogo de materiales
|
||||||
|
escolares típicos (9) + catálogo completo de rutinas (14).
|
||||||
|
- `provisionChildRoutines(child)` — en `ChildService.create()`: asigna a L-V las
|
||||||
|
rutinas del catálogo cuyo `minAge` ≤ edad del niño (acumulativo: base 0 /
|
||||||
|
organización 10 / corresponsabilidad 12).
|
||||||
|
|
||||||
|
El `DataSeeder` demo **delega en el mismo servicio** (una sola fuente de verdad) y
|
||||||
|
solo añade sus extras (actividades del horario, premios, eventos).
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
### Optimizamos para:
|
||||||
|
- **Time-to-value de la familia**: la app es útil desde el minuto uno.
|
||||||
|
- **Adecuación evolutiva**: las responsabilidades crecen con la edad del niño.
|
||||||
|
|
||||||
|
### Sacrificamos:
|
||||||
|
- **Sincronización posterior**: si la familia borra rutinas del catálogo, los niños
|
||||||
|
creados después reciben solo las que sigan existiendo (se busca por etiqueta ES).
|
||||||
|
Aceptable: respeta la personalización de la familia.
|
||||||
|
- **Contenido opinado**: el set inicial es una propuesta pedagógica, no neutra;
|
||||||
|
mitigado porque todo es editable desde el panel.
|
||||||
|
|
||||||
|
### Asunciones que podrían invalidar esta decisión:
|
||||||
|
- Si el dataset inicial necesitara variar por idioma/región o ser configurable por
|
||||||
|
el admin, reconsiderar moverlo a datos (tabla de plantillas) en vez de código.
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- (+) El registro deja a la familia lista para usar; el alta de un niño de 13 años
|
||||||
|
le pone 14 rutinas x 5 días sin tocar nada.
|
||||||
|
- (−) El seed vive en código Java (constantes): cambiarlo requiere release. Asumido
|
||||||
|
para v1 doméstica.
|
||||||
|
|
||||||
|
## Criterios de Éxito
|
||||||
|
|
||||||
|
- [x] Registro → 9 materiales + 14 rutinas de catálogo (test verde).
|
||||||
|
- [x] Niño de 8 → 25 asignaciones; niño de 13 → 70 (test verde).
|
||||||
|
- [x] Demo y familias reales comparten la misma provisión (DataSeederIT verde).
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- `backend/.../service/InitialDatasetService.java` (+ test)
|
||||||
|
- `backend/.../security/AuthService.java`, `service/ChildService.java`, `bootstrap/DataSeeder.java`
|
||||||
|
- ADR-002 (el catálogo reutilizable es prerrequisito de esta provisión)
|
||||||
105
docs/adr/adr-004-madurez-operativa-saas.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# ADR-004: Madurez operativa para la instancia pública (SaaS Fase 1)
|
||||||
|
|
||||||
|
| Metadata | Valor |
|
||||||
|
|----------|-------|
|
||||||
|
| **Estado** | Aceptado (implementado; despliegue pendiente) |
|
||||||
|
| **Fecha** | 2026-07-13 |
|
||||||
|
| **Autor(es)** | Jaume Garriga Maestre |
|
||||||
|
| **Supersede** | — |
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Se decide ofrecer recordaLexia como **servicio hospedado** para familias sin
|
||||||
|
conocimientos técnicos (abrir URL, registrarse, «añadir a pantalla de inicio»),
|
||||||
|
en lugar de distribuir software instalable. El multi-tenant ya existía (ADR-001);
|
||||||
|
lo que faltaba era madurez operativa para exponer públicamente una app que guarda
|
||||||
|
datos de menores: recuperación de contraseña, freno de fuerza bruta, RGPD mínimo,
|
||||||
|
PWA y despliegue reproducible con backups. Este ADR fija las decisiones técnicas
|
||||||
|
de esa transformación (cambio SDD `saas-fase-1`; artefactos en engram).
|
||||||
|
|
||||||
|
## Decisiones y trade-offs
|
||||||
|
|
||||||
|
### 1. Rate-limiter propio en memoria (no bucket4j/Redis)
|
||||||
|
|
||||||
|
`LoginAttemptService`: mapa concurrente por clave (`login:email:ip`,
|
||||||
|
`unlock:sesión`, `forgot:email:ip`), 5 fallos → bloqueo 30 s con backoff x2 (tope
|
||||||
|
1 h), comprobado **antes** de evaluar credenciales (429 + Retry-After).
|
||||||
|
|
||||||
|
- **Optimizamos**: cero dependencias e infraestructura; testeable con el `Clock`
|
||||||
|
inyectable del proyecto. El PIN de 4 dígitos pasa de fuerza-brutable en minutos
|
||||||
|
a inviable (días).
|
||||||
|
- **Sacrificamos**: el estado vive en memoria de UNA instancia.
|
||||||
|
- **Asunción que lo invalida**: si algún día hay múltiples réplicas del backend,
|
||||||
|
migrar el estado a Redis o similar.
|
||||||
|
|
||||||
|
### 2. Recuperación por código de un solo uso, hasheado, anti-enumeración
|
||||||
|
|
||||||
|
Tabla `password_reset_token` (Liquibase 005): solo el **SHA-256** del código
|
||||||
|
(el valor real viaja únicamente en el enlace del email), caducidad 30 min, un solo
|
||||||
|
uso, y emitir uno nuevo invalida los anteriores. La solicitud responde **siempre
|
||||||
|
202 con el mismo cuerpo** y el email sale `@Async` (ni el contenido ni el tiempo
|
||||||
|
de respuesta delatan si una cuenta existe). Restablecer cierra todas las sesiones
|
||||||
|
de la familia. Sin SMTP configurado, `MailService` cae a modo log (dev/CI
|
||||||
|
funcionan sin correo).
|
||||||
|
|
||||||
|
- **Optimizamos**: patrón OWASP; un volcado de BD no permite tomar cuentas.
|
||||||
|
- **Sacrificamos**: el enlace del email es el único factor — aceptable porque el
|
||||||
|
email ya es el factor de recuperación universal en consumo.
|
||||||
|
|
||||||
|
### 3. Seeder demo fail-safe
|
||||||
|
|
||||||
|
`@ConditionalOnProperty(... matchIfMissing = false)`: sin la propiedad, **no se
|
||||||
|
siembra**. El compose local y los tests que lo necesitan la activan explícitamente.
|
||||||
|
|
||||||
|
- **Optimizamos**: un despliegue olvidadizo deja la instancia pública SIN la
|
||||||
|
familia demo y su PIN público. En seguridad, el olvido debe cerrar, no abrir.
|
||||||
|
- **Sacrificamos**: el `bootRun` local necesita la variable para tener demo.
|
||||||
|
|
||||||
|
### 4. RGPD mínimo verificable
|
||||||
|
|
||||||
|
Consentimiento persistido (`family.privacy_accepted_at`, casilla no premarcada),
|
||||||
|
política de privacidad pública ES/CA, y borrado de cuenta real
|
||||||
|
(`POST /api/account/delete`, confirmado con contraseña) que elimina todos los
|
||||||
|
datos de la familia — con test que cuenta filas.
|
||||||
|
|
||||||
|
### 5. PWA en lugar de apps nativas
|
||||||
|
|
||||||
|
`@angular/pwa` con el service worker limitado a **assets** (el `/api` nunca se
|
||||||
|
cachea: el día del niño siempre fresco). Instalación vía «añadir a pantalla de
|
||||||
|
inicio»: experiencia de app sin stores, sin cuentas de desarrollador ni revisiones.
|
||||||
|
|
||||||
|
- **Asunción que lo invalida**: si hiciera falta notificación push nativa fiable
|
||||||
|
u offline profundo, reevaluar Capacitor.
|
||||||
|
|
||||||
|
### 6. Producción con superficie mínima en la VM existente
|
||||||
|
|
||||||
|
`docker-compose.prod.yml`: Postgres y backend sin puertos publicados; el frontend
|
||||||
|
solo en `127.0.0.1:8089`; el nginx nativo de la VM es el único punto público (TLS
|
||||||
|
con certbot, cabeceras de seguridad, `X-Forwarded-For` para el rate-limiter).
|
||||||
|
Backups: `pg_dump` diario con retención y copia off-VM (rclone), con la regla
|
||||||
|
operativa de que **un restore no ensayado no cuenta como backup** (runbook
|
||||||
|
`docs/operacion-saas.md`).
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- (+) Una familia se da de alta y usa la app sin instalar nada; el onboarding lo
|
||||||
|
resuelve la provisión automática (ADR-003).
|
||||||
|
- (+) 44 tests backend (incl. migración contra Postgres real) fijan el
|
||||||
|
comportamiento de seguridad: anti-enumeración, caducidad, un solo uso, 429.
|
||||||
|
- (−) El operador (una persona) asume backups, certificados y SMTP → mitigado con
|
||||||
|
runbook, cron y monitorización ya existente en la VM.
|
||||||
|
- (→) CI/CD con Gitea Actions queda como cambio hermano (`saas-cicd`).
|
||||||
|
|
||||||
|
## Criterios de éxito
|
||||||
|
|
||||||
|
- [x] Flujo completo de recuperación verificado E2E (incl. sesiones invalidadas).
|
||||||
|
- [x] Fuerza bruta frenada con test (login y PIN).
|
||||||
|
- [x] Instancia sin demo por defecto (test).
|
||||||
|
- [x] PWA instalable con service worker activo.
|
||||||
|
- [ ] Despliegue público con restore ensayado (pendiente: SMTP + DNS del usuario).
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- Artefactos SDD en engram: `sdd/saas-fase-1/{proposal,spec,design,tasks,apply-progress}`
|
||||||
|
- ADR-001 (auth propia), ADR-003 (provisión inicial)
|
||||||
|
- `backend/.../security/`, `deploy/`, `docs/operacion-saas.md`
|
||||||
BIN
docs/manual/img/01-login.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/manual/img/02-perfiles.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/manual/img/03-home-board.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
docs/manual/img/04-home-foco.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/manual/img/05-tienda.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
docs/manual/img/06-pin.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/manual/img/07-panel-padres.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
docs/manual/img/08-rutinas.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
docs/manual/img/09-horario.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/manual/img/10-materiales.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/manual/img/11-recompensas.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/manual/img/12-eventos.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
docs/manual/img/13-manana.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
docs/manual/img/14-recuperar.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
213
docs/manual/manual-usuario.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# Manual de usuario · recordaLexia
|
||||||
|
|
||||||
|
**recordaLexia** es una app familiar para niños con TDAH. Cada mañana muestra, en
|
||||||
|
una tablet en modo kiosko, qué material llevar al cole y qué rutinas hacer por la
|
||||||
|
tarde, con monedas y una tienda de recompensas. Es multi-niño y bilingüe (español /
|
||||||
|
catalán).
|
||||||
|
|
||||||
|
Hay dos mundos en la app:
|
||||||
|
|
||||||
|
- **El kiosko del niño** — sin fricción: el niño toca su cara y ve su día. No escribe
|
||||||
|
contraseñas ni configura nada.
|
||||||
|
- **El panel de padres** — protegido por un PIN: aquí configuras niños, materiales,
|
||||||
|
horario, rutinas, eventos y recompensas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Entrar (cuenta de familia)
|
||||||
|
|
||||||
|
El adulto inicia sesión **una sola vez** en la tablet con el correo y la contraseña de
|
||||||
|
la familia. A partir de ahí, el dispositivo recuerda la sesión y el niño ya no necesita
|
||||||
|
volver a entrar.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 💡 Cuenta de demostración: `demo@recordalexia.local` / `demo1234`. Si aún no tienes
|
||||||
|
> cuenta, pulsa **«Crear una»** (te pedirá aceptar la política de privacidad —
|
||||||
|
> puedes leerla desde el propio enlace).
|
||||||
|
|
||||||
|
### Instalar como app en la tablet 📲
|
||||||
|
|
||||||
|
recordaLexia se instala **sin tienda de aplicaciones**: abre la web en el navegador
|
||||||
|
de la tablet y usa **«Añadir a pantalla de inicio»** (Chrome/Android: menú ⋮ →
|
||||||
|
«Añadir a pantalla de inicio»; Safari/iPad: botón compartir → «Añadir a pantalla
|
||||||
|
de inicio»). Aparece el icono de la mascota 🦊 y, al abrirla desde ahí, ocupa la
|
||||||
|
pantalla completa sin la barra del navegador — perfecta para el modo kiosko.
|
||||||
|
|
||||||
|
### ¿Contraseña olvidada? 🔑
|
||||||
|
|
||||||
|
En la pantalla de entrada, pulsa **«¿Has olvidado la contraseña?»**, escribe tu
|
||||||
|
email y recibirás un enlace para crear una nueva. El enlace **caduca en 30 minutos
|
||||||
|
y solo sirve una vez**; por seguridad, al cambiarla se cierran las sesiones
|
||||||
|
abiertas y hay que volver a entrar en cada dispositivo.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ¿Quién entra hoy?
|
||||||
|
|
||||||
|
Tras entrar, aparece la pantalla de perfiles. Cada niño tiene su **mascota**, su
|
||||||
|
**color** y sus **monedas** 🪙. El niño solo tiene que tocar su tarjeta.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- Toca la tarjeta de un niño para abrir **su día**.
|
||||||
|
- El botón **⚙️ Padres** (abajo) lleva al panel de configuración (pide PIN).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. El día del niño (kiosko)
|
||||||
|
|
||||||
|
Es la pantalla principal. Arriba se ve el saludo, la fecha, el temporizador
|
||||||
|
**«Salimos en X min»** ⏰, el acceso a la **Tienda** 🎁 y el saldo de monedas 🪙.
|
||||||
|
|
||||||
|
El día se divide en dos bloques:
|
||||||
|
|
||||||
|
- **🎒 COLE** — el material/actividades de la mañana.
|
||||||
|
- **🌙 TARDE** — las rutinas de la tarde.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- El niño **toca cada tarea** para marcarla como hecha y ganar monedas.
|
||||||
|
- El botón 🔊 **lee la tarea en voz alta**.
|
||||||
|
- La barra **«X/Y listo ✨»** muestra el progreso del día.
|
||||||
|
|
||||||
|
### Modo Foco 🎯
|
||||||
|
|
||||||
|
Pensado para TDAH: muestra **una sola tarea a la vez** para evitar distracciones. Se
|
||||||
|
activa con el botón **🎯 Foco** (y se vuelve a la vista completa con **🗂️ Tablero**).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Ver mañana 👀
|
||||||
|
|
||||||
|
El botón **«Mañana ›»** de la cabecera muestra el día siguiente: qué toca en el cole
|
||||||
|
y qué rutinas habrá por la tarde. Es la herramienta para **preparar la mochila la
|
||||||
|
noche antes**, un hábito clave para empezar el día sin estrés.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- El aviso **«👀 Así será mañana»** deja claro que no es el día de hoy.
|
||||||
|
- Es una vista **solo de consulta**: las tareas no se pueden marcar (¡las monedas se
|
||||||
|
ganan mañana!).
|
||||||
|
- También muestra los **eventos** del día siguiente (exámenes, entregas), para no
|
||||||
|
llevarse sorpresas.
|
||||||
|
- Se vuelve con el botón **«‹ Hoy»**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. La tienda de recompensas 🎁
|
||||||
|
|
||||||
|
El niño gasta las monedas que ha ganado en premios configurados por los padres. Si no
|
||||||
|
le llega el saldo, el premio aparece bloqueado con las monedas que le faltan.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Entrar al panel de padres 🔒
|
||||||
|
|
||||||
|
Desde la pantalla de perfiles, pulsa **⚙️ Padres** e introduce el **PIN** en el
|
||||||
|
teclado. (En la demo, el PIN es `1234`.)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 🔐 El PIN es configurable y separa el mundo del niño del de configuración. El niño
|
||||||
|
> nunca necesita el PIN para usar su día.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Panel de padres
|
||||||
|
|
||||||
|
El panel tiene seis pestañas. Arriba a la derecha, **🔓 Salir** vuelve a bloquear el
|
||||||
|
panel.
|
||||||
|
|
||||||
|
### 6.1 Niños 👦
|
||||||
|
|
||||||
|
Crea perfiles (nombre, mascota, color, edad y hora de salida) y consulta los
|
||||||
|
existentes.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 💡 Al crear un niño, la app le **asigna automáticamente rutinas de tarde acordes a su
|
||||||
|
> edad** (más responsabilidades cuanto mayor es). No partes de cero.
|
||||||
|
|
||||||
|
### 6.2 Horario 📅 (mañanas)
|
||||||
|
|
||||||
|
Define qué **actividad del cole** tiene cada niño cada día de la semana. Cada actividad
|
||||||
|
arrastra el material que hay que llevar.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 6.3 Materiales 🎒
|
||||||
|
|
||||||
|
Catálogo de material escolar de la familia (estuche, libreta, ropa de gimnasia…). Cada
|
||||||
|
material tiene texto bilingüe, emoji y color. Al registrar una familia ya se siembra un
|
||||||
|
catálogo típico para no empezar en blanco.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 6.4 Rutinas 🌙 (tardes) — *reutilizables*
|
||||||
|
|
||||||
|
Esta es la pestaña clave, y funciona en **dos pasos**:
|
||||||
|
|
||||||
|
1. **Catálogo de rutinas** — defines una rutina **una sola vez** (texto, emoji, color):
|
||||||
|
«Merendar», «Hacer los deberes», etc.
|
||||||
|
2. **Asignar a días** — eliges una rutina del catálogo y marcas **los días** en los que
|
||||||
|
toca. Una misma rutina sirve para varios días y varios niños, sin recrearla.
|
||||||
|
|
||||||
|
Abajo, **Rutinas por día** muestra lo asignado a cada día, donde puedes **reordenar** (▲▼)
|
||||||
|
o **quitar** (✕) una rutina de ese día.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 💡 Si cambias el emoji o el texto de una rutina en el catálogo, el cambio se refleja
|
||||||
|
> automáticamente en **todos los días** donde está asignada.
|
||||||
|
|
||||||
|
### 6.5 Eventos 📋
|
||||||
|
|
||||||
|
Avisos puntuales del día (exámenes, deberes a entregar…). Aparecen como banner
|
||||||
|
informativo en el día del niño; no son tareas marcables.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 6.6 Recompensas 🪙
|
||||||
|
|
||||||
|
Premios de la tienda: texto bilingüe, emoji, color y **coste en monedas**. Puedes
|
||||||
|
activarlos o desactivarlos.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Preguntas frecuentes
|
||||||
|
|
||||||
|
**¿El niño puede tocar la configuración por error?**
|
||||||
|
No. Todo lo de configuración está tras el PIN del panel de padres.
|
||||||
|
|
||||||
|
**¿Tengo que crear las rutinas para cada día?**
|
||||||
|
No. Defines la rutina una vez en el catálogo y la asignas a los días que quieras.
|
||||||
|
|
||||||
|
**¿Puede el niño ver qué le toca mañana?**
|
||||||
|
Sí, con el botón «Mañana ›» de su pantalla. Es solo de consulta (no se pueden marcar
|
||||||
|
tareas por adelantado) y siempre refleja los últimos cambios del panel de padres.
|
||||||
|
|
||||||
|
**¿Y si olvido la contraseña?**
|
||||||
|
Usa «¿Has olvidado la contraseña?» en la pantalla de entrada: te llegará un enlace
|
||||||
|
por email (caduca en 30 minutos y es de un solo uso).
|
||||||
|
|
||||||
|
**¿Qué pasa con mis datos si borro la cuenta?**
|
||||||
|
Se elimina TODO de forma inmediata e irreversible: niños, tareas, historial y
|
||||||
|
monedas. Los detalles están en la política de privacidad, enlazada en el registro.
|
||||||
|
|
||||||
|
**¿Qué pasa con el histórico al cambiar de día?**
|
||||||
|
Se conserva. No se borran tareas ni canjes anteriores.
|
||||||
|
|
||||||
|
**¿En qué idiomas está?**
|
||||||
|
Español y catalán. Todos los textos visibles tienen ambas variantes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*recordaLexia · Manual de usuario · Capturas tomadas de la aplicación en
|
||||||
|
funcionamiento.*
|
||||||
BIN
docs/manual/manual-usuario.pdf
Normal file
53
docs/manual/pdf.css
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/* Estilo de impresión del manual de usuario de recordaLexia. */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 2cm 1.8cm;
|
||||||
|
@bottom-center { content: "recordaLexia · Manual de usuario"; font-size: 8pt; color: #9aa0ad; }
|
||||||
|
@bottom-right { content: counter(page) " / " counter(pages); font-size: 8pt; color: #9aa0ad; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Nunito", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
color: #2A3142;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-family: "Fredoka", "Nunito", Arial, sans-serif;
|
||||||
|
color: #2A3142;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
h1 { font-size: 22pt; color: #5B8DEF; border-bottom: 3px solid #F2A65A; padding-bottom: 6px; }
|
||||||
|
h2 { font-size: 15pt; margin-top: 1.4em; border-left: 5px solid #A78BD0; padding-left: 10px; }
|
||||||
|
h3 { font-size: 12.5pt; color: #5B8DEF; margin-top: 1.1em; }
|
||||||
|
|
||||||
|
/* Cada sección de primer nivel (## n.) empieza en página nueva, salvo la primera. */
|
||||||
|
h2 { break-before: page; }
|
||||||
|
h2:first-of-type { break-before: avoid; }
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 78%;
|
||||||
|
display: block;
|
||||||
|
margin: 12px auto;
|
||||||
|
border: 1px solid #e6e8ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(42,49,66,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
background: #FFF6E0;
|
||||||
|
border-left: 4px solid #F4C95D;
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #5a4a1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 9.5pt; }
|
||||||
|
th, td { border: 1px solid #e0e3ea; padding: 6px 10px; text-align: left; }
|
||||||
|
th { background: #5B8DEF; color: #fff; }
|
||||||
|
tr:nth-child(even) td { background: #f6f8fc; }
|
||||||
|
|
||||||
|
code { background: #f0f2f6; padding: 1px 5px; border-radius: 4px; font-size: 9.5pt; }
|
||||||
|
a { color: #5B8DEF; text-decoration: none; }
|
||||||
122
docs/operacion-saas.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# recordaLexia · Runbook de operación (instancia pública)
|
||||||
|
|
||||||
|
Operación de la instancia SaaS en la VM (Ubuntu, Docker, nginx nativo).
|
||||||
|
Escrito para poder ejecutarse dentro de 6 meses sin recordar nada.
|
||||||
|
|
||||||
|
## 0. Requisitos
|
||||||
|
|
||||||
|
- VM con Docker + Compose v2 y nginx nativo (ya presentes en `vnicjaume`).
|
||||||
|
- DNS: `recordalexia.jaumegar.work` (o el subdominio elegido) apuntando a la IP de la VM.
|
||||||
|
- Credenciales SMTP de cualquier relay estándar (para el email de recuperación).
|
||||||
|
- `rclone` configurado con un remoto para backups fuera de la VM (recomendado).
|
||||||
|
|
||||||
|
## 1. Primer despliegue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) Código
|
||||||
|
git clone https://gitea.jaumegar.work/jaume/recordalexia.git
|
||||||
|
cd recordalexia
|
||||||
|
|
||||||
|
# 2) Variables (rellenar TODAS; DB_PASSWORD nueva y larga)
|
||||||
|
cp .env.prod.example .env.prod
|
||||||
|
nano .env.prod
|
||||||
|
|
||||||
|
# 3) Levantar el stack (build en la propia VM)
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
|
||||||
|
|
||||||
|
# 4) Comprobar salud
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env.prod ps
|
||||||
|
curl -s http://127.0.0.1:8089/ | head -c 200 # el frontend responde en loopback
|
||||||
|
|
||||||
|
# 5) Nginx + TLS
|
||||||
|
sudo cp deploy/nginx-recordalexia.conf /etc/nginx/sites-available/recordalexia
|
||||||
|
sudo ln -s /etc/nginx/sites-available/recordalexia /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
sudo certbot --nginx -d recordalexia.jaumegar.work
|
||||||
|
|
||||||
|
# 6) Humo: abrir https://recordalexia.jaumegar.work, registrar una familia de
|
||||||
|
# prueba, pedir recuperación de contraseña y comprobar que LLEGA EL EMAIL.
|
||||||
|
# Después borrar la familia desde Cuenta → Borrar cuenta.
|
||||||
|
```
|
||||||
|
|
||||||
|
Checklist post-despliegue:
|
||||||
|
|
||||||
|
- [ ] `https://…` carga y Chrome ofrece «Instalar app» (o Añadir a pantalla de inicio).
|
||||||
|
- [ ] NO existe la familia demo (el seeder queda apagado en prod).
|
||||||
|
- [ ] El email de recuperación llega (si no: revisar SMTP_* y el log del backend).
|
||||||
|
- [ ] Cabeceras: `curl -sI https://… | grep -iE "nosniff|frame-ancestors|referrer"`.
|
||||||
|
- [ ] Alta del dominio en uptime-kuma.
|
||||||
|
- [ ] Backup + RESTORE ensayado (sección 3). **Sin esto no se abre el registro a terceros.**
|
||||||
|
|
||||||
|
## 2. Backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x deploy/backup.sh
|
||||||
|
crontab -e
|
||||||
|
# 03:30 Europe/Madrid, log en el home:
|
||||||
|
30 3 * * * /home/ubuntu/recordalexia/deploy/backup.sh >> $HOME/recordalexia-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
- Local: `~/backups/recordalexia/` (retención 14).
|
||||||
|
- Externa: define `RCLONE_REMOTE` en `.env.prod` (p. ej. `b2:recordalexia-backups`).
|
||||||
|
- Vigila el log: un backup de menos de 1 KB se marca como ERROR.
|
||||||
|
|
||||||
|
## 3. Restauración (ENSAYAR antes de abrir al público)
|
||||||
|
|
||||||
|
El ensayo restaura sobre una BD limpia SIN tocar la de producción:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/recordalexia
|
||||||
|
LAST=$(ls -1t ~/backups/recordalexia/recordalexia-*.sql.gz | head -1)
|
||||||
|
|
||||||
|
# BD de ensayo temporal (sin volumen persistente ni puertos publicados)
|
||||||
|
docker run -d --name restore-test \
|
||||||
|
-e POSTGRES_DB=recordalexia -e POSTGRES_USER=recordalexia \
|
||||||
|
-e POSTGRES_PASSWORD=ensayo postgres:16-alpine
|
||||||
|
sleep 5
|
||||||
|
gunzip -c "$LAST" | docker exec -i restore-test psql -U recordalexia recordalexia
|
||||||
|
|
||||||
|
# Verificar: las familias están
|
||||||
|
docker exec restore-test psql -U recordalexia recordalexia -tA \
|
||||||
|
-c "SELECT count(*) FROM family;"
|
||||||
|
|
||||||
|
docker rm -f restore-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Restauración REAL (pérdida de la BD de producción):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env.prod stop backend
|
||||||
|
gunzip -c "$LAST" | docker compose -f docker-compose.prod.yml --env-file .env.prod \
|
||||||
|
exec -T postgres psql -U recordalexia recordalexia
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env.prod start backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Actualizar la aplicación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/recordalexia && git pull
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Liquibase aplica las migraciones al arrancar. Si el backend no levanta, sus logs
|
||||||
|
dicen exactamente qué changeset falló:
|
||||||
|
`docker logs recordalexia-prod-backend | grep -iA3 liquibase`.
|
||||||
|
|
||||||
|
## 5. Problemas conocidos
|
||||||
|
|
||||||
|
| Síntoma | Causa probable | Arreglo |
|
||||||
|
|---|---|---|
|
||||||
|
| 413 al subir algo | límite del nginx | ya hay `client_max_body_size` en el server block y 64m global |
|
||||||
|
| No llegan emails | SMTP_* mal o puerto bloqueado | `docker logs recordalexia-prod-backend \| grep -i mail`; probar el relay con `openssl s_client -starttls smtp -connect $SMTP_HOST:587` |
|
||||||
|
| 429 al entrar | freno anti fuerza bruta | esperar el Retry-After; es el comportamiento diseñado |
|
||||||
|
| Certificado caducado | certbot | `sudo certbot renew --dry-run`; el timer de systemd debería renovarlo solo |
|
||||||
|
| La PWA no se actualiza | SW cacheado | el SW comprueba `ngsw.json` al abrir; forzar con recarga o cerrar/abrir la app |
|
||||||
|
|
||||||
|
## 6. Rotación de credenciales
|
||||||
|
|
||||||
|
- **DB**: cambiar en `.env.prod` + `ALTER USER recordalexia WITH PASSWORD '…'` en el
|
||||||
|
contenedor postgres + `up -d` del backend.
|
||||||
|
- **SMTP**: cambiar en `.env.prod` + `up -d backend`.
|
||||||
|
- **PIN/contraseña de una familia**: lo hace la propia familia desde la app
|
||||||
|
(Cuenta o «¿Has olvidado la contraseña?»).
|
||||||
@@ -67,7 +67,8 @@
|
|||||||
"maximumError": "8kB"
|
"maximumError": "8kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"serviceWorker": "ngsw-config.json"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
|
|||||||
30
frontend/ngsw-config.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||||
|
"index": "/index.html",
|
||||||
|
"assetGroups": [
|
||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"installMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/favicon.ico",
|
||||||
|
"/index.csr.html",
|
||||||
|
"/index.html",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/*.css",
|
||||||
|
"/*.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assets",
|
||||||
|
"installMode": "lazy",
|
||||||
|
"updateMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
488
frontend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@angular/platform-browser": "^19.2.0",
|
"@angular/platform-browser": "^19.2.0",
|
||||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||||
"@angular/router": "^19.2.0",
|
"@angular/router": "^19.2.0",
|
||||||
|
"@angular/service-worker": "^19.2.0",
|
||||||
"@fontsource/fredoka": "^5.2.10",
|
"@fontsource/fredoka": "^5.2.10",
|
||||||
"@fontsource/nunito": "^5.2.7",
|
"@fontsource/nunito": "^5.2.7",
|
||||||
"@fontsource/opendyslexic": "^5.2.5",
|
"@fontsource/opendyslexic": "^5.2.5",
|
||||||
@@ -1185,6 +1186,25 @@
|
|||||||
"rxjs": "^6.5.3 || ^7.4.0"
|
"rxjs": "^6.5.3 || ^7.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular/service-worker": {
|
||||||
|
"version": "19.2.25",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-19.2.25.tgz",
|
||||||
|
"integrity": "sha512-NB4g5bA+f5Jx6UsLmEdrHUXLOQDtAjQqeM7JHRl7qXg/xESnNubNqM0VMKO1P5oqAjQ++5Vc1G3s59uOgHKnPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ngsw-config": "ngsw-config.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/core": "19.2.25",
|
||||||
|
"rxjs": "^6.5.3 || ^7.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -14741,474 +14761,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.25.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
|
||||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/esbuild": {
|
"node_modules/vite/node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@angular/platform-browser": "^19.2.0",
|
"@angular/platform-browser": "^19.2.0",
|
||||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||||
"@angular/router": "^19.2.0",
|
"@angular/router": "^19.2.0",
|
||||||
|
"@angular/service-worker": "^19.2.0",
|
||||||
"@fontsource/fredoka": "^5.2.10",
|
"@fontsource/fredoka": "^5.2.10",
|
||||||
"@fontsource/nunito": "^5.2.7",
|
"@fontsource/nunito": "^5.2.7",
|
||||||
"@fontsource/opendyslexic": "^5.2.5",
|
"@fontsource/opendyslexic": "^5.2.5",
|
||||||
|
|||||||
BIN
frontend/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
frontend/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
frontend/public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
frontend/public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
62
frontend/public/manifest.webmanifest
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "recordaLexia",
|
||||||
|
"short_name": "recordaLexia",
|
||||||
|
"description": "Rutinas visuales y material del cole para niños con TDAH",
|
||||||
|
"lang": "es",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"scope": "./",
|
||||||
|
"start_url": "./",
|
||||||
|
"theme_color": "#5B8DEF",
|
||||||
|
"background_color": "#EFF4F6",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-152x152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
import { ApplicationConfig, provideZoneChangeDetection, isDevMode } from '@angular/core';
|
||||||
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { authInterceptor } from './core/auth.interceptor';
|
import { authInterceptor } from './core/auth.interceptor';
|
||||||
|
import { provideServiceWorker } from '@angular/service-worker';
|
||||||
|
|
||||||
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 familia.
|
// Cliente HTTP (fetch) con el interceptor de sesión de familia.
|
||||||
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
|
provideHttpClient(withFetch(), withInterceptors([authInterceptor])), provideServiceWorker('ngsw-worker.js', {
|
||||||
|
enabled: !isDevMode(),
|
||||||
|
registrationStrategy: 'registerWhenStable:30000'
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,13 +6,19 @@ import { KeypadComponent } from './features/parents/keypad.component';
|
|||||||
import { ParentsComponent } from './features/parents/parents.component';
|
import { ParentsComponent } from './features/parents/parents.component';
|
||||||
import { LoginComponent } from './features/auth/login.component';
|
import { LoginComponent } from './features/auth/login.component';
|
||||||
import { RegisterComponent } from './features/auth/register.component';
|
import { RegisterComponent } from './features/auth/register.component';
|
||||||
|
import { ForgotComponent } from './features/auth/forgot.component';
|
||||||
|
import { ResetComponent } from './features/auth/reset.component';
|
||||||
import { AccountComponent } from './features/auth/account.component';
|
import { AccountComponent } from './features/auth/account.component';
|
||||||
|
import { PrivacyComponent } from './features/legal/privacy.component';
|
||||||
import { authGuard, parentGuard } from './core/auth.guard';
|
import { authGuard, parentGuard } from './core/auth.guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
// Públicas.
|
// Públicas.
|
||||||
{ path: 'login', component: LoginComponent },
|
{ path: 'login', component: LoginComponent },
|
||||||
{ path: 'register', component: RegisterComponent },
|
{ path: 'register', component: RegisterComponent },
|
||||||
|
{ path: 'forgot', component: ForgotComponent },
|
||||||
|
{ path: 'reset', component: ResetComponent },
|
||||||
|
{ path: 'privacidad', component: PrivacyComponent },
|
||||||
|
|
||||||
// Requieren sesión de familia.
|
// Requieren sesión de familia.
|
||||||
{ path: '', component: ProfileSelectComponent, canActivate: [authGuard] },
|
{ path: '', component: ProfileSelectComponent, canActivate: [authGuard] },
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export class ApiService {
|
|||||||
return this.http.get<TodayResponse>(`${this.base}/children/${childId}/today`);
|
return this.http.get<TodayResponse>(`${this.base}/children/${childId}/today`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vista previa de MAÑANA (solo lectura): preparar la mochila la noche antes. */
|
||||||
|
getTomorrow(childId: number): Observable<TodayResponse> {
|
||||||
|
return this.http.get<TodayResponse>(`${this.base}/children/${childId}/tomorrow`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Marca/desmarca una tarea y devuelve el saldo y progreso actualizados. */
|
/** Marca/desmarca una tarea y devuelve el saldo y progreso actualizados. */
|
||||||
toggleTask(taskId: number): Observable<ToggleResult> {
|
toggleTask(taskId: number): Observable<ToggleResult> {
|
||||||
return this.http.post<ToggleResult>(`${this.base}/tasks/${taskId}/toggle`, {});
|
return this.http.post<ToggleResult>(`${this.base}/tasks/${taskId}/toggle`, {});
|
||||||
|
|||||||
@@ -24,13 +24,27 @@ export class AuthService {
|
|||||||
return this.sessionSignal();
|
return this.sessionSignal();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alta de familia (auto-login). */
|
/** Alta de familia (auto-login). Exige aceptar la política de privacidad (RGPD). */
|
||||||
register(email: string, password: string, name: string, pin: string): Observable<{ session: string }> {
|
register(email: string, password: string, name: string, pin: string,
|
||||||
|
privacyAccepted: boolean): Observable<{ session: string }> {
|
||||||
return this.http
|
return this.http
|
||||||
.post<{ session: string }>('/api/auth/register', { email, password, name, pin })
|
.post<{ session: string }>('/api/auth/register', { email, password, name, pin, privacyAccepted })
|
||||||
.pipe(tap((res) => this.store(res.session)));
|
.pipe(tap((res) => this.store(res.session)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Solicita el email de recuperación. La respuesta es neutra a propósito. */
|
||||||
|
forgotPassword(email: string): Observable<unknown> {
|
||||||
|
return this.http.post('/api/auth/forgot-password', { email });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restablece la contraseña con el código del enlace del email. */
|
||||||
|
resetPassword(code: string, nueva: string): Observable<void> {
|
||||||
|
// El DTO del backend espera la clave "newPassword".
|
||||||
|
const body: Record<string, string> = { code };
|
||||||
|
body['newPassword'] = nueva;
|
||||||
|
return this.http.post<void>('/api/auth/reset-password', body);
|
||||||
|
}
|
||||||
|
|
||||||
/** Acceso con email + contraseña. */
|
/** Acceso con email + contraseña. */
|
||||||
login(email: string, password: string): Observable<{ session: string }> {
|
login(email: string, password: string): Observable<{ session: string }> {
|
||||||
return this.http
|
return this.http
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export class I18nService {
|
|||||||
afternoon: { ES: 'TARDE', CA: 'TARDA' },
|
afternoon: { ES: 'TARDE', CA: 'TARDA' },
|
||||||
ready: { ES: 'listo', CA: 'fet' },
|
ready: { ES: 'listo', CA: 'fet' },
|
||||||
noSchool: { ES: 'HOY NO HAY COLE', CA: 'AVUI NO HI HA COLE' },
|
noSchool: { ES: 'HOY NO HAY COLE', CA: 'AVUI NO HI HA COLE' },
|
||||||
|
freeAfternoon: { ES: 'TARDE LIBRE', CA: 'TARDA LLIURE' },
|
||||||
|
emptyDayTitle: { ES: '¡Hoy no hay tareas!', CA: 'Avui no hi ha tasques!' },
|
||||||
|
emptyTomorrowTitle: { ES: '¡Mañana no hay tareas!', CA: 'Demà no hi ha tasques!' },
|
||||||
|
emptyDaySub: { ES: 'Disfruta del día', CA: 'Gaudeix del dia' },
|
||||||
|
today: { ES: 'Hoy', CA: 'Avui' },
|
||||||
|
tomorrow: { ES: 'Mañana', CA: 'Demà' },
|
||||||
|
tomorrowBanner: { ES: 'Así será mañana', CA: 'Així serà demà' },
|
||||||
allDone: { ES: '¡TODO LISTO!', CA: 'TOT FET!' },
|
allDone: { ES: '¡TODO LISTO!', CA: 'TOT FET!' },
|
||||||
great: { ES: '¡GENIAL!', CA: 'GENIAL!' },
|
great: { ES: '¡GENIAL!', CA: 'GENIAL!' },
|
||||||
done: { ES: '¡HECHO!', CA: 'FET!' },
|
done: { ES: '¡HECHO!', CA: 'FET!' },
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sonido de recompensa sintetizado con WebAudio (sin ficheros de audio). Un par
|
* Feedback de recompensa: sonido sintetizado con WebAudio (sin ficheros de
|
||||||
* de notas alegres al ganar monedas. Silencioso si WebAudio no está disponible.
|
* audio) y vibración háptica sutil en dispositivos que la soportan (tablets).
|
||||||
|
* Ambos se disparan sobre el MISMO evento (causalidad) para que se perciban
|
||||||
|
* como una sola respuesta. Silencioso/inerte si el navegador no lo soporta.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SoundService {
|
export class SoundService {
|
||||||
@@ -47,4 +49,11 @@ export class SoundService {
|
|||||||
osc.stop(start + 0.28);
|
osc.stop(start + 0.28);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vibración corta al ganar monedas (no-op si el dispositivo no vibra). */
|
||||||
|
buzz(): void {
|
||||||
|
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(15);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,31 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consentimiento RGPD: legible y tocable, casilla nunca premarcada.
|
||||||
|
.auth__consent {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-1);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex: none;
|
||||||
|
accent-color: var(--accent-blue);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-blue);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.auth__alt {
|
.auth__alt {
|
||||||
margin: var(--space-2) 0 0;
|
margin: var(--space-2) 0 0;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
|
|||||||
57
frontend/src/app/features/auth/forgot.component.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { AuthService } from '../../core/auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solicitud de recuperación de contraseña. El mensaje de confirmación es neutro
|
||||||
|
* a propósito (no revela si el email existe) y se muestra SIEMPRE, incluso si el
|
||||||
|
* backend limita por exceso de intentos: nada que un atacante pueda leer.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-forgot',
|
||||||
|
imports: [FormsModule, RouterLink],
|
||||||
|
template: `
|
||||||
|
<main class="auth">
|
||||||
|
<section class="auth__card">
|
||||||
|
<h1 class="auth__title">¿Contraseña olvidada? 🔑</h1>
|
||||||
|
|
||||||
|
@if (sent()) {
|
||||||
|
<p class="auth__sub">
|
||||||
|
Si el email existe, recibirás un enlace en unos minutos.
|
||||||
|
Revisa también la carpeta de spam.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<p class="auth__sub">Te enviaremos un enlace para crear una nueva</p>
|
||||||
|
<input class="auth__input" type="email" placeholder="Email" [(ngModel)]="email"
|
||||||
|
autocomplete="username" (keyup.enter)="submit()" />
|
||||||
|
<button class="auth__btn" [disabled]="loading() || !email" (click)="submit()">
|
||||||
|
{{ loading() ? 'Enviando…' : 'Enviar enlace' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="auth__alt"><a routerLink="/login">‹ Volver a entrar</a></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
`,
|
||||||
|
styleUrl: './auth.scss',
|
||||||
|
})
|
||||||
|
export class ForgotComponent {
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
|
||||||
|
protected email = '';
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly sent = signal(false);
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (!this.email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading.set(true);
|
||||||
|
this.auth.forgotPassword(this.email.trim()).subscribe({
|
||||||
|
next: () => this.sent.set(true),
|
||||||
|
// También en error mostramos el mensaje neutro: no filtramos nada.
|
||||||
|
error: () => this.sent.set(true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { AuthService } from '../../core/auth.service';
|
|||||||
<button class="auth__btn" [disabled]="loading() || !email || !password" (click)="submit()">
|
<button class="auth__btn" [disabled]="loading() || !email || !password" (click)="submit()">
|
||||||
{{ loading() ? 'Entrando…' : 'Entrar' }}
|
{{ loading() ? 'Entrando…' : 'Entrar' }}
|
||||||
</button>
|
</button>
|
||||||
|
<p class="auth__alt"><a routerLink="/forgot">¿Has olvidado la contraseña?</a></p>
|
||||||
<p class="auth__alt">¿No tienes cuenta? <a routerLink="/register">Crear una</a></p>
|
<p class="auth__alt">¿No tienes cuenta? <a routerLink="/register">Crear una</a></p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ import { AuthService } from '../../core/auth.service';
|
|||||||
<input class="auth__input" inputmode="numeric" maxlength="4" placeholder="PIN de padres (4 dígitos)"
|
<input class="auth__input" inputmode="numeric" maxlength="4" placeholder="PIN de padres (4 dígitos)"
|
||||||
[(ngModel)]="pin" />
|
[(ngModel)]="pin" />
|
||||||
|
|
||||||
|
<!-- RGPD: casilla NO premarcada; sin aceptación no hay cuenta. -->
|
||||||
|
<label class="auth__consent">
|
||||||
|
<input type="checkbox" [(ngModel)]="privacyAccepted" />
|
||||||
|
<span>He leído y acepto la <a routerLink="/privacidad">política de privacidad</a></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
@if (error()) { <p class="auth__err">{{ error() }}</p> }
|
@if (error()) { <p class="auth__err">{{ error() }}</p> }
|
||||||
|
|
||||||
<button class="auth__btn" [disabled]="loading() || !valid()" (click)="submit()">
|
<button class="auth__btn" [disabled]="loading() || !valid()" (click)="submit()">
|
||||||
@@ -40,11 +46,13 @@ export class RegisterComponent {
|
|||||||
protected email = '';
|
protected email = '';
|
||||||
protected password = '';
|
protected password = '';
|
||||||
protected pin = '';
|
protected pin = '';
|
||||||
|
protected privacyAccepted = false;
|
||||||
protected readonly loading = signal(false);
|
protected readonly loading = signal(false);
|
||||||
protected readonly error = signal<string | null>(null);
|
protected readonly error = signal<string | null>(null);
|
||||||
|
|
||||||
valid(): boolean {
|
valid(): boolean {
|
||||||
return !!this.email && this.password.length >= 6 && /^\d{4}$/.test(this.pin) && !!this.name;
|
return !!this.email && this.password.length >= 6 && /^\d{4}$/.test(this.pin) && !!this.name
|
||||||
|
&& this.privacyAccepted;
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
@@ -53,7 +61,8 @@ export class RegisterComponent {
|
|||||||
}
|
}
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.auth.register(this.email.trim(), this.password, this.name.trim(), this.pin).subscribe({
|
this.auth.register(this.email.trim(), this.password, this.name.trim(), this.pin,
|
||||||
|
this.privacyAccepted).subscribe({
|
||||||
next: () => this.auth.loadMe().subscribe(() => this.router.navigate(['/'])),
|
next: () => this.auth.loadMe().subscribe(() => this.router.navigate(['/'])),
|
||||||
error: (e: HttpErrorResponse) => {
|
error: (e: HttpErrorResponse) => {
|
||||||
this.error.set(e.status === 409 ? 'Ese email ya está registrado' : 'No se pudo crear la cuenta');
|
this.error.set(e.status === 409 ? 'Ese email ya está registrado' : 'No se pudo crear la cuenta');
|
||||||
|
|||||||
80
frontend/src/app/features/auth/reset.component.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { AuthService } from '../../core/auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restablecimiento de contraseña: llega desde el enlace del email con el código
|
||||||
|
* de un solo uso en la query (?code=...). Los errores son genéricos — el backend
|
||||||
|
* no distingue caducado/usado/inexistente y aquí tampoco.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reset',
|
||||||
|
imports: [FormsModule, RouterLink],
|
||||||
|
template: `
|
||||||
|
<main class="auth">
|
||||||
|
<section class="auth__card">
|
||||||
|
<h1 class="auth__title">Nueva contraseña 🔒</h1>
|
||||||
|
|
||||||
|
@if (done()) {
|
||||||
|
<p class="auth__sub">¡Listo! Ya puedes entrar con tu nueva contraseña.</p>
|
||||||
|
<button class="auth__btn" routerLink="/login">Ir a entrar</button>
|
||||||
|
} @else if (!code) {
|
||||||
|
<p class="auth__err">Este enlace no es válido. Pide uno nuevo.</p>
|
||||||
|
<p class="auth__alt"><a routerLink="/forgot">Pedir otro enlace</a></p>
|
||||||
|
} @else {
|
||||||
|
<p class="auth__sub">Elige una contraseña nueva (mínimo 6 caracteres)</p>
|
||||||
|
<input class="auth__input" type="password" placeholder="Nueva contraseña"
|
||||||
|
[(ngModel)]="nueva" autocomplete="new-password" />
|
||||||
|
<input class="auth__input" type="password" placeholder="Repítela"
|
||||||
|
[(ngModel)]="repetida" autocomplete="new-password" (keyup.enter)="submit()" />
|
||||||
|
|
||||||
|
@if (distintas()) { <p class="auth__err">Las contraseñas no coinciden</p> }
|
||||||
|
@if (error()) { <p class="auth__err">El enlace no es válido o ha caducado. Pide uno nuevo.</p> }
|
||||||
|
|
||||||
|
<button class="auth__btn" [disabled]="loading() || !valida()" (click)="submit()">
|
||||||
|
{{ loading() ? 'Guardando…' : 'Guardar contraseña' }}
|
||||||
|
</button>
|
||||||
|
<p class="auth__alt"><a routerLink="/login">‹ Volver a entrar</a></p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
`,
|
||||||
|
styleUrl: './auth.scss',
|
||||||
|
})
|
||||||
|
export class ResetComponent {
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
/** Código de un solo uso que viaja en el enlace del email. */
|
||||||
|
protected readonly code = inject(ActivatedRoute).snapshot.queryParamMap.get('code');
|
||||||
|
|
||||||
|
protected nueva = '';
|
||||||
|
protected repetida = '';
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly error = signal(false);
|
||||||
|
protected readonly done = signal(false);
|
||||||
|
|
||||||
|
protected distintas(): boolean {
|
||||||
|
return this.repetida.length > 0 && this.nueva !== this.repetida;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected valida(): boolean {
|
||||||
|
return this.nueva.length >= 6 && this.nueva === this.repetida;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (!this.code || !this.valida()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(false);
|
||||||
|
this.auth.resetPassword(this.code, this.nueva).subscribe({
|
||||||
|
next: () => this.done.set(true),
|
||||||
|
error: () => {
|
||||||
|
this.error.set(true);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,13 @@ import { TaskCardComponent, ToggleEvent } from './task-card.component';
|
|||||||
|
|
||||||
<section class="col">
|
<section class="col">
|
||||||
<h2 class="col__head">🌙 {{ i18n.t('afternoon') }}</h2>
|
<h2 class="col__head">🌙 {{ i18n.t('afternoon') }}</h2>
|
||||||
|
@if (afternoon.length) {
|
||||||
@for (task of afternoon; track task.id) {
|
@for (task of afternoon; track task.id) {
|
||||||
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
|
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
|
||||||
}
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="empty">🌈 {{ i18n.t('freeAfternoon') }}</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -11,12 +11,31 @@
|
|||||||
|
|
||||||
<app-morning-timer [minutes]="t.timer.minutesUntilDeparture" />
|
<app-morning-timer [minutes]="t.timer.minutesUntilDeparture" />
|
||||||
|
|
||||||
|
<!-- Ver mañana / volver a hoy (la vista de mañana es solo lectura) -->
|
||||||
|
<button type="button" class="daybtn" (click)="toggleDay()">
|
||||||
|
@if (viewingTomorrow()) {
|
||||||
|
‹ {{ i18n.t('today') }}
|
||||||
|
} @else {
|
||||||
|
{{ i18n.t('tomorrow') }} ›
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="home__wallet">
|
<div class="home__wallet">
|
||||||
<button type="button" class="iconbtn" (click)="goStore()" aria-label="Tienda">🎁</button>
|
<button type="button" class="iconbtn" (click)="goStore()" aria-label="Tienda">🎁</button>
|
||||||
<app-wallet #wallet [coins]="t.wallet.coins" />
|
<app-wallet #wallet [coins]="t.wallet.coins" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Aviso de vista previa: que el niño sepa que está mirando mañana -->
|
||||||
|
@if (viewingTomorrow()) {
|
||||||
|
<div class="tomorrow">👀 {{ i18n.t('tomorrowBanner') }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Eventos del día -->
|
||||||
|
<app-event-banner [events]="t.specialEvents" [ttsEnabled]="t.child.ttsEnabled" />
|
||||||
|
|
||||||
|
@if (t.progress.total > 0) {
|
||||||
|
@if (!viewingTomorrow()) {
|
||||||
<!-- Conmutador de modo -->
|
<!-- Conmutador de modo -->
|
||||||
<div class="modes">
|
<div class="modes">
|
||||||
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'BOARD'" (click)="setMode('BOARD')">
|
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'BOARD'" (click)="setMode('BOARD')">
|
||||||
@@ -27,14 +46,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Eventos del día -->
|
|
||||||
<app-event-banner [events]="t.specialEvents" [ttsEnabled]="t.child.ttsEnabled" />
|
|
||||||
|
|
||||||
<!-- Progreso global -->
|
<!-- Progreso global -->
|
||||||
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
|
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Tareas: tablero o foco -->
|
<!-- Tareas: tablero o foco (la vista de mañana siempre en tablero, solo lectura) -->
|
||||||
@if (mode() === 'BOARD') {
|
@if (mode() === 'BOARD' || viewingTomorrow()) {
|
||||||
<app-board-view
|
<app-board-view
|
||||||
[morning]="t.morning"
|
[morning]="t.morning"
|
||||||
[afternoon]="t.afternoon"
|
[afternoon]="t.afternoon"
|
||||||
@@ -44,6 +61,16 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<app-focus-view [tasksInput]="allTasks()" [ttsEnabled]="t.child.ttsEnabled" (toggle)="onToggle($event)" />
|
<app-focus-view [tasksInput]="allTasks()" [ttsEnabled]="t.child.ttsEnabled" (toggle)="onToggle($event)" />
|
||||||
}
|
}
|
||||||
|
} @else {
|
||||||
|
<!-- Día sin tareas: mensaje amable, nunca una pantalla en blanco. -->
|
||||||
|
<div class="freeday">
|
||||||
|
<span class="freeday__mascot">{{ t.child.mascot }}</span>
|
||||||
|
<p class="freeday__title">
|
||||||
|
{{ i18n.t(viewingTomorrow() ? 'emptyTomorrowTitle' : 'emptyDayTitle') }}
|
||||||
|
</p>
|
||||||
|
<p class="freeday__sub">{{ i18n.t('emptyDaySub') }} 🎉</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Monedas voladoras (capa superpuesta) -->
|
<!-- Monedas voladoras (capa superpuesta) -->
|
||||||
|
|||||||
@@ -63,6 +63,36 @@
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Botón "Mañana ›" / "‹ Hoy" de la cabecera.
|
||||||
|
.daybtn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
padding: 0 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-btn);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-1);
|
||||||
|
transition: transform var(--dur-press) var(--ease-out);
|
||||||
|
}
|
||||||
|
.daybtn:active { transform: scale(0.95); }
|
||||||
|
|
||||||
|
// Aviso de vista previa de mañana (solo lectura).
|
||||||
|
.tomorrow {
|
||||||
|
align-self: center;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: color-mix(in srgb, var(--accent-purple) 16%, #fff);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
animation: slideUp 0.3s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
// Conmutador Tablero / Foco.
|
// Conmutador Tablero / Foco.
|
||||||
.modes {
|
.modes {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -89,6 +119,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Día sin tareas: tarjeta amable con la mascota, nunca una pantalla vacía.
|
||||||
|
.freeday {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
text-align: center;
|
||||||
|
animation: slideUp 0.4s ease both;
|
||||||
|
|
||||||
|
&__mascot {
|
||||||
|
font-size: 96px;
|
||||||
|
line-height: 1;
|
||||||
|
animation: floatY 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
&__title {
|
||||||
|
margin: var(--space-3) 0 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
&__sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Moneda voladora: parte de la tarea y viaja al monedero.
|
// Moneda voladora: parte de la tarea y viaja al monedero.
|
||||||
.flycoin {
|
.flycoin {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export class HomeComponent implements OnInit {
|
|||||||
protected readonly coins = signal<FlyingCoin[]>([]);
|
protected readonly coins = signal<FlyingCoin[]>([]);
|
||||||
protected readonly celebrating = signal(false);
|
protected readonly celebrating = signal(false);
|
||||||
protected readonly lastEarned = signal(0);
|
protected readonly lastEarned = signal(0);
|
||||||
|
/** true mientras se muestra la vista previa (solo lectura) de mañana. */
|
||||||
|
protected readonly viewingTomorrow = signal(false);
|
||||||
|
|
||||||
private childId!: number;
|
private childId!: number;
|
||||||
private coinSeq = 0;
|
private coinSeq = 0;
|
||||||
@@ -69,14 +71,18 @@ export class HomeComponent implements OnInit {
|
|||||||
return t ? [...t.morning, ...t.afternoon] : [];
|
return t ? [...t.morning, ...t.afternoon] : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Fecha de hoy formateada en el idioma activo. */
|
/** Fecha mostrada (hoy o mañana, según la vista) formateada en el idioma activo. */
|
||||||
protected readonly dateLabel = computed(() => {
|
protected readonly dateLabel = computed(() => {
|
||||||
const locale = this.i18n.lang() === 'CA' ? 'ca-ES' : 'es-ES';
|
const locale = this.i18n.lang() === 'CA' ? 'ca-ES' : 'es-ES';
|
||||||
|
const date = new Date();
|
||||||
|
if (this.viewingTomorrow()) {
|
||||||
|
date.setDate(date.getDate() + 1);
|
||||||
|
}
|
||||||
return new Intl.DateTimeFormat(locale, {
|
return new Intl.DateTimeFormat(locale, {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
}).format(new Date());
|
}).format(date);
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -107,8 +113,25 @@ export class HomeComponent implements OnInit {
|
|||||||
this.api.updateSettings(this.childId, { viewMode: mode }).subscribe();
|
this.api.updateSettings(this.childId, { viewMode: mode }).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Alterna entre hoy y la vista previa (solo lectura) de mañana. */
|
||||||
|
toggleDay(): void {
|
||||||
|
if (this.viewingTomorrow()) {
|
||||||
|
this.viewingTomorrow.set(false);
|
||||||
|
this.load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.api.getTomorrow(this.childId).subscribe((data) => {
|
||||||
|
this.today.set(data);
|
||||||
|
this.viewingTomorrow.set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Marca/desmarca una tarea y aplica el feedback (monedas, sonido, celebración). */
|
/** Marca/desmarca una tarea y aplica el feedback (monedas, sonido, celebración). */
|
||||||
onToggle(ev: ToggleEvent): void {
|
onToggle(ev: ToggleEvent): void {
|
||||||
|
// La vista de mañana es solo lectura: mirar, no marcar.
|
||||||
|
if (this.viewingTomorrow()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.api.toggleTask(ev.taskId).subscribe((result) => {
|
this.api.toggleTask(ev.taskId).subscribe((result) => {
|
||||||
const current = this.today();
|
const current = this.today();
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -127,12 +150,14 @@ export class HomeComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
this.today.set(updated);
|
this.today.set(updated);
|
||||||
|
|
||||||
// Feedback positivo solo al ganar monedas (marcar, no desmarcar).
|
// Feedback positivo solo al ganar monedas (marcar, no desmarcar). Sonido y
|
||||||
|
// vibración se disparan juntos sobre el mismo evento (armonía multimodal).
|
||||||
if (result.coinsEarned > 0) {
|
if (result.coinsEarned > 0) {
|
||||||
this.flyCoins(ev.x, ev.y, result.coinsEarned);
|
this.flyCoins(ev.x, ev.y, result.coinsEarned);
|
||||||
this.wallet?.bump();
|
this.wallet?.bump();
|
||||||
if (current.child.soundEnabled) {
|
if (current.child.soundEnabled) {
|
||||||
this.sound.playReward();
|
this.sound.playReward();
|
||||||
|
this.sound.buzz();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,9 +48,11 @@ export interface ToggleEvent {
|
|||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.15s, border-color 0.25s, background 0.25s;
|
transition: transform var(--dur-press) var(--ease-out),
|
||||||
|
border-color var(--dur-settle) var(--ease-out), background var(--dur-settle) var(--ease-out);
|
||||||
}
|
}
|
||||||
.card:active { transform: scale(0.99); }
|
/* Respuesta al toque: visible e inmediata (es el control que el niño más pulsa). */
|
||||||
|
.card:active { transform: scale(0.97); }
|
||||||
.card__tile {
|
.card__tile {
|
||||||
width: var(--tile-size);
|
width: var(--tile-size);
|
||||||
height: var(--tile-size);
|
height: var(--tile-size);
|
||||||
@@ -68,16 +70,25 @@ export interface ToggleEvent {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
/* Las mayúsculas necesitan tracking positivo para respirar (legibilidad). */
|
||||||
|
letter-spacing: 0.03em;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
.card__tts {
|
.card__tts {
|
||||||
all: unset;
|
all: unset;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
padding: 6px;
|
width: var(--touch-min);
|
||||||
|
height: var(--touch-min);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
transition: transform var(--dur-press) var(--ease-out),
|
||||||
|
background var(--dur-settle) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
.card__tts:active { transform: scale(0.9); background: var(--surface-softer); }
|
||||||
.card__check {
|
.card__check {
|
||||||
width: var(--touch-check);
|
width: var(--touch-check);
|
||||||
height: var(--touch-check);
|
height: var(--touch-check);
|
||||||
|
|||||||
88
frontend/src/app/features/legal/privacy.component.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Política de privacidad (RGPD). Pública, sin sesión, bilingüe ES/CA en la misma
|
||||||
|
* página (texto legal: mejor ambas versiones visibles que un conmutador).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-privacy',
|
||||||
|
imports: [RouterLink],
|
||||||
|
template: `
|
||||||
|
<main class="privacy">
|
||||||
|
<article class="privacy__card">
|
||||||
|
<h1>Política de privacidad · Política de privacitat</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>🇪🇸 Español</h2>
|
||||||
|
<p><strong>Qué guardamos.</strong> Lo mínimo para que la app funcione: el email y
|
||||||
|
el nombre de la cuenta de familia, y de cada niño solo su nombre de pila, edad,
|
||||||
|
una mascota y un color. No pedimos apellidos, ni fotos, ni datos de salud, ni
|
||||||
|
ubicación. Las tareas, rutinas y monedas que se generan al usar la app.</p>
|
||||||
|
<p><strong>Para qué.</strong> Únicamente para mostrar a cada familia sus rutinas.
|
||||||
|
No usamos los datos para publicidad, no los analizamos con fines comerciales y
|
||||||
|
no los cedemos a nadie.</p>
|
||||||
|
<p><strong>Dónde.</strong> En un servidor gestionado por el responsable de la
|
||||||
|
aplicación, con copias de seguridad cifradas. Las contraseñas y el PIN se
|
||||||
|
guardan con hash seguro: nadie puede leerlos, ni siquiera nosotros.</p>
|
||||||
|
<p><strong>Tus derechos.</strong> Puedes borrar la cuenta desde
|
||||||
|
Cuenta → Borrar cuenta: se elimina TODO (niños, tareas, historial) de forma
|
||||||
|
inmediata e irreversible. Para cualquier otra petición sobre tus datos,
|
||||||
|
escríbenos al correo del pie de la aplicación.</p>
|
||||||
|
<p><strong>Menores.</strong> La cuenta la crea y administra siempre una persona
|
||||||
|
adulta. Los perfiles de los niños no contienen datos identificativos más allá
|
||||||
|
del nombre de pila que su familia decida.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>🏴 Català</h2>
|
||||||
|
<p><strong>Què guardem.</strong> El mínim perquè l'app funcioni: l'email i el nom
|
||||||
|
del compte de família, i de cada infant només el nom de pila, l'edat, una
|
||||||
|
mascota i un color. No demanem cognoms, ni fotos, ni dades de salut, ni
|
||||||
|
ubicació. Les tasques, rutines i monedes que es generen en fer servir l'app.</p>
|
||||||
|
<p><strong>Per a què.</strong> Únicament per mostrar a cada família les seves
|
||||||
|
rutines. No fem servir les dades per a publicitat, no les analitzem amb fins
|
||||||
|
comercials i no les cedim a ningú.</p>
|
||||||
|
<p><strong>On.</strong> En un servidor gestionat pel responsable de l'aplicació,
|
||||||
|
amb còpies de seguretat xifrades. Les contrasenyes i el PIN es guarden amb hash
|
||||||
|
segur: ningú no els pot llegir, ni tan sols nosaltres.</p>
|
||||||
|
<p><strong>Els teus drets.</strong> Pots esborrar el compte des de
|
||||||
|
Compte → Esborrar compte: s'elimina TOT (infants, tasques, historial) de manera
|
||||||
|
immediata i irreversible. Per a qualsevol altra petició sobre les teves dades,
|
||||||
|
escriu-nos al correu del peu de l'aplicació.</p>
|
||||||
|
<p><strong>Menors.</strong> El compte el crea i l'administra sempre una persona
|
||||||
|
adulta. Els perfils dels infants no contenen dades identificatives més enllà
|
||||||
|
del nom de pila que la seva família decideixi.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="privacy__back"><a routerLink="/register">‹ Volver · Tornar</a></p>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host { display: block; }
|
||||||
|
.privacy {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
.privacy__card {
|
||||||
|
max-width: 720px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-1);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--space-6);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.5rem; margin-top: 0; }
|
||||||
|
h2 { font-size: 1.15rem; color: var(--accent-blue); }
|
||||||
|
p { line-height: 1.6; color: var(--text-1); }
|
||||||
|
strong { color: var(--text-strong); }
|
||||||
|
.privacy__back a { color: var(--accent-blue); font-weight: 700; text-decoration: none; }
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class PrivacyComponent {
|
||||||
|
}
|
||||||
@@ -56,7 +56,9 @@ import { AuthService } from '../../core/auth.service';
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
|
transition: transform var(--dur-press) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
.key-screen__back:active { transform: scale(0.94); }
|
||||||
.key-screen__title { margin: 0; font-family: var(--font-display); font-weight: 700; font-size: 1.4rem; }
|
.key-screen__title { margin: 0; font-family: var(--font-display); font-weight: 700; font-size: 1.4rem; }
|
||||||
.dots { display: flex; gap: 18px; }
|
.dots { display: flex; gap: 18px; }
|
||||||
.dots--error { animation: shake 0.4s ease; }
|
.dots--error { animation: shake 0.4s ease; }
|
||||||
@@ -79,7 +81,7 @@ import { AuthService } from '../../core/auth.service';
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
transition: transform 0.1s;
|
transition: transform var(--dur-press) var(--ease-out);
|
||||||
}
|
}
|
||||||
.key:active { transform: scale(0.94); }
|
.key:active { transform: scale(0.94); }
|
||||||
.key--fn { background: var(--surface-softer); font-size: 1.4rem; }
|
.key--fn { background: var(--surface-softer); font-size: 1.4rem; }
|
||||||
|
|||||||
@@ -210,13 +210,12 @@ export class RoutinesTabComponent {
|
|||||||
if (this.selectedTaskId === null || this.selectedDays().length === 0) {
|
if (this.selectedTaskId === null || this.selectedDays().length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const order = this.routinesForDay().length;
|
// El backend calcula el orden al final de CADA día destino.
|
||||||
this.api
|
this.api
|
||||||
.createRoutine({
|
.createRoutine({
|
||||||
childId: this._childId,
|
childId: this._childId,
|
||||||
routineTaskId: this.selectedTaskId,
|
routineTaskId: this.selectedTaskId,
|
||||||
daysOfWeek: this.selectedDays(),
|
daysOfWeek: this.selectedDays(),
|
||||||
orderIndex: order,
|
|
||||||
})
|
})
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.selectedTaskId = null;
|
this.selectedTaskId = null;
|
||||||
|
|||||||
@@ -73,14 +73,15 @@
|
|||||||
border: var(--card-border-width) solid color-mix(in srgb, var(--c) 35%, #fff);
|
border: var(--card-border-width) solid color-mix(in srgb, var(--c) 35%, #fff);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
transition: transform 0.15s, box-shadow 0.2s;
|
transition: transform var(--dur-press) var(--ease-out), box-shadow var(--dur-settle) var(--ease-out);
|
||||||
animation: slideUp 0.4s ease both;
|
animation: slideUp 0.4s ease both;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: var(--shadow-soft);
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
&:active { transform: scale(0.98); }
|
// Mantiene la elevación del hover al pulsar: sin salto entre ambos estados.
|
||||||
|
&:active { transform: translateY(-2px) scale(0.97); }
|
||||||
|
|
||||||
&__mascot {
|
&__mascot {
|
||||||
font-size: 84px;
|
font-size: 84px;
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ button {
|
|||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mata el retardo de doble-tap del navegador en la tablet kiosko: la pulsación
|
||||||
|
// debe responder al instante (se conserva el scroll y el pinch-zoom de página).
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
select,
|
||||||
|
input,
|
||||||
|
[role='button'] {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers del panel de padres (formularios de administración) ---
|
// --- Helpers del panel de padres (formularios de administración) ---
|
||||||
.adm-card {
|
.adm-card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -112,6 +122,7 @@ button {
|
|||||||
background: var(--accent-blue);
|
background: var(--accent-blue);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: transform var(--dur-press) var(--ease-out);
|
||||||
}
|
}
|
||||||
.adm-btn:active { transform: scale(0.97); }
|
.adm-btn:active { transform: scale(0.97); }
|
||||||
.adm-btn:disabled { opacity: 0.4; cursor: default; }
|
.adm-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
@@ -120,9 +131,17 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--accent-pink);
|
color: var(--accent-pink);
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
padding: 4px 8px;
|
// Área táctil mínima real aunque el glifo sea pequeño.
|
||||||
|
min-width: var(--touch-min);
|
||||||
|
min-height: var(--touch-min);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
transition: transform var(--dur-press) var(--ease-out), background var(--dur-settle) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
.adm-del:active { transform: scale(0.9); background: var(--surface-softer); }
|
||||||
|
.adm-del:disabled { opacity: 0.35; cursor: default; }
|
||||||
.adm-list { display: flex; flex-direction: column; gap: 10px; }
|
.adm-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
.adm-item {
|
.adm-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -136,12 +155,21 @@ button {
|
|||||||
.adm-chip {
|
.adm-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 12px;
|
// Los chips actúan como botones (selector de días): área táctil de 44px mínimo.
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 0;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
background: var(--surface-softer);
|
background: var(--surface-softer);
|
||||||
|
font-family: var(--font-display);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-1);
|
color: var(--text-1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--dur-press) var(--ease-out), background var(--dur-settle) var(--ease-out),
|
||||||
|
color var(--dur-settle) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
.adm-chip:active { transform: scale(0.95); }
|
||||||
.adm-empty { color: var(--text-3); padding: var(--space-4); text-align: center; }
|
.adm-empty { color: var(--text-3); padding: var(--space-4); text-align: center; }
|
||||||
.adm-label { font-weight: 600; color: var(--text-1); }
|
.adm-label { font-weight: 600; color: var(--text-1); }
|
||||||
|
|||||||