Skip to content
Merged
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
18 changes: 18 additions & 0 deletions back/controllers/user.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,22 @@ exports.registerPushToken = async (req, res, next) => {
} catch (error) {
next(error);
}
};

/**
* SYNCHRONISER L'XP LOCALE
* Pousse l'XP totale accumulée localement (front, AsyncStorage-first) vers
* user.xp/level backend — Source de Vérité pour tout ce qui est gated côté
* serveur (coffres niveau 11+, conditions de titres). Voir user.service.js
* → syncXp pour le détail du ratchet anti-régression et du recalcul de niveau.
*/
exports.syncXp = async (req, res, next) => {
try {
const { xp } = req.body;
const result = await userService.syncXp(req.user.id, xp);

res.status(200).json({ success: true, ...result });
} catch (error) {
next(error);
}
};
6 changes: 5 additions & 1 deletion back/routes/user.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const router = express.Router();
const userController = require("../controllers/user.controller");
const auth = require("../middleware/auth.middleware");
const validate = require("../middleware/validate.middleware");
const { updateProfile, updateFrame, updateShowcase, updateRecordsShowcase, registerPushToken } = require("../validators/user.validator");
const { updateProfile, updateFrame, updateShowcase, updateRecordsShowcase, registerPushToken, syncXp } = require("../validators/user.validator");

/**
* ROUTES UTILISATEURS PROTEGEES
Expand All @@ -28,6 +28,10 @@ router.put("/me/records-showcase", auth, validate(updateRecordsShowcase), userCo
// Enregistrer (ou effacer) le token Expo Push de l'appareil courant
router.put("/me/push-token", auth, validate(registerPushToken), userController.registerPushToken);

// Synchronise l'XP totale locale (front) vers le backend — Source de Vérité
// pour le gating serveur (coffres niveau 11+, conditions de titres).
router.post("/me/sync-xp", auth, validate(syncXp), userController.syncXp);

// Suppression définitive du compte (RGPD)
router.delete("/delete-account", auth, userController.deleteAccount);

Expand Down
54 changes: 54 additions & 0 deletions back/services/user.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,60 @@ class UserService {
return updatedUser;
}

/**
* Synchronise l'XP totale calculée localement (front, AsyncStorage-first)
* vers user.xp/level backend — Source de Vérité pour tout ce qui est gated
* côté serveur (coffres niveau 11+, conditions de titres comme PERFORM_LEVEL_50).
*
* Sans ce point de synchro explicite, le niveau backend dérive silencieusement
* de celui vécu par le joueur : `finalizeWorkout` calcule sa PROPRE xp
* (anti-cheat serveur, à partir des exercices soumis) plutôt que d'adopter
* l'xp locale — les deux ledgers divergent avec le temps.
*
* Sécurité : XP en lecture seule ratchet (ne redescend jamais) — un sync
* redondant ou tardif ne peut jamais effacer une progression déjà actée.
* Bornée à xpForLevel(MAX_LEVEL) pour rejeter tout payload absurde (la
* validation Joi en amont ne fait qu'un garde-fou grossier).
*
* @param {string} userId
* @param {number} submittedXp — XP cumulée locale (jamais un delta)
* @returns {{ level, xp, rank, newlyUnlockedTitles }}
*/
async syncXp(userId, submittedXp) {
const { levelFromXP, getRankForLevel, xpForLevel, MAX_LEVEL } = require('../utils/levelHelpers');

const user = await User.findById(userId).select('xp level rank');
if (!user) throw new Error('Utilisateur non trouvé.');

const clampedXp = Math.max(0, Math.min(submittedXp, xpForLevel(MAX_LEVEL)));
const nextXp = Math.max(user.xp || 0, clampedXp);

let newlyUnlockedTitles = [];

if (nextXp !== user.xp) {
user.xp = nextXp;
user.level = levelFromXP(nextXp);
user.rank = getRankForLevel(user.level);
await user.save();

// Require tardif : évite le cycle user.service ↔ title.controller au
// chargement des modules (voir même idiome dans reward.controller.js).
try {
const { checkAndUnlockTitles } = require('../controllers/title.controller');
newlyUnlockedTitles = await checkAndUnlockTitles(userId);
} catch (_) {
// best-effort — la synchro XP elle-même ne doit jamais échouer pour ça.
}
}

return {
level: user.level,
xp: user.xp,
rank: user.rank,
newlyUnlockedTitles,
};
}

