Compare commits

..

14 Commits

Author SHA1 Message Date
Jaume Garriga Maestre
3dfe0218a4 docs: manual con PWA y recuperación de contraseña + ADR-004
Manual: instalación como app (añadir a pantalla de inicio), recuperación
de contraseña con captura real, login actualizado y FAQ ampliado (olvido
de contraseña, borrado de datos). PDF regenerado.
ADR-004: decisiones de madurez operativa SaaS (rate-limiter propio, código
de recuperación hasheado y anti-enumeración, seeder fail-safe, RGPD
verificable, PWA sin stores, producción de superficie mínima).
2026-07-13 22:41:47 +02:00
Jaume Garriga Maestre
6b655634ca feat(saas): infraestructura de producción para la VM
Bloque C de la fase SaaS (ficheros; el despliegue se hace con el runbook):
- docker-compose.prod.yml: superficie mínima (solo el frontend en
  127.0.0.1:8089 para el nginx nativo), límites de memoria, sin seeder.
- deploy/nginx-recordalexia.conf: server block con cabeceras de seguridad,
  X-Forwarded-For para el rate-limiter y preparado para certbot.
- deploy/backup.sh: pg_dump diario comprimido, retención 14, copia externa
  vía rclone y detección de backups vacíos.
- .env.prod.example (y .env.prod en .gitignore).
- docs/operacion-saas.md: runbook con primer despliegue, checklist,
  restore ENSAYADO paso a paso, actualización y problemas conocidos.
2026-07-13 22:33:12 +02:00
Jaume Garriga Maestre
ddd1a96af9 feat(saas): PWA instalable y pantallas de recuperación y privacidad
Bloque B de la fase SaaS:
- @angular/pwa: service worker (solo assets; /api siempre fresco), manifest
  con identidad recordaLexia (standalone, tema de la paleta) e iconos
  propios 72-512px (mascota sobre azul de marca).
- Pantallas /forgot y /reset (?code=... del email): mensajes neutros que
  no revelan si una cuenta existe; validación de contraseña repetida.
- Política de privacidad ES/CA en /privacidad (pública) y casilla de
  consentimiento obligatoria en el registro (no premarcada).
- AuthService: forgotPassword/resetPassword y registro con privacyAccepted.
Verificado E2E en navegador: registro con gating del checkbox, enlace del
log, reset, login con la nueva contraseña (la vieja 401), SW 'activated',
y familia de prueba borrada con el endpoint RGPD (dogfooding).
2026-07-13 22:27:29 +02:00
Jaume Garriga Maestre
203d9b0b47 feat(saas): recuperación de contraseña, freno de fuerza bruta y hardening
Bloque A de la fase SaaS:
- Recuperación por email: código de un solo uso (solo su SHA-256 en BD),
  caducidad 30 min, emitir uno nuevo invalida los anteriores, respuesta
  idéntica exista o no la cuenta (anti-enumeración) y email asíncrono;
  restablecer cierra todas las sesiones de la familia. MailService cae a
  modo log sin SMTP configurado.
- LoginAttemptService: 5 fallos -> bloqueo con backoff exponencial (429 +
  Retry-After) en login, PIN del panel y solicitudes de reset, comprobado
  ANTES de evaluar credenciales.
- Registro exige aceptar la política de privacidad (privacy_accepted_at).
- Borrado definitivo de cuenta (RGPD) con confirmación por contraseña.
- CORS por configuración; seeder demo fail-safe (matchIfMissing=false,
  activado explícito en el compose local); application-prod.yml.
- Liquibase 005 con rollback. 44 tests verdes incl. migración en Postgres.
2026-07-13 22:07:06 +02:00
Jaume Garriga Maestre
4606f71bdc docs(manual): sección de la vista de mañana con captura real 2026-07-12 22:50:54 +02:00
Jaume Garriga Maestre
e5385f7ddd feat(hoy): vista previa de mañana en el kiosko del niño
Botón "Mañana ›" en la cabecera: muestra el día siguiente en solo
lectura para preparar la mochila la noche antes. La vista se calcula al
vuelo desde las plantillas SIN materializar DailyTask (los cambios del
panel de esa noche siguen entrando al generarse el día de verdad), con
ids sintéticos negativos no accionables, sin temporizador ni progreso.
Banner "Así será mañana" y estados vacíos propios. Test de integración:
la vista previa no persiste tareas del día siguiente.
2026-07-12 22:37:39 +02:00
Jaume Garriga Maestre
4eccee9b05 feat(demo): horario real de 4t A para Leo en el seeder
La familia demo siembra el horario escolar real de 4º de primaria para
Leo (11 asignaturas nuevas bilingües + 29 entradas semanales en orden
cronológico; Piscina lleva la Toalla asociada). Nora y Mía conservan el
horario genérico. Test que fija el lunes completo de Leo.
2026-07-12 22:29:17 +02:00
Jaume Garriga Maestre
1db262f8ea fix(hoy): el temporizador de salida desaparece sin cole o pasada la hora
buildTimer clavaba el contador en "0 min" el resto del día (Math.max(0,..)),
incluso en días sin cole. Ahora es función pura: sin tareas de mañana o con
la hora de salida ya pasada devuelve minutos null y el kiosko no lo pinta.
Test unitario determinista de los cinco casos.
2026-07-12 22:12:44 +02:00
Jaume Garriga Maestre
0e8bfbcc9c docs(adr): decisiones de auth, catálogo de rutinas y provisión inicial 2026-07-12 22:08:16 +02:00
Jaume Garriga Maestre
5c79480aa4 feat(home): estado vacío amable y vibración háptica al ganar monedas
Un día sin tareas muestra una tarjeta con la mascota del niño en vez de
una pantalla en blanco; la columna de tarde vacía tiene su propio mensaje.
El feedback de recompensa suma una vibración corta (Vibration API) que se
dispara junto al sonido sobre el mismo evento.
2026-07-12 22:06:03 +02:00
Jaume Garriga Maestre
3ec611d373 fix(rutinas): cascade al borrar del catálogo, orden por día y test de migración real
- Changeset 004: ON DELETE CASCADE en fk_routine_assignment_task; borrar
  una rutina del catálogo arrastra sus asignaciones en vez de fallar (test).
