Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
692 changes: 338 additions & 354 deletions admin-service/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion ai-service/constraints-ai.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Install with: pip install -c constraints-ai.txt -r requirements.txt
# These versions are the verified CPU/macOS x86_64-compatible AI runtime.
numpy==1.26.4
torch==2.12.1
torch==2.2.2
transformers==4.57.6
sentence-transformers==4.1.0
faster-whisper==1.2.1
Expand Down
2 changes: 1 addition & 1 deletion ai-service/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ gTTS>=2.5.4
transformers>=4.41.0,<5.0.0
accelerate>=0.34.2
# NOTE: torch>=2.4.0 has no wheels for macOS x86_64. Max available: 2.2.2.
torch<=2.12.1,>=2.12.1
torch<=2.2.2,>=2.2.2
peft>=0.19.1 # LoRA fine-tuning and adapter loading

# LangGraph + LangChain (TRACECAG Orchestration)
Expand Down
3 changes: 3 additions & 0 deletions backend-service/app/crud/gamification.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError

from app.core.cache import build_cache_key, delete_cached
from app.models.gamification import (
Achievement, UserAchievement, UserWallet, WalletTransaction,
LeaderboardEntry, UserFollowing, ActivityFeed, ShopItem, UserInventory
Expand Down Expand Up @@ -207,6 +208,7 @@ async def add_gems(
await db.refresh(wallet)
else:
await db.flush()
await delete_cached(build_cache_key("wallet", user_id=str(user_id)))

return wallet, transaction

Expand Down Expand Up @@ -256,6 +258,7 @@ async def spend_gems(
await db.refresh(wallet)
else:
await db.flush()
await delete_cached(build_cache_key("wallet", user_id=str(user_id)))

return wallet, transaction

Expand Down
3 changes: 3 additions & 0 deletions backend-service/app/routes/referral.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.cache import build_cache_key, delete_cached
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.models.gamification import UserWallet, WalletTransaction
Expand Down Expand Up @@ -150,6 +151,8 @@ async def claim_referral_code(
# Mark referral as claimed — set after wallet checks so the error path is clear
locked_user.referred_by = referrer.id
await db.commit()
await delete_cached(build_cache_key("wallet", user_id=str(referrer.id)))
await delete_cached(build_cache_key("wallet", user_id=str(locked_user.id)))
logger.info("Referral claimed: %s referred %s", referrer.id, locked_user.id)

return ClaimResponse(
Expand Down
2 changes: 1 addition & 1 deletion backend-service/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ redis>=8.0.0,<9.0.0


# Background jobs — install without redis extras; redis-py is a direct dep and satisfies the transport at runtime
kombu>=5.6.2,<5.7
kombu>=5.5.2,<5.6
celery>=5.5.3,<5.6

# System metrics (used by admin monitoring endpoints)
Expand Down
25 changes: 25 additions & 0 deletions backend-service/tests/services/test_starter_reward_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from sqlalchemy.orm import sessionmaker

from app.core.database import Base
from app.core.cache import build_cache_key
from app.crud.gamification import WalletCRUD
from app.models.gamification import UserWallet, WalletTransaction
from app.models.notification import Notification
from app.models.rbac import Role
Expand Down Expand Up @@ -128,6 +130,29 @@ async def test_mark_seen_is_idempotent(db_session, test_user):
assert len(grants) == 1


async def test_add_gems_invalidates_wallet_cache(
db_session,
test_user,
monkeypatch,
):
deleted = []

async def fake_delete_cached(key):
deleted.append(key)

monkeypatch.setattr("app.crud.gamification.delete_cached", fake_delete_cached)

await WalletCRUD.add_gems(
db_session,
test_user.id,
100,
source="test",
description="test grant",
)

assert deleted == [build_cache_key("wallet", user_id=str(test_user.id))]


async def test_first_social_login_receives_reward_only_once(db_session):
claims = {
"email": "social@example.com",
Expand Down
14 changes: 13 additions & 1 deletion backend-service/tests/test_referral_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import pytest
from httpx import AsyncClient, ASGITransport

from app.core.cache import build_cache_key


BASE = "/api/v1/referral"

Expand Down Expand Up @@ -154,13 +156,19 @@ async def fake_execute(query):

class TestClaimCode:
@pytest.mark.asyncio
async def test_successful_claim_awards_gems(self, authed_client):
async def test_successful_claim_awards_gems(self, authed_client, monkeypatch):
client, session, claimer = authed_client
claimer.referred_by = None

referrer = _make_user(referral_code="REFER123")
referrer_wallet = _make_wallet(referrer.id, gems=50)
claimer_wallet = _make_wallet(claimer.id, gems=0)
deleted = []

async def fake_delete_cached(key):
deleted.append(key)

monkeypatch.setattr("app.routes.referral.delete_cached", fake_delete_cached)

# Sequence: with_for_update user, referrer lookup, referrer wallet, claimer wallet
results = [
Expand Down Expand Up @@ -189,6 +197,10 @@ async def fake_execute(query):
# referred_by should be set
assert claimer.referred_by == referrer.id
session.commit.assert_called_once()
assert deleted == [
build_cache_key("wallet", user_id=str(referrer.id)),
build_cache_key("wallet", user_id=str(claimer.id)),
]

@pytest.mark.asyncio
async def test_self_referral_returns_400(self, authed_client):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,24 +109,33 @@ class LoginScreen extends StatelessWidget {
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset(
'assets/out-app/LexiLingo-logo.png',
height: 30,
fit: BoxFit.contain,
),
Text(
'ADMIN CONSOLE',
style: GoogleFonts.spaceGrotesk(
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.18,
color: AppColors.onSurfaceMuted,
Flexible(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 220,
),
child: Image.asset(
'assets/out-app/lexilingo-logo.png',
height: 30,
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
),
),
),
],
Text(
'ADMIN CONSOLE',
style: GoogleFonts.spaceGrotesk(
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.18,
color: AppColors.onSurfaceMuted,
),
),
],
),
),
],
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,38 +21,22 @@ class AuthRepositoryImpl implements AuthRepository {
String? displayName,
}) async {
try {
// Step 1: Create account on backend
await backendDataSource.register(
final user = await backendDataSource.register(
email: email,
username: username,
password: password,
displayName: displayName,
);

// Step 2: Auto-login to obtain JWT tokens, so the user is fully
// authenticated immediately after registration (no second login needed).
final loginResponse = await backendDataSource.login(
email: email,
password: password,
);

// Step 3: Fetch full profile (same pattern as login)
UserEntity user;
try {
user = await backendDataSource.getCurrentUser();
} on ServerException {
user = _buildFallbackUser(loginResponse);
}

return Right(user);
} on ApiErrorException catch (e) {
return Left(_mapApiErrorToFailure(e));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on UnauthorizedException catch (_) {
return Left(AuthFailure('Auto-login after registration failed.'));
return Left(AuthFailure('Registration failed.'));
} on AuthException catch (_) {
return Left(AuthFailure('Auto-login after registration failed.'));
return Left(AuthFailure('Registration failed.'));
} catch (e) {
return Left(ServerFailure('Registration failed: $e'));
}
Expand Down
12 changes: 8 additions & 4 deletions flutter-app/lib/features/auth/presentation/pages/login_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ class _LoginPageState extends State<LoginPage> {
if (!remember) return;

final savedEmail = prefs.getString(_savedEmailKey) ?? '';
final savedPassword = await _secureStorage.read(key: _savedPasswordKey) ?? '';
final savedPassword =
await _secureStorage.read(key: _savedPasswordKey) ?? '';

if (!mounted) return;
setState(() {
Expand Down Expand Up @@ -135,7 +136,8 @@ class _LoginPageState extends State<LoginPage> {
mediaQuery.viewInsets.bottom;
final fitT = ((usableHeight - tightHeight) / (roomyHeight - tightHeight))
.clamp(0.0, 1.0);
double gap(double full, double compact) => compact + (full - compact) * fitT;
double gap(double full, double compact) =>
compact + (full - compact) * fitT;

return Scaffold(
key: ValueKey<String>('login-page-$localeCode'),
Expand Down Expand Up @@ -170,7 +172,7 @@ class _LoginPageState extends State<LoginPage> {
Expanded(
child: Center(
child: Image.asset(
'assets/out-app/LexiLingo-logo.png',
'assets/out-app/lexilingo-logo.png',
height: 40,
fit: BoxFit.contain,
color: isDark ? Colors.white : null,
Expand Down Expand Up @@ -263,7 +265,9 @@ class _LoginPageState extends State<LoginPage> {
if (value == null || value.isEmpty) {
return 'auth.pleaseEnterEmail'.tr();
}
if (!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(value.trim())) {
if (!RegExp(
r'^[^@\s]+@[^@\s]+\.[^@\s]+$',
).hasMatch(value.trim())) {
return 'auth.invalidEmail'.tr();
}
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class _WelcomePageState extends State<WelcomePage>
Expanded(
child: Center(
child: Image.asset(
'assets/out-app/LexiLingo-logo.png',
'assets/out-app/lexilingo-logo.png',
height: 40,
fit: BoxFit.contain,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,9 @@ class AuthProvider extends ChangeNotifier {
_user = user;
_errorMessage = null;
_isJustLoggedIn = true;
FirebaseMessagingService.instance.registerTokenWithBackend(sl<ApiClient>());
FirebaseMessagingService.instance.registerTokenWithBackend(
sl<ApiClient>(),
);
unawaited(_claimPendingReferral());
},
);
Expand Down Expand Up @@ -247,7 +249,9 @@ class AuthProvider extends ChangeNotifier {
_user = user;
_errorMessage = null;
_isJustLoggedIn = true;
FirebaseMessagingService.instance.registerTokenWithBackend(sl<ApiClient>());
FirebaseMessagingService.instance.registerTokenWithBackend(
sl<ApiClient>(),
);
unawaited(_claimPendingReferral());
},
);
Expand Down Expand Up @@ -286,7 +290,9 @@ class AuthProvider extends ChangeNotifier {
_user = user;
_errorMessage = null;
_isJustLoggedIn = true;
FirebaseMessagingService.instance.registerTokenWithBackend(sl<ApiClient>());
FirebaseMessagingService.instance.registerTokenWithBackend(
sl<ApiClient>(),
);
unawaited(_claimPendingReferral());
},
);
Expand Down Expand Up @@ -452,10 +458,10 @@ class AuthProvider extends ChangeNotifier {
_user = null;
_isJustLoggedIn = false;
},
(user) {
_user = user;
(_) {
_user = null;
_errorMessage = null;
_isJustLoggedIn = true;
_isJustLoggedIn = false;
},
);
} catch (e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ class LexiChatDataSource {
msg.contains('not found');
}

bool _isForbiddenError(Object error) {
final msg = error.toString().toLowerCase();
return msg.contains('forbidden') ||
msg.contains('status 403') ||
msg.contains('403');
}

bool _isSessionAccessError(Object error) {
return _isSessionNotFoundError(error) || _isForbiddenError(error);
}

bool _isUnauthorizedError(Object error) {
final msg = error.toString().toLowerCase();
return msg.contains('unauthorized') ||
Expand Down Expand Up @@ -288,6 +299,9 @@ class LexiChatDataSource {
);
}).toList();
} catch (e) {
if (_isSessionAccessError(e)) {
rethrow;
}
logWarn(_tag, 'getMessages failed, return empty history: $e');
return [];
}
Expand Down Expand Up @@ -346,17 +360,12 @@ class LexiChatDataSource {
returned: (pagination['returned'] as num?)?.toInt() ?? messages.length,
);
} catch (e) {
if (_isSessionNotFoundError(e)) {
if (_isSessionAccessError(e)) {
logWarn(
_tag,
'getMessagesPaged session not found, return empty page: $e',
);
return const LexiMessagesPage(
messages: [],
hasMore: false,
nextCursor: null,
returned: 0,
'getMessagesPaged session unavailable, hand off to provider: $e',
);
rethrow;
}
logWarn(_tag, 'getMessagesPaged fallback to full history: $e');
final all = await getMessages(sessionId: sessionId);
Expand Down Expand Up @@ -405,6 +414,9 @@ class LexiChatDataSource {
oldestTs: metadata['oldest_ts']?.toString(),
);
} catch (e) {
if (_isSessionAccessError(e)) {
rethrow;
}
logWarn(_tag, 'getMessagesMetadata failed, return empty metadata: $e');
return const LexiMessagesMetadata(
totalCount: 0,
Expand Down
Loading
Loading