async addExperience(userId, xpAmount) {
const user = await User.findById(userId);
user.xp += xpAmount;
Expand Down
105 changes: 105 additions & 0 deletions back/tests/user.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,109 @@ describe('User API (Routes Protégées)', () => {
expect(res.statusCode).toBe(401);
});
});

describe('POST /api/users/me/sync-xp — syncXp (Section X)', () => {
const { xpForLevel } = require('../utils/levelHelpers');

beforeEach(async () => {
await User.updateOne(
{ email: 'user@test.fr' },
{ $set: { xp: 0, level: 0, rank: 'Novice', unlockedTitles: [] } },
);
});

it('✅ Recalcule level/rank côté serveur à partir de xp', async () => {
const res = await request(app)
.post('/api/users/me/sync-xp')
.set('Authorization', `Bearer ${token}`)
.send({ xp: xpForLevel(12) });

expect(res.statusCode).toBe(200);
expect(res.body.level).toBe(12);
expect(res.body.rank).toBe('Initié');
expect(res.body.xp).toBe(xpForLevel(12));

const user = await User.findOne({ email: 'user@test.fr' });
expect(user.level).toBe(12);
expect(user.xp).toBe(xpForLevel(12));
});

it("✅ Ratchet : un xp inférieur à l'existant ne fait jamais redescendre le niveau", async () => {
await User.updateOne({ email: 'user@test.fr' }, { $set: { xp: xpForLevel(20), level: 20, rank: 'Initié' } });

const res = await request(app)
.post('/api/users/me/sync-xp')
.set('Authorization', `Bearer ${token}`)
.send({ xp: xpForLevel(5) });

expect(res.statusCode).toBe(200);
expect(res.body.level).toBe(20);

const user = await User.findOne({ email: 'user@test.fr' });
expect(user.level).toBe(20);
});

it('✅ Idempotent : renvoyer la même valeur ne déclenche pas de re-déblocage', async () => {
await request(app)
.post('/api/users/me/sync-xp')
.set('Authorization', `Bearer ${token}`)
.send({ xp: xpForLevel(10) });

const second = await request(app)
.post('/api/users/me/sync-xp')
.set('Authorization', `Bearer ${token}`)
.send({ xp: xpForLevel(10) });

expect(second.body.newlyUnlockedTitles).not.toContain('PERFORM_LEVEL_10');
});

it('✅ Débloque les titres dont la condition de niveau est franchie', async () => {
const res = await request(app)
.post('/api/users/me/sync-xp')
.set('Authorization', `Bearer ${token}`)
.send({ xp: xpForLevel(10) });

expect(res.body.newlyUnlockedTitles).toContain('PERFORM_LEVEL_10');

const user = await User.findOne({ email: 'user@test.fr' });
expect(user.unlockedTitles).toContain('PERFORM_LEVEL_10');
});

it('✅ Clampe un xp aberrant (au-delà du niveau 200) sans planter', async () => {
// En dessous du plafond Joi (garde-fou grossier, 100M) mais bien
// au-delà de xpForLevel(200) (~1.72M) — exerce le clamp du service.
const res = await request(app)
.post('/api/users/me/sync-xp')
.set('Authorization', `Bearer ${token}`)
.send({ xp: 50000000 });

expect(res.statusCode).toBe(200);
expect(res.body.level).toBe(200);
});

it('❌ 400 si xp négatif, non numérique ou manquant', async () => {
const negative = await request(app)
.post('/api/users/me/sync-xp')
.set('Authorization', `Bearer ${token}`)
.send({ xp: -5 });
expect(negative.statusCode).toBe(400);

const invalid = await request(app)
.post('/api/users/me/sync-xp')
.set('Authorization', `Bearer ${token}`)
.send({ xp: 'abc' });
expect(invalid.statusCode).toBe(400);

const missing = await request(app)
.post('/api/users/me/sync-xp')
.set('Authorization', `Bearer ${token}`)
.send({});
expect(missing.statusCode).toBe(400);
});

it('❌ 401 sans token', async () => {
const res = await request(app).post('/api/users/me/sync-xp').send({ xp: 100 });
expect(res.statusCode).toBe(401);
});
});
});
2 changes: 1 addition & 1 deletion back/utils/levelHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@ function getRankForLevel(level) {
return match ? match.rank : 'Novice';
}