- El orderIndex de la asignación multi-día lo calcula el backend al final
  de CADA día destino (antes lo enviaba el front desde el día visible).
- MigrationIT con Testcontainers: el changelog completo y el mapeo JPA se
  verifican contra Postgres 16 real (H2 no detecta su tipado estricto).
- DataSeeder delega en InitialDatasetService: una sola fuente de verdad
  del dataset inicial; la demo solo añade sus extras.
- Eliminado .gitkeep de changes/ (WARN de Liquibase en cada arranque).
2026-07-12 22:02:22 +02:00
Jaume Garriga Maestre
5421080b04 feat(ui): respuesta táctil y áreas de toque según guía de fluidez
Tokens de movimiento centralizados (duraciones y curvas únicas), feedback
de pulsación visible e inmediato en tarjeta de tarea, TTS, chips, teclado
y navegación; áreas táctiles mínimas de 44-48px en controles pequeños;
tracking positivo en etiquetas en mayúsculas; touch-action manipulation
para eliminar el retardo de doble-tap en la tablet kiosko.
2026-07-12 21:35:28 +02:00
Jaume Garriga Maestre
08c7289f25 docs(manual): exportar manual de usuario a PDF 2026-06-23 14:49:25 +02:00
Jaume Garriga Maestre
c165b759b7 docs(manual): manual de usuario con capturas de la app 2026-06-23 14:32:21 +02:00
101 changed files with 3221 additions and 620 deletions

25
.env.prod.example Normal file
View 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
View File

@@ -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

View File

@@ -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'
} }

View File

@@ -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) {
}
} }

View File

@@ -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 {
}

View File

@@ -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;
}
} }

View File

@@ -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;
}
}

View File

@@ -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()));
}
} }

View File

@@ -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;
}
}

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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);
});
}
}

View File

@@ -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();

View File

@@ -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);
}
}
}

View File

@@ -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);
} }
} }

View File

@@ -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"));

View File

@@ -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();
}
} }

View File

@@ -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);

View File

@@ -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();
} }
} }

View File

@@ -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();

View File

@@ -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) {

View 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.

View File

@@ -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}

View File

@@ -1 +0,0 @@
# Los changesets de Liquibase de la Fase 2 (dominio) vivirán aquí.

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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);
} }
} }

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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");

View File

@@ -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());
}
}

View File

@@ -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
View 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

View 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
View 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:

View File

@@ -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"

View 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`

View 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}`

View 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)

View 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`

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
docs/manual/img/06-pin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View 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.
![Pantalla de inicio de sesión](img/01-login.png)
> 💡 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.
![Recuperar contraseña](img/14-recuperar.png)
---
## 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.
![Selección de perfil](img/02-perfiles.png)
- 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.
![Vista de tablero](img/03-home-board.png)
- 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**).
![Modo foco](img/04-home-foco.png)
### 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.
![Vista de mañana](img/13-manana.png)
- 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.
![Tienda de recompensas](img/05-tienda.png)
---
## 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`.)
![Teclado del PIN](img/06-pin.png)
> 🔐 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.
![Pestaña Niños](img/07-panel-padres.png)
> 💡 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.
![Pestaña Horario](img/09-horario.png)
### 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.
![Pestaña Materiales](img/10-materiales.png)
### 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.
![Pestaña Rutinas](img/08-rutinas.png)
> 💡 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.
![Pestaña Eventos](img/12-eventos.png)
### 6.6 Recompensas 🪙
Premios de la tienda: texto bilingüe, emoji, color y **coste en monedas**. Puedes
activarlos o desactivarlos.
![Pestaña Recompensas](img/11-recompensas.png)
---
## 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.*

Binary file not shown.

53
docs/manual/pdf.css Normal file
View 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
View 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?»).

View File

@@ -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
View 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)"
]
}
}
]
}

View File

@@ -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",

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View 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"
}
]
}

View File

@@ -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'
}),
], ],
}; };

View File

@@ -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] },

View File

@@ -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`, {});

View File

@@ -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

View File

@@ -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!' },

View File

@@ -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);
}
}
} }

View File

@@ -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);

View 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),
});
}
}

View File

@@ -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>

View File

@@ -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');

View 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);
},
});
}
}

View File

@@ -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>
`, `,

View File

@@ -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) -->

View File

@@ -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;

View File

@@ -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();
} }
} }

View File

@@ -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);

View 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 {
}

View File

@@ -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; }

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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); }

Some files were not shown because too many files have changed in this diff Show More