diff --git a/mobile/lib/core/network/api_client.dart b/mobile/lib/core/network/api_client.dart index 870fd8c..75072f0 100644 --- a/mobile/lib/core/network/api_client.dart +++ b/mobile/lib/core/network/api_client.dart @@ -1,4 +1,6 @@ import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'auth_interceptor.dart'; /// Single entry point for all HTTP calls, routed through api-gateway. /// Never hit a microservice's own port directly from the app. @@ -6,7 +8,7 @@ class ApiClient { static const String baseUrl = 'http://localhost:8080'; late final Dio dio; - ApiClient() { + ApiClient(FlutterSecureStorage storage) { dio = Dio( BaseOptions( baseUrl: baseUrl, @@ -15,9 +17,10 @@ class ApiClient { headers: {'Content-Type': 'application/json'}, ), ); + dio.interceptors.add(AuthInterceptor(storage)); } /// Test-only constructor: injects a (typically mocked) Dio instance - /// directly, bypassing real network configuration. + /// directly, bypassing real network configuration and the auth interceptor. ApiClient.withDio(this.dio); -} \ No newline at end of file +} diff --git a/mobile/lib/core/network/auth_interceptor.dart b/mobile/lib/core/network/auth_interceptor.dart new file mode 100644 index 0000000..99b8b04 --- /dev/null +++ b/mobile/lib/core/network/auth_interceptor.dart @@ -0,0 +1,20 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Attaches the stored access token to every outgoing request. Without this, +/// every call is rejected with 401 by api-gateway's JwtAuthenticationFilter +/// (except the public /api/auth/** routes). +class AuthInterceptor extends Interceptor { + final FlutterSecureStorage _storage; + + AuthInterceptor(this._storage); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + final token = await _storage.read(key: 'access_token'); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } +} diff --git a/mobile/lib/features/auth/presentation/auth_providers.dart b/mobile/lib/features/auth/presentation/auth_providers.dart index e2ed03e..c932389 100644 --- a/mobile/lib/features/auth/presentation/auth_providers.dart +++ b/mobile/lib/features/auth/presentation/auth_providers.dart @@ -4,7 +4,7 @@ import '../../../core/network/api_client.dart'; import '../data/auth_repository.dart'; import '../domain/auth_models.dart'; -final apiClientProvider = Provider((ref) => ApiClient()); +final apiClientProvider = Provider((ref) => ApiClient(ref.watch(secureStorageProvider))); final authRepositoryProvider = Provider((ref) { return AuthRepository(ref.watch(apiClientProvider)); @@ -21,20 +21,46 @@ class AuthController extends StateNotifier { Future login(String email, String password) async { final result = await _repository.login(email: email, password: password); - await _storage.write(key: 'access_token', value: result.accessToken); - await _storage.write(key: 'refresh_token', value: result.refreshToken); - await _storage.write(key: 'account_id', value: result.id); + await _persistSession(result); state = result; return result; } Future register(String email, String password) async { final result = await _repository.register(email: email, password: password); + await _persistSession(result); + state = result; + return result; + } + + Future _persistSession(AuthResponse result) async { await _storage.write(key: 'access_token', value: result.accessToken); await _storage.write(key: 'refresh_token', value: result.refreshToken); await _storage.write(key: 'account_id', value: result.id); - state = result; - return result; + await _storage.write(key: 'email', value: result.email); + await _storage.write(key: 'role', value: result.role); + } + + /// Reconstructs the session from secure storage on app startup (page + /// reload, direct URL navigation, hot restart). Without this, the + /// in-memory-only state means every reload silently logs the user out + /// even though valid tokens are still on disk. + Future restoreSession() async { + final accessToken = await _storage.read(key: 'access_token'); + final refreshToken = await _storage.read(key: 'refresh_token'); + final accountId = await _storage.read(key: 'account_id'); + final email = await _storage.read(key: 'email'); + final role = await _storage.read(key: 'role'); + + if (accessToken != null && refreshToken != null && accountId != null && email != null && role != null) { + state = AuthResponse( + id: accountId, + accessToken: accessToken, + refreshToken: refreshToken, + email: email, + role: role, + ); + } } Future logout() async { @@ -50,4 +76,10 @@ final authControllerProvider = StateNotifierProvider((ref) { return ref.watch(authControllerProvider)?.id; -}); \ No newline at end of file +}); +/// Gate used at app startup: the app waits for this to complete before +/// building any real screen, so no route ever runs with a stale/null +/// session while restoreSession() is still in flight. +final sessionRestorationProvider = FutureProvider((ref) async { + await ref.read(authControllerProvider.notifier).restoreSession(); +}); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 54939c1..5735281 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -2,9 +2,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/router/app_router.dart'; import 'core/theme/app_theme.dart'; +import 'features/auth/presentation/auth_providers.dart'; -void main() { - runApp(const ProviderScope(child: AfriLinguaApp())); +Future main() async { + // Restaure la session AVANT de construire l'arbre de widgets, pour que + // go_router (via MaterialApp.router) evalue l'URL initiale du navigateur + // avec un accountIdProvider deja peuple. Ca evite a la fois le flash + // "login -> ecran demande" et le bug de parsing d'URL introduit par une + // precedente tentative avec un MaterialApp non-router imbrique. + final container = ProviderContainer(); + await container.read(authControllerProvider.notifier).restoreSession(); + + runApp( + UncontrolledProviderScope( + container: container, + child: const AfriLinguaApp(), + ), + ); } class AfriLinguaApp extends ConsumerWidget { @@ -13,7 +27,6 @@ class AfriLinguaApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(routerProvider); - return MaterialApp.router( title: 'AfriLingua', debugShowCheckedModeBanner: false, @@ -22,4 +35,3 @@ class AfriLinguaApp extends ConsumerWidget { ); } } - diff --git a/services/api-gateway/pom.xml b/services/api-gateway/pom.xml index 54d4481..dfb1689 100644 --- a/services/api-gateway/pom.xml +++ b/services/api-gateway/pom.xml @@ -20,6 +20,7 @@ 21 2023.0.3 + 0.12.6 @@ -35,6 +36,23 @@ org.springframework.boot spring-boot-starter-actuator + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + org.springframework.boot spring-boot-starter-test diff --git a/services/api-gateway/src/main/java/cm/afrilingua/gateway/ApiGatewayApplication.java b/services/api-gateway/src/main/java/cm/afrilingua/gateway/ApiGatewayApplication.java index 67921d7..5b854cd 100644 --- a/services/api-gateway/src/main/java/cm/afrilingua/gateway/ApiGatewayApplication.java +++ b/services/api-gateway/src/main/java/cm/afrilingua/gateway/ApiGatewayApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication +@ConfigurationPropertiesScan public class ApiGatewayApplication { public static void main(String[] args) { SpringApplication.run(ApiGatewayApplication.class, args); diff --git a/services/api-gateway/src/main/java/cm/afrilingua/gateway/config/JwtProperties.java b/services/api-gateway/src/main/java/cm/afrilingua/gateway/config/JwtProperties.java new file mode 100644 index 0000000..1ffd173 --- /dev/null +++ b/services/api-gateway/src/main/java/cm/afrilingua/gateway/config/JwtProperties.java @@ -0,0 +1,8 @@ +package cm.afrilingua.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Mirrors auth-service's JwtProperties -- must share the same secret-key so + * tokens signed by auth-service can be verified here. */ +@ConfigurationProperties(prefix = "application.security.jwt") +public record JwtProperties(String secretKey) {} diff --git a/services/api-gateway/src/main/java/cm/afrilingua/gateway/security/JwtAuthenticationFilter.java b/services/api-gateway/src/main/java/cm/afrilingua/gateway/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..6d728c4 --- /dev/null +++ b/services/api-gateway/src/main/java/cm/afrilingua/gateway/security/JwtAuthenticationFilter.java @@ -0,0 +1,100 @@ +package cm.afrilingua.gateway.security; + +import cm.afrilingua.gateway.config.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Validates the JWT on every request except the public auth routes, then + * strips the client-supplied Authorization header and replaces it with a + * trusted X-User-Id header before forwarding downstream. Internal services + * never need to know about JWT at all -- they just trust X-User-Id, because + * this filter is the only entry point into the system (services are not + * exposed directly, only through this gateway). + * + * This is the single point of authentication for the whole platform: no + * other service validates tokens. Keeping it here (rather than duplicating + * JWT validation in every microservice) is what makes X-User-Id trustworthy + * downstream -- as long as nothing bypasses the gateway in production. + */ +@Component +public class JwtAuthenticationFilter implements GlobalFilter, Ordered { + + private static final List PUBLIC_PATH_PREFIXES = List.of("/api/auth/"); + + private final SecretKey signingKey; + + public JwtAuthenticationFilter(JwtProperties jwtProperties) { + this.signingKey = Keys.hmacShaKeyFor(jwtProperties.secretKey().getBytes(StandardCharsets.UTF_8)); + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + String path = exchange.getRequest().getURI().getPath(); + + if (isPublicPath(path)) { + return chain.filter(exchange); + } + + String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return unauthorized(exchange, "Missing or malformed Authorization header"); + } + + String token = authHeader.substring("Bearer ".length()); + + try { + Claims claims = Jwts.parser() + .verifyWith(signingKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + String userId = claims.getSubject(); + + ServerHttpRequest mutatedRequest = exchange.getRequest().mutate() + .headers(headers -> { + headers.remove("Authorization"); + headers.remove("X-User-Id"); // never trust a client-supplied value + headers.set("X-User-Id", userId); + }) + .build(); + + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + } catch (JwtException | IllegalArgumentException e) { + return unauthorized(exchange, "Invalid or expired token"); + } + } + + private boolean isPublicPath(String path) { + return PUBLIC_PATH_PREFIXES.stream().anyMatch(path::startsWith); + } + + private Mono unauthorized(ServerWebExchange exchange, String message) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().add("Content-Type", "application/json"); + byte[] body = ("{\"message\":\"" + message + "\"}").getBytes(StandardCharsets.UTF_8); + return response.writeWith(Mono.just(response.bufferFactory().wrap(body))); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } +} diff --git a/services/api-gateway/src/main/resources/application.yml b/services/api-gateway/src/main/resources/application.yml index a2189f4..270ef20 100644 --- a/services/api-gateway/src/main/resources/application.yml +++ b/services/api-gateway/src/main/resources/application.yml @@ -70,4 +70,8 @@ management: endpoints: web: exposure: - include: health,gateway \ No newline at end of file + include: health,gateway +application: + security: + jwt: + secret-key: ${JWT_SECRET_KEY:404E635266556A586E3272357538782F413F4428472B4B6250645367566B59} diff --git a/services/lesson-service/scripts/generate_lessons.py b/services/lesson-service/scripts/generate_lessons.py new file mode 100644 index 0000000..1671492 --- /dev/null +++ b/services/lesson-service/scripts/generate_lessons.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Genere des lecons a partir du vocabulaire deja importe dans content-service, +en regroupant les mots par niveau de difficulte deja calcule (classification +C4.5 automatique, voir WordService.resolveDifficultyLevel) plutot que par un +critere arbitraire. + +Pour chaque langue : les mots BEGINNER puis INTERMEDIATE puis ADVANCED sont +decoupes en lecons de taille fixe (par defaut 9 mots), dans cet ordre -- +progression pedagogique naturelle du plus facile au plus difficile. + +Idempotent par langue : si une langue a deja au moins une lecon, elle est +ignoree par defaut (pour ne pas dupliquer du contenu existant comme la lecon +"Salutations" deja creee pour Yemba). Utiliser --force pour regenerer quand +meme (les lecons existantes ne sont pas supprimees, de nouvelles s'ajoutent +a la suite avec un ordre continu). + +Usage: + python3 generate_lessons.py --words-per-lesson 9 + python3 generate_lessons.py --language-id --force +""" +import argparse + +import requests + +CONTENT_BASE_URL = "http://localhost:8080/api/content" +LESSON_BASE_URL = "http://localhost:8080/api" + +DIFFICULTY_ORDER = ["BEGINNER", "INTERMEDIATE", "ADVANCED"] +DIFFICULTY_LABELS = { + "BEGINNER": "Débutant", + "INTERMEDIATE": "Intermédiaire", + "ADVANCED": "Avancé", +} +DIFFICULTY_LEVEL_NUMBER = {"BEGINNER": 1, "INTERMEDIATE": 2, "ADVANCED": 3} + + +def chunk(items: list, size: int) -> list[list]: + return [items[i : i + size] for i in range(0, len(items), size)] + + +def fetch_languages() -> list[dict]: + response = requests.get(f"{CONTENT_BASE_URL}/languages", timeout=10) + response.raise_for_status() + return response.json() + + +def fetch_words(language_id: str) -> list[dict]: + response = requests.get(f"{CONTENT_BASE_URL}/languages/{language_id}/words", timeout=10) + response.raise_for_status() + return response.json() + + +def fetch_existing_lesson_count(language_id: str) -> int: + response = requests.get(f"{LESSON_BASE_URL}/lessons", params={"languageId": language_id}, timeout=10) + response.raise_for_status() + return len(response.json()) + + +def create_lesson(language_id: str, title: str, order: int, level: int) -> str: + payload = {"languageId": language_id, "title": title, "order": order, "level": level} + response = requests.post(f"{LESSON_BASE_URL}/lessons", json=payload, timeout=10) + response.raise_for_status() + return response.json()["id"] + + +def attach_word(lesson_id: str, word_id: str) -> None: + response = requests.post( + f"{LESSON_BASE_URL}/lessons/{lesson_id}/words", json={"wordId": word_id}, timeout=10 + ) + response.raise_for_status() + + +def generate_for_language(language: dict, words_per_lesson: int, force: bool) -> None: + language_id = language["id"] + language_name = language["name"] + + existing_count = fetch_existing_lesson_count(language_id) + if existing_count > 0 and not force: + print(f"[{language_name}] {existing_count} lecon(s) deja presente(s), ignore (utiliser --force pour regenerer)") + return + + words = fetch_words(language_id) + if not words: + print(f"[{language_name}] Aucun mot en base, rien a generer") + return + + words_by_level = {level: [] for level in DIFFICULTY_ORDER} + for word in words: + level = word.get("difficultyLevel") + if level in words_by_level: + words_by_level[level].append(word) + + order_counter = existing_count + 1 + lessons_created = 0 + words_attached = 0 + + for level in DIFFICULTY_ORDER: + level_words = words_by_level[level] + if not level_words: + continue + + chunks = chunk(level_words, words_per_lesson) + for part_number, word_chunk in enumerate(chunks, start=1): + title = f"{language_name} · {DIFFICULTY_LABELS[level]} · Partie {part_number}" + lesson_id = create_lesson( + language_id=language_id, + title=title, + order=order_counter, + level=DIFFICULTY_LEVEL_NUMBER[level], + ) + for word in word_chunk: + attach_word(lesson_id, word["id"]) + words_attached += 1 + + lessons_created += 1 + order_counter += 1 + + print(f"[{language_name}] {lessons_created} lecon(s) creee(s), {words_attached} mot(s) attache(s)") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--words-per-lesson", type=int, default=9) + parser.add_argument("--language-id", help="Ne traiter qu'une seule langue (sinon toutes)") + parser.add_argument("--force", action="store_true", help="Regenerer meme si des lecons existent deja") + args = parser.parse_args() + + languages = fetch_languages() + if args.language_id: + languages = [lang for lang in languages if lang["id"] == args.language_id] + if not languages: + print(f"Langue {args.language_id!r} introuvable") + return + + for language in languages: + generate_for_language(language, args.words_per_lesson, args.force) + + +if __name__ == "__main__": + main() diff --git a/services/quiz-service/pom.xml b/services/quiz-service/pom.xml index 27f40d7..b053ac5 100644 --- a/services/quiz-service/pom.xml +++ b/services/quiz-service/pom.xml @@ -32,6 +32,10 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-amqp + org.springframework.boot spring-boot-starter-validation diff --git a/services/quiz-service/scripts/generate_quizzes.py b/services/quiz-service/scripts/generate_quizzes.py new file mode 100644 index 0000000..9310d88 --- /dev/null +++ b/services/quiz-service/scripts/generate_quizzes.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +Genere des questions QCM (MULTIPLE_CHOICE) pour chaque lecon deja creee, +a partir des mots qui lui sont attaches (content-service + lesson-service). + +Pour chaque mot d'une lecon : une question "Comment dit-on {traduction} en +{langue} ?", avec la bonne reponse (le mot) melangee a 3 distracteurs tires +aleatoirement parmi les AUTRES mots de la MEME lecon (des choix plausibles, +du meme registre/niveau, plutot que des mots au hasard dans toute la langue). + +Une lecon avec moins de 4 mots ne peut pas avoir de distracteurs suffisants : +elle est alors ignoree avec un avertissement plutot que de generer une +question avec moins de 4 options. + +Idempotent par lecon : une lecon ayant deja au moins une question est ignoree +par defaut (meme logique que generate_lessons.py). Utiliser --force pour +regenerer (les questions existantes ne sont pas supprimees). + +Usage: + python3 generate_quizzes.py + python3 generate_quizzes.py --language-id --force +""" +import argparse +import random + +import requests + +CONTENT_BASE_URL = "http://localhost:8080/api/content" +LESSON_BASE_URL = "http://localhost:8080/api" +QUIZ_BASE_URL = "http://localhost:8080/api" + +MIN_WORDS_FOR_QUESTION = 4 # 1 bonne reponse + 3 distracteurs + + +def fetch_languages() -> list[dict]: + response = requests.get(f"{CONTENT_BASE_URL}/languages", timeout=10) + response.raise_for_status() + return response.json() + + +def fetch_lessons(language_id: str) -> list[dict]: + response = requests.get(f"{LESSON_BASE_URL}/lessons", params={"languageId": language_id}, timeout=10) + response.raise_for_status() + return response.json() + + +def fetch_word(word_id: str) -> dict: + response = requests.get(f"{CONTENT_BASE_URL}/words/{word_id}", timeout=10) + response.raise_for_status() + return response.json() + + +def fetch_existing_question_count(lesson_id: str) -> int: + response = requests.get(f"{QUIZ_BASE_URL}/quizzes", params={"lessonId": lesson_id}, timeout=10) + response.raise_for_status() + return len(response.json()) + + +def create_question(lesson_id: str, word_id: str, question_text: str, options: list[str], correct_answer: str) -> None: + payload = { + "lessonId": lesson_id, + "wordId": word_id, + "type": "MULTIPLE_CHOICE", + "questionText": question_text, + "options": options, + "correctAnswer": correct_answer, + } + response = requests.post(f"{QUIZ_BASE_URL}/quizzes", json=payload, timeout=10) + response.raise_for_status() + + +def generate_for_lesson(language_name: str, lesson: dict, force: bool) -> tuple[int, int]: + lesson_id = lesson["id"] + word_ids = lesson.get("wordIds", []) + + if len(word_ids) < MIN_WORDS_FOR_QUESTION: + print(f" [{lesson['title']}] Ignoree : seulement {len(word_ids)} mot(s), minimum {MIN_WORDS_FOR_QUESTION} requis") + return 0, 0 + + existing_count = fetch_existing_question_count(lesson_id) + if existing_count > 0 and not force: + print(f" [{lesson['title']}] {existing_count} question(s) deja presente(s), ignoree") + return 0, 0 + + words = [fetch_word(wid) for wid in word_ids] + questions_created = 0 + + for word in words: + distractor_pool = [w["word"] for w in words if w["id"] != word["id"]] + distractors = random.sample(distractor_pool, min(3, len(distractor_pool))) + options = distractors + [word["word"]] + random.shuffle(options) + + question_text = f"Comment dit-on « {word['translation']} » en {language_name} ?" + create_question(lesson_id, word["id"], question_text, options, word["word"]) + questions_created += 1 + + print(f" [{lesson['title']}] {questions_created} question(s) creee(s)") + return questions_created, 1 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--language-id", help="Ne traiter qu'une seule langue (sinon toutes)") + parser.add_argument("--force", action="store_true", help="Regenerer meme si des questions existent deja") + args = parser.parse_args() + + languages = fetch_languages() + if args.language_id: + languages = [lang for lang in languages if lang["id"] == args.language_id] + if not languages: + print(f"Langue {args.language_id!r} introuvable") + return + + for language in languages: + print(f"[{language['name']}]") + lessons = fetch_lessons(language["id"]) + total_questions, total_lessons = 0, 0 + for lesson in lessons: + q, l = generate_for_lesson(language["name"], lesson, args.force) + total_questions += q + total_lessons += l + print(f" -> {total_lessons} lecon(s) traitee(s), {total_questions} question(s) creee(s) au total\n") + + +if __name__ == "__main__": + main() diff --git a/services/quiz-service/src/main/java/cm/afrilingua/quiz/QuizServiceApplication.java b/services/quiz-service/src/main/java/cm/afrilingua/quiz/QuizServiceApplication.java index 03f93ba..d544971 100644 --- a/services/quiz-service/src/main/java/cm/afrilingua/quiz/QuizServiceApplication.java +++ b/services/quiz-service/src/main/java/cm/afrilingua/quiz/QuizServiceApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import java.util.Locale; @SpringBootApplication +@ConfigurationPropertiesScan public class QuizServiceApplication { public static void main(String[] args) { Locale.setDefault(Locale.ENGLISH); diff --git a/services/quiz-service/src/main/java/cm/afrilingua/quiz/config/EventProperties.java b/services/quiz-service/src/main/java/cm/afrilingua/quiz/config/EventProperties.java new file mode 100644 index 0000000..3ac714d --- /dev/null +++ b/services/quiz-service/src/main/java/cm/afrilingua/quiz/config/EventProperties.java @@ -0,0 +1,6 @@ +package cm.afrilingua.quiz.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "application.events") +public record EventProperties(String exchange) {} diff --git a/services/quiz-service/src/main/java/cm/afrilingua/quiz/config/RabbitMQConfig.java b/services/quiz-service/src/main/java/cm/afrilingua/quiz/config/RabbitMQConfig.java new file mode 100644 index 0000000..9c0f765 --- /dev/null +++ b/services/quiz-service/src/main/java/cm/afrilingua/quiz/config/RabbitMQConfig.java @@ -0,0 +1,24 @@ +package cm.afrilingua.quiz.config; + +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Declares the same topic exchange recommendation-service's consumer binds + * to (afrilingua.events) -- this service is a producer only, it never + * consumes anything itself. */ +@Configuration +public class RabbitMQConfig { + + @Bean + public TopicExchange eventsExchange(EventProperties eventProperties) { + return new TopicExchange(eventProperties.exchange(), true, false); + } + + @Bean + public MessageConverter jsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } +} diff --git a/services/quiz-service/src/main/java/cm/afrilingua/quiz/entity/AnswerAttempt.java b/services/quiz-service/src/main/java/cm/afrilingua/quiz/entity/AnswerAttempt.java new file mode 100644 index 0000000..4d3cd39 --- /dev/null +++ b/services/quiz-service/src/main/java/cm/afrilingua/quiz/entity/AnswerAttempt.java @@ -0,0 +1,44 @@ +package cm.afrilingua.quiz.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "answer_attempts") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AnswerAttempt { + + @Id + @GeneratedValue + private UUID id; + + @Column(name = "question_id", nullable = false) + private UUID questionId; + + /** Extracted from the X-User-Id header injected by api-gateway's + * JwtAuthenticationFilter -- nullable for legacy rows only, always + * populated for attempts submitted through the gateway. */ + @Column(name = "user_id") + private UUID userId; + + @Column(name = "submitted_answer", nullable = false) + private String submittedAnswer; + + @Column(name = "is_correct", nullable = false) + private boolean isCorrect; + + @Column(name = "submitted_at", nullable = false, updatable = false) + @Builder.Default + private Instant submittedAt = Instant.now(); +} diff --git a/services/quiz-service/src/main/java/cm/afrilingua/quiz/entity/LessonCompletion.java b/services/quiz-service/src/main/java/cm/afrilingua/quiz/entity/LessonCompletion.java new file mode 100644 index 0000000..ecc3e20 --- /dev/null +++ b/services/quiz-service/src/main/java/cm/afrilingua/quiz/entity/LessonCompletion.java @@ -0,0 +1,40 @@ +package cm.afrilingua.quiz.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; +import java.util.UUID; + +/** One row per (user, lesson) the first time that lesson is completed -- + * guards against re-publishing lesson.completed/quiz.completed every time a + * question is answered correctly after the lesson was already finished + * (e.g. re-answering an already-correct question, or answering questions + * out of order near the completion point). */ +@Entity +@Table(name = "lesson_completions", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "lesson_id"})) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class LessonCompletion { + + @Id + @GeneratedValue + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "lesson_id", nullable = false) + private UUID lessonId; + + @Column(name = "completed_at", nullable = false, updatable = false) + @Builder.Default + private Instant completedAt = Instant.now(); +} diff --git a/services/quiz-service/src/main/java/cm/afrilingua/quiz/entity/Question.java b/services/quiz-service/src/main/java/cm/afrilingua/quiz/entity/Question.java index 0477ad8..a533b5d 100644 --- a/services/quiz-service/src/main/java/cm/afrilingua/quiz/entity/Question.java +++ b/services/quiz-service/src/main/java/cm/afrilingua/quiz/entity/Question.java @@ -28,6 +28,13 @@ public class Question { @Column(name = "lesson_id", nullable = false) private UUID lessonId; + /** content-service's word id this question tests -- nullable for legacy + * questions created before this field existed, but required for the + * gamification pipeline (recommendation-service needs it to track + * unique learned words per user). */ + @Column(name = "word_id") + private UUID wordId; + @Enumerated(EnumType.STRING) @Column(nullable = false) private QuestionType type; diff --git a/services/quiz-service/src/main/java/cm/afrilingua/quiz/messaging/LessonCompletionEventPublisher.java b/services/quiz-service/src/main/java/cm/afrilingua/quiz/messaging/LessonCompletionEventPublisher.java new file mode 100644 index 0000000..6a490c8 --- /dev/null +++ b/services/quiz-service/src/main/java/cm/afrilingua/quiz/messaging/LessonCompletionEventPublisher.java @@ -0,0 +1,56 @@ +package cm.afrilingua.quiz.messaging; + +import cm.afrilingua.quiz.config.EventProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Publishes quiz.completed to the afrilingua.events topic exchange, consumed + * by recommendation-service's gamification pipeline (XP, streak, badges, + * unique learned words). The payload shape must match what + * gamification_service.handle_quiz_completed expects: user_id and + * correct_word_ids. + * + * A lesson only "completes" (see LessonCompletionService) once every + * question has at least one correct attempt -- mistakes must be corrected + * via retry before completion, matching Duolingo's model. is_perfect + * separately tracks whether that happened with zero wrong attempts at all. + */ +@Component +@RequiredArgsConstructor +public class LessonCompletionEventPublisher { + + private static final String QUIZ_ROUTING_KEY = "quiz.completed"; + private static final String LESSON_ROUTING_KEY = "lesson.completed"; + + private final RabbitTemplate rabbitTemplate; + private final EventProperties eventProperties; + + /** is_perfect must be accurately computed by the caller (true only if + * zero incorrect attempts were recorded for any question in this + * completion pass) -- never hardcode this to true, or the + * "Perfectionniste" badge and its XP bonus become meaningless. */ + public void publishQuizCompleted(UUID userId, List correctWordIds, boolean isPerfect) { + Map payload = Map.of( + "user_id", userId.toString(), + "correct_word_ids", correctWordIds.stream().map(UUID::toString).toList(), + "is_perfect", isPerfect + ); + + rabbitTemplate.convertAndSend(eventProperties.exchange(), QUIZ_ROUTING_KEY, payload); + } + + /** Fired whenever a lesson is finished (every question answered + * correctly at least once), regardless of how many wrong attempts + * preceded it -- completing a lesson with mistakes still counts as + * completing it, matching how Duolingo-style apps work. */ + public void publishLessonCompleted(UUID userId) { + Map payload = Map.of("user_id", userId.toString()); + rabbitTemplate.convertAndSend(eventProperties.exchange(), LESSON_ROUTING_KEY, payload); + } +} diff --git a/services/quiz-service/src/main/java/cm/afrilingua/quiz/repository/AnswerAttemptRepository.java b/services/quiz-service/src/main/java/cm/afrilingua/quiz/repository/AnswerAttemptRepository.java new file mode 100644 index 0000000..752f885 --- /dev/null +++ b/services/quiz-service/src/main/java/cm/afrilingua/quiz/repository/AnswerAttemptRepository.java @@ -0,0 +1,14 @@ +package cm.afrilingua.quiz.repository; + +import cm.afrilingua.quiz.entity.AnswerAttempt; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface AnswerAttemptRepository extends JpaRepository { + + List findByUserIdAndQuestionIdInAndIsCorrectTrue(UUID userId, List questionIds); + + boolean existsByUserIdAndQuestionIdInAndIsCorrectFalse(UUID userId, List questionIds); +} diff --git a/services/quiz-service/src/main/java/cm/afrilingua/quiz/repository/LessonCompletionRepository.java b/services/quiz-service/src/main/java/cm/afrilingua/quiz/repository/LessonCompletionRepository.java new file mode 100644 index 0000000..8d22e8e --- /dev/null +++ b/services/quiz-service/src/main/java/cm/afrilingua/quiz/repository/LessonCompletionRepository.java @@ -0,0 +1,11 @@ +package cm.afrilingua.quiz.repository; + +import cm.afrilingua.quiz.entity.LessonCompletion; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface LessonCompletionRepository extends JpaRepository { + + boolean existsByUserIdAndLessonId(UUID userId, UUID lessonId); +} diff --git a/services/quiz-service/src/main/java/cm/afrilingua/quiz/service/LessonCompletionService.java b/services/quiz-service/src/main/java/cm/afrilingua/quiz/service/LessonCompletionService.java new file mode 100644 index 0000000..0ae5f52 --- /dev/null +++ b/services/quiz-service/src/main/java/cm/afrilingua/quiz/service/LessonCompletionService.java @@ -0,0 +1,89 @@ +package cm.afrilingua.quiz.service; + +import cm.afrilingua.quiz.entity.Question; +import cm.afrilingua.quiz.messaging.LessonCompletionEventPublisher; +import cm.afrilingua.quiz.entity.LessonCompletion; +import cm.afrilingua.quiz.repository.AnswerAttemptRepository; +import cm.afrilingua.quiz.repository.LessonCompletionRepository; +import cm.afrilingua.quiz.repository.QuestionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Detects lesson completion after each correct answer and fires + * quiz.completed accordingly. A lesson is "complete" for a user when every + * question in it has at least one correct attempt from that user -- this + * intentionally does not require completing them in a single sitting or + * session, matching how Duolingo-style apps let learners resume lessons + * across multiple visits. + * + * Questions without a wordId (legacy data, or non-vocabulary question types) + * are excluded from the emitted correct_word_ids but still count toward + * "all questions answered" -- a lesson with a mix of word-based and other + * question types can still complete. + */ +@Service +@RequiredArgsConstructor +public class LessonCompletionService { + + private final QuestionRepository questionRepository; + private final AnswerAttemptRepository answerAttemptRepository; + private final LessonCompletionRepository lessonCompletionRepository; + private final LessonCompletionEventPublisher eventPublisher; + + public void checkAndPublishIfComplete(UUID userId, UUID lessonId) { + if (userId == null) { + return; // no trusted identity (e.g. request bypassed the gateway) -- skip silently + } + + // Idempotency guard: once this (user, lesson) pair is recorded, never + // re-publish -- without this, every correct answer submitted AFTER + // the lesson was already completed (e.g. re-answering a question + // that was already correct) would re-fire both events and + // re-award XP/badges. + if (lessonCompletionRepository.existsByUserIdAndLessonId(userId, lessonId)) { + return; + } + + List lessonQuestions = questionRepository.findByLessonId(lessonId); + if (lessonQuestions.isEmpty()) { + return; + } + + List questionIds = lessonQuestions.stream().map(Question::getId).toList(); + + Set correctlyAnsweredQuestionIds = answerAttemptRepository + .findByUserIdAndQuestionIdInAndIsCorrectTrue(userId, questionIds) + .stream() + .map(cm.afrilingua.quiz.entity.AnswerAttempt::getQuestionId) + .collect(Collectors.toSet()); + + boolean allAnswered = questionIds.stream().allMatch(correctlyAnsweredQuestionIds::contains); + if (!allAnswered) { + return; + } + + List correctWordIds = lessonQuestions.stream() + .map(Question::getWordId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + + // Accurate perfection check: true only if NO wrong attempt exists for + // any question in this lesson, not just "eventually got it right". + boolean isPerfect = !answerAttemptRepository + .existsByUserIdAndQuestionIdInAndIsCorrectFalse(userId, questionIds); + + lessonCompletionRepository.save( + LessonCompletion.builder().userId(userId).lessonId(lessonId).build() + ); + + eventPublisher.publishLessonCompleted(userId); + eventPublisher.publishQuizCompleted(userId, correctWordIds, isPerfect); + } +} diff --git a/services/quiz-service/src/main/java/cm/afrilingua/quiz/service/QuestionService.java b/services/quiz-service/src/main/java/cm/afrilingua/quiz/service/QuestionService.java index c261600..17d7f25 100644 --- a/services/quiz-service/src/main/java/cm/afrilingua/quiz/service/QuestionService.java +++ b/services/quiz-service/src/main/java/cm/afrilingua/quiz/service/QuestionService.java @@ -5,12 +5,16 @@ import cm.afrilingua.quiz.dto.Question; import cm.afrilingua.quiz.dto.QuestionWithAnswer; import cm.afrilingua.quiz.dto.SubmitAnswerRequest; +import cm.afrilingua.quiz.entity.AnswerAttempt; import cm.afrilingua.quiz.exception.QuestionNotFoundException; +import cm.afrilingua.quiz.repository.AnswerAttemptRepository; import cm.afrilingua.quiz.repository.QuestionRepository; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.text.Normalizer; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @@ -20,11 +24,15 @@ public class QuestionService { private final QuestionRepository questionRepository; + private final AnswerAttemptRepository answerAttemptRepository; + private final HttpServletRequest httpServletRequest; + private final LessonCompletionService lessonCompletionService; @Transactional public QuestionWithAnswer create(CreateQuestionRequest request) { cm.afrilingua.quiz.entity.Question question = cm.afrilingua.quiz.entity.Question.builder() .lessonId(request.getLessonId()) + .wordId(request.getWordId()) .type(cm.afrilingua.quiz.entity.Question.QuestionType.valueOf(request.getType().getValue())) .questionText(request.getQuestionText()) .options(request.getOptions() != null ? request.getOptions() : List.of()) @@ -53,13 +61,27 @@ public List listByLesson(UUID lessonId) { .collect(Collectors.toList()); } - @Transactional(readOnly = true) + @Transactional public AnswerResult submitAnswer(UUID questionId, SubmitAnswerRequest request) { cm.afrilingua.quiz.entity.Question question = questionRepository.findById(questionId) .orElseThrow(() -> new QuestionNotFoundException(questionId)); - boolean isCorrect = question.getCorrectAnswer() - .trim().equalsIgnoreCase(request.getSubmittedAnswer().trim()); + boolean isCorrect = normalize(question.getCorrectAnswer()) + .equalsIgnoreCase(normalize(request.getSubmittedAnswer())); + + UUID userId = extractUserId(); + + AnswerAttempt attempt = AnswerAttempt.builder() + .questionId(question.getId()) + .userId(userId) + .submittedAnswer(request.getSubmittedAnswer()) + .isCorrect(isCorrect) + .build(); + answerAttemptRepository.save(attempt); + + if (isCorrect) { + lessonCompletionService.checkAndPublishIfComplete(userId, question.getLessonId()); + } return new AnswerResult() .questionId(question.getId()) @@ -67,10 +89,29 @@ public AnswerResult submitAnswer(UUID questionId, SubmitAnswerRequest request) { .correctAnswer(question.getCorrectAnswer()); } + /** Normalizes to NFC before comparison: the words dataset (Yemba tones in + * particular) can be stored with combining diacritics (NFD) while user + * input -- or even the reference data itself -- may use precomposed + * characters (NFC). Without this, visually identical strings compare + * as different, silently marking correct answers as wrong. */ + private String normalize(String value) { + return Normalizer.normalize(value.trim(), Normalizer.Form.NFC); + } + + /** Reads the trusted X-User-Id header injected by api-gateway's + * JwtAuthenticationFilter. Never trust a client-supplied value here -- + * the gateway strips any client-provided X-User-Id before setting its + * own, so if this header is present, it came from a validated JWT. */ + private UUID extractUserId() { + String userId = httpServletRequest.getHeader("X-User-Id"); + return userId != null ? UUID.fromString(userId) : null; + } + private Question toPublicDto(cm.afrilingua.quiz.entity.Question question) { return new Question() .id(question.getId()) .lessonId(question.getLessonId()) + .wordId(question.getWordId()) .type(Question.TypeEnum.valueOf(question.getType().name())) .questionText(question.getQuestionText()) .options(new java.util.ArrayList<>(question.getOptions())); @@ -85,4 +126,4 @@ private QuestionWithAnswer toDtoWithAnswer(cm.afrilingua.quiz.entity.Question qu .options(new java.util.ArrayList<>(question.getOptions())) .correctAnswer(question.getCorrectAnswer()); } -} \ No newline at end of file +} diff --git a/services/quiz-service/src/main/resources/application.yml b/services/quiz-service/src/main/resources/application.yml index 03c9513..6e198ed 100644 --- a/services/quiz-service/src/main/resources/application.yml +++ b/services/quiz-service/src/main/resources/application.yml @@ -18,6 +18,11 @@ spring: hibernate: format_sql: true + rabbitmq: + host: localhost + port: 5672 + username: afrilingua + password: afrilingua_dev_password flyway: enabled: true locations: classpath:db/migration @@ -35,3 +40,7 @@ management: web: exposure: include: health,info + +application: + events: + exchange: afrilingua.events diff --git a/services/quiz-service/src/main/resources/db/migration/V2__add_word_id_to_questions.sql b/services/quiz-service/src/main/resources/db/migration/V2__add_word_id_to_questions.sql new file mode 100644 index 0000000..f0b3c65 --- /dev/null +++ b/services/quiz-service/src/main/resources/db/migration/V2__add_word_id_to_questions.sql @@ -0,0 +1,3 @@ +ALTER TABLE questions ADD COLUMN word_id UUID; + +CREATE INDEX idx_questions_word ON questions (word_id); diff --git a/services/quiz-service/src/main/resources/db/migration/V3__add_user_id_to_answer_attempts.sql b/services/quiz-service/src/main/resources/db/migration/V3__add_user_id_to_answer_attempts.sql new file mode 100644 index 0000000..c69e3bf --- /dev/null +++ b/services/quiz-service/src/main/resources/db/migration/V3__add_user_id_to_answer_attempts.sql @@ -0,0 +1,4 @@ +ALTER TABLE answer_attempts ADD COLUMN user_id UUID; + +CREATE INDEX idx_answer_attempts_user ON answer_attempts (user_id); +CREATE INDEX idx_answer_attempts_user_question ON answer_attempts (user_id, question_id); diff --git a/services/quiz-service/src/main/resources/db/migration/V4__create_lesson_completions_table.sql b/services/quiz-service/src/main/resources/db/migration/V4__create_lesson_completions_table.sql new file mode 100644 index 0000000..37d15b3 --- /dev/null +++ b/services/quiz-service/src/main/resources/db/migration/V4__create_lesson_completions_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE lesson_completions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + lesson_id UUID NOT NULL, + completed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, lesson_id) +); + +CREATE INDEX idx_lesson_completions_user ON lesson_completions (user_id); diff --git a/services/quiz-service/src/main/resources/openapi/quiz-service.yaml b/services/quiz-service/src/main/resources/openapi/quiz-service.yaml index 4a2fe27..fd6e9bd 100644 --- a/services/quiz-service/src/main/resources/openapi/quiz-service.yaml +++ b/services/quiz-service/src/main/resources/openapi/quiz-service.yaml @@ -117,6 +117,11 @@ components: lessonId: type: string format: uuid + wordId: + type: string + format: uuid + nullable: true + description: content-service's word id this question tests, used by recommendation-service to track unique learned words. type: type: string enum: [MULTIPLE_CHOICE, MATCHING, FILL_IN_THE_BLANK] @@ -157,6 +162,10 @@ components: lessonId: type: string format: uuid + wordId: + type: string + format: uuid + nullable: true type: type: string enum: [MULTIPLE_CHOICE, MATCHING, FILL_IN_THE_BLANK]