module.exports = { xpForLevel, levelFromXP, getRankForLevel };
module.exports = { xpForLevel, levelFromXP, getRankForLevel, MAX_LEVEL };
7 changes: 7 additions & 0 deletions back/validators/user.validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ const userSchemas = {
// null explicite = désenregistrement (permissions révoquées côté client)
pushToken: Joi.string().max(200).allow(null).required(),
}),

// Synchronisation XP local → backend (voir user.service.js → syncXp).
// Borne haute large mais finie : le clamp fin (xpForLevel(200)) est fait
// côté service, cette borne Joi n'est qu'un garde-fou anti-payload absurde.
syncXp: Joi.object({
xp: Joi.number().integer().min(0).max(100000000).required(),
}),
};

module.exports = userSchemas;
48 changes: 44 additions & 4 deletions front/src/context/WorkoutLogsContext.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import React, { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { AppState } from 'react-native';
import { listLogs, addLog, removeLog, totalCumulativeXP, addRitualLog, addBonusXpLog } from '../services/stats.service';
import { syncXp, retryPendingXpSync } from '../services/xpSync.service';

// Context global pour l'historique des séances finalisées (logs).
// Source de vérité unique pour StatsScreen, ExerciseStatsScreen, ProfileScreen.
Expand All @@ -10,13 +12,17 @@ export function WorkoutLogsProvider({ children }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const appState = useRef(AppState.currentState);

const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const list = await listLogs();
setItems(list);
// Check-up de cohérence au démarrage (Section X) : pousse le total XP
// local vers le backend, fire-and-forget — voir xpSync.service.js.
syncXp(totalCumulativeXP(list));
} catch (e) {
setError(e && e.message ? e.message : 'Erreur de chargement');
} finally {
Expand All @@ -28,10 +34,31 @@ export function WorkoutLogsProvider({ children }) {
refresh();
}, [refresh]);

// Pas de détection réseau active dans ce projet (pas de NetInfo) : le retour
// au premier plan de l'app est le proxy le plus proche de "le réseau est
// peut-être revenu" — ne retente que si un sync précédent a échoué
// (isXpSynced: false), pour ne pas spammer l'API à chaque changement d'onglet.
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState) => {
if (appState.current.match(/inactive|background/) && nextState === 'active') {
retryPendingXpSync(totalCumulativeXP(items));
}
appState.current = nextState;
});
return () => subscription.remove();
}, [items]);

const create = useCallback(async (log) => {
const item = await addLog(log);
// Insertion en tête (logs triés par date desc)
setItems((prev) => [item, ...prev]);
let newTotal;
setItems((prev) => {
const next = [item, ...prev];
newTotal = totalCumulativeXP(next);
return next;
});
// Événement clé (Section X) : fin de séance (solo ou Multi) — sync fire-and-forget.
syncXp(newTotal);
return item;
}, []);

Expand Down Expand Up @@ -63,7 +90,13 @@ export function WorkoutLogsProvider({ children }) {
const addRitual = useCallback(async (ritualId, ritualLabel, durationSeconds, xpEarned) => {
const item = await addRitualLog(ritualId, ritualLabel, durationSeconds, xpEarned);
if (!item) return null;
setItems((prev) => [item, ...prev]);
let newTotal;
setItems((prev) => {
const next = [item, ...prev];
newTotal = totalCumulativeXP(next);
return next;
});
syncXp(newTotal);
return item;
}, []);

Expand All @@ -73,7 +106,14 @@ export function WorkoutLogsProvider({ children }) {
const addBonusXp = useCallback(async (source, xpEarned) => {
const item = await addBonusXpLog(source, xpEarned);
if (!item) return null;
setItems((prev) => [item, ...prev]);
let newTotal;
setItems((prev) => {
const next = [item, ...prev];
newTotal = totalCumulativeXP(next);
return next;
});
// Événement clé (Section X) : bonus XP (ex: bonus de groupe Multi) — sync fire-and-forget.
syncXp(newTotal);
return item;
}, []);

Expand Down
Loading
Loading