From 96423db4a0750b54f755196bbb29385a4cc6c40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Thu, 2 Apr 2026 12:15:07 +1100 Subject: [PATCH 1/3] =?UTF-8?q?BE13=20=E2=80=93=20Wearable=20Device=20Data?= =?UTF-8?q?=20Integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/wearableDataController.js | 38 ++++ database/wearable-device-data.sql | 20 ++ .../wearable-device/authRepository.js | 179 +++++++++++++++ .../wearable-device/errorLogRepository.js | 51 +++++ .../recommendationRepository.js | 33 +++ .../securityEventsRepository.js | 46 ++++ .../wearable-device/wearableDataRepository.js | 34 +++ routes/index.js | 1 + routes/wearables.js | 28 +++ services/authService.js | 187 +++++----------- services/errorLogService.js | 46 +--- services/recommendationService.js | 26 +-- .../securityEvents/securityEventsService.js | 11 +- services/wearableDataService.js | 208 ++++++++++++++++++ test/recommendationService.test.js | 116 +++------- test/wearableDataController.test.js | 93 ++++++++ test/wearableDataService.test.js | 116 ++++++++++ test/wearableDataValidator.test.js | 56 +++++ validators/wearableDataValidator.js | 81 +++++++ 19 files changed, 1093 insertions(+), 277 deletions(-) create mode 100644 controller/wearableDataController.js create mode 100644 database/wearable-device-data.sql create mode 100644 repositories/wearable-device/authRepository.js create mode 100644 repositories/wearable-device/errorLogRepository.js create mode 100644 repositories/wearable-device/recommendationRepository.js create mode 100644 repositories/wearable-device/securityEventsRepository.js create mode 100644 repositories/wearable-device/wearableDataRepository.js create mode 100644 routes/wearables.js create mode 100644 services/wearableDataService.js create mode 100644 test/wearableDataController.test.js create mode 100644 test/wearableDataService.test.js create mode 100644 test/wearableDataValidator.test.js create mode 100644 validators/wearableDataValidator.js diff --git a/controller/wearableDataController.js b/controller/wearableDataController.js new file mode 100644 index 00000000..0ac12fd2 --- /dev/null +++ b/controller/wearableDataController.js @@ -0,0 +1,38 @@ +const wearableDataService = require("../services/wearableDataService"); + +async function ingestWearableData(req, res) { + try { + const result = await wearableDataService.ingestWearableData(req.user.userId, req.body || {}); + return res.status(201).json(result); + } catch (error) { + const status = error.statusCode || 500; + return res.status(status).json({ + success: false, + error: status >= 500 ? "Failed to ingest wearable data" : error.message, + details: error.details || undefined, + }); + } +} + +async function getLatestWearableSummary(req, res) { + try { + const limit = Number.isInteger(Number(req.query.limit)) + ? Math.min(Math.max(parseInt(req.query.limit, 10), 1), 100) + : 50; + + const result = await wearableDataService.getLatestWearableSummary(req.user.userId, limit); + return res.status(200).json(result); + } catch (error) { + const status = error.statusCode || 500; + return res.status(status).json({ + success: false, + error: status >= 500 ? "Failed to load wearable data" : error.message, + details: error.details || undefined, + }); + } +} + +module.exports = { + getLatestWearableSummary, + ingestWearableData, +}; diff --git a/database/wearable-device-data.sql b/database/wearable-device-data.sql new file mode 100644 index 00000000..c26bed96 --- /dev/null +++ b/database/wearable-device-data.sql @@ -0,0 +1,20 @@ +create table if not exists public.wearable_device_data ( + id bigint generated always as identity primary key, + user_id bigint not null references public.users(user_id) on delete cascade, + source text not null, + device_id text null, + device_name text null, + metric_type text not null, + metric_value numeric not null, + metric_unit text not null, + recorded_at timestamptz not null, + received_at timestamptz not null default now(), + timezone text null, + metadata jsonb not null default '{}'::jsonb +); + +create index if not exists idx_wearable_device_data_user_recorded_at + on public.wearable_device_data(user_id, recorded_at desc); + +create index if not exists idx_wearable_device_data_metric_type + on public.wearable_device_data(metric_type); diff --git a/repositories/wearable-device/authRepository.js b/repositories/wearable-device/authRepository.js new file mode 100644 index 00000000..d1f7b5f8 --- /dev/null +++ b/repositories/wearable-device/authRepository.js @@ -0,0 +1,179 @@ +const { supabaseAnon, supabaseService } = require('../../services/supabaseClient'); + +async function findUserIdByEmail(email) { + const { data, error } = await supabaseAnon + .from('users') + .select('user_id') + .eq('email', email) + .single(); + + if (error) { + throw error; + } + + return data || null; +} + +async function createUser(userData) { + const { data, error } = await supabaseAnon + .from('users') + .insert(userData) + .select('user_id, email, name') + .single(); + + if (error) { + throw error; + } + + return data || null; +} + +async function findUserWithRoleByEmail(email) { + const { data, error } = await supabaseAnon + .from('users') + .select(` + user_id, email, password, name, role_id, + account_status, email_verified, + user_roles!inner(id, role_name) + `) + .eq('email', email) + .single(); + + if (error) { + throw error; + } + + return data || null; +} + +async function updateLastLogin(userId, lastLogin) { + const { error } = await supabaseAnon + .from('users') + .update({ last_login: lastLogin }) + .eq('user_id', userId); + + if (error) { + throw error; + } +} + +async function deactivateSessionsByUserId(userId) { + const { error } = await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('user_id', userId); + + if (error) { + throw error; + } +} + +async function createRefreshSession(sessionData) { + const { error } = await supabaseService + .from('user_sessiontoken') + .insert(sessionData); + + if (error) { + throw error; + } +} + +async function findActiveSessionsByLookupHash(lookupHash) { + const { data, error } = await supabaseService + .from('user_sessiontoken') + .select(` + id, + user_id, + refresh_token, + refresh_token_lookup, + expires_at, + is_active + `) + .eq('refresh_token_lookup', lookupHash) + .eq('is_active', true) + .limit(1); + + if (error) { + throw error; + } + + return data || []; +} + +async function findUserById(userId) { + const { data, error } = await supabaseAnon + .from('users') + .select(` + user_id, + email, + name, + role_id, + account_status + `) + .eq('user_id', userId) + .single(); + + if (error) { + throw error; + } + + return data || null; +} + +async function deactivateSessionById(sessionId) { + const { error } = await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('id', sessionId); + + if (error) { + throw error; + } +} + +async function deactivateSessionsByLookupHash(lookupHash) { + const { error } = await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('refresh_token_lookup', lookupHash); + + if (error) { + throw error; + } +} + +async function insertAuthLog(logEntry) { + const { error } = await supabaseAnon + .from('auth_logs') + .insert(logEntry); + + if (error) { + throw error; + } +} + +async function deactivateExpiredSessions(referenceTime) { + const { error } = await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .lt('expires_at', referenceTime); + + if (error) { + throw error; + } +} + +module.exports = { + createRefreshSession, + createUser, + deactivateExpiredSessions, + deactivateSessionById, + deactivateSessionsByLookupHash, + deactivateSessionsByUserId, + findActiveSessionsByLookupHash, + findUserById, + findUserIdByEmail, + findUserWithRoleByEmail, + insertAuthLog, + updateLastLogin +}; diff --git a/repositories/wearable-device/errorLogRepository.js b/repositories/wearable-device/errorLogRepository.js new file mode 100644 index 00000000..8b5a1df8 --- /dev/null +++ b/repositories/wearable-device/errorLogRepository.js @@ -0,0 +1,51 @@ +let supabase = null; + +try { + const { createClient } = require('@supabase/supabase-js'); + if (process.env.SUPABASE_URL && process.env.SUPABASE_ANON_KEY) { + supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY); + } +} catch { + supabase = null; +} + +function isAvailable() { + return !!supabase; +} + +async function insertErrorLog(dbEntry) { + if (!supabase) { + throw new Error('Supabase client not available'); + } + + const { data, error } = await supabase + .from('error_logs') + .insert([dbEntry]) + .select() + .single(); + + if (error) { + throw error; + } + + return data || null; +} + +async function checkConnection() { + if (!supabase) { + return false; + } + + try { + const { error } = await supabase.from('error_logs').select('id').limit(1); + return !error; + } catch { + return false; + } +} + +module.exports = { + checkConnection, + insertErrorLog, + isAvailable +}; diff --git a/repositories/wearable-device/recommendationRepository.js b/repositories/wearable-device/recommendationRepository.js new file mode 100644 index 00000000..a708da87 --- /dev/null +++ b/repositories/wearable-device/recommendationRepository.js @@ -0,0 +1,33 @@ +const supabase = require('../../dbConnection'); + +async function fetchRecentRecipeIds(userId) { + const { data, error } = await supabase + .from('recipe_meal') + .select('recipe_id') + .eq('user_id', userId) + .limit(20); + + if (error) { + throw error; + } + + return (data || []).map((row) => row.recipe_id); +} + +async function fetchCandidateRecipes(limit = 50) { + const { data, error } = await supabase + .from('recipes') + .select('id, recipe_name, cuisine_id, cooking_method_id, total_servings, preparation_time, calories, fat, carbohydrates, protein, fiber, sodium, sugar, allergy, dislike') + .limit(limit); + + if (error) { + throw error; + } + + return data || []; +} + +module.exports = { + fetchCandidateRecipes, + fetchRecentRecipeIds +}; diff --git a/repositories/wearable-device/securityEventsRepository.js b/repositories/wearable-device/securityEventsRepository.js new file mode 100644 index 00000000..fd9f4860 --- /dev/null +++ b/repositories/wearable-device/securityEventsRepository.js @@ -0,0 +1,46 @@ +const supabase = require('../../dbConnection'); + +async function fetchAuthLogs(fromIso, toIso) { + const { data, error } = await supabase + .from('auth_logs') + .select('*') + .gte('created_at', fromIso) + .lte('created_at', toIso); + + return { + data: data || [], + error: error || null + }; +} + +async function fetchBruteForceLogs(fromIso, toIso) { + const { data, error } = await supabase + .from('brute_force_logs') + .select('*') + .gte('created_at', fromIso) + .lte('created_at', toIso); + + return { + data: data || [], + error: error || null + }; +} + +async function fetchUserSessions(fromIso, toIso) { + const { data, error } = await supabase + .from('user_session') + .select('*') + .gte('created_at', fromIso) + .lte('created_at', toIso); + + return { + data: data || [], + error: error || null + }; +} + +module.exports = { + fetchAuthLogs, + fetchBruteForceLogs, + fetchUserSessions +}; diff --git a/repositories/wearable-device/wearableDataRepository.js b/repositories/wearable-device/wearableDataRepository.js new file mode 100644 index 00000000..f0cbaaa0 --- /dev/null +++ b/repositories/wearable-device/wearableDataRepository.js @@ -0,0 +1,34 @@ +const supabase = require("../../dbConnection"); + +async function insertWearableData(records) { + const { data, error } = await supabase + .from("wearable_device_data") + .insert(records) + .select(); + + if (error) { + throw error; + } + + return data || []; +} + +async function getLatestWearableDataByUserId(userId, limit = 50) { + const { data, error } = await supabase + .from("wearable_device_data") + .select("*") + .eq("user_id", userId) + .order("recorded_at", { ascending: false }) + .limit(limit); + + if (error) { + throw error; + } + + return data || []; +} + +module.exports = { + getLatestWearableDataByUserId, + insertWearableData, +}; diff --git a/routes/index.js b/routes/index.js index e2757b76..eabda4e9 100644 --- a/routes/index.js +++ b/routes/index.js @@ -33,6 +33,7 @@ module.exports = app => { app.use('/api/water-intake', require('./waterIntake')); app.use('/api/health-news', require('./healthNews')); app.use('/api/health-tools', require('./healthTools')); + app.use('/api/wearables', require('./wearables')); // Add shopping list routes app.use('/api/shopping-list', require('./shoppingList')); diff --git a/routes/wearables.js b/routes/wearables.js new file mode 100644 index 00000000..1115d693 --- /dev/null +++ b/routes/wearables.js @@ -0,0 +1,28 @@ +const express = require("express"); +const wearableDataController = require("../controller/wearableDataController"); +const { authenticateToken } = require("../middleware/authenticateToken"); +const validateRequest = require("../middleware/validateRequest"); +const { + validateWearablePayload, + validateWearableQuery, +} = require("../validators/wearableDataValidator"); + +const router = express.Router(); + +router.post( + "/", + authenticateToken, + validateWearablePayload, + validateRequest, + wearableDataController.ingestWearableData, +); + +router.get( + "/latest", + authenticateToken, + validateWearableQuery, + validateRequest, + wearableDataController.getLatestWearableSummary, +); + +module.exports = router; diff --git a/services/authService.js b/services/authService.js index 43de0c8c..de9c29d0 100644 --- a/services/authService.js +++ b/services/authService.js @@ -1,20 +1,7 @@ -console.log("🟢 Loaded AuthService from:", __filename); -console.log("URL:", process.env.SUPABASE_URL); -console.log("KEY:", process.env.SUPABASE_ANON_KEY); -const { createClient } = require('@supabase/supabase-js'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); - -const supabaseAnon = createClient( - process.env.SUPABASE_URL, - process.env.SUPABASE_ANON_KEY -); - -const supabaseService = createClient( - process.env.SUPABASE_URL, - process.env.SUPABASE_SERVICE_ROLE_KEY -); +const authRepository = require('../repositories/wearable-device/authRepository'); class AuthService { constructor() { @@ -40,11 +27,12 @@ class AuthService { const { name, email, password, first_name, last_name } = userData; try { - const { data: existingUser } = await supabaseAnon - .from('users') - .select('user_id') - .eq('email', email) - .single(); + const existingUser = await authRepository.findUserIdByEmail(email).catch((error) => { + if (error.code === 'PGRST116') { + return null; + } + throw error; + }); if (existingUser) { throw new Error('User already exists'); @@ -52,24 +40,18 @@ class AuthService { const hashedPassword = await bcrypt.hash(password, 12); - const { data: newUser, error } = await supabaseAnon - .from('users') - .insert({ - name, - email, - password: hashedPassword, - first_name, - last_name, - role_id: 7, - account_status: 'active', - email_verified: false, - mfa_enabled: false, - registration_date: new Date().toISOString() - }) - .select('user_id, email, name') - .single(); - - if (error) throw error; + const newUser = await authRepository.createUser({ + name, + email, + password: hashedPassword, + first_name, + last_name, + role_id: 7, + account_status: 'active', + email_verified: false, + mfa_enabled: false, + registration_date: new Date().toISOString() + }); return { success: true, @@ -88,17 +70,14 @@ class AuthService { const { email, password } = loginData; try { - const { data: user, error } = await supabaseAnon - .from('users') - .select(` - user_id, email, password, name, role_id, - account_status, email_verified, - user_roles!inner(id, role_name) - `) - .eq('email', email) - .single(); - - if (error || !user) throw new Error('Invalid credentials'); + const user = await authRepository.findUserWithRoleByEmail(email).catch((error) => { + if (error.code === 'PGRST116') { + return null; + } + throw error; + }); + + if (!user) throw new Error('Invalid credentials'); if (user.account_status !== 'active') throw new Error('Account is not active'); const validPassword = await bcrypt.compare(password, user.password); @@ -106,10 +85,7 @@ class AuthService { const tokens = await this.generateTokenPair(user, deviceInfo); - await supabaseAnon - .from('users') - .update({ last_login: new Date().toISOString() }) - .eq('user_id', user.user_id); + await authRepository.updateLastLogin(user.user_id, new Date().toISOString()); await this.logAuthAttempt(user.user_id, email, true, deviceInfo); @@ -147,31 +123,24 @@ class AuthService { { expiresIn: this.accessTokenExpiry, algorithm: 'HS256' } ); - await supabaseService - .from('user_sessiontoken') - .update({ is_active: false }) - .eq('user_id', user.user_id); + await authRepository.deactivateSessionsByUserId(user.user_id); const rawRefreshToken = crypto.randomBytes(32).toString('hex'); const hashedRefreshToken = await bcrypt.hash(rawRefreshToken, 12); const lookupHash = this.createLookupHash(rawRefreshToken); const expiresAt = new Date(Date.now() + this.refreshTokenExpiry); - const { error } = await supabaseService - .from('user_sessiontoken') - .insert({ - user_id: user.user_id, - refresh_token: hashedRefreshToken, - refresh_token_lookup: lookupHash, - token_type: 'refresh', - device_info: deviceInfo, - ip_address: deviceInfo.ip || null, - user_agent: deviceInfo.userAgent || null, - expires_at: expiresAt.toISOString(), - is_active: true - }); - - if (error) throw error; + await authRepository.createRefreshSession({ + user_id: user.user_id, + refresh_token: hashedRefreshToken, + refresh_token_lookup: lookupHash, + token_type: 'refresh', + device_info: deviceInfo, + ip_address: deviceInfo.ip || null, + user_agent: deviceInfo.userAgent || null, + expires_at: expiresAt.toISOString(), + is_active: true + }); return { accessToken, @@ -193,23 +162,9 @@ class AuthService { const lookupHash = this.createLookupHash(refreshToken); - const { data: sessions, error } = await supabaseService - .from('user_sessiontoken') - .select(` - id, - user_id, - refresh_token, - refresh_token_lookup, - expires_at, - is_active - `) - .eq('refresh_token_lookup', lookupHash) - .eq('is_active', true) - .limit(1); - - console.log('supabase query result:', { sessions, error}); + const sessions = await authRepository.findActiveSessionsByLookupHash(lookupHash); - if (error || !sessions || sessions.length === 0) { + if (!sessions || sessions.length === 0) { throw new Error('Invalid refresh token'); } @@ -222,19 +177,14 @@ class AuthService { throw new Error('Refresh token expired'); } - const { data: user, error: userError } = await supabaseAnon - .from('users') - .select(` - user_id, - email, - name, - role_id, - account_status - `) - .eq('user_id', session.user_id) - .single(); - - if (userError || !user) { + const user = await authRepository.findUserById(session.user_id).catch((error) => { + if (error.code === 'PGRST116') { + return null; + } + throw error; + }); + + if (!user) { throw new Error('User not found'); } @@ -245,17 +195,13 @@ class AuthService { const newTokens = await this.generateTokenPair(user, deviceInfo); - await supabaseService - .from('user_sessiontoken') - .update({ is_active: false }) - .eq('id', session.id); + await authRepository.deactivateSessionById(session.id); return { success: true, ...newTokens }; } catch (error) { - console.error('REFRESH FAILED:', error.message); throw new Error(`Token refresh failed: ${error.message}`); } } @@ -267,10 +213,7 @@ class AuthService { try { const lookupHash = this.createLookupHash(refreshToken); - await supabaseService - .from('user_sessiontoken') - .update({ is_active: false }) - .eq('refresh_token_lookup', lookupHash); + await authRepository.deactivateSessionsByLookupHash(lookupHash); return { success: true, message: 'Logout successful' }; } catch (error) { @@ -283,10 +226,7 @@ class AuthService { ========================= */ async logoutAll(userId) { try { - await supabaseService - .from('user_sessiontoken') - .update({ is_active: false }) - .eq('user_id', userId); + await authRepository.deactivateSessionsByUserId(userId); return { success: true, message: 'Logged out from all devices' }; } catch (error) { @@ -306,15 +246,13 @@ class AuthService { ========================= */ async logAuthAttempt(userId, email, success, deviceInfo) { try { - await supabaseAnon - .from('auth_logs') - .insert({ - user_id: userId, - email, - success, - ip_address: deviceInfo.ip || null, - created_at: new Date().toISOString() - }); + await authRepository.insertAuthLog({ + user_id: userId, + email, + success, + ip_address: deviceInfo.ip || null, + created_at: new Date().toISOString() + }); } catch { // silent by design } @@ -325,10 +263,7 @@ class AuthService { ========================= */ async cleanupExpiredSessions() { try { - await supabaseService - .from('user_sessiontoken') - .update({ is_active: false }) - .lt('expires_at', new Date().toISOString()); + await authRepository.deactivateExpiredSessions(new Date().toISOString()); } catch { // silent by design } diff --git a/services/errorLogService.js b/services/errorLogService.js index 1162594d..7703ac18 100644 --- a/services/errorLogService.js +++ b/services/errorLogService.js @@ -1,17 +1,6 @@ const fs = require('fs'); const path = require('path'); - -// Dynamically import Supabase (if available) -let supabase = null; -try { - const { createClient } = require('@supabase/supabase-js'); - if (process.env.SUPABASE_URL && process.env.SUPABASE_ANON_KEY) { - supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY); - } -} catch (error) { - // Supabase not available, using file-based logging - console.warn('Supabase not available, using file-based logging'); -} +const errorLogRepository = require('../repositories/wearable-device/errorLogRepository'); class UnifiedErrorLogService { constructor() { @@ -24,7 +13,7 @@ class UnifiedErrorLogService { // Configuration options this.config = { - enableDatabaseLogging: !!supabase, + enableDatabaseLogging: errorLogRepository.isAvailable(), enableFileLogging: true, enableConsoleLogging: true, logLevel: process.env.LOG_LEVEL || 'info' @@ -62,7 +51,7 @@ class UnifiedErrorLogService { // Execute all logging methods in parallel const logPromises = []; - if (this.config.enableDatabaseLogging && supabase) { + if (this.config.enableDatabaseLogging) { logPromises.push(this.logToDatabase(logEntry)); } @@ -150,10 +139,6 @@ class UnifiedErrorLogService { * Database logging (feature of Extended_Middleware_Error_Logging branch) */ async logToDatabase(logEntry) { - if (!supabase) { - throw new Error('Supabase client not available'); - } - const dbEntry = { error_type: logEntry.error_type || logEntry.type, error_message: logEntry.error_message || logEntry.message, @@ -166,17 +151,7 @@ class UnifiedErrorLogService { created_at: logEntry.created_at || logEntry.timestamp }; - const { data, error: insertError } = await supabase - .from('error_logs') - .insert([dbEntry]) - .select() - .single(); - - if (insertError) { - throw insertError; - } - - return data; + return errorLogRepository.insertErrorLog(dbEntry); } /** @@ -382,7 +357,7 @@ class UnifiedErrorLogService { this.config = { ...this.config, ...newConfig }; // If database logging is enabled but Supabase is not available, issue a warning - if (this.config.enableDatabaseLogging && !supabase) { + if (this.config.enableDatabaseLogging && !errorLogRepository.isAvailable()) { console.warn('Database logging enabled but Supabase client not available'); } } @@ -406,13 +381,8 @@ class UnifiedErrorLogService { }; // Check database connection - if (supabase) { - try { - const { error } = await supabase.from('error_logs').select('id').limit(1); - health.database = !error; - } catch (e) { - health.database = false; - } + if (errorLogRepository.isAvailable()) { + health.database = await errorLogRepository.checkConnection(); } // Check file write permissions @@ -439,4 +409,4 @@ module.exports = unifiedErrorLogService; // Additional exports to support different import methods module.exports.logError = unifiedErrorLogService.logError.bind(unifiedErrorLogService); -module.exports.UnifiedErrorLogService = UnifiedErrorLogService; \ No newline at end of file +module.exports.UnifiedErrorLogService = UnifiedErrorLogService; diff --git a/services/recommendationService.js b/services/recommendationService.js index 9f6b98a4..6936e6cd 100644 --- a/services/recommendationService.js +++ b/services/recommendationService.js @@ -1,6 +1,6 @@ -const supabase = require('../dbConnection'); const fetchUserPreferences = require('../model/fetchUserPreferences'); const getUserProfile = require('../model/getUserProfile'); +const recommendationRepository = require('../repositories/wearable-device/recommendationRepository'); const { AI_ADAPTER_VERSION, resolveAiRecommendationSignals @@ -96,30 +96,12 @@ function normalizeHealthGoals(healthGoals) { } async function fetchRecentRecipeIds(userId) { - const { data, error } = await supabase - .from('recipe_meal') - .select('recipe_id') - .eq('user_id', userId) - .limit(20); - - if (error) { - throw error; - } - - return unique((data || []).map((row) => row.recipe_id)); + const recipeIds = await recommendationRepository.fetchRecentRecipeIds(userId); + return unique(recipeIds); } async function fetchCandidateRecipes(limit = 50) { - const { data, error } = await supabase - .from('recipes') - .select('id, recipe_name, cuisine_id, cooking_method_id, total_servings, preparation_time, calories, fat, carbohydrates, protein, fiber, sodium, sugar, allergy, dislike') - .limit(limit); - - if (error) { - throw error; - } - - return data || []; + return recommendationRepository.fetchCandidateRecipes(limit); } function buildExplanation(reasons, fallbackReason) { diff --git a/services/securityEvents/securityEventsService.js b/services/securityEvents/securityEventsService.js index 1196eb28..ee662784 100644 --- a/services/securityEvents/securityEventsService.js +++ b/services/securityEvents/securityEventsService.js @@ -1,8 +1,7 @@ const { SecurityEventType } = require('./securityEventTypes'); const { SecurityEvent } = require('./securityEventModel'); const { aggregateIncidents } = require('./securityIncidentAggregator'); - -const supabase = require('../../dbConnection'); // Supabase client +const securityEventsRepository = require('../../repositories/wearable-device/securityEventsRepository'); function correlateSecurityEvents(events) { const incidents = aggregateIncidents(events) || []; @@ -20,9 +19,9 @@ async function getSecurityEvents(fromDate, toDate) { { data: bruteLogs, error: bruteError }, { data: sessions, error: sessionError }, ] = await Promise.all([ - supabase.from('auth_logs').select('*').gte('created_at', fromIso).lte('created_at', toIso), - supabase.from('brute_force_logs').select('*').gte('created_at', fromIso).lte('created_at', toIso), - supabase.from('user_session').select('*').gte('created_at', fromIso).lte('created_at', toIso), + securityEventsRepository.fetchAuthLogs(fromIso, toIso), + securityEventsRepository.fetchBruteForceLogs(fromIso, toIso), + securityEventsRepository.fetchUserSessions(fromIso, toIso), ]); // ===== 1) Login events from public.auth_logs ===== @@ -188,4 +187,4 @@ async function getSecurityEvents(fromDate, toDate) { module.exports = { getSecurityEvents, -}; \ No newline at end of file +}; diff --git a/services/wearableDataService.js b/services/wearableDataService.js new file mode 100644 index 00000000..72ca30dd --- /dev/null +++ b/services/wearableDataService.js @@ -0,0 +1,208 @@ +const wearableDataRepository = require("../repositories/wearable-device/wearableDataRepository"); + +const SUPPORTED_METRICS = ["steps", "heart_rate", "sleep_duration", "calories_burned"]; +const SUPPORTED_SOURCES = ["apple_health", "google_fit", "fitbit", "garmin", "samsung_health", "manual", "other"]; + +function createClientError(message, details) { + const error = new Error(message); + error.statusCode = 400; + if (details) { + error.details = details; + } + return error; +} + +function roundNumber(value, digits = 2) { + return Number(Number(value).toFixed(digits)); +} + +function normalizeSource(source) { + const normalized = String(source || "").trim().toLowerCase(); + if (!SUPPORTED_SOURCES.includes(normalized)) { + throw createClientError("Unsupported wearable source", { + supportedSources: SUPPORTED_SOURCES, + }); + } + return normalized; +} + +function normalizeMetricValue(metricType, metricPayload) { + const rawValue = Number(metricPayload.value); + const rawUnit = String(metricPayload.unit || "").trim().toLowerCase(); + + if (!Number.isFinite(rawValue)) { + throw createClientError(`Invalid value for ${metricType}`); + } + + switch (metricType) { + case "steps": { + if (!["count", "step", "steps"].includes(rawUnit)) { + throw createClientError("steps unit must be count, step, or steps"); + } + if (rawValue < 0) { + throw createClientError("steps value must be zero or greater"); + } + return { + metricType, + normalizedValue: Math.round(rawValue), + normalizedUnit: "count", + }; + } + case "heart_rate": { + if (rawUnit !== "bpm") { + throw createClientError("heart_rate unit must be bpm"); + } + if (rawValue <= 0 || rawValue > 300) { + throw createClientError("heart_rate value must be between 1 and 300 bpm"); + } + return { + metricType, + normalizedValue: roundNumber(rawValue), + normalizedUnit: "bpm", + }; + } + case "sleep_duration": { + if (rawValue < 0) { + throw createClientError("sleep_duration value must be zero or greater"); + } + + let normalizedValue; + if (["minute", "minutes", "min", "mins"].includes(rawUnit)) { + normalizedValue = rawValue; + } else if (["hour", "hours", "hr", "hrs"].includes(rawUnit)) { + normalizedValue = rawValue * 60; + } else if (["second", "seconds", "sec", "secs"].includes(rawUnit)) { + normalizedValue = rawValue / 60; + } else { + throw createClientError("sleep_duration unit must be minutes, hours, or seconds"); + } + + return { + metricType, + normalizedValue: roundNumber(normalizedValue), + normalizedUnit: "minutes", + }; + } + case "calories_burned": { + if (rawValue < 0) { + throw createClientError("calories_burned value must be zero or greater"); + } + + let normalizedValue; + if (["kcal"].includes(rawUnit)) { + normalizedValue = rawValue; + } else if (["cal", "calorie", "calories"].includes(rawUnit)) { + normalizedValue = rawValue / 1000; + } else { + throw createClientError("calories_burned unit must be kcal or cal"); + } + + return { + metricType, + normalizedValue: roundNumber(normalizedValue), + normalizedUnit: "kcal", + }; + } + default: + throw createClientError(`Unsupported metric type: ${metricType}`); + } +} + +function normalizePayload(userId, payload) { + const source = normalizeSource(payload.source); + const recordedAt = new Date(payload.recorded_at); + if (Number.isNaN(recordedAt.getTime())) { + throw createClientError("recorded_at must be a valid ISO-8601 timestamp"); + } + + const metrics = payload.metrics || {}; + const metricKeys = Object.keys(metrics).filter((key) => SUPPORTED_METRICS.includes(key)); + + if (!metricKeys.length) { + throw createClientError("At least one supported metric is required", { + supportedMetrics: SUPPORTED_METRICS, + }); + } + + const device = payload.device || {}; + const receivedAt = new Date().toISOString(); + + return metricKeys.map((metricType) => { + const metricPayload = metrics[metricType]; + if (!metricPayload || typeof metricPayload !== "object") { + throw createClientError(`Metric ${metricType} must be an object with value and unit`); + } + + const normalized = normalizeMetricValue(metricType, metricPayload); + + return { + user_id: userId, + source, + device_id: device.id || null, + device_name: device.name || null, + metric_type: normalized.metricType, + metric_value: normalized.normalizedValue, + metric_unit: normalized.normalizedUnit, + recorded_at: recordedAt.toISOString(), + received_at: receivedAt, + timezone: payload.timezone || null, + metadata: { + originalValue: Number(metricPayload.value), + originalUnit: String(metricPayload.unit || "").trim(), + }, + }; + }); +} + +function buildLatestMetrics(records) { + const latestMetrics = {}; + + for (const record of records || []) { + if (!latestMetrics[record.metric_type]) { + latestMetrics[record.metric_type] = { + value: record.metric_value, + unit: record.metric_unit, + recordedAt: record.recorded_at, + source: record.source, + device: { + id: record.device_id || null, + name: record.device_name || null, + }, + }; + } + } + + return latestMetrics; +} + +async function ingestWearableData(userId, payload) { + const normalizedRecords = normalizePayload(userId, payload); + const storedRecords = await wearableDataRepository.insertWearableData(normalizedRecords); + + return { + success: true, + source: normalizedRecords[0]?.source || null, + recordedAt: normalizedRecords[0]?.recorded_at || null, + metricsStored: normalizedRecords.length, + records: storedRecords, + }; +} + +async function getLatestWearableSummary(userId, limit = 50) { + const records = await wearableDataRepository.getLatestWearableDataByUserId(userId, limit); + + return { + success: true, + count: records.length, + latestMetrics: buildLatestMetrics(records), + records, + }; +} + +module.exports = { + SUPPORTED_METRICS, + SUPPORTED_SOURCES, + getLatestWearableSummary, + ingestWearableData, + normalizePayload, +}; diff --git a/test/recommendationService.test.js b/test/recommendationService.test.js index 71fc4dd4..ed3e7d33 100644 --- a/test/recommendationService.test.js +++ b/test/recommendationService.test.js @@ -5,42 +5,17 @@ process.env.SUPABASE_URL = process.env.SUPABASE_URL || 'https://example.supabase process.env.SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || 'anon-key'; process.env.SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || 'service-role-key'; -function createSupabaseStub({ recentRecipeIds = [], recipes = [] } = {}) { +function createRecommendationRepositoryStub({ recentRecipeIds = [], recipes = [] } = {}) { return { - from(table) { - return { - select() { - return this; - }, - eq() { - return this; - }, - limit() { - if (table === 'recipe_meal') { - return Promise.resolve({ - data: recentRecipeIds.map((recipeId) => ({ recipe_id: recipeId })), - error: null - }); - } - - if (table === 'recipes') { - return Promise.resolve({ - data: recipes, - error: null - }); - } - - return Promise.resolve({ data: [], error: null }); - } - }; - } + fetchRecentRecipeIds: async () => recentRecipeIds, + fetchCandidateRecipes: async () => recipes }; } describe('Recommendation Service', () => { it('ranks recommendations using preferences and AI insight metadata', async () => { const service = proxyquire('../services/recommendationService', { - '../dbConnection': createSupabaseStub({ + '../repositories/wearable-device/recommendationRepository': createRecommendationRepositoryStub({ recentRecipeIds: [2], recipes: [ { @@ -129,41 +104,25 @@ describe('Recommendation Service', () => { it('returns cached results for repeated requests', async () => { let recipeQueryCount = 0; const service = proxyquire('../services/recommendationService', { - '../dbConnection': { - from(table) { - return { - select() { - return this; - }, - eq() { - return this; - }, - limit() { - if (table === 'recipe_meal') { - return Promise.resolve({ data: [], error: null }); - } - - recipeQueryCount += 1; - return Promise.resolve({ - data: [{ - id: 1, - recipe_name: 'Cached Meal', - cuisine_id: 1, - cooking_method_id: 1, - calories: 450, - protein: 20, - fiber: 5, - sugar: 5, - sodium: 300, - fat: 10, - carbohydrates: 35, - allergy: false, - dislike: false - }], - error: null - }); - } - }; + '../repositories/wearable-device/recommendationRepository': { + fetchRecentRecipeIds: async () => [], + fetchCandidateRecipes: async () => { + recipeQueryCount += 1; + return [{ + id: 1, + recipe_name: 'Cached Meal', + cuisine_id: 1, + cooking_method_id: 1, + calories: 450, + protein: 20, + fiber: 5, + sugar: 5, + sodium: 300, + fat: 10, + carbohydrates: 35, + allergy: false, + dislike: false + }]; } }, '../model/fetchUserPreferences': async () => ({ @@ -201,7 +160,7 @@ describe('Recommendation Service', () => { it('falls back cleanly when the AI adapter reports failure', async () => { const service = proxyquire('../services/recommendationService', { - '../dbConnection': createSupabaseStub({ + '../repositories/wearable-device/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 4, recipe_name: 'Fallback Soup', @@ -259,7 +218,7 @@ describe('Recommendation Service', () => { delete process.env.AI_RECOMMENDATION_URL; const service = proxyquire('../services/recommendationService', { - '../dbConnection': createSupabaseStub({ + '../repositories/wearable-device/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 4, recipe_name: 'Fallback Soup', @@ -296,24 +255,11 @@ describe('Recommendation Service', () => { it('propagates recent recipe fetch failures instead of silently treating them as empty history', async () => { const service = proxyquire('../services/recommendationService', { - '../dbConnection': { - from(table) { - return { - select() { - return this; - }, - eq() { - return this; - }, - limit() { - if (table === 'recipe_meal') { - return Promise.resolve({ data: null, error: new Error('recent recipe query failed') }); - } - - return Promise.resolve({ data: [], error: null }); - } - }; - } + '../repositories/wearable-device/recommendationRepository': { + fetchRecentRecipeIds: async () => { + throw new Error('recent recipe query failed'); + }, + fetchCandidateRecipes: async () => [] }, '../model/fetchUserPreferences': async () => ({}), '../model/getUserProfile': async () => ([{ user_id: 8, email: 'cache@example.com' }]), @@ -343,7 +289,7 @@ describe('Recommendation Service', () => { it('handles multiple medical reports and combines hint derivation signals', async () => { const service = proxyquire('../services/recommendationService', { - '../dbConnection': createSupabaseStub({ + '../repositories/wearable-device/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 1, recipe_name: 'Protein Bowl', diff --git a/test/wearableDataController.test.js b/test/wearableDataController.test.js new file mode 100644 index 00000000..dd372f6f --- /dev/null +++ b/test/wearableDataController.test.js @@ -0,0 +1,93 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const proxyquire = require("proxyquire"); + +describe("wearableDataController", () => { + afterEach(() => { + sinon.restore(); + }); + + it("returns 201 for successful ingestion", async () => { + const ingestWearableData = sinon.stub().resolves({ + success: true, + metricsStored: 2, + }); + + const controller = proxyquire("../controller/wearableDataController", { + "../services/wearableDataService": { + ingestWearableData, + getLatestWearableSummary: sinon.stub(), + }, + }); + + const req = { + user: { userId: 42 }, + body: { source: "fitbit" }, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + + await controller.ingestWearableData(req, res); + + expect(ingestWearableData.calledOnceWith(42, req.body)).to.equal(true); + expect(res.status.calledWith(201)).to.equal(true); + }); + + it("returns 400 for client validation errors from the service", async () => { + const error = new Error("Unsupported wearable source"); + error.statusCode = 400; + + const controller = proxyquire("../controller/wearableDataController", { + "../services/wearableDataService": { + ingestWearableData: sinon.stub().rejects(error), + getLatestWearableSummary: sinon.stub(), + }, + }); + + const req = { + user: { userId: 42 }, + body: {}, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + + await controller.ingestWearableData(req, res); + + expect(res.status.calledWith(400)).to.equal(true); + expect(res.json.firstCall.args[0].error).to.equal("Unsupported wearable source"); + }); + + it("returns latest wearable summary", async () => { + const getLatestWearableSummary = sinon.stub().resolves({ + success: true, + count: 1, + latestMetrics: { steps: { value: 5000, unit: "count" } }, + records: [], + }); + + const controller = proxyquire("../controller/wearableDataController", { + "../services/wearableDataService": { + ingestWearableData: sinon.stub(), + getLatestWearableSummary, + }, + }); + + const req = { + user: { userId: 7 }, + query: { limit: "10" }, + }; + const res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + + await controller.getLatestWearableSummary(req, res); + + expect(getLatestWearableSummary.calledOnceWith(7, 10)).to.equal(true); + expect(res.status.calledWith(200)).to.equal(true); + }); +}); diff --git a/test/wearableDataService.test.js b/test/wearableDataService.test.js new file mode 100644 index 00000000..6ba43ffc --- /dev/null +++ b/test/wearableDataService.test.js @@ -0,0 +1,116 @@ +const { expect } = require("chai"); +const proxyquire = require("proxyquire"); + +describe("wearableDataService", () => { + it("normalizes supported metrics before persisting", async () => { + const insertWearableData = async (records) => records; + + const service = proxyquire("../services/wearableDataService", { + "../repositories/wearable-device/wearableDataRepository": { + insertWearableData, + getLatestWearableDataByUserId: async () => [], + }, + }); + + const result = await service.ingestWearableData(42, { + source: "apple_health", + recorded_at: "2026-04-02T08:30:00.000Z", + timezone: "Australia/Melbourne", + device: { id: "watch-1", name: "Apple Watch" }, + metrics: { + steps: { value: 4567.4, unit: "steps" }, + sleep_duration: { value: 7.5, unit: "hours" }, + calories_burned: { value: 450, unit: "kcal" }, + }, + }); + + expect(result.success).to.equal(true); + expect(result.metricsStored).to.equal(3); + expect(result.records[0].metric_type).to.equal("steps"); + expect(result.records[0].metric_value).to.equal(4567); + expect(result.records[1].metric_type).to.equal("sleep_duration"); + expect(result.records[1].metric_unit).to.equal("minutes"); + expect(result.records[1].metric_value).to.equal(450); + }); + + it("converts calories from cal to kcal", async () => { + const service = proxyquire("../services/wearableDataService", { + "../repositories/wearable-device/wearableDataRepository": { + insertWearableData: async (records) => records, + getLatestWearableDataByUserId: async () => [], + }, + }); + + const result = await service.ingestWearableData(7, { + source: "fitbit", + recorded_at: "2026-04-02T08:30:00.000Z", + metrics: { + calories_burned: { value: 120000, unit: "cal" }, + }, + }); + + expect(result.records[0].metric_value).to.equal(120); + expect(result.records[0].metric_unit).to.equal("kcal"); + }); + + it("rejects unsupported units with a client error", async () => { + const service = proxyquire("../services/wearableDataService", { + "../repositories/wearable-device/wearableDataRepository": { + insertWearableData: async (records) => records, + getLatestWearableDataByUserId: async () => [], + }, + }); + + let caughtError = null; + try { + await service.ingestWearableData(42, { + source: "google_fit", + recorded_at: "2026-04-02T08:30:00.000Z", + metrics: { + heart_rate: { value: 70, unit: "beats" }, + }, + }); + } catch (error) { + caughtError = error; + } + + expect(caughtError).to.be.an("error"); + expect(caughtError.statusCode).to.equal(400); + expect(caughtError.message).to.equal("heart_rate unit must be bpm"); + }); + + it("builds latest metric summary from stored records", async () => { + const service = proxyquire("../services/wearableDataService", { + "../repositories/wearable-device/wearableDataRepository": { + insertWearableData: async (records) => records, + getLatestWearableDataByUserId: async () => ([ + { + metric_type: "heart_rate", + metric_value: 66, + metric_unit: "bpm", + recorded_at: "2026-04-02T08:30:00.000Z", + source: "fitbit", + device_id: "fitbit-1", + device_name: "Fitbit Sense", + }, + { + metric_type: "steps", + metric_value: 10234, + metric_unit: "count", + recorded_at: "2026-04-02T08:00:00.000Z", + source: "fitbit", + device_id: "fitbit-1", + device_name: "Fitbit Sense", + }, + ]), + }, + }); + + const result = await service.getLatestWearableSummary(42, 10); + + expect(result.success).to.equal(true); + expect(result.count).to.equal(2); + expect(result.latestMetrics.heart_rate.value).to.equal(66); + expect(result.latestMetrics.steps.unit).to.equal("count"); + }); +}); diff --git a/test/wearableDataValidator.test.js b/test/wearableDataValidator.test.js new file mode 100644 index 00000000..c826c4f3 --- /dev/null +++ b/test/wearableDataValidator.test.js @@ -0,0 +1,56 @@ +const { expect } = require("chai"); +const { validationResult } = require("express-validator"); +const { + validateWearablePayload, + validateWearableQuery, +} = require("../validators/wearableDataValidator"); + +async function runValidation(validators, req) { + for (const validator of validators) { + await validator.run(req); + } + return validationResult(req).array(); +} + +describe("wearableDataValidator", () => { + it("accepts a valid wearable ingestion payload", async () => { + const req = { + body: { + source: "fitbit", + recorded_at: "2026-04-02T08:30:00.000Z", + metrics: { + steps: { value: 8765, unit: "steps" }, + heart_rate: { value: 72, unit: "bpm" }, + }, + }, + }; + + const errors = await runValidation(validateWearablePayload, req); + expect(errors).to.have.length(0); + }); + + it("rejects malformed wearable payloads", async () => { + const req = { + body: { + source: "fitbit", + recorded_at: "invalid-date", + metrics: {}, + }, + }; + + const errors = await runValidation(validateWearablePayload, req); + expect(errors.length).to.be.greaterThan(0); + }); + + it("rejects invalid latest query limit", async () => { + const req = { + query: { + limit: "500", + }, + }; + + const errors = await runValidation(validateWearableQuery, req); + expect(errors.length).to.equal(1); + expect(errors[0].msg).to.equal("limit must be an integer between 1 and 100"); + }); +}); diff --git a/validators/wearableDataValidator.js b/validators/wearableDataValidator.js new file mode 100644 index 00000000..48cbc5c2 --- /dev/null +++ b/validators/wearableDataValidator.js @@ -0,0 +1,81 @@ +const { body, query } = require("express-validator"); + +const supportedSources = ["apple_health", "google_fit", "fitbit", "garmin", "samsung_health", "manual", "other"]; +const supportedMetrics = ["steps", "heart_rate", "sleep_duration", "calories_burned"]; + +const validateWearablePayload = [ + body("source") + .notEmpty() + .withMessage("source is required") + .isString() + .withMessage("source must be a string") + .customSanitizer((value) => String(value).trim().toLowerCase()) + .isIn(supportedSources) + .withMessage(`source must be one of: ${supportedSources.join(", ")}`), + + body("recorded_at") + .notEmpty() + .withMessage("recorded_at is required") + .isISO8601() + .withMessage("recorded_at must be a valid ISO-8601 timestamp"), + + body("timezone") + .optional() + .isString() + .withMessage("timezone must be a string"), + + body("device") + .optional() + .isObject() + .withMessage("device must be an object"), + + body("device.id") + .optional() + .isString() + .withMessage("device.id must be a string"), + + body("device.name") + .optional() + .isString() + .withMessage("device.name must be a string"), + + body("metrics") + .notEmpty() + .withMessage("metrics is required") + .isObject() + .withMessage("metrics must be an object") + .custom((metrics) => { + const presentMetrics = Object.keys(metrics || {}).filter((key) => supportedMetrics.includes(key)); + if (!presentMetrics.length) { + throw new Error(`At least one supported metric is required: ${supportedMetrics.join(", ")}`); + } + return true; + }), + + ...supportedMetrics.flatMap((metricKey) => ([ + body(`metrics.${metricKey}`) + .optional() + .isObject() + .withMessage(`${metricKey} must be an object`), + body(`metrics.${metricKey}.value`) + .optional() + .isNumeric() + .withMessage(`${metricKey}.value must be numeric`), + body(`metrics.${metricKey}.unit`) + .optional() + .isString() + .withMessage(`${metricKey}.unit must be a string`), + ])), +]; + +const validateWearableQuery = [ + query("limit") + .optional() + .isInt({ min: 1, max: 100 }) + .withMessage("limit must be an integer between 1 and 100"), +]; + +module.exports = { + validateWearablePayload, + validateWearableQuery, +}; From d1b8554aed97ee39046b4f345851e20ef401cd74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Thu, 2 Apr 2026 12:21:43 +1100 Subject: [PATCH 2/3] fix(security-events): merge repository refactor with new log sources --- .../securityEventsRepository.js | 42 +++++ .../securityEvents/securityEventsService.js | 151 ++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/repositories/wearable-device/securityEventsRepository.js b/repositories/wearable-device/securityEventsRepository.js index fd9f4860..e339a5aa 100644 --- a/repositories/wearable-device/securityEventsRepository.js +++ b/repositories/wearable-device/securityEventsRepository.js @@ -39,8 +39,50 @@ async function fetchUserSessions(fromIso, toIso) { }; } +async function fetchAuditLogs(fromIso, toIso) { + const { data, error } = await supabase + .from('audit_logs') + .select('*') + .gte('created_at', fromIso) + .lte('created_at', toIso); + + return { + data: data || [], + error: error || null + }; +} + +async function fetchErrorLogs(fromIso, toIso) { + const { data, error } = await supabase + .from('error_logs') + .select('*') + .gte('created_at', fromIso) + .lte('created_at', toIso); + + return { + data: data || [], + error: error || null + }; +} + +async function fetchRbacViolationLogs(fromIso, toIso) { + const { data, error } = await supabase + .from('rbac_violation_logs') + .select('*') + .gte('created_at', fromIso) + .lte('created_at', toIso); + + return { + data: data || [], + error: error || null + }; +} + module.exports = { + fetchAuditLogs, fetchAuthLogs, fetchBruteForceLogs, + fetchErrorLogs, + fetchRbacViolationLogs, fetchUserSessions }; diff --git a/services/securityEvents/securityEventsService.js b/services/securityEvents/securityEventsService.js index ee662784..0ba15213 100644 --- a/services/securityEvents/securityEventsService.js +++ b/services/securityEvents/securityEventsService.js @@ -8,6 +8,21 @@ function correlateSecurityEvents(events) { return { events, incidents }; } +function inferAuditEventType(row) { + const eventType = String(row.event_type || row.action || '').toLowerCase(); + const outcome = String(row.outcome || row.status || '').toLowerCase(); + + if (eventType.includes('login') && (outcome === 'success' || eventType.includes('success'))) { + return SecurityEventType.LOGIN_SUCCESS; + } + + if (eventType.includes('login') && (outcome === 'failure' || outcome === 'failed' || eventType.includes('fail'))) { + return SecurityEventType.LOGIN_FAILURE; + } + + return SecurityEventType.ANOMALY_DETECTED; +} + async function getSecurityEvents(fromDate, toDate) { const fromIso = fromDate.toISOString(); const toIso = toDate.toISOString(); @@ -18,10 +33,16 @@ async function getSecurityEvents(fromDate, toDate) { { data: authLogs, error: authError }, { data: bruteLogs, error: bruteError }, { data: sessions, error: sessionError }, + { data: auditLogs, error: auditError }, + { data: errorLogs, error: errorLogError }, + { data: rbacViolationLogs, error: rbacError }, ] = await Promise.all([ securityEventsRepository.fetchAuthLogs(fromIso, toIso), securityEventsRepository.fetchBruteForceLogs(fromIso, toIso), securityEventsRepository.fetchUserSessions(fromIso, toIso), + securityEventsRepository.fetchAuditLogs(fromIso, toIso), + securityEventsRepository.fetchErrorLogs(fromIso, toIso), + securityEventsRepository.fetchRbacViolationLogs(fromIso, toIso), ]); // ===== 1) Login events from public.auth_logs ===== @@ -179,6 +200,136 @@ async function getSecurityEvents(fromDate, toDate) { } } + // ===== 4) Audit events from public.audit_logs ===== + if (auditError) { + console.error('Error loading audit_logs:', auditError); + } else if (auditLogs && auditLogs.length > 0) { + for (const row of auditLogs) { + events.push({ + ...SecurityEvent, + id: `audit_${row.id || row.created_at}`, + occurredAt: row.created_at, + type: inferAuditEventType(row), + severity: 'LOW', + + actor: { + userId: row.user_id || null, + email: row.email || null, + role: row.role || null, + }, + + network: { + ip: row.ip_address || row.ip || null, + userAgent: row.user_agent || null, + }, + + session: { + sessionId: row.session_id || null, + refreshTokenHash: null, + }, + + source: { + system: 'supabase', + table: 'public.audit_logs', + recordId: row.id || null, + }, + + metadata: { + eventType: row.event_type || row.action || null, + outcome: row.outcome || row.status || null, + details: row.details || null, + }, + }); + } + } + + // ===== 5) Application/system errors from public.error_logs ===== + if (errorLogError) { + console.error('Error loading error_logs:', errorLogError); + } else if (errorLogs && errorLogs.length > 0) { + for (const row of errorLogs) { + events.push({ + ...SecurityEvent, + id: `error_${row.id || row.created_at}`, + occurredAt: row.created_at, + type: SecurityEventType.ANOMALY_DETECTED, + severity: 'HIGH', + + actor: { + userId: row.user_id || null, + email: row.email || null, + role: null, + }, + + network: { + ip: row.ip_address || row.ip || null, + userAgent: row.user_agent || null, + }, + + session: { + sessionId: row.session_id || null, + refreshTokenHash: null, + }, + + source: { + system: 'supabase', + table: 'public.error_logs', + recordId: row.id || null, + }, + + metadata: { + errorType: row.error_type || null, + errorMessage: row.error_message || row.message || null, + endpoint: row.endpoint || null, + method: row.method || null, + }, + }); + } + } + + // ===== 6) RBAC violations from public.rbac_violation_logs ===== + if (rbacError) { + console.error('Error loading rbac_violation_logs:', rbacError); + } else if (rbacViolationLogs && rbacViolationLogs.length > 0) { + for (const row of rbacViolationLogs) { + events.push({ + ...SecurityEvent, + id: `rbac_${row.id || row.created_at}`, + occurredAt: row.created_at, + type: SecurityEventType.ANOMALY_DETECTED, + severity: 'HIGH', + + actor: { + userId: row.user_id || null, + email: row.email || null, + role: row.role || null, + }, + + network: { + ip: row.ip_address || row.ip || null, + userAgent: row.user_agent || null, + }, + + session: { + sessionId: row.session_id || null, + refreshTokenHash: null, + }, + + source: { + system: 'supabase', + table: 'public.rbac_violation_logs', + recordId: row.id || null, + }, + + metadata: { + endpoint: row.endpoint || null, + method: row.method || null, + status: row.status || null, + }, + }); + } + } + // ===== sort by occurredAt (do this ONCE) ===== events.sort((a, b) => String(a.occurredAt).localeCompare(String(b.occurredAt))); From 239b88754003667b4ae3f05fcc23033ac1c4bb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Thu, 2 Apr 2026 18:18:02 +1100 Subject: [PATCH 3/3] chore(be13): align conflicted services/tests with master --- services/authService.js | 329 ++++++++++++++++++++++------- services/recommendationService.js | 8 +- test/recommendationService.test.js | 28 ++- 3 files changed, 270 insertions(+), 95 deletions(-) diff --git a/services/authService.js b/services/authService.js index de9c29d0..f4023723 100644 --- a/services/authService.js +++ b/services/authService.js @@ -1,7 +1,18 @@ +const { createClient } = require('@supabase/supabase-js'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); -const authRepository = require('../repositories/wearable-device/authRepository'); +const { ServiceError } = require('./serviceError'); + +const supabaseAnon = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY +); + +const supabaseService = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE_KEY +); class AuthService { constructor() { @@ -27,31 +38,40 @@ class AuthService { const { name, email, password, first_name, last_name } = userData; try { - const existingUser = await authRepository.findUserIdByEmail(email).catch((error) => { - if (error.code === 'PGRST116') { - return null; - } - throw error; - }); + if (!name || !email || !password) { + throw new ServiceError(400, 'Name, email, and password are required'); + } + + const { data: existingUser } = await supabaseAnon + .from('users') + .select('user_id') + .eq('email', email) + .single(); if (existingUser) { - throw new Error('User already exists'); + throw new ServiceError(400, 'User already exists'); } const hashedPassword = await bcrypt.hash(password, 12); - const newUser = await authRepository.createUser({ - name, - email, - password: hashedPassword, - first_name, - last_name, - role_id: 7, - account_status: 'active', - email_verified: false, - mfa_enabled: false, - registration_date: new Date().toISOString() - }); + const { data: newUser, error } = await supabaseAnon + .from('users') + .insert({ + name, + email, + password: hashedPassword, + first_name, + last_name, + role_id: 7, + account_status: 'active', + email_verified: false, + mfa_enabled: false, + registration_date: new Date().toISOString() + }) + .select('user_id, email, name') + .single(); + + if (error) throw error; return { success: true, @@ -59,7 +79,11 @@ class AuthService { message: 'User registered successfully' }; } catch (error) { - throw new Error(`Registration failed: ${error.message}`); + if (error instanceof ServiceError) { + throw error; + } + + throw new ServiceError(400, `Registration failed: ${error.message}`); } } @@ -70,22 +94,32 @@ class AuthService { const { email, password } = loginData; try { - const user = await authRepository.findUserWithRoleByEmail(email).catch((error) => { - if (error.code === 'PGRST116') { - return null; - } - throw error; - }); + if (!email || !password) { + throw new ServiceError(400, 'Email and password are required'); + } + + const { data: user, error } = await supabaseAnon + .from('users') + .select(` + user_id, email, password, name, role_id, + account_status, email_verified, + user_roles!inner(id, role_name) + `) + .eq('email', email) + .single(); - if (!user) throw new Error('Invalid credentials'); - if (user.account_status !== 'active') throw new Error('Account is not active'); + if (error || !user) throw new ServiceError(401, 'Invalid credentials'); + if (user.account_status !== 'active') throw new ServiceError(403, 'Account is not active'); const validPassword = await bcrypt.compare(password, user.password); - if (!validPassword) throw new Error('Invalid credentials'); + if (!validPassword) throw new ServiceError(401, 'Invalid credentials'); const tokens = await this.generateTokenPair(user, deviceInfo); - await authRepository.updateLastLogin(user.user_id, new Date().toISOString()); + await supabaseAnon + .from('users') + .update({ last_login: new Date().toISOString() }) + .eq('user_id', user.user_id); await this.logAuthAttempt(user.user_id, email, true, deviceInfo); @@ -101,7 +135,11 @@ class AuthService { }; } catch (error) { await this.logAuthAttempt(null, email, false, deviceInfo); - throw error; + if (error instanceof ServiceError) { + throw error; + } + + throw new ServiceError(401, error.message); } } @@ -123,24 +161,31 @@ class AuthService { { expiresIn: this.accessTokenExpiry, algorithm: 'HS256' } ); - await authRepository.deactivateSessionsByUserId(user.user_id); + await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('user_id', user.user_id); const rawRefreshToken = crypto.randomBytes(32).toString('hex'); const hashedRefreshToken = await bcrypt.hash(rawRefreshToken, 12); const lookupHash = this.createLookupHash(rawRefreshToken); const expiresAt = new Date(Date.now() + this.refreshTokenExpiry); - await authRepository.createRefreshSession({ - user_id: user.user_id, - refresh_token: hashedRefreshToken, - refresh_token_lookup: lookupHash, - token_type: 'refresh', - device_info: deviceInfo, - ip_address: deviceInfo.ip || null, - user_agent: deviceInfo.userAgent || null, - expires_at: expiresAt.toISOString(), - is_active: true - }); + const { error } = await supabaseService + .from('user_sessiontoken') + .insert({ + user_id: user.user_id, + refresh_token: hashedRefreshToken, + refresh_token_lookup: lookupHash, + token_type: 'refresh', + device_info: deviceInfo, + ip_address: deviceInfo.ip || null, + user_agent: deviceInfo.userAgent || null, + expires_at: expiresAt.toISOString(), + is_active: true + }); + + if (error) throw error; return { accessToken, @@ -158,51 +203,77 @@ class AuthService { ========================= */ async refreshAccessToken(refreshToken, deviceInfo = {}) { try { - + if (!refreshToken) { + throw new ServiceError(400, 'Refresh token is required'); + } const lookupHash = this.createLookupHash(refreshToken); - const sessions = await authRepository.findActiveSessionsByLookupHash(lookupHash); - - if (!sessions || sessions.length === 0) { - throw new Error('Invalid refresh token'); + const { data: sessions, error } = await supabaseService + .from('user_sessiontoken') + .select(` + id, + user_id, + refresh_token, + refresh_token_lookup, + expires_at, + is_active + `) + .eq('refresh_token_lookup', lookupHash) + .eq('is_active', true) + .limit(1); + + if (error || !sessions || sessions.length === 0) { + throw new ServiceError(401, 'Invalid refresh token'); } const session = sessions[0]; const match = await bcrypt.compare(refreshToken, session.refresh_token); - if (!match) throw new Error('Invalid refresh token'); + if (!match) throw new ServiceError(401, 'Invalid refresh token'); if (new Date(session.expires_at) < new Date()) { - throw new Error('Refresh token expired'); + throw new ServiceError(401, 'Refresh token expired'); } - const user = await authRepository.findUserById(session.user_id).catch((error) => { - if (error.code === 'PGRST116') { - return null; - } - throw error; - }); - - if (!user) { - throw new Error('User not found'); + const { data: user, error: userError } = await supabaseAnon + .from('users') + .select(` + user_id, + email, + name, + role_id, + account_status + `) + .eq('user_id', session.user_id) + .single(); + + if (userError || !user) { + throw new ServiceError(404, 'User not found'); } if (user.account_status !== 'active') { - throw new Error('Account is not active'); + throw new ServiceError(403, 'Account is not active'); } const newTokens = await this.generateTokenPair(user, deviceInfo); - await authRepository.deactivateSessionById(session.id); + await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('id', session.id); return { success: true, ...newTokens }; } catch (error) { - throw new Error(`Token refresh failed: ${error.message}`); + if (error instanceof ServiceError) { + throw error; + } + + throw new ServiceError(401, `Token refresh failed: ${error.message}`); } } @@ -211,13 +282,24 @@ class AuthService { ========================= */ async logout(refreshToken) { try { + if (!refreshToken) { + throw new ServiceError(400, 'Refresh token is required'); + } + const lookupHash = this.createLookupHash(refreshToken); - await authRepository.deactivateSessionsByLookupHash(lookupHash); + await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('refresh_token_lookup', lookupHash); return { success: true, message: 'Logout successful' }; } catch (error) { - throw new Error(`Logout failed: ${error.message}`); + if (error instanceof ServiceError) { + throw error; + } + + throw new ServiceError(500, `Logout failed: ${error.message}`); } } @@ -226,11 +308,22 @@ class AuthService { ========================= */ async logoutAll(userId) { try { - await authRepository.deactivateSessionsByUserId(userId); + if (!userId) { + throw new ServiceError(400, 'User ID is required'); + } + + await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('user_id', userId); return { success: true, message: 'Logged out from all devices' }; } catch (error) { - throw new Error(`Logout all failed: ${error.message}`); + if (error instanceof ServiceError) { + throw error; + } + + throw new ServiceError(500, `Logout all failed: ${error.message}`); } } @@ -246,13 +339,15 @@ class AuthService { ========================= */ async logAuthAttempt(userId, email, success, deviceInfo) { try { - await authRepository.insertAuthLog({ - user_id: userId, - email, - success, - ip_address: deviceInfo.ip || null, - created_at: new Date().toISOString() - }); + await supabaseAnon + .from('auth_logs') + .insert({ + user_id: userId, + email, + success, + ip_address: deviceInfo.ip || null, + created_at: new Date().toISOString() + }); } catch { // silent by design } @@ -263,11 +358,95 @@ class AuthService { ========================= */ async cleanupExpiredSessions() { try { - await authRepository.deactivateExpiredSessions(new Date().toISOString()); + await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .lt('expires_at', new Date().toISOString()); } catch { // silent by design } } + + async getProfile(userId) { + if (!userId) { + throw new ServiceError(400, 'User ID is required'); + } + + const { data: user, error } = await supabaseAnon + .from('users') + .select(` + user_id, email, name, first_name, last_name, + registration_date, last_login, account_status, + user_roles!inner(role_name) + `) + .eq('user_id', userId) + .single(); + + if (error || !user) { + throw new ServiceError(404, 'User not found'); + } + + return { + success: true, + user: { + id: user.user_id, + email: user.email, + name: user.name, + firstName: user.first_name, + lastName: user.last_name, + role: user.user_roles?.role_name, + registrationDate: user.registration_date, + lastLogin: user.last_login, + accountStatus: user.account_status + } + }; + } + + async logLoginAttempt({ email, userId, success, ipAddress, createdAt }) { + if (!email || success === undefined || !ipAddress || !createdAt) { + throw new ServiceError(400, 'Missing required fields: email, success, ip_address, created_at'); + } + + const { error } = await supabaseAnon.from('auth_logs').insert([ + { + email, + user_id: userId || null, + success, + ip_address: ipAddress, + created_at: createdAt + } + ]); + + if (error) { + throw new ServiceError(500, 'Failed to log login attempt'); + } + + return { message: 'Login attempt logged successfully' }; + } + + async sendSmsCodeByEmail(email) { + if (!email) { + throw new ServiceError(400, 'Email is required'); + } + + const { data, error } = await supabaseAnon + .from('users') + .select('contact_number') + .eq('email', email) + .single(); + + if (error || !data?.contact_number) { + throw new ServiceError(404, 'Phone number not found for the given email'); + } + + const verificationCode = Math.floor(100000 + Math.random() * 900000).toString(); + console.log(`📨 [DEV] Verification code for ${data.contact_number}: ${verificationCode}`); + + return { + message: 'SMS code sent (check server console for code)', + phone: data.contact_number + }; + } } module.exports = new AuthService(); diff --git a/services/recommendationService.js b/services/recommendationService.js index 6936e6cd..6641c669 100644 --- a/services/recommendationService.js +++ b/services/recommendationService.js @@ -1,6 +1,6 @@ const fetchUserPreferences = require('../model/fetchUserPreferences'); const getUserProfile = require('../model/getUserProfile'); -const recommendationRepository = require('../repositories/wearable-device/recommendationRepository'); +const recommendationRepository = require('../repositories/mobile/recommendationRepository'); const { AI_ADAPTER_VERSION, resolveAiRecommendationSignals @@ -96,12 +96,12 @@ function normalizeHealthGoals(healthGoals) { } async function fetchRecentRecipeIds(userId) { - const recipeIds = await recommendationRepository.fetchRecentRecipeIds(userId); - return unique(recipeIds); + const rows = await recommendationRepository.getRecentRecipeIdsByUserId(userId, 20); + return unique((rows || []).map((row) => row.recipe_id)); } async function fetchCandidateRecipes(limit = 50) { - return recommendationRepository.fetchCandidateRecipes(limit); + return recommendationRepository.getCandidateRecipes(limit); } function buildExplanation(reasons, fallbackReason) { diff --git a/test/recommendationService.test.js b/test/recommendationService.test.js index ed3e7d33..a2b6f94a 100644 --- a/test/recommendationService.test.js +++ b/test/recommendationService.test.js @@ -1,21 +1,17 @@ const { expect } = require('chai'); const proxyquire = require('proxyquire'); -process.env.SUPABASE_URL = process.env.SUPABASE_URL || 'https://example.supabase.co'; -process.env.SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || 'anon-key'; -process.env.SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || 'service-role-key'; - function createRecommendationRepositoryStub({ recentRecipeIds = [], recipes = [] } = {}) { return { - fetchRecentRecipeIds: async () => recentRecipeIds, - fetchCandidateRecipes: async () => recipes + getRecentRecipeIdsByUserId: async () => recentRecipeIds.map((recipeId) => ({ recipe_id: recipeId })), + getCandidateRecipes: async () => recipes, }; } describe('Recommendation Service', () => { it('ranks recommendations using preferences and AI insight metadata', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/wearable-device/recommendationRepository': createRecommendationRepositoryStub({ + '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ recentRecipeIds: [2], recipes: [ { @@ -104,9 +100,9 @@ describe('Recommendation Service', () => { it('returns cached results for repeated requests', async () => { let recipeQueryCount = 0; const service = proxyquire('../services/recommendationService', { - '../repositories/wearable-device/recommendationRepository': { - fetchRecentRecipeIds: async () => [], - fetchCandidateRecipes: async () => { + '../repositories/mobile/recommendationRepository': { + getRecentRecipeIdsByUserId: async () => [], + getCandidateRecipes: async () => { recipeQueryCount += 1; return [{ id: 1, @@ -160,7 +156,7 @@ describe('Recommendation Service', () => { it('falls back cleanly when the AI adapter reports failure', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/wearable-device/recommendationRepository': createRecommendationRepositoryStub({ + '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 4, recipe_name: 'Fallback Soup', @@ -218,7 +214,7 @@ describe('Recommendation Service', () => { delete process.env.AI_RECOMMENDATION_URL; const service = proxyquire('../services/recommendationService', { - '../repositories/wearable-device/recommendationRepository': createRecommendationRepositoryStub({ + '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 4, recipe_name: 'Fallback Soup', @@ -255,11 +251,11 @@ describe('Recommendation Service', () => { it('propagates recent recipe fetch failures instead of silently treating them as empty history', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/wearable-device/recommendationRepository': { - fetchRecentRecipeIds: async () => { + '../repositories/mobile/recommendationRepository': { + getRecentRecipeIdsByUserId: async () => { throw new Error('recent recipe query failed'); }, - fetchCandidateRecipes: async () => [] + getCandidateRecipes: async () => [], }, '../model/fetchUserPreferences': async () => ({}), '../model/getUserProfile': async () => ([{ user_id: 8, email: 'cache@example.com' }]), @@ -289,7 +285,7 @@ describe('Recommendation Service', () => { it('handles multiple medical reports and combines hint derivation signals', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/wearable-device/recommendationRepository': createRecommendationRepositoryStub({ + '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 1, recipe_name: 'Protein Bowl',